Skip to main content

omnigraph_compiler/query/
typecheck.rs

1use std::collections::{HashMap, HashSet};
2use std::sync::Arc;
3
4use arrow_schema::{DataType, Field, Schema, SchemaRef};
5
6use crate::catalog::Catalog;
7use crate::error::{NanoError, Result};
8use crate::types::{Direction, PropType, ScalarType};
9
10use super::ast::*;
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub enum BindingKind {
14    Node,
15    Edge,
16}
17
18#[derive(Debug, Clone)]
19pub struct BoundVariable {
20    pub var_name: String,
21    pub type_name: String,
22    pub kind: BindingKind,
23}
24
25#[derive(Debug, Clone)]
26pub struct TypeContext {
27    pub bindings: HashMap<String, BoundVariable>,
28    pub aliases: HashMap<String, ResolvedType>,
29    pub traversals: Vec<ResolvedTraversal>,
30}
31
32#[derive(Debug, Clone)]
33pub struct ResolvedTraversal {
34    pub src: String,
35    pub dst: String,
36    pub edge_type: String,
37    pub direction: Direction,
38    pub min_hops: u32,
39    pub max_hops: Option<u32>,
40}
41
42#[derive(Debug, Clone, PartialEq)]
43pub enum ResolvedType {
44    Scalar(PropType),
45    Node(String),
46    Aggregate,
47}
48
49impl ResolvedType {
50    fn display_name(&self) -> String {
51        match self {
52            Self::Scalar(prop) => prop.display_name(),
53            Self::Node(type_name) => format!("node `{}`", type_name),
54            Self::Aggregate => "aggregate".to_string(),
55        }
56    }
57}
58
59#[derive(Debug, Clone)]
60pub struct MutationTypeContext {
61    pub target_types: Vec<String>,
62}
63
64#[derive(Debug, Clone)]
65pub enum CheckedQuery {
66    Read(TypeContext),
67    Mutation(MutationTypeContext),
68}
69
70pub fn typecheck_query_decl(catalog: &Catalog, query: &QueryDecl) -> Result<CheckedQuery> {
71    if !query.mutations.is_empty() {
72        let mut target_types = Vec::with_capacity(query.mutations.len());
73        for mutation in &query.mutations {
74            let target_type = typecheck_mutation(catalog, mutation, &query.params)?;
75            target_types.push(target_type);
76        }
77        Ok(CheckedQuery::Mutation(MutationTypeContext { target_types }))
78    } else {
79        Ok(CheckedQuery::Read(typecheck_read_query(catalog, query)?))
80    }
81}
82
83pub fn typecheck_query(catalog: &Catalog, query: &QueryDecl) -> Result<TypeContext> {
84    if !query.mutations.is_empty() {
85        return Err(NanoError::Type(
86            "mutation query cannot be typechecked with read-query API".to_string(),
87        ));
88    }
89    typecheck_read_query(catalog, query)
90}
91
92pub fn infer_query_result_schema(
93    catalog: &Catalog,
94    query: &QueryDecl,
95    ctx: &TypeContext,
96) -> Result<SchemaRef> {
97    let params = parse_declared_param_types(&query.params)?;
98    let mut fields = Vec::with_capacity(query.return_clause.len());
99
100    for projection in &query.return_clause {
101        let field = infer_projection_field(
102            catalog,
103            &projection.expr,
104            projection.alias.as_deref(),
105            ctx,
106            &params,
107        )?;
108        fields.push(field);
109    }
110
111    Ok(Arc::new(Schema::new(fields)))
112}
113
114fn parse_declared_param_types(params: &[Param]) -> Result<HashMap<String, PropType>> {
115    let mut out = HashMap::with_capacity(params.len());
116    for p in params {
117        if p.name == NOW_PARAM_NAME {
118            return Err(NanoError::Type(format!(
119                "parameter name `${}` is reserved for runtime timestamp injection",
120                NOW_PARAM_NAME
121            )));
122        }
123        let prop_type =
124            PropType::from_param_type_name(&p.type_name, p.nullable).ok_or_else(|| {
125                NanoError::Type(format!(
126                    "unknown parameter type `{}` for `${}`",
127                    p.type_name, p.name
128                ))
129            })?;
130        out.insert(p.name.clone(), prop_type);
131    }
132    Ok(out)
133}
134
135fn typecheck_read_query(catalog: &Catalog, query: &QueryDecl) -> Result<TypeContext> {
136    let mut ctx = TypeContext {
137        bindings: HashMap::new(),
138        aliases: HashMap::new(),
139        traversals: Vec::new(),
140    };
141    let mut alias_exprs: HashMap<String, &Expr> = HashMap::new();
142
143    let params = parse_declared_param_types(&query.params)?;
144
145    // Typecheck match clauses
146    typecheck_clauses(catalog, &query.match_clause, &mut ctx, &params, false)?;
147
148    // Typecheck return projections
149    for proj in &query.return_clause {
150        let resolved = resolve_expr_type(catalog, &proj.expr, &ctx, &params)?;
151        if let Some(alias) = &proj.alias {
152            ctx.aliases.insert(alias.clone(), resolved);
153            alias_exprs.insert(alias.clone(), &proj.expr);
154        }
155    }
156
157    // Typecheck order expressions
158    for ord in &query.order_clause {
159        resolve_expr_type(catalog, &ord.expr, &ctx, &params)?;
160    }
161
162    let has_standalone_nearest = query
163        .order_clause
164        .iter()
165        .any(|ord| expr_contains_standalone_nearest_with_aliases(&ord.expr, &alias_exprs));
166    let has_rrf = query
167        .order_clause
168        .iter()
169        .any(|ord| expr_contains_rrf_with_aliases(&ord.expr, &alias_exprs));
170    if has_rrf && query.limit.is_none() {
171        return Err(NanoError::Type(
172            "T21: rrf ordering requires a limit clause".to_string(),
173        ));
174    }
175    if has_standalone_nearest && query.limit.is_none() {
176        return Err(NanoError::Type(
177            "T17: nearest ordering requires a limit clause".to_string(),
178        ));
179    }
180    if has_standalone_nearest
181        && query
182            .order_clause
183            .iter()
184            .any(|ord| matches!(ord.expr, Expr::AliasRef(_)))
185    {
186        return Err(NanoError::Type(
187            "T18: alias-based ordering is not supported together with nearest in phase 1"
188                .to_string(),
189        ));
190    }
191
192    // T9: If any return expression is an aggregate, non-aggregate expressions
193    // must be valid group-by keys (PropAccess or Variable).
194    let has_agg = query
195        .return_clause
196        .iter()
197        .any(|p| matches!(p.expr, Expr::Aggregate { .. }));
198    if has_agg {
199        for proj in &query.return_clause {
200            if !matches!(proj.expr, Expr::Aggregate { .. }) {
201                match &proj.expr {
202                    Expr::PropAccess { .. } | Expr::Variable(_) => {}
203                    _ => {
204                        return Err(NanoError::Type(
205                            "T9: non-aggregate expressions in an aggregate query must be \
206                             property accesses or variables"
207                                .to_string(),
208                        ));
209                    }
210                }
211            }
212        }
213    }
214
215    Ok(ctx)
216}
217
218fn typecheck_mutation(catalog: &Catalog, mutation: &Mutation, params: &[Param]) -> Result<String> {
219    let param_types = parse_declared_param_types(params)?;
220
221    match mutation {
222        Mutation::Insert(insert) => {
223            if insert.assignments.is_empty() {
224                return Err(NanoError::Type(
225                    "T10: insert mutation requires at least one assignment".to_string(),
226                ));
227            }
228
229            ensure_no_duplicate_assignment_names(&insert.assignments)?;
230
231            if let Some(node_type) = catalog.node_types.get(&insert.type_name) {
232                for assignment in &insert.assignments {
233                    let prop_type =
234                        node_type
235                            .properties
236                            .get(&assignment.property)
237                            .ok_or_else(|| {
238                                NanoError::Type(format!(
239                                    "T11: type `{}` has no property `{}`",
240                                    insert.type_name, assignment.property
241                                ))
242                            })?;
243                    check_match_value_type(
244                        &assignment.value,
245                        &param_types,
246                        prop_type,
247                        &assignment.property,
248                    )?;
249                }
250
251                let assigned_props: HashSet<&str> = insert
252                    .assignments
253                    .iter()
254                    .map(|assignment| assignment.property.as_str())
255                    .collect();
256                for (prop_name, prop_type) in &node_type.properties {
257                    if prop_type.nullable {
258                        continue;
259                    }
260                    if assigned_props.contains(prop_name.as_str()) {
261                        continue;
262                    }
263
264                    if let Some(source_prop) = node_type.embed_sources.get(prop_name) {
265                        if assigned_props.contains(source_prop.as_str()) {
266                            continue;
267                        }
268                        return Err(NanoError::Type(format!(
269                            "T12: insert for `{}` must provide non-nullable property `{}` or @embed source `{}`",
270                            insert.type_name, prop_name, source_prop
271                        )));
272                    }
273
274                    return Err(NanoError::Type(format!(
275                        "T12: insert for `{}` must provide non-nullable property `{}`",
276                        insert.type_name, prop_name
277                    )));
278                }
279                return Ok(insert.type_name.clone());
280            }
281
282            if let Some(edge_type) = catalog.edge_types.get(&insert.type_name) {
283                let mut has_from = false;
284                let mut has_to = false;
285
286                for assignment in &insert.assignments {
287                    match assignment.property.as_str() {
288                        "from" => {
289                            has_from = true;
290                            check_match_value_type(
291                                &assignment.value,
292                                &param_types,
293                                &PropType::scalar(ScalarType::String, false),
294                                "from",
295                            )?;
296                        }
297                        "to" => {
298                            has_to = true;
299                            check_match_value_type(
300                                &assignment.value,
301                                &param_types,
302                                &PropType::scalar(ScalarType::String, false),
303                                "to",
304                            )?;
305                        }
306                        _ => {
307                            let prop_type = edge_type
308                                .properties
309                                .get(&assignment.property)
310                                .ok_or_else(|| {
311                                    NanoError::Type(format!(
312                                        "T11: type `{}` has no property `{}`",
313                                        insert.type_name, assignment.property
314                                    ))
315                                })?;
316                            check_match_value_type(
317                                &assignment.value,
318                                &param_types,
319                                prop_type,
320                                &assignment.property,
321                            )?;
322                        }
323                    }
324                }
325
326                if !has_from {
327                    return Err(NanoError::Type(format!(
328                        "T12: insert for `{}` must provide required endpoint `from`",
329                        insert.type_name
330                    )));
331                }
332                if !has_to {
333                    return Err(NanoError::Type(format!(
334                        "T12: insert for `{}` must provide required endpoint `to`",
335                        insert.type_name
336                    )));
337                }
338
339                for (prop_name, prop_type) in &edge_type.properties {
340                    if prop_type.nullable {
341                        continue;
342                    }
343                    if !insert.assignments.iter().any(|a| &a.property == prop_name) {
344                        return Err(NanoError::Type(format!(
345                            "T12: insert for `{}` must provide non-nullable property `{}`",
346                            insert.type_name, prop_name
347                        )));
348                    }
349                }
350                return Ok(insert.type_name.clone());
351            }
352
353            Err(NanoError::Type(format!(
354                "T10: unknown node/edge type `{}`",
355                insert.type_name
356            )))
357        }
358        Mutation::Update(update) => {
359            let node_type = if let Some(node_type) = catalog.node_types.get(&update.type_name) {
360                node_type
361            } else if catalog.edge_types.contains_key(&update.type_name) {
362                return Err(NanoError::Type(format!(
363                    "T16: update mutation for edge type `{}` is not supported",
364                    update.type_name
365                )));
366            } else {
367                return Err(NanoError::Type(format!(
368                    "T10: unknown node/edge type `{}`",
369                    update.type_name
370                )));
371            };
372
373            if update.assignments.is_empty() {
374                return Err(NanoError::Type(
375                    "T10: update mutation requires at least one assignment".to_string(),
376                ));
377            }
378            ensure_no_duplicate_assignment_names(&update.assignments)?;
379
380            for assignment in &update.assignments {
381                let prop_type =
382                    node_type
383                        .properties
384                        .get(&assignment.property)
385                        .ok_or_else(|| {
386                            NanoError::Type(format!(
387                                "T11: type `{}` has no property `{}`",
388                                update.type_name, assignment.property
389                            ))
390                        })?;
391                check_match_value_type(
392                    &assignment.value,
393                    &param_types,
394                    prop_type,
395                    &assignment.property,
396                )?;
397            }
398
399            typecheck_mutation_predicate(
400                &update.type_name,
401                &update.predicate,
402                node_type,
403                &param_types,
404            )?;
405            Ok(update.type_name.clone())
406        }
407        Mutation::Delete(delete) => {
408            if let Some(node_type) = catalog.node_types.get(&delete.type_name) {
409                typecheck_mutation_predicate(
410                    &delete.type_name,
411                    &delete.predicate,
412                    node_type,
413                    &param_types,
414                )?;
415                Ok(delete.type_name.clone())
416            } else if let Some(edge_type) = catalog.edge_types.get(&delete.type_name) {
417                typecheck_edge_mutation_predicate(
418                    &delete.type_name,
419                    &delete.predicate,
420                    edge_type,
421                    &param_types,
422                )?;
423                Ok(delete.type_name.clone())
424            } else {
425                Err(NanoError::Type(format!(
426                    "T10: unknown node/edge type `{}`",
427                    delete.type_name
428                )))
429            }
430        }
431    }
432}
433
434fn ensure_no_duplicate_assignment_names(assignments: &[MutationAssignment]) -> Result<()> {
435    let mut seen = std::collections::HashSet::new();
436    for assignment in assignments {
437        if !seen.insert(&assignment.property) {
438            return Err(NanoError::Type(format!(
439                "T13: duplicate assignment for property `{}`",
440                assignment.property
441            )));
442        }
443    }
444    Ok(())
445}
446
447fn typecheck_mutation_predicate(
448    type_name: &str,
449    predicate: &MutationPredicate,
450    node_type: &crate::catalog::NodeType,
451    param_types: &HashMap<String, PropType>,
452) -> Result<()> {
453    let prop_type = node_type
454        .properties
455        .get(&predicate.property)
456        .ok_or_else(|| {
457            NanoError::Type(format!(
458                "T11: type `{}` has no property `{}`",
459                type_name, predicate.property
460            ))
461        })?;
462    if matches!(prop_type.scalar, ScalarType::Blob) {
463        return Err(NanoError::Type(format!(
464            "T11: blob property `{}` cannot be used in WHERE predicates",
465            predicate.property
466        )));
467    }
468    check_match_value_type(
469        &predicate.value,
470        param_types,
471        prop_type,
472        &predicate.property,
473    )?;
474    Ok(())
475}
476
477fn typecheck_edge_mutation_predicate(
478    type_name: &str,
479    predicate: &MutationPredicate,
480    edge_type: &crate::catalog::EdgeType,
481    param_types: &HashMap<String, PropType>,
482) -> Result<()> {
483    if predicate.property == "from" || predicate.property == "to" {
484        return check_match_value_type(
485            &predicate.value,
486            param_types,
487            &PropType::scalar(ScalarType::String, false),
488            &predicate.property,
489        );
490    }
491
492    let prop_type = edge_type
493        .properties
494        .get(&predicate.property)
495        .ok_or_else(|| {
496            NanoError::Type(format!(
497                "T11: type `{}` has no property `{}`",
498                type_name, predicate.property
499            ))
500        })?;
501    check_match_value_type(
502        &predicate.value,
503        param_types,
504        prop_type,
505        &predicate.property,
506    )?;
507    Ok(())
508}
509
510fn check_match_value_type(
511    value: &MatchValue,
512    params: &HashMap<String, PropType>,
513    expected: &PropType,
514    property: &str,
515) -> Result<()> {
516    match value {
517        MatchValue::Literal(lit) => check_literal_type(lit, expected, property),
518        MatchValue::Variable(v) => {
519            let Some(actual) = params.get(v) else {
520                return Err(NanoError::Type(format!(
521                    "T14: mutation variable `${}` must be a declared query parameter",
522                    v
523                )));
524            };
525            // Allow String param → Blob property (URI assignment)
526            let compatible = types_compatible(actual, expected)
527                || (matches!(expected.scalar, ScalarType::Blob)
528                    && matches!(actual.scalar, ScalarType::String)
529                    && !actual.list);
530            if !compatible {
531                return Err(NanoError::Type(format!(
532                    "T7: cannot assign/compare {} with {} for property `{}`",
533                    actual.display_name(),
534                    expected.display_name(),
535                    property
536                )));
537            }
538            Ok(())
539        }
540        MatchValue::Now => check_now_match_value_type(expected, property),
541    }
542}
543
544fn check_now_match_value_type(expected: &PropType, property: &str) -> Result<()> {
545    if expected.list || expected.scalar != ScalarType::DateTime {
546        return Err(NanoError::Type(format!(
547            "T7: cannot assign/compare DateTime with {} for property `{}`",
548            expected.display_name(),
549            property
550        )));
551    }
552    Ok(())
553}
554
555fn typecheck_clauses(
556    catalog: &Catalog,
557    clauses: &[Clause],
558    ctx: &mut TypeContext,
559    params: &HashMap<String, PropType>,
560    _in_negation: bool,
561) -> Result<()> {
562    for clause in clauses {
563        match clause {
564            Clause::Binding(b) => typecheck_binding(catalog, b, ctx, params)?,
565            Clause::Traversal(t) => typecheck_traversal(catalog, t, ctx)?,
566            Clause::Filter(f) => typecheck_filter(catalog, f, ctx, params)?,
567            Clause::Negation(inner) => {
568                // T9: at least one variable in the negation block must be bound outside
569                let outer_vars: Vec<String> = ctx.bindings.keys().cloned().collect();
570
571                // Typecheck inner clauses in a copy of ctx
572                let mut inner_ctx = ctx.clone();
573                typecheck_clauses(catalog, inner, &mut inner_ctx, params, true)?;
574
575                // Check T9
576                let mut has_outer = false;
577                for clause in inner {
578                    match clause {
579                        Clause::Traversal(t) => {
580                            if outer_vars.contains(&t.src) || outer_vars.contains(&t.dst) {
581                                has_outer = true;
582                            }
583                        }
584                        Clause::Filter(f) => {
585                            if expr_references_any(&f.left, &outer_vars)
586                                || expr_references_any(&f.right, &outer_vars)
587                            {
588                                has_outer = true;
589                            }
590                        }
591                        Clause::Binding(b) => {
592                            if outer_vars.contains(&b.variable) {
593                                has_outer = true;
594                            }
595                        }
596                        _ => {}
597                    }
598                }
599                if !has_outer {
600                    return Err(NanoError::Type(
601                        "T9: negation block must reference at least one outer-bound variable"
602                            .to_string(),
603                    ));
604                }
605            }
606        }
607    }
608    Ok(())
609}
610
611fn typecheck_binding(
612    catalog: &Catalog,
613    binding: &Binding,
614    ctx: &mut TypeContext,
615    params: &HashMap<String, PropType>,
616) -> Result<()> {
617    // T1: binding type must exist in catalog
618    if !catalog.node_types.contains_key(&binding.type_name) {
619        return Err(NanoError::Type(format!(
620            "T1: unknown node type `{}`",
621            binding.type_name
622        )));
623    }
624
625    let node_type = &catalog.node_types[&binding.type_name];
626
627    // T2 + T3: property match fields must exist and have correct types
628    for pm in &binding.prop_matches {
629        let prop = node_type.properties.get(&pm.prop_name).ok_or_else(|| {
630            NanoError::Type(format!(
631                "T2: type `{}` has no property `{}`",
632                binding.type_name, pm.prop_name
633            ))
634        })?;
635
636        if matches!(prop.scalar, ScalarType::Blob) {
637            return Err(NanoError::Type(format!(
638                "T3: blob property `{}.{}` cannot be used in match patterns",
639                binding.type_name, pm.prop_name
640            )));
641        }
642
643        // T3: check value type matches property type
644        match &pm.value {
645            MatchValue::Literal(lit) => {
646                check_binding_literal_type(lit, prop, &pm.prop_name)?;
647            }
648            MatchValue::Variable(v) => {
649                if let Some(actual) = params.get(v) {
650                    check_binding_variable_type(actual, prop, &pm.prop_name)?;
651                }
652            }
653            MatchValue::Now => check_now_match_value_type(prop, &pm.prop_name)?,
654        }
655    }
656
657    // Don't overwrite if already bound to same type (re-binding same var is OK)
658    if let Some(existing) = ctx.bindings.get(&binding.variable)
659        && existing.type_name != binding.type_name
660    {
661        return Err(NanoError::Type(format!(
662            "variable `${}` already bound to type `{}`, cannot rebind to `{}`",
663            binding.variable, existing.type_name, binding.type_name
664        )));
665    }
666
667    ctx.bindings.insert(
668        binding.variable.clone(),
669        BoundVariable {
670            var_name: binding.variable.clone(),
671            type_name: binding.type_name.clone(),
672            kind: BindingKind::Node,
673        },
674    );
675
676    Ok(())
677}
678
679fn check_binding_literal_type(lit: &Literal, expected: &PropType, property: &str) -> Result<()> {
680    if expected.list {
681        let lit_type = literal_type(lit)?;
682        if lit_type.list {
683            return Err(NanoError::Type(format!(
684                "T3: list equality is not supported for property `{}`; use a scalar value to match list membership",
685                property
686            )));
687        }
688
689        let expected_member = PropType::scalar(expected.scalar, expected.nullable);
690        if !types_compatible(&lit_type, &expected_member) {
691            return Err(NanoError::Type(format!(
692                "T3: property `{}` has type {} but membership match got {}",
693                property,
694                expected.display_name(),
695                lit_type.display_name()
696            )));
697        }
698        return Ok(());
699    }
700
701    check_literal_type(lit, expected, property)
702}
703
704fn check_binding_variable_type(
705    actual: &PropType,
706    expected: &PropType,
707    property: &str,
708) -> Result<()> {
709    if expected.list {
710        if actual.list {
711            return Err(NanoError::Type(format!(
712                "T7: list equality is not supported for property `{}`; use a scalar parameter for membership matching",
713                property
714            )));
715        }
716
717        let expected_member = PropType::scalar(expected.scalar, expected.nullable);
718        if !types_compatible(actual, &expected_member) {
719            return Err(NanoError::Type(format!(
720                "T7: cannot compare {} membership against {} for property `{}`",
721                actual.display_name(),
722                expected.display_name(),
723                property
724            )));
725        }
726        return Ok(());
727    }
728
729    if !types_compatible(actual, expected) {
730        return Err(NanoError::Type(format!(
731            "T7: cannot assign/compare {} with {} for property `{}`",
732            actual.display_name(),
733            expected.display_name(),
734            property
735        )));
736    }
737    Ok(())
738}
739
740fn typecheck_traversal(
741    catalog: &Catalog,
742    traversal: &Traversal,
743    ctx: &mut TypeContext,
744) -> Result<()> {
745    // T4: edge must exist
746    let edge = catalog
747        .lookup_edge_by_name(&traversal.edge_name)
748        .ok_or_else(|| {
749            NanoError::Type(format!("T4: unknown edge type `{}`", traversal.edge_name))
750        })?;
751
752    if traversal.min_hops == 0 {
753        return Err(NanoError::Type(
754            "T15: traversal min hop bound must be >= 1".to_string(),
755        ));
756    }
757    if let Some(max_hops) = traversal.max_hops {
758        if max_hops < traversal.min_hops {
759            return Err(NanoError::Type(format!(
760                "T15: invalid traversal bounds {{{},{}}}; max must be >= min",
761                traversal.min_hops, max_hops
762            )));
763        }
764    } else {
765        return Err(NanoError::Type(
766            "T15: unbounded traversal is disabled; use bounded traversal {min,max}".to_string(),
767        ));
768    }
769
770    // Determine direction based on bound variables and edge endpoints
771    let src_bound = ctx.bindings.get(&traversal.src);
772    let dst_bound = ctx.bindings.get(&traversal.dst);
773
774    let direction;
775
776    if let Some(src_bv) = src_bound {
777        // T5: src type must match one endpoint of the edge
778        if src_bv.type_name == edge.from_type {
779            direction = Direction::Out;
780            // dst should be edge.to_type
781            bind_traversal_endpoint(ctx, &traversal.dst, &edge.to_type, edge)?;
782        } else if src_bv.type_name == edge.to_type {
783            direction = Direction::In;
784            // dst should be edge.from_type
785            bind_traversal_endpoint(ctx, &traversal.dst, &edge.from_type, edge)?;
786        } else {
787            return Err(NanoError::Type(format!(
788                "T5: variable `${}` has type `{}`, which is not an endpoint of edge `{}: {} -> {}`",
789                traversal.src, src_bv.type_name, edge.name, edge.from_type, edge.to_type
790            )));
791        }
792    } else if let Some(dst_bv) = dst_bound {
793        // dst is bound, infer direction from it
794        if dst_bv.type_name == edge.to_type {
795            direction = Direction::Out;
796            bind_traversal_endpoint(ctx, &traversal.src, &edge.from_type, edge)?;
797        } else if dst_bv.type_name == edge.from_type {
798            direction = Direction::In;
799            bind_traversal_endpoint(ctx, &traversal.src, &edge.to_type, edge)?;
800        } else {
801            return Err(NanoError::Type(format!(
802                "T5: variable `${}` has type `{}`, which is not an endpoint of edge `{}: {} -> {}`",
803                traversal.dst, dst_bv.type_name, edge.name, edge.from_type, edge.to_type
804            )));
805        }
806    } else {
807        // Neither bound — default Out direction, bind both
808        direction = Direction::Out;
809        bind_traversal_endpoint(ctx, &traversal.src, &edge.from_type, edge)?;
810        bind_traversal_endpoint(ctx, &traversal.dst, &edge.to_type, edge)?;
811    }
812
813    ctx.traversals.push(ResolvedTraversal {
814        src: traversal.src.clone(),
815        dst: traversal.dst.clone(),
816        edge_type: edge.name.clone(),
817        direction,
818        min_hops: traversal.min_hops,
819        max_hops: traversal.max_hops,
820    });
821
822    Ok(())
823}
824
825fn bind_traversal_endpoint(
826    ctx: &mut TypeContext,
827    var: &str,
828    expected_type: &str,
829    edge: &crate::catalog::EdgeType,
830) -> Result<()> {
831    if var == "_" {
832        return Ok(()); // anonymous variable
833    }
834    if let Some(existing) = ctx.bindings.get(var) {
835        if existing.type_name != expected_type {
836            return Err(NanoError::Type(format!(
837                "T5: variable `${}` has type `{}` but edge `{}` expects `{}`",
838                var, existing.type_name, edge.name, expected_type
839            )));
840        }
841    } else {
842        ctx.bindings.insert(
843            var.to_string(),
844            BoundVariable {
845                var_name: var.to_string(),
846                type_name: expected_type.to_string(),
847                kind: BindingKind::Node,
848            },
849        );
850    }
851    Ok(())
852}
853
854fn typecheck_filter(
855    catalog: &Catalog,
856    filter: &Filter,
857    ctx: &TypeContext,
858    params: &HashMap<String, PropType>,
859) -> Result<()> {
860    let left_type = resolve_expr_type(catalog, &filter.left, ctx, params)?;
861    let right_type = resolve_expr_type(catalog, &filter.right, ctx, params)?;
862
863    if let (ResolvedType::Scalar(l), ResolvedType::Scalar(r)) = (&left_type, &right_type) {
864        if filter.op == CompOp::Contains {
865            if !l.list {
866                return Err(NanoError::Type(format!(
867                    "T7: contains requires a list property on the left, got {}",
868                    l.display_name()
869                )));
870            }
871            if r.list {
872                return Err(NanoError::Type(
873                    "T7: contains requires a scalar right operand".to_string(),
874                ));
875            }
876            if matches!(l.scalar, ScalarType::Vector(_))
877                || matches!(r.scalar, ScalarType::Vector(_))
878            {
879                return Err(NanoError::Type(
880                    "T7: vector membership filters are not supported".to_string(),
881                ));
882            }
883
884            let expected_member = PropType::scalar(l.scalar, l.nullable);
885            if !types_compatible(&expected_member, r) {
886                return Err(NanoError::Type(format!(
887                    "T7: cannot test membership of {} in {}",
888                    r.display_name(),
889                    l.display_name()
890                )));
891            }
892            return Ok(());
893        }
894
895        // T7: check type compatibility
896        if l.list || r.list {
897            return Err(NanoError::Type(
898                "T7: list comparisons in filters are not supported; use `contains` for list membership".to_string(),
899            ));
900        }
901        if matches!(l.scalar, ScalarType::Vector(_)) || matches!(r.scalar, ScalarType::Vector(_)) {
902            return Err(NanoError::Type(
903                "T7: vector comparisons in filters are not supported".to_string(),
904            ));
905        }
906        if matches!(l.scalar, ScalarType::Blob) || matches!(r.scalar, ScalarType::Blob) {
907            return Err(NanoError::Type(
908                "T7: blob comparisons in filters are not supported".to_string(),
909            ));
910        }
911        if !types_compatible(l, r) {
912            return Err(NanoError::Type(format!(
913                "T7: cannot compare {} with {}",
914                l.display_name(),
915                r.display_name()
916            )));
917        }
918    } else {
919        return Err(NanoError::Type(format!(
920            "T7: filter comparisons require scalar operands, got {} and {}",
921            left_type.display_name(),
922            right_type.display_name()
923        )));
924    }
925
926    Ok(())
927}
928
929fn resolve_expr_type(
930    catalog: &Catalog,
931    expr: &Expr,
932    ctx: &TypeContext,
933    params: &HashMap<String, PropType>,
934) -> Result<ResolvedType> {
935    match expr {
936        Expr::Now => Ok(ResolvedType::Scalar(PropType::scalar(
937            ScalarType::DateTime,
938            false,
939        ))),
940        Expr::PropAccess { variable, property } => {
941            // T6: variable must be bound and property must exist
942            let bv = ctx.bindings.get(variable).ok_or_else(|| {
943                NanoError::Type(format!("T6: variable `${}` is not bound", variable))
944            })?;
945
946            let node_type = catalog.node_types.get(&bv.type_name).ok_or_else(|| {
947                NanoError::Type(format!("T6: type `{}` not found in catalog", bv.type_name))
948            })?;
949
950            let prop = node_type.properties.get(property).ok_or_else(|| {
951                NanoError::Type(format!(
952                    "T6: type `{}` has no property `{}`",
953                    bv.type_name, property
954                ))
955            })?;
956
957            Ok(ResolvedType::Scalar(prop.clone()))
958        }
959        Expr::Nearest {
960            variable,
961            property,
962            query,
963        } => {
964            let node_binding = ctx.bindings.get(variable).ok_or_else(|| {
965                NanoError::Type(format!("T15: variable `${}` is not bound", variable))
966            })?;
967            let node_type = catalog
968                .node_types
969                .get(&node_binding.type_name)
970                .ok_or_else(|| {
971                    NanoError::Type(format!(
972                        "T15: type `{}` not found in catalog",
973                        node_binding.type_name
974                    ))
975                })?;
976            let prop_type = node_type.properties.get(property).ok_or_else(|| {
977                NanoError::Type(format!(
978                    "T15: type `{}` has no property `{}`",
979                    node_binding.type_name, property
980                ))
981            })?;
982            let vector_dim = match prop_type.scalar {
983                ScalarType::Vector(dim) => dim,
984                _ => {
985                    return Err(NanoError::Type(format!(
986                        "T15: nearest requires a Vector property, got {}.{}: {}",
987                        node_binding.type_name,
988                        property,
989                        prop_type.display_name()
990                    )));
991                }
992            };
993            if prop_type.list {
994                return Err(NanoError::Type(
995                    "T15: nearest does not support list-wrapped vectors".to_string(),
996                ));
997            }
998
999            if let Expr::Literal(lit) = query.as_ref()
1000                && let Some(dim) = numeric_vector_literal_dim(lit)
1001            {
1002                if dim != vector_dim {
1003                    return Err(NanoError::Type(format!(
1004                        "T15: nearest vector dimension mismatch: property is Vector({}), query literal has {} elements",
1005                        vector_dim, dim
1006                    )));
1007                }
1008                return Ok(ResolvedType::Scalar(PropType::scalar(
1009                    ScalarType::F64,
1010                    false,
1011                )));
1012            }
1013
1014            let query_type = resolve_expr_type(catalog, query, ctx, params)?;
1015            match query_type {
1016                ResolvedType::Scalar(s) if matches!(s.scalar, ScalarType::Vector(_)) && !s.list => {
1017                    let qdim = match s.scalar {
1018                        ScalarType::Vector(dim) => dim,
1019                        _ => unreachable!(),
1020                    };
1021                    if qdim != vector_dim {
1022                        return Err(NanoError::Type(format!(
1023                            "T15: nearest vector dimension mismatch: property is Vector({}), query is Vector({})",
1024                            vector_dim, qdim
1025                        )));
1026                    }
1027                }
1028                ResolvedType::Scalar(s) if s.scalar == ScalarType::String && !s.list => {
1029                    // query-time string embedding is supported by the runtime executor
1030                }
1031                ResolvedType::Scalar(s) => {
1032                    return Err(NanoError::Type(format!(
1033                        "T15: nearest query must be Vector({}) or String, got {}",
1034                        vector_dim,
1035                        s.display_name()
1036                    )));
1037                }
1038                _ => {
1039                    return Err(NanoError::Type(
1040                        "T15: nearest query must be a scalar expression".to_string(),
1041                    ));
1042                }
1043            }
1044
1045            Ok(ResolvedType::Scalar(PropType::scalar(
1046                ScalarType::F64,
1047                false,
1048            )))
1049        }
1050        Expr::Search { field, query } => {
1051            let field_type = resolve_expr_type(catalog, field, ctx, params)?;
1052            match field_type {
1053                ResolvedType::Scalar(s) if s.scalar == ScalarType::String && !s.list => {}
1054                ResolvedType::Scalar(s) => {
1055                    return Err(NanoError::Type(format!(
1056                        "T19: search field must be String, got {}",
1057                        s.display_name()
1058                    )));
1059                }
1060                _ => {
1061                    return Err(NanoError::Type(
1062                        "T19: search field must be a scalar String expression".to_string(),
1063                    ));
1064                }
1065            }
1066
1067            let query_type = resolve_expr_type(catalog, query, ctx, params)?;
1068            match query_type {
1069                ResolvedType::Scalar(s) if s.scalar == ScalarType::String && !s.list => {}
1070                ResolvedType::Scalar(s) => {
1071                    return Err(NanoError::Type(format!(
1072                        "T19: search query must be String, got {}",
1073                        s.display_name()
1074                    )));
1075                }
1076                _ => {
1077                    return Err(NanoError::Type(
1078                        "T19: search query must be a scalar String expression".to_string(),
1079                    ));
1080                }
1081            }
1082
1083            Ok(ResolvedType::Scalar(PropType::scalar(
1084                ScalarType::Bool,
1085                false,
1086            )))
1087        }
1088        Expr::Fuzzy {
1089            field,
1090            query,
1091            max_edits,
1092        } => {
1093            let field_type = resolve_expr_type(catalog, field, ctx, params)?;
1094            match field_type {
1095                ResolvedType::Scalar(s) if s.scalar == ScalarType::String && !s.list => {}
1096                ResolvedType::Scalar(s) => {
1097                    return Err(NanoError::Type(format!(
1098                        "T19: fuzzy field must be String, got {}",
1099                        s.display_name()
1100                    )));
1101                }
1102                _ => {
1103                    return Err(NanoError::Type(
1104                        "T19: fuzzy field must be a scalar String expression".to_string(),
1105                    ));
1106                }
1107            }
1108
1109            let query_type = resolve_expr_type(catalog, query, ctx, params)?;
1110            match query_type {
1111                ResolvedType::Scalar(s) if s.scalar == ScalarType::String && !s.list => {}
1112                ResolvedType::Scalar(s) => {
1113                    return Err(NanoError::Type(format!(
1114                        "T19: fuzzy query must be String, got {}",
1115                        s.display_name()
1116                    )));
1117                }
1118                _ => {
1119                    return Err(NanoError::Type(
1120                        "T19: fuzzy query must be a scalar String expression".to_string(),
1121                    ));
1122                }
1123            }
1124
1125            if let Some(max_edits_expr) = max_edits {
1126                let max_edits_type = resolve_expr_type(catalog, max_edits_expr, ctx, params)?;
1127                match max_edits_type {
1128                    ResolvedType::Scalar(s)
1129                        if !s.list
1130                            && matches!(
1131                                s.scalar,
1132                                ScalarType::I32
1133                                    | ScalarType::I64
1134                                    | ScalarType::U32
1135                                    | ScalarType::U64
1136                            ) => {}
1137                    ResolvedType::Scalar(s) => {
1138                        return Err(NanoError::Type(format!(
1139                            "T19: fuzzy max_edits must be an integer scalar, got {}",
1140                            s.display_name()
1141                        )));
1142                    }
1143                    _ => {
1144                        return Err(NanoError::Type(
1145                            "T19: fuzzy max_edits must be an integer scalar expression".to_string(),
1146                        ));
1147                    }
1148                }
1149            }
1150
1151            Ok(ResolvedType::Scalar(PropType::scalar(
1152                ScalarType::Bool,
1153                false,
1154            )))
1155        }
1156        Expr::MatchText { field, query } => {
1157            let field_type = resolve_expr_type(catalog, field, ctx, params)?;
1158            match field_type {
1159                ResolvedType::Scalar(s) if s.scalar == ScalarType::String && !s.list => {}
1160                ResolvedType::Scalar(s) => {
1161                    return Err(NanoError::Type(format!(
1162                        "T20: match_text field must be String, got {}",
1163                        s.display_name()
1164                    )));
1165                }
1166                _ => {
1167                    return Err(NanoError::Type(
1168                        "T20: match_text field must be a scalar String expression".to_string(),
1169                    ));
1170                }
1171            }
1172
1173            let query_type = resolve_expr_type(catalog, query, ctx, params)?;
1174            match query_type {
1175                ResolvedType::Scalar(s) if s.scalar == ScalarType::String && !s.list => {}
1176                ResolvedType::Scalar(s) => {
1177                    return Err(NanoError::Type(format!(
1178                        "T20: match_text query must be String, got {}",
1179                        s.display_name()
1180                    )));
1181                }
1182                _ => {
1183                    return Err(NanoError::Type(
1184                        "T20: match_text query must be a scalar String expression".to_string(),
1185                    ));
1186                }
1187            }
1188
1189            Ok(ResolvedType::Scalar(PropType::scalar(
1190                ScalarType::Bool,
1191                false,
1192            )))
1193        }
1194        Expr::Bm25 { field, query } => {
1195            let field_type = resolve_expr_type(catalog, field, ctx, params)?;
1196            match field_type {
1197                ResolvedType::Scalar(s) if s.scalar == ScalarType::String && !s.list => {}
1198                ResolvedType::Scalar(s) => {
1199                    return Err(NanoError::Type(format!(
1200                        "T20: bm25 field must be String, got {}",
1201                        s.display_name()
1202                    )));
1203                }
1204                _ => {
1205                    return Err(NanoError::Type(
1206                        "T20: bm25 field must be a scalar String expression".to_string(),
1207                    ));
1208                }
1209            }
1210
1211            let query_type = resolve_expr_type(catalog, query, ctx, params)?;
1212            match query_type {
1213                ResolvedType::Scalar(s) if s.scalar == ScalarType::String && !s.list => {}
1214                ResolvedType::Scalar(s) => {
1215                    return Err(NanoError::Type(format!(
1216                        "T20: bm25 query must be String, got {}",
1217                        s.display_name()
1218                    )));
1219                }
1220                _ => {
1221                    return Err(NanoError::Type(
1222                        "T20: bm25 query must be a scalar String expression".to_string(),
1223                    ));
1224                }
1225            }
1226
1227            Ok(ResolvedType::Scalar(PropType::scalar(
1228                ScalarType::F64,
1229                false,
1230            )))
1231        }
1232        Expr::Rrf {
1233            primary,
1234            secondary,
1235            k,
1236        } => {
1237            if !matches!(primary.as_ref(), Expr::Nearest { .. } | Expr::Bm25 { .. }) {
1238                return Err(NanoError::Type(
1239                    "T21: rrf primary expression must be nearest(...) or bm25(...)".to_string(),
1240                ));
1241            }
1242            if !matches!(secondary.as_ref(), Expr::Nearest { .. } | Expr::Bm25 { .. }) {
1243                return Err(NanoError::Type(
1244                    "T21: rrf secondary expression must be nearest(...) or bm25(...)".to_string(),
1245                ));
1246            }
1247
1248            let primary_ty = resolve_expr_type(catalog, primary, ctx, params)?;
1249            let secondary_ty = resolve_expr_type(catalog, secondary, ctx, params)?;
1250
1251            for ty in [primary_ty, secondary_ty] {
1252                match ty {
1253                    ResolvedType::Scalar(s) if s.scalar == ScalarType::F64 && !s.list => {}
1254                    ResolvedType::Scalar(s) => {
1255                        return Err(NanoError::Type(format!(
1256                            "T21: rrf rank expressions must evaluate to F64, got {}",
1257                            s.display_name()
1258                        )));
1259                    }
1260                    _ => {
1261                        return Err(NanoError::Type(
1262                            "T21: rrf rank expressions must be scalar numeric expressions"
1263                                .to_string(),
1264                        ));
1265                    }
1266                }
1267            }
1268
1269            if let Some(k_expr) = k {
1270                let k_type = resolve_expr_type(catalog, k_expr, ctx, params)?;
1271                match k_type {
1272                    ResolvedType::Scalar(s)
1273                        if !s.list
1274                            && matches!(
1275                                s.scalar,
1276                                ScalarType::I32
1277                                    | ScalarType::I64
1278                                    | ScalarType::U32
1279                                    | ScalarType::U64
1280                            ) => {}
1281                    ResolvedType::Scalar(s) => {
1282                        return Err(NanoError::Type(format!(
1283                            "T21: rrf k must be an integer scalar, got {}",
1284                            s.display_name()
1285                        )));
1286                    }
1287                    _ => {
1288                        return Err(NanoError::Type(
1289                            "T21: rrf k must be an integer scalar expression".to_string(),
1290                        ));
1291                    }
1292                }
1293                if let Expr::Literal(Literal::Integer(v)) = k_expr.as_ref()
1294                    && *v <= 0
1295                {
1296                    return Err(NanoError::Type(
1297                        "T21: rrf k must be greater than 0".to_string(),
1298                    ));
1299                }
1300            }
1301
1302            Ok(ResolvedType::Scalar(PropType::scalar(
1303                ScalarType::F64,
1304                false,
1305            )))
1306        }
1307        Expr::Variable(name) => {
1308            // Could be a query parameter or a bound variable
1309            if let Some(prop_type) = params.get(name) {
1310                Ok(ResolvedType::Scalar(prop_type.clone()))
1311            } else if let Some(bv) = ctx.bindings.get(name) {
1312                Ok(ResolvedType::Node(bv.type_name.clone()))
1313            } else {
1314                Err(NanoError::Type(format!(
1315                    "variable `${}` is not bound",
1316                    name
1317                )))
1318            }
1319        }
1320        Expr::Literal(lit) => Ok(ResolvedType::Scalar(literal_type(lit)?)),
1321        Expr::Aggregate { func, arg } => {
1322            let arg_type = resolve_expr_type(catalog, arg, ctx, params)?;
1323
1324            // T8: sum/avg require numeric; min/max require numeric or string
1325            match func {
1326                AggFunc::Sum | AggFunc::Avg => {
1327                    if let ResolvedType::Scalar(s) = &arg_type
1328                        && (s.list || !s.scalar.is_numeric())
1329                    {
1330                        return Err(NanoError::Type(format!(
1331                            "T8: {} requires numeric type, got {}",
1332                            func,
1333                            s.display_name()
1334                        )));
1335                    }
1336                }
1337                AggFunc::Min | AggFunc::Max => {
1338                    if let ResolvedType::Scalar(s) = &arg_type
1339                        && (s.list || (!s.scalar.is_numeric() && s.scalar != ScalarType::String))
1340                    {
1341                        return Err(NanoError::Type(format!(
1342                            "T8: {} requires numeric or string type, got {}",
1343                            func,
1344                            s.display_name()
1345                        )));
1346                    }
1347                }
1348                _ => {} // count works on any type
1349            }
1350
1351            Ok(ResolvedType::Aggregate)
1352        }
1353        Expr::AliasRef(name) => {
1354            // Check if it's a known alias from return clause
1355            if let Some(resolved) = ctx.aliases.get(name) {
1356                Ok(resolved.clone())
1357            } else {
1358                // Might be an alias not yet registered (forward reference in order)
1359                Ok(ResolvedType::Aggregate)
1360            }
1361        }
1362    }
1363}
1364
1365fn infer_projection_field(
1366    catalog: &Catalog,
1367    expr: &Expr,
1368    alias: Option<&str>,
1369    ctx: &TypeContext,
1370    params: &HashMap<String, PropType>,
1371) -> Result<Field> {
1372    let name = projection_name(expr, alias);
1373    match expr {
1374        Expr::Aggregate { func, arg } => {
1375            let (data_type, nullable) = match func {
1376                AggFunc::Count => (DataType::Int64, true),
1377                AggFunc::Avg | AggFunc::Sum => (DataType::Float64, true),
1378                AggFunc::Min | AggFunc::Max => {
1379                    let resolved = resolve_expr_type(catalog, arg, ctx, params)?;
1380                    let (data_type, _) = resolved_type_to_field_shape(catalog, &resolved)?;
1381                    (data_type, true)
1382                }
1383            };
1384            Ok(Field::new(name, data_type, nullable))
1385        }
1386        _ => {
1387            let resolved = resolve_expr_type(catalog, expr, ctx, params)?;
1388            let (data_type, nullable) = resolved_type_to_field_shape(catalog, &resolved)?;
1389            Ok(Field::new(name, data_type, nullable))
1390        }
1391    }
1392}
1393
1394fn projection_name(expr: &Expr, alias: Option<&str>) -> String {
1395    if let Some(alias) = alias {
1396        return alias.to_string();
1397    }
1398
1399    match expr {
1400        Expr::Now => "now".to_string(),
1401        Expr::PropAccess { property, .. } => property.clone(),
1402        Expr::Variable(variable) => variable.clone(),
1403        Expr::Literal(_) => "literal".to_string(),
1404        Expr::Nearest { .. } => "nearest".to_string(),
1405        Expr::Search { .. } => "search".to_string(),
1406        Expr::Fuzzy { .. } => "fuzzy".to_string(),
1407        Expr::MatchText { .. } => "match_text".to_string(),
1408        Expr::Bm25 { .. } => "bm25".to_string(),
1409        Expr::Rrf { .. } => "rrf".to_string(),
1410        Expr::Aggregate { func, .. } => func.to_string(),
1411        Expr::AliasRef(name) => name.clone(),
1412    }
1413}
1414
1415fn resolved_type_to_field_shape(
1416    catalog: &Catalog,
1417    resolved: &ResolvedType,
1418) -> Result<(DataType, bool)> {
1419    match resolved {
1420        ResolvedType::Scalar(prop_type) => Ok((prop_type.to_arrow(), prop_type.nullable)),
1421        ResolvedType::Node(type_name) => {
1422            let node_type = catalog.node_types.get(type_name).ok_or_else(|| {
1423                NanoError::Type(format!("type `{}` not found in catalog", type_name))
1424            })?;
1425            let fields: Vec<Field> = node_type
1426                .arrow_schema
1427                .fields()
1428                .iter()
1429                .map(|field| field.as_ref().clone())
1430                .collect();
1431            Ok((DataType::Struct(fields.into()), false))
1432        }
1433        ResolvedType::Aggregate => Ok((DataType::Int64, true)),
1434    }
1435}
1436
1437fn literal_type(lit: &Literal) -> Result<PropType> {
1438    match lit {
1439        // Null is compatible with any nullable type; default to String for inference.
1440        Literal::Null => Ok(PropType::scalar(ScalarType::String, true)),
1441        Literal::String(_) => Ok(PropType::scalar(ScalarType::String, false)),
1442        Literal::Integer(_) => Ok(PropType::scalar(ScalarType::I64, false)),
1443        Literal::Float(_) => Ok(PropType::scalar(ScalarType::F64, false)),
1444        Literal::Bool(_) => Ok(PropType::scalar(ScalarType::Bool, false)),
1445        Literal::Date(_) => Ok(PropType::scalar(ScalarType::Date, false)),
1446        Literal::DateTime(_) => Ok(PropType::scalar(ScalarType::DateTime, false)),
1447        Literal::List(items) => {
1448            if items.is_empty() {
1449                return Ok(PropType::list_of(ScalarType::String, false));
1450            }
1451            let first = literal_type(&items[0])?;
1452            if first.list {
1453                return Err(NanoError::Type(
1454                    "nested list literals are not supported".to_string(),
1455                ));
1456            }
1457            for item in items.iter().skip(1) {
1458                let item_type = literal_type(item)?;
1459                if item_type.list || !types_compatible(&first, &item_type) {
1460                    return Err(NanoError::Type(
1461                        "list literal elements must share a compatible scalar type".to_string(),
1462                    ));
1463                }
1464            }
1465            Ok(PropType::list_of(first.scalar, false))
1466        }
1467    }
1468}
1469
1470fn check_literal_type(lit: &Literal, expected: &PropType, prop_name: &str) -> Result<()> {
1471    // Null is compatible with any nullable property type.
1472    if matches!(lit, Literal::Null) {
1473        return if expected.nullable {
1474            Ok(())
1475        } else {
1476            Err(NanoError::Type(format!(
1477                "T3: property `{}` is non-nullable but got null",
1478                prop_name
1479            )))
1480        };
1481    }
1482
1483    if !expected.list
1484        && let ScalarType::Vector(expected_dim) = expected.scalar
1485        && let Some(actual_dim) = numeric_vector_literal_dim(lit)
1486    {
1487        if actual_dim == expected_dim {
1488            return Ok(());
1489        }
1490        return Err(NanoError::Type(format!(
1491            "T3: property `{}` has type Vector({}) but got vector literal with {} elements",
1492            prop_name, expected_dim, actual_dim
1493        )));
1494    }
1495
1496    let lit_type = literal_type(lit)?;
1497    if !types_compatible(&lit_type, expected) {
1498        return Err(NanoError::Type(format!(
1499            "T3: property `{}` has type {} but got {}",
1500            prop_name,
1501            expected.display_name(),
1502            lit_type.display_name()
1503        )));
1504    }
1505    if expected.is_enum() {
1506        let allowed = expected.enum_values.as_ref().cloned().unwrap_or_default();
1507        match lit {
1508            Literal::String(v) => {
1509                if !allowed.contains(v) {
1510                    return Err(NanoError::Type(format!(
1511                        "T3: property `{}` expects one of [{}], got '{}'",
1512                        prop_name,
1513                        allowed.join(", "),
1514                        v
1515                    )));
1516                }
1517            }
1518            Literal::List(items) if expected.list => {
1519                for item in items {
1520                    match item {
1521                        Literal::String(v) if allowed.contains(v) => {}
1522                        Literal::String(v) => {
1523                            return Err(NanoError::Type(format!(
1524                                "T3: property `{}` expects one of [{}], got '{}'",
1525                                prop_name,
1526                                allowed.join(", "),
1527                                v
1528                            )));
1529                        }
1530                        _ => {}
1531                    }
1532                }
1533            }
1534            _ => {}
1535        }
1536    }
1537    Ok(())
1538}
1539
1540fn types_compatible(a: &PropType, b: &PropType) -> bool {
1541    if a.list != b.list {
1542        return false;
1543    }
1544    if a.scalar == b.scalar {
1545        return true;
1546    }
1547    // Numeric types are mutually compatible for comparison
1548    if a.scalar.is_numeric() && b.scalar.is_numeric() {
1549        return true;
1550    }
1551    false
1552}
1553
1554fn numeric_vector_literal_dim(lit: &Literal) -> Option<u32> {
1555    let items = match lit {
1556        Literal::List(items) => items,
1557        _ => return None,
1558    };
1559    if items.is_empty() {
1560        return None;
1561    }
1562    if items
1563        .iter()
1564        .all(|v| matches!(v, Literal::Integer(_) | Literal::Float(_)))
1565    {
1566        Some(items.len() as u32)
1567    } else {
1568        None
1569    }
1570}
1571
1572fn expr_references_any(expr: &Expr, vars: &[String]) -> bool {
1573    match expr {
1574        Expr::PropAccess { variable, .. } => vars.contains(variable),
1575        Expr::Nearest {
1576            variable, query, ..
1577        } => vars.contains(variable) || expr_references_any(query, vars),
1578        Expr::Search { field, query } => {
1579            expr_references_any(field, vars) || expr_references_any(query, vars)
1580        }
1581        Expr::Fuzzy {
1582            field,
1583            query,
1584            max_edits,
1585        } => {
1586            expr_references_any(field, vars)
1587                || expr_references_any(query, vars)
1588                || max_edits
1589                    .as_deref()
1590                    .is_some_and(|m| expr_references_any(m, vars))
1591        }
1592        Expr::MatchText { field, query } => {
1593            expr_references_any(field, vars) || expr_references_any(query, vars)
1594        }
1595        Expr::Bm25 { field, query } => {
1596            expr_references_any(field, vars) || expr_references_any(query, vars)
1597        }
1598        Expr::Rrf {
1599            primary,
1600            secondary,
1601            k,
1602        } => {
1603            expr_references_any(primary, vars)
1604                || expr_references_any(secondary, vars)
1605                || k.as_deref()
1606                    .is_some_and(|expr| expr_references_any(expr, vars))
1607        }
1608        Expr::Variable(v) => vars.contains(v),
1609        Expr::Aggregate { arg, .. } => expr_references_any(arg, vars),
1610        _ => false,
1611    }
1612}
1613
1614fn expr_contains_standalone_nearest_with_aliases(
1615    expr: &Expr,
1616    alias_exprs: &HashMap<String, &Expr>,
1617) -> bool {
1618    expr_contains_standalone_nearest_inner(expr, alias_exprs, &mut HashSet::new())
1619}
1620
1621fn expr_contains_standalone_nearest_inner(
1622    expr: &Expr,
1623    alias_exprs: &HashMap<String, &Expr>,
1624    seen_aliases: &mut HashSet<String>,
1625) -> bool {
1626    match expr {
1627        Expr::Nearest { .. } => true,
1628        Expr::Aggregate { arg, .. } => {
1629            expr_contains_standalone_nearest_inner(arg, alias_exprs, seen_aliases)
1630        }
1631        Expr::Search { field, query }
1632        | Expr::MatchText { field, query }
1633        | Expr::Bm25 { field, query } => {
1634            expr_contains_standalone_nearest_inner(field, alias_exprs, seen_aliases)
1635                || expr_contains_standalone_nearest_inner(query, alias_exprs, seen_aliases)
1636        }
1637        Expr::Fuzzy {
1638            field,
1639            query,
1640            max_edits,
1641        } => {
1642            expr_contains_standalone_nearest_inner(field, alias_exprs, seen_aliases)
1643                || expr_contains_standalone_nearest_inner(query, alias_exprs, seen_aliases)
1644                || max_edits.as_deref().is_some_and(|expr| {
1645                    expr_contains_standalone_nearest_inner(expr, alias_exprs, seen_aliases)
1646                })
1647        }
1648        Expr::AliasRef(name) => {
1649            if !seen_aliases.insert(name.clone()) {
1650                return false;
1651            }
1652            let found = alias_exprs.get(name).is_some_and(|expr| {
1653                expr_contains_standalone_nearest_inner(expr, alias_exprs, seen_aliases)
1654            });
1655            seen_aliases.remove(name);
1656            found
1657        }
1658        // nearest() nested under rrf() is handled by T21 and should not trigger T17/T18 checks.
1659        Expr::Rrf { .. } => false,
1660        _ => false,
1661    }
1662}
1663
1664fn expr_contains_rrf_with_aliases(expr: &Expr, alias_exprs: &HashMap<String, &Expr>) -> bool {
1665    expr_contains_rrf_inner(expr, alias_exprs, &mut HashSet::new())
1666}
1667
1668fn expr_contains_rrf_inner(
1669    expr: &Expr,
1670    alias_exprs: &HashMap<String, &Expr>,
1671    seen_aliases: &mut HashSet<String>,
1672) -> bool {
1673    match expr {
1674        Expr::Rrf { .. } => true,
1675        Expr::Aggregate { arg, .. } => expr_contains_rrf_inner(arg, alias_exprs, seen_aliases),
1676        Expr::Search { field, query }
1677        | Expr::MatchText { field, query }
1678        | Expr::Bm25 { field, query } => {
1679            expr_contains_rrf_inner(field, alias_exprs, seen_aliases)
1680                || expr_contains_rrf_inner(query, alias_exprs, seen_aliases)
1681        }
1682        Expr::Fuzzy {
1683            field,
1684            query,
1685            max_edits,
1686        } => {
1687            expr_contains_rrf_inner(field, alias_exprs, seen_aliases)
1688                || expr_contains_rrf_inner(query, alias_exprs, seen_aliases)
1689                || max_edits
1690                    .as_deref()
1691                    .is_some_and(|expr| expr_contains_rrf_inner(expr, alias_exprs, seen_aliases))
1692        }
1693        Expr::AliasRef(name) => {
1694            if !seen_aliases.insert(name.clone()) {
1695                return false;
1696            }
1697            let found = alias_exprs
1698                .get(name)
1699                .is_some_and(|expr| expr_contains_rrf_inner(expr, alias_exprs, seen_aliases));
1700            seen_aliases.remove(name);
1701            found
1702        }
1703        _ => false,
1704    }
1705}
1706
1707#[cfg(test)]
1708#[path = "typecheck_tests.rs"]
1709mod tests;