1use std::collections::{BTreeMap, BTreeSet};
9
10use type_bridge_contract::diagnostic::{Diagnostic, DiagnosticCategory, DiagnosticCode};
11use type_bridge_contract::id::{TypeId, TypeKind, is_typeql_3_12_builtin_function_name};
12use type_bridge_contract::limits::StructuralLimits;
13use type_bridge_contract::migration_assertion::BindingId;
14use type_bridge_contract::query_plan::{
15 DocumentSource, LocalFunction, QueryOperand, QueryOutput, QueryPattern, QueryPatternV2,
16 QueryPlan, ReadStage, Reducer,
17};
18use type_bridge_contract::schema::{AnnotationKindId, SchemaAnnotationValue};
19use type_bridge_contract::schema_delta::ManagedSchemaState;
20use type_bridge_contract::value::{CanonicalValue, ValueTypeTag};
21
22use crate::engine::{self, EngineCode, EngineCodes};
23use crate::query_v2_claims::validate_v2_schema_claims;
24use crate::{
25 BindingDomain, DocumentColumn, DocumentColumnShape, DocumentSchema,
26 MigrationAssertionValidationContext, OutputSchema, RowColumn, RowSchema,
27};
28
29const QUERY_ENGINE_CODES: EngineCodes = EngineCodes {
31 unknown_type: EngineCode {
32 code: "query_plan_unknown_type",
33 message: "isa pattern references a type outside the resolved schema",
34 },
35 unknown_attribute: EngineCode {
36 code: "query_plan_unknown_attribute",
37 message: "has pattern references an attribute outside the resolved schema",
38 },
39 unknown_relation: EngineCode {
40 code: "query_plan_unknown_relation",
41 message: "links pattern relation is absent or not relation-kind",
42 },
43 unknown_role: EngineCode {
44 code: "query_plan_unknown_role",
45 message: "links pattern references a role outside the resolved schema",
46 },
47 role_relation_mismatch: EngineCode {
48 code: "query_plan_role_relation_mismatch",
49 message: "links role is not effective on the declared relation",
50 },
51 root_reference_not_positive: EngineCode {
52 code: "query_plan_binding_not_positive",
53 message: "a root-scope reference is not positively established at the root",
54 },
55 negation_unbound: EngineCode {
56 code: "query_plan_negation_unbound_binding",
57 message: "negation-local reference is not positively established in its body",
58 },
59 empty_negated_domain: EngineCode {
60 code: "query_plan_empty_negated_domain",
61 message: "negated pattern has an impossible schema domain",
62 },
63 value_domain_mismatch: EngineCode {
64 code: "query_plan_value_domain_mismatch",
65 message: "value comparison operands have different scalar domains",
66 },
67 value_comparator_unsupported: EngineCode {
68 code: "query_plan_value_comparator_unsupported",
69 message: "ordered comparisons require a provider-orderable scalar domain",
70 },
71 binding_not_scalar: EngineCode {
72 code: "query_plan_binding_not_scalar",
73 message: "value operand binding has no uniform attribute scalar domain",
74 },
75 nonuniform_value_domain: EngineCode {
76 code: "query_plan_nonuniform_value_domain",
77 message: "binding domain mixes incompatible scalar domains",
78 },
79 disconnected_topology: EngineCode {
80 code: "query_plan_disconnected_topology",
81 message: "positive query bindings form a disconnected cross join",
82 },
83 unknown_input: EngineCode {
84 code: "query_plan_unknown_input_column",
85 message: "pattern references an undeclared input column",
86 },
87 unknown_function: EngineCode {
88 code: "query_plan_unknown_function",
89 message: "call references a function outside the resolved schema",
90 },
91 function_return_unsupported: EngineCode {
92 code: "query_plan_function_return_unsupported",
93 message: "the first function vocabulary admits scalar non-optional returns only",
94 },
95 function_arity_mismatch: EngineCode {
96 code: "query_plan_function_arity_mismatch",
97 message: "call arguments do not match the function signature arity",
98 },
99 function_argument_type: EngineCode {
100 code: "query_plan_function_argument_type",
101 message: "call argument disagrees with the declared parameter type",
102 },
103 function_dependency_cycle: EngineCode {
104 code: "query_plan_function_dependency_cycle",
105 message: "function-call result dependencies must be acyclic",
106 },
107 value_binding_misuse: EngineCode {
108 code: "query_plan_value_binding_misuse",
109 message: "a value binding may appear only as a comparison or argument operand",
110 },
111 try_unbound: EngineCode {
112 code: "query_plan_try_unbound_binding",
113 message: "try-body reference is not established in its body or the root",
114 },
115 try_uncorrelated: EngineCode {
116 code: "query_plan_try_not_correlated",
117 message: "a try body must reference at least one mandatory root binding",
118 },
119 empty_try_domain: EngineCode {
120 code: "query_plan_empty_try_domain",
121 message: "try body has an impossible schema domain",
122 },
123 try_binding_shared: EngineCode {
124 code: "query_plan_try_binding_shared",
125 message: "an optional binding belongs to exactly one try body and no negation scope",
126 },
127 local_unbound: EngineCode {
128 code: "query_plan_local_function_unbound",
129 message: "local-body reference is not a parameter or body-established",
130 },
131 local_uncorrelated: EngineCode {
132 code: "query_plan_local_function_uncorrelated",
133 message: "a local function body must reference every parameter",
134 },
135 empty_local_domain: EngineCode {
136 code: "query_plan_empty_local_function_domain",
137 message: "local function body has an impossible schema domain",
138 },
139 local_return_domain: EngineCode {
140 code: "query_plan_local_function_return_domain",
141 message: "the declared return does not fit the reducer over its input domain",
142 },
143};
144
145#[derive(Clone, Debug, Eq, PartialEq)]
147pub struct ValidatedQuery {
148 binding_domains: BTreeMap<BindingId, BindingDomain>,
149 output_schema: OutputSchema,
150 plan: QueryPlan,
151 root_visibility: Vec<BindingId>,
152 source_state: ManagedSchemaState,
153 structural_limits: StructuralLimits,
154}
155
156impl ValidatedQuery {
157 pub const fn plan(&self) -> &QueryPlan {
159 &self.plan
160 }
161
162 pub fn binding_domain(&self, id: &BindingId) -> Option<&BindingDomain> {
164 self.binding_domains.get(id)
165 }
166
167 pub const fn output_schema(&self) -> &OutputSchema {
169 &self.output_schema
170 }
171
172 pub const fn source_state(&self) -> &ManagedSchemaState {
174 &self.source_state
175 }
176
177 pub const fn structural_limits(&self) -> StructuralLimits {
179 self.structural_limits
180 }
181
182 pub fn root_visibility(&self) -> &[BindingId] {
191 &self.root_visibility
192 }
193}
194
195fn v2_root_topology(plan: &QueryPlan) -> BTreeSet<(BindingId, BindingId)> {
205 let Some(compatibility) = plan.v2_compatibility() else {
206 return BTreeSet::new();
207 };
208 let mut edges = compatibility
209 .predicate()
210 .map(definite_v2_edges)
211 .unwrap_or_default();
212 edges.extend(
213 compatibility
214 .allowed_cross_joins()
215 .iter()
216 .map(|pair| canonical_topology_edge(pair.left(), pair.right())),
217 );
218 edges
219}
220
221fn definite_v2_edges(pattern: &QueryPatternV2) -> BTreeSet<(BindingId, BindingId)> {
222 match pattern {
223 QueryPatternV2::FieldValue { .. } => BTreeSet::new(),
224 QueryPatternV2::FieldComparison { left, right, .. } => {
225 BTreeSet::from([canonical_topology_edge(left.binding(), right.binding())])
226 }
227 QueryPatternV2::RoleEdge {
228 relation, player, ..
229 } => BTreeSet::from([canonical_topology_edge(*relation, *player)]),
230 QueryPatternV2::Reachable { source, target, .. } => {
231 BTreeSet::from([canonical_topology_edge(*source, *target)])
232 }
233 QueryPatternV2::And { patterns } => patterns
234 .iter()
235 .flat_map(definite_v2_edges)
236 .collect::<BTreeSet<_>>(),
237 QueryPatternV2::Or { patterns } => {
238 let mut patterns = patterns.iter();
239 let Some(first) = patterns.next() else {
240 return BTreeSet::new();
241 };
242 patterns.fold(definite_v2_edges(first), |common, pattern| {
243 common
244 .intersection(&definite_v2_edges(pattern))
245 .copied()
246 .collect()
247 })
248 }
249 QueryPatternV2::Not { .. } => BTreeSet::new(),
250 }
251}
252
253const fn canonical_topology_edge(left: BindingId, right: BindingId) -> (BindingId, BindingId) {
254 if left.get() <= right.get() {
255 (left, right)
256 } else {
257 (right, left)
258 }
259}
260
261pub fn validate_query_plan(
263 plan: &QueryPlan,
264 context: &MigrationAssertionValidationContext<'_>,
265 limits: StructuralLimits,
266) -> Result<ValidatedQuery, Diagnostic> {
267 if plan.managed_semantics() != context.managed_state().managed_semantic_schema() {
268 return Err(plan_failure(
269 DiagnosticCategory::Integrity,
270 "query_plan_managed_semantic_mismatch",
271 "plan managed semantic fingerprint does not match validation state",
272 ));
273 }
274 if context.resolved_schema().declared_identity_fingerprint()
275 != context.managed_state().declared_identity()
276 {
277 return Err(plan_failure(
278 DiagnosticCategory::Integrity,
279 "query_plan_declared_identity_mismatch",
280 "resolved schema declaration identity does not match validation state",
281 ));
282 }
283 if plan.functions().iter().any(|function| {
284 is_typeql_3_12_builtin_function_name(function.name().label().as_str())
285 || patterns_contain_typeql_builtin_function(function.body())
286 }) || plan.pipeline().iter().any(|stage| match stage {
287 ReadStage::Match { patterns } => patterns_contain_typeql_builtin_function(patterns),
288 _ => false,
289 }) {
290 return Err(typeql_builtin_function_collision());
291 }
292 plan.check_structural_limits(limits)?;
296
297 let schema = context.resolved_schema();
298 let v2_claims = validate_v2_schema_claims(plan, schema)?;
302 let inputs = plan
303 .inputs()
304 .iter()
305 .map(|column| column.value_type())
306 .collect::<Vec<ValueTypeTag>>();
307 let Some(ReadStage::Match { patterns }) = plan.pipeline().first() else {
308 return Err(plan_failure(
309 DiagnosticCategory::InvalidContract,
310 "query_plan_match_not_first",
311 "the pattern conjunction must be the first pipeline stage",
312 ));
313 };
314 let mut locals = BTreeMap::new();
315 for function in plan.functions() {
316 if schema.functions().contains_key(function.name()) {
317 return Err(plan_failure(
318 DiagnosticCategory::InvalidContract,
319 "query_plan_local_function_shadows_schema",
320 "a plan-local function cannot shadow a schema function",
321 ));
322 }
323 let signature = engine::analyze_local_function(function, schema, &QUERY_ENGINE_CODES)?;
324 locals.insert(function.name().clone(), signature);
325 }
326
327 let engine::PatternAnalysis {
328 domains,
329 optional_positive,
330 positive,
331 scoped_positive,
332 used,
333 value_bindings,
334 } = engine::analyze_patterns(
335 patterns,
336 plan.bindings().len(),
337 &inputs,
338 schema,
339 &locals,
340 &v2_root_topology(plan),
341 &QUERY_ENGINE_CODES,
342 )?;
343
344 let reduce_assigned: BTreeSet<BindingId> = plan
347 .pipeline()
348 .iter()
349 .filter_map(|stage| match stage {
350 ReadStage::Reduce { assignments, .. } => Some(assignments),
351 _ => None,
352 })
353 .flatten()
354 .map(|assignment| assignment.assigned())
355 .collect();
356
357 let projected: BTreeSet<BindingId> = match plan.output() {
358 QueryOutput::Rows { columns } => columns.iter().copied().collect(),
359 QueryOutput::Documents { fields } => fields
360 .iter()
361 .map(|field| match field.source() {
362 DocumentSource::Binding { binding } => *binding,
363 DocumentSource::AttributeList { owner, .. } => *owner,
364 })
365 .collect(),
366 };
367 for binding in plan.bindings() {
368 let id = binding.id();
369 if reduce_assigned.contains(&id) {
370 continue;
371 }
372 if !used.contains(&id) {
373 return Err(plan_failure(
374 DiagnosticCategory::InvalidContract,
375 "query_plan_binding_not_used",
376 "every declared binding must be referenced by a pattern",
377 ));
378 }
379 if projected.contains(&id) && !positive.contains(&id) && !optional_positive.contains(&id) {
380 return Err(plan_failure(
381 DiagnosticCategory::InvalidContract,
382 "query_plan_binding_not_positive",
383 "an output binding must be positively established at the root",
384 ));
385 }
386 if !projected.contains(&id)
387 && !positive.contains(&id)
388 && !optional_positive.contains(&id)
389 && !scoped_positive.contains(&id)
390 {
391 return Err(plan_failure(
392 DiagnosticCategory::InvalidContract,
393 "query_plan_invalid_witness",
394 "a hidden witness must be positively established in its lexical scope",
395 ));
396 }
397 if (positive.contains(&id) || optional_positive.contains(&id))
398 && domains[&id].is_empty()
399 && !value_bindings.contains_key(&id)
400 && !v2_claims.proves_empty_runtime_binding(id)
401 {
402 return Err(plan_failure(
403 DiagnosticCategory::InvalidContract,
404 "query_plan_empty_domain",
405 "schema validation reduced a binding to an empty runtime domain",
406 ));
407 }
408 }
409
410 let mut binding_domains = domains
411 .into_iter()
412 .filter(|(id, _)| positive.contains(id) || optional_positive.contains(id))
413 .map(|(id, type_ids)| {
414 let value_type = match value_bindings.get(&id) {
415 Some(tag) => Some(*tag),
416 None => engine::uniform_value_type(&type_ids, schema, &QUERY_ENGINE_CODES)?,
417 };
418 Ok((id, BindingDomain::new(type_ids, value_type)))
419 })
420 .collect::<Result<BTreeMap<_, _>, Diagnostic>>()?;
421
422 for stage in plan.pipeline() {
423 match stage {
424 ReadStage::Select { bindings } => {
425 for binding in bindings {
426 if !positive.contains(binding) && !optional_positive.contains(binding) {
427 return Err(plan_failure(
428 DiagnosticCategory::InvalidContract,
429 "query_plan_binding_not_positive",
430 "stage bindings must be positively established at the root",
431 ));
432 }
433 }
434 }
435 ReadStage::Require { bindings } => {
436 for binding in bindings {
437 if !positive.contains(binding) {
438 return Err(plan_failure(
439 DiagnosticCategory::InvalidContract,
440 "query_plan_binding_not_positive",
441 "stage bindings must be positively established at the root",
442 ));
443 }
444 }
445 }
446 ReadStage::Reduce {
447 assignments,
448 groups,
449 } => {
450 for group in groups {
451 if !positive.contains(group) {
452 return Err(plan_failure(
453 DiagnosticCategory::InvalidContract,
454 "query_plan_binding_not_positive",
455 "stage bindings must be positively established at the root",
456 ));
457 }
458 }
459 for assignment in assignments {
460 let input_scalar = match assignment.input() {
461 Some(input) => {
462 let admitted = positive.contains(&input)
463 || (optional_positive.contains(&input)
464 && assignment.reducer().total_without_groups());
465 if !admitted {
466 return Err(plan_failure(
467 DiagnosticCategory::InvalidContract,
468 "query_plan_binding_not_positive",
469 "stage bindings must be positively established at the root",
470 ));
471 }
472 binding_domains
473 .get(&input)
474 .and_then(|domain| domain.value_type())
475 }
476 None => None,
477 };
478 let result_type = match assignment.reducer() {
479 Reducer::Count => ValueTypeTag::Long,
480 Reducer::Sum | Reducer::Max | Reducer::Min => match input_scalar {
481 Some(tag @ (ValueTypeTag::Long | ValueTypeTag::Double)) => tag,
482 _ => {
483 return Err(plan_failure(
484 DiagnosticCategory::InvalidContract,
485 "query_plan_reduce_input_domain",
486 "this reducer requires a uniform numeric scalar input",
487 ));
488 }
489 },
490 Reducer::Mean | Reducer::Median | Reducer::Std => match input_scalar {
491 Some(ValueTypeTag::Long | ValueTypeTag::Double) => ValueTypeTag::Double,
492 _ => {
493 return Err(plan_failure(
494 DiagnosticCategory::InvalidContract,
495 "query_plan_reduce_input_domain",
496 "this reducer requires a uniform numeric scalar input",
497 ));
498 }
499 },
500 };
501 binding_domains.insert(
502 assignment.assigned(),
503 BindingDomain::new(BTreeSet::new(), Some(result_type)),
504 );
505 }
506 }
507 ReadStage::Sort { terms } => {
508 for term in terms {
509 if !positive.contains(&term.binding())
510 && !reduce_assigned.contains(&term.binding())
511 {
512 return Err(plan_failure(
513 DiagnosticCategory::InvalidContract,
514 "query_plan_stage_unknown_binding",
515 "sort references a binding outside the mandatory row environment",
516 ));
517 }
518 let scalar = binding_domains
519 .get(&term.binding())
520 .and_then(|domain| domain.value_type());
521 let Some(scalar) = scalar else {
522 return Err(plan_failure(
523 DiagnosticCategory::InvalidContract,
524 "query_plan_sort_not_scalar",
525 "sort keys require a validated uniform scalar domain",
526 ));
527 };
528 if !provider_sort_is_orderable(scalar) {
529 return Err(plan_failure(
530 DiagnosticCategory::InvalidContract,
531 "query_plan_sort_not_orderable",
532 "sort keys require a scalar domain ordered by the target provider",
533 ));
534 }
535 }
536 }
537 ReadStage::Match { .. }
538 | ReadStage::Distinct
539 | ReadStage::Offset { .. }
540 | ReadStage::Limit { .. } => {}
541 }
542 }
543
544 let windowed = plan
553 .pipeline()
554 .iter()
555 .any(|stage| matches!(stage, ReadStage::Offset { .. } | ReadStage::Limit { .. }));
556 if windowed {
557 let sort_keys: BTreeSet<BindingId> = plan
558 .pipeline()
559 .iter()
560 .filter_map(|stage| match stage {
561 ReadStage::Sort { terms } => Some(terms),
562 _ => None,
563 })
564 .flatten()
565 .map(|term| term.binding())
566 .collect();
567 let reduce = plan.pipeline().iter().find_map(|stage| match stage {
568 ReadStage::Reduce {
569 assignments,
570 groups,
571 } => Some((assignments.as_slice(), groups.as_slice())),
572 _ => None,
573 });
574 let window_environment: Vec<BindingId> = if let Some((assignments, groups)) = reduce {
575 groups
576 .iter()
577 .copied()
578 .chain(assignments.iter().map(|assignment| assignment.assigned()))
579 .collect()
580 } else if let Some(ReadStage::Select { bindings }) = plan
581 .pipeline()
582 .iter()
583 .find(|stage| matches!(stage, ReadStage::Select { .. }))
584 {
585 bindings.clone()
586 } else {
587 positive.union(&optional_positive).copied().collect()
588 };
589
590 let not_total = || {
591 plan_failure(
592 DiagnosticCategory::InvalidContract,
593 "query_plan_window_order_not_total",
594 "offset and limit require a sort tuple proven total for every visible column",
595 )
596 };
597 let global_reduce = reduce.is_some_and(|(_, groups)| groups.is_empty());
598 if !global_reduce {
599 let mut determined = BTreeSet::new();
600 for binding in &sort_keys {
601 let domain = binding_domains.get(binding).ok_or_else(¬_total)?;
602 if !sort_key_domain_is_identity_total(domain, schema) {
603 return Err(not_total());
604 }
605 determined.insert(*binding);
606 }
607
608 loop {
609 let previous_len = determined.len();
610 for pattern in patterns {
611 match pattern {
612 QueryPattern::Has {
613 owner,
614 attribute,
615 attribute_id,
616 } if determined.contains(attribute) => {
617 let owner_domain = binding_domains.get(owner).ok_or_else(¬_total)?;
618 if !owner_domain.type_ids().is_empty()
619 && binding_domains.get(attribute).is_some_and(|domain| {
620 sort_key_domain_is_identity_total(domain, schema)
621 })
622 && one_unique_owns_scope_covers_domain(
623 owner_domain.type_ids(),
624 attribute_id,
625 schema,
626 )
627 {
628 determined.insert(*owner);
629 }
630 }
631 QueryPattern::FunctionCall {
632 arguments,
633 assigned,
634 function,
635 } if locals.contains_key(function)
636 && arguments.iter().all(|argument| match argument {
637 QueryOperand::Binding { binding } => determined.contains(binding),
638 QueryOperand::Literal { .. } | QueryOperand::Input { .. } => true,
639 }) =>
640 {
641 determined.insert(*assigned);
646 }
647 _ => {}
648 }
649 }
650 if let Some((assignments, groups)) = reduce
651 && groups.iter().all(|group| determined.contains(group))
652 {
653 determined.extend(assignments.iter().map(|assignment| assignment.assigned()));
654 }
655 if determined.len() == previous_len {
656 break;
657 }
658 }
659
660 if window_environment
661 .iter()
662 .any(|binding| !determined.contains(binding))
663 {
664 return Err(not_total());
665 }
666 }
667 }
668
669 let output_schema = match plan.output() {
670 QueryOutput::Rows { columns } => OutputSchema::Rows(RowSchema::new(
671 columns
672 .iter()
673 .map(|id| {
674 let binding = plan
675 .bindings()
676 .get(usize::from(id.get()))
677 .expect("validated output binding exists");
678 RowColumn::new(
679 *id,
680 binding_domains[id].clone(),
681 binding.variable().clone(),
682 optional_positive.contains(id),
683 )
684 })
685 .collect::<Vec<_>>(),
686 )),
687 QueryOutput::Documents { fields } => {
688 let columns = fields
689 .iter()
690 .map(|field| {
691 let shape = match field.source() {
692 DocumentSource::Binding { binding } => {
693 let Some(value_type) = binding_domains[binding].value_type() else {
694 return Err(plan_failure(
695 DiagnosticCategory::InvalidContract,
696 "query_plan_document_field_not_scalar",
697 "document fields fetch uniform scalar bindings",
698 ));
699 };
700 DocumentColumnShape::Scalar {
701 value_type,
702 optional: optional_positive.contains(binding),
703 }
704 }
705 DocumentSource::AttributeList { attribute, owner } => {
706 if !positive.contains(owner) {
707 return Err(plan_failure(
708 DiagnosticCategory::InvalidContract,
709 "query_plan_output_not_visible",
710 "attribute lists require a mandatory owner binding",
711 ));
712 }
713 let attribute_type = TypeId::new(
714 TypeKind::Attribute,
715 attribute.label().as_str().to_owned(),
716 )?;
717 let Some(element_type) = schema
718 .types()
719 .get(&attribute_type)
720 .and_then(|resolved| resolved.value_type())
721 .map(|value| value.value_type())
722 else {
723 return Err(plan_failure(
724 DiagnosticCategory::InvalidContract,
725 "query_plan_unknown_attribute",
726 "attribute list references no resolved scalar attribute",
727 ));
728 };
729 let reachable = binding_domains[owner].type_ids().iter().any(|id| {
730 schema
731 .types()
732 .get(id)
733 .is_some_and(|resolved| resolved.owns().contains_key(attribute))
734 });
735 if !reachable {
736 return Err(plan_failure(
737 DiagnosticCategory::InvalidContract,
738 "query_plan_document_unreachable_attribute",
739 "no type in the owner domain owns the listed attribute",
740 ));
741 }
742 DocumentColumnShape::List {
743 attribute: attribute.clone(),
744 element_type,
745 }
746 }
747 };
748 Ok(DocumentColumn::new(field.key().clone(), shape))
749 })
750 .collect::<Result<Vec<_>, Diagnostic>>()?;
751 OutputSchema::Documents(DocumentSchema::new(columns))
752 }
753 };
754
755 Ok(ValidatedQuery {
756 binding_domains,
757 output_schema,
758 plan: plan.clone(),
759 root_visibility: positive.union(&optional_positive).copied().collect(),
760 source_state: context.managed_state().clone(),
761 structural_limits: limits,
762 })
763}
764
765pub fn validate_query_local_function(
772 function: &LocalFunction,
773 context: &MigrationAssertionValidationContext<'_>,
774) -> Result<(), Diagnostic> {
775 let schema = context.resolved_schema();
776 if is_typeql_3_12_builtin_function_name(function.name().label().as_str())
777 || patterns_contain_typeql_builtin_function(function.body())
778 {
779 return Err(typeql_builtin_function_collision());
780 }
781 if schema.functions().contains_key(function.name()) {
782 return Err(plan_failure(
783 DiagnosticCategory::InvalidContract,
784 "query_plan_local_function_shadows_schema",
785 "a plan-local function cannot shadow a schema function",
786 ));
787 }
788 engine::analyze_local_function(function, schema, &QUERY_ENGINE_CODES)?;
789 Ok(())
790}
791
792const fn provider_sort_is_orderable(value_type: ValueTypeTag) -> bool {
793 !matches!(value_type, ValueTypeTag::Duration)
794}
795
796fn sort_key_domain_is_identity_total(
797 domain: &BindingDomain,
798 schema: &type_bridge_schema::ResolvedSchema,
799) -> bool {
800 let Some(value_type) = domain.value_type() else {
801 return false;
802 };
803 if !provider_comparison_equality_matches_canonical_identity(value_type) {
808 return false;
809 }
810 if domain.type_ids().len() <= 1 {
811 return true;
812 }
813
814 finite_attribute_value_domains_are_pairwise_disjoint(domain, value_type, schema)
815}
816
817const fn provider_comparison_equality_matches_canonical_identity(value_type: ValueTypeTag) -> bool {
818 matches!(
819 value_type,
820 ValueTypeTag::String
821 | ValueTypeTag::Long
822 | ValueTypeTag::Boolean
823 | ValueTypeTag::Date
824 | ValueTypeTag::DateTime
825 | ValueTypeTag::Decimal
826 )
827}
828
829fn finite_attribute_value_domains_are_pairwise_disjoint(
836 domain: &BindingDomain,
837 value_type: ValueTypeTag,
838 schema: &type_bridge_schema::ResolvedSchema,
839) -> bool {
840 let mut seen = BTreeSet::<&CanonicalValue>::new();
841 for type_id in domain.type_ids() {
842 if type_id.kind() != TypeKind::Attribute {
843 return false;
844 }
845 let Some(resolved) = schema.types().get(type_id) else {
846 return false;
847 };
848 if !resolved.is_constructible() {
849 return false;
850 }
851 let Some(resolved_value) = resolved.value_type() else {
852 return false;
853 };
854 if resolved_value.value_type() != value_type {
855 return false;
856 }
857 let Some(SchemaAnnotationValue::Values(values)) =
858 resolved_value.annotations().get(&AnnotationKindId::Values)
859 else {
860 return false;
861 };
862 for value in values.iter() {
863 if value.value_type() != value_type || !seen.insert(value) {
864 return false;
865 }
866 }
867 }
868 true
869}
870
871fn one_unique_owns_scope_covers_domain(
880 domain: &BTreeSet<TypeId>,
881 attribute: &type_bridge_contract::id::AttributeId,
882 schema: &type_bridge_schema::ResolvedSchema,
883) -> bool {
884 let mut origin = None;
885 for type_id in domain {
886 let Some(owns) = schema
887 .types()
888 .get(type_id)
889 .and_then(|resolved| resolved.owns().get(attribute))
890 else {
891 return false;
892 };
893 if !owns.is_unique() {
894 return false;
895 }
896 match &origin {
897 Some(expected) if expected != owns.origin().declared() => return false,
898 Some(_) => {}
899 None => origin = Some(owns.origin().declared().clone()),
900 }
901 }
902 origin.is_some()
903}
904
905fn typeql_builtin_function_collision() -> Diagnostic {
906 plan_failure(
907 DiagnosticCategory::InvalidContract,
908 "query_plan_builtin_function_collision",
909 "TypeQL 3.12 built-in function names cannot identify schema calls or plan-local functions",
910 )
911}
912
913fn patterns_contain_typeql_builtin_function(patterns: &[QueryPattern]) -> bool {
914 patterns.iter().any(|pattern| match pattern {
915 QueryPattern::FunctionCall { function, .. } => {
916 is_typeql_3_12_builtin_function_name(function.label().as_str())
917 }
918 QueryPattern::Or { branches } => branches
919 .iter()
920 .any(|branch| patterns_contain_typeql_builtin_function(branch)),
921 QueryPattern::Not { patterns } | QueryPattern::Try { patterns } => {
922 patterns_contain_typeql_builtin_function(patterns)
923 }
924 QueryPattern::Isa { .. }
925 | QueryPattern::Has { .. }
926 | QueryPattern::Links { .. }
927 | QueryPattern::Value { .. }
928 | QueryPattern::Reachable { .. } => false,
929 })
930}
931
932fn plan_failure(
933 category: DiagnosticCategory,
934 code: &'static str,
935 message: &'static str,
936) -> Diagnostic {
937 Diagnostic::new(
938 category,
939 DiagnosticCode::new(code).expect("static query-plan diagnostic code"),
940 message,
941 )
942}
943
944#[cfg(test)]
945mod tests {
946 use type_bridge_contract::id::{FunctionId, RoleId, TypeId, TypeKind};
947
948 use super::*;
949
950 #[test]
951 fn builtin_function_collision_scan_descends_every_pattern_container() {
952 let assigned = BindingId::new(0).expect("binding");
953 let nested = vec![QueryPattern::Not {
954 patterns: vec![QueryPattern::Try {
955 patterns: vec![QueryPattern::FunctionCall {
956 arguments: Vec::new(),
957 assigned,
958 function: FunctionId::new("abs").expect("contextual function ID"),
959 }],
960 }],
961 }];
962 assert!(patterns_contain_typeql_builtin_function(&nested));
963
964 let safe = vec![QueryPattern::FunctionCall {
965 arguments: Vec::new(),
966 assigned,
967 function: FunctionId::new("absolute").expect("function ID"),
968 }];
969 assert!(!patterns_contain_typeql_builtin_function(&safe));
970 }
971
972 #[test]
973 fn compatibility_topology_exports_only_definite_positive_edges() {
974 let binding = |value| BindingId::new(value).expect("binding");
975 let role_edge =
976 |relation, player, relation_label: &str, role: &str| QueryPatternV2::RoleEdge {
977 include_relation_subtypes: false,
978 player: binding(player),
979 relation: binding(relation),
980 relation_type: TypeId::new(TypeKind::Relation, relation_label).expect("relation"),
981 role: RoleId::new(relation_label, role).expect("role"),
982 };
983 let edge_01 = role_edge(0, 1, "first-link", "member");
984 let edge_12 = role_edge(1, 2, "second-link", "member");
985
986 let conjunction = QueryPatternV2::And {
987 patterns: vec![edge_01.clone(), edge_12.clone()],
988 };
989 assert_eq!(
990 definite_v2_edges(&conjunction),
991 BTreeSet::from([(binding(0), binding(1)), (binding(1), binding(2))]),
992 );
993
994 let disjunction = QueryPatternV2::Or {
995 patterns: vec![
996 conjunction,
997 QueryPatternV2::And {
998 patterns: vec![edge_01.clone()],
999 },
1000 ],
1001 };
1002 assert_eq!(
1003 definite_v2_edges(&disjunction),
1004 BTreeSet::from([(binding(0), binding(1))]),
1005 "an edge absent from one branch is not a root topology proof",
1006 );
1007 assert!(
1008 definite_v2_edges(&QueryPatternV2::Not {
1009 pattern: Box::new(edge_12),
1010 })
1011 .is_empty(),
1012 "negated edges never connect the positive root graph",
1013 );
1014 }
1015}