//! The value sources of a jitexpr expression on a segment. use columnar::{ Cardinality, Column, ColumnType, DynamicColumn, DynamicColumnHandle, MonotonicallyMappableToU64, RowId, StrColumn, }; use jitexpr::compile::CompiledFnCtx; use jitexpr::types::{VarType, VariableValue}; use super::dynamic_term_dictionary::DynamicTermDictionary; use crate::aggregation::value_source::{ValueSource, ValueSourceDictionary}; use crate::query::doc_predicate_query::var_type_for_column_type; use crate::{DocId, TantivyError, COLLECT_BLOCK_BUFFER_LEN}; /// The value source of a jitexpr expression on a segment. /// /// Each input takes the first value of the doc. A doc without a value for an input gets null for /// that input. The expression is evaluated once per doc, and a null result means that the doc has /// no value. A doc therefore has at most one value. /// /// Why the first value only: using all of the values of multivalued inputs would require /// evaluating the cartesian product of the values of the inputs, which explodes with several /// multivalued inputs. /// /// Values use the monotonic `u64` mapping of fast fields. `Str` results are interned in a /// [`DynamicTermDictionary`] owned by the source: their values are term ords in first-seen order. pub(super) struct JitExprValueSource { compiled_fn: CompiledFnCtx, column_type: ColumnType, /// Invariant: `inputs[i]` is bound to `compiled_fn.inputs()[i]`, and has the same type. inputs: Vec, /// Interns the `Str` results. term_dictionary: DynamicTermDictionary, } impl JitExprValueSource { /// Precondition: `column_type` is the column type of the result type of `compiled_fn`. /// /// Panics if `inputs` do not match the inputs of `compiled_fn`. pub(super) fn new( compiled_fn: CompiledFnCtx, column_type: ColumnType, inputs: Vec, ) -> JitExprValueSource { // Evaluating the function with inputs of the wrong type is undefined behavior. assert_eq!(compiled_fn.inputs().len(), inputs.len()); for (compiled_input, input) in compiled_fn.inputs().iter().zip(&inputs) { assert_eq!(compiled_input.r#type, input.var_type); } JitExprValueSource { compiled_fn, column_type, inputs, term_dictionary: DynamicTermDictionary::default(), } } } impl std::fmt::Debug for JitExprValueSource { fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { formatter .debug_struct("JitExprValueSource") .field("column_type", &self.column_type) .field("num_inputs", &self.inputs.len()) .finish() } } impl ValueSource for JitExprValueSource { fn column_type(&self) -> ColumnType { self.column_type } fn load_block( &mut self, docs: &[DocId], values: &mut Vec, docids: &mut Vec, _row_ids: &mut Vec, ) -> Cardinality { values.clear(); docids.clear(); for input in &mut self.inputs { input.load_block(docs); } let JitExprValueSource { compiled_fn, inputs, term_dictionary, .. } = self; let result_type: VarType = compiled_fn.result_type(); let mut args: Vec = Vec::with_capacity(inputs.len()); for (doc_pos, &doc) in docs.iter().enumerate() { args.clear(); for input in inputs.iter() { args.push(input.value(doc_pos)); } // SAFETY: `args` follow the compiled inputs, with their types (see `new`). The strings // are borrowed from `inputs`, which are not modified during the call. let result: VariableValue = unsafe { compiled_fn.call(&args) }; // SAFETY: `result` has the result type of the compiled function. let Some(value) = (unsafe { encode_result(result, result_type, term_dictionary) }) else { continue; }; values.push(value); docids.push(doc); } if values.len() == docs.len() { // Each doc has exactly one value, in the order of `docs`. return Cardinality::Full; } Cardinality::Optional } fn term_dictionary(&self) -> Option<&dyn ValueSourceDictionary> { if self.column_type != ColumnType::Str { return None; } Some(&self.term_dictionary) } } /// A source with the same value for all of the docs. #[derive(Debug)] pub(super) struct ConstantValueSource { column_type: ColumnType, /// In the monotonic `u64` mapping. For `Str`, the term ord of the only term of /// `term_dictionary`. value: u64, term_dictionary: DynamicTermDictionary, } impl ConstantValueSource { /// Evaluates a compiled function without any input. Returns `None` if the result is null. /// /// Precondition: `column_type` is the column type of the result type of `compiled_fn`. pub(super) fn evaluate( mut compiled_fn: CompiledFnCtx, column_type: ColumnType, ) -> Option { assert!(compiled_fn.inputs().is_empty()); let result_type: VarType = compiled_fn.result_type(); let mut term_dictionary = DynamicTermDictionary::default(); // SAFETY: the function has no input. let result: VariableValue = unsafe { compiled_fn.call(&[]) }; // SAFETY: `result` has the result type of the compiled function. let value = unsafe { encode_result(result, result_type, &mut term_dictionary) }?; Some(ConstantValueSource { column_type, value, term_dictionary, }) } } impl ValueSource for ConstantValueSource { fn column_type(&self) -> ColumnType { self.column_type } fn load_block( &mut self, docs: &[DocId], values: &mut Vec, _docids: &mut Vec, _row_ids: &mut Vec, ) -> Cardinality { values.clear(); values.resize(docs.len(), self.value); Cardinality::Full } fn term_dictionary(&self) -> Option<&dyn ValueSourceDictionary> { if self.column_type != ColumnType::Str { return None; } Some(&self.term_dictionary) } } /// Encodes a result in the monotonic `u64` mapping. Returns `None` for a null result. /// /// `Str` results are interned in `term_dictionary`, and encoded as their term ord. /// /// # Safety /// /// `result` must have the type `result_type`. unsafe fn encode_result( result: VariableValue, result_type: VarType, term_dictionary: &mut DynamicTermDictionary, ) -> Option { // SAFETY: guaranteed by the caller. unsafe { match result_type { VarType::Bool => result.as_bool().map(bool::to_u64), VarType::I64 => result.as_i64().map(i64::to_u64), VarType::U64 => result.as_u64(), VarType::F64 => result.as_f64().map(f64::to_u64), VarType::Str => result .as_str() .map(|term: &str| term_dictionary.intern(term.as_bytes())), VarType::None => None, } } } /// A fast-field column bound to an input of the compiled expression. pub(super) struct InputColumn { var_type: VarType, /// Values in their monotonic `u64` mapping. Term ords for a `Str` input. column: Column, /// The dictionary of the term ords. `Some` if and only if `var_type` is `Str`. str_column: Option, /// `first_values[i]` is the first value of `docs[i]`, for the docs of the last block. first_values: Vec>, /// The terms of `first_values`, for a `Str` input. block_terms: BlockTerms, } impl InputColumn { /// Opens the column of `handle`, for an input of type `var_type`. pub(super) fn open( handle: &DynamicColumnHandle, var_type: VarType, ) -> crate::Result { let column_type = handle.column_type(); if var_type_for_column_type(column_type) != Some(var_type) { return Err(TantivyError::InternalError(format!( "an input of type {var_type:?} cannot be bound to a column of type {column_type}" ))); } let (column, str_column) = if var_type == VarType::Str { let DynamicColumn::Str(str_column) = handle.open()? else { return Err(TantivyError::InternalError( "a text column could not be opened as a text column".to_string(), )); }; (str_column.ords().clone(), Some(str_column)) } else { let Some(column) = handle.open_u64_lenient()? else { return Err(TantivyError::InternalError(format!( "a column of type {column_type} could not be opened as u64" ))); }; (column, None) }; Ok(InputColumn { var_type, column, str_column, first_values: Vec::with_capacity(COLLECT_BLOCK_BUFFER_LEN), block_terms: BlockTerms::default(), }) } /// Loads the first value of each doc of `docs`. /// /// Panics if the dictionary of a text column is corrupted. fn load_block(&mut self, docs: &[DocId]) { // `first_vals` does not write the entries of the docs without a value: we reset them. self.first_values.clear(); self.first_values.resize(docs.len(), None); self.column.first_vals(docs, &mut self.first_values); if let Some(str_column) = &self.str_column { self.block_terms.load(str_column, &self.first_values); } } /// Returns the value of `docs[doc_pos]`, for the `docs` of the last `load_block` call. #[inline] fn value(&self, doc_pos: usize) -> VariableValue<'_> { let Some(value) = self.first_values[doc_pos] else { return VariableValue::none(); }; match self.var_type { VarType::Bool => VariableValue::from(bool::from_u64(value)), VarType::I64 => VariableValue::from(i64::from_u64(value)), VarType::U64 => VariableValue::from(value), VarType::F64 => VariableValue::from(f64::from_u64(value)), VarType::Str => VariableValue::from(self.block_terms.term(value)), VarType::None => VariableValue::none(), } } } /// The terms of the term ords of a block, fetched with a single dictionary lookup. #[derive(Default)] struct BlockTerms { /// The distinct term ords of the block, sorted. sorted_ords: Vec, /// The concatenated terms, in the order of `sorted_ords`. buffer: String, /// `buffer[offsets[i]..offsets[i + 1]]` is the term of `sorted_ords[i]`. offsets: Vec, } impl BlockTerms { /// Panics if the dictionary is corrupted: `load_block` cannot return an error. fn load(&mut self, str_column: &StrColumn, term_ords: &[Option]) { let BlockTerms { sorted_ords, buffer, offsets, } = self; sorted_ords.clear(); sorted_ords.extend(term_ords.iter().flatten()); sorted_ords.sort_unstable(); sorted_ords.dedup(); buffer.clear(); offsets.clear(); offsets.push(0); let all_found = str_column .dictionary() .sorted_ords_to_term_cb(sorted_ords, |term| { let term_str = std::str::from_utf8(term).expect("fast-field string dictionary is corrupted"); buffer.push_str(term_str); offsets.push(buffer.len()); }) .expect("fast-field string dictionary is corrupted"); assert!(all_found, "fast-field string dictionary is corrupted"); } /// Precondition: `term_ord` is one of the term ords of the last `load` call. #[inline] fn term(&self, term_ord: u64) -> &str { let idx = self .sorted_ords .binary_search(&term_ord) .expect("the term ord belongs to the block"); &self.buffer[self.offsets[idx]..self.offsets[idx + 1]] } } #[cfg(test)] mod tests { use super::*; use crate::aggregation::{JitExprValueSourceProvider, ValueSourceProvider}; use crate::schema::{Schema, FAST, STRING}; use crate::Index; /// One segment: doc `i` has `number = i` and `label = labels[i]`, except for the docs of /// `null_docs`, which have neither. fn create_index(labels: &[&str], null_docs: &[usize]) -> Index { let mut schema_builder = Schema::builder(); let number = schema_builder.add_u64_field("number", FAST); let label = schema_builder.add_text_field("label", STRING | FAST); let index = Index::create_in_ram(schema_builder.build()); let mut writer = index.writer_for_tests().unwrap(); for (doc_pos, label_value) in labels.iter().enumerate() { if null_docs.contains(&doc_pos) { writer.add_document(doc!()).unwrap(); } else { writer .add_document(doc!(number => doc_pos as u64, label => *label_value)) .unwrap(); } } writer.commit().unwrap(); index } fn open_source(index: &Index, expression: &str) -> Box { let expression = jitexpr::ast::deserialize(expression).unwrap(); let provider = JitExprValueSourceProvider::new(expression).unwrap(); let searcher = index.reader().unwrap().searcher(); provider .for_segment(searcher.segment_reader(0), None) .unwrap() } /// Returns the values and docids of a block. `docids` is only filled for a non-full block. fn load_block( source: &mut dyn ValueSource, docs: &[DocId], ) -> (Cardinality, Vec, Vec) { let mut values = Vec::new(); let mut docids = Vec::new(); let mut row_ids = Vec::new(); let cardinality = source.load_block(docs, &mut values, &mut docids, &mut row_ids); if cardinality.is_full() { docids.clear(); } (cardinality, values, docids) } fn terms(source: &dyn ValueSource, term_ords: &[u64]) -> Vec { let mut terms = Vec::new(); let all_found = source .term_dictionary() .unwrap() .sorted_ords_to_term_cb(term_ords, &mut |term| { terms.push(String::from_utf8(term.to_vec()).unwrap()) }) .unwrap(); assert!(all_found); terms } #[test] fn test_block_cardinality() { let index = create_index(&["a", "b", "c", "d"], &[2]); let mut source = open_source(&index, "(ADD number 10u64)"); assert_eq!(source.column_type(), ColumnType::U64); assert!(source.term_dictionary().is_none()); // All of the docs have a value. let (cardinality, values, _) = load_block(&mut *source, &[0, 1, 3]); assert_eq!(cardinality, Cardinality::Full); assert_eq!(values, vec![10, 11, 13]); // Doc 2 has no value. The buffers of the previous block are cleared. let (cardinality, values, docids) = load_block(&mut *source, &[1, 2, 3]); assert_eq!(cardinality, Cardinality::Optional); assert_eq!(values, vec![11, 13]); assert_eq!(docids, vec![1, 3]); let (cardinality, values, docids) = load_block(&mut *source, &[2]); assert_eq!(cardinality, Cardinality::Optional); assert!(values.is_empty()); assert!(docids.is_empty()); } #[test] fn test_text_term_ords_are_stable_across_blocks() { let index = create_index(&["b", "a", "b", "c", "a"], &[]); let mut source = open_source(&index, "(UPPER label)"); assert_eq!(source.column_type(), ColumnType::Str); assert!(!source.term_dictionary().unwrap().ords_sorted_with_terms()); let (cardinality, first_block_ords, _) = load_block(&mut *source, &[0, 1, 2]); assert_eq!(cardinality, Cardinality::Full); // Ords are assigned in first-seen order. assert_eq!(first_block_ords, vec![0, 1, 0]); let (_, second_block_ords, _) = load_block(&mut *source, &[3, 4]); assert_eq!(second_block_ords, vec![2, 1]); assert_eq!(source.term_dictionary().unwrap().num_terms(), 3); assert_eq!(terms(&*source, &[0, 1, 2]), vec!["B", "A", "C"]); } #[test] fn test_constant_source() { let index = create_index(&["a", "b"], &[]); let mut source = open_source(&index, r#"(UPPER "x")"#); assert_eq!(source.column_type(), ColumnType::Str); let (cardinality, values, _) = load_block(&mut *source, &[0, 1]); assert_eq!(cardinality, Cardinality::Full); assert_eq!(values, vec![0, 0]); assert_eq!(terms(&*source, &[0]), vec!["X"]); // A null constant is a source without any value. let mut source = open_source(&index, "(ADD absent 1u64)"); let (cardinality, values, docids) = load_block(&mut *source, &[0, 1]); assert!(!cardinality.is_full()); assert!(values.is_empty()); assert!(docids.is_empty()); } }