Skip to main content

delta_kernel/plans/ir/
nodes.rs

1//! Plan node operator kinds and their payloads.
2//!
3//! [`Operator`] enumerates every operator. Each operator's payload struct is defined
4//! below.
5
6use std::collections::HashMap;
7use std::sync::{Arc, LazyLock};
8
9use itertools::Itertools;
10use strum::Display;
11use url::Url;
12
13use crate::actions::deletion_vector::DeletionVectorDescriptor;
14use crate::error::add_scalar_path_context;
15use crate::expressions::{ColumnName, ExpressionRef, PredicateRef, Scalar, StructData};
16use crate::schema::{DataType, SchemaRef, StructField, StructType, ToSchema};
17use crate::utils::CollectInto;
18use crate::{DeltaResult, Error, FileMeta};
19
20// ============================================================================
21// Operator: enumerates every operator kind
22// ============================================================================
23
24/// Plan node operators, grouped below by input arity. Each variant wraps a payload struct
25/// documenting that operator's semantics, invariants, and output shape.
26///
27/// An operator that reshapes its rows (a source, projection, aggregation, or file scan) carries a
28/// caller-declared `schema` field holding its output schema. The rest emit rows they were given, so
29/// they inherit an input's schema; each payload's docs name which input.
30#[derive(Debug, Clone, Display)]
31#[strum(serialize_all = "snake_case")]
32pub enum Operator {
33    // === Source operators (0 inputs) =========================================
34    ScanParquet(ScanParquet),
35    ScanJson(ScanJson),
36    Values(Values),
37
38    // === Unary operators (1 input) ===========================================
39    Project(Project),
40    Filter(Filter),
41    DynamicScan(DynamicScan),
42    Aggregate(Aggregate),
43
44    // === Binary operators (2 inputs) =========================================
45    SemiJoin(SemiJoin),
46
47    // === N-ary operators (variable inputs) ===================================
48    UnionAll(UnionAll),
49}
50
51/// Generate `From<Payload> for Operator` for each listed variant, wrapping the payload in the
52/// same-named [`Operator`] variant. Example: `Filter { .. }.into()` yields `Operator::Filter`).
53macro_rules! impl_from_payload_for_operator {
54    ($($variant:ident),+ $(,)?) => {
55        $(impl From<$variant> for Operator {
56            fn from(payload: $variant) -> Self {
57                Operator::$variant(payload)
58            }
59        })+
60    };
61}
62
63impl_from_payload_for_operator!(
64    ScanParquet,
65    ScanJson,
66    Values,
67    Project,
68    Filter,
69    DynamicScan,
70    Aggregate,
71    SemiJoin,
72    UnionAll,
73);
74
75/// One file to scan plus literal values broadcast to every row read from that file.
76///
77/// `file_constants` holds one [`Scalar`] per entry in the parent scan node's
78/// [`ScanParquet::file_constant_columns`] / [`ScanJson::file_constant_columns`], in the
79/// same order.
80#[derive(Debug, Clone, PartialEq)]
81pub struct ScanFile {
82    pub meta: FileMeta,
83    /// One [`Scalar`] per `file_constant_columns` on the enclosing scan node, same order.
84    pub file_constants: Vec<Scalar>,
85}
86
87impl ScanFile {
88    /// A scan file with no file-constant column values.
89    pub fn new(meta: FileMeta) -> Self {
90        Self {
91            meta,
92            file_constants: Vec::new(),
93        }
94    }
95}
96
97impl From<FileMeta> for ScanFile {
98    fn from(meta: FileMeta) -> Self {
99        Self::new(meta)
100    }
101}
102
103/// Reads Parquet `files` into row batches matching `schema`. The engine returns exactly the
104/// columns named by `schema`, in schema order.
105///
106/// Output row order is unspecified: the engine is free to read `files` in any order, in
107/// parallel, and to interleave rows from different files.
108///
109/// # Column resolution
110///
111/// The engine iterates `schema`'s fields in order; for each field it produces one column of
112/// output:
113///
114/// 1. **Metadata columns**: if the field is annotated as a metadata column (e.g. via
115///    [`StructField::create_metadata_column`] with [`MetadataColumnSpec::RowIndex`]), the engine
116///    populates it from the read context rather than from the Parquet file. See [Metadata columns]
117///    below.
118/// 2. **File-constant columns**: if the field's name appears in [`Self::file_constant_columns`],
119///    the engine broadcasts the corresponding entry from [`ScanFile::file_constants`] for the file
120///    being read (not from Parquet bytes). See [File-constant columns] below.
121/// 3. **Data columns**: otherwise the engine attempts to locate the field in the Parquet file, in
122///    this order:
123///    - **Field ID**: if the field carries a Parquet field ID via
124///      [`ColumnMetadataKey::ParquetFieldId`] metadata, match it against the Parquet column with
125///      the same field id.
126///    - **Field name**: otherwise, or if no Parquet column has the requested field id, match by
127///      column name.
128///    - **No match**: the output column is filled with NULLs when the field is nullable, or an
129///      error is returned when it is non-nullable.
130///
131/// Parquet columns not referenced by any `schema` field are ignored.
132///
133/// [Metadata columns]: #metadata-columns
134/// [File-constant columns]: #file-constant-columns
135/// [`StructField::create_metadata_column`]: crate::schema::StructField::create_metadata_column
136/// [`MetadataColumnSpec::RowIndex`]: crate::schema::MetadataColumnSpec::RowIndex
137/// [`ColumnMetadataKey::ParquetFieldId`]: crate::schema::ColumnMetadataKey::ParquetFieldId
138///
139/// ## Example
140///
141/// Consider a `schema` with the following fields (none of which are metadata columns):
142/// - Column 0: `"i_logical"` (integer, non-null) with field ID 1 (via
143///   [`ColumnMetadataKey::ParquetFieldId`])
144/// - Column 1: `"s"` (string, nullable) with no field ID metadata
145/// - Column 2: `"i2"` (integer, nullable) with no field ID metadata
146///
147/// And a Parquet file containing these columns:
148/// - Column 0: `"i2"` (integer, nullable) with field ID 3
149/// - Column 1: `"i"` (integer, non-null) with field ID 1
150/// - No `"s"` column present
151///
152/// Resolving each `schema` field in turn:
153/// - `"i_logical"` matches `"i"` by field ID (both have ID 1).
154/// - `"s"` has no matching Parquet column, so the output column is filled with NULLs.
155/// - `"i2"` matches `"i2"` by column name (no field ID to match on).
156///
157/// The returned data contains exactly 3 columns in schema order:
158/// `{i_logical: parquet[1], s: NULL.., i2: parquet[0]}`.
159///
160/// # Metadata columns
161///
162/// A field marked as a row index metadata column (via [`StructField::create_metadata_column`]
163/// with [`MetadataColumnSpec::RowIndex`]) is populated by the engine with the 0-based row
164/// position within the file (`LONG`, non-nullable); a file with 5 rows yields `[0, 1, 2, 3, 4]`.
165/// The column name is caller-chosen (commonly `"row_index"`).
166///
167/// # File-constant columns
168///
169/// [`Self::file_constant_columns`] names output fields whose values are identical for every row
170/// in a given file (for example Delta partition columns or a table-changes `version`). Types and
171/// nullability come from [`Self::schema`]; [`ScanFile::file_constants`] supplies the per-file
172/// literals in the same order as `file_constant_columns`.
173///
174/// File-constant columns are distinct from [metadata columns], which are engine-generated
175/// (such as row index). [`DynamicScan::file_constant_columns`] is the same concept for the
176/// [`DynamicScan`] node.
177///
178/// # Invariants
179///
180/// - `files[i].file_constants.len() == file_constant_columns.len()` for every `i`.
181/// - Every name in `file_constant_columns` resolves to a field in `schema` that is not a metadata
182///   column.
183/// - Each scalar in `file_constants` is compatible with its schema field's type.
184#[derive(Debug, Clone)]
185pub struct ScanParquet {
186    pub files: Vec<ScanFile>,
187    pub file_constant_columns: Vec<String>,
188    pub schema: SchemaRef,
189}
190
191/// Reads newline-delimited JSON `files` (one JSON object per line) into row batches matching
192/// `schema`.
193///
194/// Column resolution matches [`ScanParquet`]: metadata columns, then file-constant columns
195/// (see [`Self::file_constant_columns`] and [`ScanFile::file_constants`]), then fields read from
196/// each JSON line. Missing JSON fields produce NULL for nullable `schema` fields and an error for
197/// non-nullable fields.
198///
199/// Output row order is unspecified: the engine is free to read `files` in any order, in
200/// parallel, and to interleave rows from different files.
201///
202/// # File-constant columns
203///
204/// Same contract as [`ScanParquet::file_constant_columns`].
205///
206/// # Invariants
207///
208/// Same invariants as [`ScanParquet`].
209#[derive(Debug, Clone)]
210pub struct ScanJson {
211    pub files: Vec<ScanFile>,
212    pub file_constant_columns: Vec<String>,
213    pub schema: SchemaRef,
214}
215
216/// Inline literal rows. Each `rows[i]` carries one [`Scalar`] per **top-level** field
217/// of `schema`, in field order; `rows[i].len() == schema.fields().count()` for every
218/// row. Nested struct values are encoded as [`Scalar::Struct`], and array / map
219/// values as [`Scalar::Array`] / [`Scalar::Map`]; nested leaves are not flattened
220/// into the row vec.
221///
222/// # Example (flat)
223///
224/// Two rows over `{ id: int, active: bool }`:
225///
226/// ```text
227/// Values {
228///     schema: { id: int, active: bool },
229///     rows: [
230///         [1, true],
231///         [2, false],
232///     ],
233/// }
234/// ```
235///
236/// produces:
237///
238/// ```text
239/// id | active
240/// ---+--------
241///  1 |  true
242///  2 | false
243/// ```
244///
245/// # Example (nested)
246///
247/// Two rows over `{ id: int, address: { city: string, zip: int } }`. The `address`
248/// field is one top-level slot in the row vec, populated with a single
249/// `Scalar::Struct`:
250///
251/// ```text
252/// Values {
253///     schema: { id: int, address: { city: string, zip: int } },
254///     rows: [
255///         [1, Scalar::Struct({ city: "NYC", zip: 10001 })],
256///         [2, Scalar::Struct({ city: "SF",  zip: 94102 })],
257///     ],
258/// }
259/// ```
260///
261/// produces:
262///
263/// ```text
264/// id | address.city | address.zip
265/// ---+--------------+------------
266///  1 |     NYC      |    10001
267///  2 |     SF       |    94102
268/// ```
269#[derive(Debug, Clone)]
270pub struct Values {
271    pub schema: SchemaRef,
272    pub rows: Vec<Vec<Scalar>>,
273}
274
275impl Values {
276    /// Literal `rows` matching `schema`. Empty `rows` is the uninhabited relation over `schema`.
277    pub fn new(schema: impl Into<SchemaRef>, rows: Vec<Vec<Scalar>>) -> Self {
278        Self {
279            schema: schema.into(),
280            rows,
281        }
282    }
283}
284
285/// Collect rows of `T` into a [`Values`] node.
286///
287/// Schema comes from [`ToSchema`]. Each row is converted via [`Into<StructData>`] and peeled into
288/// top-level field scalars (nested fields remain [`Scalar::Struct`]).
289impl<T: Into<StructData> + ToSchema> FromIterator<T> for Values {
290    fn from_iter<I: IntoIterator<Item = T>>(iter: I) -> Self {
291        let rows = iter.into_iter().map(|row| row.into().into_parts().1);
292        Self::new(Arc::new(T::to_schema()), rows.collect())
293    }
294}
295
296/// Inverse of [`FromIterator<T> for Values`]: rebuild each row as [`StructData`] and convert via
297/// [`TryFrom`].
298impl<T> TryFrom<Values> for Vec<T>
299where
300    T: TryFrom<StructData, Error = Error> + ToSchema,
301{
302    type Error = Error;
303
304    fn try_from(Values { schema, rows }: Values) -> DeltaResult<Self> {
305        rows.into_iter()
306            .enumerate()
307            .map(|(index, row)| {
308                let schema = schema.as_ref().clone();
309                T::try_from(StructData::from_values_unchecked(schema, row))
310                    .map_err(|error| add_scalar_path_context(error, format!("[{index}]")))
311            })
312            .try_collect()
313    }
314}
315
316/// Projects the input through `expr` into rows of `schema`.
317///
318/// `expr` must be a struct constructor or struct patch whose fields match `schema`. It is
319/// evaluated with `schema` as its output struct type: the struct's fields are the output
320/// columns, `schema` supplies names and nullability, and any type or arity mismatch is an error.
321/// Downstream nodes see the logical field names declared in `schema`.
322///
323/// A struct patch carries the input struct through field by field, naming only the columns that
324/// change -- replacing or dropping existing fields and injecting new ones -- while everything else
325/// passes through unchanged, so it costs O(changes) rather than O(schema width). The patched
326/// result still covers every field in `schema`.
327///
328/// # Example
329///
330/// Input `{ id, first, last, add: { path, size, stats_parsed: { numRecords } } }` projected to
331/// `{ id, names, file_meta }`, showing passthrough, array construction, nested input access, and a
332/// struct output column:
333///
334/// ```text
335/// Project {
336///     expr: Expression::struct_from([
337///         col!("id"),
338///         Expression::array([col!("first"), col!("last")]),
339///         Expression::struct_from([
340///             col!("add.path"),
341///             col!("add.size"),
342///             col!("add.stats_parsed.numRecords"),
343///         ]),
344///     ]),
345///     schema: {
346///         id: int,
347///         names: array<string>,
348///         file_meta: { path: string, size: long, num_records: long },
349///     },
350/// }
351/// ```
352#[derive(Debug, Clone)]
353pub struct Project {
354    pub expr: ExpressionRef,
355    pub schema: SchemaRef,
356}
357
358/// Keeps input rows where `predicate` evaluates true (SQL null semantics).
359/// Output schema is the input schema unchanged.
360#[derive(Debug, Clone)]
361pub struct Filter {
362    pub predicate: PredicateRef,
363}
364
365/// File formats supported by [`DynamicScan`].
366#[derive(Debug, Clone, Copy, PartialEq, Eq)]
367pub enum FileType {
368    Parquet,
369    Json,
370}
371
372/// Reads data files from an upstream stream of file-metadata tuples, one input row per file.
373/// For each row, the path, size, and last-modified columns describe the file; the engine resolves
374/// its path against `base_url` (see below), opens it as `file_type`, and reads columns matching
375/// `schema`.
376///
377/// `file_constant_columns` lists upstream columns whose per-file values are broadcast onto
378/// every emitted file row. This is file-constant metadata, the same concept as
379/// [`ScanParquet::file_constant_columns`]. Each named input field must have the same type and
380/// nullability as its output field. See the example below.
381///
382/// `dv_column` names a nullable column on the upstream row holding a Delta
383/// [`DeletionVectorDescriptor`] struct. The engine resolves it into a roaring bitmap
384/// and drops file rows whose row index appears in the DV. A NULL value for a given
385/// input row means "no DV for this file", so all file rows are emitted.
386///
387/// [`DeletionVectorDescriptor`]: crate::actions::deletion_vector::DeletionVectorDescriptor
388///
389/// Each path value is resolved against `base_url` via [`Url::join`]. URL-reference resolution need
390/// not stay under `base_url`: a different-scheme absolute URL replaces the base, while a value
391/// starting with `/` or `//` replaces its path or authority.
392///
393/// Output row order is unspecified: the engine is free to read files in any order, in
394/// parallel, and to interleave rows from different files. The relative order of upstream
395/// rows is not preserved.
396///
397/// # Example
398///
399/// Given an upstream metadata stream and a `DynamicScan` configuration:
400///
401/// ```text
402/// upstream (metadata)
403///     path             | size | filemod | version | dv
404///     -----------------+------+---------+---------+------
405///     part-0.parquet   | 1024 |  100000 |       7 | NULL
406///     part-1.parquet   | 2048 |  200000 |       8 | NULL
407/// ```
408/// ```text
409/// DynamicScan {
410///     schema: { id: int, name: string, version: long },
411///     file_type: Parquet,
412///     base_url: "s3://table/",
413///     file_constant_columns: ["version"],
414///     path_column: "path",
415///     file_size_column: "size",
416///     last_modified_column: "filemod",
417///     dv_column: "dv",
418/// }
419/// ```
420/// The engine opens `s3://table/part-0.parquet` and `s3://table/part-1.parquet`, reads
421/// `{id, name}` from each, sees a NULL DV for each file so all rows survive, and
422/// broadcasts the row's `version` onto every emitted file row. One possible output
423/// (row order is not guaranteed):
424/// ```text
425///     | id | name | version
426///     +----+------+--------
427///     |  3 |  c   |       8
428///     |  2 |  b   |       7
429///     |  4 |  d   |       8
430///     |  1 |  a   |       7
431/// ```
432#[derive(Debug, Clone)]
433pub struct DynamicScan {
434    pub schema: SchemaRef,
435    pub file_type: FileType,
436    /// Hierarchical base URL ending in `/` against which per-row path values resolve.
437    pub base_url: Url,
438    pub file_constant_columns: Vec<String>,
439    /// Non-nullable input column holding the per-row file path or URL fragment.
440    pub path_column: ColumnName,
441    /// Non-nullable input column with the file's total size in bytes.
442    pub file_size_column: ColumnName,
443    /// Non-nullable input column with the last-modified timestamp in milliseconds since epoch.
444    pub last_modified_column: ColumnName,
445    /// Nullable input column with the schema of [`DeletionVectorDescriptor`].
446    pub dv_column: ColumnName,
447}
448
449impl DynamicScan {
450    /// Constructs a [`DynamicScan`] whose emitted rows match `output_schema`.
451    ///
452    /// `input_schema` describes the upstream rows containing file metadata. The scan reads
453    /// `file_type` files relative to `base_url`.
454    ///
455    /// # Errors
456    ///
457    /// Returns an error when `base_url` is not hierarchical or does not end in `/`; when a required
458    /// metadata or deletion-vector column is absent from `input_schema`, has an incompatible type,
459    /// or has invalid nullability; or when a file-constant column is absent from either schema, is
460    /// a metadata column, or has different input and output types or nullability.
461    #[allow(clippy::too_many_arguments)]
462    pub fn try_new(
463        input_schema: &SchemaRef,
464        output_schema: impl Into<SchemaRef>,
465        file_type: FileType,
466        base_url: Url,
467        file_constant_columns: impl IntoIterator<Item = impl Into<String>>,
468        path_column: ColumnName,
469        file_size_column: ColumnName,
470        last_modified_column: ColumnName,
471        dv_column: ColumnName,
472    ) -> DeltaResult<Self> {
473        let schema = output_schema.into();
474        let file_constant_columns = file_constant_columns
475            .into_iter()
476            .map(Into::into)
477            .collect::<Vec<_>>();
478        let dynamic_scan = Self {
479            schema,
480            file_type,
481            base_url,
482            file_constant_columns,
483            path_column,
484            file_size_column,
485            last_modified_column,
486            dv_column,
487        };
488        dynamic_scan.validate_input(input_schema)?;
489        Ok(dynamic_scan)
490    }
491
492    /// Validates the columns consumed by this scan against an upstream `input_schema`.
493    ///
494    /// Returns `Ok(())` when the base URL is valid and every configured column resolves with the
495    /// required type and nullability.
496    ///
497    /// # Errors
498    ///
499    /// Returns an error when `base_url` is not hierarchical or does not end in `/`; when a required
500    /// metadata or deletion-vector column is absent, has an incompatible type, or has invalid
501    /// nullability; or when a file-constant column is absent from either schema, is a metadata
502    /// column, or has different input and output types or nullability.
503    pub fn validate_input(&self, input_schema: &SchemaRef) -> DeltaResult<()> {
504        static DELETION_VECTOR_DATA_TYPE: LazyLock<DataType> =
505            LazyLock::new(|| DataType::from(DeletionVectorDescriptor::to_schema()));
506
507        if self.base_url.cannot_be_a_base() || !self.base_url.path().ends_with('/') {
508            return Err(Error::generic(format!(
509                "dynamic scan: base URL `{}` must be hierarchical and end in `/`",
510                self.base_url
511            )));
512        }
513
514        Self::validate_required_column(input_schema, &self.path_column, &DataType::STRING)?;
515        Self::validate_required_column(input_schema, &self.file_size_column, &DataType::LONG)?;
516        Self::validate_required_column(input_schema, &self.last_modified_column, &DataType::LONG)?;
517        Self::validate_file_constant_columns(
518            input_schema,
519            &self.schema,
520            &self.file_constant_columns,
521        )?;
522
523        let fields = input_schema
524            .fields_of_path(&self.dv_column)
525            .map_err(|err| {
526                Error::generic(format!(
527                    "dynamic scan: deletion-vector column `{}` is invalid: {err}",
528                    self.dv_column
529                ))
530            })?;
531        let Some((field, _ancestors)) = fields.split_last() else {
532            return Err(Error::internal_error("fields_of_path returned no fields"));
533        };
534        let expected = &*DELETION_VECTOR_DATA_TYPE;
535        if field.data_type() != expected {
536            return Err(Error::generic(format!(
537                "dynamic scan: deletion-vector column `{}` must have type {expected}, found {}",
538                self.dv_column,
539                field.data_type()
540            )));
541        }
542        if !field.is_nullable() {
543            return Err(Error::generic(format!(
544                "dynamic scan: deletion-vector column `{}` must be nullable",
545                self.dv_column
546            )));
547        }
548
549        Ok(())
550    }
551
552    fn validate_required_column(
553        schema: &SchemaRef,
554        column: &ColumnName,
555        expected_type: &DataType,
556    ) -> DeltaResult<()> {
557        let fields = schema.fields_of_path(column)?;
558        let Some((field, ancestors)) = fields.split_last() else {
559            return Err(Error::internal_error("fields_of_path returned no fields"));
560        };
561        if field.data_type() != expected_type {
562            return Err(Error::generic(format!(
563                "dynamic scan: column `{column}` must have type {expected_type}, found {}",
564                field.data_type()
565            )));
566        }
567        if field.is_nullable() || ancestors.iter().any(|field| field.is_nullable()) {
568            return Err(Error::generic(format!(
569                "dynamic scan: required column `{column}` is nullable"
570            )));
571        }
572        Ok(())
573    }
574
575    fn validate_file_constant_columns(
576        input_schema: &SchemaRef,
577        output_schema: &SchemaRef,
578        file_constant_columns: &[String],
579    ) -> DeltaResult<()> {
580        for name in file_constant_columns {
581            let Some(input_field) = input_schema.field(name) else {
582                return Err(Error::generic(format!(
583                    "dynamic scan file_constant source: column `{name}` not found; schema has \
584                     {:?}",
585                    Vec::from_iter(input_schema.fields().map(|field| field.name())),
586                )));
587            };
588            if input_field.is_metadata_column() {
589                return Err(Error::generic(format!(
590                    "dynamic scan file_constant source: column `{name}` is a metadata column"
591                )));
592            }
593            let Some(output_field) = output_schema.field(name) else {
594                return Err(Error::generic(format!(
595                    "dynamic scan file_constant: column `{name}` not found; schema has {:?}",
596                    Vec::from_iter(output_schema.fields().map(|field| field.name())),
597                )));
598            };
599            if output_field.is_metadata_column() {
600                return Err(Error::generic(format!(
601                    "dynamic scan file_constant: column `{name}` is a metadata column"
602                )));
603            }
604            if input_field.data_type() != output_field.data_type()
605                || input_field.is_nullable() != output_field.is_nullable()
606            {
607                return Err(Error::generic(format!(
608                    "dynamic scan file_constant: column `{name}` must have the same type and \
609                     nullability in input and output"
610                )));
611            }
612        }
613        Ok(())
614    }
615}
616
617/// Groups input rows by `group_by` (a global aggregation over all rows when `group_by` is
618/// empty) and computes one output column per [`Agg`] in `aggs`. The output `schema` lists the
619/// group-by key columns first (in order), then the aggregate columns (in order).
620///
621/// Build an `Aggregate` with [`Aggregate::group_by`], which derives `schema` from the input
622/// schema -- including each output column's name, type, and nullability.
623///
624/// # Output schema
625///
626/// - **Group keys** pass through verbatim: each key column keeps its input type, nullability, and
627///   metadata.
628/// - **Aggregate columns**: name, type, and nullability come from each [`Agg`] (see per-function
629///   docs); use [`AggregateBuilder::aggregate_as`] to override the name. Aggregates that preserve
630///   the input type also preserve its field metadata; fixed-LONG aggregates emit a bare field.
631///
632/// # SQL equivalent
633///
634/// ```sql
635/// SELECT
636///     <group_by fields>,
637///     <aggs>
638/// FROM input
639/// GROUP BY <group_by fields>
640/// ```
641///
642/// SQL grouping uses null-safe equals, so `(NULL, b)`, `(a, NULL)`, and `(NULL, NULL)` are all
643/// different groups.
644///
645/// # Example
646///
647/// Each person's best and worst score across their bowling games:
648///
649/// ```text
650/// Aggregate {
651///     group_by: [name],
652///     aggs: [max(score) AS high, min(score) AS low],
653///     schema: { name: string, high: long, low: long },
654/// }
655/// ```
656///
657/// Input:
658///
659/// ```text
660/// name    | score
661/// --------+------
662///  Alice  | 140
663///  Bob    | 200
664///  Alice  | 180
665///  Bob    | 160
666///  Alice  | 155
667///  Charlie| 175
668/// ```
669///
670/// Output:
671///
672/// ```text
673/// name    | high | low
674/// --------+------+-----
675///  Bob    |  200 | 160
676///  Charlie|  175 | 175
677///  Alice  |  180 | 140
678/// ```
679///
680/// An ungrouped aggregate (`group_by` empty) always emits one row. Over empty input that row holds
681/// each agg's initial value (i.e. NULL for [`Agg::min`] and `0` for [`Agg::count`]). See individual
682/// [`Agg`] docs for per-function initial values and NULL-handling semantics.
683#[derive(Debug, Clone)]
684pub struct Aggregate {
685    /// Group-by key columns, emitted first in the output schema. Empty means a single global
686    /// group over all input rows.
687    pub group_by: Vec<ColumnName>,
688    /// The aggregate columns, emitted after the group keys in the output schema.
689    pub aggs: Vec<Agg>,
690    /// Output schema: group-by key columns followed by aggregate columns.
691    pub schema: SchemaRef,
692}
693
694impl Aggregate {
695    /// Starts building an ungrouped [`Aggregate`] over `input_schema`. Add aggregators directly
696    /// with [`aggregate`](AggregateBuilder::aggregate) or using named helpers
697    /// (e.g. [`max`](AggregateBuilder::max)), and finalize the aggregate by calling
698    /// [`build`](AggregateBuilder::build). The output schema follows aggregator insertion order.
699    pub fn ungrouped(input_schema: SchemaRef) -> AggregateBuilder {
700        Self::group_by(input_schema, std::iter::empty::<ColumnName>())
701    }
702
703    /// Starts building an [`Aggregate`] over `input_schema`, grouped by `grouping_keys`. Add
704    /// aggregators directly with [`aggregate`](AggregateBuilder::aggregate) or using named helpers
705    /// (e.g. [`max`](AggregateBuilder::max)), and finalize the aggregate by calling
706    /// [`build`](AggregateBuilder::build). Grouping keys are emitted first in the output schema,
707    /// followed by aggregators in insertion order.
708    pub fn group_by(
709        input_schema: SchemaRef,
710        grouping_keys: impl CollectInto<Vec<ColumnName>>,
711    ) -> AggregateBuilder {
712        AggregateBuilder {
713            input_schema,
714            group_by: grouping_keys.collect_into(),
715            aggs: Vec::new(),
716        }
717    }
718}
719
720/// An aggregate function and its operand column(s) within an [`Aggregate`] operator.
721#[derive(Debug, Clone)]
722pub enum Agg {
723    /// Operand for [`Agg::min`].
724    Min(ColumnName),
725    /// Operand for [`Agg::max`].
726    Max(ColumnName),
727    /// Operand for [`Agg::sum`].
728    Sum(ColumnName),
729    /// Operand for [`Agg::count`].
730    Count(ColumnName),
731    /// [`Agg::count_star`] has no operands.
732    CountStar,
733    /// Operands for [`Agg::min_non_null_by`].
734    MinNonNullBy(NonNullByOperands),
735    /// Operands for [`Agg::max_non_null_by`].
736    MaxNonNullBy(NonNullByOperands),
737}
738
739/// Operands for [`Agg::min_non_null_by`] and [`Agg::max_non_null_by`].
740#[derive(Debug, Clone)]
741pub struct NonNullByOperands {
742    pub value: ColumnName,
743    pub null_sentinel: ColumnName,
744    pub key: ColumnName,
745}
746
747impl Agg {
748    /// Like [`max`](Self::max), but selects the least non-NULL value in each group.
749    ///
750    /// ```text
751    /// [3, NULL, 5, 1] -> 1
752    /// [NULL, NULL]    -> NULL
753    /// []              -> NULL
754    /// ```
755    pub fn min(value: impl Into<ColumnName>) -> Self {
756        Self::Min(value.into())
757    }
758
759    /// The greatest non-NULL value in each group, or NULL if the group has no non-NULL value.
760    /// The output is always nullable, with name and type matching `value`.
761    ///
762    /// ```text
763    /// [3, NULL, 5, 1] -> 5
764    /// [NULL, NULL]    -> NULL
765    /// []              -> NULL
766    /// ```
767    pub fn max(value: impl Into<ColumnName>) -> Self {
768        Self::Max(value.into())
769    }
770
771    /// The sum of non-NULL LONG values in each group, or NULL if the group has no non-NULL value.
772    /// The output is always a nullable LONG, with default name matching `value`.
773    ///
774    /// ```text
775    /// [3, NULL, 5, 1] -> 9
776    /// [NULL, NULL]    -> NULL
777    /// []              -> NULL
778    /// ```
779    pub fn sum(value: impl Into<ColumnName>) -> Self {
780        Self::Sum(value.into())
781    }
782
783    /// The number of non-NULL values in `value` for each group. The output is always a non-nullable
784    /// LONG, with default name matching `value`.
785    ///
786    /// ```text
787    /// [3, NULL, 5, 1] -> 3
788    /// [NULL, NULL]    -> 0
789    /// []              -> 0
790    /// ```
791    pub fn count(value: impl Into<ColumnName>) -> Self {
792        Self::Count(value.into())
793    }
794
795    /// The number of input rows in each group (`COUNT(*)`). The output is always a non-nullable
796    /// LONG named `count` by default.
797    ///
798    /// ```text
799    /// [3, NULL, 5, 1] -> 4
800    /// [NULL, NULL]    -> 2
801    /// []              -> 0
802    /// ```
803    pub fn count_star() -> Self {
804        Self::CountStar
805    }
806
807    /// Like [`max_non_null_by`](Self::max_non_null_by), but selects the `value` from the qualifying
808    /// row with the *least* `key`.
809    pub fn min_non_null_by(
810        value: impl Into<ColumnName>,
811        null_sentinel: impl Into<ColumnName>,
812        key: impl Into<ColumnName>,
813    ) -> Self {
814        Self::MinNonNullBy(NonNullByOperands {
815            value: value.into(),
816            null_sentinel: null_sentinel.into(),
817            key: key.into(),
818        })
819    }
820
821    /// The `value` from a row with the greatest `key` where `null_sentinel` and `key` are both
822    /// non-NULL. Returns NULL if no qualifying row exists. A winning `value` may itself be NULL. It
823    /// is unspecified which of multiple rows with greatest `key` provides the winning `value`. The
824    /// output is always nullable, with name and type matching `value`.
825    ///
826    /// ```text
827    ///  key | sentinel | value  ->  NULL
828    /// -----+----------+------
829    ///    1 | present  | a
830    ///    3 | present  | c
831    ///    5 | present  | NULL       (greatest qualifying key; NULL value is retained)
832    ///    7 | NULL     | d          (ignored: NULL sentinel)
833    /// NULL | present  | e          (ignored: NULL key)
834    ///
835    /// (no rows)        ->  NULL
836    /// ```
837    ///
838    /// Most systems with a native `max_by` only provide a two-arg form that considers all rows with
839    /// non-NULL keys. The sentinel check can be added manually in one of two ways:
840    ///
841    /// ```sql
842    /// -- FILTER that drops NULL-sentinel rows before aggregating
843    /// max_by(value, key) FILTER (WHERE sentinel IS NOT NULL)
844    ///
845    /// -- NULL out the key when sentinel is NULL, which max_by then ignores. Use this where
846    /// -- FILTER is unavailable, such as a DataFrame API with no filtered-aggregate form.
847    /// max_by(value, CASE WHEN sentinel IS NOT NULL THEN key END)
848    /// ```
849    ///
850    /// In systems without `max_by`, it can also be expressed using window functions, with the
851    /// caveat that window functions don't work correctly for ungrouped aggs over empty input
852    /// (produces no rows when it should produce one row containing initial agg values):
853    ///
854    /// ```sql
855    /// SELECT
856    ///     <group_by columns>,
857    ///     value
858    /// FROM (
859    ///     SELECT
860    ///         value,
861    ///         <group_by columns>,
862    ///         ROW_NUMBER() OVER (
863    ///             PARTITION BY <group_by columns>
864    ///             ORDER BY key DESC
865    ///         ) AS rn
866    ///     FROM input
867    ///     WHERE key IS NOT NULL AND null_sentinel IS NOT NULL
868    /// ) WHERE rn = 1
869    /// ```
870    pub fn max_non_null_by(
871        value: impl Into<ColumnName>,
872        null_sentinel: impl Into<ColumnName>,
873        key: impl Into<ColumnName>,
874    ) -> Self {
875        Self::MaxNonNullBy(NonNullByOperands {
876            value: value.into(),
877            null_sentinel: null_sentinel.into(),
878            key: key.into(),
879        })
880    }
881
882    /// Derives this aggregate's output [`StructField`] over `input_schema`, validating that every
883    /// operand column resolves.
884    fn output_field(
885        &self,
886        input_schema: &StructType,
887        alias: Option<String>,
888    ) -> DeltaResult<StructField> {
889        // `output_data_type: None` preserves the input field's type and metadata; `Some` overrides
890        // the type and strips metadata (new column).
891        let resolve = |value: &ColumnName, output_data_type: Option<DataType>, nullable: bool| {
892            let field = input_schema.field_at(value)?;
893            let (data_type, metadata) = match output_data_type {
894                Some(data_type) => (data_type, HashMap::new()),
895                None => (field.data_type.clone(), field.metadata.clone()),
896            };
897            Ok(StructField {
898                // Without clone, we capture `alias` by value and `CountStar` arm can't use it
899                name: alias.clone().unwrap_or_else(|| field.name.clone()),
900                data_type,
901                metadata,
902                nullable,
903            })
904        };
905        match self {
906            Agg::Min(value) | Agg::Max(value) => resolve(value, None, true),
907            Agg::Sum(value) => resolve(value, Some(DataType::LONG), true),
908            Agg::Count(value) => resolve(value, Some(DataType::LONG), false),
909            Agg::CountStar => Ok(StructField::not_null(
910                alias.unwrap_or_else(|| "count".to_string()),
911                DataType::LONG,
912            )),
913            Agg::MinNonNullBy(operands) | Agg::MaxNonNullBy(operands) => {
914                let _ = input_schema.field_at(&operands.key)?;
915                let _ = input_schema.field_at(&operands.null_sentinel)?;
916                resolve(&operands.value, None, true)
917            }
918        }
919    }
920}
921
922/// Builds an [`Aggregate`] over an input schema, deriving the output schema from the group keys
923/// and aggregators.
924///
925/// Created by [`Aggregate::group_by`], which fixes the group keys. Aggregators are then collected
926/// by the named helpers or [`aggregate`](Self::aggregate); [`build`](Self::build) resolves keys and
927/// aggregators against the input schema; derives each output column's name, type and nullability
928/// from its [`Agg`] or group-by column; and validates that all output column names are unique.
929#[derive(Debug)]
930pub struct AggregateBuilder {
931    input_schema: SchemaRef,
932    group_by: Vec<ColumnName>,
933    aggs: Vec<(Agg, Option<String>)>,
934}
935
936impl AggregateBuilder {
937    /// Adds an aggregate column, emitted after the group keys in call order, using each [`Agg`]'s
938    /// default output name (see the per-function docs). Prefer the named helpers for the common
939    /// case (e.g. [`max`](Self::max); use [`aggregate_as`](Self::aggregate_as) to override the
940    /// output name.
941    pub fn aggregate(mut self, agg: Agg) -> Self {
942        self.aggs.push((agg, None));
943        self
944    }
945
946    /// Like [`aggregate`](Self::aggregate), but with the specified output name.
947    pub fn aggregate_as(mut self, agg: Agg, name: impl Into<String>) -> Self {
948        self.aggs.push((agg, Some(name.into())));
949        self
950    }
951
952    /// Adds an unaliased [`Agg::min`] over `value`.
953    pub fn min(self, value: impl Into<ColumnName>) -> Self {
954        self.aggregate(Agg::min(value))
955    }
956
957    /// Adds an unaliased [`Agg::max`] over `value`.
958    pub fn max(self, value: impl Into<ColumnName>) -> Self {
959        self.aggregate(Agg::max(value))
960    }
961
962    /// Adds an unaliased [`Agg::sum`] over `value`.
963    pub fn sum(self, value: impl Into<ColumnName>) -> Self {
964        self.aggregate(Agg::sum(value))
965    }
966
967    /// Adds an unaliased [`Agg::count`] over `value`.
968    pub fn count(self, value: impl Into<ColumnName>) -> Self {
969        self.aggregate(Agg::count(value))
970    }
971
972    /// Adds an unaliased [`Agg::count_star`].
973    pub fn count_star(self) -> Self {
974        self.aggregate(Agg::count_star())
975    }
976
977    /// Adds an unaliased [`Agg::min_non_null_by`] over `value`, qualifying rows with
978    /// `null_sentinel` and keyed on `key`.
979    pub fn min_non_null_by(
980        self,
981        value: impl Into<ColumnName>,
982        null_sentinel: impl Into<ColumnName>,
983        key: impl Into<ColumnName>,
984    ) -> Self {
985        self.aggregate(Agg::min_non_null_by(value, null_sentinel, key))
986    }
987
988    /// Adds an unaliased [`Agg::max_non_null_by`] over `value`, qualifying rows with
989    /// `null_sentinel` and keyed on `key`.
990    pub fn max_non_null_by(
991        self,
992        value: impl Into<ColumnName>,
993        null_sentinel: impl Into<ColumnName>,
994        key: impl Into<ColumnName>,
995    ) -> Self {
996        self.aggregate(Agg::max_non_null_by(value, null_sentinel, key))
997    }
998
999    /// Resolves group keys and aggregators against the input schema and builds the [`Aggregate`].
1000    ///
1001    /// # Errors
1002    ///
1003    /// Returns an error if a group key or an aggregate's operand column is not found in the input
1004    /// schema, or if two output columns would share a name (case-insensitive).
1005    pub fn build(self) -> DeltaResult<Aggregate> {
1006        let mut fields = Vec::with_capacity(self.group_by.len() + self.aggs.len());
1007        for key in &self.group_by {
1008            fields.push(self.input_schema.field_at(key)?.clone());
1009        }
1010        let mut aggs = Vec::with_capacity(self.aggs.len());
1011        for (agg, alias) in self.aggs {
1012            fields.push(agg.output_field(&self.input_schema, alias)?);
1013            aggs.push(agg);
1014        }
1015        // NOTE: `StructType::try_new` rejects duplicate (case-insensitive) output column names.
1016        Ok(Aggregate {
1017            group_by: self.group_by,
1018            aggs,
1019            schema: Arc::new(StructType::try_new(fields)?),
1020        })
1021    }
1022}
1023
1024impl TryFrom<AggregateBuilder> for Aggregate {
1025    type Error = Error;
1026
1027    fn try_from(builder: AggregateBuilder) -> DeltaResult<Self> {
1028        builder.build()
1029    }
1030}
1031
1032/// Performs a semi join between two inputs, `inputs.len() == 2`, the child
1033/// nodes are `[probe, build]` in this order. It emits a subset of probe rows;
1034/// the build side acts as a filter and never contributes columns. This is
1035/// analogous to a SQL `SEMI JOIN` (`inverted = false`) or `ANTI JOIN`
1036/// (`inverted = true`). A semi join finds all probe rows that are present in
1037/// the build side, and an anti join finds all probe rows **not** present in the
1038/// build side. This is analogous to set intersection and set difference,
1039/// respectively.
1040///
1041/// The output schema is the same as the probe input's schema.
1042///
1043/// # Example
1044///
1045/// ```text
1046/// SemiJoin { probe_keys: ["path"], build_keys: ["path"] }
1047///
1048/// probe               build
1049/// path | version      path
1050/// -----+--------      ----
1051///  a   |   1           b
1052///  b   |   2           d
1053///  c   |   3
1054///
1055/// output (inverted = false, semi join: probe rows whose path is in build):
1056/// path | version
1057/// -----+--------
1058///  b   |   2
1059///
1060/// output (inverted = true, anti join: probe rows whose path is not in build):
1061/// path | version
1062/// -----+--------
1063///  a   |   1
1064///  c   |   3
1065/// ```
1066#[derive(Debug, Clone)]
1067pub struct SemiJoin {
1068    pub inverted: bool,
1069    pub probe_keys: Vec<ColumnName>,
1070    pub build_keys: Vec<ColumnName>,
1071}
1072
1073/// The unordered bag union of N input relations. All rows of all inputs appear in the
1074/// output, in arbitrary order. All input schemas must agree, and the output schema
1075/// is the common schema of the inputs.
1076///
1077/// # Example
1078///
1079/// `UnionAll` over two relations with schema `{ id: int }`:
1080///
1081/// ```text
1082/// input 0:
1083/// id
1084/// --
1085///  1
1086///  2
1087///  3
1088///
1089/// input 1:
1090/// id
1091/// --
1092///  3
1093///  4
1094///  5
1095///
1096/// output (arbitrary order; bag semantics keep the duplicate 3):
1097/// id
1098/// --
1099///  4
1100///  1
1101///  3
1102///  2
1103///  5
1104///  3
1105/// ```
1106#[derive(Debug, Clone)]
1107pub struct UnionAll;
1108
1109#[cfg(test)]
1110mod tests {
1111    use delta_kernel_derive::{IntoStructData, ToSchema, TryFromStructData};
1112
1113    use super::*;
1114    use crate::expressions::column_name;
1115    use crate::schema::{DataType, MetadataValue, StructField};
1116    use crate::unit_test_utils::assert_result_error_with_message;
1117
1118    /// Builds a flat `LONG` schema from `(name, nullable)` pairs.
1119    fn schema(fields: &[(&str, bool)]) -> SchemaRef {
1120        Arc::new(StructType::new_unchecked(fields.iter().map(
1121            |(name, nullable)| StructField::new(*name, DataType::LONG, *nullable),
1122        )))
1123    }
1124
1125    #[test]
1126    fn output_lists_group_keys_then_aggregates_in_order() {
1127        let input = schema(&[("g", false), ("a", true), ("b", true)]);
1128        let agg = Aggregate::group_by(input, [column_name!("g")])
1129            .max(column_name!("a"))
1130            .min(column_name!("b"))
1131            .build()
1132            .unwrap();
1133        let names: Vec<&str> = agg.schema.fields().map(|f| f.name().as_str()).collect();
1134        assert_eq!(names, ["g", "a", "b"]);
1135    }
1136
1137    /// Group keys and type-preserving aggregates keep input field metadata; only nullability
1138    /// changes. Fixed-LONG aggregates build a fresh field and so carry no metadata.
1139    #[test]
1140    fn output_fields_preserve_input_field_metadata() {
1141        let metadata = [("k", MetadataValue::Number(7))];
1142        let input = Arc::new(StructType::new_unchecked([
1143            StructField::not_null("g", DataType::LONG).with_metadata(metadata.clone()),
1144            StructField::not_null("a", DataType::LONG).with_metadata(metadata.clone()),
1145            StructField::not_null("s", DataType::LONG).with_metadata(metadata),
1146        ]));
1147        let agg = Aggregate::group_by(input, [column_name!("g")])
1148            .max(column_name!("a"))
1149            .sum(column_name!("s"))
1150            .build()
1151            .unwrap();
1152
1153        let key = agg.schema.field("g").unwrap();
1154        assert!(!key.nullable);
1155        assert_eq!(key.metadata()["k"], MetadataValue::Number(7));
1156        let max = agg.schema.field("a").unwrap();
1157        assert!(max.nullable);
1158        assert_eq!(max.metadata()["k"], MetadataValue::Number(7));
1159        assert!(agg.schema.field("s").unwrap().metadata().is_empty());
1160    }
1161
1162    /// Output nullability is fixed by the aggregate kind, independent of input nullability.
1163    #[rstest::rstest]
1164    #[case::min(Agg::min(column_name!("a")), "a", true)]
1165    #[case::max(Agg::max(column_name!("a")), "a", true)]
1166    #[case::sum(Agg::sum(column_name!("a")), "a", true)]
1167    #[case::count(Agg::count(column_name!("a")), "a", false)]
1168    #[case::count_star(Agg::count_star(), "count", false)]
1169    #[case::min_non_null_by(
1170        Agg::min_non_null_by(column_name!("a"), column_name!("s"), column_name!("v")),
1171        "a",
1172        true
1173    )]
1174    #[case::max_non_null_by(
1175        Agg::max_non_null_by(column_name!("a"), column_name!("s"), column_name!("v")),
1176        "a",
1177        true
1178    )]
1179    fn agg_output_nullability(
1180        #[case] agg: Agg,
1181        #[case] name: &str,
1182        #[case] nullable: bool,
1183        #[values(true, false)] value_nullable: bool,
1184    ) {
1185        let input = schema(&[("a", value_nullable), ("s", true), ("v", true)]);
1186        let built = Aggregate::ungrouped(input).aggregate(agg).build().unwrap();
1187        let field = built.schema.field(name).unwrap();
1188        assert_eq!(field.nullable, nullable);
1189        assert_eq!(field.data_type(), &DataType::LONG);
1190    }
1191
1192    #[test]
1193    fn alias_overrides_default_output_name() {
1194        let input = schema(&[("a", true)]);
1195        let agg = Aggregate::group_by(input, [])
1196            .aggregate_as(Agg::max(column_name!("a")), "a_max")
1197            .build()
1198            .unwrap();
1199        assert!(agg.schema.field("a_max").is_some());
1200        assert!(agg.schema.field("a").is_none());
1201    }
1202
1203    #[test]
1204    fn duplicate_output_names_are_rejected() {
1205        let input = schema(&[("a", true)]);
1206        // min and max of the same column collide on the default name "a".
1207        let result = Aggregate::group_by(input, [])
1208            .min(column_name!("a"))
1209            .max(column_name!("a"))
1210            .build();
1211        assert_result_error_with_message(result, "Duplicate field name");
1212    }
1213
1214    #[test]
1215    fn distinct_aliases_resolve_min_max_collision() {
1216        let input = schema(&[("a", true)]);
1217        let agg = Aggregate::group_by(input, [])
1218            .aggregate_as(Agg::min(column_name!("a")), "a_min")
1219            .aggregate_as(Agg::max(column_name!("a")), "a_max")
1220            .build()
1221            .unwrap();
1222        let names: Vec<&str> = agg.schema.fields().map(|f| f.name().as_str()).collect();
1223        assert_eq!(names, ["a_min", "a_max"]);
1224    }
1225
1226    #[rstest::rstest]
1227    #[case::missing_value_column(false)]
1228    #[case::missing_group_key(true)]
1229    fn build_rejects_missing_column(#[case] missing_in_key: bool) {
1230        let input = schema(&[("a", true)]);
1231        let (keys, value) = if missing_in_key {
1232            (vec![column_name!("missing")], column_name!("a"))
1233        } else {
1234            (vec![], column_name!("missing"))
1235        };
1236        let result = Aggregate::group_by(input, keys).max(value).build();
1237        assert_result_error_with_message(result, "missing");
1238    }
1239
1240    /// A `*_non_null_by` aggregate whose key column is absent is rejected, even with no group keys
1241    /// (i.e. the validation does not ride on the grouped/nullability path).
1242    #[test]
1243    fn build_rejects_missing_non_null_by_key() {
1244        let input = schema(&[("a", true)]);
1245        let result = Aggregate::group_by(input, [])
1246            .max_non_null_by(
1247                column_name!("a"),
1248                column_name!("a"),
1249                column_name!("missing"),
1250            )
1251            .build();
1252        assert_result_error_with_message(result, "missing");
1253    }
1254
1255    #[test]
1256    fn build_rejects_missing_non_null_by_sentinel_column() {
1257        let input = schema(&[("a", true), ("v", true)]);
1258        let result = Aggregate::group_by(input, [])
1259            .max_non_null_by(
1260                column_name!("a"),
1261                column_name!("missing"),
1262                column_name!("v"),
1263            )
1264            .build();
1265        assert_result_error_with_message(result, "missing");
1266    }
1267
1268    #[derive(Clone, Debug, PartialEq, ToSchema, IntoStructData, TryFromStructData)]
1269    struct Address {
1270        city: String,
1271    }
1272
1273    #[derive(Clone, Debug, PartialEq, ToSchema, IntoStructData, TryFromStructData)]
1274    struct Person {
1275        id: i32,
1276        address: Address,
1277    }
1278
1279    #[test]
1280    fn values_from_iter_peels_top_level_and_keeps_nested_struct() {
1281        let values = Values::from_iter([Person {
1282            id: 1,
1283            address: Address { city: "NYC".into() },
1284        }]);
1285
1286        assert_eq!(
1287            values
1288                .schema
1289                .fields()
1290                .map(|f| f.name().as_str())
1291                .collect::<Vec<_>>(),
1292            ["id", "address"]
1293        );
1294        assert_eq!(values.rows.len(), 1);
1295        assert_eq!(values.rows[0].len(), 2);
1296        assert_eq!(values.rows[0][0], Scalar::Integer(1));
1297        let Scalar::Struct(address) = &values.rows[0][1] else {
1298            panic!("expected nested Struct for address");
1299        };
1300        assert_eq!(address.values(), &[Scalar::String("NYC".into())]);
1301    }
1302
1303    #[test]
1304    fn values_from_iter_empty_still_carries_schema() {
1305        let values: Values = std::iter::empty::<Person>().collect();
1306        assert!(values.rows.is_empty());
1307        assert_eq!(values.schema.num_fields(), 2);
1308    }
1309
1310    #[test]
1311    fn values_round_trips_through_vec() {
1312        let people = vec![
1313            Person {
1314                id: 1,
1315                address: Address { city: "NYC".into() },
1316            },
1317            Person {
1318                id: 2,
1319                address: Address { city: "SF".into() },
1320            },
1321        ];
1322        let values = Values::from_iter(people.clone());
1323        assert_eq!(Vec::<Person>::try_from(values).unwrap(), people);
1324    }
1325
1326    #[test]
1327    fn values_conversion_adds_row_index_to_error_path() {
1328        let mut values = Values::from_iter([Person {
1329            id: 1,
1330            address: Address { city: "NYC".into() },
1331        }]);
1332        values.rows[0][0] = Scalar::from("not an integer");
1333        assert_result_error_with_message(
1334            Vec::<Person>::try_from(values),
1335            "[0].id: expected i32, found string",
1336        );
1337    }
1338}