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    }
315}
316
317/// Split an expression into top-level `AND` conjuncts.
318///
319/// Only `AND` may be split. An `OR` branch constrains the pair as a whole, so
320/// treating either side as an independent key would match pairs that the
321/// predicate rejects.
322fn conjuncts<'a>(e: &'a Expr, out: &mut Vec<&'a Expr>) {
323    match e {
324        Expr::Binary { op, left, right } if op == "AND" => {
325            conjuncts(left, out);
326            conjuncts(right, out);
327        }
328        other => out.push(other),
329    }
330}
331
332/// The equality key pairs a hash join can use, as `(left expr, right expr)`.
333///
334/// Returns an empty vector when the hash path is unavailable, in which case
335/// the caller uses the nested loop. Note that non-equality conjuncts are NOT
336/// extracted or rewritten — they stay in the `ON` expression and are evaluated
337/// by the confirm step, so nothing here has to reason about their semantics.
338pub fn hash_keys(on: Option<&Expr>, left: &[String], right: &str) -> Vec<(Expr, Expr)> {
339    let Some(on) = on else { return vec![] };
340    let mut parts = vec![];
341    conjuncts(on, &mut parts);
342    let mut keys = vec![];
343    for p in parts {
344        let Expr::Binary { op, left: l, right: r } = p else { continue };
345        // Only `=`. `IS NOT DISTINCT FROM` would need NULL keys to match each
346        // other, and no other operator partitions rows at all.
347        if op != "=" {
348            continue;
349        }
350        match (side_of(l, left, right), side_of(r, left, right)) {
351            (Side::Left, Side::Right) => keys.push(((**l).clone(), (**r).clone())),
352            (Side::Right, Side::Left) => keys.push(((**r).clone(), (**l).clone())),
353            _ => {}
354        }
355    }
356    keys
357}
358
359/// The strategy to use, given a plan and the actual relation sizes.
360pub fn choose(exec: JoinExec, keys: usize, left_rows: usize, right_rows: usize) -> Strategy {
361    if keys == 0 {
362        // Not a matter of preference: with no provable equality key there is
363        // nothing to hash on.
364        return Strategy::NestedLoop;
365    }
366    match exec {
367        JoinExec::NestedLoop => Strategy::NestedLoop,
368        JoinExec::Hash => Strategy::Hash,
369        JoinExec::Auto => {
370            if left_rows.saturating_mul(right_rows) > AUTO_HASH_MIN_PAIRS {
371                Strategy::Hash
372            } else {
373                Strategy::NestedLoop
374            }
375        }
376    }
377}
378
379// ─────────────────────────────────────────────────────────────────────────────
380// The hash table
381// ─────────────────────────────────────────────────────────────────────────────
382
383/// Right-hand rows indexed by their key bucket.
384pub struct HashSide {
385    buckets: HashMap<Vec<HKey>, Vec<usize>>,
386    /// Right rows whose key contains a NULL. They match nothing, but a
387    /// `RIGHT`/`FULL` join still has to emit them as unmatched, so they cannot
388    /// simply be dropped.
389    pub null_keyed: Vec<usize>,
390}
391
392impl HashSide {
393    /// Build the probe side. `key_of` yields one row's key values, or `None`
394    /// when any of them is NULL.
395    pub fn build(
396        n: usize,
397        mut key_of: impl FnMut(usize) -> Result<Option<Vec<HKey>>>,
398    ) -> Result<Self> {
399        let mut buckets: HashMap<Vec<HKey>, Vec<usize>> = HashMap::new();
400        let mut null_keyed = vec![];
401        for i in 0..n {
402            match key_of(i)? {
403                // Insertion order is ascending `i`, which is what keeps output
404                // row order identical to the nested loop's.
405                Some(k) => buckets.entry(k).or_default().push(i),
406                None => null_keyed.push(i),
407            }
408        }
409        Ok(Self { buckets, null_keyed })
410    }
411
412    /// Candidate right-row indices for a left key, in ascending order.
413    pub fn probe(&self, key: &[HKey]) -> &[usize] {
414        self.buckets.get(key).map(|v| v.as_slice()).unwrap_or(&[])
415    }
416
417    pub fn distinct_keys(&self) -> usize {
418        self.buckets.len()
419    }
420}
421
422#[cfg(test)]
423mod tests {
424    use super::*;
425    use crate::sqlselect::parse;
426    use serde_json::json;
427
428    // ── the no-false-negatives invariant ─────────────────────────────────────
429
430    /// Every value the engine can hold, including the pairs that make equality
431    /// non-transitive. Kept deliberately hostile.
432    fn corpus() -> Vec<Value> {
433        vec![
434            Value::Null,
435            json!(0),
436            json!(-0.0),
437            json!(0.0),
438            json!(1),
439            json!(1.0),
440            json!(-1),
441            json!(1000),
442            json!(0.1),
443            json!(9007199254740993i64),
444            json!(9007199254740992i64),
445            json!("0"),
446            json!("1"),
447            json!("1.0"),
448            json!("1.00"),
449            json!("01"),
450            json!("1e3"),
451            json!(" 1"),
452            json!("1abc"),
453            json!(""),
454            json!("t"),
455            json!("f"),
456            json!("true"),
457            json!("nan"),
458            json!("inf"),
459            json!("-0"),
460            json!("abc"),
461            json!("ABC"),
462            json!(true),
463            json!(false),
464            json!([1, 2]),
465            json!("[1,2]"),
466            json!({"a": 1}),
467            json!(r#"{"a":1}"#),
468        ]
469    }
470
471    /// Ask the real evaluator whether `a = b`, so the invariant is checked
472    /// against the actual semantics rather than a restatement of them.
473    fn equals(a: &Value, b: &Value) -> bool {
474        let sel = parse("SELECT l.v = r.v AS eq FROM l JOIN r ON 1 = 1").expect("parses");
475        let (la, lb) = (a.clone(), b.clone());
476        let resolve = move |t: &str| -> Result<Option<Box<dyn crate::sqlselect::Relation>>> {
477            Ok(Some(crate::sqlselect::from_vec(match t {
478                "l" => vec![json!({"v": la})],
479                _ => vec![json!({"v": lb})],
480            })))
481        };
482        let (_, rows) = crate::sqlselect::execute(&sel, &resolve).expect("runs");
483        rows.first().and_then(|r| r.get("eq")).and_then(|v| v.as_bool()) == Some(true)
484    }
485
486    #[test]
487    fn equality_implies_same_bucket() {
488        let c = corpus();
489        let mut equal_pairs = 0;
490        for a in &c {
491            for b in &c {
492                if !equals(a, b) {
493                    continue;
494                }
495                equal_pairs += 1;
496                let (ka, kb) = (hkey(a), hkey(b));
497                assert!(
498                    ka.is_some() && kb.is_some(),
499                    "{a:?} = {b:?} is TRUE but a key is unhashable"
500                );
501                assert_eq!(
502                    ka, kb,
503                    "{a:?} = {b:?} is TRUE but they bucket apart — the hash \
504                     join would LOSE this match"
505                );
506            }
507        }
508        // Guards against the corpus silently degenerating into values that are
509        // never equal, which would make the assertion above vacuous.
510        assert!(equal_pairs > 40, "corpus proved too little: {equal_pairs} equal pairs");
511    }
512
513    #[test]
514    fn null_never_hashes() {
515        assert_eq!(hkey(&Value::Null), None);
516        // And NULL is equal to nothing, including itself.
517        for v in corpus() {
518            assert!(!equals(&Value::Null, &v));
519            assert!(!equals(&v, &Value::Null));
520        }
521    }
522
523    #[test]
524    fn the_non_transitive_case_is_real_and_survives() {
525        // The reason bucketing alone cannot be trusted.
526        assert!(equals(&json!(1), &json!("1")));
527        assert!(equals(&json!(1), &json!("1.0")));
528        assert!(!equals(&json!("1"), &json!("1.0")));
529        // All three share a bucket, so no match is lost; the confirm step is
530        // what keeps '1' from joining '1.0'.
531        assert_eq!(hkey(&json!(1)), hkey(&json!("1")));
532        assert_eq!(hkey(&json!(1)), hkey(&json!("1.0")));
533        assert_eq!(hkey(&json!("1")), hkey(&json!("1.0")));
534    }
535
536    #[test]
537    fn signed_zero_shares_a_bucket() {
538        assert_eq!(hkey(&json!(0.0)), hkey(&json!(-0.0)));
539        assert_eq!(hkey(&json!(0)), hkey(&json!(-0.0)));
540    }
541
542    #[test]
543    fn bool_and_its_text_share_a_bucket() {
544        assert!(equals(&json!(true), &json!("t")));
545        assert_eq!(hkey(&json!(true)), hkey(&json!("t")));
546        assert_eq!(hkey(&json!(false)), hkey(&json!("f")));
547    }
548
549    #[test]
550    fn composite_and_its_json_text_share_a_bucket() {
551        assert_eq!(hkey(&json!([1, 2])), hkey(&json!("[1,2]")));
552    }
553
554    // ── planning ─────────────────────────────────────────────────────────────
555
556    fn keys_for(sql: &str) -> Vec<(Expr, Expr)> {
557        let s = parse(sql).expect("parses");
558        let left = vec![s.from.as_ref().unwrap().binding()];
559        let j = &s.joins[0];
560        hash_keys(j.on.as_ref(), &left, &j.table.binding())
561    }
562
563    #[test]
564    fn simple_equijoin_yields_one_key() {
565        assert_eq!(keys_for("SELECT 1 FROM a JOIN b ON a.x = b.y").len(), 1);
566    }
567
568    #[test]
569    fn key_pairs_are_normalised_left_then_right() {
570        // Written right-side-first; the planner must still order the pair
571        // (left, right) or the probe would look up the wrong relation's value.
572        let k = keys_for("SELECT 1 FROM a JOIN b ON b.y = a.x");
573        assert_eq!(k.len(), 1);
574        assert_eq!(k[0].0, Expr::Column { qual: Some("a".into()), name: "x".into() });
575        assert_eq!(k[0].1, Expr::Column { qual: Some("b".into()), name: "y".into() });
576    }
577
578    #[test]
579    fn multiple_equality_conjuncts_all_become_keys() {
580        assert_eq!(keys_for("SELECT 1 FROM a JOIN b ON a.x = b.x AND a.y = b.y").len(), 2);
581    }
582
583    #[test]
584    fn non_equality_conjuncts_are_left_to_the_evaluator() {
585        // One usable key; the `>` stays in the ON expression, where the confirm
586        // step applies it.
587        assert_eq!(keys_for("SELECT 1 FROM a JOIN b ON a.x = b.x AND a.n > b.n").len(), 1);
588    }
589
590    #[test]
591    fn or_is_never_split() {
592        assert!(keys_for("SELECT 1 FROM a JOIN b ON a.x = b.x OR a.y = b.y").is_empty());
593    }
594
595    #[test]
596    fn a_constant_side_is_not_a_key() {
597        assert!(keys_for("SELECT 1 FROM a JOIN b ON a.x = 5").is_empty());
598        assert!(keys_for("SELECT 1 FROM a JOIN b ON 1 = 1").is_empty());
599    }
600
601    #[test]
602    fn same_side_equality_is_not_a_key() {
603        assert!(keys_for("SELECT 1 FROM a JOIN b ON a.x = a.y").is_empty());
604    }
605
606    #[test]
607    fn a_bare_column_is_refused() {
608        // `x` resolves by scanning bindings at evaluation time, so it cannot be
609        // attributed to a relation here.
610        assert!(keys_for("SELECT 1 FROM a JOIN b ON x = b.y").is_empty());
611        assert!(keys_for("SELECT 1 FROM a JOIN b ON a.x = y").is_empty());
612    }
613
614    #[test]
615    fn an_expression_key_is_allowed_when_it_reads_one_side() {
616        assert_eq!(keys_for("SELECT 1 FROM a JOIN b ON lower(a.x) = lower(b.y)").len(), 1);
617        assert_eq!(keys_for("SELECT 1 FROM a JOIN b ON a.x = b.y::text").len(), 1);
618    }
619
620    #[test]
621    fn a_key_spanning_both_sides_is_refused() {
622        assert!(keys_for("SELECT 1 FROM a JOIN b ON coalesce(a.x, b.y) = b.z").is_empty());
623    }
624
625    #[test]
626    fn an_unknown_function_is_refused() {
627        // Not on the allowlist, so it cannot be trusted to be pure.
628        let s = parse("SELECT 1 FROM a JOIN b ON a.x = b.y").expect("parses");
629        let left = vec!["a".to_string()];
630        let on = Expr::Binary {
631            op: "=".into(),
632            left: Box::new(Expr::Column { qual: Some("a".into()), name: "x".into() }),
633            right: Box::new(Expr::Func {
634                name: "random".into(),
635                args: vec![Expr::Column { qual: Some("b".into()), name: "y".into() }],
636            }),
637        };
638        assert!(hash_keys(Some(&on), &left, &s.joins[0].table.binding()).is_empty());
639    }
640
641    #[test]
642    fn cross_join_has_no_keys() {
643        assert!(keys_for("SELECT 1 FROM a CROSS JOIN b").is_empty());
644    }
645
646    #[test]
647    fn a_second_join_may_key_off_either_earlier_relation() {
648        let s = parse("SELECT 1 FROM a JOIN b ON a.x = b.x JOIN c ON b.y = c.y").expect("parses");
649        let left = vec!["a".to_string(), "b".to_string()];
650        let j = &s.joins[1];
651        assert_eq!(hash_keys(j.on.as_ref(), &left, &j.table.binding()).len(), 1);
652    }
653
654    // ── strategy choice ──────────────────────────────────────────────────────
655
656    #[test]
657    fn no_keys_forces_the_nested_loop_even_when_hash_is_requested() {
658        assert_eq!(choose(JoinExec::Hash, 0, 1000, 1000), Strategy::NestedLoop);
659    }
660
661    #[test]
662    fn auto_stays_on_the_reference_path_for_small_inputs() {
663        assert_eq!(choose(JoinExec::Auto, 1, 4, 4), Strategy::NestedLoop);
664        assert_eq!(choose(JoinExec::Auto, 1, 8, 8), Strategy::NestedLoop);
665        assert_eq!(choose(JoinExec::Auto, 1, 8, 9), Strategy::Hash);
666    }
667
668    #[test]
669    fn forcing_is_honoured_so_differential_tests_mean_something() {
670        assert_eq!(choose(JoinExec::NestedLoop, 2, 10_000, 10_000), Strategy::NestedLoop);
671        assert_eq!(choose(JoinExec::Hash, 2, 1, 1), Strategy::Hash);
672    }
673
674    // ── the table ────────────────────────────────────────────────────────────
675
676    #[test]
677    fn build_preserves_ascending_row_order_within_a_bucket() {
678        let vals = vec![json!("a"), json!("b"), json!("a"), json!("a")];
679        let side = HashSide::build(vals.len(), |i| Ok(hkey(&vals[i]).map(|k| vec![k])))
680            .expect("builds");
681        let k = vec![hkey(&json!("a")).unwrap()];
682        assert_eq!(side.probe(&k), &[0, 2, 3]);
683        assert_eq!(side.distinct_keys(), 2);
684    }
685
686    #[test]
687    fn null_keyed_rows_are_set_aside_not_dropped() {
688        let vals = vec![json!("a"), Value::Null, json!("b")];
689        let side = HashSide::build(vals.len(), |i| Ok(hkey(&vals[i]).map(|k| vec![k])))
690            .expect("builds");
691        assert_eq!(side.null_keyed, vec![1]);
692        assert!(side.probe(&[HKey::Text("zzz".into())]).is_empty());
693        // Still reachable, which is what a RIGHT/FULL join needs.
694        assert_eq!(side.distinct_keys(), 2);
695    }
696
697    #[test]
698    fn a_compound_key_matches_only_on_every_column() {
699        let rows = vec![(json!(1), json!("x")), (json!(1), json!("y"))];
700        let side = HashSide::build(rows.len(), |i| {
701            Ok(match (hkey(&rows[i].0), hkey(&rows[i].1)) {
702                (Some(a), Some(b)) => Some(vec![a, b]),
703                _ => None,
704            })
705        })
706        .expect("builds");
707        let want = vec![hkey(&json!(1)).unwrap(), hkey(&json!("x")).unwrap()];
708        assert_eq!(side.probe(&want), &[0]);
709    }
710}