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, CollectionMode, SchemaAnnotationValue};
19use type_bridge_contract::schema_delta::ManagedSchemaState;
20use type_bridge_contract::value::{CanonicalValue, Cardinality, 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 { .. }
224 | QueryPatternV2::FieldPresence { .. }
225 | QueryPatternV2::BindingIid { .. } => BTreeSet::new(),
226 QueryPatternV2::FieldComparison { left, right, .. } => {
227 BTreeSet::from([canonical_topology_edge(left.binding(), right.binding())])
228 }
229 QueryPatternV2::RoleEdge {
230 relation, player, ..
231 } => BTreeSet::from([canonical_topology_edge(*relation, *player)]),
232 QueryPatternV2::Reachable { source, target, .. } => {
233 BTreeSet::from([canonical_topology_edge(*source, *target)])
234 }
235 QueryPatternV2::And { patterns } => patterns
236 .iter()
237 .flat_map(definite_v2_edges)
238 .collect::<BTreeSet<_>>(),
239 QueryPatternV2::Or { patterns } => {
240 let mut patterns = patterns.iter();
241 let Some(first) = patterns.next() else {
242 return BTreeSet::new();
243 };
244 patterns.fold(definite_v2_edges(first), |common, pattern| {
245 common
246 .intersection(&definite_v2_edges(pattern))
247 .copied()
248 .collect()
249 })
250 }
251 QueryPatternV2::Not { .. } => BTreeSet::new(),
252 }
253}
254
255const fn canonical_topology_edge(left: BindingId, right: BindingId) -> (BindingId, BindingId) {
256 if left.get() <= right.get() {
257 (left, right)
258 } else {
259 (right, left)
260 }
261}
262
263pub fn validate_query_plan(
265 plan: &QueryPlan,
266 context: &MigrationAssertionValidationContext<'_>,
267 limits: StructuralLimits,
268) -> Result<ValidatedQuery, Diagnostic> {
269 if plan.managed_semantics() != context.managed_state().managed_semantic_schema() {
270 return Err(plan_failure(
271 DiagnosticCategory::Integrity,
272 "query_plan_managed_semantic_mismatch",
273 "plan managed semantic fingerprint does not match validation state",
274 ));
275 }
276 if context.resolved_schema().declared_identity_fingerprint()
277 != context.managed_state().declared_identity()
278 {
279 return Err(plan_failure(
280 DiagnosticCategory::Integrity,
281 "query_plan_declared_identity_mismatch",
282 "resolved schema declaration identity does not match validation state",
283 ));
284 }
285 if plan.functions().iter().any(|function| {
286 is_typeql_3_12_builtin_function_name(function.name().label().as_str())
287 || patterns_contain_typeql_builtin_function(function.body())
288 }) || plan.pipeline().iter().any(|stage| match stage {
289 ReadStage::Match { patterns } => patterns_contain_typeql_builtin_function(patterns),
290 _ => false,
291 }) {
292 return Err(typeql_builtin_function_collision());
293 }
294 plan.check_structural_limits(limits)?;
298
299 let schema = context.resolved_schema();
300 let v2_claims = validate_v2_schema_claims(plan, schema)?;
304 let inputs = plan
305 .inputs()
306 .iter()
307 .map(|column| column.value_type())
308 .collect::<Vec<ValueTypeTag>>();
309 let Some(ReadStage::Match { patterns }) = plan.pipeline().first() else {
310 return Err(plan_failure(
311 DiagnosticCategory::InvalidContract,
312 "query_plan_match_not_first",
313 "the pattern conjunction must be the first pipeline stage",
314 ));
315 };
316 let mut locals = BTreeMap::new();
317 for function in plan.functions() {
318 if schema.functions().contains_key(function.name()) {
319 return Err(plan_failure(
320 DiagnosticCategory::InvalidContract,
321 "query_plan_local_function_shadows_schema",
322 "a plan-local function cannot shadow a schema function",
323 ));
324 }
325 let signature = engine::analyze_local_function(function, schema, &QUERY_ENGINE_CODES)?;
326 locals.insert(function.name().clone(), signature);
327 }
328
329 let engine::PatternAnalysis {
330 domains,
331 optional_positive,
332 positive,
333 scoped_positive,
334 used,
335 value_bindings,
336 } = engine::analyze_patterns(
337 patterns,
338 plan.bindings().len(),
339 &inputs,
340 schema,
341 &locals,
342 &v2_root_topology(plan),
343 &QUERY_ENGINE_CODES,
344 )?;
345
346 let reduce_assigned: BTreeSet<BindingId> = plan
349 .pipeline()
350 .iter()
351 .filter_map(|stage| match stage {
352 ReadStage::Reduce { assignments, .. } => Some(assignments),
353 _ => None,
354 })
355 .flatten()
356 .map(|assignment| assignment.assigned())
357 .collect();
358
359 let projected: BTreeSet<BindingId> = match plan.output() {
360 QueryOutput::Rows { columns } => columns.iter().copied().collect(),
361 QueryOutput::Documents { fields } => fields
362 .iter()
363 .map(|field| match field.source() {
364 DocumentSource::Binding { binding } => *binding,
365 DocumentSource::AttributeList { owner, .. } => *owner,
366 })
367 .collect(),
368 };
369 for binding in plan.bindings() {
370 let id = binding.id();
371 if reduce_assigned.contains(&id) {
372 continue;
373 }
374 if !used.contains(&id) {
375 return Err(plan_failure(
376 DiagnosticCategory::InvalidContract,
377 "query_plan_binding_not_used",
378 "every declared binding must be referenced by a pattern",
379 ));
380 }
381 if projected.contains(&id) && !positive.contains(&id) && !optional_positive.contains(&id) {
382 return Err(plan_failure(
383 DiagnosticCategory::InvalidContract,
384 "query_plan_binding_not_positive",
385 "an output binding must be positively established at the root",
386 ));
387 }
388 if !projected.contains(&id)
389 && !positive.contains(&id)
390 && !optional_positive.contains(&id)
391 && !scoped_positive.contains(&id)
392 {
393 return Err(plan_failure(
394 DiagnosticCategory::InvalidContract,
395 "query_plan_invalid_witness",
396 "a hidden witness must be positively established in its lexical scope",
397 ));
398 }
399 if (positive.contains(&id) || optional_positive.contains(&id))
400 && domains[&id].is_empty()
401 && !value_bindings.contains_key(&id)
402 && !v2_claims.proves_empty_runtime_binding(id)
403 {
404 return Err(plan_failure(
405 DiagnosticCategory::InvalidContract,
406 "query_plan_empty_domain",
407 "schema validation reduced a binding to an empty runtime domain",
408 ));
409 }
410 }
411
412 let mut binding_domains = domains
413 .into_iter()
414 .filter(|(id, _)| positive.contains(id) || optional_positive.contains(id))
415 .map(|(id, type_ids)| {
416 let value_type = match value_bindings.get(&id) {
417 Some(tag) => Some(*tag),
418 None => engine::uniform_value_type(&type_ids, schema, &QUERY_ENGINE_CODES)?,
419 };
420 Ok((id, BindingDomain::new(type_ids, value_type)))
421 })
422 .collect::<Result<BTreeMap<_, _>, Diagnostic>>()?;
423
424 for stage in plan.pipeline() {
425 match stage {
426 ReadStage::Select { bindings } => {
427 for binding in bindings {
428 if !positive.contains(binding) && !optional_positive.contains(binding) {
429 return Err(plan_failure(
430 DiagnosticCategory::InvalidContract,
431 "query_plan_binding_not_positive",
432 "stage bindings must be positively established at the root",
433 ));
434 }
435 }
436 }
437 ReadStage::Require { bindings } => {
438 for binding in bindings {
439 if !positive.contains(binding) {
440 return Err(plan_failure(
441 DiagnosticCategory::InvalidContract,
442 "query_plan_binding_not_positive",
443 "stage bindings must be positively established at the root",
444 ));
445 }
446 }
447 }
448 ReadStage::Reduce {
449 assignments,
450 groups,
451 } => {
452 for group in groups {
453 if !positive.contains(group) {
454 return Err(plan_failure(
455 DiagnosticCategory::InvalidContract,
456 "query_plan_binding_not_positive",
457 "stage bindings must be positively established at the root",
458 ));
459 }
460 }
461 for assignment in assignments {
462 let input_scalar = match assignment.input() {
463 Some(input) => {
464 let admitted = positive.contains(&input)
465 || (optional_positive.contains(&input)
466 && assignment.reducer().total_without_groups());
467 if !admitted {
468 return Err(plan_failure(
469 DiagnosticCategory::InvalidContract,
470 "query_plan_binding_not_positive",
471 "stage bindings must be positively established at the root",
472 ));
473 }
474 binding_domains
475 .get(&input)
476 .and_then(|domain| domain.value_type())
477 }
478 None => None,
479 };
480 let result_type = match assignment.reducer() {
481 Reducer::Count => ValueTypeTag::Long,
482 Reducer::Sum | Reducer::Max | Reducer::Min => match input_scalar {
483 Some(tag @ (ValueTypeTag::Long | ValueTypeTag::Double)) => tag,
484 _ => {
485 return Err(plan_failure(
486 DiagnosticCategory::InvalidContract,
487 "query_plan_reduce_input_domain",
488 "this reducer requires a uniform numeric scalar input",
489 ));
490 }
491 },
492 Reducer::Mean | Reducer::Median | Reducer::Std => match input_scalar {
493 Some(ValueTypeTag::Long | ValueTypeTag::Double) => ValueTypeTag::Double,
494 _ => {
495 return Err(plan_failure(
496 DiagnosticCategory::InvalidContract,
497 "query_plan_reduce_input_domain",
498 "this reducer requires a uniform numeric scalar input",
499 ));
500 }
501 },
502 };
503 binding_domains.insert(
504 assignment.assigned(),
505 BindingDomain::new(BTreeSet::new(), Some(result_type)),
506 );
507 }
508 }
509 ReadStage::Sort { terms } => {
510 for term in terms {
511 if !positive.contains(&term.binding())
512 && !reduce_assigned.contains(&term.binding())
513 {
514 return Err(plan_failure(
515 DiagnosticCategory::InvalidContract,
516 "query_plan_stage_unknown_binding",
517 "sort references a binding outside the mandatory row environment",
518 ));
519 }
520 let scalar = binding_domains
521 .get(&term.binding())
522 .and_then(|domain| domain.value_type());
523 let Some(scalar) = scalar else {
524 return Err(plan_failure(
525 DiagnosticCategory::InvalidContract,
526 "query_plan_sort_not_scalar",
527 "sort keys require a validated uniform scalar domain",
528 ));
529 };
530 if !provider_sort_is_orderable(scalar) {
531 return Err(plan_failure(
532 DiagnosticCategory::InvalidContract,
533 "query_plan_sort_not_orderable",
534 "sort keys require a scalar domain ordered by the target provider",
535 ));
536 }
537 }
538 }
539 ReadStage::Match { .. }
540 | ReadStage::Distinct
541 | ReadStage::Offset { .. }
542 | ReadStage::Limit { .. } => {}
543 }
544 }
545
546 let windowed = plan
559 .pipeline()
560 .iter()
561 .any(|stage| matches!(stage, ReadStage::Offset { .. } | ReadStage::Limit { .. }));
562 if windowed {
563 let sort_keys: BTreeSet<BindingId> = plan
564 .pipeline()
565 .iter()
566 .filter_map(|stage| match stage {
567 ReadStage::Sort { terms } => Some(terms),
568 _ => None,
569 })
570 .flatten()
571 .map(|term| term.binding())
572 .collect();
573 let reduce = plan.pipeline().iter().find_map(|stage| match stage {
574 ReadStage::Reduce {
575 assignments,
576 groups,
577 } => Some((assignments.as_slice(), groups.as_slice())),
578 _ => None,
579 });
580 let window_environment: Vec<BindingId> = if let Some((assignments, groups)) = reduce {
581 groups
582 .iter()
583 .copied()
584 .chain(assignments.iter().map(|assignment| assignment.assigned()))
585 .collect()
586 } else if let Some(ReadStage::Select { bindings }) = plan
587 .pipeline()
588 .iter()
589 .find(|stage| matches!(stage, ReadStage::Select { .. }))
590 {
591 bindings.clone()
592 } else {
593 positive.union(&optional_positive).copied().collect()
594 };
595
596 let not_total = || {
597 plan_failure(
598 DiagnosticCategory::InvalidContract,
599 "query_plan_window_order_not_total",
600 "offset and limit require a sort tuple proven total for every visible column",
601 )
602 };
603 let global_reduce = reduce.is_some_and(|(_, groups)| groups.is_empty());
604 if !global_reduce {
605 let mut determined = BTreeSet::new();
606 for binding in &sort_keys {
611 let domain = binding_domains.get(binding).ok_or_else(¬_total)?;
612 if sort_key_domain_is_identity_total(domain, schema) {
613 determined.insert(*binding);
614 }
615 }
616
617 loop {
618 let previous_len = determined.len();
619 for pattern in patterns {
620 match pattern {
621 QueryPattern::Has {
622 owner,
623 attribute,
624 attribute_id,
625 } if determined.contains(attribute) => {
626 let owner_domain = binding_domains.get(owner).ok_or_else(¬_total)?;
627 if !owner_domain.type_ids().is_empty()
628 && binding_domains.get(attribute).is_some_and(|domain| {
629 sort_key_domain_is_identity_total(domain, schema)
630 })
631 && one_unique_owns_scope_covers_domain(
632 owner_domain.type_ids(),
633 attribute_id,
634 schema,
635 )
636 {
637 determined.insert(*owner);
638 }
639 }
640 QueryPattern::Has {
641 owner,
642 attribute,
643 attribute_id,
644 } if determined.contains(owner)
645 && binding_domains.get(owner).is_some_and(|domain| {
646 singleton_owns_covers_domain(
647 domain.type_ids(),
648 attribute_id,
649 schema,
650 )
651 }) =>
652 {
653 determined.insert(*attribute);
654 }
655 QueryPattern::Links {
656 players, relation, ..
657 } if determined.contains(relation) => {
658 let relation_domain =
659 binding_domains.get(relation).ok_or_else(¬_total)?;
660 for player in players {
661 if singleton_relates_covers_domain(
662 relation_domain.type_ids(),
663 player.role(),
664 schema,
665 ) {
666 determined.insert(player.player());
667 }
668 }
669 }
670 QueryPattern::FunctionCall {
671 arguments,
672 assigned,
673 function,
674 } if locals.contains_key(function)
675 && arguments.iter().all(|argument| match argument {
676 QueryOperand::Binding { binding } => determined.contains(binding),
677 QueryOperand::Literal { .. } | QueryOperand::Input { .. } => true,
678 }) =>
679 {
680 determined.insert(*assigned);
685 }
686 _ => {}
687 }
688 }
689 if let Some((assignments, groups)) = reduce
690 && groups.iter().all(|group| determined.contains(group))
691 {
692 determined.extend(assignments.iter().map(|assignment| assignment.assigned()));
693 }
694 if determined.len() == previous_len {
695 break;
696 }
697 }
698
699 if window_environment
700 .iter()
701 .chain(&sort_keys)
702 .any(|binding| !determined.contains(binding))
703 {
704 return Err(not_total());
705 }
706 }
707 }
708
709 let output_schema = match plan.output() {
710 QueryOutput::Rows { columns } => OutputSchema::Rows(RowSchema::new(
711 columns
712 .iter()
713 .map(|id| {
714 let binding = plan
715 .bindings()
716 .get(usize::from(id.get()))
717 .expect("validated output binding exists");
718 RowColumn::new(
719 *id,
720 binding_domains[id].clone(),
721 binding.variable().clone(),
722 optional_positive.contains(id),
723 )
724 })
725 .collect::<Vec<_>>(),
726 )),
727 QueryOutput::Documents { fields } => {
728 let columns = fields
729 .iter()
730 .map(|field| {
731 let shape = match field.source() {
732 DocumentSource::Binding { binding } => {
733 let Some(value_type) = binding_domains[binding].value_type() else {
734 return Err(plan_failure(
735 DiagnosticCategory::InvalidContract,
736 "query_plan_document_field_not_scalar",
737 "document fields fetch uniform scalar bindings",
738 ));
739 };
740 DocumentColumnShape::Scalar {
741 value_type,
742 optional: optional_positive.contains(binding),
743 }
744 }
745 DocumentSource::AttributeList { attribute, owner } => {
746 if !positive.contains(owner) {
747 return Err(plan_failure(
748 DiagnosticCategory::InvalidContract,
749 "query_plan_output_not_visible",
750 "attribute lists require a mandatory owner binding",
751 ));
752 }
753 let attribute_type = TypeId::new(
754 TypeKind::Attribute,
755 attribute.label().as_str().to_owned(),
756 )?;
757 let Some(element_type) = schema
758 .types()
759 .get(&attribute_type)
760 .and_then(|resolved| resolved.value_type())
761 .map(|value| value.value_type())
762 else {
763 return Err(plan_failure(
764 DiagnosticCategory::InvalidContract,
765 "query_plan_unknown_attribute",
766 "attribute list references no resolved scalar attribute",
767 ));
768 };
769 let reachable = binding_domains[owner].type_ids().iter().any(|id| {
770 schema
771 .types()
772 .get(id)
773 .is_some_and(|resolved| resolved.owns().contains_key(attribute))
774 });
775 if !reachable {
776 return Err(plan_failure(
777 DiagnosticCategory::InvalidContract,
778 "query_plan_document_unreachable_attribute",
779 "no type in the owner domain owns the listed attribute",
780 ));
781 }
782 DocumentColumnShape::List {
783 attribute: attribute.clone(),
784 element_type,
785 }
786 }
787 };
788 Ok(DocumentColumn::new(field.key().clone(), shape))
789 })
790 .collect::<Result<Vec<_>, Diagnostic>>()?;
791 OutputSchema::Documents(DocumentSchema::new(columns))
792 }
793 };
794
795 Ok(ValidatedQuery {
796 binding_domains,
797 output_schema,
798 plan: plan.clone(),
799 root_visibility: positive.union(&optional_positive).copied().collect(),
800 source_state: context.managed_state().clone(),
801 structural_limits: limits,
802 })
803}
804
805pub fn validate_query_local_function(
812 function: &LocalFunction,
813 context: &MigrationAssertionValidationContext<'_>,
814) -> Result<(), Diagnostic> {
815 let schema = context.resolved_schema();
816 if is_typeql_3_12_builtin_function_name(function.name().label().as_str())
817 || patterns_contain_typeql_builtin_function(function.body())
818 {
819 return Err(typeql_builtin_function_collision());
820 }
821 if schema.functions().contains_key(function.name()) {
822 return Err(plan_failure(
823 DiagnosticCategory::InvalidContract,
824 "query_plan_local_function_shadows_schema",
825 "a plan-local function cannot shadow a schema function",
826 ));
827 }
828 engine::analyze_local_function(function, schema, &QUERY_ENGINE_CODES)?;
829 Ok(())
830}
831
832const fn provider_sort_is_orderable(value_type: ValueTypeTag) -> bool {
833 !matches!(value_type, ValueTypeTag::Duration)
834}
835
836fn sort_key_domain_is_identity_total(
837 domain: &BindingDomain,
838 schema: &type_bridge_schema::ResolvedSchema,
839) -> bool {
840 let Some(value_type) = domain.value_type() else {
841 return false;
842 };
843 if !provider_comparison_equality_matches_canonical_identity(value_type) {
848 return false;
849 }
850 if domain.type_ids().len() <= 1 {
851 return true;
852 }
853
854 finite_attribute_value_domains_are_pairwise_disjoint(domain, value_type, schema)
855}
856
857const fn provider_comparison_equality_matches_canonical_identity(value_type: ValueTypeTag) -> bool {
858 matches!(
859 value_type,
860 ValueTypeTag::String
861 | ValueTypeTag::Long
862 | ValueTypeTag::Boolean
863 | ValueTypeTag::Date
864 | ValueTypeTag::DateTime
865 | ValueTypeTag::Decimal
866 )
867}
868
869fn finite_attribute_value_domains_are_pairwise_disjoint(
876 domain: &BindingDomain,
877 value_type: ValueTypeTag,
878 schema: &type_bridge_schema::ResolvedSchema,
879) -> bool {
880 let mut seen = BTreeSet::<&CanonicalValue>::new();
881 for type_id in domain.type_ids() {
882 if type_id.kind() != TypeKind::Attribute {
883 return false;
884 }
885 let Some(resolved) = schema.types().get(type_id) else {
886 return false;
887 };
888 if !resolved.is_constructible() {
889 return false;
890 }
891 let Some(resolved_value) = resolved.value_type() else {
892 return false;
893 };
894 if resolved_value.value_type() != value_type {
895 return false;
896 }
897 let Some(SchemaAnnotationValue::Values(values)) =
898 resolved_value.annotations().get(&AnnotationKindId::Values)
899 else {
900 return false;
901 };
902 for value in values.iter() {
903 if value.value_type() != value_type || !seen.insert(value) {
904 return false;
905 }
906 }
907 }
908 true
909}
910
911fn one_unique_owns_scope_covers_domain(
920 domain: &BTreeSet<TypeId>,
921 attribute: &type_bridge_contract::id::AttributeId,
922 schema: &type_bridge_schema::ResolvedSchema,
923) -> bool {
924 let mut origin = None;
925 for type_id in domain {
926 let Some(owns) = schema
927 .types()
928 .get(type_id)
929 .and_then(|resolved| resolved.owns().get(attribute))
930 else {
931 return false;
932 };
933 if !owns.is_unique() {
934 return false;
935 }
936 match &origin {
937 Some(expected) if expected != owns.origin().declared() => return false,
938 Some(_) => {}
939 None => origin = Some(owns.origin().declared().clone()),
940 }
941 }
942 origin.is_some()
943}
944
945const fn is_exactly_one(cardinality: Cardinality) -> bool {
946 cardinality.min() == 1 && matches!(cardinality.max(), Some(1))
947}
948
949fn singleton_owns_covers_domain(
955 domain: &BTreeSet<TypeId>,
956 attribute: &type_bridge_contract::id::AttributeId,
957 schema: &type_bridge_schema::ResolvedSchema,
958) -> bool {
959 !domain.is_empty()
960 && domain.iter().all(|type_id| {
961 schema
962 .types()
963 .get(type_id)
964 .and_then(|resolved| resolved.owns().get(attribute))
965 .is_some_and(|owns| {
966 owns.collection_mode() == CollectionMode::Unordered
967 && is_exactly_one(owns.cardinality())
968 })
969 })
970}
971
972fn singleton_relates_covers_domain(
978 domain: &BTreeSet<TypeId>,
979 role: &type_bridge_contract::id::RoleId,
980 schema: &type_bridge_schema::ResolvedSchema,
981) -> bool {
982 !domain.is_empty()
983 && domain.iter().all(|type_id| {
984 schema.types().get(type_id).is_some_and(|resolved| {
985 resolved.relates().get(role).is_some_and(|relates| {
986 relates.collection_mode() == CollectionMode::Unordered
987 && is_exactly_one(relates.cardinality())
988 }) && !resolved
989 .relates()
990 .values()
991 .any(|relates| relates.replaced_roles().contains(role))
992 })
993 })
994}
995
996fn typeql_builtin_function_collision() -> Diagnostic {
997 plan_failure(
998 DiagnosticCategory::InvalidContract,
999 "query_plan_builtin_function_collision",
1000 "TypeQL 3.12 built-in function names cannot identify schema calls or plan-local functions",
1001 )
1002}
1003
1004fn patterns_contain_typeql_builtin_function(patterns: &[QueryPattern]) -> bool {
1005 patterns.iter().any(|pattern| match pattern {
1006 QueryPattern::FunctionCall { function, .. } => {
1007 is_typeql_3_12_builtin_function_name(function.label().as_str())
1008 }
1009 QueryPattern::Or { branches } => branches
1010 .iter()
1011 .any(|branch| patterns_contain_typeql_builtin_function(branch)),
1012 QueryPattern::Not { patterns } | QueryPattern::Try { patterns } => {
1013 patterns_contain_typeql_builtin_function(patterns)
1014 }
1015 QueryPattern::Isa { .. }
1016 | QueryPattern::Has { .. }
1017 | QueryPattern::Links { .. }
1018 | QueryPattern::Value { .. }
1019 | QueryPattern::Reachable { .. } => false,
1020 })
1021}
1022
1023fn plan_failure(
1024 category: DiagnosticCategory,
1025 code: &'static str,
1026 message: &'static str,
1027) -> Diagnostic {
1028 Diagnostic::new(
1029 category,
1030 DiagnosticCode::new(code).expect("static query-plan diagnostic code"),
1031 message,
1032 )
1033}
1034
1035#[cfg(test)]
1036mod tests {
1037 use type_bridge_contract::id::{FunctionId, RoleId, TypeId, TypeKind};
1038
1039 use super::*;
1040
1041 #[test]
1042 fn builtin_function_collision_scan_descends_every_pattern_container() {
1043 let assigned = BindingId::new(0).expect("binding");
1044 let nested = vec![QueryPattern::Not {
1045 patterns: vec![QueryPattern::Try {
1046 patterns: vec![QueryPattern::FunctionCall {
1047 arguments: Vec::new(),
1048 assigned,
1049 function: FunctionId::new("abs").expect("contextual function ID"),
1050 }],
1051 }],
1052 }];
1053 assert!(patterns_contain_typeql_builtin_function(&nested));
1054
1055 let safe = vec![QueryPattern::FunctionCall {
1056 arguments: Vec::new(),
1057 assigned,
1058 function: FunctionId::new("absolute").expect("function ID"),
1059 }];
1060 assert!(!patterns_contain_typeql_builtin_function(&safe));
1061 }
1062
1063 #[test]
1064 fn compatibility_topology_exports_only_definite_positive_edges() {
1065 let binding = |value| BindingId::new(value).expect("binding");
1066 let role_edge =
1067 |relation, player, relation_label: &str, role: &str| QueryPatternV2::RoleEdge {
1068 include_relation_subtypes: false,
1069 player: binding(player),
1070 relation: binding(relation),
1071 relation_type: TypeId::new(TypeKind::Relation, relation_label).expect("relation"),
1072 role: RoleId::new(relation_label, role).expect("role"),
1073 };
1074 let edge_01 = role_edge(0, 1, "first-link", "member");
1075 let edge_12 = role_edge(1, 2, "second-link", "member");
1076
1077 let conjunction = QueryPatternV2::And {
1078 patterns: vec![edge_01.clone(), edge_12.clone()],
1079 };
1080 assert_eq!(
1081 definite_v2_edges(&conjunction),
1082 BTreeSet::from([(binding(0), binding(1)), (binding(1), binding(2))]),
1083 );
1084
1085 let disjunction = QueryPatternV2::Or {
1086 patterns: vec![
1087 conjunction,
1088 QueryPatternV2::And {
1089 patterns: vec![edge_01.clone()],
1090 },
1091 ],
1092 };
1093 assert_eq!(
1094 definite_v2_edges(&disjunction),
1095 BTreeSet::from([(binding(0), binding(1))]),
1096 "an edge absent from one branch is not a root topology proof",
1097 );
1098 assert!(
1099 definite_v2_edges(&QueryPatternV2::Not {
1100 pattern: Box::new(edge_12),
1101 })
1102 .is_empty(),
1103 "negated edges never connect the positive root graph",
1104 );
1105 }
1106}