Rindle docs and package mapSkip to main content

rindle_wire/
family_key.rs

1//! [`FamilyKey`] — the identity of a **parameterized query family** (design 310 §3):
2//! the [`QueryKey`](crate::query_key::QueryKey) construction over a query's *template*,
3//! the canonical AST with its eligible root-equality literals punched into holes. Two
4//! subscriptions are family-mates iff their `FamilyKey`s are equal; the extracted
5//! literal tuple — the [`Binding`], a [`CanonKey`] — is what distinguishes them.
6//!
7//! Extraction is pure data work: no schema, no builder, no engine. What it decides:
8//!
9//! - **Which literals are holes** (design §3.1): the RHS of every root-level equality
10//!   conjunct — the single top-level `Simple`, or the direct `Simple` children of a
11//!   top-level `And` — whose op is `=`, whose LHS is a column, and whose literal is a
12//!   scalar a family may bind on ([`canon_of_lit`]: not `Null`, not an array). *Every*
13//!   eligible conjunct is holed, no inference: two ASTs group iff they agree everywhere
14//!   else, and a conjunct whose literal happens to agree across bindings simply
15//!   contributes a constant column to the binding tuple.
16//! - **What the key hashes** (impl plan D2): the canonical JSON of the
17//!   number-canonicalized AST with each holed conjunct's `right` replaced **in position**
18//!   by `{"hole": i}`. Position is kept deliberately: conjunct order participates in the
19//!   template (design §3.1 — there is no `normalize_ast` pass), so `And[a=?, b>3]` and
20//!   `And[b>3, a=?]` are different families. A key over the stripped AST alone would
21//!   silently widen grouping.
22//! - **What the builder compiles** ([`FamilyTemplate::stripped`]): the same canonical
23//!   AST with the holed conjuncts *removed* (an `And` left with one child collapses to
24//!   that child; zero ⇒ `where: None`), so the engine's family root connection carries
25//!   only the residual predicate and the membership test replaces the holes.
26//! - **What does not group** (design §3.2): an aggregate root (`aggregate` /
27//!   `group_by` / `having`) is refused outright; a query with no eligible conjunct
28//!   yields `Ok(None)` and falls back to today's per-query materialization, byte for
29//!   byte. Everything outside the holes — `related` structure, subquery literals,
30//!   `order_by`, `limit`, `one`, `select`, `start` — stays in the key, so it separates
31//!   families exactly as it separates `QueryKey`s.
32//!
33//! One shape the engine's alias normalization would otherwise reject is also refused
34//! here: when stripping leaves the root `where` a **bare** EXISTS whose alias collides
35//! with a materialized `related` of the same name. The concrete query's `And` gets that
36//! alias uniquified (`x` → `x_0`); the stripped template's singleton does not, and the
37//! builder refuses the collision. Such a query stays a singleton.
38
39use 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
53/// A family member's parameter tuple: the canonical literal of each holed conjunct, in
54/// hole order. A [`CanonKey`] (impl plan D1) — the *same* type the engine's partition
55/// membership test and the daemon's per-partition demux key by, so the three agree by
56/// construction. `Send + Sync` (the payloads are `Arc<str>`), so it crosses worker
57/// command channels.
58pub type Binding = CanonKey;
59
60/// The compiled half of a family: the stripped AST the builder lowers, plus the names
61/// of the holed columns (the partition key, resolved to `ColId`s by the builder) and
62/// each hole's position among the original top-level conjuncts (what
63/// [`instantiate`](FamilyTemplate::instantiate) needs to be an exact inverse).
64#[derive(Clone, Debug, PartialEq)]
65pub struct FamilyTemplate {
66    /// The number-canonicalized AST with the holed conjuncts removed.
67    pub stripped: Ast,
68    /// The holed conjuncts' column names, in hole order.
69    pub params: Vec<Box<str>>,
70    /// Each hole's index among the concrete query's top-level conjuncts (`0` for a
71    /// query whose whole `where` was the one holed conjunct).
72    pub positions: Vec<usize>,
73}
74
75impl FamilyTemplate {
76    /// The concrete AST for one binding — the exact inverse of extraction on the
77    /// number-canonicalized AST (`instantiate(extract(a).template, extract(a).binding)
78    /// == canonicalized(a)`). Used by the engine's differential (the standalone twin
79    /// of a partition), by the daemon's per-partition normalize fold, and by
80    /// migration.
81    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
125/// The literal spelling of a binding value: the wire-token identity
126/// `canonicalize_wire_number_lits` would produce (`Int` for the integral class, the
127/// exact bits otherwise). `Null`/`Absent`/`Json` never come out of extraction
128/// ([`canon_of_lit`] refuses them); they are mapped for totality only.
129fn 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/// Every daemon-side input that can affect the emitted rows of a family: the same four
140/// fields as [`QueryKey`](crate::query_key::QueryKey), with the canonical **template**
141/// bytes in place of the canonical AST bytes.
142#[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    /// The canonical template bytes: the canonical AST JSON with `{"hole": i}` in each
165    /// holed conjunct's `right` position.
166    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/// The result of a successful extraction: the family the query belongs to, the template
213/// its pipeline compiles from, and the binding this query is.
214#[derive(Clone, Debug)]
215pub struct FamilyExtraction {
216    pub key: FamilyKey,
217    pub template: FamilyTemplate,
218    pub binding: Binding,
219}
220
221/// Extract `ast`'s family, if it can join one (module docs). `Ok(None)` ⇒ the query
222/// cannot join any family and falls back to its `QueryKey` — today's path, byte for
223/// byte. `Err` only on the (practically unreachable) serialization failure `QueryKey`
224/// shares.
225pub 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    // Design §3.2: aggregates are v1-excluded outright (the builder dispatches them to
232    // a different lowering the family entry does not touch).
233    if ast.aggregate.is_some() || !ast.group_by.is_empty() || ast.having.is_some() {
234        return Ok(None);
235    }
236    // The wire-token identity first (impl plan F2 / D2): `Int(1)` and `Number(1.0)`
237    // must be one binding, and the key must not fork on a spelling.
238    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    // (position, column, canonical literal) of every hole, in conjunct order.
245    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        // A root `Or` or a bare EXISTS holds no eligible conjunct.
267        Condition::Or { .. } | Condition::CorrelatedSubquery(_) => return Ok(None),
268    }
269
270    // The stripped template.
271    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    // A lone residual that is itself an `And` stays wrapped in a one-conjunct `And`:
283    // left bare, [`FamilyTemplate::instantiate`] would read its conjuncts as several
284    // residuals and rebuild a flatter tree than the concrete query — `instantiate ∘
285    // extract` would not be the identity (found by the fuzz corpus round-trip,
286    // `rindle-fuzz/tests/family_key_roundtrip.rs`). The alias-collision check above runs
287    // on the unwrapped residual, exactly as before.
288    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    // The key: canonical JSON with holes punched in place.
298    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    // Domain-separate from `QueryKey` so a family fingerprint never collides with a
308    // query fingerprint by construction, even though they key different maps.
309    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
336/// The hole test (design §3.1): `Column = <bindable scalar literal>`.
337fn 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
350/// Would the builder refuse the stripped template where it accepts the concrete
351/// query? The one such shape: stripping leaves a *bare* EXISTS (the singleton
352/// `normalize_pipeline_ast` does not alias-uniquify) whose alias — `""` when absent,
353/// exactly as the builder spells it — names a materialized `related` of the same
354/// query. A `limit 0` EXISTS builds no join and claims no slot, so it cannot collide.
355fn 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
380/// Replace each holed conjunct's `right` in the AST JSON with `{"hole": k}`. The walk
381/// mirrors the eligibility walk exactly: `where` is either the single holed `simple`
382/// or an `and` whose `conditions[i]` are the holes.
383fn 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    // --- family-mate iff equal modulo holes -------------------------------------
479
480    #[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        // The exact `QueryKey`s still differ — the family is a coarser identity.
490        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        // Both equality conjuncts are holes; a conjunct whose literal agrees across the
506        // family just contributes a constant column to the binding.
507        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        // Both conjuncts stripped ⇒ no residual predicate at all.
525        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        // `And[a = ?, b > 3, EXISTS]` ⇒ holes [a]; residual `And[b > 3, EXISTS]`.
532        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        // One residual conjunct collapses to itself (no singleton `And`).
548        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    // --- order participates (D2) --------------------------------------------------
559
560    #[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        // ...and so does hole order among two holes.
572        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    // --- the exhaustive fallback list (design §3.2) ------------------------------
578
579    #[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        // The binding is the root hole only.
599        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        // A paging cursor is per-subscriber resume state, not a parameter: two
629        // otherwise-identical queries with different `start` bounds are different
630        // families, not two bindings.
631        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        // Under an `Or`: nothing is a hole ⇒ no family.
647        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        // Non-`=` ops are never holes.
650        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        // …but they ride along as residual beside a real hole.
672        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        // A bare EXISTS root, or a root `Or` around a hole-shaped leaf, is a singleton.
684        assert!(extract(&albums_where(exists("tracks"))).is_none());
685        // A literal LHS / column RHS is not a hole.
686        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        // Beside a real hole they are residual, never a binding column.
712        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        // Concrete: `And[artistId = 1, EXISTS tracks]` + related `tracks` — the builder
729        // uniquifies the gate to `tracks_0` and accepts it. Stripped: the bare EXISTS
730        // keeps `tracks` and collides with the related slot ⇒ refuse here.
731        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        // A different alias does not collide.
735        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        // Two residual EXISTS keep the `And` (both uniquified on both sides) — no collision.
739        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    /// The stripped template must build with the same relationship slot layout as the
749    /// concrete query (the EXISTS gate's alias may lose its `_N` suffix when the `And`
750    /// collapses, but slots are positional: `related` first, then gates in pre-order).
751    #[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    // --- binding canonicalization ---------------------------------------------------
766
767    #[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        // Strings and bools bind too, type-tagged.
779        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        // Different literal *types* on the same column are different bindings of the
788        // same family (the key does not depend on the literal at all).
789        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        // The `values_equal` coherence property stated over `Lit` pairs: two literals
797        // are one binding iff the scalars they lower to are join-equal.
798        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        // `values_equal` is total only within one JS type class (a number vs a string
812        // is a builder bug there), so the property is stated per class.
813        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    // --- instantiate ∘ extract = id ---------------------------------------------------
841
842    #[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            // And a re-extraction of the instantiated form lands in the same family.
878            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    /// A lone residual conjunct that is itself an `And` (the generator ANDs a root
886    /// equality onto an already-conjoined `where`) must round-trip with its nesting
887    /// intact: the template keeps it wrapped so `instantiate` reads ONE residual, not
888    /// its members. Found by `rindle-fuzz/tests/family_key_roundtrip.rs`.
889    #[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        // The template's residual is the inner `And`, kept as one conjunct.
904        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        // A hole AFTER the nested residual lands at its position too.
912        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    // --- select-order normalization still applies -------------------------------------
942
943    #[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        // The hole sits in the second conjunct (after `year > 1990`).
1000        let year = text.find("1990").unwrap();
1001        let hole = text.find(r#"{"hole":0}"#).unwrap();
1002        assert!(year < hole);
1003    }
1004}