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 { .. }
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
555 .pipeline()
556 .iter()
557 .any(|stage| matches!(stage, ReadStage::Offset { .. } | ReadStage::Limit { .. }));
558 if windowed {
559 let sort_keys: BTreeSet<BindingId> = plan
560 .pipeline()
561 .iter()
562 .filter_map(|stage| match stage {
563 ReadStage::Sort { terms } => Some(terms),
564 _ => None,
565 })
566 .flatten()
567 .map(|term| term.binding())
568 .collect();
569 let reduce = plan.pipeline().iter().find_map(|stage| match stage {
570 ReadStage::Reduce {
571 assignments,
572 groups,
573 } => Some((assignments.as_slice(), groups.as_slice())),
574 _ => None,
575 });
576 let window_environment: Vec<BindingId> = if let Some((assignments, groups)) = reduce {
577 groups
578 .iter()
579 .copied()
580 .chain(assignments.iter().map(|assignment| assignment.assigned()))
581 .collect()
582 } else if let Some(ReadStage::Select { bindings }) = plan
583 .pipeline()
584 .iter()
585 .find(|stage| matches!(stage, ReadStage::Select { .. }))
586 {
587 bindings.clone()
588 } else {
589 positive.union(&optional_positive).copied().collect()
590 };
591
592 let not_total = || {
593 plan_failure(
594 DiagnosticCategory::InvalidContract,
595 "query_plan_window_order_not_total",
596 "offset and limit require a sort tuple proven total for every visible column",
597 )
598 };
599 let global_reduce = reduce.is_some_and(|(_, groups)| groups.is_empty());
600 if !global_reduce {
601 let mut determined = BTreeSet::new();
602 for binding in &sort_keys {
603 let domain = binding_domains.get(binding).ok_or_else(¬_total)?;
604 if !sort_key_domain_is_identity_total(domain, schema) {
605 return Err(not_total());
606 }
607 determined.insert(*binding);
608 }
609
610 loop {
611 let previous_len = determined.len();
612 for pattern in patterns {
613 match pattern {
614 QueryPattern::Has {
615 owner,
616 attribute,
617 attribute_id,
618 } if determined.contains(attribute) => {
619 let owner_domain = binding_domains.get(owner).ok_or_else(¬_total)?;
620 if !owner_domain.type_ids().is_empty()
621 && binding_domains.get(attribute).is_some_and(|domain| {
622 sort_key_domain_is_identity_total(domain, schema)
623 })
624 && one_unique_owns_scope_covers_domain(
625 owner_domain.type_ids(),
626 attribute_id,
627 schema,
628 )
629 {
630 determined.insert(*owner);
631 }
632 }
633 QueryPattern::FunctionCall {
634 arguments,
635 assigned,
636 function,
637 } if locals.contains_key(function)
638 && arguments.iter().all(|argument| match argument {
639 QueryOperand::Binding { binding } => determined.contains(binding),
640 QueryOperand::Literal { .. } | QueryOperand::Input { .. } => true,
641 }) =>
642 {
643 determined.insert(*assigned);
648 }
649 _ => {}
650 }
651 }
652 if let Some((assignments, groups)) = reduce
653 && groups.iter().all(|group| determined.contains(group))
654 {
655 determined.extend(assignments.iter().map(|assignment| assignment.assigned()));
656 }
657 if determined.len() == previous_len {
658 break;
659 }
660 }
661
662 if window_environment
663 .iter()
664 .any(|binding| !determined.contains(binding))
665 {
666 return Err(not_total());
667 }
668 }
669 }
670
671 let output_schema = match plan.output() {
672 QueryOutput::Rows { columns } => OutputSchema::Rows(RowSchema::new(
673 columns
674 .iter()
675 .map(|id| {
676 let binding = plan
677 .bindings()
678 .get(usize::from(id.get()))
679 .expect("validated output binding exists");
680 RowColumn::new(
681 *id,
682 binding_domains[id].clone(),
683 binding.variable().clone(),
684 optional_positive.contains(id),
685 )
686 })
687 .collect::<Vec<_>>(),
688 )),
689 QueryOutput::Documents { fields } => {
690 let columns = fields
691 .iter()
692 .map(|field| {
693 let shape = match field.source() {
694 DocumentSource::Binding { binding } => {
695 let Some(value_type) = binding_domains[binding].value_type() else {
696 return Err(plan_failure(
697 DiagnosticCategory::InvalidContract,
698 "query_plan_document_field_not_scalar",
699 "document fields fetch uniform scalar bindings",
700 ));
701 };
702 DocumentColumnShape::Scalar {
703 value_type,
704 optional: optional_positive.contains(binding),
705 }
706 }
707 DocumentSource::AttributeList { attribute, owner } => {
708 if !positive.contains(owner) {
709 return Err(plan_failure(
710 DiagnosticCategory::InvalidContract,
711 "query_plan_output_not_visible",
712 "attribute lists require a mandatory owner binding",
713 ));
714 }
715 let attribute_type = TypeId::new(
716 TypeKind::Attribute,
717 attribute.label().as_str().to_owned(),
718 )?;
719 let Some(element_type) = schema
720 .types()
721 .get(&attribute_type)
722 .and_then(|resolved| resolved.value_type())
723 .map(|value| value.value_type())
724 else {
725 return Err(plan_failure(
726 DiagnosticCategory::InvalidContract,
727 "query_plan_unknown_attribute",
728 "attribute list references no resolved scalar attribute",
729 ));
730 };
731 let reachable = binding_domains[owner].type_ids().iter().any(|id| {
732 schema
733 .types()
734 .get(id)
735 .is_some_and(|resolved| resolved.owns().contains_key(attribute))
736 });
737 if !reachable {
738 return Err(plan_failure(
739 DiagnosticCategory::InvalidContract,
740 "query_plan_document_unreachable_attribute",
741 "no type in the owner domain owns the listed attribute",
742 ));
743 }
744 DocumentColumnShape::List {
745 attribute: attribute.clone(),
746 element_type,
747 }
748 }
749 };
750 Ok(DocumentColumn::new(field.key().clone(), shape))
751 })
752 .collect::<Result<Vec<_>, Diagnostic>>()?;
753 OutputSchema::Documents(DocumentSchema::new(columns))
754 }
755 };
756
757 Ok(ValidatedQuery {
758 binding_domains,
759 output_schema,
760 plan: plan.clone(),
761 root_visibility: positive.union(&optional_positive).copied().collect(),
762 source_state: context.managed_state().clone(),
763 structural_limits: limits,
764 })
765}
766
767pub fn validate_query_local_function(
774 function: &LocalFunction,
775 context: &MigrationAssertionValidationContext<'_>,
776) -> Result<(), Diagnostic> {
777 let schema = context.resolved_schema();
778 if is_typeql_3_12_builtin_function_name(function.name().label().as_str())
779 || patterns_contain_typeql_builtin_function(function.body())
780 {
781 return Err(typeql_builtin_function_collision());
782 }
783 if schema.functions().contains_key(function.name()) {
784 return Err(plan_failure(
785 DiagnosticCategory::InvalidContract,
786 "query_plan_local_function_shadows_schema",
787 "a plan-local function cannot shadow a schema function",
788 ));
789 }
790 engine::analyze_local_function(function, schema, &QUERY_ENGINE_CODES)?;
791 Ok(())
792}
793
794const fn provider_sort_is_orderable(value_type: ValueTypeTag) -> bool {
795 !matches!(value_type, ValueTypeTag::Duration)
796}
797
798fn sort_key_domain_is_identity_total(
799 domain: &BindingDomain,
800 schema: &type_bridge_schema::ResolvedSchema,
801) -> bool {
802 let Some(value_type) = domain.value_type() else {
803 return false;
804 };
805 if !provider_comparison_equality_matches_canonical_identity(value_type) {
810 return false;
811 }
812 if domain.type_ids().len() <= 1 {
813 return true;
814 }
815
816 finite_attribute_value_domains_are_pairwise_disjoint(domain, value_type, schema)
817}
818
819const fn provider_comparison_equality_matches_canonical_identity(value_type: ValueTypeTag) -> bool {
820 matches!(
821 value_type,
822 ValueTypeTag::String
823 | ValueTypeTag::Long
824 | ValueTypeTag::Boolean
825 | ValueTypeTag::Date
826 | ValueTypeTag::DateTime
827 | ValueTypeTag::Decimal
828 )
829}
830
831fn finite_attribute_value_domains_are_pairwise_disjoint(
838 domain: &BindingDomain,
839 value_type: ValueTypeTag,
840 schema: &type_bridge_schema::ResolvedSchema,
841) -> bool {
842 let mut seen = BTreeSet::<&CanonicalValue>::new();
843 for type_id in domain.type_ids() {
844 if type_id.kind() != TypeKind::Attribute {
845 return false;
846 }
847 let Some(resolved) = schema.types().get(type_id) else {
848 return false;
849 };
850 if !resolved.is_constructible() {
851 return false;
852 }
853 let Some(resolved_value) = resolved.value_type() else {
854 return false;
855 };
856 if resolved_value.value_type() != value_type {
857 return false;
858 }
859 let Some(SchemaAnnotationValue::Values(values)) =
860 resolved_value.annotations().get(&AnnotationKindId::Values)
861 else {
862 return false;
863 };
864 for value in values.iter() {
865 if value.value_type() != value_type || !seen.insert(value) {
866 return false;
867 }
868 }
869 }
870 true
871}
872
873fn one_unique_owns_scope_covers_domain(
882 domain: &BTreeSet<TypeId>,
883 attribute: &type_bridge_contract::id::AttributeId,
884 schema: &type_bridge_schema::ResolvedSchema,
885) -> bool {
886 let mut origin = None;
887 for type_id in domain {
888 let Some(owns) = schema
889 .types()
890 .get(type_id)
891 .and_then(|resolved| resolved.owns().get(attribute))
892 else {
893 return false;
894 };
895 if !owns.is_unique() {
896 return false;
897 }
898 match &origin {
899 Some(expected) if expected != owns.origin().declared() => return false,
900 Some(_) => {}
901 None => origin = Some(owns.origin().declared().clone()),
902 }
903 }
904 origin.is_some()
905}
906
907fn typeql_builtin_function_collision() -> Diagnostic {
908 plan_failure(
909 DiagnosticCategory::InvalidContract,
910 "query_plan_builtin_function_collision",
911 "TypeQL 3.12 built-in function names cannot identify schema calls or plan-local functions",
912 )
913}
914
915fn patterns_contain_typeql_builtin_function(patterns: &[QueryPattern]) -> bool {
916 patterns.iter().any(|pattern| match pattern {
917 QueryPattern::FunctionCall { function, .. } => {
918 is_typeql_3_12_builtin_function_name(function.label().as_str())
919 }
920 QueryPattern::Or { branches } => branches
921 .iter()
922 .any(|branch| patterns_contain_typeql_builtin_function(branch)),
923 QueryPattern::Not { patterns } | QueryPattern::Try { patterns } => {
924 patterns_contain_typeql_builtin_function(patterns)
925 }
926 QueryPattern::Isa { .. }
927 | QueryPattern::Has { .. }
928 | QueryPattern::Links { .. }
929 | QueryPattern::Value { .. }
930 | QueryPattern::Reachable { .. } => false,
931 })
932}
933
934fn plan_failure(
935 category: DiagnosticCategory,
936 code: &'static str,
937 message: &'static str,
938) -> Diagnostic {
939 Diagnostic::new(
940 category,
941 DiagnosticCode::new(code).expect("static query-plan diagnostic code"),
942 message,
943 )
944}
945
946#[cfg(test)]
947mod tests {
948 use type_bridge_contract::id::{FunctionId, RoleId, TypeId, TypeKind};
949
950 use super::*;
951
952 #[test]
953 fn builtin_function_collision_scan_descends_every_pattern_container() {
954 let assigned = BindingId::new(0).expect("binding");
955 let nested = vec![QueryPattern::Not {
956 patterns: vec![QueryPattern::Try {
957 patterns: vec![QueryPattern::FunctionCall {
958 arguments: Vec::new(),
959 assigned,
960 function: FunctionId::new("abs").expect("contextual function ID"),
961 }],
962 }],
963 }];
964 assert!(patterns_contain_typeql_builtin_function(&nested));
965
966 let safe = vec![QueryPattern::FunctionCall {
967 arguments: Vec::new(),
968 assigned,
969 function: FunctionId::new("absolute").expect("function ID"),
970 }];
971 assert!(!patterns_contain_typeql_builtin_function(&safe));
972 }
973
974 #[test]
975 fn compatibility_topology_exports_only_definite_positive_edges() {
976 let binding = |value| BindingId::new(value).expect("binding");
977 let role_edge =
978 |relation, player, relation_label: &str, role: &str| QueryPatternV2::RoleEdge {
979 include_relation_subtypes: false,
980 player: binding(player),
981 relation: binding(relation),
982 relation_type: TypeId::new(TypeKind::Relation, relation_label).expect("relation"),
983 role: RoleId::new(relation_label, role).expect("role"),
984 };
985 let edge_01 = role_edge(0, 1, "first-link", "member");
986 let edge_12 = role_edge(1, 2, "second-link", "member");
987
988 let conjunction = QueryPatternV2::And {
989 patterns: vec![edge_01.clone(), edge_12.clone()],
990 };
991 assert_eq!(
992 definite_v2_edges(&conjunction),
993 BTreeSet::from([(binding(0), binding(1)), (binding(1), binding(2))]),
994 );
995
996 let disjunction = QueryPatternV2::Or {
997 patterns: vec![
998 conjunction,
999 QueryPatternV2::And {
1000 patterns: vec![edge_01.clone()],
1001 },
1002 ],
1003 };
1004 assert_eq!(
1005 definite_v2_edges(&disjunction),
1006 BTreeSet::from([(binding(0), binding(1))]),
1007 "an edge absent from one branch is not a root topology proof",
1008 );
1009 assert!(
1010 definite_v2_edges(&QueryPatternV2::Not {
1011 pattern: Box::new(edge_12),
1012 })
1013 .is_empty(),
1014 "negated edges never connect the positive root graph",
1015 );
1016 }
1017}