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}