1use std::fmt;
40use std::hash::{Hash, Hasher};
41
42use rindle::canon::{CanonKey, CanonVal};
43use rindle::{
44 canon_of_lit, canonicalize_wire_number_lits, Ast, Condition, Lit, Op, SimpleCondition,
45 ValuePosition,
46};
47use serde_json::{json, Value};
48
49use crate::query_key::{
50 hash_field, normalize_select_arrays, write_canonical_json, Fnv1a64, QueryKeyError, StreamMode,
51};
52
53pub type Binding = CanonKey;
59
60#[derive(Clone, Debug, PartialEq)]
65pub struct FamilyTemplate {
66 pub stripped: Ast,
68 pub params: Vec<Box<str>>,
70 pub positions: Vec<usize>,
73}
74
75impl FamilyTemplate {
76 pub fn instantiate(&self, binding: &Binding) -> Ast {
82 assert_eq!(
83 binding.len(),
84 self.params.len(),
85 "binding arity must match the template's parameter count"
86 );
87 let residual: Vec<Condition> = match &self.stripped.r#where {
88 None => Vec::new(),
89 Some(Condition::And { conditions }) => conditions.clone(),
90 Some(other) => vec![other.clone()],
91 };
92 let total = residual.len() + self.params.len();
93 let mut residual = residual.into_iter();
94 let mut conjuncts: Vec<Condition> = Vec::with_capacity(total);
95 for i in 0..total {
96 match self.positions.iter().position(|&p| p == i) {
97 Some(k) => conjuncts.push(Condition::Simple(SimpleCondition {
98 op: Op::Eq,
99 left: ValuePosition::Column {
100 name: self.params[k].clone(),
101 },
102 right: ValuePosition::Literal {
103 value: lit_of_canon(&binding[k]),
104 },
105 })),
106 None => conjuncts.push(
107 residual
108 .next()
109 .expect("template positions are consistent with its residual"),
110 ),
111 }
112 }
113 let mut ast = self.stripped.clone();
114 ast.r#where = match conjuncts.len() {
115 0 => None,
116 1 => conjuncts.pop(),
117 _ => Some(Condition::And {
118 conditions: conjuncts,
119 }),
120 };
121 ast
122 }
123}
124
125fn lit_of_canon(v: &CanonVal) -> Lit {
130 match v {
131 CanonVal::Absent | CanonVal::Null => Lit::Null,
132 CanonVal::Bool(b) => Lit::Bool(*b),
133 CanonVal::Int(i) => Lit::Int(*i),
134 CanonVal::Float(bits) => Lit::Number(f64::from_bits(*bits)),
135 CanonVal::Str(s) | CanonVal::Json(s) => Lit::Str(Box::from(&**s)),
136 }
137}
138
139#[derive(Clone, Eq)]
143pub struct FamilyKey {
144 schema_version: String,
145 stream_mode: StreamMode,
146 visibility_key: String,
147 canonical_template: Vec<u8>,
148 fingerprint: u64,
149}
150
151impl FamilyKey {
152 pub fn schema_version(&self) -> &str {
153 &self.schema_version
154 }
155
156 pub fn stream_mode(&self) -> StreamMode {
157 self.stream_mode
158 }
159
160 pub fn visibility_key(&self) -> &str {
161 &self.visibility_key
162 }
163
164 pub fn canonical_template(&self) -> &[u8] {
167 &self.canonical_template
168 }
169
170 pub fn fingerprint(&self) -> u64 {
171 self.fingerprint
172 }
173
174 pub fn fingerprint_hex(&self) -> String {
175 format!("{:016x}", self.fingerprint)
176 }
177}
178
179impl fmt::Debug for FamilyKey {
180 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
181 f.debug_struct("FamilyKey")
182 .field("schema_version", &self.schema_version)
183 .field("stream_mode", &self.stream_mode)
184 .field("visibility_key", &self.visibility_key)
185 .field(
186 "canonical_template",
187 &String::from_utf8_lossy(&self.canonical_template),
188 )
189 .field("fingerprint", &self.fingerprint_hex())
190 .finish()
191 }
192}
193
194impl PartialEq for FamilyKey {
195 fn eq(&self, other: &Self) -> bool {
196 self.schema_version == other.schema_version
197 && self.stream_mode == other.stream_mode
198 && self.visibility_key == other.visibility_key
199 && self.canonical_template == other.canonical_template
200 }
201}
202
203impl Hash for FamilyKey {
204 fn hash<H: Hasher>(&self, state: &mut H) {
205 self.schema_version.hash(state);
206 self.stream_mode.hash(state);
207 self.visibility_key.hash(state);
208 self.canonical_template.hash(state);
209 }
210}
211
212#[derive(Clone, Debug)]
215pub struct FamilyExtraction {
216 pub key: FamilyKey,
217 pub template: FamilyTemplate,
218 pub binding: Binding,
219}
220
221pub fn extract_family(
226 schema_version: impl Into<String>,
227 stream_mode: StreamMode,
228 ast: &Ast,
229 visibility_key: impl Into<String>,
230) -> Result<Option<FamilyExtraction>, QueryKeyError> {
231 if ast.aggregate.is_some() || !ast.group_by.is_empty() || ast.having.is_some() {
234 return Ok(None);
235 }
236 let mut canon = ast.clone();
239 canonicalize_wire_number_lits(&mut canon);
240
241 let Some(root) = canon.r#where.as_ref() else {
242 return Ok(None);
243 };
244 let mut holes: Vec<(usize, Box<str>, CanonVal)> = Vec::new();
246 let mut residual: Vec<Condition> = Vec::new();
247 match root {
248 Condition::Simple(sc) => match hole_of(sc) {
249 Some((col, v)) => holes.push((0, col, v)),
250 None => return Ok(None),
251 },
252 Condition::And { conditions } => {
253 for (i, c) in conditions.iter().enumerate() {
254 match c {
255 Condition::Simple(sc) => match hole_of(sc) {
256 Some((col, v)) => holes.push((i, col, v)),
257 None => residual.push(c.clone()),
258 },
259 other => residual.push(other.clone()),
260 }
261 }
262 if holes.is_empty() {
263 return Ok(None);
264 }
265 }
266 Condition::Or { .. } | Condition::CorrelatedSubquery(_) => return Ok(None),
268 }
269
270 let residual_was_one = residual.len() == 1;
272 let stripped_where = match residual.len() {
273 0 => None,
274 1 => residual.pop(),
275 _ => Some(Condition::And {
276 conditions: residual,
277 }),
278 };
279 if bare_exists_alias_collides(stripped_where.as_ref(), &canon) {
280 return Ok(None);
281 }
282 let stripped_where = match stripped_where {
289 Some(inner @ Condition::And { .. }) if residual_was_one => Some(Condition::And {
290 conditions: vec![inner],
291 }),
292 other => other,
293 };
294 let mut stripped = canon.clone();
295 stripped.r#where = stripped_where;
296
297 let mut value = serde_json::to_value(&canon).map_err(QueryKeyError)?;
299 normalize_select_arrays(&mut value);
300 punch_holes(&mut value, &holes);
301 let mut canonical_template = Vec::new();
302 write_canonical_json(&value, &mut canonical_template)?;
303
304 let schema_version = schema_version.into();
305 let visibility_key = visibility_key.into();
306 let mut hasher = Fnv1a64::new();
307 hash_field(&mut hasher, b"family");
310 hash_field(&mut hasher, schema_version.as_bytes());
311 hash_field(&mut hasher, stream_mode.as_str().as_bytes());
312 hash_field(&mut hasher, visibility_key.as_bytes());
313 hash_field(&mut hasher, &canonical_template);
314 let fingerprint = hasher.finish();
315
316 let positions = holes.iter().map(|(p, _, _)| *p).collect();
317 let params = holes.iter().map(|(_, c, _)| c.clone()).collect();
318 let binding: Binding = holes.into_iter().map(|(_, _, v)| v).collect();
319 Ok(Some(FamilyExtraction {
320 key: FamilyKey {
321 schema_version,
322 stream_mode,
323 visibility_key,
324 canonical_template,
325 fingerprint,
326 },
327 template: FamilyTemplate {
328 stripped,
329 params,
330 positions,
331 },
332 binding,
333 }))
334}
335
336fn hole_of(sc: &SimpleCondition) -> Option<(Box<str>, CanonVal)> {
338 if sc.op != Op::Eq {
339 return None;
340 }
341 let ValuePosition::Column { name } = &sc.left else {
342 return None;
343 };
344 let ValuePosition::Literal { value } = &sc.right else {
345 return None;
346 };
347 canon_of_lit(value).map(|v| (name.clone(), v))
348}
349
350fn bare_exists_alias_collides(stripped_where: Option<&Condition>, ast: &Ast) -> bool {
356 fn bare(cond: &Condition) -> Option<&rindle::CorrelatedSubqueryCondition> {
357 match cond {
358 Condition::CorrelatedSubquery(c) => Some(c),
359 Condition::And { conditions } | Condition::Or { conditions }
360 if conditions.len() == 1 =>
361 {
362 bare(&conditions[0])
363 }
364 _ => None,
365 }
366 }
367 let Some(c) = stripped_where.and_then(bare) else {
368 return false;
369 };
370 if c.related.subquery.limit == Some(0) {
371 return false;
372 }
373 let alias = c.related.subquery.alias.as_deref().unwrap_or("");
374 ast.related
375 .iter()
376 .filter_map(|r| r.subquery.alias.as_deref())
377 .any(|a| a == alias)
378}
379
380fn punch_holes(value: &mut Value, holes: &[(usize, Box<str>, CanonVal)]) {
384 let Some(w) = value.get_mut("where") else {
385 return;
386 };
387 let is_and = w.get("type").and_then(Value::as_str) == Some("and");
388 for (k, (pos, _, _)) in holes.iter().enumerate() {
389 let target = if is_and {
390 w.get_mut("conditions")
391 .and_then(Value::as_array_mut)
392 .and_then(|cs| cs.get_mut(*pos))
393 } else {
394 debug_assert_eq!(*pos, 0);
395 Some(&mut *w)
396 };
397 if let Some(t) = target {
398 t["right"] = json!({ "hole": k });
399 }
400 }
401}
402
403#[cfg(test)]
404mod tests {
405 use super::*;
406 use crate::query_key::QueryKey;
407 use rindle::{
408 table, Bound, CorrelatedSubquery, CorrelatedSubqueryCondition, Correlation, Dir, ExistsOp,
409 OrderPart,
410 };
411 use std::collections::BTreeMap;
412
413 fn extract(ast: &Ast) -> Option<FamilyExtraction> {
414 extract_family("v1", StreamMode::Normalized, ast, "vis").expect("extract")
415 }
416 fn key(ast: &Ast) -> FamilyKey {
417 extract(ast).expect("eligible").key
418 }
419 fn binding(ast: &Ast) -> Binding {
420 extract(ast).expect("eligible").binding
421 }
422 fn col(name: &str) -> ValuePosition {
423 ValuePosition::Column { name: name.into() }
424 }
425 fn lit(v: Lit) -> ValuePosition {
426 ValuePosition::Literal { value: v }
427 }
428 fn simple(name: &str, op: Op, v: Lit) -> Condition {
429 Condition::Simple(SimpleCondition {
430 op,
431 left: col(name),
432 right: lit(v),
433 })
434 }
435 fn eq(name: &str, v: Lit) -> Condition {
436 simple(name, Op::Eq, v)
437 }
438 fn and(conditions: Vec<Condition>) -> Condition {
439 Condition::And { conditions }
440 }
441 fn or(conditions: Vec<Condition>) -> Condition {
442 Condition::Or { conditions }
443 }
444 fn csq(alias: &str, table: &str, parent: &str, child: &str) -> CorrelatedSubquery {
445 CorrelatedSubquery {
446 correlation: Correlation {
447 parent_field: vec![parent.into()],
448 child_field: vec![child.into()],
449 },
450 subquery: Box::new(Ast {
451 table: table.into(),
452 alias: Some(alias.into()),
453 ..Default::default()
454 }),
455 system: None,
456 }
457 }
458 fn exists(alias: &str) -> Condition {
459 Condition::CorrelatedSubquery(CorrelatedSubqueryCondition {
460 related: csq(alias, "track", "id", "albumId"),
461 op: ExistsOp::Exists,
462 flip: None,
463 scalar: None,
464 plan_id: None,
465 })
466 }
467 fn albums_where(w: Condition) -> Ast {
468 Ast {
469 table: "album".into(),
470 r#where: Some(w),
471 ..Default::default()
472 }
473 }
474 fn albums_of(artist: i64) -> Ast {
475 albums_where(eq("artistId", Lit::Int(artist)))
476 }
477
478 #[test]
481 fn same_query_different_literal_shares_a_key_and_differs_in_binding() {
482 let a = albums_of(1);
483 let b = albums_of(2);
484 assert_eq!(key(&a), key(&b));
485 assert_eq!(key(&a).fingerprint(), key(&b).fingerprint());
486 assert_ne!(binding(&a), binding(&b));
487 assert_eq!(binding(&a), vec![CanonVal::Int(1)]);
488 assert_eq!(binding(&b), vec![CanonVal::Int(2)]);
489 let qa = QueryKey::new("v1", StreamMode::Normalized, &a, "vis").unwrap();
491 let qb = QueryKey::new("v1", StreamMode::Normalized, &b, "vis").unwrap();
492 assert_ne!(qa, qb);
493 }
494
495 #[test]
496 fn identical_queries_share_key_and_binding() {
497 let a = albums_of(1);
498 let b = albums_of(1);
499 assert_eq!(key(&a), key(&b));
500 assert_eq!(binding(&a), binding(&b));
501 }
502
503 #[test]
504 fn every_eligible_conjunct_is_holed_no_inference() {
505 let a = albums_where(and(vec![
508 eq("orgId", Lit::Int(9)),
509 eq("artistId", Lit::Int(1)),
510 ]));
511 let b = albums_where(and(vec![
512 eq("orgId", Lit::Int(9)),
513 eq("artistId", Lit::Int(2)),
514 ]));
515 let ea = extract(&a).unwrap();
516 let eb = extract(&b).unwrap();
517 assert_eq!(ea.key, eb.key);
518 assert_eq!(
519 ea.template.params,
520 vec![Box::from("orgId"), Box::from("artistId")]
521 );
522 assert_eq!(ea.binding, vec![CanonVal::Int(9), CanonVal::Int(1)]);
523 assert_eq!(eb.binding, vec![CanonVal::Int(9), CanonVal::Int(2)]);
524 assert_eq!(ea.template.stripped.r#where, None);
526 assert_eq!(ea.template.positions, vec![0, 1]);
527 }
528
529 #[test]
530 fn residual_conjuncts_stay_in_the_template() {
531 let a = albums_where(and(vec![
533 eq("artistId", Lit::Int(1)),
534 simple("year", Op::Gt, Lit::Int(1990)),
535 exists("tracks"),
536 ]));
537 let e = extract(&a).unwrap();
538 assert_eq!(e.template.params, vec![Box::from("artistId")]);
539 assert_eq!(e.template.positions, vec![0]);
540 assert_eq!(
541 e.template.stripped.r#where,
542 Some(and(vec![
543 simple("year", Op::Gt, Lit::Int(1990)),
544 exists("tracks")
545 ]))
546 );
547 let b = albums_where(and(vec![
549 eq("artistId", Lit::Int(1)),
550 simple("year", Op::Gt, Lit::Int(1990)),
551 ]));
552 assert_eq!(
553 extract(&b).unwrap().template.stripped.r#where,
554 Some(simple("year", Op::Gt, Lit::Int(1990)))
555 );
556 }
557
558 #[test]
561 fn conjunct_order_participates_in_the_key() {
562 let ab = albums_where(and(vec![
563 eq("a", Lit::Int(1)),
564 simple("b", Op::Gt, Lit::Int(3)),
565 ]));
566 let ba = albums_where(and(vec![
567 simple("b", Op::Gt, Lit::Int(3)),
568 eq("a", Lit::Int(1)),
569 ]));
570 assert_ne!(key(&ab), key(&ba));
571 let xy = albums_where(and(vec![eq("x", Lit::Int(1)), eq("y", Lit::Int(2))]));
573 let yx = albums_where(and(vec![eq("y", Lit::Int(2)), eq("x", Lit::Int(1))]));
574 assert_ne!(key(&xy), key(&yx));
575 }
576
577 #[test]
580 fn differing_related_structure_is_a_different_family() {
581 let plain = albums_of(1);
582 let mut with_rel = albums_of(2);
583 with_rel.related = vec![csq("tracks", "track", "id", "albumId")];
584 assert_ne!(key(&plain), key(&with_rel));
585 }
586
587 #[test]
588 fn differing_subquery_literal_is_a_different_family() {
589 let mut a = albums_of(1);
590 let mut b = albums_of(2);
591 let mut ra = csq("tracks", "track", "id", "albumId");
592 ra.subquery.r#where = Some(eq("genre", Lit::Int(1)));
593 let mut rb = csq("tracks", "track", "id", "albumId");
594 rb.subquery.r#where = Some(eq("genre", Lit::Int(2)));
595 a.related = vec![ra];
596 b.related = vec![rb];
597 assert_ne!(key(&a), key(&b), "a subquery literal is not a hole");
598 assert_eq!(binding(&a), vec![CanonVal::Int(1)]);
600 }
601
602 #[test]
603 fn order_by_limit_one_select_start_all_separate_families() {
604 let base = albums_of(1);
605 let mut order_by = albums_of(2);
606 order_by.order_by = vec![OrderPart("title".into(), Dir::Asc)];
607 let mut limit = albums_of(2);
608 limit.limit = Some(10);
609 let mut one = albums_of(2);
610 one.one = true;
611 one.limit = Some(1);
612 let mut select = albums_of(2);
613 select.select = Some(vec!["title".into()]);
614 let mut start = albums_of(2);
615 start.start = Some(Bound {
616 row: BTreeMap::from([(Box::from("id"), Lit::Int(5))]),
617 exclusive: true,
618 });
619 for (name, other) in [
620 ("order_by", order_by),
621 ("limit", limit),
622 ("one", one),
623 ("select", select),
624 ("start", start),
625 ] {
626 assert_ne!(key(&base), key(&other), "{name} must separate families");
627 }
628 let mut start2 = albums_of(2);
632 start2.start = Some(Bound {
633 row: BTreeMap::from([(Box::from("id"), Lit::Int(6))]),
634 exclusive: true,
635 });
636 let mut start1 = albums_of(2);
637 start1.start = Some(Bound {
638 row: BTreeMap::from([(Box::from("id"), Lit::Int(5))]),
639 exclusive: true,
640 });
641 assert_ne!(key(&start1), key(&start2));
642 }
643
644 #[test]
645 fn candidates_under_or_or_with_other_ops_are_not_holes() {
646 let under_or = albums_where(or(vec![eq("a", Lit::Int(1)), eq("b", Lit::Int(2))]));
648 assert!(extract(&under_or).is_none());
649 for op in [
651 Op::Ne,
652 Op::In,
653 Op::Lt,
654 Op::Le,
655 Op::Gt,
656 Op::Ge,
657 Op::Is,
658 Op::IsNot,
659 Op::Like,
660 ] {
661 let v = if op == Op::In {
662 Lit::Array(vec![Lit::Int(1)])
663 } else {
664 Lit::Int(1)
665 };
666 assert!(
667 extract(&albums_where(simple("a", op, v))).is_none(),
668 "{op:?} must not be a hole"
669 );
670 }
671 let mixed = albums_where(and(vec![
673 simple("a", Op::Ne, Lit::Int(1)),
674 eq("b", Lit::Int(2)),
675 ]));
676 let e = extract(&mixed).unwrap();
677 assert_eq!(e.template.params, vec![Box::from("b")]);
678 assert_eq!(e.template.positions, vec![1]);
679 assert_eq!(
680 e.template.stripped.r#where,
681 Some(simple("a", Op::Ne, Lit::Int(1)))
682 );
683 assert!(extract(&albums_where(exists("tracks"))).is_none());
685 let lhs_lit = albums_where(Condition::Simple(SimpleCondition {
687 op: Op::Eq,
688 left: lit(Lit::Int(1)),
689 right: lit(Lit::Int(1)),
690 }));
691 assert!(extract(&lhs_lit).is_none());
692 }
693
694 #[test]
695 fn aggregate_group_by_having_are_refused() {
696 let mut agg = albums_of(1);
697 agg.aggregate = Some(rindle::Aggregate::Count);
698 assert!(extract(&agg).is_none());
699 let mut grouped = albums_of(1);
700 grouped.group_by = vec!["artistId".into()];
701 assert!(extract(&grouped).is_none());
702 let mut having = albums_of(1);
703 having.having = Some(eq("count", Lit::Int(1)));
704 assert!(extract(&having).is_none());
705 }
706
707 #[test]
708 fn null_and_array_literals_are_not_holes() {
709 assert!(extract(&albums_where(eq("a", Lit::Null))).is_none());
710 assert!(extract(&albums_where(eq("a", Lit::Array(vec![Lit::Int(1)])))).is_none());
711 let e = extract(&albums_where(and(vec![
713 eq("a", Lit::Null),
714 eq("b", Lit::Int(2)),
715 ])))
716 .unwrap();
717 assert_eq!(e.template.params, vec![Box::from("b")]);
718 assert_eq!(e.template.stripped.r#where, Some(eq("a", Lit::Null)));
719 }
720
721 #[test]
722 fn no_where_is_a_singleton() {
723 assert!(extract(&Ast::new("album")).is_none());
724 }
725
726 #[test]
727 fn bare_exists_alias_colliding_with_related_stays_a_singleton() {
728 let mut a = albums_where(and(vec![eq("artistId", Lit::Int(1)), exists("tracks")]));
732 a.related = vec![csq("tracks", "track", "id", "albumId")];
733 assert!(extract(&a).is_none());
734 let mut b = albums_where(and(vec![eq("artistId", Lit::Int(1)), exists("hasTracks")]));
736 b.related = vec![csq("tracks", "track", "id", "albumId")];
737 assert!(extract(&b).is_some());
738 let mut c = albums_where(and(vec![
740 eq("artistId", Lit::Int(1)),
741 exists("tracks"),
742 exists("tracks"),
743 ]));
744 c.related = vec![csq("tracks", "track", "id", "albumId")];
745 assert!(extract(&c).is_some());
746 }
747
748 #[test]
752 fn stripped_template_keeps_the_concrete_slot_layout() {
753 use rindle::{normalize_pipeline_ast, query_local_slot_names};
754 let mut a = albums_where(and(vec![eq("artistId", Lit::Int(1)), exists("hasTracks")]));
755 a.related = vec![csq("tracks", "track", "id", "albumId")];
756 let e = extract(&a).unwrap();
757 let concrete = query_local_slot_names(&normalize_pipeline_ast(&a));
758 let stripped = query_local_slot_names(&normalize_pipeline_ast(&e.template.stripped));
759 assert_eq!(concrete.len(), stripped.len());
760 assert_eq!(concrete[0], stripped[0], "related slots are identical");
761 assert_eq!(concrete[1].as_ref(), "hasTracks_0");
762 assert_eq!(stripped[1].as_ref(), "hasTracks");
763 }
764
765 #[test]
768 fn int_and_integral_number_are_one_binding() {
769 let int = albums_where(eq("artistId", Lit::Int(1)));
770 let num = albums_where(eq("artistId", Lit::Number(1.0)));
771 let frac = albums_where(eq("artistId", Lit::Number(1.5)));
772 assert_eq!(key(&int), key(&num));
773 assert_eq!(binding(&int), binding(&num));
774 assert_eq!(binding(&int), vec![CanonVal::Int(1)]);
775 assert_eq!(key(&int), key(&frac), "the spelling is a hole either way");
776 assert_ne!(binding(&int), binding(&frac));
777 assert_eq!(binding(&frac), vec![CanonVal::Float(1.5f64.to_bits())]);
778 assert_eq!(
780 binding(&albums_where(eq("name", Lit::Str("x".into())))),
781 vec![CanonVal::Str("x".into())]
782 );
783 assert_eq!(
784 binding(&albums_where(eq("flag", Lit::Bool(true)))),
785 vec![CanonVal::Bool(true)]
786 );
787 let s = albums_where(eq("artistId", Lit::Str("1".into())));
790 assert_eq!(key(&int), key(&s));
791 assert_ne!(binding(&int), binding(&s));
792 }
793
794 #[test]
795 fn binding_equality_agrees_with_values_equal_over_lit_pairs() {
796 use rindle::value::values_equal;
799 let lits = [
800 Lit::Int(0),
801 Lit::Int(1),
802 Lit::Int(1 << 53),
803 Lit::Int((1 << 53) + 1),
804 Lit::Number(0.0),
805 Lit::Number(1.0),
806 Lit::Number(1.5),
807 Lit::Number((1u64 << 53) as f64),
808 Lit::Str("1".into()),
809 Lit::Bool(true),
810 ];
811 let same_class = |a: &Lit, b: &Lit| {
814 matches!(
815 (a, b),
816 (Lit::Int(_) | Lit::Number(_), Lit::Int(_) | Lit::Number(_))
817 | (Lit::Str(_), Lit::Str(_))
818 | (Lit::Bool(_), Lit::Bool(_))
819 )
820 };
821 for a in &lits {
822 for b in &lits {
823 let ca = canon_of_lit(a).unwrap();
824 let cb = canon_of_lit(b).unwrap();
825 if !same_class(a, b) {
826 assert_ne!(ca, cb, "{a:?} vs {b:?}: type-tagged keys never collide");
827 continue;
828 }
829 let va = ca.to_owned_value();
830 let vb = cb.to_owned_value();
831 assert_eq!(
832 ca == cb,
833 values_equal(va.as_ref(), vb.as_ref()),
834 "{a:?} vs {b:?}"
835 );
836 }
837 }
838 }
839
840 #[test]
843 fn instantiate_is_the_exact_inverse_of_extraction() {
844 let mut corpus: Vec<Ast> = vec![
845 albums_of(1),
846 albums_where(and(vec![
847 eq("a", Lit::Int(1)),
848 eq("b", Lit::Str("x".into())),
849 ])),
850 albums_where(and(vec![
851 simple("year", Op::Gt, Lit::Int(1990)),
852 eq("artistId", Lit::Int(1)),
853 exists("tracks"),
854 eq("genre", Lit::Bool(true)),
855 ])),
856 albums_where(and(vec![
857 eq("a", Lit::Number(2.5)),
858 eq("b", Lit::Number(3.0)),
859 ])),
860 table("issue")
861 .r#where("assigneeId", 7i64)
862 .order_by("createdAt", Dir::Desc)
863 .limit(50)
864 .build(),
865 ];
866 let mut rel = albums_where(and(vec![eq("artistId", Lit::Int(1)), exists("hasTracks")]));
867 rel.related = vec![csq("tracks", "track", "id", "albumId")];
868 rel.order_by = vec![OrderPart("title".into(), Dir::Asc)];
869 rel.limit = Some(5);
870 rel.select = Some(vec!["title".into(), "id".into()]);
871 corpus.push(rel);
872 for ast in &corpus {
873 let e = extract(ast).unwrap();
874 let mut canon = ast.clone();
875 canonicalize_wire_number_lits(&mut canon);
876 assert_eq!(e.template.instantiate(&e.binding), canon, "{ast:?}");
877 let again = extract(&e.template.instantiate(&e.binding)).unwrap();
879 assert_eq!(again.key, e.key);
880 assert_eq!(again.binding, e.binding);
881 assert_eq!(again.template, e.template);
882 }
883 }
884
885 #[test]
890 fn a_lone_nested_and_residual_round_trips_with_its_nesting() {
891 let ast = albums_where(and(vec![
892 eq("artistId", Lit::Int(2)),
893 and(vec![
894 exists("hasTracks"),
895 simple("year", Op::IsNot, Lit::Null),
896 ]),
897 ]));
898 let e = extract(&ast).unwrap();
899 let mut canon = ast.clone();
900 canonicalize_wire_number_lits(&mut canon);
901 assert_eq!(e.template.instantiate(&e.binding), canon);
902 assert_eq!(e.template.params.len(), 1);
903 match &e.template.stripped.r#where {
905 Some(Condition::And { conditions }) => {
906 assert_eq!(conditions.len(), 1);
907 assert!(matches!(conditions[0], Condition::And { .. }));
908 }
909 other => panic!("unexpected stripped where {other:?}"),
910 }
911 let tail = albums_where(and(vec![
913 and(vec![
914 exists("hasTracks"),
915 simple("year", Op::IsNot, Lit::Null),
916 ]),
917 eq("artistId", Lit::Int(2)),
918 ]));
919 let et = extract(&tail).unwrap();
920 let mut canon_tail = tail.clone();
921 canonicalize_wire_number_lits(&mut canon_tail);
922 assert_eq!(et.template.instantiate(&et.binding), canon_tail);
923 assert_ne!(et.key, e.key, "conjunct order is part of the key");
924 }
925
926 #[test]
927 fn instantiating_another_binding_yields_that_family_mate() {
928 let a = albums_where(and(vec![
929 simple("year", Op::Gt, Lit::Int(1990)),
930 eq("artistId", Lit::Int(1)),
931 ]));
932 let b = albums_where(and(vec![
933 simple("year", Op::Gt, Lit::Int(1990)),
934 eq("artistId", Lit::Int(2)),
935 ]));
936 let ea = extract(&a).unwrap();
937 let eb = extract(&b).unwrap();
938 assert_eq!(ea.template.instantiate(&eb.binding), b);
939 }
940
941 #[test]
944 fn select_order_does_not_fork_a_family_key() {
945 let a = table("issue")
946 .select("title")
947 .select("priority")
948 .r#where("ownerId", 1i64)
949 .build();
950 let b = table("issue")
951 .select("priority")
952 .select("title")
953 .r#where("ownerId", 2i64)
954 .build();
955 assert_eq!(key(&a), key(&b));
956 }
957
958 #[test]
959 fn schema_version_stream_mode_and_visibility_key_separate_families() {
960 let a = albums_of(1);
961 let base = extract_family("v1", StreamMode::Normalized, &a, "vis")
962 .unwrap()
963 .unwrap()
964 .key;
965 let sv = extract_family("v2", StreamMode::Normalized, &a, "vis")
966 .unwrap()
967 .unwrap()
968 .key;
969 let sm = extract_family("v1", StreamMode::Flat, &a, "vis")
970 .unwrap()
971 .unwrap()
972 .key;
973 let vk = extract_family("v1", StreamMode::Normalized, &a, "other")
974 .unwrap()
975 .unwrap()
976 .key;
977 assert_ne!(base, sv);
978 assert_ne!(base, sm);
979 assert_ne!(base, vk);
980 assert_eq!(base.schema_version(), "v1");
981 assert_eq!(base.visibility_key(), "vis");
982 assert_eq!(base.stream_mode(), StreamMode::Normalized);
983 assert_eq!(base.fingerprint_hex().len(), 16);
984 }
985
986 #[test]
987 fn canonical_template_carries_positional_holes() {
988 let a = albums_where(and(vec![
989 simple("year", Op::Gt, Lit::Int(1990)),
990 eq("artistId", Lit::Int(1)),
991 ]));
992 let k = key(&a);
993 let text = String::from_utf8(k.canonical_template().to_vec()).unwrap();
994 assert!(text.contains(r#"{"hole":0}"#), "{text}");
995 assert!(
996 !text.contains("\"value\":1}"),
997 "the literal must not leak: {text}"
998 );
999 let year = text.find("1990").unwrap();
1001 let hole = text.find(r#"{"hole":0}"#).unwrap();
1002 assert!(year < hole);
1003 }
1004}