Skip to main content

vortex_layout/layouts/struct_/
writer.rs

1// SPDX-License-Identifier: Apache-2.0
2// SPDX-FileCopyrightText: Copyright the Vortex contributors
3
4//! A writer strategy for struct-typed arrays.
5//!
6//! [`StructStrategy`] transposes a stream of struct chunks into one ordered stream per field
7//! (plus a validity stream when the struct is nullable) and writes each through a configurable
8//! child strategy, producing a single [`StructLayout`]. It is a *structural* writer: it does not
9//! inspect child dtypes or resolve field-path overrides itself. Dispatching a child to the right
10//! layout kind is the job of the caller (see [`TableStrategy`]).
11//!
12//! [`TableStrategy`]: crate::layouts::table::TableStrategy
13
14use std::sync::Arc;
15
16use async_trait::async_trait;
17use futures::StreamExt;
18use futures::TryStreamExt;
19use futures::future::try_join;
20use futures::future::try_join_all;
21use futures::pin_mut;
22use itertools::Itertools;
23use vortex_array::ArrayRef;
24use vortex_array::IntoArray;
25use vortex_array::VortexSessionExecute;
26use vortex_array::arrays::StructArray;
27use vortex_array::arrays::struct_::StructArrayExt;
28use vortex_array::dtype::DType;
29use vortex_array::dtype::FieldName;
30use vortex_array::dtype::Nullability;
31use vortex_error::VortexError;
32use vortex_error::VortexResult;
33use vortex_error::vortex_bail;
34use vortex_io::kanal_ext::KanalExt;
35use vortex_io::session::RuntimeSessionExt;
36use vortex_session::VortexSession;
37use vortex_utils::aliases::DefaultHashBuilder;
38use vortex_utils::aliases::hash_map::HashMap;
39use vortex_utils::aliases::hash_set::HashSet;
40
41use crate::LayoutRef;
42use crate::LayoutStrategy;
43use crate::LayoutWriterContext;
44use crate::layouts::struct_::StructLayout;
45use crate::segments::SegmentSinkRef;
46use crate::sequence::SendableSequentialStream;
47use crate::sequence::SequenceId;
48use crate::sequence::SequencePointer;
49use crate::sequence::SequentialStreamAdapter;
50use crate::sequence::SequentialStreamExt;
51
52/// Writes struct-typed arrays into a [`StructLayout`], one child layout per field.
53///
54/// Each field is written through a strategy resolved by direct field name: an entry in
55/// `field_writers` if present, otherwise `default`. When the struct is nullable, its validity
56/// bitmap is written through `validity`.
57///
58/// `StructStrategy` is intentionally unaware of nested dtypes and field-path overrides. To write
59/// arbitrarily nested struct trees with per-path overrides, drive it from
60/// [`TableStrategy`][crate::layouts::table::TableStrategy], which dispatches on dtype and resolves
61/// the per-field child strategies before handing them here.
62#[derive(Clone)]
63pub struct StructStrategy {
64    /// Per-field child strategies, keyed by direct field name. Fields without an entry use
65    /// `default`.
66    field_writers: HashMap<FieldName, Arc<dyn LayoutStrategy>>,
67    /// Strategy for fields that have no entry in `field_writers`.
68    default: Arc<dyn LayoutStrategy>,
69    /// Strategy for the struct's own validity bitmap, used only when the struct is nullable.
70    validity: Arc<dyn LayoutStrategy>,
71}
72
73impl StructStrategy {
74    /// Create a new struct writer that writes every field through `default` and the validity
75    /// bitmap (when present) through `validity`.
76    pub fn new(validity: Arc<dyn LayoutStrategy>, default: Arc<dyn LayoutStrategy>) -> Self {
77        Self {
78            field_writers: HashMap::default(),
79            default,
80            validity,
81        }
82    }
83
84    /// Override the strategy for a single field by name.
85    pub fn with_field_writer(
86        mut self,
87        name: impl Into<FieldName>,
88        writer: Arc<dyn LayoutStrategy>,
89    ) -> Self {
90        self.field_writers.insert(name.into(), writer);
91        self
92    }
93
94    /// Override the strategy for several fields by name at once.
95    pub fn with_field_writers(
96        mut self,
97        writers: impl IntoIterator<Item = (FieldName, Arc<dyn LayoutStrategy>)>,
98    ) -> Self {
99        self.field_writers.extend(writers);
100        self
101    }
102}
103
104#[async_trait]
105impl LayoutStrategy for StructStrategy {
106    async fn write_stream(
107        &self,
108        ctx: LayoutWriterContext,
109        segment_sink: SegmentSinkRef,
110        stream: SendableSequentialStream,
111        mut eof: SequencePointer,
112        session: &VortexSession,
113    ) -> VortexResult<LayoutRef> {
114        let dtype = stream.dtype().clone();
115
116        let Some(struct_dtype) = dtype.as_struct_fields_opt() else {
117            vortex_bail!("StructStrategy can only write struct-typed streams, got {dtype}");
118        };
119
120        // Check for unique field names at write time.
121        if HashSet::<_, DefaultHashBuilder>::from_iter(struct_dtype.names().iter()).len()
122            != struct_dtype.names().len()
123        {
124            vortex_bail!("StructLayout must have unique field names");
125        }
126        let is_nullable = dtype.is_nullable();
127
128        // Optimization: when there are no fields, don't spawn any work and just write a trivial
129        // StructLayout.
130        if struct_dtype.nfields() == 0 && !is_nullable {
131            let row_count = stream
132                .try_fold(
133                    0u64,
134                    |acc, (_, arr)| async move { Ok(acc + arr.len() as u64) },
135                )
136                .await?;
137            return Ok(StructLayout::new(row_count, dtype, vec![]).into_layout());
138        }
139
140        // stream<struct_chunk> -> stream<vec<column_chunk>>
141        let columns_session = session.clone();
142        let columns_vec_stream = stream.map(move |chunk| {
143            let (sequence_id, chunk) = chunk?;
144            let mut sequence_pointer = sequence_id.descend();
145            let mut ctx = columns_session.create_execution_ctx();
146            let struct_chunk = chunk.clone().execute::<StructArray>(&mut ctx)?;
147            let mut columns: Vec<(SequenceId, ArrayRef)> = Vec::new();
148            if is_nullable {
149                columns.push((
150                    sequence_pointer.advance(),
151                    chunk
152                        .validity()?
153                        .execute_mask(chunk.len(), &mut ctx)?
154                        .into_array(),
155                ));
156            }
157
158            columns.extend(
159                struct_chunk
160                    .iter_unmasked_fields()
161                    .map(|field| (sequence_pointer.advance(), field.clone())),
162            );
163
164            Ok(columns)
165        });
166
167        let mut stream_count = struct_dtype.nfields();
168        if is_nullable {
169            stream_count += 1;
170        }
171
172        let (column_streams_tx, column_streams_rx): (Vec<_>, Vec<_>) =
173            (0..stream_count).map(|_| kanal::bounded_async(1)).unzip();
174
175        // Fan out column chunks to their respective transposed streams. Keep this future joined
176        // with the column writers so producer panics/errors cannot be hidden as channel EOF.
177        let handle = session.handle();
178        let fanout_fut = async move {
179            pin_mut!(columns_vec_stream);
180            while let Some(result) = columns_vec_stream.next().await {
181                match result {
182                    Ok(columns) => {
183                        for (tx, column) in column_streams_tx.iter().zip_eq(columns) {
184                            if tx.send(Ok(column)).await.is_err() {
185                                vortex_bail!(
186                                    "struct column writer finished before all chunks were sent"
187                                );
188                            }
189                        }
190                    }
191                    Err(e) => {
192                        let e: Arc<VortexError> = Arc::new(e);
193                        for tx in column_streams_tx.iter() {
194                            let _ = tx.send(Err(VortexError::from(Arc::clone(&e)))).await;
195                        }
196                        return Err(VortexError::from(e));
197                    }
198                }
199            }
200            Ok(())
201        };
202
203        // First child column is the validity, subsequent children are the individual struct fields
204        let column_dtypes: Vec<DType> = if is_nullable {
205            std::iter::once(DType::Bool(Nullability::NonNullable))
206                .chain(struct_dtype.fields())
207                .collect()
208        } else {
209            struct_dtype.fields().collect()
210        };
211
212        let column_names: Vec<FieldName> = if is_nullable {
213            std::iter::once(FieldName::from("__validity"))
214                .chain(struct_dtype.names().iter().cloned())
215                .collect()
216        } else {
217            struct_dtype.names().iter().cloned().collect()
218        };
219
220        let layout_futures: Vec<_> = column_dtypes
221            .into_iter()
222            .zip_eq(column_streams_rx)
223            .zip_eq(column_names)
224            .enumerate()
225            .map(move |(index, ((dtype, recv), name))| {
226                let column_stream =
227                    SequentialStreamAdapter::new(dtype, recv.into_stream().boxed()).sendable();
228                let child_eof = eof.split_off();
229                let session = session.clone();
230                let is_validity = index == 0 && is_nullable;
231                // Fields are written unmasked, so a nullable struct hides some of their values
232                // behind its own validity. Tell the field writers so they omit statistics that
233                // those hidden values would corrupt.
234                let ctx = if is_nullable && !is_validity {
235                    ctx.clone().with_nullable_ancestor()
236                } else {
237                    ctx.clone()
238                };
239                let segment_sink = Arc::clone(&segment_sink);
240                handle.spawn_nested(move |_| {
241                    // Validity is written through the validity strategy; every other field
242                    // resolves to its named override or the default strategy.
243                    let writer = if is_validity {
244                        Arc::clone(&self.validity)
245                    } else {
246                        self.field_writers
247                            .get(&name)
248                            .cloned()
249                            .unwrap_or_else(|| Arc::clone(&self.default))
250                    };
251
252                    async move {
253                        writer
254                            .write_stream(ctx, segment_sink, column_stream, child_eof, &session)
255                            .await
256                    }
257                })
258            })
259            .collect();
260
261        let (_success, column_layouts) = try_join(fanout_fut, try_join_all(layout_futures)).await?;
262        // TODO(os): transposed stream could count row counts as well,
263        // This must hold though, all columns must have the same row count of the struct layout
264        let row_count = column_layouts.first().map(|l| l.row_count()).unwrap_or(0);
265        Ok(StructLayout::new(row_count, dtype, column_layouts).into_layout())
266    }
267}
268
269#[cfg(test)]
270mod tests {
271    use std::num::NonZeroUsize;
272
273    use vortex_array::ArrayContext;
274    use vortex_array::arrays::BoolArray;
275    use vortex_array::validity::Validity;
276    use vortex_buffer::buffer;
277    use vortex_error::VortexExpect;
278    use vortex_io::runtime::single::block_on;
279    use vortex_io::session::RuntimeSessionExt;
280
281    use super::*;
282    use crate::layouts::chunked::writer::ChunkedLayoutStrategy;
283    use crate::layouts::flat::writer::FlatLayoutStrategy;
284    use crate::layouts::table::TableStrategy;
285    use crate::layouts::zoned::Zoned;
286    use crate::layouts::zoned::writer::ZonedLayoutOptions;
287    use crate::layouts::zoned::writer::ZonedStrategy;
288    use crate::segments::TestSegments;
289    use crate::sequence::SequentialArrayStreamExt;
290    use crate::test::new_session;
291
292    /// Write a single-field struct with zoned fields, returning whether the field got a zone map.
293    fn field_has_zone_map(validity: Validity) -> VortexResult<bool> {
294        let strategy = StructStrategy::new(
295            Arc::new(FlatLayoutStrategy::default()),
296            Arc::new(ZonedStrategy::new(
297                ChunkedLayoutStrategy::new(FlatLayoutStrategy::default()),
298                FlatLayoutStrategy::default(),
299                ZonedLayoutOptions {
300                    block_size: NonZeroUsize::new(3).vortex_expect("non zero"),
301                    ..Default::default()
302                },
303            )),
304        );
305        let is_nullable = validity.nullability().is_nullable();
306        let (ptr, eof) = SequenceId::root().split();
307        let stream = StructArray::try_from_iter_with_validity(
308            [("a", buffer![1, 2, 3].into_array())],
309            validity,
310        )?
311        .into_array()
312        .to_array_stream()
313        .sequenced(ptr);
314
315        let layout = block_on(|handle| async move {
316            let session = new_session().with_handle(handle);
317            strategy
318                .write_stream(
319                    LayoutWriterContext::new(ArrayContext::empty()),
320                    Arc::new(TestSegments::default()),
321                    stream,
322                    eof,
323                    &session,
324                )
325                .await
326        })?;
327
328        // The validity child, when present, precedes the fields.
329        Ok(layout.children()?[usize::from(is_nullable)].is::<Zoned>())
330    }
331
332    /// Write `array` through a [`TableStrategy`] with zoned leaves, returning the layout.
333    fn write_table(array: StructArray) -> VortexResult<LayoutRef> {
334        let strategy = TableStrategy::new(
335            Arc::new(FlatLayoutStrategy::default()),
336            Arc::new(ZonedStrategy::new(
337                ChunkedLayoutStrategy::new(FlatLayoutStrategy::default()),
338                FlatLayoutStrategy::default(),
339                ZonedLayoutOptions {
340                    block_size: NonZeroUsize::new(3).vortex_expect("non zero"),
341                    ..Default::default()
342                },
343            )),
344        );
345        let (ptr, eof) = SequenceId::root().split();
346        let stream = array.into_array().to_array_stream().sequenced(ptr);
347        block_on(|handle| async move {
348            let session = new_session().with_handle(handle);
349            strategy
350                .write_stream(
351                    LayoutWriterContext::new(ArrayContext::empty()),
352                    Arc::new(TestSegments::default()),
353                    stream,
354                    eof,
355                    &session,
356                )
357                .await
358        })
359    }
360
361    #[test]
362    fn non_nullable_struct_fields_get_a_zone_map() -> VortexResult<()> {
363        assert!(field_has_zone_map(Validity::NonNullable)?);
364        Ok(())
365    }
366
367    /// The field holds values in rows the struct nulls out, so no aggregate over it is exact for
368    /// the logical column and no zone map is written.
369    #[test]
370    fn nullable_struct_fields_get_no_zone_map() -> VortexResult<()> {
371        assert!(!field_has_zone_map(Validity::Array(
372            BoolArray::from_iter([false, true, true]).into_array(),
373        ))?);
374        Ok(())
375    }
376
377    /// A nullable struct anywhere above a leaf hides its values, however deep the leaf sits, while
378    /// leaves beside a nullable struct are unaffected.
379    #[test]
380    fn nullable_ancestor_propagates_through_nested_structs() -> VortexResult<()> {
381        let some_nulls = || Validity::Array(BoolArray::from_iter([false, true, true]).into_array());
382
383        // Nullable outer struct, non-nullable inner struct: the inner leaf is still hidden.
384        let outer_nullable = StructArray::try_from_iter_with_validity(
385            [(
386                "inner",
387                StructArray::try_from_iter([("a", buffer![1, 2, 3].into_array())])?.into_array(),
388            )],
389            some_nulls(),
390        )?;
391        let layout = write_table(outer_nullable)?;
392        let inner = &layout.children()?[1];
393        assert!(!inner.children()?[0].is::<Zoned>());
394
395        // Non-nullable outer struct: a leaf beside the nullable inner struct keeps its zone map,
396        // the leaf below it loses it.
397        let inner_nullable = StructArray::try_from_iter([
398            ("sibling", buffer![1, 2, 3].into_array()),
399            (
400                "inner",
401                StructArray::try_from_iter_with_validity(
402                    [("a", buffer![1, 2, 3].into_array())],
403                    some_nulls(),
404                )?
405                .into_array(),
406            ),
407        ])?;
408        let layout = write_table(inner_nullable)?;
409        let children = layout.children()?;
410        assert!(children[0].is::<Zoned>());
411        assert!(!children[1].children()?[1].is::<Zoned>());
412        Ok(())
413    }
414
415    /// Wrap `leaf` in `depth` non-nullable single-field structs named `l0`, `l1`, ...
416    fn nest(leaf: ArrayRef, depth: usize) -> VortexResult<ArrayRef> {
417        (0..depth).try_fold(leaf, |inner, level| {
418            Ok(StructArray::try_from_iter([(format!("l{level}"), inner)])?.into_array())
419        })
420    }
421
422    /// Descend `depth` single-field non-nullable struct layouts to their leaf.
423    fn descend(layout: &LayoutRef, depth: usize) -> VortexResult<LayoutRef> {
424        (0..depth).try_fold(Arc::clone(layout), |layout, _| {
425            Ok(Arc::clone(&layout.children()?[0]))
426        })
427    }
428
429    /// The flag survives any number of non-nullable struct levels below the nullable one, and is
430    /// only set at the nullable level, not above it.
431    #[test]
432    fn nullable_ancestor_propagates_through_deep_nesting() -> VortexResult<()> {
433        const DEPTH: usize = 6;
434        let some_nulls = || Validity::Array(BoolArray::from_iter([false, true, true]).into_array());
435
436        // Nullable root over DEPTH non-nullable levels.
437        let root = StructArray::try_from_iter_with_validity(
438            [("top", nest(buffer![1, 2, 3].into_array(), DEPTH)?)],
439            some_nulls(),
440        )?;
441        let layout = write_table(root)?;
442        // Skip the root's validity child, then walk every non-nullable level to the leaf.
443        let leaf = descend(&layout.children()?[1], DEPTH)?;
444        assert!(!leaf.is::<Zoned>());
445
446        // Non-nullable levels above, a nullable struct in the middle, non-nullable levels below.
447        // A leaf above the nullable level keeps its zone map, the leaf below it does not.
448        let middle = StructArray::try_from_iter_with_validity(
449            [("below", nest(buffer![1, 2, 3].into_array(), DEPTH)?)],
450            some_nulls(),
451        )?;
452        let root = nest(
453            StructArray::try_from_iter([
454                ("above", buffer![1, 2, 3].into_array()),
455                ("middle", middle.into_array()),
456            ])?
457            .into_array(),
458            DEPTH,
459        )?
460        .execute::<StructArray>(&mut new_session().create_execution_ctx())?;
461        let layout = write_table(root)?;
462        let holder = descend(&layout, DEPTH)?;
463        let holder_children = holder.children()?;
464        assert!(holder_children[0].is::<Zoned>());
465        // The nullable middle struct's children are `[validity, below]`.
466        let leaf = descend(&holder_children[1].children()?[1], DEPTH)?;
467        assert!(!leaf.is::<Zoned>());
468        Ok(())
469    }
470}