Skip to main content

kyu_binder/
expression_binder.rs

1//! Expression binder — resolves parser AST expressions to bound expressions.
2//!
3//! Transforms `kyu_parser::ast::Expression` → `BoundExpression` using scope
4//! and catalog for name resolution and type inference.
5
6use std::collections::HashMap;
7
8use kyu_catalog::CatalogContent;
9use kyu_common::{KyuError, KyuResult};
10use kyu_expression::bound_expr::BoundExpression;
11use kyu_expression::{
12    FunctionRegistry, coerce_binary_arithmetic, coerce_comparison, coerce_concat, common_type,
13    try_coerce,
14};
15use kyu_parser::ast::{BinaryOp, ComparisonOp, Expression, Literal};
16use kyu_parser::span::Spanned;
17use kyu_types::{LogicalType, TypedValue};
18use smol_str::SmolStr;
19
20use crate::scope::BinderScope;
21
22/// Bind-time context: parameter values and environment variables.
23///
24/// The Cypher evaluator is treated like a virtual machine — it accepts a query
25/// string plus two context maps that are resolved to literals during binding:
26///
27/// - `params`: `$param` placeholders → `TypedValue`
28/// - `env`: `env('KEY')` lookups → `TypedValue`
29pub struct BindContext {
30    pub params: HashMap<SmolStr, TypedValue>,
31    pub env: HashMap<SmolStr, TypedValue>,
32}
33
34impl BindContext {
35    pub fn empty() -> Self {
36        Self {
37            params: HashMap::new(),
38            env: HashMap::new(),
39        }
40    }
41
42    /// Build a context with `params` from a `serde_json::Value` object.
43    ///
44    /// ```ignore
45    /// use serde_json::json;
46    /// let ctx = BindContext::with_params_json(json!({"min_age": 25, "name": "Alice"}));
47    /// ```
48    pub fn with_params_json(params: serde_json::Value) -> Self {
49        Self {
50            params: kyu_types::json_object_to_map(params),
51            env: HashMap::new(),
52        }
53    }
54
55    /// Build a context with `env` from a `serde_json::Value` object.
56    ///
57    /// ```ignore
58    /// use serde_json::json;
59    /// let ctx = BindContext::with_env_json(json!({"DATA_DIR": "/data"}));
60    /// ```
61    pub fn with_env_json(env: serde_json::Value) -> Self {
62        Self {
63            params: HashMap::new(),
64            env: kyu_types::json_object_to_map(env),
65        }
66    }
67
68    /// Build a full context from two `serde_json::Value` objects.
69    pub fn from_json(params: serde_json::Value, env: serde_json::Value) -> Self {
70        Self {
71            params: kyu_types::json_object_to_map(params),
72            env: kyu_types::json_object_to_map(env),
73        }
74    }
75
76    /// Build a context with `params` parsed from a JSON string.
77    ///
78    /// Returns `Err` if the string is not valid JSON.
79    pub fn with_params_str(json: &str) -> Result<Self, serde_json::Error> {
80        Ok(Self {
81            params: kyu_types::json_str_to_map(json)?,
82            env: HashMap::new(),
83        })
84    }
85
86    /// Build a context with `env` parsed from a JSON string.
87    ///
88    /// Returns `Err` if the string is not valid JSON.
89    pub fn with_env_str(json: &str) -> Result<Self, serde_json::Error> {
90        Ok(Self {
91            params: HashMap::new(),
92            env: kyu_types::json_str_to_map(json)?,
93        })
94    }
95
96    /// Build a full context from two JSON strings.
97    ///
98    /// Returns `Err` if either string is not valid JSON.
99    pub fn from_json_str(params: &str, env: &str) -> Result<Self, serde_json::Error> {
100        Ok(Self {
101            params: kyu_types::json_str_to_map(params)?,
102            env: kyu_types::json_str_to_map(env)?,
103        })
104    }
105}
106
107/// Bind a parser expression to a resolved BoundExpression.
108pub fn bind_expression(
109    expr: &Spanned<Expression>,
110    scope: &BinderScope,
111    catalog: &CatalogContent,
112    registry: &FunctionRegistry,
113    ctx: &BindContext,
114) -> KyuResult<BoundExpression> {
115    match &expr.0 {
116        Expression::Literal(lit) => bind_literal(lit),
117
118        Expression::Variable(name) => bind_variable(name, scope),
119
120        Expression::Parameter(name) => match ctx.params.get(name.as_str()) {
121            Some(value) => Ok(BoundExpression::Literal {
122                value: value.clone(),
123                result_type: value.logical_type(),
124            }),
125            None => Err(KyuError::Binder(format!("unresolved parameter '${name}'"))),
126        },
127
128        Expression::Property { object, key } => {
129            bind_property(object, key, scope, catalog, registry, ctx)
130        }
131
132        Expression::FunctionCall {
133            name,
134            distinct,
135            args,
136        } => bind_function_call(name, *distinct, args, scope, catalog, registry, ctx),
137
138        Expression::CountStar => Ok(BoundExpression::CountStar),
139
140        Expression::UnaryOp { op, operand } => {
141            bind_unary_op(*op, operand, scope, catalog, registry, ctx)
142        }
143
144        Expression::BinaryOp { left, op, right } => {
145            bind_binary_op(*op, left, right, scope, catalog, registry, ctx)
146        }
147
148        Expression::Comparison { left, ops } => {
149            bind_comparison(left, ops, scope, catalog, registry, ctx)
150        }
151
152        Expression::IsNull {
153            expr: inner,
154            negated,
155        } => {
156            let bound = bind_expression(inner, scope, catalog, registry, ctx)?;
157            Ok(BoundExpression::IsNull {
158                expr: Box::new(bound),
159                negated: *negated,
160            })
161        }
162
163        Expression::InList {
164            expr: inner,
165            list,
166            negated,
167        } => bind_in_list(inner, list, *negated, scope, catalog, registry, ctx),
168
169        Expression::ListLiteral(elements) => {
170            bind_list_literal(elements, scope, catalog, registry, ctx)
171        }
172
173        Expression::MapLiteral(entries) => bind_map_literal(entries, scope, catalog, registry, ctx),
174
175        Expression::Subscript { expr: inner, index } => {
176            let bound_expr = bind_expression(inner, scope, catalog, registry, ctx)?;
177            let bound_index = bind_expression(index, scope, catalog, registry, ctx)?;
178            let result_type = match bound_expr.result_type() {
179                LogicalType::List(elem) => *elem.clone(),
180                _ => LogicalType::Any,
181            };
182            Ok(BoundExpression::Subscript {
183                expr: Box::new(bound_expr),
184                index: Box::new(bound_index),
185                result_type,
186            })
187        }
188
189        Expression::Slice {
190            expr: inner,
191            from,
192            to,
193        } => {
194            let bound_expr = bind_expression(inner, scope, catalog, registry, ctx)?;
195            let bound_from = from
196                .as_ref()
197                .map(|e| bind_expression(e, scope, catalog, registry, ctx))
198                .transpose()?
199                .map(Box::new);
200            let bound_to = to
201                .as_ref()
202                .map(|e| bind_expression(e, scope, catalog, registry, ctx))
203                .transpose()?
204                .map(Box::new);
205            let result_type = bound_expr.result_type().clone();
206            Ok(BoundExpression::Slice {
207                expr: Box::new(bound_expr),
208                from: bound_from,
209                to: bound_to,
210                result_type,
211            })
212        }
213
214        Expression::Case {
215            operand,
216            whens,
217            else_expr,
218        } => bind_case(operand, whens, else_expr, scope, catalog, registry, ctx),
219
220        Expression::StringOp { left, op, right } => {
221            let bound_left = bind_expression(left, scope, catalog, registry, ctx)?;
222            let bound_right = bind_expression(right, scope, catalog, registry, ctx)?;
223            let bound_left = try_coerce(bound_left, &LogicalType::String)?;
224            let bound_right = try_coerce(bound_right, &LogicalType::String)?;
225            Ok(BoundExpression::StringOp {
226                op: *op,
227                left: Box::new(bound_left),
228                right: Box::new(bound_right),
229            })
230        }
231
232        Expression::HasLabel {
233            expr: inner,
234            labels,
235        } => {
236            let bound = bind_expression(inner, scope, catalog, registry, ctx)?;
237            let mut table_ids = Vec::with_capacity(labels.len());
238            for label in labels {
239                let entry = catalog
240                    .find_by_name(&label.0)
241                    .ok_or_else(|| KyuError::Binder(format!("label '{}' not found", label.0)))?;
242                table_ids.push(entry.table_id());
243            }
244            Ok(BoundExpression::HasLabel {
245                expr: Box::new(bound),
246                table_ids,
247            })
248        }
249
250        Expression::ExistsSubquery(_)
251        | Expression::CountSubquery(_)
252        | Expression::Quantifier { .. }
253        | Expression::ListComprehension { .. } => Err(KyuError::NotImplemented(
254            "subqueries and quantifiers not yet supported in binder".into(),
255        )),
256    }
257}
258
259fn bind_literal(lit: &Literal) -> KyuResult<BoundExpression> {
260    let (value, result_type) = match lit {
261        Literal::Integer(v) => (TypedValue::Int64(*v), LogicalType::Int64),
262        Literal::Float(v) => (TypedValue::Double(*v), LogicalType::Double),
263        Literal::String(s) => (TypedValue::String(s.clone()), LogicalType::String),
264        Literal::Bool(b) => (TypedValue::Bool(*b), LogicalType::Bool),
265        Literal::Null => (TypedValue::Null, LogicalType::Any),
266    };
267    Ok(BoundExpression::Literal { value, result_type })
268}
269
270fn bind_variable(name: &SmolStr, scope: &BinderScope) -> KyuResult<BoundExpression> {
271    let info = scope
272        .resolve(name)
273        .ok_or_else(|| KyuError::Binder(format!("variable '{name}' is not defined")))?;
274    Ok(BoundExpression::Variable {
275        index: info.index,
276        result_type: info.data_type.clone(),
277    })
278}
279
280fn bind_property(
281    object: &Spanned<Expression>,
282    key: &Spanned<SmolStr>,
283    scope: &BinderScope,
284    catalog: &CatalogContent,
285    registry: &FunctionRegistry,
286    ctx: &BindContext,
287) -> KyuResult<BoundExpression> {
288    let bound_object = bind_expression(object, scope, catalog, registry, ctx)?;
289
290    // If the object is a variable bound to a table, resolve the property from catalog.
291    if let BoundExpression::Variable { index, .. } = &bound_object {
292        // Look up the variable in scope to get its table_id.
293        let var_info = scope
294            .current_variables()
295            .iter()
296            .chain(std::iter::empty()) // Just to have an iterator
297            .find(|(_, info)| info.index == *index)
298            .map(|(_, info)| info);
299
300        // Also search all frames via resolve with the variable name.
301        let var_info = var_info.or_else(|| {
302            // We need the name — search scope by index.
303            find_variable_by_index(scope, *index)
304        });
305
306        if let Some(info) = var_info
307            && let Some(table_id) = info.table_id
308        {
309            let entry = catalog.find_by_id(table_id).ok_or_else(|| {
310                KyuError::Binder(format!("table id {table_id:?} not found in catalog"))
311            })?;
312            let prop_name = &key.0;
313            let prop = find_property_on_entry(entry, prop_name)?;
314            return Ok(BoundExpression::Property {
315                object: Box::new(bound_object),
316                property_id: prop.id,
317                property_name: prop.name.clone(),
318                result_type: prop.data_type.clone(),
319            });
320        }
321    }
322
323    // Fallback: if object is Node or Rel type but we couldn't resolve via catalog,
324    // return Any type.
325    Ok(BoundExpression::Property {
326        object: Box::new(bound_object),
327        property_id: kyu_common::id::PropertyId(0),
328        property_name: key.0.clone(),
329        result_type: LogicalType::Any,
330    })
331}
332
333fn find_variable_by_index(scope: &BinderScope, index: u32) -> Option<&crate::scope::VariableInfo> {
334    scope
335        .current_variables()
336        .iter()
337        .find(|(_, info)| info.index == index)
338        .map(|(_, info)| info)
339}
340
341fn find_property_on_entry<'a>(
342    entry: &'a kyu_catalog::CatalogEntry,
343    name: &str,
344) -> KyuResult<&'a kyu_catalog::Property> {
345    let lower = name.to_lowercase();
346    entry
347        .properties()
348        .iter()
349        .find(|p| p.name.to_lowercase() == lower)
350        .ok_or_else(|| {
351            KyuError::Binder(format!(
352                "property '{}' not found on table '{}'",
353                name,
354                entry.name()
355            ))
356        })
357}
358
359fn bind_function_call(
360    name: &[Spanned<SmolStr>],
361    distinct: bool,
362    args: &[Spanned<Expression>],
363    scope: &BinderScope,
364    catalog: &CatalogContent,
365    registry: &FunctionRegistry,
366    ctx: &BindContext,
367) -> KyuResult<BoundExpression> {
368    // Join multi-part name with '.'.
369    let func_name: String = name
370        .iter()
371        .map(|(s, _)| s.as_str())
372        .collect::<Vec<_>>()
373        .join(".");
374
375    // Compile-time function: env('KEY')
376    // Resolved from the BindContext env map, not from the OS environment.
377    if func_name == "env" {
378        if args.len() != 1 {
379            return Err(KyuError::Binder(
380                "env() requires exactly one argument".into(),
381            ));
382        }
383        let bound_arg = bind_expression(&args[0], scope, catalog, registry, ctx)?;
384        let key = match &bound_arg {
385            BoundExpression::Literal {
386                value: TypedValue::String(s),
387                ..
388            } => s.clone(),
389            _ => {
390                return Err(KyuError::Binder(
391                    "env() argument must be a string literal".into(),
392                ));
393            }
394        };
395        return match ctx.env.get(key.as_str()) {
396            Some(value) => Ok(BoundExpression::Literal {
397                value: value.clone(),
398                result_type: value.logical_type(),
399            }),
400            None => Ok(BoundExpression::Literal {
401                value: TypedValue::Null,
402                result_type: LogicalType::String,
403            }),
404        };
405    }
406
407    // Bind all arguments.
408    let bound_args: Vec<BoundExpression> = args
409        .iter()
410        .map(|a| bind_expression(a, scope, catalog, registry, ctx))
411        .collect::<KyuResult<_>>()?;
412
413    let arg_types: Vec<LogicalType> = bound_args.iter().map(|a| a.result_type().clone()).collect();
414
415    // Resolve function.
416    let sig = registry.resolve(&func_name, &arg_types)?;
417
418    Ok(BoundExpression::FunctionCall {
419        function_id: sig.id,
420        function_name: sig.name.clone(),
421        args: bound_args,
422        distinct,
423        result_type: sig.return_type.clone(),
424    })
425}
426
427fn bind_unary_op(
428    op: kyu_parser::ast::UnaryOp,
429    operand: &Spanned<Expression>,
430    scope: &BinderScope,
431    catalog: &CatalogContent,
432    registry: &FunctionRegistry,
433    ctx: &BindContext,
434) -> KyuResult<BoundExpression> {
435    let bound = bind_expression(operand, scope, catalog, registry, ctx)?;
436    let result_type = match op {
437        kyu_parser::ast::UnaryOp::Not => {
438            let bound = try_coerce(bound, &LogicalType::Bool)?;
439            return Ok(BoundExpression::UnaryOp {
440                op,
441                operand: Box::new(bound),
442                result_type: LogicalType::Bool,
443            });
444        }
445        kyu_parser::ast::UnaryOp::Minus => bound.result_type().clone(),
446        kyu_parser::ast::UnaryOp::BitwiseNot => bound.result_type().clone(),
447    };
448    Ok(BoundExpression::UnaryOp {
449        op,
450        operand: Box::new(bound),
451        result_type,
452    })
453}
454
455fn bind_binary_op(
456    op: BinaryOp,
457    left: &Spanned<Expression>,
458    right: &Spanned<Expression>,
459    scope: &BinderScope,
460    catalog: &CatalogContent,
461    registry: &FunctionRegistry,
462    ctx: &BindContext,
463) -> KyuResult<BoundExpression> {
464    let bound_left = bind_expression(left, scope, catalog, registry, ctx)?;
465    let bound_right = bind_expression(right, scope, catalog, registry, ctx)?;
466
467    match op {
468        BinaryOp::Add
469        | BinaryOp::Sub
470        | BinaryOp::Mul
471        | BinaryOp::Div
472        | BinaryOp::Mod
473        | BinaryOp::Pow => {
474            let (l, r, result_type) = coerce_binary_arithmetic(bound_left, bound_right)?;
475            Ok(BoundExpression::BinaryOp {
476                op,
477                left: Box::new(l),
478                right: Box::new(r),
479                result_type,
480            })
481        }
482        BinaryOp::And | BinaryOp::Or | BinaryOp::Xor => {
483            let l = try_coerce(bound_left, &LogicalType::Bool)?;
484            let r = try_coerce(bound_right, &LogicalType::Bool)?;
485            Ok(BoundExpression::BinaryOp {
486                op,
487                left: Box::new(l),
488                right: Box::new(r),
489                result_type: LogicalType::Bool,
490            })
491        }
492        BinaryOp::Concat => {
493            let (l, r) = coerce_concat(bound_left, bound_right)?;
494            Ok(BoundExpression::BinaryOp {
495                op,
496                left: Box::new(l),
497                right: Box::new(r),
498                result_type: LogicalType::String,
499            })
500        }
501        BinaryOp::BitwiseAnd | BinaryOp::BitwiseOr | BinaryOp::ShiftLeft | BinaryOp::ShiftRight => {
502            Ok(BoundExpression::BinaryOp {
503                op,
504                left: Box::new(bound_left),
505                right: Box::new(bound_right),
506                result_type: LogicalType::Int64,
507            })
508        }
509    }
510}
511
512/// Desugar chained comparison `a < b < c` into `a < b AND b < c`.
513fn bind_comparison(
514    left: &Spanned<Expression>,
515    ops: &[(ComparisonOp, Spanned<Expression>)],
516    scope: &BinderScope,
517    catalog: &CatalogContent,
518    registry: &FunctionRegistry,
519    ctx: &BindContext,
520) -> KyuResult<BoundExpression> {
521    if ops.is_empty() {
522        return bind_expression(left, scope, catalog, registry, ctx);
523    }
524
525    // Single comparison: a op b
526    if ops.len() == 1 {
527        let (op, ref right_expr) = ops[0];
528        let bound_left = bind_expression(left, scope, catalog, registry, ctx)?;
529        let bound_right = bind_expression(right_expr, scope, catalog, registry, ctx)?;
530        let (l, r) = coerce_comparison(bound_left, bound_right)?;
531        return Ok(BoundExpression::Comparison {
532            op,
533            left: Box::new(l),
534            right: Box::new(r),
535        });
536    }
537
538    // Chained: a op1 b op2 c → (a op1 b) AND (b op2 c)
539    let mut conjuncts = Vec::new();
540    let mut prev = bind_expression(left, scope, catalog, registry, ctx)?;
541
542    for (op, right_expr) in ops {
543        let right = bind_expression(right_expr, scope, catalog, registry, ctx)?;
544        let (l, r) = coerce_comparison(prev.clone(), right.clone())?;
545        conjuncts.push(BoundExpression::Comparison {
546            op: *op,
547            left: Box::new(l),
548            right: Box::new(r),
549        });
550        prev = right;
551    }
552
553    // Fold into AND chain.
554    let mut result = conjuncts.pop().unwrap();
555    while let Some(cmp) = conjuncts.pop() {
556        result = BoundExpression::BinaryOp {
557            op: BinaryOp::And,
558            left: Box::new(cmp),
559            right: Box::new(result),
560            result_type: LogicalType::Bool,
561        };
562    }
563
564    Ok(result)
565}
566
567fn bind_in_list(
568    expr: &Spanned<Expression>,
569    list: &Spanned<Expression>,
570    negated: bool,
571    scope: &BinderScope,
572    catalog: &CatalogContent,
573    registry: &FunctionRegistry,
574    ctx: &BindContext,
575) -> KyuResult<BoundExpression> {
576    let bound_expr = bind_expression(expr, scope, catalog, registry, ctx)?;
577    let bound_list = bind_expression(list, scope, catalog, registry, ctx)?;
578
579    // The list expression should be a list literal; flatten it.
580    let list_items = match bound_list {
581        BoundExpression::ListLiteral { elements, .. } => elements,
582        other => vec![other],
583    };
584
585    Ok(BoundExpression::InList {
586        expr: Box::new(bound_expr),
587        list: list_items,
588        negated,
589    })
590}
591
592fn bind_list_literal(
593    elements: &[Spanned<Expression>],
594    scope: &BinderScope,
595    catalog: &CatalogContent,
596    registry: &FunctionRegistry,
597    ctx: &BindContext,
598) -> KyuResult<BoundExpression> {
599    let bound: Vec<BoundExpression> = elements
600        .iter()
601        .map(|e| bind_expression(e, scope, catalog, registry, ctx))
602        .collect::<KyuResult<_>>()?;
603
604    let elem_types: Vec<LogicalType> = bound.iter().map(|e| e.result_type().clone()).collect();
605    let elem_type = if elem_types.is_empty() {
606        LogicalType::Any
607    } else {
608        common_type(&elem_types)?
609    };
610
611    Ok(BoundExpression::ListLiteral {
612        elements: bound,
613        result_type: LogicalType::List(Box::new(elem_type)),
614    })
615}
616
617fn bind_map_literal(
618    entries: &[(Spanned<SmolStr>, Spanned<Expression>)],
619    scope: &BinderScope,
620    catalog: &CatalogContent,
621    registry: &FunctionRegistry,
622    ctx: &BindContext,
623) -> KyuResult<BoundExpression> {
624    let bound: Vec<(BoundExpression, BoundExpression)> = entries
625        .iter()
626        .map(|(k, v)| {
627            let key = BoundExpression::Literal {
628                value: TypedValue::String(k.0.clone()),
629                result_type: LogicalType::String,
630            };
631            let val = bind_expression(v, scope, catalog, registry, ctx)?;
632            Ok((key, val))
633        })
634        .collect::<KyuResult<_>>()?;
635
636    let val_types: Vec<LogicalType> = bound.iter().map(|(_, v)| v.result_type().clone()).collect();
637    let val_type = if val_types.is_empty() {
638        LogicalType::Any
639    } else {
640        common_type(&val_types)?
641    };
642
643    Ok(BoundExpression::MapLiteral {
644        entries: bound,
645        result_type: LogicalType::Map {
646            key: Box::new(LogicalType::String),
647            value: Box::new(val_type),
648        },
649    })
650}
651
652fn bind_case(
653    operand: &Option<Box<Spanned<Expression>>>,
654    whens: &[(Spanned<Expression>, Spanned<Expression>)],
655    else_expr: &Option<Box<Spanned<Expression>>>,
656    scope: &BinderScope,
657    catalog: &CatalogContent,
658    registry: &FunctionRegistry,
659    ctx: &BindContext,
660) -> KyuResult<BoundExpression> {
661    let bound_operand = operand
662        .as_ref()
663        .map(|e| bind_expression(e, scope, catalog, registry, ctx))
664        .transpose()?
665        .map(Box::new);
666
667    let mut bound_whens = Vec::with_capacity(whens.len());
668    let mut result_types = Vec::new();
669
670    for (when_expr, then_expr) in whens {
671        let w = bind_expression(when_expr, scope, catalog, registry, ctx)?;
672        let t = bind_expression(then_expr, scope, catalog, registry, ctx)?;
673        result_types.push(t.result_type().clone());
674        bound_whens.push((w, t));
675    }
676
677    let bound_else = else_expr
678        .as_ref()
679        .map(|e| bind_expression(e, scope, catalog, registry, ctx))
680        .transpose()?;
681
682    if let Some(ref e) = bound_else {
683        result_types.push(e.result_type().clone());
684    }
685
686    let result_type = if result_types.is_empty() {
687        LogicalType::Any
688    } else {
689        common_type(&result_types)?
690    };
691
692    Ok(BoundExpression::Case {
693        operand: bound_operand,
694        whens: bound_whens,
695        else_expr: bound_else.map(Box::new),
696        result_type,
697    })
698}
699
700#[cfg(test)]
701mod tests {
702    use super::*;
703    use kyu_catalog::{CatalogContent, NodeTableEntry, Property, RelTableEntry};
704    use kyu_common::id::{PropertyId, TableId};
705    use kyu_expression::FunctionRegistry;
706
707    fn make_catalog() -> CatalogContent {
708        let mut catalog = CatalogContent::new();
709        catalog
710            .add_node_table(NodeTableEntry {
711                table_id: TableId(0),
712                name: SmolStr::new("Person"),
713                properties: vec![
714                    Property::new(PropertyId(0), "name", LogicalType::String, true),
715                    Property::new(PropertyId(1), "age", LogicalType::Int64, false),
716                ],
717                primary_key_idx: 0,
718                num_rows: 0,
719                comment: None,
720            })
721            .unwrap();
722        catalog
723            .add_rel_table(RelTableEntry {
724                table_id: TableId(1),
725                name: SmolStr::new("KNOWS"),
726                from_table_id: TableId(0),
727                to_table_id: TableId(0),
728                properties: vec![Property::new(
729                    PropertyId(2),
730                    "since",
731                    LogicalType::Int64,
732                    false,
733                )],
734                num_rows: 0,
735                comment: None,
736            })
737            .unwrap();
738        catalog
739    }
740
741    fn parse_expr(s: &str) -> Spanned<Expression> {
742        // Parse "RETURN <expr>" and extract the expression.
743        let result = kyu_parser::parse(&format!("RETURN {s}"));
744        let stmt = result.ast.expect("parse failed");
745        match stmt {
746            kyu_parser::ast::Statement::Query(q) => {
747                let proj = q.parts[0].projection.as_ref().unwrap();
748                match &proj.items {
749                    kyu_parser::ast::ProjectionItems::Expressions(exprs) => exprs[0].0.clone(),
750                    _ => panic!("expected expressions"),
751                }
752            }
753            _ => panic!("expected query"),
754        }
755    }
756
757    #[test]
758    fn bind_integer_literal() {
759        let catalog = make_catalog();
760        let scope = BinderScope::new();
761        let registry = FunctionRegistry::with_builtins();
762        let ctx = BindContext::empty();
763        let expr = parse_expr("42");
764        let bound = bind_expression(&expr, &scope, &catalog, &registry, &ctx).unwrap();
765        assert_eq!(bound.result_type(), &LogicalType::Int64);
766        assert!(bound.is_constant());
767    }
768
769    #[test]
770    fn bind_string_literal() {
771        let catalog = make_catalog();
772        let scope = BinderScope::new();
773        let registry = FunctionRegistry::with_builtins();
774        let ctx = BindContext::empty();
775        let expr = parse_expr("'hello'");
776        let bound = bind_expression(&expr, &scope, &catalog, &registry, &ctx).unwrap();
777        assert_eq!(bound.result_type(), &LogicalType::String);
778    }
779
780    #[test]
781    fn bind_bool_literal() {
782        let catalog = make_catalog();
783        let scope = BinderScope::new();
784        let registry = FunctionRegistry::with_builtins();
785        let ctx = BindContext::empty();
786        let expr = parse_expr("true");
787        let bound = bind_expression(&expr, &scope, &catalog, &registry, &ctx).unwrap();
788        assert_eq!(bound.result_type(), &LogicalType::Bool);
789    }
790
791    #[test]
792    fn bind_null_literal() {
793        let catalog = make_catalog();
794        let scope = BinderScope::new();
795        let registry = FunctionRegistry::with_builtins();
796        let ctx = BindContext::empty();
797        let expr = parse_expr("null");
798        let bound = bind_expression(&expr, &scope, &catalog, &registry, &ctx).unwrap();
799        assert_eq!(bound.result_type(), &LogicalType::Any);
800    }
801
802    #[test]
803    fn bind_variable_found() {
804        let catalog = make_catalog();
805        let mut scope = BinderScope::new();
806        scope
807            .define("p", LogicalType::Node, Some(TableId(0)))
808            .unwrap();
809        let registry = FunctionRegistry::with_builtins();
810        let ctx = BindContext::empty();
811        let expr = parse_expr("p");
812        let bound = bind_expression(&expr, &scope, &catalog, &registry, &ctx).unwrap();
813        assert!(matches!(bound, BoundExpression::Variable { index: 0, .. }));
814    }
815
816    #[test]
817    fn bind_variable_not_found() {
818        let catalog = make_catalog();
819        let scope = BinderScope::new();
820        let registry = FunctionRegistry::with_builtins();
821        let ctx = BindContext::empty();
822        let expr = parse_expr("unknown_var");
823        let result = bind_expression(&expr, &scope, &catalog, &registry, &ctx);
824        assert!(result.is_err());
825    }
826
827    #[test]
828    fn bind_property_access() {
829        let catalog = make_catalog();
830        let mut scope = BinderScope::new();
831        scope
832            .define("p", LogicalType::Node, Some(TableId(0)))
833            .unwrap();
834        let registry = FunctionRegistry::with_builtins();
835        let ctx = BindContext::empty();
836        let expr = parse_expr("p.name");
837        let bound = bind_expression(&expr, &scope, &catalog, &registry, &ctx).unwrap();
838        assert_eq!(bound.result_type(), &LogicalType::String);
839        if let BoundExpression::Property { property_id, .. } = &bound {
840            assert_eq!(*property_id, PropertyId(0));
841        } else {
842            panic!("expected Property");
843        }
844    }
845
846    #[test]
847    fn bind_property_not_found() {
848        let catalog = make_catalog();
849        let mut scope = BinderScope::new();
850        scope
851            .define("p", LogicalType::Node, Some(TableId(0)))
852            .unwrap();
853        let registry = FunctionRegistry::with_builtins();
854        let ctx = BindContext::empty();
855        let expr = parse_expr("p.nonexistent");
856        let result = bind_expression(&expr, &scope, &catalog, &registry, &ctx);
857        assert!(result.is_err());
858    }
859
860    #[test]
861    fn bind_binary_add_coercion() {
862        let catalog = make_catalog();
863        let scope = BinderScope::new();
864        let registry = FunctionRegistry::with_builtins();
865        let ctx = BindContext::empty();
866        let expr = parse_expr("1 + 2.0");
867        let bound = bind_expression(&expr, &scope, &catalog, &registry, &ctx).unwrap();
868        assert_eq!(bound.result_type(), &LogicalType::Double);
869    }
870
871    #[test]
872    fn bind_comparison_gt() {
873        let catalog = make_catalog();
874        let scope = BinderScope::new();
875        let registry = FunctionRegistry::with_builtins();
876        let ctx = BindContext::empty();
877        let expr = parse_expr("1 > 2");
878        let bound = bind_expression(&expr, &scope, &catalog, &registry, &ctx).unwrap();
879        assert_eq!(bound.result_type(), &LogicalType::Bool);
880    }
881
882    #[test]
883    fn bind_function_call_test() {
884        let catalog = make_catalog();
885        let scope = BinderScope::new();
886        let registry = FunctionRegistry::with_builtins();
887        let ctx = BindContext::empty();
888        let expr = parse_expr("upper('hello')");
889        let bound = bind_expression(&expr, &scope, &catalog, &registry, &ctx).unwrap();
890        assert_eq!(bound.result_type(), &LogicalType::String);
891    }
892
893    #[test]
894    fn bind_count_star() {
895        let catalog = make_catalog();
896        let scope = BinderScope::new();
897        let registry = FunctionRegistry::with_builtins();
898        let ctx = BindContext::empty();
899        let expr = parse_expr("count(*)");
900        let bound = bind_expression(&expr, &scope, &catalog, &registry, &ctx).unwrap();
901        assert_eq!(bound.result_type(), &LogicalType::Int64);
902    }
903
904    #[test]
905    fn bind_is_null() {
906        let catalog = make_catalog();
907        let scope = BinderScope::new();
908        let registry = FunctionRegistry::with_builtins();
909        let ctx = BindContext::empty();
910        let expr = parse_expr("null IS NULL");
911        let bound = bind_expression(&expr, &scope, &catalog, &registry, &ctx).unwrap();
912        assert_eq!(bound.result_type(), &LogicalType::Bool);
913    }
914
915    #[test]
916    fn bind_case_expression() {
917        let catalog = make_catalog();
918        let scope = BinderScope::new();
919        let registry = FunctionRegistry::with_builtins();
920        let ctx = BindContext::empty();
921        let expr = parse_expr("CASE WHEN true THEN 1 ELSE 2 END");
922        let bound = bind_expression(&expr, &scope, &catalog, &registry, &ctx).unwrap();
923        assert_eq!(bound.result_type(), &LogicalType::Int64);
924    }
925
926    #[test]
927    fn bind_string_starts_with() {
928        let catalog = make_catalog();
929        let scope = BinderScope::new();
930        let registry = FunctionRegistry::with_builtins();
931        let ctx = BindContext::empty();
932        let expr = parse_expr("'hello' STARTS WITH 'he'");
933        let bound = bind_expression(&expr, &scope, &catalog, &registry, &ctx).unwrap();
934        assert_eq!(bound.result_type(), &LogicalType::Bool);
935    }
936
937    #[test]
938    fn bind_list_literal_test() {
939        let catalog = make_catalog();
940        let scope = BinderScope::new();
941        let registry = FunctionRegistry::with_builtins();
942        let ctx = BindContext::empty();
943        let expr = parse_expr("[1, 2, 3]");
944        let bound = bind_expression(&expr, &scope, &catalog, &registry, &ctx).unwrap();
945        assert_eq!(
946            bound.result_type(),
947            &LogicalType::List(Box::new(LogicalType::Int64))
948        );
949    }
950
951    #[test]
952    fn bind_arithmetic_type_error() {
953        let catalog = make_catalog();
954        let scope = BinderScope::new();
955        let registry = FunctionRegistry::with_builtins();
956        let ctx = BindContext::empty();
957        let expr = parse_expr("'hello' + 42");
958        let result = bind_expression(&expr, &scope, &catalog, &registry, &ctx);
959        assert!(result.is_err());
960    }
961
962    #[test]
963    fn bind_unary_not() {
964        let catalog = make_catalog();
965        let scope = BinderScope::new();
966        let registry = FunctionRegistry::with_builtins();
967        let ctx = BindContext::empty();
968        let expr = parse_expr("NOT true");
969        let bound = bind_expression(&expr, &scope, &catalog, &registry, &ctx).unwrap();
970        assert_eq!(bound.result_type(), &LogicalType::Bool);
971    }
972
973    // ---- Parameter resolution tests ----
974
975    #[test]
976    fn bind_param_resolved() {
977        let catalog = make_catalog();
978        let scope = BinderScope::new();
979        let registry = FunctionRegistry::with_builtins();
980        let mut ctx = BindContext::empty();
981        ctx.params.insert(SmolStr::new("x"), TypedValue::Int64(42));
982        let expr = parse_expr("$x");
983        let bound = bind_expression(&expr, &scope, &catalog, &registry, &ctx).unwrap();
984        assert_eq!(bound.result_type(), &LogicalType::Int64);
985        match &bound {
986            BoundExpression::Literal { value, .. } => {
987                assert_eq!(value, &TypedValue::Int64(42));
988            }
989            _ => panic!("expected Literal"),
990        }
991    }
992
993    #[test]
994    fn bind_param_unresolved_error() {
995        let catalog = make_catalog();
996        let scope = BinderScope::new();
997        let registry = FunctionRegistry::with_builtins();
998        let ctx = BindContext::empty();
999        let expr = parse_expr("$missing");
1000        let result = bind_expression(&expr, &scope, &catalog, &registry, &ctx);
1001        assert!(result.is_err());
1002        let err = result.unwrap_err().to_string();
1003        assert!(err.contains("unresolved parameter '$missing'"));
1004    }
1005
1006    #[test]
1007    fn bind_param_in_comparison() {
1008        let catalog = make_catalog();
1009        let scope = BinderScope::new();
1010        let registry = FunctionRegistry::with_builtins();
1011        let mut ctx = BindContext::empty();
1012        ctx.params
1013            .insert(SmolStr::new("age"), TypedValue::Int64(30));
1014        let expr = parse_expr("42 > $age");
1015        let bound = bind_expression(&expr, &scope, &catalog, &registry, &ctx).unwrap();
1016        assert_eq!(bound.result_type(), &LogicalType::Bool);
1017    }
1018
1019    #[test]
1020    fn bind_param_string_type() {
1021        let catalog = make_catalog();
1022        let scope = BinderScope::new();
1023        let registry = FunctionRegistry::with_builtins();
1024        let mut ctx = BindContext::empty();
1025        ctx.params.insert(
1026            SmolStr::new("name"),
1027            TypedValue::String(SmolStr::new("Alice")),
1028        );
1029        let expr = parse_expr("$name");
1030        let bound = bind_expression(&expr, &scope, &catalog, &registry, &ctx).unwrap();
1031        assert_eq!(bound.result_type(), &LogicalType::String);
1032    }
1033
1034    // ---- env() resolution tests ----
1035
1036    #[test]
1037    fn bind_env_resolved() {
1038        let catalog = make_catalog();
1039        let scope = BinderScope::new();
1040        let registry = FunctionRegistry::with_builtins();
1041        let mut ctx = BindContext::empty();
1042        ctx.env.insert(
1043            SmolStr::new("DATA_DIR"),
1044            TypedValue::String(SmolStr::new("/data")),
1045        );
1046        let expr = parse_expr("env('DATA_DIR')");
1047        let bound = bind_expression(&expr, &scope, &catalog, &registry, &ctx).unwrap();
1048        assert_eq!(bound.result_type(), &LogicalType::String);
1049        match &bound {
1050            BoundExpression::Literal { value, .. } => {
1051                assert_eq!(value, &TypedValue::String(SmolStr::new("/data")));
1052            }
1053            _ => panic!("expected Literal"),
1054        }
1055    }
1056
1057    #[test]
1058    fn bind_env_missing_returns_null() {
1059        let catalog = make_catalog();
1060        let scope = BinderScope::new();
1061        let registry = FunctionRegistry::with_builtins();
1062        let ctx = BindContext::empty();
1063        let expr = parse_expr("env('MISSING')");
1064        let bound = bind_expression(&expr, &scope, &catalog, &registry, &ctx).unwrap();
1065        match &bound {
1066            BoundExpression::Literal { value, .. } => {
1067                assert_eq!(value, &TypedValue::Null);
1068            }
1069            _ => panic!("expected Literal"),
1070        }
1071    }
1072
1073    #[test]
1074    fn bind_env_non_string_arg_error() {
1075        let catalog = make_catalog();
1076        let scope = BinderScope::new();
1077        let registry = FunctionRegistry::with_builtins();
1078        let ctx = BindContext::empty();
1079        let expr = parse_expr("env(42)");
1080        let result = bind_expression(&expr, &scope, &catalog, &registry, &ctx);
1081        assert!(result.is_err());
1082        let err = result.unwrap_err().to_string();
1083        assert!(err.contains("string literal"));
1084    }
1085
1086    // ---- JSON constructor tests ----
1087
1088    #[test]
1089    fn bind_ctx_with_params_json() {
1090        let catalog = make_catalog();
1091        let scope = BinderScope::new();
1092        let registry = FunctionRegistry::with_builtins();
1093        let ctx = BindContext::with_params_json(serde_json::json!({"x": 42}));
1094        let expr = parse_expr("$x");
1095        let bound = bind_expression(&expr, &scope, &catalog, &registry, &ctx).unwrap();
1096        assert_eq!(bound.result_type(), &LogicalType::Int64);
1097        match &bound {
1098            BoundExpression::Literal { value, .. } => {
1099                assert_eq!(value, &TypedValue::Int64(42));
1100            }
1101            _ => panic!("expected Literal"),
1102        }
1103    }
1104
1105    #[test]
1106    fn bind_ctx_with_env_json() {
1107        let catalog = make_catalog();
1108        let scope = BinderScope::new();
1109        let registry = FunctionRegistry::with_builtins();
1110        let ctx = BindContext::with_env_json(serde_json::json!({"DIR": "/data"}));
1111        let expr = parse_expr("env('DIR')");
1112        let bound = bind_expression(&expr, &scope, &catalog, &registry, &ctx).unwrap();
1113        match &bound {
1114            BoundExpression::Literal { value, .. } => {
1115                assert_eq!(value, &TypedValue::String(SmolStr::new("/data")));
1116            }
1117            _ => panic!("expected Literal"),
1118        }
1119    }
1120
1121    #[test]
1122    fn bind_ctx_from_json() {
1123        let catalog = make_catalog();
1124        let scope = BinderScope::new();
1125        let registry = FunctionRegistry::with_builtins();
1126        let ctx = BindContext::from_json(
1127            serde_json::json!({"x": 100}),
1128            serde_json::json!({"KEY": "val"}),
1129        );
1130        let expr = parse_expr("$x");
1131        let bound = bind_expression(&expr, &scope, &catalog, &registry, &ctx).unwrap();
1132        assert_eq!(bound.result_type(), &LogicalType::Int64);
1133        let expr2 = parse_expr("env('KEY')");
1134        let bound2 = bind_expression(&expr2, &scope, &catalog, &registry, &ctx).unwrap();
1135        assert_eq!(bound2.result_type(), &LogicalType::String);
1136    }
1137
1138    #[test]
1139    fn bind_ctx_with_params_str() {
1140        let catalog = make_catalog();
1141        let scope = BinderScope::new();
1142        let registry = FunctionRegistry::with_builtins();
1143        let ctx = BindContext::with_params_str(r#"{"n": 7}"#).unwrap();
1144        let expr = parse_expr("$n");
1145        let bound = bind_expression(&expr, &scope, &catalog, &registry, &ctx).unwrap();
1146        assert_eq!(bound.result_type(), &LogicalType::Int64);
1147    }
1148
1149    #[test]
1150    fn bind_ctx_with_params_str_invalid() {
1151        let result = BindContext::with_params_str("not json");
1152        assert!(result.is_err());
1153    }
1154}