use std::sync::Arc;
use arrow_array::{Array, RecordBatch};
use arrow_schema::{DataType, Field, SchemaRef};
use datafusion::common::ScalarValue;
use datafusion::physical_plan::limit::GlobalLimitExec;
use datafusion::physical_plan::{ExecutionPlan, SendableRecordBatchStream};
use datafusion::prelude::{Expr, SessionContext};
use datafusion_physical_expr::PhysicalExprRef;
use futures::TryStreamExt;
use lance_core::{Error, ROW_ID, Result};
use lance_datafusion::expr::safe_coerce_scalar;
use lance_datafusion::planner::Planner;
use lance_index::scalar::FullTextSearchQuery;
use lance_index::scalar::inverted::query::{FtsQuery as IndexFtsQuery, Operator};
use lance_linalg::distance::DistanceType;
use super::exec::{
BTreeIndexExec, FtsIndexExec, MemTableBruteForceVectorExec, MemTableDedupScanExec,
MemTableScanExec, SCORE_COLUMN, VectorIndexExec,
};
use crate::dataset::mem_wal::scanner::{exec::validate_pk_types, parse_filter_expr};
use crate::dataset::mem_wal::write::{BatchStore, IndexStore};
#[derive(Debug, Clone)]
pub struct VectorQuery {
pub column: String,
pub query_vector: Arc<dyn Array>,
pub k: usize,
pub nprobes: usize,
pub maximum_nprobes: Option<usize>,
pub distance_type: Option<DistanceType>,
pub ef: Option<usize>,
pub refine_factor: Option<u32>,
pub distance_lower_bound: Option<f32>,
pub distance_upper_bound: Option<f32>,
}
#[derive(Debug, Clone)]
pub enum FtsQueryType {
Match {
query: String,
operator: Operator,
boost: f32,
},
Phrase {
query: String,
slop: u32,
},
Boolean {
must: Vec<String>,
should: Vec<String>,
must_not: Vec<String>,
},
Fuzzy {
query: String,
fuzziness: Option<u32>,
prefix_length: u32,
max_expansions: usize,
boost: f32,
},
}
#[derive(Debug, Clone)]
pub struct FtsQuery {
pub column: String,
pub query_type: FtsQueryType,
pub wand_factor: f32,
pub limit: Option<usize>,
pub include_tail: bool,
}
pub const DEFAULT_MAX_EXPANSIONS: usize = 50;
pub const DEFAULT_WAND_FACTOR: f32 = 1.0;
impl FtsQuery {
pub fn match_query(column: impl Into<String>, query: impl Into<String>) -> Self {
Self::match_query_with_operator(column, query, Operator::Or)
}
pub fn match_query_with_operator(
column: impl Into<String>,
query: impl Into<String>,
operator: Operator,
) -> Self {
Self {
column: column.into(),
query_type: FtsQueryType::Match {
query: query.into(),
operator,
boost: 1.0,
},
wand_factor: DEFAULT_WAND_FACTOR,
limit: None,
include_tail: true,
}
}
pub fn phrase(column: impl Into<String>, query: impl Into<String>, slop: u32) -> Self {
Self {
column: column.into(),
query_type: FtsQueryType::Phrase {
query: query.into(),
slop,
},
wand_factor: DEFAULT_WAND_FACTOR,
limit: None,
include_tail: true,
}
}
pub fn boolean(
column: impl Into<String>,
must: Vec<String>,
should: Vec<String>,
must_not: Vec<String>,
) -> Self {
Self {
column: column.into(),
query_type: FtsQueryType::Boolean {
must,
should,
must_not,
},
wand_factor: DEFAULT_WAND_FACTOR,
limit: None,
include_tail: true,
}
}
pub fn fuzzy(column: impl Into<String>, query: impl Into<String>) -> Self {
Self {
column: column.into(),
query_type: FtsQueryType::Fuzzy {
query: query.into(),
fuzziness: None,
prefix_length: 0,
max_expansions: DEFAULT_MAX_EXPANSIONS,
boost: 1.0,
},
wand_factor: DEFAULT_WAND_FACTOR,
limit: None,
include_tail: true,
}
}
pub fn fuzzy_with_distance(
column: impl Into<String>,
query: impl Into<String>,
fuzziness: u32,
) -> Self {
Self {
column: column.into(),
query_type: FtsQueryType::Fuzzy {
query: query.into(),
fuzziness: Some(fuzziness),
prefix_length: 0,
max_expansions: DEFAULT_MAX_EXPANSIONS,
boost: 1.0,
},
wand_factor: DEFAULT_WAND_FACTOR,
limit: None,
include_tail: true,
}
}
pub fn fuzzy_with_options(
column: impl Into<String>,
query: impl Into<String>,
fuzziness: Option<u32>,
prefix_length: u32,
max_expansions: usize,
) -> Self {
Self {
column: column.into(),
query_type: FtsQueryType::Fuzzy {
query: query.into(),
fuzziness,
prefix_length,
max_expansions,
boost: 1.0,
},
wand_factor: DEFAULT_WAND_FACTOR,
limit: None,
include_tail: true,
}
}
pub fn with_wand_factor(mut self, wand_factor: f32) -> Self {
self.wand_factor = wand_factor.clamp(0.0, 1.0);
self
}
pub fn with_limit(mut self, limit: Option<usize>) -> Self {
self.limit = limit;
self
}
pub fn with_include_tail(mut self, include_tail: bool) -> Self {
self.include_tail = include_tail;
self
}
fn with_boost(mut self, boost: f32) -> Self {
match &mut self.query_type {
FtsQueryType::Match { boost: b, .. } | FtsQueryType::Fuzzy { boost: b, .. } => {
*b = boost;
}
FtsQueryType::Phrase { .. } | FtsQueryType::Boolean { .. } => {}
}
self
}
}
fn local_fts_query(query: FullTextSearchQuery) -> Result<FtsQuery> {
let wand_factor = query.wand_factor.unwrap_or(DEFAULT_WAND_FACTOR);
let limit = query
.limit
.map(|limit| {
if limit < 0 {
Err(Error::invalid_input(
"full-text search limit must be non-negative".to_string(),
))
} else {
Ok(limit as usize)
}
})
.transpose()?;
let require_column = |column: Option<String>| {
column.ok_or_else(|| {
Error::invalid_input(
"full-text search requires a column; set it with \
`FullTextSearchQuery::with_column`"
.to_string(),
)
})
};
let local = match query.query {
IndexFtsQuery::Match(m) => {
let column = require_column(m.column)?;
match m.fuzziness {
Some(0) => FtsQuery::match_query_with_operator(column, m.terms, m.operator)
.with_boost(m.boost),
_ if m.operator != Operator::Or => {
return Err(Error::not_supported(
"MemTable fuzzy full-text search only supports OR match operators"
.to_string(),
));
}
fuzziness => FtsQuery::fuzzy_with_options(
column,
m.terms,
fuzziness,
m.prefix_length,
m.max_expansions,
)
.with_boost(m.boost),
}
}
IndexFtsQuery::Phrase(p) => FtsQuery::phrase(require_column(p.column)?, p.terms, p.slop),
other => {
return Err(Error::not_supported(format!(
"MemTable full-text search supports match and phrase queries, got: {other}"
)));
}
};
Ok(local.with_wand_factor(wand_factor).with_limit(limit))
}
#[derive(Debug, Clone)]
pub enum ScalarPredicate {
Eq { column: String, value: ScalarValue },
Range {
column: String,
lower: Option<ScalarValue>,
upper: Option<ScalarValue>,
},
In {
column: String,
values: Vec<ScalarValue>,
},
}
impl ScalarPredicate {
pub fn column(&self) -> &str {
match self {
Self::Eq { column, .. } => column,
Self::Range { column, .. } => column,
Self::In { column, .. } => column,
}
}
}
pub struct MemTableScanner {
batch_store: Arc<BatchStore>,
indexes: Arc<IndexStore>,
schema: SchemaRef,
max_visible_batch_position: usize,
projection: Option<Vec<String>>,
filter: Option<Expr>,
limit: Option<usize>,
offset: Option<usize>,
nearest: Option<VectorQuery>,
full_text_query: Option<FtsQuery>,
use_index: bool,
batch_size: Option<usize>,
with_row_id: bool,
with_row_address: bool,
pk_columns: Option<Vec<String>>,
}
impl MemTableScanner {
pub fn new(batch_store: Arc<BatchStore>, indexes: Arc<IndexStore>, schema: SchemaRef) -> Self {
let max_visible_batch_position = indexes.max_visible_batch_position();
Self {
batch_store,
indexes,
schema,
max_visible_batch_position,
projection: None,
filter: None,
limit: None,
offset: None,
nearest: None,
full_text_query: None,
use_index: true,
batch_size: None,
with_row_id: false,
with_row_address: false,
pk_columns: None,
}
}
pub fn with_pk_columns(&mut self, pk_columns: Vec<String>) -> &mut Self {
self.pk_columns = if pk_columns.is_empty() {
None
} else {
Some(pk_columns)
};
self
}
pub fn project<T: AsRef<str>>(&mut self, columns: &[T]) -> Result<&mut Self> {
let mut filtered_columns = Vec::new();
for col in columns {
let col = col.as_ref();
if col == ROW_ID {
self.with_row_id = true;
} else {
filtered_columns.push(col.to_string());
}
}
if !filtered_columns.is_empty() || self.with_row_id {
self.projection = Some(filtered_columns);
}
Ok(self)
}
pub fn with_row_id(&mut self) -> &mut Self {
self.with_row_id = true;
self
}
pub fn max_visible_batch_position(&self) -> usize {
self.max_visible_batch_position
}
pub fn with_row_address(&mut self) -> &mut Self {
self.with_row_address = true;
self
}
pub fn filter(&mut self, filter_expr: &str) -> Result<&mut Self> {
let expr = parse_filter_expr(self.schema.as_ref(), filter_expr)?;
self.filter = Some(expr);
Ok(self)
}
pub fn filter_expr(&mut self, expr: Expr) -> &mut Self {
self.filter = Some(expr);
self
}
pub fn limit(&mut self, limit: Option<i64>, offset: Option<i64>) -> Result<&mut Self> {
if let Some(value) = limit
&& value < 0
{
return Err(Error::invalid_input(
"limit must be non-negative".to_string(),
));
}
if let Some(value) = offset
&& value < 0
{
return Err(Error::invalid_input(
"offset must be non-negative".to_string(),
));
}
self.limit = limit.map(|value| value as usize);
self.offset = offset.map(|value| value as usize);
Ok(self)
}
pub fn nearest(&mut self, column: &str, query: &dyn Array, k: usize) -> Result<&mut Self> {
if k == 0 {
return Err(Error::invalid_input("k must be positive".to_string()));
}
if query.is_empty() {
return Err(Error::invalid_input(
"query vector must have non-zero length".to_string(),
));
}
self.nearest = Some(VectorQuery {
column: column.to_string(),
query_vector: query.slice(0, query.len()),
k,
nprobes: 1,
maximum_nprobes: None,
distance_type: None,
ef: None,
refine_factor: None,
distance_lower_bound: None,
distance_upper_bound: None,
});
Ok(self)
}
pub fn nprobes(&mut self, n: usize) -> &mut Self {
if let Some(ref mut q) = self.nearest {
q.nprobes = n;
q.maximum_nprobes = Some(n);
} else {
log::warn!("nprobes is not set because nearest has not been called yet");
}
self
}
pub fn minimum_nprobes(&mut self, n: usize) -> &mut Self {
if let Some(ref mut q) = self.nearest {
q.nprobes = n;
} else {
log::warn!("minimum_nprobes is not set because nearest has not been called yet");
}
self
}
pub fn maximum_nprobes(&mut self, n: usize) -> &mut Self {
if let Some(ref mut q) = self.nearest {
q.maximum_nprobes = Some(n);
} else {
log::warn!("maximum_nprobes is not set because nearest has not been called yet");
}
self
}
pub fn distance_metric(&mut self, metric: DistanceType) -> &mut Self {
if let Some(ref mut q) = self.nearest {
q.distance_type = Some(metric);
} else {
log::warn!("distance_metric is not set because nearest has not been called yet");
}
self
}
pub fn ef(&mut self, ef: usize) -> &mut Self {
if let Some(ref mut q) = self.nearest {
q.ef = Some(ef);
} else {
log::warn!("ef is not set because nearest has not been called yet");
}
self
}
pub fn refine(&mut self, factor: u32) -> &mut Self {
if let Some(ref mut q) = self.nearest {
q.refine_factor = Some(factor);
} else {
log::warn!("refine is not set because nearest has not been called yet");
}
self
}
pub fn distance_range(&mut self, lower: Option<f32>, upper: Option<f32>) -> &mut Self {
if let Some(ref mut q) = self.nearest {
q.distance_lower_bound = lower;
q.distance_upper_bound = upper;
} else {
log::warn!("distance_range is not set because nearest has not been called yet");
}
self
}
pub fn full_text_search(&mut self, query: FullTextSearchQuery) -> Result<&mut Self> {
self.full_text_query = Some(local_fts_query(query)?);
Ok(self)
}
pub fn full_text_phrase(&mut self, column: &str, phrase: &str, slop: u32) -> &mut Self {
self.full_text_query = Some(FtsQuery::phrase(column, phrase, slop));
self
}
pub fn full_text_boolean(
&mut self,
column: &str,
must: Vec<String>,
should: Vec<String>,
must_not: Vec<String>,
) -> &mut Self {
self.full_text_query = Some(FtsQuery::boolean(column, must, should, must_not));
self
}
pub fn full_text_fuzzy(&mut self, column: &str, query: &str) -> &mut Self {
self.full_text_query = Some(FtsQuery::fuzzy(column, query));
self
}
pub fn full_text_fuzzy_with_distance(
&mut self,
column: &str,
query: &str,
fuzziness: u32,
) -> &mut Self {
self.full_text_query = Some(FtsQuery::fuzzy_with_distance(column, query, fuzziness));
self
}
pub fn full_text_fuzzy_with_options(
&mut self,
column: &str,
query: &str,
fuzziness: Option<u32>,
max_expansions: usize,
) -> &mut Self {
self.full_text_query = Some(FtsQuery::fuzzy_with_options(
column,
query,
fuzziness,
0,
max_expansions,
));
self
}
pub fn fts_wand_factor(&mut self, wand_factor: f32) -> &mut Self {
if let Some(ref mut q) = self.full_text_query {
q.wand_factor = wand_factor.clamp(0.0, 1.0);
} else {
log::warn!(
"fts_wand_factor is not set because full_text_query has not been called yet"
);
}
self
}
pub fn fts_include_tail(&mut self, include_tail: bool) -> &mut Self {
if let Some(ref mut q) = self.full_text_query {
q.include_tail = include_tail;
} else {
log::warn!(
"fts_include_tail is not set because full_text_query has not been called yet"
);
}
self
}
pub fn use_index(&mut self, use_index: bool) -> &mut Self {
self.use_index = use_index;
self
}
pub fn batch_size(&mut self, size: usize) -> &mut Self {
self.batch_size = Some(size);
self
}
pub async fn try_into_stream(&self) -> Result<SendableRecordBatchStream> {
let plan = self.create_plan().await?;
let ctx = SessionContext::new();
let task_ctx = ctx.task_ctx();
plan.execute(0, task_ctx)
.map_err(|e| Error::io(format!("Failed to execute plan: {}", e)))
}
pub async fn try_into_batch(&self) -> Result<RecordBatch> {
let plan = self.create_plan().await?;
let output_schema = plan.schema();
let ctx = SessionContext::new();
let task_ctx = ctx.task_ctx();
let stream = plan
.execute(0, task_ctx)
.map_err(|e| Error::io(format!("Failed to execute plan: {}", e)))?;
let batches: Vec<RecordBatch> = stream
.try_collect()
.await
.map_err(|e| Error::io(format!("Failed to collect batches: {}", e)))?;
if batches.is_empty() {
return Ok(RecordBatch::new_empty(output_schema));
}
arrow_select::concat::concat_batches(&output_schema, &batches)
.map_err(|e| Error::io(format!("Failed to concatenate batches: {}", e)))
}
pub async fn count_rows(&self) -> Result<u64> {
let stream = self.try_into_stream().await?;
let batches: Vec<RecordBatch> = stream
.try_collect()
.await
.map_err(|e| Error::io(format!("Failed to count rows: {}", e)))?;
Ok(batches.iter().map(|b| b.num_rows() as u64).sum())
}
pub fn output_schema(&self) -> SchemaRef {
use super::exec::ROW_ADDRESS_COLUMN;
let mut fields: Vec<Field> = if let Some(ref projection) = self.projection {
projection
.iter()
.filter_map(|name| self.schema.field_with_name(name).ok().cloned())
.collect()
} else {
self.schema
.fields()
.iter()
.map(|f| f.as_ref().clone())
.collect()
};
if self.with_row_id {
fields.push(Field::new(ROW_ID, DataType::UInt64, true));
}
if self.with_row_address {
fields.push(Field::new(ROW_ADDRESS_COLUMN, DataType::UInt64, true));
}
Arc::new(arrow_schema::Schema::new(fields))
}
fn base_output_schema(&self) -> SchemaRef {
let fields: Vec<Field> = if let Some(ref projection) = self.projection {
projection
.iter()
.filter_map(|name| self.schema.field_with_name(name).ok().cloned())
.collect()
} else {
self.schema
.fields()
.iter()
.map(|f| f.as_ref().clone())
.collect()
};
Arc::new(arrow_schema::Schema::new(fields))
}
pub async fn create_plan(&self) -> Result<Arc<dyn ExecutionPlan>> {
if self.nearest.is_some() && self.full_text_query.is_some() {
return Err(Error::invalid_input(
"MemTableScanner cannot combine vector and full-text search".to_string(),
));
}
if let Some(ref vector_query) = self.nearest {
return self.plan_vector_search(vector_query).await;
}
if let Some(ref fts_query) = self.full_text_query {
return self.plan_fts_search(fts_query).await;
}
if self.use_index
&& let Some(predicate) = self.extract_btree_predicate()
&& self.has_btree_index(predicate.column())
{
return self.plan_btree_query(&predicate).await;
}
self.plan_full_scan().await
}
async fn plan_full_scan(&self) -> Result<Arc<dyn ExecutionPlan>> {
let projection_indices = self.compute_projection_indices()?;
let (filter_predicate, filter_expr) = if let Some(ref filter) = self.filter {
let planner = Planner::new(self.schema.clone());
let optimized = planner.optimize_expr(filter.clone())?;
let predicate = planner.create_physical_expr(&optimized)?;
(Some(predicate), Some(optimized))
} else {
(None, None)
};
let scan = MemTableScanExec::with_filter(
self.batch_store.clone(),
self.max_visible_batch_position,
projection_indices,
self.output_schema(),
self.schema.clone(),
self.with_row_id,
self.with_row_address,
filter_predicate,
filter_expr,
);
let mut plan: Arc<dyn ExecutionPlan> = Arc::new(scan);
if self.limit.is_some() || self.offset.unwrap_or(0) > 0 {
plan = Arc::new(GlobalLimitExec::new(
plan,
self.offset.unwrap_or(0),
self.limit,
));
}
Ok(plan)
}
pub async fn create_dedup_plan(&self, pk_columns: &[String]) -> Result<Arc<dyn ExecutionPlan>> {
validate_pk_types(&self.schema, pk_columns)?;
let pk_indices = pk_columns
.iter()
.map(|name| {
self.schema
.column_with_name(name)
.map(|(idx, _)| idx)
.ok_or_else(|| {
Error::invalid_input(format!(
"Primary key column '{}' not found in schema",
name
))
})
})
.collect::<Result<Vec<usize>>>()?;
let projection_indices = self.compute_projection_indices()?;
let (filter_predicate, filter_expr) = if let Some(ref filter) = self.filter {
let planner = Planner::new(self.schema.clone());
let optimized = planner.optimize_expr(filter.clone())?;
let predicate = planner.create_physical_expr(&optimized)?;
(Some(predicate), Some(optimized))
} else {
(None, None)
};
Ok(Arc::new(MemTableDedupScanExec::new(
self.batch_store.clone(),
self.max_visible_batch_position,
projection_indices,
self.output_schema(),
pk_indices,
self.with_row_id,
self.with_row_address,
filter_predicate,
filter_expr,
)))
}
async fn plan_btree_query(
&self,
predicate: &ScalarPredicate,
) -> Result<Arc<dyn ExecutionPlan>> {
if !self.has_btree_index(predicate.column()) {
return self.plan_full_scan().await;
}
let max_visible = self.max_visible_batch_position;
let projection_indices = self.compute_projection_indices()?;
let index_exec = BTreeIndexExec::new(
self.batch_store.clone(),
self.indexes.clone(),
predicate.clone(),
max_visible,
projection_indices,
self.output_schema(),
self.with_row_id,
self.with_row_address,
)?;
self.apply_post_index_ops(Arc::new(index_exec)).await
}
fn filter_predicate(&self) -> Result<Option<PhysicalExprRef>> {
let Some(ref filter) = self.filter else {
return Ok(None);
};
let planner = Planner::new(self.schema.clone());
let optimized = planner.optimize_expr(filter.clone())?;
Ok(Some(planner.create_physical_expr(&optimized)?))
}
async fn plan_vector_search(&self, query: &VectorQuery) -> Result<Arc<dyn ExecutionPlan>> {
let max_visible = self.max_visible_batch_position;
let projection_indices = self.compute_projection_indices()?;
let base_schema = self.base_output_schema();
let filter_predicate = self.filter_predicate()?;
if let Some(pk_columns) = &self.pk_columns {
validate_pk_types(&self.schema, pk_columns)?;
}
let hnsw_safe_with_pk = self
.pk_columns
.as_ref()
.map(|_| self.indexes.has_pk_index() && !self.indexes.pk_has_overrides())
.unwrap_or(true);
let exec: Arc<dyn ExecutionPlan> = if filter_predicate.is_none()
&& hnsw_safe_with_pk
&& self.has_vector_index(&query.column)
{
Arc::new(VectorIndexExec::new(
self.batch_store.clone(),
self.indexes.clone(),
query.clone(),
max_visible,
projection_indices,
base_schema,
self.with_row_id,
)?)
} else {
Arc::new(
MemTableBruteForceVectorExec::new(
self.batch_store.clone(),
query.clone(),
max_visible,
projection_indices,
base_schema,
self.with_row_id,
)?
.with_filter(filter_predicate)
.with_pk_columns(self.pk_columns.clone()),
)
};
self.apply_post_index_ops(exec).await
}
async fn plan_fts_search(&self, query: &FtsQuery) -> Result<Arc<dyn ExecutionPlan>> {
if !self.has_fts_index(&query.column) {
return self.empty_fts_plan();
}
let max_visible = self.max_visible_batch_position;
let projection_indices = self.compute_projection_indices()?;
let filter_predicate = self.filter_predicate()?;
if let Some(pk_columns) = &self.pk_columns {
validate_pk_types(&self.schema, pk_columns)?;
}
let index_exec = FtsIndexExec::new(
self.batch_store.clone(),
self.indexes.clone(),
query.clone(),
max_visible,
projection_indices,
self.base_output_schema(),
self.with_row_id,
)?
.with_filter(filter_predicate)
.with_pk_columns(self.pk_columns.clone());
self.apply_post_index_ops(Arc::new(index_exec)).await
}
fn empty_fts_plan(&self) -> Result<Arc<dyn ExecutionPlan>> {
use datafusion::physical_plan::empty::EmptyExec;
let mut fields: Vec<Field> = self
.base_output_schema()
.fields()
.iter()
.map(|f| f.as_ref().clone())
.collect();
fields.push(Field::new(SCORE_COLUMN, DataType::Float32, true));
if self.with_row_id {
fields.push(Field::new(ROW_ID, DataType::UInt64, true));
}
let schema = Arc::new(arrow_schema::Schema::new(fields));
Ok(Arc::new(EmptyExec::new(schema)))
}
async fn apply_post_index_ops(
&self,
plan: Arc<dyn ExecutionPlan>,
) -> Result<Arc<dyn ExecutionPlan>> {
let mut result = plan;
if self.limit.is_some() || self.offset.unwrap_or(0) > 0 {
result = Arc::new(GlobalLimitExec::new(
result,
self.offset.unwrap_or(0),
self.limit,
));
}
Ok(result)
}
fn compute_projection_indices(&self) -> Result<Option<Vec<usize>>> {
if let Some(ref columns) = self.projection {
let indices: Result<Vec<usize>> = columns
.iter()
.map(|name| {
self.schema
.column_with_name(name)
.map(|(idx, _)| idx)
.ok_or_else(|| {
Error::invalid_input(format!("Column '{}' not found in schema", name))
})
})
.collect();
Ok(Some(indices?))
} else {
Ok(None)
}
}
fn extract_btree_predicate(&self) -> Option<ScalarPredicate> {
let filter = self.filter.as_ref()?;
match filter {
Expr::BinaryExpr(binary) => {
if let (Expr::Column(col), Expr::Literal(lit, _)) =
(binary.left.as_ref(), binary.right.as_ref())
{
let coerced_lit = self.coerce_literal_to_column(&col.name, lit)?;
match binary.op {
datafusion::logical_expr::Operator::Eq => {
return Some(ScalarPredicate::Eq {
column: col.name.clone(),
value: coerced_lit,
});
}
datafusion::logical_expr::Operator::Lt => {
return Some(ScalarPredicate::Range {
column: col.name.clone(),
lower: None,
upper: Some(coerced_lit),
});
}
datafusion::logical_expr::Operator::GtEq => {
return Some(ScalarPredicate::Range {
column: col.name.clone(),
lower: Some(coerced_lit),
upper: None,
});
}
_ => {}
}
}
}
Expr::InList(in_list) if !in_list.negated => {
if let Expr::Column(col) = in_list.expr.as_ref() {
let values: Vec<ScalarValue> = in_list
.list
.iter()
.filter_map(|e| {
if let Expr::Literal(lit, _) = e {
self.coerce_literal_to_column(&col.name, lit)
} else {
None
}
})
.collect();
if values.len() == in_list.list.len() {
return Some(ScalarPredicate::In {
column: col.name.clone(),
values,
});
}
}
}
_ => {}
}
None
}
fn coerce_literal_to_column(&self, column: &str, lit: &ScalarValue) -> Option<ScalarValue> {
let field = self.schema.field_with_name(column).ok()?;
let target_type = field.data_type();
if &lit.data_type() == target_type {
return Some(lit.clone());
}
safe_coerce_scalar(lit, target_type)
}
fn has_btree_index(&self, column: &str) -> bool {
self.indexes.get_btree_by_column(column).is_some()
}
fn has_vector_index(&self, column: &str) -> bool {
self.indexes.get_hnsw_by_column(column).is_some()
}
fn has_fts_index(&self, column: &str) -> bool {
self.indexes.get_fts_by_column(column).is_some()
}
}
#[cfg(test)]
mod tests {
use super::*;
use arrow_array::{BooleanArray, Int32Array, StringArray};
use arrow_schema::{DataType, Field, Schema};
fn create_test_schema() -> SchemaRef {
Arc::new(Schema::new(vec![
Field::new("id", DataType::Int32, false),
Field::new("name", DataType::Utf8, true),
]))
}
fn create_test_batch(schema: &Schema, start_id: i32, count: usize) -> RecordBatch {
let ids: Vec<i32> = (start_id..start_id + count as i32).collect();
let names: Vec<String> = ids.iter().map(|id| format!("name_{}", id)).collect();
RecordBatch::try_new(
Arc::new(schema.clone()),
vec![
Arc::new(Int32Array::from(ids)),
Arc::new(StringArray::from(names)),
],
)
.unwrap()
}
fn create_index_store_with_batches(
batch_store: &Arc<BatchStore>,
schema: &Schema,
batches: &[(i32, usize)], ) -> Arc<IndexStore> {
let mut index_store = IndexStore::new();
index_store.add_btree("id_idx".to_string(), 0, "id".to_string());
let mut row_offset = 0u64;
for (batch_pos, (start_id, count)) in batches.iter().enumerate() {
let batch = create_test_batch(schema, *start_id, *count);
batch_store.append(batch.clone()).unwrap();
index_store
.insert_with_batch_position(&batch, row_offset, Some(batch_pos))
.unwrap();
row_offset += *count as u64;
}
Arc::new(index_store)
}
#[tokio::test]
async fn test_scanner_basic_scan() {
let schema = create_test_schema();
let batch_store = Arc::new(BatchStore::with_capacity(100));
let indexes = create_index_store_with_batches(&batch_store, &schema, &[(0, 10)]);
let scanner = MemTableScanner::new(batch_store, indexes, schema.clone());
let result = scanner.try_into_batch().await.unwrap();
assert_eq!(result.num_rows(), 10);
}
#[tokio::test]
async fn test_scanner_visibility_filtering() {
let schema = create_test_schema();
let batch_store = Arc::new(BatchStore::with_capacity(100));
let mut index_store = IndexStore::new();
index_store.add_btree("id_idx".to_string(), 0, "id".to_string());
let batch1 = create_test_batch(&schema, 0, 10);
batch_store.append(batch1.clone()).unwrap();
index_store
.insert_with_batch_position(&batch1, 0, Some(0))
.unwrap();
let batch2 = create_test_batch(&schema, 10, 10);
batch_store.append(batch2.clone()).unwrap();
index_store
.insert_with_batch_position(&batch2, 10, Some(1))
.unwrap();
let batch3 = create_test_batch(&schema, 20, 10);
batch_store.append(batch3).unwrap();
let indexes = Arc::new(index_store);
let scanner = MemTableScanner::new(batch_store, indexes, schema.clone());
let result = scanner.try_into_batch().await.unwrap();
assert_eq!(result.num_rows(), 20);
}
#[tokio::test]
async fn test_scanner_projection() {
let schema = create_test_schema();
let batch_store = Arc::new(BatchStore::with_capacity(100));
let indexes = create_index_store_with_batches(&batch_store, &schema, &[(0, 10)]);
let mut scanner = MemTableScanner::new(batch_store, indexes, schema.clone());
scanner.project(&["id"]).unwrap();
let result = scanner.try_into_batch().await.unwrap();
assert_eq!(result.num_columns(), 1);
assert_eq!(result.schema().field(0).name(), "id");
}
#[tokio::test]
async fn test_scanner_limit() {
let schema = create_test_schema();
let batch_store = Arc::new(BatchStore::with_capacity(100));
let indexes = create_index_store_with_batches(&batch_store, &schema, &[(0, 100)]);
let mut scanner = MemTableScanner::new(batch_store, indexes, schema.clone());
scanner.limit(Some(10), None).unwrap();
let result = scanner.try_into_batch().await.unwrap();
assert_eq!(result.num_rows(), 10);
}
#[tokio::test]
async fn test_scanner_offset_without_limit() {
let schema = create_test_schema();
let batch_store = Arc::new(BatchStore::with_capacity(100));
let indexes = create_index_store_with_batches(&batch_store, &schema, &[(0, 10)]);
let mut scanner = MemTableScanner::new(batch_store, indexes, schema.clone());
scanner.limit(Some(3), None).unwrap();
scanner.limit(None, Some(2)).unwrap();
let result = scanner.try_into_batch().await.unwrap();
let ids = result
.column_by_name("id")
.unwrap()
.as_any()
.downcast_ref::<Int32Array>()
.unwrap()
.values()
.to_vec();
assert_eq!(ids, vec![2, 3, 4, 5, 6, 7, 8, 9]);
}
#[tokio::test]
async fn btree_filter_fallback_preserves_non_representable_predicates() {
let schema = create_test_schema();
let batch_store = Arc::new(BatchStore::with_capacity(100));
let indexes = create_index_store_with_batches(&batch_store, &schema, &[(0, 10)]);
async fn ids_for(
batch_store: Arc<BatchStore>,
indexes: Arc<IndexStore>,
schema: SchemaRef,
filter: &str,
) -> Vec<i32> {
let mut scanner = MemTableScanner::new(batch_store, indexes, schema);
scanner.filter(filter).unwrap();
scanner
.try_into_batch()
.await
.unwrap()
.column_by_name("id")
.unwrap()
.as_any()
.downcast_ref::<Int32Array>()
.unwrap()
.values()
.to_vec()
}
assert_eq!(
ids_for(
batch_store.clone(),
indexes.clone(),
schema.clone(),
"id NOT IN (1, 2)"
)
.await,
vec![0, 3, 4, 5, 6, 7, 8, 9]
);
assert_eq!(
ids_for(
batch_store.clone(),
indexes.clone(),
schema.clone(),
"id <= 5"
)
.await,
vec![0, 1, 2, 3, 4, 5]
);
assert_eq!(
ids_for(batch_store, indexes, schema, "id > 5").await,
vec![6, 7, 8, 9]
);
}
#[test]
fn local_fts_query_maps_leaf_shapes_and_rejects_the_rest() {
use lance_index::scalar::inverted::query::{
BooleanQuery, MatchQuery, Occur, Operator, PhraseQuery,
};
let q = FullTextSearchQuery::new("hello".to_string())
.with_column("text".to_string())
.unwrap();
let local = local_fts_query(q).unwrap();
assert_eq!(local.column, "text");
assert!(
matches!(local.query_type, FtsQueryType::Match { query, operator, .. }
if query == "hello" && operator == Operator::Or)
);
let exact_and = FullTextSearchQuery::new_query(IndexFtsQuery::Match(
MatchQuery::new("hello world".to_string())
.with_operator(Operator::And)
.with_boost(3.0)
.with_column(Some("text".to_string())),
));
let local = local_fts_query(exact_and).unwrap();
assert!(
matches!(local.query_type, FtsQueryType::Match { query, operator, boost }
if query == "hello world" && operator == Operator::And && boost == 3.0)
);
let fuzzy = FullTextSearchQuery::new_query(IndexFtsQuery::Match(
MatchQuery::new("lance".to_string())
.with_fuzziness(Some(2))
.with_prefix_length(2)
.with_boost(2.5)
.with_column(Some("text".to_string())),
));
let local = local_fts_query(fuzzy).unwrap();
assert!(
matches!(local.query_type, FtsQueryType::Fuzzy { fuzziness, prefix_length, boost, .. }
if fuzziness == Some(2) && prefix_length == 2 && boost == 2.5)
);
let fuzzy_and = FullTextSearchQuery::new_query(IndexFtsQuery::Match(
MatchQuery::new("lance memwal".to_string())
.with_operator(Operator::And)
.with_fuzziness(Some(1))
.with_column(Some("text".to_string())),
));
assert!(
local_fts_query(fuzzy_and).is_err(),
"fuzzy AND cannot be represented by the local memtable query"
);
let phrase = FullTextSearchQuery::new_query(IndexFtsQuery::Phrase(
PhraseQuery::new("quick fox".to_string()).with_column(Some("text".to_string())),
));
let local = local_fts_query(phrase).unwrap();
assert!(matches!(local.query_type, FtsQueryType::Phrase { .. }));
let boolean =
FullTextSearchQuery::new_query(IndexFtsQuery::Boolean(BooleanQuery::new(vec![(
Occur::Must,
IndexFtsQuery::Match(
MatchQuery::new("x".to_string()).with_column(Some("text".to_string())),
),
)])));
assert!(
local_fts_query(boolean).is_err(),
"boolean must be rejected"
);
let no_col = FullTextSearchQuery::new("hi".to_string());
assert!(
local_fts_query(no_col).is_err(),
"missing column must error"
);
}
#[tokio::test]
async fn full_text_search_honors_query_limit() {
let schema = Arc::new(Schema::new(vec![
Field::new("id", DataType::Int32, false),
Field::new("text", DataType::Utf8, true),
]));
let batch_store = Arc::new(BatchStore::with_capacity(16));
let batch = RecordBatch::try_new(
schema.clone(),
vec![
Arc::new(Int32Array::from(vec![1, 2, 3])),
Arc::new(StringArray::from(vec![
"lance",
"lance filler",
"lance filler filler",
])),
],
)
.unwrap();
let mut indexes = IndexStore::new();
indexes.add_fts("text_fts".to_string(), 1, "text".to_string());
batch_store.append(batch.clone()).unwrap();
indexes
.insert_with_batch_position(&batch, 0, Some(0))
.unwrap();
let mut scanner = MemTableScanner::new(batch_store, Arc::new(indexes), schema);
scanner
.full_text_search(
FullTextSearchQuery::new("lance".to_string())
.with_column("text".to_string())
.unwrap()
.limit(Some(1)),
)
.unwrap();
let result = scanner.try_into_batch().await.unwrap();
assert_eq!(
result.num_rows(),
1,
"query-level FTS limit must cap direct MemTableScanner results"
);
}
#[tokio::test]
async fn full_text_search_without_index_returns_empty_score_schema() {
let schema = Arc::new(Schema::new(vec![
Field::new("id", DataType::Int32, false),
Field::new("text", DataType::Utf8, true),
]));
let batch_store = Arc::new(BatchStore::with_capacity(16));
let batch = RecordBatch::try_new(
schema.clone(),
vec![
Arc::new(Int32Array::from(vec![1, 2])),
Arc::new(StringArray::from(vec!["needle", "needle"])),
],
)
.unwrap();
batch_store.append(batch).unwrap();
let mut scanner = MemTableScanner::new(batch_store, Arc::new(IndexStore::new()), schema);
scanner
.full_text_search(
FullTextSearchQuery::new("needle".to_string())
.with_column("text".to_string())
.unwrap(),
)
.unwrap();
let result = scanner.try_into_batch().await.unwrap();
assert_eq!(result.num_rows(), 0);
assert!(
result.schema().field_with_name("_score").is_ok(),
"missing FTS indexes should produce an empty FTS-shaped result"
);
}
#[tokio::test]
async fn full_text_search_prefilter_null_predicate_excludes_rows() {
let schema = Arc::new(Schema::new(vec![
Field::new("id", DataType::Int32, false),
Field::new("text", DataType::Utf8, true),
Field::new("active", DataType::Boolean, true),
]));
let batch_store = Arc::new(BatchStore::with_capacity(16));
let batch = RecordBatch::try_new(
schema.clone(),
vec![
Arc::new(Int32Array::from(vec![1, 2, 3])),
Arc::new(StringArray::from(vec!["needle", "needle", "needle"])),
Arc::new(BooleanArray::from(vec![None, Some(true), Some(false)])),
],
)
.unwrap();
let mut indexes = IndexStore::new();
indexes.add_fts("text_fts".to_string(), 1, "text".to_string());
batch_store.append(batch.clone()).unwrap();
indexes
.insert_with_batch_position(&batch, 0, Some(0))
.unwrap();
let mut scanner = MemTableScanner::new(batch_store, Arc::new(indexes), schema);
scanner.filter("active = true").unwrap();
scanner
.full_text_search(
FullTextSearchQuery::new("needle".to_string())
.with_column("text".to_string())
.unwrap(),
)
.unwrap();
let result = scanner.try_into_batch().await.unwrap();
let ids = result
.column_by_name("id")
.unwrap()
.as_any()
.downcast_ref::<Int32Array>()
.unwrap()
.values()
.to_vec();
assert_eq!(
ids,
vec![2],
"NULL predicate results must be excluded from FTS prefilter candidates"
);
}
#[tokio::test]
async fn full_text_search_prefilter_disables_wand_pruning() {
let schema = Arc::new(Schema::new(vec![
Field::new("id", DataType::Int32, false),
Field::new("text", DataType::Utf8, true),
Field::new("active", DataType::Boolean, true),
]));
let batch_store = Arc::new(BatchStore::with_capacity(16));
let batch = RecordBatch::try_new(
schema.clone(),
vec![
Arc::new(Int32Array::from(vec![1, 2])),
Arc::new(StringArray::from(vec!["alpha beta gamma delta", "alpha"])),
Arc::new(BooleanArray::from(vec![Some(false), Some(true)])),
],
)
.unwrap();
let mut indexes = IndexStore::new();
indexes.add_fts("text_fts".to_string(), 1, "text".to_string());
batch_store.append(batch.clone()).unwrap();
indexes
.insert_with_batch_position(&batch, 0, Some(0))
.unwrap();
let mut scanner = MemTableScanner::new(batch_store, Arc::new(indexes), schema);
scanner.filter("active = true").unwrap();
scanner
.full_text_search(
FullTextSearchQuery::new("alpha beta gamma delta".to_string())
.with_column("text".to_string())
.unwrap()
.wand_factor(Some(0.99)),
)
.unwrap();
let result = scanner.try_into_batch().await.unwrap();
let ids = result
.column_by_name("id")
.unwrap()
.as_any()
.downcast_ref::<Int32Array>()
.unwrap()
.values()
.to_vec();
assert_eq!(
ids,
vec![2],
"filtered FTS must not let WAND prune rows before the prefilter is applied"
);
}
#[tokio::test]
async fn full_text_search_append_only_pk_keeps_wand_pruning() {
let schema = Arc::new(Schema::new(vec![
Field::new("id", DataType::Int32, false),
Field::new("text", DataType::Utf8, true),
]));
let batch_store = Arc::new(BatchStore::with_capacity(16));
let batch = RecordBatch::try_new(
schema.clone(),
vec![
Arc::new(Int32Array::from(vec![1, 2])),
Arc::new(StringArray::from(vec!["alpha beta gamma delta", "alpha"])),
],
)
.unwrap();
let mut indexes = IndexStore::new();
indexes.enable_pk_index(&[("id".to_string(), 0)]);
indexes.add_fts("text_fts".to_string(), 1, "text".to_string());
batch_store.append(batch.clone()).unwrap();
indexes
.insert_with_batch_position(&batch, 0, Some(0))
.unwrap();
let mut scanner = MemTableScanner::new(batch_store, Arc::new(indexes), schema);
scanner.with_pk_columns(vec!["id".to_string()]);
scanner
.full_text_search(
FullTextSearchQuery::new("alpha beta gamma delta".to_string())
.with_column("text".to_string())
.unwrap()
.wand_factor(Some(0.99)),
)
.unwrap();
let result = scanner.try_into_batch().await.unwrap();
let ids = result
.column_by_name("id")
.unwrap()
.as_any()
.downcast_ref::<Int32Array>()
.unwrap()
.values()
.to_vec();
assert_eq!(
ids,
vec![1],
"append-only PK data should keep index WAND pruning enabled"
);
}
#[tokio::test]
async fn full_text_search_with_pk_rewrite_disables_index_limit_pushdown() {
let schema = Arc::new(Schema::new(vec![
Field::new("id", DataType::Int32, false),
Field::new("text", DataType::Utf8, true),
]));
let batch_store = Arc::new(BatchStore::with_capacity(16));
let batch = RecordBatch::try_new(
schema.clone(),
vec![
Arc::new(Int32Array::from(vec![1, 1, 2, 3])),
Arc::new(StringArray::from(vec![
"alpha beta gamma delta epsilon",
"other",
"alpha beta gamma delta",
"alpha",
])),
],
)
.unwrap();
let mut indexes = IndexStore::new();
indexes.enable_pk_index(&[("id".to_string(), 0)]);
indexes.add_fts("text_fts".to_string(), 1, "text".to_string());
batch_store.append(batch.clone()).unwrap();
indexes
.insert_with_batch_position(&batch, 0, Some(0))
.unwrap();
let mut scanner = MemTableScanner::new(batch_store, Arc::new(indexes), schema);
scanner.with_pk_columns(vec!["id".to_string()]);
scanner
.full_text_search(
FullTextSearchQuery::new("alpha beta gamma delta epsilon".to_string())
.with_column("text".to_string())
.unwrap()
.limit(Some(2)),
)
.unwrap();
let result = scanner.try_into_batch().await.unwrap();
let ids = result
.column_by_name("id")
.unwrap()
.as_any()
.downcast_ref::<Int32Array>()
.unwrap()
.values()
.to_vec();
assert_eq!(
ids,
vec![2, 3],
"FTS-only PK rewrites must disable index limit pushdown so live lower-scoring PKs can backfill"
);
}
#[tokio::test]
async fn full_text_search_with_pk_columns_drops_stale_filtered_hits() {
let schema = Arc::new(Schema::new(vec![
Field::new("id", DataType::Int32, false),
Field::new("text", DataType::Utf8, true),
Field::new("active", DataType::Boolean, false),
]));
let batch_store = Arc::new(BatchStore::with_capacity(16));
let batch = RecordBatch::try_new(
schema.clone(),
vec![
Arc::new(Int32Array::from(vec![1, 1])),
Arc::new(StringArray::from(vec!["needle", "needle"])),
Arc::new(BooleanArray::from(vec![true, false])),
],
)
.unwrap();
let mut indexes = IndexStore::new();
indexes.enable_pk_index(&[("id".to_string(), 0)]);
indexes.add_fts("text_fts".to_string(), 1, "text".to_string());
batch_store.append(batch.clone()).unwrap();
indexes
.insert_with_batch_position(&batch, 0, Some(0))
.unwrap();
let mut scanner = MemTableScanner::new(batch_store, Arc::new(indexes), schema);
scanner.with_pk_columns(vec!["id".to_string()]);
scanner.filter("active = true").unwrap();
scanner
.full_text_search(
FullTextSearchQuery::new("needle".to_string())
.with_column("text".to_string())
.unwrap(),
)
.unwrap();
let result = scanner.try_into_batch().await.unwrap();
assert_eq!(
result.num_rows(),
0,
"the older matching version must not leak when the newest PK fails the filter"
);
}
#[tokio::test]
async fn full_text_search_with_pk_columns_falls_back_without_pk_index() {
let schema = Arc::new(Schema::new(vec![
Field::new("id", DataType::Int32, false),
Field::new("text", DataType::Utf8, true),
]));
let batch_store = Arc::new(BatchStore::with_capacity(16));
let batch = RecordBatch::try_new(
schema.clone(),
vec![
Arc::new(Int32Array::from(vec![1, 1, 2, 2])),
Arc::new(StringArray::from(vec![
"needle stale",
"other",
"other",
"needle fresh",
])),
],
)
.unwrap();
let mut indexes = IndexStore::new();
indexes.add_fts("text_fts".to_string(), 1, "text".to_string());
batch_store.append(batch.clone()).unwrap();
indexes
.insert_with_batch_position(&batch, 0, Some(0))
.unwrap();
let mut scanner = MemTableScanner::new(batch_store, Arc::new(indexes), schema);
scanner.with_pk_columns(vec!["id".to_string()]);
scanner
.full_text_search(
FullTextSearchQuery::new("needle".to_string())
.with_column("text".to_string())
.unwrap(),
)
.unwrap();
let result = scanner
.try_into_batch()
.await
.expect("FTS PK recency should fall back without a PK index");
let ids = result
.column_by_name("id")
.unwrap()
.as_any()
.downcast_ref::<Int32Array>()
.unwrap()
.values()
.to_vec();
assert_eq!(
ids,
vec![2],
"without a PK index the batch-scan fallback must drop stale id=1 \
but keep id=2 whose newest version still matches"
);
}
#[tokio::test]
async fn test_scanner_count_rows() {
let schema = create_test_schema();
let batch_store = Arc::new(BatchStore::with_capacity(100));
let indexes = create_index_store_with_batches(&batch_store, &schema, &[(0, 50)]);
let scanner = MemTableScanner::new(batch_store, indexes, schema.clone());
let count = scanner.count_rows().await.unwrap();
assert_eq!(count, 50);
}
#[tokio::test]
async fn test_scanner_with_row_id() {
let schema = create_test_schema();
let batch_store = Arc::new(BatchStore::with_capacity(100));
let indexes = create_index_store_with_batches(&batch_store, &schema, &[(0, 10)]);
let mut scanner = MemTableScanner::new(batch_store, indexes, schema.clone());
scanner.with_row_id();
let output_schema = scanner.output_schema();
assert_eq!(output_schema.fields().len(), 3);
assert_eq!(output_schema.field(0).name(), "id");
assert_eq!(output_schema.field(1).name(), "name");
assert_eq!(output_schema.field(2).name(), "_rowid");
assert_eq!(output_schema.field(2).data_type(), &DataType::UInt64);
let result = scanner.try_into_batch().await.unwrap();
assert_eq!(result.num_columns(), 3);
assert_eq!(result.schema().field(2).name(), "_rowid");
let row_ids = result
.column(2)
.as_any()
.downcast_ref::<arrow_array::UInt64Array>()
.unwrap();
assert_eq!(row_ids.len(), 10);
for i in 0..10 {
assert_eq!(row_ids.value(i), i as u64);
}
}
#[tokio::test]
async fn test_scanner_project_with_row_id() {
let schema = create_test_schema();
let batch_store = Arc::new(BatchStore::with_capacity(100));
let indexes = create_index_store_with_batches(&batch_store, &schema, &[(0, 10)]);
let mut scanner = MemTableScanner::new(batch_store, indexes, schema.clone());
scanner.project(&["id", "_rowid"]).unwrap();
let output_schema = scanner.output_schema();
assert_eq!(output_schema.fields().len(), 2);
assert_eq!(output_schema.field(0).name(), "id");
assert_eq!(output_schema.field(1).name(), "_rowid");
let result = scanner.try_into_batch().await.unwrap();
assert_eq!(result.num_columns(), 2);
assert_eq!(result.schema().field(0).name(), "id");
assert_eq!(result.schema().field(1).name(), "_rowid");
}
#[tokio::test]
async fn test_scanner_row_id_across_batches() {
let schema = create_test_schema();
let batch_store = Arc::new(BatchStore::with_capacity(100));
let indexes = create_index_store_with_batches(&batch_store, &schema, &[(0, 5), (5, 5)]);
let mut scanner = MemTableScanner::new(batch_store, indexes, schema.clone());
scanner.with_row_id();
let result = scanner.try_into_batch().await.unwrap();
assert_eq!(result.num_rows(), 10);
let row_ids = result
.column(2)
.as_any()
.downcast_ref::<arrow_array::UInt64Array>()
.unwrap();
for i in 0..10 {
assert_eq!(row_ids.value(i), i as u64);
}
}
#[test]
fn test_output_schema_with_row_id() {
let schema = create_test_schema();
let batch_store = Arc::new(BatchStore::with_capacity(100));
let indexes = Arc::new(IndexStore::new());
let mut scanner = MemTableScanner::new(batch_store, indexes, schema);
let output_schema = scanner.output_schema();
assert_eq!(output_schema.fields().len(), 2);
assert!(output_schema.field_with_name("_rowid").is_err());
scanner.with_row_id();
let output_schema = scanner.output_schema();
assert_eq!(output_schema.fields().len(), 3);
assert!(output_schema.field_with_name("_rowid").is_ok());
}
#[test]
fn test_project_extracts_row_id() {
let schema = create_test_schema();
let batch_store = Arc::new(BatchStore::with_capacity(100));
let indexes = Arc::new(IndexStore::new());
let mut scanner = MemTableScanner::new(batch_store, indexes, schema);
scanner.project(&["id", "_rowid"]).unwrap();
assert!(scanner.with_row_id);
assert_eq!(scanner.projection, Some(vec!["id".to_string()]));
let output_schema = scanner.output_schema();
assert_eq!(output_schema.fields().len(), 2);
assert_eq!(output_schema.field(0).name(), "id");
assert_eq!(output_schema.field(1).name(), "_rowid");
}
#[tokio::test]
async fn test_scan_plan_with_row_id() {
use crate::utils::test::assert_plan_node_equals;
let schema = create_test_schema();
let batch_store = Arc::new(BatchStore::with_capacity(100));
let indexes = create_index_store_with_batches(&batch_store, &schema, &[(0, 10)]);
let mut scanner = MemTableScanner::new(batch_store, indexes, schema.clone());
scanner.with_row_id();
let plan = scanner.create_plan().await.unwrap();
assert_plan_node_equals(
plan,
"MemTableScanExec: projection=[id, name, _rowid], with_row_id=true",
)
.await
.unwrap();
}
#[tokio::test]
async fn test_scan_plan_projection_with_row_id() {
use crate::utils::test::assert_plan_node_equals;
let schema = create_test_schema();
let batch_store = Arc::new(BatchStore::with_capacity(100));
let indexes = create_index_store_with_batches(&batch_store, &schema, &[(0, 10)]);
let mut scanner = MemTableScanner::new(batch_store, indexes, schema.clone());
scanner.project(&["id", "_rowid"]).unwrap();
let plan = scanner.create_plan().await.unwrap();
assert_plan_node_equals(
plan,
"MemTableScanExec: projection=[id, _rowid], with_row_id=true",
)
.await
.unwrap();
}
#[tokio::test]
async fn test_scan_plan_without_row_id() {
use crate::utils::test::assert_plan_node_equals;
let schema = create_test_schema();
let batch_store = Arc::new(BatchStore::with_capacity(100));
let indexes = create_index_store_with_batches(&batch_store, &schema, &[(0, 10)]);
let scanner = MemTableScanner::new(batch_store, indexes, schema.clone());
let plan = scanner.create_plan().await.unwrap();
assert_plan_node_equals(
plan,
"MemTableScanExec: projection=[id, name], with_row_id=false",
)
.await
.unwrap();
}
#[test]
fn test_output_schema_with_row_address() {
let schema = create_test_schema();
let batch_store = Arc::new(BatchStore::with_capacity(100));
let indexes = Arc::new(IndexStore::new());
let mut scanner = MemTableScanner::new(batch_store, indexes, schema);
let output_schema = scanner.output_schema();
assert_eq!(output_schema.fields().len(), 2);
assert!(output_schema.field_with_name("_rowaddr").is_err());
scanner.with_row_address();
let output_schema = scanner.output_schema();
assert_eq!(output_schema.fields().len(), 3);
assert!(output_schema.field_with_name("_rowaddr").is_ok());
}
#[tokio::test]
async fn test_scanner_with_row_address() {
let schema = create_test_schema();
let batch_store = Arc::new(BatchStore::with_capacity(100));
let indexes = create_index_store_with_batches(&batch_store, &schema, &[(0, 10)]);
let mut scanner = MemTableScanner::new(batch_store, indexes, schema.clone());
scanner.with_row_address();
let output_schema = scanner.output_schema();
assert_eq!(output_schema.fields().len(), 3);
assert_eq!(output_schema.field(0).name(), "id");
assert_eq!(output_schema.field(1).name(), "name");
assert_eq!(output_schema.field(2).name(), "_rowaddr");
assert_eq!(output_schema.field(2).data_type(), &DataType::UInt64);
let result = scanner.try_into_batch().await.unwrap();
assert_eq!(result.num_columns(), 3);
assert_eq!(result.schema().field(2).name(), "_rowaddr");
let row_addrs = result
.column(2)
.as_any()
.downcast_ref::<arrow_array::UInt64Array>()
.unwrap();
assert_eq!(row_addrs.len(), 10);
for i in 0..10 {
assert_eq!(row_addrs.value(i), i as u64);
}
}
#[tokio::test]
async fn test_scan_plan_with_row_address() {
use crate::utils::test::assert_plan_node_equals;
let schema = create_test_schema();
let batch_store = Arc::new(BatchStore::with_capacity(100));
let indexes = create_index_store_with_batches(&batch_store, &schema, &[(0, 10)]);
let mut scanner = MemTableScanner::new(batch_store, indexes, schema.clone());
scanner.with_row_address();
let plan = scanner.create_plan().await.unwrap();
assert_plan_node_equals(
plan,
"MemTableScanExec: projection=[id, name, _rowaddr], with_row_id=false, with_row_address=true",
)
.await
.unwrap();
}
#[tokio::test]
async fn test_scanner_with_both_row_id_and_row_address() {
let schema = create_test_schema();
let batch_store = Arc::new(BatchStore::with_capacity(100));
let indexes = create_index_store_with_batches(&batch_store, &schema, &[(0, 5)]);
let mut scanner = MemTableScanner::new(batch_store, indexes, schema.clone());
scanner.with_row_id();
scanner.with_row_address();
let output_schema = scanner.output_schema();
assert_eq!(output_schema.fields().len(), 4);
assert_eq!(output_schema.field(2).name(), "_rowid");
assert_eq!(output_schema.field(3).name(), "_rowaddr");
let result = scanner.try_into_batch().await.unwrap();
assert_eq!(result.num_columns(), 4);
let row_ids = result
.column(2)
.as_any()
.downcast_ref::<arrow_array::UInt64Array>()
.unwrap();
let row_addrs = result
.column(3)
.as_any()
.downcast_ref::<arrow_array::UInt64Array>()
.unwrap();
for i in 0..5 {
assert_eq!(row_ids.value(i), i as u64);
assert_eq!(row_addrs.value(i), i as u64);
}
}
#[tokio::test]
async fn test_plan_vector_search_without_hnsw_produces_distance_schema() {
use std::sync::Arc;
const DISTANCE_COLUMN: &str = "_distance";
let schema: SchemaRef = Arc::new(Schema::new(vec![
Field::new("id", DataType::Int32, false),
Field::new(
"vector",
DataType::FixedSizeList(Arc::new(Field::new("item", DataType::Float32, true)), 2),
true,
),
]));
let batch_store = Arc::new(BatchStore::with_capacity(4));
let indexes = Arc::new(IndexStore::new());
let mut scanner = MemTableScanner::new(batch_store, indexes, schema.clone());
let query: Arc<dyn arrow_array::Array> =
Arc::new(arrow_array::Float32Array::from(vec![0.0_f32, 0.0_f32]));
scanner.nearest("vector", query.as_ref(), 5).unwrap();
let plan = scanner
.create_plan()
.await
.expect("planner must produce a plan when no HNSW exists");
let out_schema = plan.schema();
assert!(
out_schema.field_with_name(DISTANCE_COLUMN).is_ok(),
"plan output schema missing `{DISTANCE_COLUMN}` — got {:?}",
out_schema
);
}
#[tokio::test]
async fn test_nearest_rejects_invalid_query_shape() {
let schema: SchemaRef = Arc::new(Schema::new(vec![
Field::new("id", DataType::Int32, false),
Field::new(
"vector",
DataType::FixedSizeList(Arc::new(Field::new("item", DataType::Float32, true)), 2),
true,
),
]));
let batch_store = Arc::new(BatchStore::with_capacity(4));
let indexes = Arc::new(IndexStore::new());
let mut scanner =
MemTableScanner::new(batch_store.clone(), indexes.clone(), schema.clone());
let query: Arc<dyn arrow_array::Array> =
Arc::new(arrow_array::Float32Array::from(vec![0.0_f32, 0.0_f32]));
let Err(err) = scanner.nearest("vector", query.as_ref(), 0) else {
panic!("zero-k vector search should fail");
};
assert!(
err.to_string().contains("k must be positive"),
"unexpected zero-k error: {err}"
);
let mut scanner = MemTableScanner::new(batch_store, indexes, schema);
let empty_query: Arc<dyn arrow_array::Array> =
Arc::new(arrow_array::Float32Array::from(Vec::<f32>::new()));
let Err(err) = scanner.nearest("vector", empty_query.as_ref(), 5) else {
panic!("empty vector search should fail");
};
assert!(
err.to_string().contains("non-zero length"),
"unexpected empty-query error: {err}"
);
}
#[tokio::test]
async fn test_create_plan_rejects_vector_and_fts_combination() {
let schema: SchemaRef = Arc::new(Schema::new(vec![
Field::new("id", DataType::Int32, false),
Field::new("text", DataType::Utf8, true),
Field::new(
"vector",
DataType::FixedSizeList(Arc::new(Field::new("item", DataType::Float32, true)), 2),
true,
),
]));
let batch_store = Arc::new(BatchStore::with_capacity(4));
let indexes = Arc::new(IndexStore::new());
let mut scanner = MemTableScanner::new(batch_store, indexes, schema);
let query: Arc<dyn arrow_array::Array> =
Arc::new(arrow_array::Float32Array::from(vec![0.0_f32, 0.0_f32]));
scanner.nearest("vector", query.as_ref(), 5).unwrap();
scanner
.full_text_search(
FullTextSearchQuery::new("needle".to_string())
.with_column("text".to_string())
.unwrap(),
)
.unwrap();
let err = scanner
.create_plan()
.await
.expect_err("vector and FTS search must not be silently combined");
assert!(
err.to_string().contains("vector and full-text search"),
"unexpected combined-search error: {err}"
);
}
#[tokio::test]
async fn test_plan_vector_search_validates_pk_types() {
let schema: SchemaRef = Arc::new(Schema::new(vec![
Field::new("id", DataType::Float64, false),
Field::new(
"vector",
DataType::FixedSizeList(Arc::new(Field::new("item", DataType::Float32, true)), 2),
true,
),
]));
let batch_store = Arc::new(BatchStore::with_capacity(4));
let indexes = Arc::new(IndexStore::new());
let mut scanner = MemTableScanner::new(batch_store, indexes, schema);
scanner.with_pk_columns(vec!["id".to_string()]);
let query: Arc<dyn arrow_array::Array> =
Arc::new(arrow_array::Float32Array::from(vec![0.0_f32, 0.0_f32]));
scanner.nearest("vector", query.as_ref(), 5).unwrap();
let err = scanner
.create_plan()
.await
.expect_err("unsupported vector PK type must be rejected");
assert!(
err.to_string().contains("unsupported type Float64"),
"unexpected error: {err}"
);
}
}