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