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