Skip to main content

uqa_execution/
distinct.rs

1//
2// Unified Query Algebra
3//
4// Copyright (c) 2023-2026 Cognica, Inc.
5//
6
7//! Byte-bounded streaming physical `DISTINCT` operator.
8//!
9//! The operator keeps exact encoded keys in memory until their combined byte
10//! size reaches `work_mem`. It then migrates every key to a temporary,
11//! bucketed on-disk set. Disk probes compare the complete encoded key, so a
12//! hash collision can never turn a new row into a duplicate. Output remains
13//! streaming and preserves the first row for every key in child order.
14
15use std::collections::{BTreeMap, BTreeSet, HashMap};
16use std::fs::{File, OpenOptions};
17use std::hash::{BuildHasher, Hasher};
18use std::io::{ErrorKind, Read, Seek, SeekFrom, Write};
19use std::path::{Path, PathBuf};
20
21use smallvec::{Array, SmallVec};
22use tempfile::{Builder as TempBuilder, TempDir};
23use uqa_core::{DecimalValue, TemporalValue, Value};
24use uqa_sql::ResultRow;
25
26use crate::{
27    Batch, ExecError, ExecResult, PhysicalOperator, PhysicalRow, RowSchema, ScalarExpr,
28    SharedExpressionEvaluator,
29};
30
31/// Default used by compatibility constructors. Engine callers should pass the
32/// current session's `work_mem` through [`Distinct::all_with_work_mem`] or
33/// [`Distinct::on_with_work_mem`].
34pub const DEFAULT_DISTINCT_WORK_MEM_BYTES: usize = 64 * 1024 * 1024;
35
36const DISK_BUCKETS: u64 = 64;
37const COPY_BUFFER_BYTES: usize = 8 * 1024;
38const MICROS_PER_DAY: i128 = 86_400_000_000;
39
40pub(crate) type EncodedKey = SmallVec<[u8; 64]>;
41
42/// Hash a borrowed positional SQL row in its canonical equality domain.
43///
44/// This streams encoded components straight into the caller's hasher, so it
45/// does not allocate or construct an intermediate byte key. Hash collisions
46/// remain possible; callers must verify complete [`Value`] equality before
47/// reusing an existing row or group.
48pub fn hash_canonical_row<'a, S: BuildHasher>(
49    build_hasher: &S,
50    values: impl ExactSizeIterator<Item = Option<&'a Value>>,
51) -> ExecResult<u64> {
52    let count = values.len();
53    let mut hasher = build_hasher.build_hasher();
54    {
55        let mut output = HasherOutput(&mut hasher);
56        encode_len(count, &mut output)?;
57        for value in values {
58            if let Some(value) = value {
59                encode_value(value, &mut output)?;
60            } else {
61                output.push_byte(0);
62            }
63        }
64    }
65    Ok(hasher.finish())
66}
67
68/// Pack exactly two text-or-NULL values of at most three bytes each into an injective integer key. `None` selects the general collision-safe encoder for every other row.
69pub fn try_pack_compact_text_pair<'a>(
70    values: impl ExactSizeIterator<Item = Option<&'a Value>>,
71) -> Option<u64> {
72    if values.len() != 2 {
73        return None;
74    }
75    let mut values = values;
76    let first = compact_text_component(values.next()?)?;
77    let second = compact_text_component(values.next()?)?;
78    Some(u64::from(first) << 32 | u64::from(second))
79}
80
81fn compact_text_component(value: Option<&Value>) -> Option<u32> {
82    match value {
83        None | Some(Value::Null) => Some(0),
84        Some(Value::Str(value)) if value.len() <= 3 => {
85            let mut packed = [0u8; 4];
86            packed[0] = u8::try_from(value.len()).ok()? + 1;
87            packed[1..][..value.len()].copy_from_slice(value.as_bytes());
88            Some(u32::from_be_bytes(packed))
89        }
90        Some(_) => None,
91    }
92}
93
94/// Encode positional SQL values in the exact equality domain used by DISTINCT and spill-backed row-key state. Callers that need an external exact index can persist this representation without relying on `Value`'s serialization format.
95pub fn canonical_row_key(values: &[Value]) -> ExecResult<Vec<u8>> {
96    encode_key(values)
97}
98
99/// Collision-safe in-memory set for positional SQL rows.
100///
101/// Probes consume borrowed values and stream their canonical representation
102/// directly into the hash function. Only the first distinct row is copied
103/// into the contiguous key arena; repeated build rows and every lookup avoid
104/// both a positional `Vec<Value>` allocation and value cloning. Hash matches
105/// always verify the complete SQL [`Value`] equality domain.
106pub struct CanonicalRowHashSet {
107    rows: Vec<SmallVec<[Value; 2]>>,
108    index: HashMap<u64, SmallVec<[usize; 1]>, ahash::RandomState>,
109}
110
111impl CanonicalRowHashSet {
112    #[must_use]
113    pub fn new() -> Self {
114        Self {
115            rows: Vec::new(),
116            index: HashMap::with_hasher(ahash::RandomState::new()),
117        }
118    }
119
120    /// Insert a positional key assembled from borrowed values.
121    /// Returns `true` only when this is the first SQL-equal key.
122    pub fn insert_borrowed(&mut self, values: &[&Value]) -> ExecResult<bool> {
123        let hash = hash_canonical_row(self.index.hasher(), values.iter().copied().map(Some))?;
124        if self.matching_borrowed(hash, values) {
125            return Ok(false);
126        }
127
128        let row = values
129            .iter()
130            .map(|value| (*value).clone())
131            .collect::<SmallVec<[Value; 2]>>();
132        let row_index = self.rows.len();
133        self.rows.push(row);
134        self.index.entry(hash).or_default().push(row_index);
135        Ok(true)
136    }
137
138    /// Insert an already positional key without an intermediate borrowed-row
139    /// carrier. Values are copied only for a previously unseen key.
140    pub fn insert_values(&mut self, values: &[Value]) -> ExecResult<bool> {
141        let hash = hash_canonical_row(self.index.hasher(), values.iter().map(Some))?;
142        if self.matching_values(hash, values) {
143            return Ok(false);
144        }
145
146        let row_index = self.rows.len();
147        self.rows.push(values.iter().cloned().collect());
148        self.index.entry(hash).or_default().push(row_index);
149        Ok(true)
150    }
151
152    /// Probe with a composite row of borrowed values without allocating or
153    /// copying the key.
154    pub fn contains_borrowed(&self, values: &[&Value]) -> ExecResult<bool> {
155        let hash = hash_canonical_row(self.index.hasher(), values.iter().copied().map(Some))?;
156        Ok(self.matching_borrowed(hash, values))
157    }
158
159    /// Probe with an already positional value slice.
160    pub fn contains_values(&self, values: &[Value]) -> ExecResult<bool> {
161        let hash = hash_canonical_row(self.index.hasher(), values.iter().map(Some))?;
162        Ok(self.matching_values(hash, values))
163    }
164
165    fn matching_borrowed(&self, hash: u64, values: &[&Value]) -> bool {
166        self.index.get(&hash).is_some_and(|bucket| {
167            bucket.iter().copied().any(|index| {
168                let stored = &self.rows[index];
169                stored.len() == values.len()
170                    && stored
171                        .iter()
172                        .zip(values)
173                        .all(|(stored, value)| stored == *value)
174            })
175        })
176    }
177
178    fn matching_values(&self, hash: u64, values: &[Value]) -> bool {
179        self.index.get(&hash).is_some_and(|bucket| {
180            bucket
181                .iter()
182                .copied()
183                .any(|index| self.rows[index].as_slice() == values)
184        })
185    }
186}
187
188impl Default for CanonicalRowHashSet {
189    fn default() -> Self {
190        Self::new()
191    }
192}
193
194/// Exact, byte-bounded row-key set that can outlive one physical operator.
195///
196/// Recursive fixpoint evaluation needs duplicate state to survive across
197/// multiple executions of its recursive term. [`Distinct`] deliberately
198/// resets its state on every `open`, so this small public carrier exposes the
199/// same collision-safe memory-to-disk migration without coupling the engine to
200/// the on-disk format.
201pub struct ExactRowSet {
202    seen: SeenKeySet,
203}
204
205impl ExactRowSet {
206    pub fn new(work_mem_bytes: usize) -> Self {
207        Self {
208            seen: SeenKeySet::new(work_mem_bytes, None),
209        }
210    }
211
212    pub fn with_spill_directory(work_mem_bytes: usize, directory: impl Into<PathBuf>) -> Self {
213        Self {
214            seen: SeenKeySet::new(work_mem_bytes, Some(directory.into())),
215        }
216    }
217
218    /// Insert the positional values from `row` in `schema` order.
219    /// Returns `true` only for the first exact occurrence.
220    pub fn insert_row(&mut self, row: &ResultRow, schema: &[String]) -> ExecResult<bool> {
221        self.seen.insert(row_key(row, schema)?)
222    }
223
224    pub fn contains_row(&mut self, row: &ResultRow, schema: &[String]) -> ExecResult<bool> {
225        self.seen.contains(&row_key(row, schema)?)
226    }
227
228    /// Insert an already-positional SQL value key without constructing a
229    /// named row. The binary encoding is the same collision-safe,
230    /// cross-numeric representation used by physical DISTINCT.
231    pub fn insert_values(&mut self, values: &[Value]) -> ExecResult<bool> {
232        self.seen.insert(encode_key(values)?)
233    }
234
235    /// Probe an already-positional SQL value key without constructing a named
236    /// row. Disk-backed sets perform an exact full-key comparison.
237    pub fn contains_values(&mut self, values: &[Value]) -> ExecResult<bool> {
238        self.seen.contains(&encode_key(values)?)
239    }
240
241    /// Insert a physical row directly in logical schema order without constructing a named row or cloning its values.
242    pub fn insert_physical(&mut self, row: &PhysicalRow, schema: &RowSchema) -> ExecResult<bool> {
243        let view = schema.view(row);
244        self.seen.insert(encode_key_borrowed(
245            (0..schema.len()).map(|position| view.value_at(position)),
246        )?)
247    }
248
249    /// Probe a physical row directly in logical schema order without constructing a named row or cloning its values.
250    pub fn contains_physical(&mut self, row: &PhysicalRow, schema: &RowSchema) -> ExecResult<bool> {
251        let view = schema.view(row);
252        self.seen.contains(&encode_key_borrowed(
253            (0..schema.len()).map(|position| view.value_at(position)),
254        )?)
255    }
256
257    pub fn has_spilled(&self) -> bool {
258        self.seen.has_spilled()
259    }
260
261    pub fn in_memory_key_bytes(&self) -> usize {
262        self.seen.in_memory_bytes()
263    }
264}
265
266fn row_key(row: &ResultRow, schema: &[String]) -> ExecResult<Vec<u8>> {
267    encode_key_borrowed(schema.iter().map(|column| row.get(column)))
268}
269
270/// Stable SQL duplicate elimination.
271///
272/// With no key expressions, the complete positional output row is the key.
273/// With expressions, the operator implements `DISTINCT ON`: it preserves the
274/// first row for each evaluated key in child order.
275pub struct Distinct<'a> {
276    child: Box<dyn PhysicalOperator + 'a>,
277    keys: Option<Vec<ScalarExpr>>,
278    evaluator: Option<SharedExpressionEvaluator<'a>>,
279    schema: RowSchema,
280    work_mem_bytes: usize,
281    spill_directory: Option<PathBuf>,
282    seen: SeenKeySet,
283}
284
285impl<'a> Distinct<'a> {
286    /// Construct a bounded full-row `DISTINCT` with the compatibility default
287    /// work-memory budget.
288    pub fn all(child: Box<dyn PhysicalOperator + 'a>) -> Self {
289        Self::all_with_work_mem(child, DEFAULT_DISTINCT_WORK_MEM_BYTES)
290    }
291
292    /// Construct a bounded full-row `DISTINCT` with an explicit byte budget.
293    pub fn all_with_work_mem(child: Box<dyn PhysicalOperator + 'a>, work_mem_bytes: usize) -> Self {
294        let schema = child.row_schema().clone();
295        Self {
296            child,
297            keys: None,
298            evaluator: None,
299            schema,
300            work_mem_bytes,
301            spill_directory: None,
302            seen: SeenKeySet::new(work_mem_bytes, None),
303        }
304    }
305
306    /// Construct a bounded `DISTINCT ON` with the compatibility default
307    /// work-memory budget.
308    pub fn on(
309        child: Box<dyn PhysicalOperator + 'a>,
310        keys: Vec<ScalarExpr>,
311        evaluator: SharedExpressionEvaluator<'a>,
312    ) -> Self {
313        Self::on_with_work_mem(child, keys, evaluator, DEFAULT_DISTINCT_WORK_MEM_BYTES)
314    }
315
316    /// Construct a bounded `DISTINCT ON` with an explicit byte budget.
317    pub fn on_with_work_mem(
318        child: Box<dyn PhysicalOperator + 'a>,
319        keys: Vec<ScalarExpr>,
320        evaluator: SharedExpressionEvaluator<'a>,
321        work_mem_bytes: usize,
322    ) -> Self {
323        let schema = child.row_schema().clone();
324        Self {
325            child,
326            keys: Some(keys),
327            evaluator: Some(evaluator),
328            schema,
329            work_mem_bytes,
330            spill_directory: None,
331            seen: SeenKeySet::new(work_mem_bytes, None),
332        }
333    }
334
335    /// Place the exact-set files in a caller-selected temporary-data
336    /// directory. The directory must already exist; a private child directory
337    /// is created lazily on the first spill and removed through RAII.
338    pub fn with_spill_directory(mut self, directory: impl Into<PathBuf>) -> Self {
339        self.spill_directory = Some(directory.into());
340        self.reset_seen();
341        self
342    }
343
344    /// Whether this invocation has migrated its key set to disk.
345    pub fn has_spilled(&self) -> bool {
346        self.seen.has_spilled()
347    }
348
349    /// Exact encoded key bytes retained by the in-memory set.
350    pub fn in_memory_key_bytes(&self) -> usize {
351        self.seen.in_memory_bytes()
352    }
353
354    /// Live private spill directory, for diagnostics and cleanup tests.
355    pub fn spill_path(&self) -> Option<&Path> {
356        self.seen.spill_path()
357    }
358
359    fn reset_seen(&mut self) {
360        self.seen = SeenKeySet::new(self.work_mem_bytes, self.spill_directory.clone());
361    }
362
363    fn key(&self, schema: &RowSchema, row: &crate::PhysicalRow) -> ExecResult<Vec<u8>> {
364        if let Some(keys) = self.keys.as_ref() {
365            let evaluator = self.evaluator.as_ref().ok_or_else(|| {
366                ExecError::Other("DISTINCT ON evaluator is not configured".into())
367            })?;
368            let values = keys
369                .iter()
370                .map(|expression| evaluator.evaluate_physical(expression, schema, row))
371                .collect::<ExecResult<Vec<_>>>()?;
372            return encode_key(&values);
373        }
374        let row = schema.view(row);
375        encode_key_borrowed((0..self.schema.len()).map(|index| row.value_at(index)))
376    }
377}
378
379impl PhysicalOperator for Distinct<'_> {
380    fn row_schema(&self) -> &RowSchema {
381        &self.schema
382    }
383
384    fn open(&mut self) -> ExecResult<()> {
385        self.reset_seen();
386        self.child.open()
387    }
388
389    fn next(&mut self) -> ExecResult<Option<Batch>> {
390        loop {
391            let Some(batch) = self.child.next()? else {
392                return Ok(None);
393            };
394            if batch.schema != self.schema {
395                return Err(ExecError::Other(format!(
396                    "DISTINCT input schema mismatch: expected {:?}, got {:?}",
397                    self.schema, batch.schema
398                )));
399            }
400            let mut rows = Vec::with_capacity(batch.rows.len());
401            for row in batch.rows {
402                let key = self.key(&batch.schema, &row)?;
403                if self.seen.insert(key)? {
404                    rows.push(row.without_lock_origins());
405                }
406            }
407            if !rows.is_empty() {
408                return Ok(Some(Batch::from_physical_rows(self.schema.clone(), rows)));
409            }
410        }
411    }
412
413    fn close(&mut self) -> ExecResult<()> {
414        self.reset_seen();
415        self.child.close()
416    }
417}
418
419pub(crate) struct SeenKeySet {
420    memory: BTreeSet<Vec<u8>>,
421    memory_bytes: usize,
422    budget_bytes: usize,
423    spill_directory: Option<PathBuf>,
424    disk: Option<DiskKeySet>,
425}
426
427impl SeenKeySet {
428    pub(crate) fn new(budget_bytes: usize, spill_directory: Option<PathBuf>) -> Self {
429        Self {
430            memory: BTreeSet::new(),
431            memory_bytes: 0,
432            budget_bytes,
433            spill_directory,
434            disk: None,
435        }
436    }
437
438    pub(crate) fn insert(&mut self, key: Vec<u8>) -> ExecResult<bool> {
439        if let Some(disk) = self.disk.as_mut() {
440            return disk.insert(&key);
441        }
442        if self.memory.contains(&key) {
443            return Ok(false);
444        }
445
446        let fits = self
447            .memory_bytes
448            .checked_add(key.len())
449            .is_some_and(|bytes| bytes <= self.budget_bytes);
450        if fits {
451            self.memory_bytes += key.len();
452            self.memory.insert(key);
453            return Ok(true);
454        }
455
456        // Build the disk set off to the side. A create/migration/write error
457        // leaves the original in-memory set intact and is returned to the
458        // execution pipeline; no key is silently forgotten.
459        let mut disk = DiskKeySet::new(self.spill_directory.as_deref())?;
460        for existing in &self.memory {
461            if !disk.insert(existing)? {
462                return Err(distinct_error(
463                    "duplicate key found while migrating DISTINCT state",
464                ));
465            }
466        }
467        let inserted = disk.insert(&key)?;
468        self.memory.clear();
469        self.memory_bytes = 0;
470        self.disk = Some(disk);
471        Ok(inserted)
472    }
473
474    pub(crate) fn contains(&mut self, key: &[u8]) -> ExecResult<bool> {
475        match self.disk.as_mut() {
476            Some(disk) => disk.contains(key),
477            None => Ok(self.memory.contains(key)),
478        }
479    }
480
481    fn has_spilled(&self) -> bool {
482        self.disk.is_some()
483    }
484
485    fn in_memory_bytes(&self) -> usize {
486        self.memory_bytes
487    }
488
489    fn spill_path(&self) -> Option<&Path> {
490        self.disk.as_ref().map(|disk| disk.directory.path())
491    }
492}
493
494/// Temporary bucketed exact set. Each record is `[u64 length][key bytes]`.
495/// Bucket selection is only an accelerator: probes stream and compare the
496/// complete record, making equality collision-free even if every hash collides.
497struct DiskKeySet {
498    directory: TempDir,
499    buckets: BTreeMap<u8, File>,
500}
501
502impl DiskKeySet {
503    fn new(parent: Option<&Path>) -> ExecResult<Self> {
504        let mut builder = TempBuilder::new();
505        builder.prefix("uqa-distinct-");
506        let directory = parent
507            .map_or_else(|| builder.tempdir(), |parent| builder.tempdir_in(parent))
508            .map_err(|error| {
509                distinct_error(format!(
510                    "failed to create DISTINCT spill directory: {error}"
511                ))
512            })?;
513        Ok(Self {
514            directory,
515            buckets: BTreeMap::new(),
516        })
517    }
518
519    fn insert(&mut self, key: &[u8]) -> ExecResult<bool> {
520        let bucket = u8::try_from(stable_hash(key) % DISK_BUCKETS)
521            .map_err(|_| distinct_error("DISTINCT spill bucket exceeds u8"))?;
522        if !self.buckets.contains_key(&bucket) {
523            let path = self.directory.path().join(format!("bucket-{bucket:02x}"));
524            let file = OpenOptions::new()
525                .create_new(true)
526                .read(true)
527                .write(true)
528                .open(&path)
529                .map_err(|error| {
530                    distinct_error(format!(
531                        "failed to create DISTINCT spill bucket {}: {error}",
532                        path.display()
533                    ))
534                })?;
535            self.buckets.insert(bucket, file);
536        }
537        let file = self
538            .buckets
539            .get_mut(&bucket)
540            .ok_or_else(|| distinct_error("DISTINCT spill bucket registration failed"))?;
541
542        file.seek(SeekFrom::Start(0)).map_err(|error| {
543            distinct_error(format!("failed to seek DISTINCT spill bucket: {error}"))
544        })?;
545        while let Some(record_len) = read_record_len(file)? {
546            let matches = compare_record(file, record_len, key)?;
547            if matches {
548                return Ok(false);
549            }
550        }
551
552        let original_len = file.seek(SeekFrom::End(0)).map_err(|error| {
553            distinct_error(format!("failed to seek DISTINCT spill bucket: {error}"))
554        })?;
555        let key_len = u64::try_from(key.len())
556            .map_err(|_| distinct_error("DISTINCT key length exceeds the on-disk format"))?;
557        let write_result = (|| {
558            file.write_all(&key_len.to_le_bytes()).map_err(|error| {
559                distinct_error(format!("failed to write DISTINCT key length: {error}"))
560            })?;
561            file.write_all(key).map_err(|error| {
562                distinct_error(format!("failed to write DISTINCT key: {error}"))
563            })?;
564            file.flush().map_err(|error| {
565                distinct_error(format!("failed to flush DISTINCT spill bucket: {error}"))
566            })
567        })();
568        if let Err(error) = write_result {
569            if let Err(rollback_error) = file.set_len(original_len) {
570                return Err(distinct_error(format!(
571                    "{error}; failed to roll back partial DISTINCT key: {rollback_error}"
572                )));
573            }
574            return Err(error);
575        }
576        Ok(true)
577    }
578
579    fn contains(&mut self, key: &[u8]) -> ExecResult<bool> {
580        let bucket = u8::try_from(stable_hash(key) % DISK_BUCKETS)
581            .map_err(|_| distinct_error("DISTINCT spill bucket exceeds u8"))?;
582        let Some(file) = self.buckets.get_mut(&bucket) else {
583            return Ok(false);
584        };
585        file.seek(SeekFrom::Start(0)).map_err(|error| {
586            distinct_error(format!("failed to seek DISTINCT spill bucket: {error}"))
587        })?;
588        while let Some(record_len) = read_record_len(file)? {
589            if compare_record(file, record_len, key)? {
590                return Ok(true);
591            }
592        }
593        Ok(false)
594    }
595}
596
597fn read_record_len(file: &mut File) -> ExecResult<Option<u64>> {
598    let mut encoded = [0_u8; 8];
599    match file.read(&mut encoded[..1]) {
600        Ok(0) => return Ok(None),
601        Ok(1) => {}
602        Ok(count) => {
603            return Err(distinct_error(format!(
604                "invalid DISTINCT spill read count: requested 1 byte, received {count}"
605            )));
606        }
607        Err(error) => {
608            return Err(distinct_error(format!(
609                "failed to read DISTINCT spill bucket: {error}"
610            )));
611        }
612    }
613    file.read_exact(&mut encoded[1..]).map_err(|error| {
614        if error.kind() == ErrorKind::UnexpectedEof {
615            distinct_error("truncated DISTINCT spill key length")
616        } else {
617            distinct_error(format!("failed to read DISTINCT key length: {error}"))
618        }
619    })?;
620    Ok(Some(u64::from_le_bytes(encoded)))
621}
622
623/// Compare one disk record without allocating a second key-sized buffer.
624fn compare_record(file: &mut File, record_len: u64, key: &[u8]) -> ExecResult<bool> {
625    let key_len = u64::try_from(key.len())
626        .map_err(|_| distinct_error("DISTINCT key length exceeds the on-disk format"))?;
627    let mut remaining = record_len;
628    let mut offset = 0_usize;
629    let mut matches = record_len == key_len;
630    let mut buffer = [0_u8; COPY_BUFFER_BYTES];
631    while remaining > 0 {
632        let copy_buffer_bytes = u64::try_from(COPY_BUFFER_BYTES)
633            .map_err(|_| distinct_error("DISTINCT copy buffer exceeds the on-disk length range"))?;
634        let take = usize::try_from(remaining.min(copy_buffer_bytes)).map_err(|_| {
635            distinct_error("DISTINCT spill key chunk exceeds the addressable memory range")
636        })?;
637        file.read_exact(&mut buffer[..take]).map_err(|error| {
638            if error.kind() == ErrorKind::UnexpectedEof {
639                distinct_error("truncated DISTINCT spill key")
640            } else {
641                distinct_error(format!("failed to read DISTINCT spill key: {error}"))
642            }
643        })?;
644        if matches && buffer[..take] != key[offset..offset + take] {
645            matches = false;
646        }
647        if matches {
648            offset += take;
649        }
650        let consumed = u64::try_from(take)
651            .map_err(|_| distinct_error("DISTINCT spill key chunk exceeds the length range"))?;
652        remaining -= consumed;
653    }
654    Ok(matches)
655}
656
657fn stable_hash(bytes: &[u8]) -> u64 {
658    // FNV-1a is deliberately fixed rather than using RandomState: spill files
659    // are ephemeral, and equality always verifies the complete key anyway.
660    let mut hash = 0xcbf2_9ce4_8422_2325_u64;
661    for byte in bytes {
662        hash ^= u64::from(*byte);
663        hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
664    }
665    hash
666}
667
668fn distinct_error(message: impl Into<String>) -> ExecError {
669    ExecError::Other(message.into())
670}
671
672/// Collision-free binary key encoding. Numeric values deliberately share one
673/// canonical domain so `1`, `1.0`, `DECIMAL '1'`, and `TRUE` retain the same
674/// equality behavior as UQA's SQL comparisons. Every structural value carries
675/// lengths/counts, preventing concatenation and nested-container collisions.
676pub(crate) fn encode_key(values: &[Value]) -> ExecResult<Vec<u8>> {
677    encode_key_borrowed(values.iter().map(Some))
678}
679
680fn encode_key_borrowed<'a>(
681    values: impl ExactSizeIterator<Item = Option<&'a Value>>,
682) -> ExecResult<Vec<u8>> {
683    let estimated_capacity = encoded_key_capacity(values.len())?;
684    let mut output = Vec::with_capacity(estimated_capacity);
685    encode_len(values.len(), &mut output)?;
686    for value in values {
687        match value {
688            Some(value) => encode_value(value, &mut output)?,
689            None => encode_value(&Value::Null, &mut output)?,
690        }
691    }
692    Ok(output)
693}
694
695/// Encode a join probe key directly from physical slots. Single- and
696/// two-column numeric keys stay inline, and a NULL/missing component rejects
697/// the SQL equality key without allocating or cloning a `Value`.
698pub(crate) fn encode_non_null_key<'a>(
699    values: impl ExactSizeIterator<Item = Option<&'a Value>>,
700) -> ExecResult<Option<EncodedKey>> {
701    let count = values.len();
702    let mut output = EncodedKey::with_capacity(encoded_key_capacity(count)?);
703    encode_len(count, &mut output)?;
704    for value in values {
705        let Some(value) = value else {
706            return Ok(None);
707        };
708        if matches!(value, Value::Null) {
709            return Ok(None);
710        }
711        encode_value(value, &mut output)?;
712    }
713    Ok(Some(output))
714}
715
716fn encoded_key_capacity(values: usize) -> ExecResult<usize> {
717    values
718        .checked_mul(22)
719        .and_then(|bytes| bytes.checked_add(8))
720        .ok_or_else(|| distinct_error("DISTINCT key capacity overflow"))
721}
722
723trait KeyOutput {
724    fn push_byte(&mut self, value: u8);
725    fn extend_bytes(&mut self, values: &[u8]);
726}
727
728impl KeyOutput for Vec<u8> {
729    fn push_byte(&mut self, value: u8) {
730        self.push(value);
731    }
732
733    fn extend_bytes(&mut self, values: &[u8]) {
734        self.extend_from_slice(values);
735    }
736}
737
738impl<A: Array<Item = u8>> KeyOutput for SmallVec<A> {
739    fn push_byte(&mut self, value: u8) {
740        self.push(value);
741    }
742
743    fn extend_bytes(&mut self, values: &[u8]) {
744        self.extend_from_slice(values);
745    }
746}
747
748struct HasherOutput<'a, H: Hasher>(&'a mut H);
749
750impl<H: Hasher> KeyOutput for HasherOutput<'_, H> {
751    fn push_byte(&mut self, value: u8) {
752        self.0.write_u8(value);
753    }
754
755    fn extend_bytes(&mut self, values: &[u8]) {
756        self.0.write(values);
757    }
758}
759
760fn encode_value(value: &Value, output: &mut impl KeyOutput) -> ExecResult<()> {
761    match value {
762        Value::Null => output.push_byte(0),
763        Value::Bool(value) => {
764            encode_decimal_numeric(&DecimalValue::from_bool(*value), output)?;
765        }
766        Value::Int(value) => {
767            encode_decimal_numeric(&DecimalValue::from_i64(*value), output)?;
768        }
769        Value::Float(value) => encode_float_numeric(*value, output)?,
770        Value::Decimal(value) => encode_decimal_numeric(value, output)?,
771        Value::Str(value) => {
772            output.push_byte(2);
773            encode_bytes(value.as_bytes(), output)?;
774        }
775        Value::FixedChar(value) => {
776            output.push_byte(7);
777            encode_bytes(value.trim_end_matches(' ').as_bytes(), output)?;
778        }
779        Value::Bytes(value) => {
780            output.push_byte(3);
781            encode_bytes(value, output)?;
782        }
783        Value::Temporal(value) => encode_temporal(value, output),
784        Value::Json(value) => {
785            output.push_byte(8);
786            encode_bytes(value.as_bytes(), output)?;
787        }
788        Value::JsonB(value) => {
789            output.push_byte(9);
790            let canonical = uqa_core::jsonb_equality_key(value)
791                .ok_or_else(|| ExecError::Other("stored JSONB value is not valid JSON".into()))?;
792            encode_bytes(&canonical, output)?;
793        }
794        Value::Array(array) => {
795            output.push_byte(12);
796            encode_len(array.lower_bounds().len(), output)?;
797            for lower_bound in array.lower_bounds() {
798                output.extend_bytes(&lower_bound.to_le_bytes());
799            }
800            encode_len(array.elements().len(), output)?;
801            for value in array.elements() {
802                encode_value(value, output)?;
803            }
804        }
805        Value::List(values) => {
806            output.push_byte(5);
807            encode_len(values.len(), output)?;
808            for value in values {
809                encode_value(value, output)?;
810            }
811        }
812        Value::Row(values) => {
813            output.push_byte(10);
814            encode_len(values.len(), output)?;
815            for value in values {
816                encode_value(value, output)?;
817            }
818        }
819        Value::Record(fields) => {
820            output.push_byte(11);
821            encode_len(fields.len(), output)?;
822            for (_, value) in fields {
823                encode_value(value, output)?;
824            }
825        }
826        Value::Map(values) => {
827            output.push_byte(6);
828            encode_len(values.len(), output)?;
829            for (name, value) in values {
830                encode_bytes(name.as_bytes(), output)?;
831                encode_value(value, output)?;
832            }
833        }
834    }
835    Ok(())
836}
837
838fn encode_decimal_numeric(value: &DecimalValue, output: &mut impl KeyOutput) -> ExecResult<()> {
839    if value.is_nan() {
840        output.extend_bytes(&[1, 1]);
841    } else if value.is_negative_infinity() {
842        output.extend_bytes(&[1, 2]);
843    } else if value.is_positive_infinity() {
844        output.extend_bytes(&[1, 3]);
845    } else {
846        output.extend_bytes(&[1, 0]);
847        encode_bytes(value.to_canonical_string().as_bytes(), output)?;
848    }
849    Ok(())
850}
851
852fn encode_float_numeric(value: f64, output: &mut impl KeyOutput) -> ExecResult<()> {
853    if value.is_nan() {
854        // PostgreSQL groups all NaN values together for DISTINCT.
855        output.extend_bytes(&[1, 1]);
856    } else if value == f64::NEG_INFINITY {
857        output.extend_bytes(&[1, 2]);
858    } else if value == f64::INFINITY {
859        output.extend_bytes(&[1, 3]);
860    } else if let Some(decimal) = DecimalValue::from_f64_lossy(value) {
861        encode_decimal_numeric(&decimal, output)?;
862    } else {
863        // Preserve a finite value that cannot enter PostgreSQL's NUMERIC
864        // domain. Normalize signed zero before storing bits.
865        output.extend_bytes(&[1, 4]);
866        let normalized = if value == 0.0 { 0.0 } else { value };
867        output.extend_bytes(&normalized.to_bits().to_be_bytes());
868    }
869    Ok(())
870}
871
872fn encode_temporal(value: &TemporalValue, output: &mut impl KeyOutput) {
873    output.push_byte(4);
874    match value {
875        TemporalValue::Date { days } => {
876            output.push_byte(0);
877            output.extend_bytes(&days.to_be_bytes());
878        }
879        TemporalValue::Time { micros } => {
880            output.push_byte(1);
881            let normalized = i128::from(*micros).rem_euclid(MICROS_PER_DAY);
882            output.extend_bytes(&normalized.to_be_bytes());
883        }
884        TemporalValue::TimeTz {
885            micros,
886            offset_minutes,
887        } => {
888            output.push_byte(2);
889            let normalized = (i128::from(*micros) - i128::from(*offset_minutes) * 60_000_000)
890                .rem_euclid(MICROS_PER_DAY);
891            output.extend_bytes(&normalized.to_be_bytes());
892        }
893        TemporalValue::Timestamp { micros } => {
894            output.push_byte(3);
895            output.extend_bytes(&micros.to_be_bytes());
896        }
897        TemporalValue::TimestampTz { micros } => {
898            output.push_byte(4);
899            output.extend_bytes(&micros.to_be_bytes());
900        }
901        TemporalValue::Interval {
902            months,
903            days,
904            micros,
905        } => {
906            output.push_byte(5);
907            let normalized = (i128::from(*months) * 30 + i128::from(*days)) * MICROS_PER_DAY
908                + i128::from(*micros);
909            output.extend_bytes(&normalized.to_be_bytes());
910        }
911    }
912}
913
914fn encode_bytes(bytes: &[u8], output: &mut impl KeyOutput) -> ExecResult<()> {
915    encode_len(bytes.len(), output)?;
916    output.extend_bytes(bytes);
917    Ok(())
918}
919
920fn encode_len(length: usize, output: &mut impl KeyOutput) -> ExecResult<()> {
921    let length = u64::try_from(length)
922        .map_err(|_| distinct_error("DISTINCT key component exceeds the binary format"))?;
923    output.extend_bytes(&length.to_be_bytes());
924    Ok(())
925}
926
927#[cfg(test)]
928mod tests {
929    use std::sync::Arc;
930
931    use tempfile::NamedTempFile;
932
933    use super::*;
934    use crate::physical::run_to_rows;
935    use crate::scan::TableScan;
936    use crate::{ExpressionEvaluator, PhysicalRow, ScalarEvalContext};
937
938    fn row(a: i64, b: i64) -> ResultRow {
939        [("a".into(), Value::Int(a)), ("b".into(), Value::Int(b))]
940            .into_iter()
941            .collect()
942    }
943
944    fn value_row(value: Value) -> ResultRow {
945        [("v".into(), value)].into_iter().collect()
946    }
947
948    struct Evaluator;
949
950    impl ExpressionEvaluator for Evaluator {
951        fn evaluate(
952            &self,
953            expression: &ScalarExpr,
954            row: &dyn uqa_sql::expr::RowLookup,
955        ) -> ExecResult<Value> {
956            Ok(crate::eval_scalar(
957                expression,
958                &ScalarEvalContext::from_row_lookup(row, &[]),
959            )?)
960        }
961    }
962
963    #[test]
964    fn all_columns_and_distinct_on_preserve_the_first_row() {
965        let rows = vec![row(1, 10), row(1, 10), row(1, 11), row(2, 20)];
966        let scan = TableScan::from_rows(vec!["a".into(), "b".into()], rows.clone());
967        let mut all = Distinct::all_with_work_mem(Box::new(scan), 1);
968        let (_, all_rows) = run_to_rows(&mut all).unwrap();
969        assert_eq!(all_rows, vec![row(1, 10), row(1, 11), row(2, 20)]);
970
971        let scan = TableScan::from_rows(vec!["a".into(), "b".into()], rows);
972        let mut on = Distinct::on_with_work_mem(
973            Box::new(scan),
974            vec![ScalarExpr::Column("a".into())],
975            Arc::new(Evaluator),
976            1,
977        );
978        let (_, on_rows) = run_to_rows(&mut on).unwrap();
979        assert_eq!(on_rows, vec![row(1, 10), row(2, 20)]);
980    }
981
982    #[test]
983    fn distinct_is_a_row_lock_identity_barrier() {
984        let schema = RowSchema::new(vec!["v".into()]);
985        let row = PhysicalRow::from_values(vec![Value::Int(1)])
986            .with_lock_origin(crate::RowLockOrigin::new("source", "public.source", 1));
987        let scan = TableScan::from_physical_rows(schema, vec![row]);
988        let mut distinct = Distinct::all_with_work_mem(Box::new(scan), 1);
989
990        let batches = crate::physical::run_to_batches(&mut distinct).unwrap();
991        assert!(batches[0].rows[0].lock_origins().is_empty());
992    }
993
994    #[test]
995    fn tiny_budget_migrates_to_disk_and_never_retains_key_bytes() {
996        let rows: Vec<_> = (0..50)
997            .flat_map(|value| [value_row(Value::Int(value)), value_row(Value::Int(value))])
998            .collect();
999        let scan = TableScan::from_rows(vec!["v".into()], rows);
1000        let mut distinct = Distinct::all_with_work_mem(Box::new(scan), 1);
1001        distinct.open().unwrap();
1002        let output = distinct.next().unwrap().unwrap();
1003        assert_eq!(output.rows.len(), 50);
1004        assert!(distinct.has_spilled());
1005        assert_eq!(distinct.in_memory_key_bytes(), 0);
1006        assert!(distinct.next().unwrap().is_none());
1007        distinct.close().unwrap();
1008    }
1009
1010    #[test]
1011    fn exact_row_set_persists_disk_backed_state_across_fixpoint_phases() {
1012        let schema = vec!["a".into(), "b".into()];
1013        let mut seen = ExactRowSet::new(1);
1014        for value in 0..100 {
1015            assert!(seen.insert_row(&row(value, value + 1), &schema).unwrap());
1016        }
1017        assert!(seen.has_spilled());
1018        assert_eq!(seen.in_memory_key_bytes(), 0);
1019        for value in 0..100 {
1020            assert!(seen.contains_row(&row(value, value + 1), &schema).unwrap());
1021            assert!(!seen.insert_row(&row(value, value + 1), &schema).unwrap());
1022        }
1023        assert!(!seen.contains_row(&row(101, 102), &schema).unwrap());
1024    }
1025
1026    #[test]
1027    fn binary_keys_cover_every_value_variant_without_structural_collisions() {
1028        let one = DecimalValue::parse("1.000").unwrap();
1029        let mut nested_map = BTreeMap::new();
1030        nested_map.insert("x".into(), Value::Float(1.0));
1031        let values = vec![
1032            Value::Null,
1033            Value::Bool(true),
1034            Value::Int(1),
1035            Value::Float(1.0),
1036            Value::Decimal(one),
1037            Value::Float(f64::NAN),
1038            Value::Float(f64::from_bits(0x7ff8_0000_0000_0001)),
1039            Value::Float(f64::NEG_INFINITY),
1040            Value::Float(f64::INFINITY),
1041            Value::Str("a\0b".into()),
1042            Value::Bytes(vec![b'a', 0, b'b']),
1043            Value::Temporal(TemporalValue::Date { days: 1 }),
1044            Value::Temporal(TemporalValue::Time {
1045                micros: MICROS_PER_DAY as i64 + 7,
1046            }),
1047            Value::Temporal(TemporalValue::Time { micros: 7 }),
1048            Value::Temporal(TemporalValue::TimeTz {
1049                micros: 3_600_000_000,
1050                offset_minutes: 60,
1051            }),
1052            Value::Temporal(TemporalValue::TimeTz {
1053                micros: 0,
1054                offset_minutes: 0,
1055            }),
1056            Value::Temporal(TemporalValue::Timestamp { micros: 9 }),
1057            Value::Temporal(TemporalValue::TimestampTz { micros: 9 }),
1058            Value::Temporal(TemporalValue::Interval {
1059                months: 1,
1060                days: 0,
1061                micros: 0,
1062            }),
1063            Value::Temporal(TemporalValue::Interval {
1064                months: 0,
1065                days: 30,
1066                micros: 0,
1067            }),
1068            Value::List(vec![Value::Int(1), Value::Str("x".into())]),
1069            Value::List(vec![Value::Float(1.0), Value::Str("x".into())]),
1070            Value::Map(nested_map),
1071        ];
1072        let rows: Vec<_> = values
1073            .iter()
1074            .cloned()
1075            .chain(values.iter().cloned())
1076            .map(value_row)
1077            .collect();
1078        let scan = TableScan::from_rows(vec!["v".into()], rows);
1079        let mut distinct = Distinct::all_with_work_mem(Box::new(scan), 0);
1080        let (_, output) = run_to_rows(&mut distinct).unwrap();
1081
1082        // true/int/float/decimal share one numeric key; NaN payloads share one;
1083        // normalized time/time-tz/interval pairs and nested numeric values do
1084        // likewise. The string and byte representations stay distinct.
1085        assert_eq!(output.len(), 15);
1086        assert_eq!(output[1], value_row(Value::Bool(true)));
1087        assert!(matches!(output[2].get("v"), Some(Value::Float(v)) if v.is_nan()));
1088        assert_eq!(output[5], value_row(Value::Str("a\0b".into())));
1089        assert_eq!(output[6], value_row(Value::Bytes(vec![b'a', 0, b'b'])));
1090    }
1091
1092    #[test]
1093    fn physical_numeric_join_key_stays_inline_and_matches_distinct_encoding() {
1094        let value = Value::Int(42);
1095        let key = encode_non_null_key(std::iter::once(Some(&value)))
1096            .unwrap()
1097            .unwrap();
1098        assert!(!key.spilled());
1099        assert_eq!(
1100            key.as_slice(),
1101            encode_key(std::slice::from_ref(&value)).unwrap()
1102        );
1103
1104        let null = Value::Null;
1105        assert!(encode_non_null_key(std::iter::once(Some(&null)))
1106            .unwrap()
1107            .is_none());
1108    }
1109
1110    #[test]
1111    fn canonical_row_hash_streams_borrowed_composites_with_sql_equality() {
1112        let integer = Value::Int(1);
1113        let decimal = Value::Decimal(DecimalValue::parse("1.000").unwrap());
1114        let text = Value::Str("group".into());
1115        let hash_state = ahash::RandomState::new();
1116        assert_eq!(
1117            hash_canonical_row(&hash_state, [Some(&integer), Some(&text)].into_iter()).unwrap(),
1118            hash_canonical_row(&hash_state, [Some(&decimal), Some(&text)].into_iter()).unwrap()
1119        );
1120
1121        let null = Value::Null;
1122        assert_eq!(
1123            hash_canonical_row(&hash_state, std::iter::once(None)).unwrap(),
1124            hash_canonical_row(&hash_state, std::iter::once(Some(&null))).unwrap()
1125        );
1126    }
1127
1128    #[test]
1129    fn compact_text_pair_key_is_stable_for_borrowed_and_owned_values() {
1130        let first = Value::Str("A".into());
1131        let second = Value::Str("O".into());
1132        let values = [Some(&first), Some(&second)];
1133
1134        let key = try_pack_compact_text_pair(values.into_iter()).unwrap();
1135        let owned = [first.clone(), second.clone()];
1136        assert_eq!(
1137            key,
1138            try_pack_compact_text_pair(owned.iter().map(Some)).unwrap(),
1139        );
1140        let long = Value::Str("x".repeat(64));
1141        assert_eq!(
1142            try_pack_compact_text_pair([Some(&long), Some(&second)].into_iter()),
1143            None,
1144        );
1145
1146        let candidates = [
1147            Value::Null,
1148            Value::Str(String::new()),
1149            Value::Str("A".into()),
1150            Value::Str("AB".into()),
1151            Value::Str("ABC".into()),
1152            Value::Str("é".into()),
1153            Value::Str("한".into()),
1154        ];
1155        let mut keys = std::collections::BTreeSet::new();
1156        for first in &candidates {
1157            for second in &candidates {
1158                assert!(keys.insert(
1159                    try_pack_compact_text_pair([Some(first), Some(second)].into_iter()).unwrap()
1160                ));
1161            }
1162        }
1163    }
1164
1165    #[test]
1166    fn canonical_row_hash_set_copies_only_new_keys_and_probes_borrowed() {
1167        let one = Value::Int(1);
1168        let two = Value::Int(2);
1169        let decimal_one = Value::Decimal(DecimalValue::parse("1.000").unwrap());
1170        let mut rows = CanonicalRowHashSet::new();
1171
1172        assert!(rows.insert_borrowed(&[&one, &two]).unwrap());
1173        assert!(!rows.insert_borrowed(&[&decimal_one, &two]).unwrap());
1174        assert!(rows.contains_borrowed(&[&decimal_one, &two]).unwrap());
1175        assert!(!rows.contains_borrowed(&[&two, &one]).unwrap());
1176        assert_eq!(rows.rows.len(), 1);
1177        assert!(!rows.rows[0].spilled());
1178    }
1179
1180    #[test]
1181    fn temporary_directory_is_removed_on_drop() {
1182        let parent = tempfile::tempdir().unwrap();
1183        let scan = TableScan::from_rows(vec!["v".into()], vec![value_row(Value::Int(1))]);
1184        let mut distinct =
1185            Distinct::all_with_work_mem(Box::new(scan), 0).with_spill_directory(parent.path());
1186        distinct.open().unwrap();
1187        distinct.next().unwrap();
1188        let spill_path = distinct.spill_path().unwrap().to_path_buf();
1189        assert!(spill_path.exists());
1190        drop(distinct);
1191        assert!(!spill_path.exists());
1192    }
1193
1194    #[test]
1195    fn spill_creation_failure_is_returned() {
1196        let not_a_directory = NamedTempFile::new().unwrap();
1197        let scan = TableScan::from_rows(vec!["v".into()], vec![value_row(Value::Int(1))]);
1198        let mut distinct = Distinct::all_with_work_mem(Box::new(scan), 0)
1199            .with_spill_directory(not_a_directory.path());
1200        let error = run_to_rows(&mut distinct).unwrap_err();
1201        assert!(error
1202            .to_string()
1203            .contains("failed to create DISTINCT spill directory"));
1204    }
1205
1206    #[test]
1207    fn truncated_disk_record_is_reported() {
1208        let first = encode_key(&[Value::Int(1)]).unwrap();
1209        let bucket = stable_hash(&first) % DISK_BUCKETS;
1210        let second = (2..10_000)
1211            .map(|value| encode_key(&[Value::Int(value)]).unwrap())
1212            .find(|key| stable_hash(key) % DISK_BUCKETS == bucket)
1213            .unwrap();
1214        let mut set = SeenKeySet::new(0, None);
1215        assert!(set.insert(first).unwrap());
1216        let disk = set.disk.as_mut().unwrap();
1217        let file = disk.buckets.get_mut(&(bucket as u8)).unwrap();
1218        file.set_len(4).unwrap();
1219        let error = set.insert(second).unwrap_err();
1220        assert!(error
1221            .to_string()
1222            .contains("truncated DISTINCT spill key length"));
1223    }
1224
1225    struct FailingEvaluator;
1226
1227    struct MismatchedSchemaScan {
1228        declared: RowSchema,
1229        emitted: Option<Batch>,
1230    }
1231
1232    impl PhysicalOperator for MismatchedSchemaScan {
1233        fn row_schema(&self) -> &RowSchema {
1234            &self.declared
1235        }
1236
1237        fn open(&mut self) -> ExecResult<()> {
1238            Ok(())
1239        }
1240
1241        fn next(&mut self) -> ExecResult<Option<Batch>> {
1242            Ok(self.emitted.take())
1243        }
1244
1245        fn close(&mut self) -> ExecResult<()> {
1246            Ok(())
1247        }
1248    }
1249
1250    impl ExpressionEvaluator for FailingEvaluator {
1251        fn evaluate(
1252            &self,
1253            _expression: &ScalarExpr,
1254            _row: &dyn uqa_sql::expr::RowLookup,
1255        ) -> ExecResult<Value> {
1256            Err(ExecError::Other("intentional evaluator failure".into()))
1257        }
1258    }
1259
1260    #[test]
1261    fn evaluator_errors_are_propagated() {
1262        let scan = TableScan::from_rows(vec!["v".into()], vec![value_row(Value::Int(1))]);
1263        let mut distinct = Distinct::on_with_work_mem(
1264            Box::new(scan),
1265            vec![ScalarExpr::Column("v".into())],
1266            Arc::new(FailingEvaluator),
1267            0,
1268        );
1269        let error = run_to_rows(&mut distinct).unwrap_err();
1270        assert!(error.to_string().contains("intentional evaluator failure"));
1271    }
1272
1273    #[test]
1274    fn distinct_rejects_a_child_batch_with_a_different_schema() {
1275        let scan = MismatchedSchemaScan {
1276            declared: RowSchema::new(vec!["declared".into()]),
1277            emitted: Some(Batch::from_physical_rows(
1278                RowSchema::new(vec!["actual".into()]),
1279                vec![PhysicalRow::from_values(vec![Value::Int(1)])],
1280            )),
1281        };
1282        let mut distinct = Distinct::all(Box::new(scan));
1283
1284        let error = run_to_rows(&mut distinct).unwrap_err();
1285
1286        assert!(error.to_string().contains("DISTINCT input schema mismatch"));
1287    }
1288}