Skip to main content

nedb_engine/
sqljoin.rs

1// SPDX-License-Identifier: BUSL-1.1
2// SPDX-FileCopyrightText: © 2026 INTERCHAINED LLC × Claude Sonnet 4.6
3
4//! Join strategy: nested loop (the reference) and hash (the fast path).
5//!
6//! # Division of responsibility
7//!
8//! The evaluator in [`crate::sqlselect`] decides what a query MEANS. This
9//! module decides only HOW the join is executed. Those are kept apart
10//! deliberately: an optimisation that can change an answer is not an
11//! optimisation, it is a bug with better throughput.
12//!
13//! # Why the hash table is not allowed to decide anything
14//!
15//! A hash join works by partitioning rows into buckets, which assumes equality
16//! is an equivalence relation. In this engine it is NOT, because comparison is
17//! dynamically typed:
18//!
19//! ```text
20//!   1   =  '1'     TRUE    (number vs numeric string -> compared numerically)
21//!   1   =  '1.0'   TRUE    (same)
22//!  '1'  =  '1.0'   FALSE   (string vs string -> compared exactly)
23//! ```
24//!
25//! Equality is therefore not transitive, and no bucketing scheme can reproduce
26//! nested-loop results by bucketing alone. So this module does not try.
27//!
28//! [`hkey`] maps a value to a bucket, and the ONLY property it must have is:
29//!
30//! > if `a = b` evaluates to TRUE, then `hkey(a) == hkey(b)`
31//!
32//! That is, no FALSE NEGATIVES. Collisions are harmless and expected — every
33//! candidate pair that survives the bucket lookup is then re-checked against
34//! the complete, unmodified `ON` expression by the evaluator itself. The hash
35//! table shrinks the candidate set; the evaluator still decides the answer.
36//!
37//! That is what makes equivalence with the nested loop provable rather than
38//! merely tested: both paths end up asking the same question of the same
39//! expression, and the fast path only skips pairs that the invariant above
40//! guarantees would have answered "no".
41//!
42//! The asymmetry is the whole safety argument, and it was checked by mutation
43//! rather than assumed. Breaking the invariant — bucketing numeric strings as
44//! text, so `1 = '1'` is no longer found — fails seven differential tests.
45//! Adding false POSITIVES, by giving `NULL` an ordinary bucket, changes no
46//! answer at all. Only one direction can be wrong, which is why this module
47//! is allowed to be approximate and the evaluator is not.
48//!
49//! # Row order
50//!
51//! Buckets hold right-hand row INDICES in ascending order, and probing walks
52//! left rows in order. A nested loop over the same inputs emits pairs in
53//! exactly that order too, so the two strategies agree row-for-row — not just
54//! as sets. Differential tests can compare ordered lists, which is a far
55//! sharper assertion than comparing sorted ones.
56
57use crate::sqlselect::{Expr, JoinKind};
58use anyhow::Result;
59use serde_json::Value;
60use std::collections::HashMap;
61
62/// Which execution strategy to use for joins.
63///
64/// `Auto` is what production uses. The two forced variants exist so that
65/// differential tests can run the SAME query down BOTH paths and compare —
66/// without them, a test believing it exercised the hash path could silently be
67/// measuring the nested loop, and the equivalence suite would prove nothing.
68#[derive(Debug, Clone, Copy, PartialEq, Eq)]
69pub enum JoinExec {
70    Auto,
71    NestedLoop,
72    Hash,
73}
74
75/// What actually ran, per join, for `EXPLAIN` and for benchmark honesty.
76#[derive(Debug, Clone, PartialEq, Eq)]
77pub struct JoinChoice {
78    pub kind: JoinKind,
79    pub table: String,
80    pub strategy: Strategy,
81    /// How many equality key pairs the planner could prove usable. Zero means
82    /// the hash path was not available at all.
83    pub keys: usize,
84    pub left_rows: usize,
85    pub right_rows: usize,
86    pub out_rows: usize,
87}
88
89#[derive(Debug, Clone, Copy, PartialEq, Eq)]
90pub enum Strategy {
91    NestedLoop,
92    Hash,
93}
94
95impl std::fmt::Display for Strategy {
96    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
97        f.write_str(match self {
98            Strategy::NestedLoop => "Nested Loop",
99            Strategy::Hash => "Hash Join",
100        })
101    }
102}
103
104/// Below this many candidate pairs, a nested loop is simply cheaper — building
105/// a hash table costs an allocation per distinct key and a clone of every
106/// right row's key values, which a handful of comparisons does not repay.
107///
108/// Catalogue queries (psql's `\dt` and friends) join a few dozen rows and stay
109/// on the reference path, which is a welcome side effect: the code path that
110/// real clients exercise most is the one whose semantics are most thoroughly
111/// tested.
112pub const AUTO_HASH_MIN_PAIRS: usize = 64;
113
114// ─────────────────────────────────────────────────────────────────────────────
115// Bucket keys
116// ─────────────────────────────────────────────────────────────────────────────
117
118/// A bucket identity for one value.
119#[derive(Debug, Clone, PartialEq, Eq, Hash)]
120pub enum HKey {
121    /// Canonical bits of the numeric interpretation.
122    Num(u64),
123    /// Canonical text.
124    Text(String),
125}
126
127/// `-0.0` and `0.0` are numerically equal and so must share a bucket; their
128/// bit patterns differ, so the sign is normalised away first.
129fn canon(f: f64) -> u64 {
130    let f = if f == 0.0 { 0.0 } else { f };
131    f.to_bits()
132}
133
134/// The bucket a value belongs to, or `None` when the value can never match.
135///
136/// `NULL` returns `None`: every comparison against `NULL` is UNKNOWN, and
137/// UNKNOWN does not join, so a NULL-keyed row is set aside rather than
138/// bucketed.
139///
140/// Worth being precise about what that buys, because it is NOT correctness.
141/// Giving `NULL` an ordinary bucket was tried as a deliberate mutation and the
142/// differential suite still passed — the confirm step evaluates `NULL = NULL`
143/// to UNKNOWN and drops the pair regardless. Setting NULLs aside avoids
144/// building one enormous bucket of rows that can never match. Correctness
145/// rests on the no-false-negatives property, not on this.
146///
147/// A numeric-looking STRING deliberately buckets with numbers, because
148/// `1 = '1'` is TRUE here. Two different numeric strings therefore share a
149/// bucket even though they are not equal to each other — the confirm step
150/// rejects that pair, and no correct match is lost.
151pub fn hkey(v: &Value) -> Option<HKey> {
152    match v {
153        Value::Null => None,
154        Value::Number(n) => Some(match n.as_f64() {
155            Some(f) => HKey::Num(canon(f)),
156            // Only reachable with arbitrary-precision numbers enabled; text is
157            // a safe over-approximation.
158            None => HKey::Text(n.to_string()),
159        }),
160        Value::String(s) => match s.parse::<f64>() {
161            Ok(f) => Some(HKey::Num(canon(f))),
162            Err(_) => Some(HKey::Text(s.clone())),
163        },
164        // Mirrors the engine's textual rendering of a boolean, because
165        // `true = 't'` is TRUE here and the two must share a bucket.
166        Value::Bool(b) => Some(HKey::Text(if *b { "t" } else { "f" }.to_string())),
167        // Arrays and objects compare as their JSON text, so they bucket by it —
168        // which also puts a string holding that same text in the same bucket,
169        // exactly as the comparison requires.
170        other => Some(HKey::Text(other.to_string())),
171    }
172}
173
174// ─────────────────────────────────────────────────────────────────────────────
175// Planning
176// ─────────────────────────────────────────────────────────────────────────────
177
178/// Functions the planner will evaluate while building a hash key.
179///
180/// This is an ALLOWLIST rather than a list of forbidden functions, and that
181/// direction is the whole point. A hash key is computed once per row and then
182/// trusted; a function whose value varies between the build and probe passes
183/// would silently drop matching rows. Every function below is pure. If a
184/// volatile one (`random()`, `clock_timestamp()`) is ever added to the
185/// evaluator and not added here, the planner refuses to use it as a key and
186/// the join falls back to the nested loop — wrong-but-slow is not a failure
187/// mode this list can produce, which is why it is written this way round.
188const PURE_FUNCS: &[&str] = &[
189    "lower",
190    "upper",
191    "length",
192    "char_length",
193    "character_length",
194    "coalesce",
195    "nullif",
196    "int2",
197    "int4",
198    "int8",
199    "text",
200    "quote_ident",
201    "format_type",
202    "array_to_string",
203    "current_schema",
204    "current_database",
205    "current_catalog",
206    "current_user",
207    "session_user",
208    "user",
209    "version",
210    "pg_get_userbyid",
211    "pg_table_is_visible",
212    "pg_type_is_visible",
213    "pg_function_is_visible",
214    "pg_encoding_to_char",
215    "pg_get_expr",
216    "pg_get_indexdef",
217    "pg_get_constraintdef",
218];
219
220/// Which relation an expression reads from.
221#[derive(Debug, Clone, Copy, PartialEq, Eq)]
222enum Side {
223    /// Reads only left-hand bindings, and at least one of them.
224    Left,
225    /// Reads only the right-hand binding, and does read it.
226    Right,
227    /// Reads no columns at all — a constant. Usable as neither key side,
228    /// because `ON a.x = 5` is a filter rather than a join condition.
229    Const,
230    /// Spans both sides, mentions an unqualified column (whose binding is
231    /// resolved at evaluation time and so cannot be attributed statically), or
232    /// contains something the planner will not evaluate early.
233    Unusable,
234}
235
236fn side_of(e: &Expr, left: &[String], right: &str) -> Side {
237    let mut saw_left = false;
238    let mut saw_right = false;
239    let mut usable = true;
240    walk(e, left, right, &mut saw_left, &mut saw_right, &mut usable);
241    if !usable || (saw_left && saw_right) {
242        return Side::Unusable;
243    }
244    match (saw_left, saw_right) {
245        (true, false) => Side::Left,
246        (false, true) => Side::Right,
247        (false, false) => Side::Const,
248        (true, true) => unreachable!("handled above"),
249    }
250}
251
252fn walk(
253    e: &Expr,
254    left: &[String],
255    right: &str,
256    saw_left: &mut bool,
257    saw_right: &mut bool,
258    usable: &mut bool,
259) {
260    match e {
261        Expr::Column { qual, .. } => match qual {
262            Some(q) => {
263                if q.eq_ignore_ascii_case(right) {
264                    *saw_right = true;
265                } else if left.iter().any(|b| b.eq_ignore_ascii_case(q)) {
266                    *saw_left = true;
267                } else {
268                    // An unknown binding is a query error, which the evaluator
269                    // reports with a better message than the planner could.
270                    *usable = false;
271                }
272            }
273            // A bare column resolves by scanning bindings in order AT
274            // EVALUATION TIME, so which relation it reads depends on the row.
275            // It cannot be attributed to a side here, and guessing would build
276            // a key from the wrong relation.
277            None => *usable = false,
278        },
279        Expr::Literal(_) => {}
280        Expr::Star | Expr::QualifiedStar(_) => *usable = false,
281        Expr::Func { name, args } => {
282            if !PURE_FUNCS.iter().any(|f| f.eq_ignore_ascii_case(name)) {
283                *usable = false;
284            }
285            for a in args {
286                walk(a, left, right, saw_left, saw_right, usable);
287            }
288        }
289        Expr::Case { operand, whens, else_ } => {
290            if let Some(o) = operand {
291                walk(o, left, right, saw_left, saw_right, usable);
292            }
293            for (w, t) in whens {
294                walk(w, left, right, saw_left, saw_right, usable);
295                walk(t, left, right, saw_left, saw_right, usable);
296            }
297            if let Some(x) = else_ {
298                walk(x, left, right, saw_left, saw_right, usable);
299            }
300        }
301        Expr::Binary { left: l, right: r, .. } => {
302            walk(l, left, right, saw_left, saw_right, usable);
303            walk(r, left, right, saw_left, saw_right, usable);
304        }
305        Expr::Unary { expr, .. } | Expr::Cast { expr, .. } | Expr::IsNull { expr, .. } => {
306            walk(expr, left, right, saw_left, saw_right, usable);
307        }
308        Expr::InList { expr, list, .. } => {
309            walk(expr, left, right, saw_left, saw_right, usable);
310            for i in list {
311                walk(i, left, right, saw_left, saw_right, usable);
312            }
313        }
314        Expr::Index { expr, index } => {
315            walk(expr, left, right, saw_left, saw_right, usable);
316            walk(index, left, right, saw_left, saw_right, usable);
317        }
318        Expr::ArrayLit(items) => {
319            for i in items {
320                walk(i, left, right, saw_left, saw_right, usable);
321            }
322        }
323        // A subquery's reads cannot be attributed to a side without running
324        // it, so an equality containing one is never a hash key.
325        Expr::Subquery(_) | Expr::Exists { .. } | Expr::ArrayQuery(_) | Expr::InSubquery { .. } => {
326            *usable = false
327        }
328        Expr::Quantified { left: l, right: r, .. } => {
329            walk(l, left, right, saw_left, saw_right, usable);
330            walk(r, left, right, saw_left, saw_right, usable);
331        }
332    }
333}
334
335/// Split an expression into top-level `AND` conjuncts.
336///
337/// Only `AND` may be split. An `OR` branch constrains the pair as a whole, so
338/// treating either side as an independent key would match pairs that the
339/// predicate rejects.
340fn conjuncts<'a>(e: &'a Expr, out: &mut Vec<&'a Expr>) {
341    match e {
342        Expr::Binary { op, left, right } if op == "AND" => {
343            conjuncts(left, out);
344            conjuncts(right, out);
345        }
346        other => out.push(other),
347    }
348}
349
350/// The equality key pairs a hash join can use, as `(left expr, right expr)`.
351///
352/// Returns an empty vector when the hash path is unavailable, in which case
353/// the caller uses the nested loop. Note that non-equality conjuncts are NOT
354/// extracted or rewritten — they stay in the `ON` expression and are evaluated
355/// by the confirm step, so nothing here has to reason about their semantics.
356pub fn hash_keys(on: Option<&Expr>, left: &[String], right: &str) -> Vec<(Expr, Expr)> {
357    let Some(on) = on else { return vec![] };
358    let mut parts = vec![];
359    conjuncts(on, &mut parts);
360    let mut keys = vec![];
361    for p in parts {
362        let Expr::Binary { op, left: l, right: r } = p else { continue };
363        // Only `=`. `IS NOT DISTINCT FROM` would need NULL keys to match each
364        // other, and no other operator partitions rows at all.
365        if op != "=" {
366            continue;
367        }
368        match (side_of(l, left, right), side_of(r, left, right)) {
369            (Side::Left, Side::Right) => keys.push(((**l).clone(), (**r).clone())),
370            (Side::Right, Side::Left) => keys.push(((**r).clone(), (**l).clone())),
371            _ => {}
372        }
373    }
374    keys
375}
376
377/// The strategy to use, given a plan and the actual relation sizes.
378pub fn choose(exec: JoinExec, keys: usize, left_rows: usize, right_rows: usize) -> Strategy {
379    if keys == 0 {
380        // Not a matter of preference: with no provable equality key there is
381        // nothing to hash on.
382        return Strategy::NestedLoop;
383    }
384    match exec {
385        JoinExec::NestedLoop => Strategy::NestedLoop,
386        JoinExec::Hash => Strategy::Hash,
387        JoinExec::Auto => {
388            if left_rows.saturating_mul(right_rows) > AUTO_HASH_MIN_PAIRS {
389                Strategy::Hash
390            } else {
391                Strategy::NestedLoop
392            }
393        }
394    }
395}
396
397// ─────────────────────────────────────────────────────────────────────────────
398// The hash table
399// ─────────────────────────────────────────────────────────────────────────────
400
401/// Right-hand rows indexed by their key bucket.
402pub struct HashSide {
403    buckets: HashMap<Vec<HKey>, Vec<usize>>,
404    /// Right rows whose key contains a NULL. They match nothing, but a
405    /// `RIGHT`/`FULL` join still has to emit them as unmatched, so they cannot
406    /// simply be dropped.
407    pub null_keyed: Vec<usize>,
408}
409
410impl HashSide {
411    /// Build the probe side. `key_of` yields one row's key values, or `None`
412    /// when any of them is NULL.
413    pub fn build(
414        n: usize,
415        mut key_of: impl FnMut(usize) -> Result<Option<Vec<HKey>>>,
416    ) -> Result<Self> {
417        let mut buckets: HashMap<Vec<HKey>, Vec<usize>> = HashMap::new();
418        let mut null_keyed = vec![];
419        for i in 0..n {
420            match key_of(i)? {
421                // Insertion order is ascending `i`, which is what keeps output
422                // row order identical to the nested loop's.
423                Some(k) => buckets.entry(k).or_default().push(i),
424                None => null_keyed.push(i),
425            }
426        }
427        Ok(Self { buckets, null_keyed })
428    }
429
430    /// Candidate right-row indices for a left key, in ascending order.
431    pub fn probe(&self, key: &[HKey]) -> &[usize] {
432        self.buckets.get(key).map(|v| v.as_slice()).unwrap_or(&[])
433    }
434
435    pub fn distinct_keys(&self) -> usize {
436        self.buckets.len()
437    }
438}
439
440#[cfg(test)]
441mod tests {
442    use super::*;
443    use crate::sqlselect::parse;
444    use serde_json::json;
445
446    // ── the no-false-negatives invariant ─────────────────────────────────────
447
448    /// Every value the engine can hold, including the pairs that make equality
449    /// non-transitive. Kept deliberately hostile.
450    fn corpus() -> Vec<Value> {
451        vec![
452            Value::Null,
453            json!(0),
454            json!(-0.0),
455            json!(0.0),
456            json!(1),
457            json!(1.0),
458            json!(-1),
459            json!(1000),
460            json!(0.1),
461            json!(9007199254740993i64),
462            json!(9007199254740992i64),
463            json!("0"),
464            json!("1"),
465            json!("1.0"),
466            json!("1.00"),
467            json!("01"),
468            json!("1e3"),
469            json!(" 1"),
470            json!("1abc"),
471            json!(""),
472            json!("t"),
473            json!("f"),
474            json!("true"),
475            json!("nan"),
476            json!("inf"),
477            json!("-0"),
478            json!("abc"),
479            json!("ABC"),
480            json!(true),
481            json!(false),
482            json!([1, 2]),
483            json!("[1,2]"),
484            json!({"a": 1}),
485            json!(r#"{"a":1}"#),
486        ]
487    }
488
489    /// Ask the real evaluator whether `a = b`, so the invariant is checked
490    /// against the actual semantics rather than a restatement of them.
491    fn equals(a: &Value, b: &Value) -> bool {
492        let sel = parse("SELECT l.v = r.v AS eq FROM l JOIN r ON 1 = 1").expect("parses");
493        let (la, lb) = (a.clone(), b.clone());
494        let resolve = move |t: &str| -> Result<Option<Box<dyn crate::sqlselect::Relation>>> {
495            Ok(Some(crate::sqlselect::from_vec(match t {
496                "l" => vec![json!({"v": la})],
497                _ => vec![json!({"v": lb})],
498            })))
499        };
500        let (_, rows) = crate::sqlselect::execute(&sel, &resolve).expect("runs");
501        rows.first().and_then(|r| r.get("eq")).and_then(|v| v.as_bool()) == Some(true)
502    }
503
504    #[test]
505    fn equality_implies_same_bucket() {
506        let c = corpus();
507        let mut equal_pairs = 0;
508        for a in &c {
509            for b in &c {
510                if !equals(a, b) {
511                    continue;
512                }
513                equal_pairs += 1;
514                let (ka, kb) = (hkey(a), hkey(b));
515                assert!(
516                    ka.is_some() && kb.is_some(),
517                    "{a:?} = {b:?} is TRUE but a key is unhashable"
518                );
519                assert_eq!(
520                    ka, kb,
521                    "{a:?} = {b:?} is TRUE but they bucket apart — the hash \
522                     join would LOSE this match"
523                );
524            }
525        }
526        // Guards against the corpus silently degenerating into values that are
527        // never equal, which would make the assertion above vacuous.
528        assert!(equal_pairs > 40, "corpus proved too little: {equal_pairs} equal pairs");
529    }
530
531    #[test]
532    fn null_never_hashes() {
533        assert_eq!(hkey(&Value::Null), None);
534        // And NULL is equal to nothing, including itself.
535        for v in corpus() {
536            assert!(!equals(&Value::Null, &v));
537            assert!(!equals(&v, &Value::Null));
538        }
539    }
540
541    #[test]
542    fn the_non_transitive_case_is_real_and_survives() {
543        // The reason bucketing alone cannot be trusted.
544        assert!(equals(&json!(1), &json!("1")));
545        assert!(equals(&json!(1), &json!("1.0")));
546        assert!(!equals(&json!("1"), &json!("1.0")));
547        // All three share a bucket, so no match is lost; the confirm step is
548        // what keeps '1' from joining '1.0'.
549        assert_eq!(hkey(&json!(1)), hkey(&json!("1")));
550        assert_eq!(hkey(&json!(1)), hkey(&json!("1.0")));
551        assert_eq!(hkey(&json!("1")), hkey(&json!("1.0")));
552    }
553
554    #[test]
555    fn signed_zero_shares_a_bucket() {
556        assert_eq!(hkey(&json!(0.0)), hkey(&json!(-0.0)));
557        assert_eq!(hkey(&json!(0)), hkey(&json!(-0.0)));
558    }
559
560    #[test]
561    fn bool_and_its_text_share_a_bucket() {
562        assert!(equals(&json!(true), &json!("t")));
563        assert_eq!(hkey(&json!(true)), hkey(&json!("t")));
564        assert_eq!(hkey(&json!(false)), hkey(&json!("f")));
565    }
566
567    #[test]
568    fn composite_and_its_json_text_share_a_bucket() {
569        assert_eq!(hkey(&json!([1, 2])), hkey(&json!("[1,2]")));
570    }
571
572    // ── planning ─────────────────────────────────────────────────────────────
573
574    fn keys_for(sql: &str) -> Vec<(Expr, Expr)> {
575        let s = parse(sql).expect("parses");
576        let left = vec![s.from.as_ref().unwrap().binding()];
577        let j = &s.joins[0];
578        hash_keys(j.on.as_ref(), &left, &j.table.binding())
579    }
580
581    #[test]
582    fn simple_equijoin_yields_one_key() {
583        assert_eq!(keys_for("SELECT 1 FROM a JOIN b ON a.x = b.y").len(), 1);
584    }
585
586    #[test]
587    fn key_pairs_are_normalised_left_then_right() {
588        // Written right-side-first; the planner must still order the pair
589        // (left, right) or the probe would look up the wrong relation's value.
590        let k = keys_for("SELECT 1 FROM a JOIN b ON b.y = a.x");
591        assert_eq!(k.len(), 1);
592        assert_eq!(k[0].0, Expr::Column { qual: Some("a".into()), name: "x".into() });
593        assert_eq!(k[0].1, Expr::Column { qual: Some("b".into()), name: "y".into() });
594    }
595
596    #[test]
597    fn multiple_equality_conjuncts_all_become_keys() {
598        assert_eq!(keys_for("SELECT 1 FROM a JOIN b ON a.x = b.x AND a.y = b.y").len(), 2);
599    }
600
601    #[test]
602    fn non_equality_conjuncts_are_left_to_the_evaluator() {
603        // One usable key; the `>` stays in the ON expression, where the confirm
604        // step applies it.
605        assert_eq!(keys_for("SELECT 1 FROM a JOIN b ON a.x = b.x AND a.n > b.n").len(), 1);
606    }
607
608    #[test]
609    fn or_is_never_split() {
610        assert!(keys_for("SELECT 1 FROM a JOIN b ON a.x = b.x OR a.y = b.y").is_empty());
611    }
612
613    #[test]
614    fn a_constant_side_is_not_a_key() {
615        assert!(keys_for("SELECT 1 FROM a JOIN b ON a.x = 5").is_empty());
616        assert!(keys_for("SELECT 1 FROM a JOIN b ON 1 = 1").is_empty());
617    }
618
619    #[test]
620    fn same_side_equality_is_not_a_key() {
621        assert!(keys_for("SELECT 1 FROM a JOIN b ON a.x = a.y").is_empty());
622    }
623
624    #[test]
625    fn a_bare_column_is_refused() {
626        // `x` resolves by scanning bindings at evaluation time, so it cannot be
627        // attributed to a relation here.
628        assert!(keys_for("SELECT 1 FROM a JOIN b ON x = b.y").is_empty());
629        assert!(keys_for("SELECT 1 FROM a JOIN b ON a.x = y").is_empty());
630    }
631
632    #[test]
633    fn an_expression_key_is_allowed_when_it_reads_one_side() {
634        assert_eq!(keys_for("SELECT 1 FROM a JOIN b ON lower(a.x) = lower(b.y)").len(), 1);
635        assert_eq!(keys_for("SELECT 1 FROM a JOIN b ON a.x = b.y::text").len(), 1);
636    }
637
638    #[test]
639    fn a_key_spanning_both_sides_is_refused() {
640        assert!(keys_for("SELECT 1 FROM a JOIN b ON coalesce(a.x, b.y) = b.z").is_empty());
641    }
642
643    #[test]
644    fn an_unknown_function_is_refused() {
645        // Not on the allowlist, so it cannot be trusted to be pure.
646        let s = parse("SELECT 1 FROM a JOIN b ON a.x = b.y").expect("parses");
647        let left = vec!["a".to_string()];
648        let on = Expr::Binary {
649            op: "=".into(),
650            left: Box::new(Expr::Column { qual: Some("a".into()), name: "x".into() }),
651            right: Box::new(Expr::Func {
652                name: "random".into(),
653                args: vec![Expr::Column { qual: Some("b".into()), name: "y".into() }],
654            }),
655        };
656        assert!(hash_keys(Some(&on), &left, &s.joins[0].table.binding()).is_empty());
657    }
658
659    #[test]
660    fn cross_join_has_no_keys() {
661        assert!(keys_for("SELECT 1 FROM a CROSS JOIN b").is_empty());
662    }
663
664    #[test]
665    fn a_second_join_may_key_off_either_earlier_relation() {
666        let s = parse("SELECT 1 FROM a JOIN b ON a.x = b.x JOIN c ON b.y = c.y").expect("parses");
667        let left = vec!["a".to_string(), "b".to_string()];
668        let j = &s.joins[1];
669        assert_eq!(hash_keys(j.on.as_ref(), &left, &j.table.binding()).len(), 1);
670    }
671
672    // ── strategy choice ──────────────────────────────────────────────────────
673
674    #[test]
675    fn no_keys_forces_the_nested_loop_even_when_hash_is_requested() {
676        assert_eq!(choose(JoinExec::Hash, 0, 1000, 1000), Strategy::NestedLoop);
677    }
678
679    #[test]
680    fn auto_stays_on_the_reference_path_for_small_inputs() {
681        assert_eq!(choose(JoinExec::Auto, 1, 4, 4), Strategy::NestedLoop);
682        assert_eq!(choose(JoinExec::Auto, 1, 8, 8), Strategy::NestedLoop);
683        assert_eq!(choose(JoinExec::Auto, 1, 8, 9), Strategy::Hash);
684    }
685
686    #[test]
687    fn forcing_is_honoured_so_differential_tests_mean_something() {
688        assert_eq!(choose(JoinExec::NestedLoop, 2, 10_000, 10_000), Strategy::NestedLoop);
689        assert_eq!(choose(JoinExec::Hash, 2, 1, 1), Strategy::Hash);
690    }
691
692    // ── the table ────────────────────────────────────────────────────────────
693
694    #[test]
695    fn build_preserves_ascending_row_order_within_a_bucket() {
696        let vals = vec![json!("a"), json!("b"), json!("a"), json!("a")];
697        let side = HashSide::build(vals.len(), |i| Ok(hkey(&vals[i]).map(|k| vec![k])))
698            .expect("builds");
699        let k = vec![hkey(&json!("a")).unwrap()];
700        assert_eq!(side.probe(&k), &[0, 2, 3]);
701        assert_eq!(side.distinct_keys(), 2);
702    }
703
704    #[test]
705    fn null_keyed_rows_are_set_aside_not_dropped() {
706        let vals = vec![json!("a"), Value::Null, json!("b")];
707        let side = HashSide::build(vals.len(), |i| Ok(hkey(&vals[i]).map(|k| vec![k])))
708            .expect("builds");
709        assert_eq!(side.null_keyed, vec![1]);
710        assert!(side.probe(&[HKey::Text("zzz".into())]).is_empty());
711        // Still reachable, which is what a RIGHT/FULL join needs.
712        assert_eq!(side.distinct_keys(), 2);
713    }
714
715    #[test]
716    fn a_compound_key_matches_only_on_every_column() {
717        let rows = vec![(json!(1), json!("x")), (json!(1), json!("y"))];
718        let side = HashSide::build(rows.len(), |i| {
719            Ok(match (hkey(&rows[i].0), hkey(&rows[i].1)) {
720                (Some(a), Some(b)) => Some(vec![a, b]),
721                _ => None,
722            })
723        })
724        .expect("builds");
725        let want = vec![hkey(&json!(1)).unwrap(), hkey(&json!("x")).unwrap()];
726        assert_eq!(side.probe(&want), &[0]);
727    }
728}