Skip to main content

sochdb_query/executor/
eval.rs

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3//! SQL expression evaluator over Volcano rows.
4//!
5//! Evaluates [`sql::ast::Expr`] nodes against a `(Row, Schema)` context,
6//! producing [`SochValue`] results. Used by Filter, Project, Sort, and
7//! Join operators for predicate and expression evaluation.
8
9use crate::soch_ql::SochValue;
10use crate::sql::ast::{BinaryOperator, ColumnRef, Expr, FunctionCall, Literal, UnaryOperator};
11use super::types::{Row, Schema};
12use sochdb_core::Result;
13
14/// Evaluate an expression against a row, producing a scalar value.
15pub fn eval_expr(expr: &Expr, row: &Row, schema: &Schema) -> Result<SochValue> {
16    match expr {
17        Expr::Literal(lit) => Ok(eval_literal(lit)),
18
19        Expr::Column(col_ref) => eval_column(col_ref, row, schema),
20
21        Expr::BinaryOp { left, op, right } => {
22            let lv = eval_expr(left, row, schema)?;
23            // Short-circuit AND/OR
24            match op {
25                BinaryOperator::And => {
26                    if !value_is_truthy(&lv) {
27                        return Ok(SochValue::Bool(false));
28                    }
29                    let rv = eval_expr(right, row, schema)?;
30                    return Ok(SochValue::Bool(value_is_truthy(&rv)));
31                }
32                BinaryOperator::Or => {
33                    if value_is_truthy(&lv) {
34                        return Ok(SochValue::Bool(true));
35                    }
36                    let rv = eval_expr(right, row, schema)?;
37                    return Ok(SochValue::Bool(value_is_truthy(&rv)));
38                }
39                _ => {}
40            }
41            let rv = eval_expr(right, row, schema)?;
42            eval_binary_op(&lv, *op, &rv)
43        }
44
45        Expr::UnaryOp { op, expr: inner } => {
46            let v = eval_expr(inner, row, schema)?;
47            eval_unary_op(*op, &v)
48        }
49
50        Expr::Function(func) => eval_function(func, row, schema),
51
52        Expr::IsNull { expr: inner, negated } => {
53            let v = eval_expr(inner, row, schema)?;
54            let is_null = matches!(v, SochValue::Null);
55            Ok(SochValue::Bool(if *negated { !is_null } else { is_null }))
56        }
57
58        Expr::Between { expr: inner, low, high, negated } => {
59            let v = eval_expr(inner, row, schema)?;
60            let lo = eval_expr(low, row, schema)?;
61            let hi = eval_expr(high, row, schema)?;
62            let in_range = compare_values(&v, &lo) != Some(std::cmp::Ordering::Less)
63                && compare_values(&v, &hi) != Some(std::cmp::Ordering::Greater);
64            Ok(SochValue::Bool(if *negated { !in_range } else { in_range }))
65        }
66
67        Expr::InList { expr: inner, list, negated } => {
68            let v = eval_expr(inner, row, schema)?;
69            let mut found = false;
70            for item in list {
71                let iv = eval_expr(item, row, schema)?;
72                if compare_values(&v, &iv) == Some(std::cmp::Ordering::Equal) {
73                    found = true;
74                    break;
75                }
76            }
77            Ok(SochValue::Bool(if *negated { !found } else { found }))
78        }
79
80        Expr::Like { expr: inner, pattern, negated, .. } => {
81            let v = eval_expr(inner, row, schema)?;
82            let p = eval_expr(pattern, row, schema)?;
83            let matched = match (&v, &p) {
84                (SochValue::Text(s), SochValue::Text(pat)) => like_match(s, pat),
85                _ => false,
86            };
87            Ok(SochValue::Bool(if *negated { !matched } else { matched }))
88        }
89
90        Expr::Case { operand, conditions, else_result } => {
91            eval_case(operand.as_deref(), conditions, else_result.as_deref(), row, schema)
92        }
93
94        Expr::Cast { expr: inner, data_type: _ } => {
95            // Simplified: just evaluate the inner expression
96            // Full type coercion can be added later
97            eval_expr(inner, row, schema)
98        }
99
100        Expr::Array(elements) => {
101            let mut vals = Vec::with_capacity(elements.len());
102            for e in elements {
103                vals.push(eval_expr(e, row, schema)?);
104            }
105            Ok(SochValue::Array(vals))
106        }
107
108        Expr::Tuple(elements) => {
109            let mut vals = Vec::with_capacity(elements.len());
110            for e in elements {
111                vals.push(eval_expr(e, row, schema)?);
112            }
113            Ok(SochValue::Array(vals))
114        }
115
116        // Placeholders should have been resolved before evaluation
117        Expr::Placeholder(_) => Err(sochdb_core::SochDBError::Internal(
118            "Unresolved placeholder in expression".into(),
119        )),
120
121        // Vector literal
122        Expr::Vector(floats) => {
123            Ok(SochValue::Array(
124                floats.iter().map(|f| SochValue::Float(*f as f64)).collect(),
125            ))
126        }
127
128        // Not yet supported in executor
129        Expr::Subquery(_)
130        | Expr::Exists(_)
131        | Expr::InSubquery { .. }
132        | Expr::VectorSearch { .. }
133        | Expr::JsonAccess { .. }
134        | Expr::ContextWindow { .. }
135        | Expr::Subscript { .. }
136        | Expr::RecordId { .. } => Err(sochdb_core::SochDBError::Internal(
137            format!("Expression type not yet supported in executor: {:?}", std::mem::discriminant(expr)),
138        )),
139    }
140}
141
142/// Evaluate a predicate expression, returning a boolean.
143pub fn eval_predicate(expr: &Expr, row: &Row, schema: &Schema) -> Result<bool> {
144    let v = eval_expr(expr, row, schema)?;
145    Ok(value_is_truthy(&v))
146}
147
148// ============================================================================
149// Internal helpers
150// ============================================================================
151
152fn eval_literal(lit: &Literal) -> SochValue {
153    match lit {
154        Literal::Null => SochValue::Null,
155        Literal::Boolean(b) => SochValue::Bool(*b),
156        Literal::Integer(i) => SochValue::Int(*i),
157        Literal::Float(f) => SochValue::Float(*f),
158        Literal::String(s) => SochValue::Text(s.clone()),
159        Literal::Blob(b) => SochValue::Binary(b.clone()),
160    }
161}
162
163fn eval_column(col: &ColumnRef, row: &Row, schema: &Schema) -> Result<SochValue> {
164    let idx = schema
165        .index_of_qualified(col.table.as_deref(), &col.column)
166        .ok_or_else(|| {
167            sochdb_core::SochDBError::Internal(format!(
168                "Column '{}' not found in schema {:?}",
169                col.column,
170                schema.column_names()
171            ))
172        })?;
173    Ok(row.get(idx).cloned().unwrap_or(SochValue::Null))
174}
175
176fn eval_binary_op(lv: &SochValue, op: BinaryOperator, rv: &SochValue) -> Result<SochValue> {
177    // NULL propagation for most ops
178    if matches!(lv, SochValue::Null) || matches!(rv, SochValue::Null) {
179        match op {
180            BinaryOperator::Eq | BinaryOperator::Ne | BinaryOperator::Lt
181            | BinaryOperator::Le | BinaryOperator::Gt | BinaryOperator::Ge => {
182                // NULL compared to anything is NULL (SQL three-valued logic)
183                return Ok(SochValue::Null);
184            }
185            _ => return Ok(SochValue::Null),
186        }
187    }
188
189    match op {
190        // Comparison operators
191        BinaryOperator::Eq => Ok(SochValue::Bool(
192            compare_values(lv, rv) == Some(std::cmp::Ordering::Equal),
193        )),
194        BinaryOperator::Ne => Ok(SochValue::Bool(
195            compare_values(lv, rv) != Some(std::cmp::Ordering::Equal),
196        )),
197        BinaryOperator::Lt => Ok(SochValue::Bool(
198            compare_values(lv, rv) == Some(std::cmp::Ordering::Less),
199        )),
200        BinaryOperator::Le => Ok(SochValue::Bool(
201            compare_values(lv, rv) != Some(std::cmp::Ordering::Greater),
202        )),
203        BinaryOperator::Gt => Ok(SochValue::Bool(
204            compare_values(lv, rv) == Some(std::cmp::Ordering::Greater),
205        )),
206        BinaryOperator::Ge => Ok(SochValue::Bool(
207            compare_values(lv, rv) != Some(std::cmp::Ordering::Less),
208        )),
209
210        // Arithmetic
211        BinaryOperator::Plus => eval_arithmetic(lv, rv, |a, b| a + b, |a, b| a + b),
212        BinaryOperator::Minus => eval_arithmetic(lv, rv, |a, b| a - b, |a, b| a - b),
213        BinaryOperator::Multiply => eval_arithmetic(lv, rv, |a, b| a * b, |a, b| a * b),
214        BinaryOperator::Divide => {
215            // Division by zero check
216            match rv {
217                SochValue::Int(0) | SochValue::UInt(0) => {
218                    return Err(sochdb_core::SochDBError::Internal("Division by zero".into()));
219                }
220                SochValue::Float(f) if *f == 0.0 => {
221                    return Err(sochdb_core::SochDBError::Internal("Division by zero".into()));
222                }
223                _ => {}
224            }
225            eval_arithmetic(lv, rv, |a, b| a / b, |a, b| a / b)
226        }
227        BinaryOperator::Modulo => {
228            match rv {
229                SochValue::Int(0) | SochValue::UInt(0) => {
230                    return Err(sochdb_core::SochDBError::Internal("Division by zero".into()));
231                }
232                _ => {}
233            }
234            eval_arithmetic(lv, rv, |a, b| a % b, |a, b| a % b)
235        }
236
237        // String concat
238        BinaryOperator::Concat => {
239            let ls = value_to_string(lv);
240            let rs = value_to_string(rv);
241            Ok(SochValue::Text(format!("{}{}", ls, rs)))
242        }
243
244        // LIKE handled separately above in eval_expr
245        BinaryOperator::Like => {
246            match (lv, rv) {
247                (SochValue::Text(s), SochValue::Text(p)) => {
248                    Ok(SochValue::Bool(like_match(s, p)))
249                }
250                _ => Ok(SochValue::Bool(false)),
251            }
252        }
253
254        // Bitwise ops
255        BinaryOperator::BitAnd => eval_bitwise(lv, rv, |a, b| a & b),
256        BinaryOperator::BitOr => eval_bitwise(lv, rv, |a, b| a | b),
257        BinaryOperator::BitXor => eval_bitwise(lv, rv, |a, b| a ^ b),
258        BinaryOperator::LeftShift => eval_bitwise(lv, rv, |a, b| a << b),
259        BinaryOperator::RightShift => eval_bitwise(lv, rv, |a, b| a >> b),
260
261        // AND/OR handled above with short-circuit
262        BinaryOperator::And | BinaryOperator::Or => unreachable!(),
263
264        // Graph traversal — requires graph execution engine (not yet implemented)
265        BinaryOperator::GraphRight | BinaryOperator::GraphLeft | BinaryOperator::GraphBi => {
266            Err(sochdb_core::SochDBError::Internal(
267                "Graph traversal operators (-> <- <->) not yet supported in scalar evaluation".into(),
268            ))
269        }
270    }
271}
272
273fn eval_unary_op(op: UnaryOperator, v: &SochValue) -> Result<SochValue> {
274    match op {
275        UnaryOperator::Minus => match v {
276            SochValue::Int(i) => Ok(SochValue::Int(-i)),
277            SochValue::Float(f) => Ok(SochValue::Float(-f)),
278            SochValue::Null => Ok(SochValue::Null),
279            _ => Err(sochdb_core::SochDBError::Internal("Cannot negate non-numeric value".into())),
280        },
281        UnaryOperator::Plus => Ok(v.clone()),
282        UnaryOperator::Not => Ok(SochValue::Bool(!value_is_truthy(v))),
283        UnaryOperator::BitNot => match v {
284            SochValue::Int(i) => Ok(SochValue::Int(!i)),
285            SochValue::Null => Ok(SochValue::Null),
286            _ => Err(sochdb_core::SochDBError::Internal("Cannot bitwise-NOT non-integer".into())),
287        },
288    }
289}
290
291fn eval_function(func: &FunctionCall, row: &Row, schema: &Schema) -> Result<SochValue> {
292    let name = func.name.name().to_uppercase();
293
294    // Evaluate arguments
295    let args: Vec<SochValue> = func
296        .args
297        .iter()
298        .map(|a| eval_expr(a, row, schema))
299        .collect::<Result<Vec<_>>>()?;
300
301    match name.as_str() {
302        "COALESCE" => {
303            for a in &args {
304                if !matches!(a, SochValue::Null) {
305                    return Ok(a.clone());
306                }
307            }
308            Ok(SochValue::Null)
309        }
310        "NULLIF" => {
311            if args.len() == 2 && compare_values(&args[0], &args[1]) == Some(std::cmp::Ordering::Equal) {
312                Ok(SochValue::Null)
313            } else {
314                Ok(args.into_iter().next().unwrap_or(SochValue::Null))
315            }
316        }
317        "ABS" => match args.first() {
318            Some(SochValue::Int(i)) => Ok(SochValue::Int(i.abs())),
319            Some(SochValue::Float(f)) => Ok(SochValue::Float(f.abs())),
320            _ => Ok(SochValue::Null),
321        },
322        "LENGTH" | "LEN" => match args.first() {
323            Some(SochValue::Text(s)) => Ok(SochValue::Int(s.len() as i64)),
324            Some(SochValue::Binary(b)) => Ok(SochValue::Int(b.len() as i64)),
325            _ => Ok(SochValue::Null),
326        },
327        "UPPER" => match args.first() {
328            Some(SochValue::Text(s)) => Ok(SochValue::Text(s.to_uppercase())),
329            _ => Ok(SochValue::Null),
330        },
331        "LOWER" => match args.first() {
332            Some(SochValue::Text(s)) => Ok(SochValue::Text(s.to_lowercase())),
333            _ => Ok(SochValue::Null),
334        },
335        "TRIM" => match args.first() {
336            Some(SochValue::Text(s)) => Ok(SochValue::Text(s.trim().to_string())),
337            _ => Ok(SochValue::Null),
338        },
339        "SUBSTR" | "SUBSTRING" => {
340            match (args.get(0), args.get(1), args.get(2)) {
341                (Some(SochValue::Text(s)), Some(SochValue::Int(start)), len) => {
342                    let start_idx = (*start as usize).saturating_sub(1); // SQL is 1-indexed
343                    let slice = if let Some(SochValue::Int(l)) = len {
344                        let end = start_idx + (*l as usize);
345                        &s[start_idx..end.min(s.len())]
346                    } else {
347                        &s[start_idx..]
348                    };
349                    Ok(SochValue::Text(slice.to_string()))
350                }
351                _ => Ok(SochValue::Null),
352            }
353        }
354        "CONCAT" => {
355            let s: String = args.iter().map(value_to_string).collect::<Vec<_>>().join("");
356            Ok(SochValue::Text(s))
357        }
358        "REPLACE" => {
359            match (args.get(0), args.get(1), args.get(2)) {
360                (Some(SochValue::Text(s)), Some(SochValue::Text(from)), Some(SochValue::Text(to))) => {
361                    Ok(SochValue::Text(s.replace(from.as_str(), to.as_str())))
362                }
363                _ => Ok(SochValue::Null),
364            }
365        }
366        "ROUND" => {
367            match (args.get(0), args.get(1)) {
368                (Some(SochValue::Float(f)), Some(SochValue::Int(digits))) => {
369                    let factor = 10f64.powi(*digits as i32);
370                    Ok(SochValue::Float((f * factor).round() / factor))
371                }
372                (Some(SochValue::Float(f)), None) => Ok(SochValue::Float(f.round())),
373                (Some(SochValue::Int(i)), _) => Ok(SochValue::Int(*i)),
374                _ => Ok(SochValue::Null),
375            }
376        }
377        "FLOOR" => match args.first() {
378            Some(SochValue::Float(f)) => Ok(SochValue::Float(f.floor())),
379            Some(v @ SochValue::Int(_)) => Ok(v.clone()),
380            _ => Ok(SochValue::Null),
381        },
382        "CEIL" | "CEILING" => match args.first() {
383            Some(SochValue::Float(f)) => Ok(SochValue::Float(f.ceil())),
384            Some(v @ SochValue::Int(_)) => Ok(v.clone()),
385            _ => Ok(SochValue::Null),
386        },
387        "GREATEST" => {
388            let mut best: Option<SochValue> = None;
389            for a in &args {
390                if matches!(a, SochValue::Null) { continue; }
391                best = Some(match &best {
392                    None => a.clone(),
393                    Some(b) => {
394                        if compare_values(a, b) == Some(std::cmp::Ordering::Greater) {
395                            a.clone()
396                        } else {
397                            b.clone()
398                        }
399                    }
400                });
401            }
402            Ok(best.unwrap_or(SochValue::Null))
403        }
404        "LEAST" => {
405            let mut best: Option<SochValue> = None;
406            for a in &args {
407                if matches!(a, SochValue::Null) { continue; }
408                best = Some(match &best {
409                    None => a.clone(),
410                    Some(b) => {
411                        if compare_values(a, b) == Some(std::cmp::Ordering::Less) {
412                            a.clone()
413                        } else {
414                            b.clone()
415                        }
416                    }
417                });
418            }
419            Ok(best.unwrap_or(SochValue::Null))
420        }
421        // Aggregate function names — evaluated at aggregate operator level, not per-row.
422        // If we get here it's because the query references an aggregate in a non-aggregate context.
423        "COUNT" | "SUM" | "AVG" | "MIN" | "MAX" | "COUNT_DISTINCT" => {
424            Err(sochdb_core::SochDBError::Internal(format!(
425                "Aggregate function {}() cannot be used outside of GROUP BY context",
426                name,
427            )))
428        }
429        _ => Err(sochdb_core::SochDBError::Internal(format!(
430            "Unknown function: {}",
431            name,
432        ))),
433    }
434}
435
436fn eval_case(
437    operand: Option<&Expr>,
438    conditions: &[(Expr, Expr)],
439    else_result: Option<&Expr>,
440    row: &Row,
441    schema: &Schema,
442) -> Result<SochValue> {
443    if let Some(op) = operand {
444        // Simple CASE: CASE operand WHEN val1 THEN result1 ...
445        let op_val = eval_expr(op, row, schema)?;
446        for (when_expr, then_expr) in conditions {
447            let when_val = eval_expr(when_expr, row, schema)?;
448            if compare_values(&op_val, &when_val) == Some(std::cmp::Ordering::Equal) {
449                return eval_expr(then_expr, row, schema);
450            }
451        }
452    } else {
453        // Searched CASE: CASE WHEN cond1 THEN result1 ...
454        for (when_expr, then_expr) in conditions {
455            if eval_predicate(when_expr, row, schema)? {
456                return eval_expr(then_expr, row, schema);
457            }
458        }
459    }
460    match else_result {
461        Some(e) => eval_expr(e, row, schema),
462        None => Ok(SochValue::Null),
463    }
464}
465
466// ============================================================================
467// Comparison and type coercion utilities
468// ============================================================================
469
470/// Compare two SochValues, returning ordering if comparable.
471pub fn compare_values(a: &SochValue, b: &SochValue) -> Option<std::cmp::Ordering> {
472    use std::cmp::Ordering;
473
474    match (a, b) {
475        (SochValue::Null, SochValue::Null) => Some(Ordering::Equal),
476        (SochValue::Null, _) | (_, SochValue::Null) => None,
477        (SochValue::Bool(a), SochValue::Bool(b)) => a.partial_cmp(b),
478        (SochValue::Int(a), SochValue::Int(b)) => a.partial_cmp(b),
479        (SochValue::UInt(a), SochValue::UInt(b)) => a.partial_cmp(b),
480        (SochValue::Float(a), SochValue::Float(b)) => a.partial_cmp(b),
481        (SochValue::Text(a), SochValue::Text(b)) => a.partial_cmp(b),
482
483        // Cross-type numeric comparisons
484        (SochValue::Int(a), SochValue::Float(b)) => (*a as f64).partial_cmp(b),
485        (SochValue::Float(a), SochValue::Int(b)) => a.partial_cmp(&(*b as f64)),
486        (SochValue::Int(a), SochValue::UInt(b)) => (*a as i128).partial_cmp(&(*b as i128)),
487        (SochValue::UInt(a), SochValue::Int(b)) => (*a as i128).partial_cmp(&(*b as i128)),
488        (SochValue::UInt(a), SochValue::Float(b)) => (*a as f64).partial_cmp(b),
489        (SochValue::Float(a), SochValue::UInt(b)) => a.partial_cmp(&(*b as f64)),
490
491        _ => None,
492    }
493}
494
495/// SQL LIKE pattern matching (% and _ wildcards).
496fn like_match(s: &str, pattern: &str) -> bool {
497    let s_chars: Vec<char> = s.chars().collect();
498    let p_chars: Vec<char> = pattern.chars().collect();
499    like_match_impl(&s_chars, &p_chars, 0, 0)
500}
501
502fn like_match_impl(s: &[char], p: &[char], si: usize, pi: usize) -> bool {
503    if pi == p.len() {
504        return si == s.len();
505    }
506    match p[pi] {
507        '%' => {
508            // % matches zero or more characters
509            for i in si..=s.len() {
510                if like_match_impl(s, p, i, pi + 1) {
511                    return true;
512                }
513            }
514            false
515        }
516        '_' => {
517            // _ matches exactly one character
518            si < s.len() && like_match_impl(s, p, si + 1, pi + 1)
519        }
520        c => {
521            si < s.len()
522                && (s[si] == c || s[si].to_lowercase().eq(c.to_lowercase()))
523                && like_match_impl(s, p, si + 1, pi + 1)
524        }
525    }
526}
527
528/// Check if a value is truthy (for predicate evaluation).
529pub fn value_is_truthy(v: &SochValue) -> bool {
530    match v {
531        SochValue::Bool(b) => *b,
532        SochValue::Int(i) => *i != 0,
533        SochValue::UInt(u) => *u != 0,
534        SochValue::Float(f) => *f != 0.0,
535        SochValue::Text(s) => !s.is_empty(),
536        SochValue::Null => false,
537        _ => true,
538    }
539}
540
541/// Convert a SochValue to a display string.
542fn value_to_string(v: &SochValue) -> String {
543    match v {
544        SochValue::Null => "NULL".to_string(),
545        SochValue::Bool(b) => b.to_string(),
546        SochValue::Int(i) => i.to_string(),
547        SochValue::UInt(u) => u.to_string(),
548        SochValue::Float(f) => f.to_string(),
549        SochValue::Text(s) => s.clone(),
550        SochValue::Binary(b) => format!("0x{}", hex::encode(b)),
551        SochValue::Array(a) => format!("[{}]", a.iter().map(value_to_string).collect::<Vec<_>>().join(",")),
552    }
553}
554
555fn eval_arithmetic<F, G>(lv: &SochValue, rv: &SochValue, int_op: F, float_op: G) -> Result<SochValue>
556where
557    F: Fn(i64, i64) -> i64,
558    G: Fn(f64, f64) -> f64,
559{
560    match (lv, rv) {
561        (SochValue::Int(a), SochValue::Int(b)) => Ok(SochValue::Int(int_op(*a, *b))),
562        (SochValue::Float(a), SochValue::Float(b)) => Ok(SochValue::Float(float_op(*a, *b))),
563        (SochValue::Int(a), SochValue::Float(b)) => Ok(SochValue::Float(float_op(*a as f64, *b))),
564        (SochValue::Float(a), SochValue::Int(b)) => Ok(SochValue::Float(float_op(*a, *b as f64))),
565        (SochValue::UInt(a), SochValue::UInt(b)) => Ok(SochValue::Int(int_op(*a as i64, *b as i64))),
566        (SochValue::Int(a), SochValue::UInt(b)) => Ok(SochValue::Int(int_op(*a, *b as i64))),
567        (SochValue::UInt(a), SochValue::Int(b)) => Ok(SochValue::Int(int_op(*a as i64, *b))),
568        (SochValue::UInt(a), SochValue::Float(b)) => Ok(SochValue::Float(float_op(*a as f64, *b))),
569        (SochValue::Float(a), SochValue::UInt(b)) => Ok(SochValue::Float(float_op(*a, *b as f64))),
570        _ => Err(sochdb_core::SochDBError::Internal(format!(
571            "Cannot perform arithmetic on {:?} and {:?}",
572            std::mem::discriminant(lv),
573            std::mem::discriminant(rv),
574        ))),
575    }
576}
577
578fn eval_bitwise<F>(lv: &SochValue, rv: &SochValue, op: F) -> Result<SochValue>
579where
580    F: Fn(i64, i64) -> i64,
581{
582    match (lv, rv) {
583        (SochValue::Int(a), SochValue::Int(b)) => Ok(SochValue::Int(op(*a, *b))),
584        (SochValue::UInt(a), SochValue::UInt(b)) => Ok(SochValue::Int(op(*a as i64, *b as i64))),
585        _ => Err(sochdb_core::SochDBError::Internal("Bitwise ops require integer operands".into())),
586    }
587}
588
589// Inline hex encoding to avoid extra dependency
590mod hex {
591    pub fn encode(data: &[u8]) -> String {
592        data.iter().map(|b| format!("{:02x}", b)).collect()
593    }
594}