use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use surrealdb_types::{SqlFormat, ToSql, write_sql};
use crate::catalog::{FieldDefinition, Index, Permission};
use crate::exec::physical_expr::{EvalContext, PhysicalExpr};
use crate::exec::{AccessMode, BoxFut, ContextLevel};
use crate::expr::idiom::Idiom;
use crate::expr::operator::{BinaryOperator, MatchesOperator, PrefixOperator};
use crate::expr::part::Part;
use crate::expr::visit::{Visit, Visitor};
use crate::expr::{Expr, FlowResult, Kind};
use crate::iam::Action;
use crate::idx::ft::fulltext::{FullTextIndex, QueryTerms};
use crate::idx::{Error, IndexKeyBase};
use crate::kvs::index::filter_online_indexes;
use crate::legacy::analyzer_function::LegacyAnalyzerFunction;
use crate::val::{TableName, Value};
#[derive(Debug)]
pub(crate) struct MatchesScope {
pub(crate) allowlist: HashSet<Expr>,
pub(crate) executor_tables: Arc<[TableName]>,
}
pub(crate) fn collect_cond_matches(expr: &Expr, out: &mut HashSet<Expr>) {
match expr {
Expr::Binary {
left,
op,
right,
} => {
if matches!(op, BinaryOperator::Matches(_)) {
out.insert(expr.clone());
}
collect_cond_matches(left, out);
collect_cond_matches(right, out);
}
Expr::Prefix {
op: PrefixOperator::Not,
expr: inner,
} => {
collect_cond_matches(inner, out);
}
_ => {}
}
}
pub(crate) fn first_unregistered_cond_matches(
cond: &Expr,
allowlist: &HashSet<Expr>,
) -> Option<Expr> {
struct Unregistered(Expr);
struct UnregisteredMatches<'a> {
allowlist: &'a HashSet<Expr>,
}
impl Visitor for UnregisteredMatches<'_> {
type Error = Unregistered;
fn visit_expr(&mut self, e: &Expr) -> Result<(), Self::Error> {
match e {
Expr::Binary {
op: BinaryOperator::Matches(_),
..
} if !self.allowlist.contains(e) => Err(Unregistered(e.clone())),
Expr::Select(_)
| Expr::Create(_)
| Expr::Update(_)
| Expr::Upsert(_)
| Expr::Delete(_)
| Expr::Relate(_)
| Expr::Insert(_)
| Expr::Define(_)
| Expr::Remove(_)
| Expr::Rebuild(_)
| Expr::Alter(_)
| Expr::Info(_)
| Expr::Foreach(_)
| Expr::Let(_)
| Expr::Sleep(_)
| Expr::Return(_)
| Expr::Explain {
..
}
| Expr::Match(_)
| Expr::Break
| Expr::Continue
| Expr::Closure(_) => Ok(()),
_ => e.visit(self),
}
}
fn visit_part(&mut self, part: &Part) -> Result<(), Self::Error> {
match part {
Part::Where(_) | Part::Lookup(_) => Ok(()),
_ => part.visit(self),
}
}
}
let mut walker = UnregisteredMatches {
allowlist,
};
match walker.visit_expr(cond) {
Ok(()) => None,
Err(Unregistered(e)) => Some(e),
}
}
enum MatchTarget {
Local(FullTextIndex, QueryTerms),
Remote(FullTextIndex, QueryTerms),
Unresolved,
}
#[derive(Debug, Clone)]
pub(crate) struct MatchProbe {
pub(crate) idiom: Idiom,
pub(crate) query: String,
}
pub struct MatchesOp {
pub(crate) left: Arc<dyn PhysicalExpr>,
pub(crate) right: Arc<dyn PhysicalExpr>,
pub(crate) operator: MatchesOperator,
pub(crate) probe: Option<MatchProbe>,
registered: bool,
executor_tables: Arc<[TableName]>,
resolution: tokio::sync::Mutex<HashMap<TableName, Arc<MatchTarget>>>,
}
impl MatchesOp {
pub(crate) fn new(
left: Arc<dyn PhysicalExpr>,
right: Arc<dyn PhysicalExpr>,
operator: MatchesOperator,
probe: Option<MatchProbe>,
registered: bool,
executor_tables: Arc<[TableName]>,
) -> Self {
Self {
left,
right,
operator,
probe,
registered,
executor_tables,
resolution: tokio::sync::Mutex::new(HashMap::new()),
}
}
fn no_index_error(&self) -> FlowResult<Value> {
Err(anyhow::Error::new(Error::NoIndexFoundForMatch {
exp: self.to_sql(),
})
.into())
}
async fn resolve_for_table(
&self,
ctx: &EvalContext<'_>,
table: &TableName,
) -> Result<Arc<MatchTarget>, anyhow::Error> {
{
let cache = self.resolution.lock().await;
if let Some(t) = cache.get(table) {
return Ok(Arc::clone(t));
}
}
let resolved = Arc::new(self.resolve_uncached(ctx, table).await?);
self.resolution.lock().await.insert(table.clone(), Arc::clone(&resolved));
Ok(resolved)
}
async fn resolve_uncached(
&self,
ctx: &EvalContext<'_>,
table: &TableName,
) -> Result<MatchTarget, anyhow::Error> {
use crate::catalog::providers::TableProvider;
let Some(probe) = self.probe.as_ref() else {
return Ok(MatchTarget::Unresolved);
};
let frozen = ctx.exec_ctx.ctx();
let root = ctx.exec_ctx.root();
let opt = root
.options
.as_ref()
.ok_or_else(|| anyhow::anyhow!("MatchesOp requires Options context"))?;
let tx = ctx.txn();
let db_ctx = ctx
.exec_ctx
.database()
.map_err(|e| anyhow::anyhow!("MatchesOp requires database context: {}", e))?;
let ns_id = db_ctx.ns_ctx.ns.namespace_id;
let db_id = db_ctx.db.database_id;
let version = ctx.exec_ctx.version_stamp();
let check_perms = crate::exec::permission::should_check_perms(db_ctx, Action::View)?;
let fields = tx.all_tb_fields(ns_id, db_id, table, version).await?;
let indexes = tx.all_tb_indexes(ns_id, db_id, table, version).await?;
let indexes = if version.is_none() {
filter_online_indexes(tx.as_ref(), ns_id, db_id, indexes).await?
} else {
indexes
};
let local = indexes.iter().find(|idx| {
matches!(&idx.index, Index::FullText(_))
&& idx.cols.contains(&probe.idiom)
&& !(check_perms && index_columns_touch_restricted(&idx.cols, &fields))
});
if let Some(index_def) = local {
let (fti, qt) = self
.open_index(ctx, table.clone(), index_def, &probe.query, frozen, opt, &tx)
.await?;
return Ok(MatchTarget::Local(fti, qt));
}
if probe.idiom.0.len() > 1 {
for field in fields.iter() {
let Some(Kind::Record(targets)) = &field.field_kind else {
continue;
};
if !probe.idiom.starts_with(&field.name.0) {
continue;
}
let remote_field = &probe.idiom.0[field.name.0.len()..];
if remote_field.is_empty() {
break;
}
let mut resolved = None;
for target in targets {
match self
.resolve_remote_target(
ctx,
target,
remote_field,
&probe.query,
check_perms,
frozen,
opt,
&tx,
ns_id,
db_id,
)
.await?
{
Some(r) => resolved = Some(r),
None => break,
}
}
return Ok(match resolved {
Some((fti, qt)) => MatchTarget::Remote(fti, qt),
None => MatchTarget::Unresolved,
});
}
}
Ok(MatchTarget::Unresolved)
}
#[expect(clippy::too_many_arguments)]
async fn resolve_remote_target(
&self,
ctx: &EvalContext<'_>,
target: &TableName,
remote_field: &[Part],
query: &str,
check_perms: bool,
frozen: &crate::ctx::FrozenContext,
opt: &crate::dbs::Options,
tx: &Arc<crate::kvs::Transaction>,
ns_id: crate::catalog::NamespaceId,
db_id: crate::catalog::DatabaseId,
) -> Result<Option<(FullTextIndex, QueryTerms)>, anyhow::Error> {
use crate::catalog::providers::TableProvider;
let version = ctx.exec_ctx.version_stamp();
let fields = tx.all_tb_fields(ns_id, db_id, target, version).await?;
let indexes = tx.all_tb_indexes(ns_id, db_id, target, version).await?;
let remote_idiom = Idiom::from(remote_field.to_vec());
let index_def = indexes.iter().find(|idx| {
matches!(&idx.index, Index::FullText(_))
&& !idx.prepare_remove
&& idx.cols.first().is_some_and(|col| col == &remote_idiom)
&& !(check_perms && index_columns_touch_restricted(&idx.cols, &fields))
});
match index_def {
Some(def) => {
let (fti, qt) =
self.open_index(ctx, target.clone(), def, query, frozen, opt, tx).await?;
Ok(Some((fti, qt)))
}
None => Ok(None),
}
}
#[expect(clippy::too_many_arguments)]
async fn open_index(
&self,
ctx: &EvalContext<'_>,
table: TableName,
index_def: &crate::catalog::IndexDefinition,
query: &str,
frozen: &crate::ctx::FrozenContext,
opt: &crate::dbs::Options,
tx: &Arc<crate::kvs::Transaction>,
) -> Result<(FullTextIndex, QueryTerms), anyhow::Error> {
let ft_params = match &index_def.index {
Index::FullText(params) => params,
_ => unreachable!("Caller checked for FullText"),
};
let db_ctx = ctx
.exec_ctx
.database()
.map_err(|e| anyhow::anyhow!("MatchesOp requires database context: {}", e))?;
let ikb = IndexKeyBase::new(
db_ctx.ns_ctx.ns.namespace_id,
db_ctx.db.database_id,
table,
index_def.index_id,
);
let fti = FullTextIndex::new(
frozen.get_index_stores(),
tx.as_ref(),
ikb,
ft_params,
&frozen.config.idx.file_allowlist,
index_def.format_version,
)
.await?;
let query_terms = {
let az_fn = LegacyAnalyzerFunction::new(frozen, opt);
let mut stack = reblessive::TreeStack::new();
stack
.enter(|stk| fti.extract_querying_terms(stk, frozen, &az_fn, query.to_owned()))
.finish()
.await?
};
Ok((fti, query_terms))
}
}
impl PhysicalExpr for MatchesOp {
fn name(&self) -> &'static str {
"MatchesOp"
}
fn as_any(&self) -> &dyn std::any::Any {
self
}
fn required_context(&self) -> ContextLevel {
let children = self.left.required_context().max(self.right.required_context());
children.max(ContextLevel::Root)
}
fn evaluate<'a>(&'a self, ctx: EvalContext<'a>) -> BoxFut<'a, FlowResult<Value>> {
Box::pin(async move {
let Some(rid) = extract_record_id(ctx.current_value) else {
return Ok(Value::Bool(false));
};
if !self.executor_tables.contains(&rid.table) {
return Ok(Value::Bool(false));
}
if !self.registered {
return self.no_index_error();
}
let target = self.resolve_for_table(&ctx, &rid.table).await?;
match target.as_ref() {
MatchTarget::Unresolved => self.no_index_error(),
MatchTarget::Local(fti, qt) => {
if qt.is_empty() {
return Ok(Value::Bool(false));
}
let tx = ctx.txn();
let matches = match fti.get_doc_id(&tx, &rid).await? {
Some(doc_id) => qt.contains_doc(doc_id),
None => false,
};
Ok(Value::Bool(matches))
}
MatchTarget::Remote(fti, qt) => {
if qt.is_empty() {
return Ok(Value::Bool(false));
}
let value = self.left.evaluate(ctx.clone()).await?;
let frozen = ctx.exec_ctx.ctx();
let root = ctx.exec_ctx.root();
let opt = root
.options
.as_ref()
.ok_or_else(|| anyhow::anyhow!("MatchesOp requires Options context"))?;
let matches = {
let az_fn = LegacyAnalyzerFunction::new(frozen, opt);
let mut stack = reblessive::TreeStack::new();
stack
.enter(|stk| {
fti.matches_value(stk, &az_fn, qt, self.operator.operator, value)
})
.finish()
.await?
};
Ok(Value::Bool(matches))
}
}
})
}
fn access_mode(&self) -> AccessMode {
self.left.access_mode().combine(self.right.access_mode())
}
}
impl ToSql for MatchesOp {
fn fmt_sql(&self, f: &mut String, fmt: SqlFormat) {
write_sql!(f, fmt, "{} {} {}", self.left, self.operator, self.right);
}
}
impl Clone for MatchesOp {
fn clone(&self) -> Self {
Self {
left: Arc::clone(&self.left),
right: Arc::clone(&self.right),
operator: self.operator.clone(),
probe: self.probe.clone(),
registered: self.registered,
executor_tables: Arc::clone(&self.executor_tables),
resolution: tokio::sync::Mutex::new(HashMap::new()),
}
}
}
impl std::fmt::Debug for MatchesOp {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("MatchesOp")
.field("probe", &self.probe)
.field("operator", &self.operator)
.field("registered", &self.registered)
.finish()
}
}
fn index_columns_touch_restricted(cols: &[Idiom], fields: &[FieldDefinition]) -> bool {
cols.iter().any(|col| {
fields.iter().any(|field| {
col.starts_with(&field.name.0) && !matches!(field.select_permission, Permission::Full)
})
})
}
fn extract_record_id(value: Option<&Value>) -> Option<crate::val::RecordId> {
match value? {
Value::Object(obj) => match obj.get("id") {
Some(Value::RecordId(rid)) => Some(rid.clone()),
_ => None,
},
Value::RecordId(rid) => Some(rid.clone()),
_ => None,
}
}