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 ¶ms,
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_clauses(catalog, &query.match_clause, &mut ctx, ¶ms, false)?;
147
148 for proj in &query.return_clause {
150 let resolved = resolve_expr_type(catalog, &proj.expr, &ctx, ¶ms)?;
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 for ord in &query.order_clause {
159 resolve_expr_type(catalog, &ord.expr, &ctx, ¶ms)?;
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 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 ¶m_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 ¶m_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 ¶m_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 ¶m_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 ¶m_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 ¶m_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 ¶m_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 ¶m_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 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 let outer_vars: Vec<String> = ctx.bindings.keys().cloned().collect();
570
571 let mut inner_ctx = ctx.clone();
573 typecheck_clauses(catalog, inner, &mut inner_ctx, params, true)?;
574
575 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 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 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 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 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 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 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 if src_bv.type_name == edge.from_type {
779 direction = Direction::Out;
780 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 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 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 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(()); }
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 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 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 }
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 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 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 _ => {} }
1350
1351 Ok(ResolvedType::Aggregate)
1352 }
1353 Expr::AliasRef(name) => {
1354 if let Some(resolved) = ctx.aliases.get(name) {
1356 Ok(resolved.clone())
1357 } else {
1358 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 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 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 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 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)]
1708mod tests {
1709 use super::*;
1710 use crate::catalog::build_catalog;
1711 use crate::query::parser::parse_query;
1712 use crate::schema::parser::parse_schema;
1713
1714 fn setup() -> Catalog {
1715 let schema = parse_schema(
1716 r#"
1717node Person {
1718 name: String
1719 age: I32?
1720}
1721node Company {
1722 name: String
1723}
1724edge Knows: Person -> Person {
1725 since: Date?
1726}
1727edge WorksAt: Person -> Company {
1728 title: String?
1729}
1730"#,
1731 )
1732 .unwrap();
1733 build_catalog(&schema).unwrap()
1734 }
1735
1736 fn setup_vector() -> Catalog {
1737 let schema = parse_schema(
1738 r#"
1739node Doc {
1740 id_str: String
1741 embedding: Vector(3)
1742}
1743"#,
1744 )
1745 .unwrap();
1746 build_catalog(&schema).unwrap()
1747 }
1748
1749 fn setup_list() -> Catalog {
1750 let schema = parse_schema(
1751 r#"
1752node Person {
1753 name: String
1754 tags: [String]?
1755}
1756"#,
1757 )
1758 .unwrap();
1759 build_catalog(&schema).unwrap()
1760 }
1761
1762 fn setup_embed_vector() -> Catalog {
1763 let schema = parse_schema(
1764 r#"
1765node Doc {
1766 slug: String
1767 body: String?
1768 embedding: Vector(3) @embed(body)
1769}
1770"#,
1771 )
1772 .unwrap();
1773 build_catalog(&schema).unwrap()
1774 }
1775
1776 #[test]
1777 fn test_basic_binding() {
1778 let catalog = setup();
1779 let qf = parse_query(
1780 r#"
1781query q() {
1782 match { $p: Person }
1783 return { $p.name }
1784}
1785"#,
1786 )
1787 .unwrap();
1788 let ctx = typecheck_query(&catalog, &qf.queries[0]).unwrap();
1789 assert!(ctx.bindings.contains_key("p"));
1790 }
1791
1792 #[test]
1793 fn test_t1_unknown_type() {
1794 let catalog = setup();
1795 let qf = parse_query(
1796 r#"
1797query q() {
1798 match { $p: Foo }
1799 return { $p.name }
1800}
1801"#,
1802 )
1803 .unwrap();
1804 let err = typecheck_query(&catalog, &qf.queries[0]).unwrap_err();
1805 assert!(err.to_string().contains("T1"));
1806 }
1807
1808 #[test]
1809 fn test_t2_unknown_property_match() {
1810 let catalog = setup();
1811 let qf = parse_query(
1812 r#"
1813query q() {
1814 match { $p: Person { salary: 100 } }
1815 return { $p.name }
1816}
1817"#,
1818 )
1819 .unwrap();
1820 let err = typecheck_query(&catalog, &qf.queries[0]).unwrap_err();
1821 assert!(err.to_string().contains("T2"));
1822 }
1823
1824 #[test]
1825 fn test_t3_wrong_type_in_match() {
1826 let catalog = setup();
1827 let qf = parse_query(
1828 r#"
1829query q() {
1830 match { $p: Person { age: "old" } }
1831 return { $p.name }
1832}
1833"#,
1834 )
1835 .unwrap();
1836 let err = typecheck_query(&catalog, &qf.queries[0]).unwrap_err();
1837 assert!(err.to_string().contains("T3"));
1838 }
1839
1840 #[test]
1841 fn test_list_membership_match_accepts_scalar_literal() {
1842 let catalog = setup_list();
1843 let qf = parse_query(
1844 r#"
1845query q() {
1846 match { $p: Person { tags: "rust" } }
1847 return { $p.name }
1848}
1849"#,
1850 )
1851 .unwrap();
1852 let ctx = typecheck_query(&catalog, &qf.queries[0]).unwrap();
1853 assert!(ctx.bindings.contains_key("p"));
1854 }
1855
1856 #[test]
1857 fn test_list_membership_match_accepts_scalar_param() {
1858 let catalog = setup_list();
1859 let qf = parse_query(
1860 r#"
1861query q($tag: String) {
1862 match { $p: Person { tags: $tag } }
1863 return { $p.name }
1864}
1865"#,
1866 )
1867 .unwrap();
1868 let ctx = typecheck_query(&catalog, &qf.queries[0]).unwrap();
1869 assert!(ctx.bindings.contains_key("p"));
1870 }
1871
1872 #[test]
1873 fn test_list_equality_match_is_rejected() {
1874 let catalog = setup_list();
1875 let qf = parse_query(
1876 r#"
1877query q() {
1878 match { $p: Person { tags: ["rust"] } }
1879 return { $p.name }
1880}
1881"#,
1882 )
1883 .unwrap();
1884 let err = typecheck_query(&catalog, &qf.queries[0]).unwrap_err();
1885 let msg = err.to_string();
1886 assert!(msg.contains("list equality is not supported"));
1887 assert!(msg.contains("membership"));
1888 }
1889
1890 #[test]
1891 fn test_contains_filter_accepts_list_membership() {
1892 let catalog = setup_list();
1893 let qf = parse_query(
1894 r#"
1895query q($tag: String) {
1896 match {
1897 $p: Person
1898 $p.tags contains $tag
1899 }
1900 return { $p.name }
1901}
1902"#,
1903 )
1904 .unwrap();
1905 let ctx = typecheck_query(&catalog, &qf.queries[0]).unwrap();
1906 assert!(ctx.bindings.contains_key("p"));
1907 }
1908
1909 #[test]
1910 fn test_declared_list_params_typecheck() {
1911 let catalog = setup_list();
1912 let qf = parse_query(
1913 r#"
1914query q($tags: [String], $days: [Date]?) {
1915 match {
1916 $p: Person
1917 $p.tags contains "friend"
1918 }
1919 return { $p.tags, $tags, $days }
1920}
1921"#,
1922 )
1923 .unwrap();
1924 assert!(typecheck_query(&catalog, &qf.queries[0]).is_ok());
1925 }
1926
1927 #[test]
1928 fn test_contains_filter_requires_list_left_operand() {
1929 let catalog = setup();
1930 let qf = parse_query(
1931 r#"
1932query q() {
1933 match {
1934 $p: Person
1935 $p.name contains "Al"
1936 }
1937 return { $p.name }
1938}
1939"#,
1940 )
1941 .unwrap();
1942 let err = typecheck_query(&catalog, &qf.queries[0]).unwrap_err();
1943 assert!(
1944 err.to_string()
1945 .contains("contains requires a list property on the left")
1946 );
1947 }
1948
1949 #[test]
1950 fn test_contains_filter_rejects_list_right_operand() {
1951 let catalog = setup_list();
1952 let qf = parse_query(
1953 r#"
1954query q() {
1955 match {
1956 $p: Person
1957 $p.tags contains ["rust"]
1958 }
1959 return { $p.name }
1960}
1961"#,
1962 )
1963 .unwrap();
1964 let err = typecheck_query(&catalog, &qf.queries[0]).unwrap_err();
1965 assert!(
1966 err.to_string()
1967 .contains("contains requires a scalar right operand")
1968 );
1969 }
1970
1971 #[test]
1972 fn test_t4_unknown_edge() {
1973 let catalog = setup();
1974 let qf = parse_query(
1975 r#"
1976query q() {
1977 match {
1978 $p: Person
1979 $p likes $f
1980 }
1981 return { $p.name }
1982}
1983"#,
1984 )
1985 .unwrap();
1986 let err = typecheck_query(&catalog, &qf.queries[0]).unwrap_err();
1987 assert!(err.to_string().contains("T4"));
1988 }
1989
1990 #[test]
1991 fn test_t5_bad_endpoints() {
1992 let catalog = setup();
1993 let qf = parse_query(
1994 r#"
1995query q() {
1996 match {
1997 $c: Company
1998 $c knows $f
1999 }
2000 return { $c.name }
2001}
2002"#,
2003 )
2004 .unwrap();
2005 let err = typecheck_query(&catalog, &qf.queries[0]).unwrap_err();
2006 assert!(err.to_string().contains("T5"));
2007 }
2008
2009 #[test]
2010 fn test_t6_bad_property() {
2011 let catalog = setup();
2012 let qf = parse_query(
2013 r#"
2014query q() {
2015 match {
2016 $p: Person
2017 $p.salary > 100
2018 }
2019 return { $p.name }
2020}
2021"#,
2022 )
2023 .unwrap();
2024 let err = typecheck_query(&catalog, &qf.queries[0]).unwrap_err();
2025 assert!(err.to_string().contains("T6"));
2026 }
2027
2028 #[test]
2029 fn test_t7_bad_comparison() {
2030 let catalog = setup();
2031 let qf = parse_query(
2032 r#"
2033query q() {
2034 match {
2035 $p: Person
2036 $p.age > "old"
2037 }
2038 return { $p.name }
2039}
2040"#,
2041 )
2042 .unwrap();
2043 let err = typecheck_query(&catalog, &qf.queries[0]).unwrap_err();
2044 assert!(err.to_string().contains("T7"));
2045 }
2046
2047 #[test]
2048 fn test_t7_rejects_non_scalar_comparison() {
2049 let catalog = setup();
2050 let qf = parse_query(
2051 r#"
2052query q() {
2053 match {
2054 $p: Person
2055 $p != 5
2056 }
2057 return { $p.name }
2058}
2059"#,
2060 )
2061 .unwrap();
2062 let err = typecheck_query(&catalog, &qf.queries[0]).unwrap_err();
2063 assert!(err.to_string().contains("scalar operands"));
2064 }
2065
2066 #[test]
2067 fn test_nearest_requires_limit() {
2068 let catalog = setup_vector();
2069 let qf = parse_query(
2070 r#"
2071query q($q: Vector(3)) {
2072 match { $d: Doc }
2073 return { $d.id_str }
2074 order { nearest($d.embedding, $q) }
2075}
2076"#,
2077 )
2078 .unwrap();
2079 let err = typecheck_query(&catalog, &qf.queries[0]).unwrap_err();
2080 assert!(err.to_string().contains("T17"));
2081 }
2082
2083 #[test]
2084 fn test_nearest_vector_dim_mismatch() {
2085 let catalog = setup_vector();
2086 let qf = parse_query(
2087 r#"
2088query q($q: Vector(2)) {
2089 match { $d: Doc }
2090 return { $d.id_str }
2091 order { nearest($d.embedding, $q) }
2092 limit 3
2093}
2094"#,
2095 )
2096 .unwrap();
2097 let err = typecheck_query(&catalog, &qf.queries[0]).unwrap_err();
2098 assert!(err.to_string().contains("T15"));
2099 }
2100
2101 #[test]
2102 fn test_nearest_vector_param_ok() {
2103 let catalog = setup_vector();
2104 let qf = parse_query(
2105 r#"
2106query q($q: Vector(3)) {
2107 match { $d: Doc }
2108 return { $d.id_str }
2109 order { nearest($d.embedding, $q) }
2110 limit 3
2111}
2112"#,
2113 )
2114 .unwrap();
2115 let ctx = typecheck_query(&catalog, &qf.queries[0]).unwrap();
2116 assert!(ctx.bindings.contains_key("d"));
2117 }
2118
2119 #[test]
2120 fn test_nearest_string_param_ok() {
2121 let catalog = setup_vector();
2122 let qf = parse_query(
2123 r#"
2124query q($q: String) {
2125 match { $d: Doc }
2126 return { $d.id_str }
2127 order { nearest($d.embedding, $q) }
2128 limit 3
2129}
2130"#,
2131 )
2132 .unwrap();
2133 let ctx = typecheck_query(&catalog, &qf.queries[0]).unwrap();
2134 assert!(ctx.bindings.contains_key("d"));
2135 }
2136
2137 #[test]
2138 fn test_search_string_param_ok() {
2139 let catalog = setup();
2140 let qf = parse_query(
2141 r#"
2142query q($q: String) {
2143 match {
2144 $p: Person
2145 search($p.name, $q)
2146 }
2147 return { $p.name }
2148}
2149"#,
2150 )
2151 .unwrap();
2152 let ctx = typecheck_query(&catalog, &qf.queries[0]).unwrap();
2153 assert!(ctx.bindings.contains_key("p"));
2154 }
2155
2156 #[test]
2157 fn test_fuzzy_max_edits_param_ok() {
2158 let catalog = setup();
2159 let qf = parse_query(
2160 r#"
2161query q($q: String, $m: I64) {
2162 match {
2163 $p: Person
2164 fuzzy($p.name, $q, $m)
2165 }
2166 return { $p.name }
2167}
2168"#,
2169 )
2170 .unwrap();
2171 let ctx = typecheck_query(&catalog, &qf.queries[0]).unwrap();
2172 assert!(ctx.bindings.contains_key("p"));
2173 }
2174
2175 #[test]
2176 fn test_fuzzy_rejects_non_integer_max_edits() {
2177 let catalog = setup();
2178 let qf = parse_query(
2179 r#"
2180query q($q: String, $m: F64) {
2181 match {
2182 $p: Person
2183 fuzzy($p.name, $q, $m)
2184 }
2185 return { $p.name }
2186}
2187"#,
2188 )
2189 .unwrap();
2190 let err = typecheck_query(&catalog, &qf.queries[0]).unwrap_err();
2191 assert!(err.to_string().contains("T19"));
2192 }
2193
2194 #[test]
2195 fn test_match_text_string_param_ok() {
2196 let catalog = setup();
2197 let qf = parse_query(
2198 r#"
2199query q($q: String) {
2200 match {
2201 $p: Person
2202 match_text($p.name, $q)
2203 }
2204 return { $p.name }
2205}
2206"#,
2207 )
2208 .unwrap();
2209 let ctx = typecheck_query(&catalog, &qf.queries[0]).unwrap();
2210 assert!(ctx.bindings.contains_key("p"));
2211 }
2212
2213 #[test]
2214 fn test_bm25_string_param_ok() {
2215 let catalog = setup();
2216 let qf = parse_query(
2217 r#"
2218query q($q: String) {
2219 match { $p: Person }
2220 return { $p.name, bm25($p.name, $q) as score }
2221 order { bm25($p.name, $q) desc }
2222}
2223"#,
2224 )
2225 .unwrap();
2226 let ctx = typecheck_query(&catalog, &qf.queries[0]).unwrap();
2227 assert!(ctx.bindings.contains_key("p"));
2228 }
2229
2230 #[test]
2231 fn test_bm25_rejects_non_string_query() {
2232 let catalog = setup();
2233 let qf = parse_query(
2234 r#"
2235query q($q: I64) {
2236 match { $p: Person }
2237 return { bm25($p.name, $q) as score }
2238}
2239"#,
2240 )
2241 .unwrap();
2242 let err = typecheck_query(&catalog, &qf.queries[0]).unwrap_err();
2243 assert!(err.to_string().contains("T20"));
2244 }
2245
2246 #[test]
2247 fn test_rrf_requires_limit_in_order() {
2248 let catalog = setup_vector();
2249 let qf = parse_query(
2250 r#"
2251query q($vq: Vector(3), $tq: String) {
2252 match { $d: Doc }
2253 return { $d.id_str }
2254 order { rrf(nearest($d.embedding, $vq), bm25($d.id_str, $tq), 60) desc }
2255}
2256"#,
2257 )
2258 .unwrap();
2259 let err = typecheck_query(&catalog, &qf.queries[0]).unwrap_err();
2260 assert!(err.to_string().contains("T21"));
2261 }
2262
2263 #[test]
2264 fn test_rrf_ordering_ok_with_limit() {
2265 let catalog = setup_vector();
2266 let qf = parse_query(
2267 r#"
2268query q($vq: Vector(3), $tq: String) {
2269 match { $d: Doc }
2270 return { $d.id_str }
2271 order { rrf(nearest($d.embedding, $vq), bm25($d.id_str, $tq), 60) desc }
2272 limit 5
2273}
2274"#,
2275 )
2276 .unwrap();
2277 let ctx = typecheck_query(&catalog, &qf.queries[0]).unwrap();
2278 assert!(ctx.bindings.contains_key("d"));
2279 }
2280
2281 #[test]
2282 fn test_rrf_ordering_ok_with_string_nearest_limit() {
2283 let catalog = setup_vector();
2284 let qf = parse_query(
2285 r#"
2286query q($vq: String, $tq: String) {
2287 match { $d: Doc }
2288 return { $d.id_str }
2289 order { rrf(nearest($d.embedding, $vq), bm25($d.id_str, $tq), 60) desc }
2290 limit 5
2291}
2292"#,
2293 )
2294 .unwrap();
2295 let ctx = typecheck_query(&catalog, &qf.queries[0]).unwrap();
2296 assert!(ctx.bindings.contains_key("d"));
2297 }
2298
2299 #[test]
2300 fn test_rrf_with_nearest_allows_alias_ordering() {
2301 let catalog = setup_vector();
2302 let qf = parse_query(
2303 r#"
2304query q($vq: Vector(3), $tq: String) {
2305 match { $d: Doc }
2306 return {
2307 $d.id_str,
2308 rrf(nearest($d.embedding, $vq), bm25($d.id_str, $tq), 60) as score
2309 }
2310 order {
2311 rrf(nearest($d.embedding, $vq), bm25($d.id_str, $tq), 60) desc,
2312 score desc
2313 }
2314 limit 5
2315}
2316"#,
2317 )
2318 .unwrap();
2319 let ctx = typecheck_query(&catalog, &qf.queries[0]).unwrap();
2320 assert!(ctx.bindings.contains_key("d"));
2321 }
2322
2323 #[test]
2324 fn test_rrf_alias_ordering_requires_limit() {
2325 let catalog = setup_vector();
2326 let qf = parse_query(
2327 r#"
2328query q($vq: Vector(3), $tq: String) {
2329 match { $d: Doc }
2330 return {
2331 $d.id_str,
2332 rrf(nearest($d.embedding, $vq), bm25($d.id_str, $tq), 60) as score
2333 }
2334 order { score desc }
2335}
2336"#,
2337 )
2338 .unwrap();
2339 let err = typecheck_query(&catalog, &qf.queries[0]).unwrap_err();
2340 assert!(err.to_string().contains("T21"));
2341 }
2342
2343 #[test]
2344 fn test_rrf_alias_ordering_with_limit_is_valid() {
2345 let catalog = setup_vector();
2346 let qf = parse_query(
2347 r#"
2348query q($vq: Vector(3), $tq: String) {
2349 match { $d: Doc }
2350 return {
2351 $d.id_str,
2352 rrf(nearest($d.embedding, $vq), bm25($d.id_str, $tq), 60) as score
2353 }
2354 order { score desc }
2355 limit 5
2356}
2357"#,
2358 )
2359 .unwrap();
2360 let ctx = typecheck_query(&catalog, &qf.queries[0]).unwrap();
2361 assert!(ctx.bindings.contains_key("d"));
2362 }
2363
2364 #[test]
2365 fn test_standalone_nearest_with_alias_ordering_still_rejected() {
2366 let catalog = setup_vector();
2367 let qf = parse_query(
2368 r#"
2369query q($vq: Vector(3)) {
2370 match { $d: Doc }
2371 return {
2372 $d.id_str as score
2373 }
2374 order {
2375 nearest($d.embedding, $vq),
2376 score desc
2377 }
2378 limit 5
2379}
2380"#,
2381 )
2382 .unwrap();
2383 let err = typecheck_query(&catalog, &qf.queries[0]).unwrap_err();
2384 assert!(err.to_string().contains("T18"));
2385 }
2386
2387 #[test]
2388 fn test_rrf_rejects_non_rank_expression_argument() {
2389 let parse = parse_query(
2390 r#"
2391query q($q: String) {
2392 match { $d: Doc }
2393 return { $d.id_str }
2394 order { rrf(bm25($d.id_str, $q), search($d.id_str, $q), 60) desc }
2395 limit 5
2396}
2397"#,
2398 );
2399 assert!(parse.is_err());
2400 }
2401
2402 #[test]
2403 fn test_rrf_rejects_non_positive_k_literal() {
2404 let catalog = setup_vector();
2405 let qf = parse_query(
2406 r#"
2407query q($vq: Vector(3), $tq: String) {
2408 match { $d: Doc }
2409 return { $d.id_str }
2410 order { rrf(nearest($d.embedding, $vq), bm25($d.id_str, $tq), 0) desc }
2411 limit 5
2412}
2413"#,
2414 )
2415 .unwrap();
2416 let err = typecheck_query(&catalog, &qf.queries[0]).unwrap_err();
2417 assert!(err.to_string().contains("T21"));
2418 }
2419
2420 #[test]
2421 fn test_t8_sum_on_string() {
2422 let catalog = setup();
2423 let qf = parse_query(
2424 r#"
2425query q() {
2426 match { $p: Person }
2427 return { sum($p.name) as s }
2428}
2429"#,
2430 )
2431 .unwrap();
2432 let err = typecheck_query(&catalog, &qf.queries[0]).unwrap_err();
2433 assert!(err.to_string().contains("T8"));
2434 }
2435
2436 #[test]
2437 fn test_traversal_direction_out() {
2438 let catalog = setup();
2439 let qf = parse_query(
2440 r#"
2441query q() {
2442 match {
2443 $p: Person { name: "Alice" }
2444 $p knows $f
2445 }
2446 return { $f.name }
2447}
2448"#,
2449 )
2450 .unwrap();
2451 let ctx = typecheck_query(&catalog, &qf.queries[0]).unwrap();
2452 assert_eq!(ctx.traversals[0].direction, Direction::Out);
2453 assert_eq!(ctx.bindings["f"].type_name, "Person");
2454 }
2455
2456 #[test]
2457 fn test_traversal_direction_in() {
2458 let catalog = setup();
2459 let qf = parse_query(
2460 r#"
2461query q() {
2462 match {
2463 $c: Company { name: "Acme" }
2464 $p worksAt $c
2465 }
2466 return { $p.name }
2467}
2468"#,
2469 )
2470 .unwrap();
2471 let ctx = typecheck_query(&catalog, &qf.queries[0]).unwrap();
2472 assert_eq!(ctx.traversals[0].direction, Direction::Out);
2475 }
2476
2477 #[test]
2478 fn test_bounded_traversal_typecheck() {
2479 let catalog = setup();
2480 let qf = parse_query(
2481 r#"
2482query q() {
2483 match {
2484 $p: Person
2485 $p knows{1,3} $f
2486 }
2487 return { $f.name }
2488}
2489"#,
2490 )
2491 .unwrap();
2492 let ctx = typecheck_query(&catalog, &qf.queries[0]).unwrap();
2493 assert_eq!(ctx.traversals[0].min_hops, 1);
2494 assert_eq!(ctx.traversals[0].max_hops, Some(3));
2495 }
2496
2497 #[test]
2498 fn test_bounded_traversal_invalid_bounds() {
2499 let catalog = setup();
2500 let qf = parse_query(
2501 r#"
2502query q() {
2503 match {
2504 $p: Person
2505 $p knows{3,1} $f
2506 }
2507 return { $f.name }
2508}
2509"#,
2510 )
2511 .unwrap();
2512 let err = typecheck_query(&catalog, &qf.queries[0]).unwrap_err();
2513 assert!(err.to_string().contains("T15"));
2514 }
2515
2516 #[test]
2517 fn test_unbounded_traversal_is_disabled() {
2518 let catalog = setup();
2519 let qf = parse_query(
2520 r#"
2521query q() {
2522 match {
2523 $p: Person
2524 $p knows{1,} $f
2525 }
2526 return { $f.name }
2527}
2528"#,
2529 )
2530 .unwrap();
2531 let err = typecheck_query(&catalog, &qf.queries[0]).unwrap_err();
2532 assert!(err.to_string().contains("unbounded traversal is disabled"));
2533 }
2534
2535 #[test]
2536 fn test_negation_typecheck() {
2537 let catalog = setup();
2538 let qf = parse_query(
2539 r#"
2540query q() {
2541 match {
2542 $p: Person
2543 not { $p worksAt $_ }
2544 }
2545 return { $p.name }
2546}
2547"#,
2548 )
2549 .unwrap();
2550 let ctx = typecheck_query(&catalog, &qf.queries[0]).unwrap();
2551 assert!(ctx.bindings.contains_key("p"));
2552 }
2553
2554 #[test]
2555 fn test_aggregation_typecheck() {
2556 let catalog = setup();
2557 let qf = parse_query(
2558 r#"
2559query q() {
2560 match {
2561 $p: Person
2562 $p knows $f
2563 }
2564 return {
2565 $p.name
2566 count($f) as friends
2567 }
2568}
2569"#,
2570 )
2571 .unwrap();
2572 typecheck_query(&catalog, &qf.queries[0]).unwrap();
2573 }
2574
2575 #[test]
2576 fn test_valid_two_hop() {
2577 let catalog = setup();
2578 let qf = parse_query(
2579 r#"
2580query q($name: String) {
2581 match {
2582 $p: Person { name: $name }
2583 $p knows $mid
2584 $mid knows $fof
2585 }
2586 return { $fof.name }
2587}
2588"#,
2589 )
2590 .unwrap();
2591 let ctx = typecheck_query(&catalog, &qf.queries[0]).unwrap();
2592 assert!(ctx.bindings.contains_key("mid"));
2593 assert!(ctx.bindings.contains_key("fof"));
2594 }
2595
2596 #[test]
2597 fn test_mutation_insert_typecheck_ok() {
2598 let catalog = setup();
2599 let qf = parse_query(
2600 r#"
2601query add_person($name: String, $age: I32) {
2602 insert Person {
2603 name: $name
2604 age: $age
2605 }
2606}
2607"#,
2608 )
2609 .unwrap();
2610 let checked = typecheck_query_decl(&catalog, &qf.queries[0]).unwrap();
2611 match checked {
2612 CheckedQuery::Mutation(ctx) => assert_eq!(ctx.target_types[0], "Person"),
2613 _ => panic!("expected mutation typecheck result"),
2614 }
2615 }
2616
2617 #[test]
2618 fn test_mutation_insert_missing_required_property() {
2619 let catalog = setup();
2620 let qf = parse_query(
2621 r#"
2622query add_person($age: I32) {
2623 insert Person { age: $age }
2624}
2625"#,
2626 )
2627 .unwrap();
2628 let err = typecheck_query_decl(&catalog, &qf.queries[0]).unwrap_err();
2629 assert!(err.to_string().contains("T12"));
2630 }
2631
2632 #[test]
2633 fn test_mutation_insert_allows_embed_target_omission_when_source_present() {
2634 let catalog = setup_embed_vector();
2635 let qf = parse_query(
2636 r#"
2637query add_doc($slug: String, $body: String) {
2638 insert Doc {
2639 slug: $slug
2640 body: $body
2641 }
2642}
2643"#,
2644 )
2645 .unwrap();
2646 let checked = typecheck_query_decl(&catalog, &qf.queries[0]).unwrap();
2647 match checked {
2648 CheckedQuery::Mutation(ctx) => assert_eq!(ctx.target_types[0], "Doc"),
2649 _ => panic!("expected mutation typecheck result"),
2650 }
2651 }
2652
2653 #[test]
2654 fn test_mutation_insert_requires_embed_source_when_target_omitted() {
2655 let catalog = setup_embed_vector();
2656 let qf = parse_query(
2657 r#"
2658query add_doc($slug: String) {
2659 insert Doc {
2660 slug: $slug
2661 }
2662}
2663"#,
2664 )
2665 .unwrap();
2666 let err = typecheck_query_decl(&catalog, &qf.queries[0]).unwrap_err();
2667 let msg = err.to_string();
2668 assert!(msg.contains("T12"));
2669 assert!(msg.contains("embedding"));
2670 assert!(msg.contains("body"));
2671 }
2672
2673 #[test]
2674 fn test_mutation_update_bad_property() {
2675 let catalog = setup();
2676 let qf = parse_query(
2677 r#"
2678query update_person($name: String) {
2679 update Person set { salary: 100 } where name = $name
2680}
2681"#,
2682 )
2683 .unwrap();
2684 let err = typecheck_query_decl(&catalog, &qf.queries[0]).unwrap_err();
2685 assert!(err.to_string().contains("T11"));
2686 }
2687
2688 #[test]
2689 fn test_mutation_delete_bad_type() {
2690 let catalog = setup();
2691 let qf = parse_query(
2692 r#"
2693query del($name: String) {
2694 delete Unknown where name = $name
2695}
2696"#,
2697 )
2698 .unwrap();
2699 let err = typecheck_query_decl(&catalog, &qf.queries[0]).unwrap_err();
2700 assert!(err.to_string().contains("T10"));
2701 }
2702
2703 #[test]
2704 fn test_mutation_insert_edge_typecheck_ok() {
2705 let catalog = setup();
2706 let qf = parse_query(
2707 r#"
2708query add_knows($from: String, $to: String) {
2709 insert Knows {
2710 from: $from
2711 to: $to
2712 }
2713}
2714"#,
2715 )
2716 .unwrap();
2717 let checked = typecheck_query_decl(&catalog, &qf.queries[0]).unwrap();
2718 match checked {
2719 CheckedQuery::Mutation(ctx) => assert_eq!(ctx.target_types[0], "Knows"),
2720 _ => panic!("expected mutation typecheck result"),
2721 }
2722 }
2723
2724 #[test]
2725 fn test_mutation_insert_edge_requires_from_and_to() {
2726 let catalog = setup();
2727 let qf = parse_query(
2728 r#"
2729query add_knows($from: String) {
2730 insert Knows {
2731 from: $from
2732 }
2733}
2734"#,
2735 )
2736 .unwrap();
2737 let err = typecheck_query_decl(&catalog, &qf.queries[0]).unwrap_err();
2738 assert!(err.to_string().contains("T12"));
2739 }
2740
2741 #[test]
2742 fn test_mutation_delete_edge_typecheck_ok() {
2743 let catalog = setup();
2744 let qf = parse_query(
2745 r#"
2746query del_knows($from: String) {
2747 delete Knows where from = $from
2748}
2749"#,
2750 )
2751 .unwrap();
2752 let checked = typecheck_query_decl(&catalog, &qf.queries[0]).unwrap();
2753 match checked {
2754 CheckedQuery::Mutation(ctx) => assert_eq!(ctx.target_types[0], "Knows"),
2755 _ => panic!("expected mutation typecheck result"),
2756 }
2757 }
2758
2759 #[test]
2760 fn test_mutation_update_edge_not_supported() {
2761 let catalog = setup();
2762 let qf = parse_query(
2763 r#"
2764query upd_knows($from: String) {
2765 update Knows set { since: 2000 } where from = $from
2766}
2767"#,
2768 )
2769 .unwrap();
2770 let err = typecheck_query_decl(&catalog, &qf.queries[0]).unwrap_err();
2771 assert!(err.to_string().contains("T16"));
2772 }
2773
2774 #[test]
2775 fn test_mutation_multi_insert_typecheck_ok() {
2776 let catalog = setup();
2777 let qf = parse_query(
2778 r#"
2779query add_and_link($name: String, $age: I32, $friend: String) {
2780 insert Person { name: $name, age: $age }
2781 insert Knows { from: $name, to: $friend }
2782}
2783"#,
2784 )
2785 .unwrap();
2786 let checked = typecheck_query_decl(&catalog, &qf.queries[0]).unwrap();
2787 match checked {
2788 CheckedQuery::Mutation(ctx) => {
2789 assert_eq!(ctx.target_types, vec!["Person", "Knows"]);
2790 }
2791 _ => panic!("expected mutation typecheck result"),
2792 }
2793 }
2794
2795 #[test]
2796 fn test_mutation_multi_second_stmt_error() {
2797 let catalog = setup();
2798 let qf = parse_query(
2799 r#"
2800query bad($name: String, $age: I32) {
2801 insert Person { name: $name, age: $age }
2802 insert Unknown { foo: $name }
2803}
2804"#,
2805 )
2806 .unwrap();
2807 let err = typecheck_query_decl(&catalog, &qf.queries[0]).unwrap_err();
2808 assert!(err.to_string().contains("T10"));
2809 }
2810
2811 #[test]
2812 fn test_now_expression_typechecks_as_datetime() {
2813 let schema = parse_schema(
2814 r#"
2815node Event {
2816 slug: String @key
2817 at: DateTime
2818}
2819"#,
2820 )
2821 .unwrap();
2822 let catalog = build_catalog(&schema).unwrap();
2823 let qf = parse_query(
2824 r#"
2825query due() {
2826 match {
2827 $e: Event
2828 $e.at <= now()
2829 }
2830 return { now() as ts }
2831}
2832"#,
2833 )
2834 .unwrap();
2835
2836 let checked = typecheck_query_decl(&catalog, &qf.queries[0]).unwrap();
2837 assert!(matches!(checked, CheckedQuery::Read(_)));
2838 }
2839
2840 #[test]
2841 fn test_now_is_rejected_for_non_datetime_mutation_property() {
2842 let schema = parse_schema(
2843 r#"
2844node Event {
2845 slug: String @key
2846 on: Date
2847}
2848"#,
2849 )
2850 .unwrap();
2851 let catalog = build_catalog(&schema).unwrap();
2852 let qf = parse_query(
2853 r#"
2854query stamp() {
2855 update Event set { on: now() } where slug = "launch"
2856}
2857"#,
2858 )
2859 .unwrap();
2860
2861 let err = typecheck_query_decl(&catalog, &qf.queries[0]).unwrap_err();
2862 assert!(err.to_string().contains("DateTime"));
2863 assert!(err.to_string().contains("property `on`"));
2864 }
2865}