Skip to main content

akar_processor/physical/write_ops/
set.rs

1//! Auto-extracted from physical_operator.rs
2use crate::expression_evaluator::ExpressionEvaluator;
3use crate::physical::types::{OperatorResult, PhysicalOperatorExec};
4use crate::physical::write_ops::delete::{ast_constant_to_value, row_id_column_index};
5use akar_common::arrow_vector::VectorAccess;
6use akar_common::types::Value;
7use akar_common::types::{PhysicalTypeID, physical_type_from_logical};
8use akar_common::vector::{DataChunk, ValueVector};
9use akar_function::registry::FunctionRegistry;
10use akar_function::scalar::evaluate_scalar;
11use akar_parser::ast::{BinaryOp, Expression};
12use akar_planner::logical_operator::SetItem;
13use akar_storage::table::{ColumnDefinition, TableCatalog};
14use akar_transaction::UndoRecord;
15use std::sync::{Arc, Mutex};
16
17// ==================== Set ====================
18
19/// Physical operator for SET — updates properties on matched rows.
20///
21/// All items of a SET clause are evaluated against the SAME pre-update
22/// snapshot of the table, then written (atomic semantics, P53.17). This means
23/// `SET n.a = n.a + 1, n.b = n.a * 10` computes both RHS values from the
24/// pre-update `n.a`, matching Cypher/Neo4j behavior.
25pub struct PhysicalSet {
26    pub table_name: String,
27    pub table_id: u64,
28    pub is_node: bool,
29    pub items: Vec<SetItem>,
30    pub table_catalog: Arc<TableCatalog>,
31    /// Active transaction id (P52.18).
32    pub txn_id: Option<u64>,
33    /// Undo sink for rollback records (P52.18).
34    pub undo_sink: Option<Arc<Mutex<Vec<UndoRecord>>>>,
35    /// Function registry for evaluating non-constant SET value expressions
36    /// (arithmetic, property reads, function calls) against old row data (P53.17).
37    pub function_registry: Option<Arc<Mutex<FunctionRegistry>>>,
38    /// True when SET is terminal (no trailing RETURN): empty match reports a
39    /// `count=0` row. False when RETURN follows: empty match must flow 0 rows,
40    /// not a phantom count row (P53.39 / kairos P59.1).
41    pub emit_count: bool,
42}
43
44impl PhysicalOperatorExec for PhysicalSet {
45    fn operator_type(&self) -> &str {
46        "set"
47    }
48
49    fn execute(&self, input: Vec<DataChunk>) -> OperatorResult {
50        // Collect row indices from input chunks. The scan emits the physical
51        // row index as the `<alias>._id` column (last column); reading column 0
52        // would treat the first *property* value as a row index.
53        let mut rows_to_update: Vec<u64> = Vec::new();
54        // For each target row, remember which input (chunk, row) it came from so
55        // the snapshot can carry pipeline-only columns (e.g. an UNWIND variable)
56        // into the SET value expressions (P53.26).
57        let mut source_rows: Vec<(usize, usize)> = Vec::new();
58
59        for (ci, chunk) in input.iter().enumerate() {
60            let row_id_col = row_id_column_index(chunk);
61            for row in 0..chunk.size {
62                if !chunk.fields.is_empty()
63                    && let Some(Value::Int64(val)) = chunk.get_value(row_id_col.unwrap_or(0), row)
64                {
65                    rows_to_update.push(val as u64);
66                    source_rows.push((ci, row));
67                }
68            }
69        }
70
71        if rows_to_update.is_empty() {
72            // No rows matched. A terminal SET (no RETURN) reports "updated: 0"
73            // via a count chunk; a SET that feeds a RETURN must emit NO rows so
74            // the projection produces zero output — not a phantom row with a
75            // default/0 value (P53.39, kairos Finding P59.1).
76            if self.emit_count {
77                return Ok(vec![count_chunk(0)]);
78            }
79            return Ok(Vec::new());
80        }
81
82        // Build ONE pre-update snapshot chunk from the table for all target rows.
83        let snapshot = self.build_snapshot_chunk(&rows_to_update, &input, &source_rows)?;
84
85        // Evaluate every item against the SAME snapshot (P53.17). This reads
86        // true pre-write values: `SET n.x = n.x + 1` increments, and a
87        // multi-item `SET n.a = ..., n.b = n.a * 10` computes `n.b` from the
88        // pre-update `n.a`, matching Cypher/Neo4j semantics.
89        let mut all_values: Vec<Vec<Value>> = Vec::with_capacity(self.items.len());
90        for item in &self.items {
91            all_values.push(self.evaluate_item(item, &snapshot)?);
92        }
93
94        // Apply updates to the table
95        let mut updated = 0u64;
96        if self.is_node {
97            if let Some(mut table) = self.table_catalog.get_node_table_by_name_mut(&self.table_name) {
98                for (item_idx, item) in self.items.iter().enumerate() {
99                    // The binder hardcodes `column_idx: 0` ("resolved by catalog
100                    // lookup") but never resolves it — resolve by name at runtime
101                    // so `SET n.prop = v` writes to `prop`, not column 0.
102                    let col_idx = table
103                        .columns
104                        .iter()
105                        .position(|c| c.name == item.column_name)
106                        .unwrap_or(item.column_idx);
107                    for (i, row_idx) in rows_to_update.iter().enumerate() {
108                        // Capture the pre-update cell for rollback (P52.18).
109                        if let Some(sink) = self.undo_sink.as_ref()
110                            && let Ok(mut u) = sink.lock()
111                        {
112                            let old_data = table.cell_undo_bytes(*row_idx, col_idx);
113                            u.push(UndoRecord::update(self.table_id, *row_idx, col_idx as u32, old_data));
114                        }
115                        if table
116                            .update_cell(*row_idx, col_idx, all_values[item_idx][i].clone())
117                            .is_ok()
118                        {
119                            updated += 1;
120                        }
121                    }
122                }
123            } else {
124                return Err(format!("Node table '{}' not found for SET", self.table_name).into());
125            }
126        } else {
127            if let Some(mut table) = self.table_catalog.get_rel_table_by_name_mut(&self.table_name) {
128                for (item_idx, item) in self.items.iter().enumerate() {
129                    let col_idx = table
130                        .columns
131                        .iter()
132                        .position(|c| c.name == item.column_name)
133                        .unwrap_or(item.column_idx);
134                    for (i, edge_idx) in rows_to_update.iter().enumerate() {
135                        if let Some(sink) = self.undo_sink.as_ref()
136                            && let Ok(mut u) = sink.lock()
137                        {
138                            let old_data = table.edge_cell_undo_bytes(*edge_idx as usize, col_idx);
139                            u.push(UndoRecord::update(self.table_id, *edge_idx, col_idx as u32, old_data));
140                        }
141                        if table
142                            .update_cell(*edge_idx as usize, col_idx, all_values[item_idx][i].clone())
143                            .is_ok()
144                        {
145                            updated += 1;
146                        }
147                    }
148                }
149            } else {
150                return Err(format!("Rel table '{}' not found for SET", self.table_name).into());
151            }
152        }
153
154        tracing::info!("SET: updated {updated} rows in '{}'", self.table_name);
155
156        // Carry the updated rows forward (P53.30): [count, <table columns>,
157        // <pipeline columns>, <_id>]. Column 0 keeps the updated count so
158        // `get_i64(0, 0)` checks stay valid; a following RETURN resolves
159        // `<alias>.prop` against the named table columns instead of the count.
160        let mut output = self.build_output_chunk(&rows_to_update, &input, &source_rows)?;
161        let mut count_v = ValueVector::new(PhysicalTypeID::Int64, rows_to_update.len());
162        count_v.resize(rows_to_update.len());
163        for i in 0..rows_to_update.len() {
164            count_v.set_i64(i, updated as i64);
165        }
166        output
167            .fields
168            .insert(0, akar_common::arrow_vector::ArrowVector::from_legacy(&count_v).array);
169        output.field_types.insert(0, PhysicalTypeID::Int64);
170        output.field_names.insert(0, String::new());
171        Ok(vec![output])
172    }
173}
174
175fn count_chunk(count: u64) -> DataChunk {
176    let mut v = ValueVector::new(PhysicalTypeID::Int64, 1);
177    v.resize(1);
178    v.set_i64(0, count as i64);
179    let arr = akar_common::arrow_vector::ArrowVector::from_legacy(&v).array;
180    DataChunk::new(vec![arr], vec![PhysicalTypeID::Int64])
181}
182
183impl PhysicalSet {
184    /// Build a `DataChunk` of the target rows' pre-update cell values, one
185    /// column per table column, keyed by the physical row offsets in `rows`.
186    /// Pipeline-only columns from the input chunks (e.g. an UNWIND variable)
187    /// are appended so SET value expressions can reference them (P53.26).
188    fn build_snapshot_chunk(
189        &self,
190        rows: &[u64],
191        input: &[DataChunk],
192        source_rows: &[(usize, usize)],
193    ) -> Result<DataChunk, String> {
194        let mut snapshot = if self.is_node {
195            let table = self
196                .table_catalog
197                .get_node_table_by_name(&self.table_name)
198                .ok_or_else(|| format!("Node table '{}' not found for SET", self.table_name))?;
199            build_old_row_chunk(&table.columns, rows, &|row, col| {
200                table.get_value(row as usize, col).cloned()
201            })?
202        } else {
203            let table = self
204                .table_catalog
205                .get_rel_table_by_name(&self.table_name)
206                .ok_or_else(|| format!("Rel table '{}' not found for SET", self.table_name))?;
207            build_old_row_chunk(&table.columns, rows, &|row, col| {
208                table.get_edge_properties(row as usize).get(col).cloned()
209            })?
210        };
211        append_pipeline_columns(&mut snapshot, input, source_rows)?;
212        Ok(snapshot)
213    }
214}
215
216/// Append input-chunk columns that are not table columns (nor the internal
217/// `_id` pseudo-column) to the chunk, aligned by the source row index of each
218/// target row. This makes UNWIND variables visible to later clauses, e.g.
219/// `UNWIND $ids AS iid ... SET n.x = iid` (P53.26).
220pub(crate) fn append_pipeline_columns(
221    snapshot: &mut DataChunk,
222    input: &[DataChunk],
223    source_rows: &[(usize, usize)],
224) -> Result<(), String> {
225    let n = source_rows.len();
226    for (ci, chunk) in input.iter().enumerate() {
227        let mut appended: Vec<(usize, String)> = Vec::new();
228        for (col_idx, name) in chunk.field_names.iter().enumerate() {
229            if name == "_id" || name.ends_with("._id") {
230                continue;
231            }
232            if snapshot.field_names.iter().any(|existing| existing == name) {
233                continue;
234            }
235            // Qualified copies (r.weight) whose base matches a plain snapshot
236            // column (weight) are pre-write stale reads from the pipeline; the
237            // evaluator's bare-name fallback resolves to the fresh table column
238            // instead, so a following `RETURN r.weight` sees the written value
239            // (P53.37a).
240            if let Some((_, base)) = name.rsplit_once('.') {
241                if snapshot.field_names.iter().any(|existing| existing == base) {
242                    continue;
243                }
244            }
245            appended.push((col_idx, name.clone()));
246        }
247        if appended.is_empty() {
248            continue;
249        }
250
251        for (col_idx, name) in appended {
252            // Gather each target row's value from this input chunk.
253            let mut col_values: Vec<Value> = vec![Value::Null; n];
254            for (i, &(cci, rowi)) in source_rows.iter().enumerate() {
255                if cci == ci {
256                    col_values[i] = chunk.get_value(col_idx, rowi).unwrap_or(Value::Null);
257                }
258            }
259            // Build via Arrow so complex values (map/struct) survive the
260            // round-trip; `ValueVector::set_value` rejects them.
261            let phys_type = col_values
262                .iter()
263                .find(|v| !matches!(v, Value::Null))
264                .map(|v| v.physical_type())
265                .unwrap_or(chunk.field_types[col_idx]);
266            let arr = crate::expression_evaluator::build_arrow_from_values(&col_values, phys_type, n)
267                .map_err(|e| e.to_string())?;
268            snapshot.fields.push(arr.array);
269            snapshot.field_types.push(arr.physical_type);
270            snapshot.field_names.push(name);
271        }
272    }
273    Ok(())
274}
275
276impl PhysicalSet {
277    /// Build the SET operator's output chunk: the post-update table columns
278    /// (named, so a following RETURN can resolve `<alias>.<prop>`), the input
279    /// pipeline columns (e.g. UNWIND variables), and the `_id` pseudo-column
280    /// with the physical row indices (so a following write op can re-target the
281    /// same rows). Previously SET returned only a count chunk, so `MATCH ... SET
282    /// ... RETURN n.prop` evaluated the projection against the count (P53.30).
283    fn build_output_chunk(
284        &self,
285        rows: &[u64],
286        input: &[DataChunk],
287        source_rows: &[(usize, usize)],
288    ) -> Result<DataChunk, String> {
289        let mut chunk = if self.is_node {
290            let table = self
291                .table_catalog
292                .get_node_table_by_name(&self.table_name)
293                .ok_or_else(|| format!("Node table '{}' not found for SET", self.table_name))?;
294            build_old_row_chunk(&table.columns, rows, &|row, col| {
295                table.get_value(row as usize, col).cloned()
296            })?
297        } else {
298            let table = self
299                .table_catalog
300                .get_rel_table_by_name(&self.table_name)
301                .ok_or_else(|| format!("Rel table '{}' not found for SET", self.table_name))?;
302            build_old_row_chunk(&table.columns, rows, &|row, col| {
303                table.get_edge_properties(row as usize).get(col).cloned()
304            })?
305        };
306        append_pipeline_columns(&mut chunk, input, source_rows)?;
307        // Append the `_id` pseudo-column (physical row indices) so a following
308        // write op can target the same rows.
309        let mut v = ValueVector::new(PhysicalTypeID::Int64, rows.len());
310        v.resize(rows.len());
311        for (i, r) in rows.iter().enumerate() {
312            v.set_i64(i, *r as i64);
313        }
314        chunk
315            .fields
316            .push(akar_common::arrow_vector::ArrowVector::from_legacy(&v).array);
317        chunk.field_types.push(PhysicalTypeID::Int64);
318        chunk.field_names.push("_id".to_string());
319        Ok(chunk)
320    }
321
322    /// Evaluate one SET item's value expression for every row of the snapshot
323    /// chunk, returning one `Value` per row.
324    fn evaluate_item(&self, item: &SetItem, chunk: &DataChunk) -> Result<Vec<Value>, String> {
325        if let Some(registry) = self.function_registry.as_ref() {
326            let evaluator = ExpressionEvaluator::new(registry.clone());
327            // Arrow-native evaluation so complex literals (list/map) survive the
328            // round-trip: `evaluate` builds a legacy ValueVector with no List
329            // storage, which silently produces `Value::Null` (P53.29).
330            let vec = evaluator
331                .evaluate_to_arrow(&item.value, chunk)
332                .map_err(|e| e.to_string())?;
333            Ok((0..chunk.size)
334                .map(|i| vec.get_value(i).unwrap_or(Value::Null))
335                .collect())
336        } else {
337            Ok((0..chunk.size)
338                .map(|i| evaluate_expression_for_row(&item.value, chunk, i))
339                .collect())
340        }
341    }
342}
343
344/// Build a `DataChunk` of the target rows' cell values, one column per table
345/// column, using the plain column names as `field_names` so the evaluator can
346/// resolve `<alias>.<prop>` and `<prop>` references.
347///
348/// The chunk is built via Arrow (`build_arrow_from_values`) rather than
349/// `ValueVector::set_value` so complex values (map/struct/list, e.g. FLOAT[]
350/// embeddings) survive the round-trip — the legacy vector has no List arm and
351/// silently produced `Value::Null` (P53.29).
352pub(crate) fn build_old_row_chunk(
353    columns: &[ColumnDefinition],
354    rows: &[u64],
355    get_cell: &dyn Fn(u64, usize) -> Option<Value>,
356) -> Result<DataChunk, String> {
357    let n = rows.len();
358    let mut fields = Vec::with_capacity(columns.len());
359    let mut field_types = Vec::with_capacity(columns.len());
360    let mut field_names = Vec::with_capacity(columns.len());
361    for (col_idx, col) in columns.iter().enumerate() {
362        let phys_type = physical_type_from_logical(col.logical_type);
363        let col_values: Vec<Value> = rows
364            .iter()
365            .map(|row| get_cell(*row, col_idx).unwrap_or(Value::Null))
366            .collect();
367        let arr = crate::expression_evaluator::build_arrow_from_values(&col_values, phys_type, n)
368            .map_err(|e| e.to_string())?;
369        fields.push(arr.array);
370        field_types.push(phys_type);
371        field_names.push(col.name.clone());
372    }
373    Ok(DataChunk::new(fields, field_types).with_names(field_names))
374}
375
376/// Simple expression evaluator for SET value expressions against a DataChunk row.
377pub fn evaluate_expression_for_row(
378    expr: &akar_parser::ast::Expression,
379    chunk: &DataChunk,
380    row: usize,
381) -> akar_common::types::Value {
382    match expr {
383        akar_parser::ast::Expression::Constant(c) => match c {
384            akar_parser::ast::Constant::Null => akar_common::types::Value::Null,
385            akar_parser::ast::Constant::Bool(b) => akar_common::types::Value::Bool(*b),
386            akar_parser::ast::Constant::Integer(i) => akar_common::types::Value::Int64(*i),
387            akar_parser::ast::Constant::Float(f) => akar_common::types::Value::Double(*f),
388            akar_parser::ast::Constant::String(s) => akar_common::types::Value::String(s.clone()),
389        },
390        akar_parser::ast::Expression::Variable(name) => chunk
391            .field_names
392            .iter()
393            .position(|n| n == name)
394            .and_then(|i| chunk.get_value(i, row))
395            .unwrap_or(akar_common::types::Value::Null),
396        akar_parser::ast::Expression::PropertyAccess(obj, prop) => {
397            let qualified = match obj.as_ref() {
398                akar_parser::ast::Expression::Variable(var) => format!("{var}.{prop}"),
399                _ => prop.clone(),
400            };
401            if let Some(i) = chunk.field_names.iter().position(|n| *n == qualified) {
402                chunk.get_value(i, row).unwrap_or(akar_common::types::Value::Null)
403            } else if let akar_parser::ast::Expression::Variable(var) = obj.as_ref()
404                && let Some(i) = chunk.field_names.iter().position(|n| n == var)
405            {
406                // P53.26: `row.id` where `row` is a map/struct column — extract
407                // the key. Runs before the bare-name match so a plain `content`
408                // table column does not shadow an UNWIND map variable.
409                let obj_val = chunk.get_value(i, row).unwrap_or(akar_common::types::Value::Null);
410                crate::expression_evaluator::map_property_value(&obj_val, prop)
411            } else if let Some(i) = chunk.field_names.iter().position(|n| *n == *prop) {
412                chunk.get_value(i, row).unwrap_or(akar_common::types::Value::Null)
413            } else {
414                akar_common::types::Value::Null
415            }
416        }
417        akar_parser::ast::Expression::UnaryOp(op, inner) => {
418            let v = evaluate_expression_for_row(inner, chunk, row);
419            match op {
420                akar_parser::ast::UnaryOp::Negate => match v {
421                    akar_common::types::Value::Int64(n) => akar_common::types::Value::Int64(-n),
422                    akar_common::types::Value::Double(n) => akar_common::types::Value::Double(-n),
423                    _ => akar_common::types::Value::Null,
424                },
425                _ => akar_common::types::Value::Null,
426            }
427        }
428        akar_parser::ast::Expression::BinaryOp(op, left, right) => {
429            let l = evaluate_expression_for_row(left, chunk, row);
430            let r = evaluate_expression_for_row(right, chunk, row);
431            binary_value_op(op, &l, &r)
432        }
433        akar_parser::ast::Expression::List(items) => {
434            let vals = items
435                .iter()
436                .map(|i| evaluate_expression_for_row(i, chunk, row))
437                .collect();
438            akar_common::types::Value::List(vals)
439        }
440        akar_parser::ast::Expression::Map(items) => {
441            let entries = items
442                .iter()
443                .map(|(k, v)| {
444                    (
445                        akar_common::types::Value::String(k.clone()),
446                        evaluate_expression_for_row(v, chunk, row),
447                    )
448                })
449                .collect();
450            akar_common::types::Value::Map(entries)
451        }
452        _ => akar_common::types::Value::Null,
453    }
454}
455
456fn as_f64(v: &Value) -> Option<f64> {
457    match v {
458        Value::Int64(n) => Some(*n as f64),
459        Value::Int32(n) => Some(*n as f64),
460        Value::Int128(n) => Some(*n as f64),
461        Value::Double(n) => Some(*n),
462        Value::Float(n) => Some(*n as f64),
463        _ => None,
464    }
465}
466
467/// Minimal binary-operator evaluation used when no function registry is
468/// available (unit-test path). The full evaluator is preferred when a registry
469/// is present.
470fn binary_value_op(op: &BinaryOp, l: &Value, r: &Value) -> Value {
471    match op {
472        BinaryOp::Add => match (l, r) {
473            (Value::String(a), Value::String(b)) => Value::String(format!("{a}{b}")),
474            _ => match (as_f64(l), as_f64(r)) {
475                (Some(a), Some(b)) => {
476                    if matches!(l, Value::Int64(_)) && matches!(r, Value::Int64(_)) {
477                        Value::Int64(a as i64 + b as i64)
478                    } else {
479                        Value::Double(a + b)
480                    }
481                }
482                _ => Value::Null,
483            },
484        },
485        BinaryOp::Subtract => match (as_f64(l), as_f64(r)) {
486            (Some(a), Some(b)) => {
487                if matches!(l, Value::Int64(_)) && matches!(r, Value::Int64(_)) {
488                    Value::Int64(a as i64 - b as i64)
489                } else {
490                    Value::Double(a - b)
491                }
492            }
493            _ => Value::Null,
494        },
495        BinaryOp::Multiply => match (as_f64(l), as_f64(r)) {
496            (Some(a), Some(b)) => {
497                if matches!(l, Value::Int64(_)) && matches!(r, Value::Int64(_)) {
498                    Value::Int64(a as i64 * b as i64)
499                } else {
500                    Value::Double(a * b)
501                }
502            }
503            _ => Value::Null,
504        },
505        BinaryOp::Divide => match (as_f64(l), as_f64(r)) {
506            (Some(a), Some(b)) if b != 0.0 => {
507                if matches!(l, Value::Int64(_)) && matches!(r, Value::Int64(_)) {
508                    Value::Int64(a as i64 / b as i64)
509                } else {
510                    Value::Double(a / b)
511                }
512            }
513            _ => Value::Null,
514        },
515        BinaryOp::Modulo => match (l, r) {
516            (Value::Int64(a), Value::Int64(b)) if *b != 0 => Value::Int64(a % b),
517            _ => Value::Null,
518        },
519        BinaryOp::Equal => Value::Bool(l == r),
520        BinaryOp::NotEqual => Value::Bool(l != r),
521        BinaryOp::LessThan => match (as_f64(l), as_f64(r)) {
522            (Some(a), Some(b)) => Value::Bool(a < b),
523            _ => Value::Null,
524        },
525        BinaryOp::LessThanOrEqual => match (as_f64(l), as_f64(r)) {
526            (Some(a), Some(b)) => Value::Bool(a <= b),
527            _ => Value::Null,
528        },
529        BinaryOp::GreaterThan => match (as_f64(l), as_f64(r)) {
530            (Some(a), Some(b)) => Value::Bool(a > b),
531            _ => Value::Null,
532        },
533        BinaryOp::GreaterThanOrEqual => match (as_f64(l), as_f64(r)) {
534            (Some(a), Some(b)) => Value::Bool(a >= b),
535            _ => Value::Null,
536        },
537        BinaryOp::And => match (l, r) {
538            (Value::Bool(a), Value::Bool(b)) => Value::Bool(*a && *b),
539            _ => Value::Null,
540        },
541        BinaryOp::Or => match (l, r) {
542            (Value::Bool(a), Value::Bool(b)) => Value::Bool(*a || *b),
543            _ => Value::Null,
544        },
545        _ => Value::Null,
546    }
547}
548
549/// Evaluate a constant-only expression (literal or function call over
550/// literals) into a `Value`, using the function registry. Used by the
551/// CREATE DML write path to support expressions like `DATE('2024-01-15')`.
552/// Returns `Value::Null` for expressions that reference variables or
553/// otherwise cannot be folded without a row context.
554pub fn evaluate_constant_expr(expr: &Expression, registry: &FunctionRegistry) -> Value {
555    match expr {
556        Expression::Constant(c) => ast_constant_to_value(c),
557        Expression::List(items) => Value::List(items.iter().map(|i| evaluate_constant_expr(i, registry)).collect()),
558        Expression::Map(items) => Value::Map(
559            items
560                .iter()
561                .map(|(k, v)| (Value::String(k.clone()), evaluate_constant_expr(v, registry)))
562                .collect(),
563        ),
564        Expression::FunctionCall(name, args) => {
565            let arg_values: Vec<Value> = args.iter().map(|a| evaluate_constant_expr(a, registry)).collect();
566            if arg_values.iter().any(|v| matches!(v, Value::Null)) {
567                return Value::Null;
568            }
569            let func = match registry.get_scalar(name).cloned() {
570                Some(f) => f,
571                None => return Value::Null,
572            };
573            evaluate_scalar(&func, &arg_values).unwrap_or(Value::Null)
574        }
575        _ => Value::Null,
576    }
577}