Skip to main content

vortex_layout/layouts/
table.rs

1// SPDX-License-Identifier: Apache-2.0
2// SPDX-FileCopyrightText: Copyright the Vortex contributors
3
4//! A configurable writer strategy for tabular data.
5//!
6//! [`TableStrategy`] is a *dispatcher*: it inspects the dtype of the stream it is handed and
7//! routes struct columns to [`StructStrategy`], list columns to [`ListLayoutStrategy`], and
8//! everything else to the configured leaf strategy. Because it hands *itself* (suitably descended)
9//! to those structural writers as the strategy for their children, arbitrarily nested struct/list
10//! trees are written with no manual wiring.
11//!
12//! The dispatcher also owns field-path overrides, letting callers force a specific leaf field —
13//! at any depth — onto a custom strategy.
14
15use std::env;
16use std::sync::Arc;
17use std::sync::LazyLock;
18
19use async_trait::async_trait;
20use vortex_array::ArrayContext;
21use vortex_array::dtype::Field;
22use vortex_array::dtype::FieldName;
23use vortex_array::dtype::FieldPath;
24use vortex_error::VortexResult;
25use vortex_session::VortexSession;
26use vortex_utils::aliases::hash_map::HashMap;
27use vortex_utils::aliases::hash_set::HashSet;
28
29use crate::LayoutRef;
30use crate::LayoutStrategy;
31use crate::layouts::list::writer::ListLayoutStrategy;
32use crate::layouts::struct_::StructStrategy;
33use crate::segments::SegmentSinkRef;
34use crate::sequence::SendableSequentialStream;
35use crate::sequence::SequencePointer;
36
37/// Whether [`TableStrategy`] writes list fields using a [`ListLayoutStrategy`] by
38/// default. Disabled unless the environment variable `VORTEX_EXPERIMENTAL_LIST_LAYOUT`
39/// is set to `1`.
40///
41/// [`ListLayoutStrategy`]: crate::layouts::list::writer::ListLayoutStrategy
42pub fn use_experimental_list_layout() -> bool {
43    static USE_EXPERIMENTAL_LIST_LAYOUT: LazyLock<bool> =
44        LazyLock::new(|| env::var("VORTEX_EXPERIMENTAL_LIST_LAYOUT").is_ok_and(|v| v == "1"));
45    *USE_EXPERIMENTAL_LIST_LAYOUT
46}
47
48type ListLayoutFactory = Arc<dyn Fn(ListLayoutStrategy) -> Arc<dyn LayoutStrategy> + Send + Sync>;
49
50/// A configurable strategy for writing nested tabular data, dispatching each (sub)stream to the
51/// structural writer for its dtype.
52///
53/// Dispatch rules, applied to the dtype of the stream handed to [`write_stream`]:
54/// - **struct** → [`StructStrategy`], with each field written by its override (if any) or by a
55///   descended copy of this dispatcher.
56/// - **list** → [`ListLayoutStrategy`], with `elements` written by a descended copy of this
57///   dispatcher (so nested structs/lists recurse) and `offsets`/`validity` by the leaf/validity
58///   strategies. Gated: only when list decomposition is enabled via
59///   [`with_list_layout`][Self::with_list_layout] (off by default); otherwise a list falls through
60///   to the leaf strategy.
61/// - **anything else** → the leaf strategy.
62///
63/// [`write_stream`]: LayoutStrategy::write_stream
64pub struct TableStrategy {
65    /// A set of field-path overrides, e.g. to force one column to be compact-compressed. Keys are
66    /// paths relative to the level this dispatcher sits at.
67    leaf_writers: HashMap<FieldPath, Arc<dyn LayoutStrategy>>,
68    /// The writer for any validity arrays that may be present, at any level of the tree.
69    validity: Arc<dyn LayoutStrategy>,
70    /// The writer for leaf fields, i.e. anything that is not a struct.
71    leaf: Arc<dyn LayoutStrategy>,
72    /// Optional factory applied to each dynamically constructed [`ListLayoutStrategy`].
73    /// Its presence also enables list decomposition.
74    ///
75    /// [`ListLayoutStrategy`]: crate::layouts::list::writer::ListLayoutStrategy
76    list_layout_factory: Option<ListLayoutFactory>,
77}
78
79impl TableStrategy {
80    /// Create a new dispatcher with the given `validity` strategy and `fallback` leaf strategy and
81    /// no overrides.
82    ///
83    /// Additional per-field overrides can be configured with
84    /// [`with_field_writer`][Self::with_field_writer].
85    ///
86    /// ## Example
87    ///
88    /// ```ignore
89    /// # use std::sync::Arc;
90    /// # use vortex_layout::layouts::flat::writer::FlatLayoutStrategy;
91    /// # use vortex_layout::layouts::table::TableStrategy;
92    ///
93    /// // Build a write strategy that does not compress validity or any leaf fields.
94    /// let flat = Arc::new(FlatLayoutStrategy::default());
95    ///
96    /// let strategy = TableStrategy::new(Arc::<FlatLayoutStrategy>::clone(&flat), Arc::<FlatLayoutStrategy>::clone(&flat));
97    /// ```
98    pub fn new(validity: Arc<dyn LayoutStrategy>, fallback: Arc<dyn LayoutStrategy>) -> Self {
99        Self {
100            leaf_writers: Default::default(),
101            validity,
102            leaf: fallback,
103            list_layout_factory: None,
104        }
105    }
106
107    /// Add a custom write strategy for the given leaf field.
108    ///
109    /// ## Example
110    ///
111    /// ```ignore
112    /// # use std::sync::Arc;
113    /// # use vortex_array::dtype::{field_path, Field, FieldPath};
114    /// # use vortex_btrblocks::BtrBlocksCompressor;
115    /// # use vortex_layout::layouts::compressed::CompressingStrategy;
116    /// # use vortex_layout::layouts::flat::writer::FlatLayoutStrategy;
117    /// # use vortex_layout::layouts::table::TableStrategy;
118    ///
119    /// // A strategy for compressing data using the balanced BtrBlocks compressor.
120    /// let compress =
121    ///     CompressingStrategy::new(FlatLayoutStrategy::default(), BtrBlocksCompressor::default());
122    ///
123    /// // Our combined strategy uses no compression for validity buffers, BtrBlocks compression
124    /// // for most columns, and stores a nested binary column uncompressed (flat) because it
125    /// // is pre-compressed or never filtered on.
126    /// let strategy = TableStrategy::new(
127    ///         Arc::new(FlatLayoutStrategy::default()),
128    ///         Arc::new(compress),
129    ///     )
130    ///     .with_field_writer(
131    ///         field_path!(request.body.bytes),
132    ///         Arc::new(FlatLayoutStrategy::default()),
133    ///     );
134    /// ```
135    pub fn with_field_writer(
136        mut self,
137        field_path: impl Into<FieldPath>,
138        writer: Arc<dyn LayoutStrategy>,
139    ) -> Self {
140        self.leaf_writers
141            .insert(self.validate_path(field_path.into()), writer);
142        self
143    }
144
145    /// Set writers for several fields at once.
146    ///
147    /// See also: [`with_field_writer`][Self::with_field_writer].
148    pub fn with_field_writers(
149        mut self,
150        writers: impl IntoIterator<Item = (FieldPath, Arc<dyn LayoutStrategy>)>,
151    ) -> Self {
152        for (field_path, strategy) in writers {
153            self.leaf_writers
154                .insert(self.validate_path(field_path), strategy);
155        }
156        self
157    }
158
159    /// Override the default strategy for leaf columns that don't have overrides.
160    pub fn with_default_strategy(mut self, default: Arc<dyn LayoutStrategy>) -> Self {
161        self.leaf = default;
162        self
163    }
164
165    /// Override the strategy for compressing struct validity at all levels of the schema tree.
166    pub fn with_validity_strategy(mut self, validity: Arc<dyn LayoutStrategy>) -> Self {
167        self.validity = validity;
168        self
169    }
170
171    /// Enable writing list fields with [`ListLayoutStrategy`].
172    ///
173    /// **Note**: this is an unstable and experimental layout that is expected to change.
174    /// Using it may lead to unreadable files in the future.
175    pub fn with_list_layout(self) -> Self {
176        self.with_list_layout_factory(|strategy| Arc::new(strategy))
177    }
178
179    /// Enable writing list fields with [`ListLayoutStrategy`] and wrap each list writer. This
180    /// allows repartitioning or zoning to operate in the list's outer-row space before shredding.
181    ///
182    /// **Note**: this is an unstable and experimental layout that is expected to change.
183    /// Using it may lead to unreadable files in the future.
184    pub fn with_list_layout_factory(
185        mut self,
186        factory: impl Fn(ListLayoutStrategy) -> Arc<dyn LayoutStrategy> + Send + Sync + 'static,
187    ) -> Self {
188        self.list_layout_factory = Some(Arc::new(factory));
189        self
190    }
191}
192
193impl TableStrategy {
194    /// Build the [`StructStrategy`] used to write a struct-typed stream at this level.
195    ///
196    /// Each field that has an override (or a deeper override beneath it) is resolved up front;
197    /// every other field falls through to a clean descended dispatcher.
198    fn struct_strategy(&self) -> StructStrategy {
199        let mut field_writers: HashMap<FieldName, Arc<dyn LayoutStrategy>> = HashMap::default();
200
201        // The distinct named first-segments of our override paths are the only fields that need
202        // anything other than the default dispatcher.
203        let mut named_first: HashSet<FieldName> = HashSet::default();
204        for path in self.leaf_writers.keys() {
205            if let Some(Field::Name(name)) = path.parts().first() {
206                named_first.insert(name.clone());
207            }
208        }
209
210        for name in named_first {
211            // `validate_path` forbids overlapping overrides, so a name has *either* an exact
212            // single-segment override *or* deeper overrides, never both.
213            let writer = match self.leaf_writers.get(&FieldPath::from_name(name.clone())) {
214                Some(exact) => Arc::clone(exact),
215                None => {
216                    Arc::new(self.descend(&Field::Name(name.clone()))) as Arc<dyn LayoutStrategy>
217                }
218            };
219            field_writers.insert(name, writer);
220        }
221
222        StructStrategy::new(Arc::clone(&self.validity), Arc::new(self.descend_clean()))
223            .with_field_writers(field_writers)
224    }
225
226    /// Build the [`ListLayoutStrategy`] used to write a list field stream at this level.
227    ///
228    /// The `elements` sub-column is routed back through a clean descended dispatcher so nested
229    /// structs/lists recurse; `offsets` go straight to the leaf (they are always a primitive
230    /// column); and `validity` uses the shared validity strategy.
231    fn list_strategy(&self) -> Option<Arc<dyn LayoutStrategy>> {
232        let factory = self.list_layout_factory.as_ref()?;
233        let list_layout = ListLayoutStrategy::default()
234            .with_elements(Arc::new(self.descend_clean()))
235            .with_offsets(Arc::clone(&self.leaf))
236            .with_validity(Arc::clone(&self.validity))
237            .with_fallback(Arc::clone(&self.leaf));
238        Some(factory(list_layout))
239    }
240
241    /// Descend into a subfield, retaining only the overrides that apply beneath it (rebased to be
242    /// relative to the child).
243    fn descend(&self, field: &Field) -> Self {
244        let mut new_writers = HashMap::with_capacity(self.leaf_writers.len());
245
246        for (field_path, strategy) in &self.leaf_writers {
247            if field_path.parts().first() == Some(field)
248                && let Some(subpath) = field_path.clone().step_into()
249                && !subpath.is_root()
250            {
251                new_writers.insert(subpath, Arc::clone(strategy));
252            }
253        }
254
255        Self {
256            leaf_writers: new_writers,
257            validity: Arc::clone(&self.validity),
258            leaf: Arc::clone(&self.leaf),
259            list_layout_factory: self.list_layout_factory.clone(),
260        }
261    }
262
263    /// A copy of this dispatcher with no overrides, used as the default child strategy for fields
264    /// that carry no override.
265    fn descend_clean(&self) -> Self {
266        Self {
267            leaf_writers: HashMap::default(),
268            validity: Arc::clone(&self.validity),
269            leaf: Arc::clone(&self.leaf),
270            list_layout_factory: self.list_layout_factory.clone(),
271        }
272    }
273
274    fn validate_path(&self, path: FieldPath) -> FieldPath {
275        assert!(
276            !path.is_root(),
277            "Do not set override as a root strategy, instead set the default strategy"
278        );
279
280        // Validate that the field path does not conflict with any overrides
281        // that we've added by overlapping.
282        for field_path in self.leaf_writers.keys() {
283            assert!(
284                !path.overlap(field_path),
285                "Override for field_path {path} conflicts with existing override for {field_path}"
286            );
287        }
288
289        path
290    }
291}
292
293/// Dispatches each stream to the structural writer for its dtype.
294#[async_trait]
295impl LayoutStrategy for TableStrategy {
296    async fn write_stream(
297        &self,
298        ctx: ArrayContext,
299        segment_sink: SegmentSinkRef,
300        stream: SendableSequentialStream,
301        eof: SequencePointer,
302        session: &VortexSession,
303    ) -> VortexResult<LayoutRef> {
304        let dtype = stream.dtype().clone();
305
306        if dtype.is_struct() {
307            return self
308                .struct_strategy()
309                .write_stream(ctx, segment_sink, stream, eof, session)
310                .await;
311        }
312
313        if dtype.is_list()
314            && let Some(list_strategy) = self.list_strategy()
315        {
316            return list_strategy
317                .write_stream(ctx, segment_sink, stream, eof, session)
318                .await;
319        }
320
321        // Leaf: hand off to the leaf strategy.
322        self.leaf
323            .write_stream(ctx, segment_sink, stream, eof, session)
324            .await
325    }
326}
327
328#[cfg(test)]
329mod tests {
330    use std::num::NonZeroUsize;
331    use std::sync::Arc;
332    use std::task::Poll;
333
334    use vortex_array::ArrayContext;
335    use vortex_array::ArrayRef;
336    use vortex_array::IntoArray;
337    use vortex_array::arrays::BoolArray;
338    use vortex_array::arrays::ChunkedArray;
339    use vortex_array::arrays::ListArray;
340    use vortex_array::arrays::PrimitiveArray;
341    use vortex_array::arrays::StructArray;
342    use vortex_array::dtype::DType;
343    use vortex_array::dtype::FieldPath;
344    use vortex_array::dtype::Nullability;
345    use vortex_array::dtype::PType;
346    use vortex_array::dtype::StructFields;
347    use vortex_array::field_path;
348    use vortex_array::validity::Validity;
349    use vortex_buffer::buffer;
350    use vortex_error::VortexExpect;
351    use vortex_error::VortexResult;
352
353    use crate::LayoutRef;
354    use crate::LayoutStrategy;
355    use crate::layouts::chunked::writer::ChunkedLayoutStrategy;
356    use crate::layouts::flat::writer::FlatLayoutStrategy;
357    use crate::layouts::list::List;
358    use crate::layouts::repartition::RepartitionStrategy;
359    use crate::layouts::repartition::RepartitionWriterOptions;
360    use crate::layouts::table::TableStrategy;
361    use crate::layouts::zoned::Zoned;
362    use crate::layouts::zoned::writer::ZonedLayoutOptions;
363    use crate::layouts::zoned::writer::ZonedStrategy;
364    use crate::segments::TestSegments;
365    use crate::sequence::SequenceId;
366    use crate::sequence::SequentialArrayStreamExt;
367    use crate::sequence::SequentialStreamAdapter;
368    use crate::sequence::SequentialStreamExt;
369    use crate::test::SESSION;
370
371    async fn write<S: LayoutStrategy>(strategy: &S, array: ArrayRef) -> VortexResult<LayoutRef> {
372        let segments = Arc::new(TestSegments::default());
373        let (ptr, eof) = SequenceId::root().split();
374        let stream = array.to_array_stream().sequenced(ptr);
375        strategy
376            .write_stream(ArrayContext::empty(), segments, stream, eof, &SESSION)
377            .await
378    }
379
380    /// A plain table dispatcher with no overrides. `flat` here is both the validity and leaf
381    /// strategy.
382    fn flat_table() -> TableStrategy {
383        let flat: Arc<dyn LayoutStrategy> = Arc::new(FlatLayoutStrategy::default());
384        TableStrategy::new(Arc::clone(&flat), flat)
385    }
386
387    /// The dispatcher shreds a top-level struct into one child per field.
388    #[tokio::test]
389    async fn dispatches_struct() -> VortexResult<()> {
390        let struct_array = StructArray::from_fields(
391            [
392                ("a", buffer![1i32, 2, 3].into_array()),
393                ("b", buffer![10i32, 20, 30].into_array()),
394            ]
395            .as_slice(),
396        )?
397        .into_array();
398
399        let layout = write(&flat_table(), struct_array).await?;
400        insta::assert_snapshot!(layout.display_tree(), @r"
401        vortex.struct, dtype: {a=i32, b=i32}, children: 2
402        ├── a: vortex.flat, dtype: i32, segment: 0
403        └── b: vortex.flat, dtype: i32, segment: 1
404        ");
405        Ok(())
406    }
407
408    /// A `list<list<i32>>` column: the dispatcher recurses into itself so the outer list's
409    /// `elements` are decomposed as a nested `ListLayout`.
410    #[tokio::test]
411    async fn dispatches_nested_list() -> VortexResult<()> {
412        let inner = ListArray::try_new(
413            buffer![1i32, 2, 3, 4, 5, 6].into_array(),
414            buffer![0u32, 2, 5, 5, 6].into_array(),
415            Validity::NonNullable,
416        )?
417        .into_array();
418        let outer = ListArray::try_new(
419            inner,
420            buffer![0u32, 2, 4].into_array(),
421            Validity::NonNullable,
422        )?
423        .into_array();
424
425        let layout = write(&flat_table().with_list_layout(), outer).await?;
426        insta::assert_snapshot!(layout.display_tree(), @r"
427        vortex.list, dtype: list(list(i32)), children: 2
428        ├── elements: vortex.list, dtype: list(i32), children: 2
429        │   ├── elements: vortex.flat, dtype: i32, segment: 1
430        │   └── offsets: vortex.flat, dtype: u64, segment: 2
431        └── offsets: vortex.flat, dtype: u64, segment: 0
432        ");
433        Ok(())
434    }
435
436    /// A `struct<{ items: list<struct<{a,b}>>? }>` column: list decomposition recurses into struct
437    /// decomposition for the elements, and a nullable list writes a validity child.
438    #[tokio::test]
439    async fn dispatches_struct_list_struct() -> VortexResult<()> {
440        let inner_struct = StructArray::from_fields(
441            [
442                ("a", buffer![1i32, 2, 3, 4, 5].into_array()),
443                ("b", buffer![10i32, 20, 30, 40, 50].into_array()),
444            ]
445            .as_slice(),
446        )?
447        .into_array();
448        let items = ListArray::try_new(
449            inner_struct,
450            buffer![0u32, 2, 5, 5].into_array(),
451            Validity::Array(BoolArray::from_iter([true, false, true]).into_array()),
452        )?
453        .into_array();
454        let st = StructArray::from_fields([("items", items)].as_slice())?.into_array();
455
456        let layout = write(&flat_table().with_list_layout(), st).await?;
457        insta::assert_snapshot!(layout.display_tree(), @r"
458        vortex.struct, dtype: {items=list({a=i32, b=i32})?}, children: 1
459        └── items: vortex.list, dtype: list({a=i32, b=i32})?, children: 3
460            ├── elements: vortex.struct, dtype: {a=i32, b=i32}, children: 2
461            │   ├── a: vortex.flat, dtype: i32, segment: 2
462            │   └── b: vortex.flat, dtype: i32, segment: 3
463            ├── offsets: vortex.flat, dtype: u64, segment: 0
464            └── validity: vortex.flat, dtype: bool, segment: 1
465        ");
466        Ok(())
467    }
468
469    /// A multi-chunk `list<i32>` written with a chunked leaf: each sub-column (`elements`,
470    /// `offsets`) becomes its own `ChunkedLayout`, so elements are chunked independently of rows.
471    /// This is the "list-of-chunkeds" topology top-level decomposition unlocks.
472    #[tokio::test]
473    async fn dispatches_chunked_list() -> VortexResult<()> {
474        let chunk0 = ListArray::try_new(
475            buffer![1i32, 2, 3].into_array(),
476            buffer![0u32, 2, 3].into_array(),
477            Validity::NonNullable,
478        )?
479        .into_array();
480        let chunk1 = ListArray::try_new(
481            buffer![4i32, 5, 6, 7].into_array(),
482            buffer![0u32, 1, 4].into_array(),
483            Validity::NonNullable,
484        )?
485        .into_array();
486        let dtype = chunk0.dtype().clone();
487        let chunked = ChunkedArray::try_new(vec![chunk0, chunk1], dtype)?.into_array();
488
489        let flat: Arc<dyn LayoutStrategy> = Arc::new(FlatLayoutStrategy::default());
490        let dispatcher = TableStrategy::new(
491            Arc::clone(&flat),
492            Arc::new(ChunkedLayoutStrategy::new(FlatLayoutStrategy::default())),
493        )
494        .with_list_layout();
495        let layout = write(&dispatcher, chunked).await?;
496        insta::assert_snapshot!(layout.display_tree(), @r"
497        vortex.list, dtype: list(i32), children: 2
498        ├── elements: vortex.chunked, dtype: i32, children: 2
499        │   ├── [0]: vortex.flat, dtype: i32, segment: 0
500        │   └── [1]: vortex.flat, dtype: i32, segment: 1
501        └── offsets: vortex.chunked, dtype: u64, children: 2
502            ├── [0]: vortex.flat, dtype: u64, segment: 2
503            └── [1]: vortex.flat, dtype: u64, segment: 3
504        ");
505        Ok(())
506    }
507
508    /// A wrapper can repartition and zone lists in outer-row space before decomposition.
509    #[tokio::test]
510    async fn wraps_list_strategy_before_decomposition() -> VortexResult<()> {
511        let list = ListArray::try_new(
512            PrimitiveArray::from_iter(0..9_i32).into_array(),
513            PrimitiveArray::from_iter(0..=9_u32).into_array(),
514            Validity::NonNullable,
515        )?
516        .into_array();
517
518        let row_block_size = NonZeroUsize::new(4).vortex_expect("4 is non-zero");
519        let flat: Arc<dyn LayoutStrategy> = Arc::new(FlatLayoutStrategy::default());
520        let stats = Arc::clone(&flat);
521        let chunked: Arc<dyn LayoutStrategy> =
522            Arc::new(ChunkedLayoutStrategy::new(FlatLayoutStrategy::default()));
523        let dispatcher = TableStrategy::new(Arc::clone(&flat), chunked).with_list_layout_factory(
524            move |list_layout| {
525                let zoned = ZonedStrategy::new(
526                    list_layout,
527                    Arc::clone(&stats),
528                    ZonedLayoutOptions {
529                        block_size: row_block_size,
530                        ..Default::default()
531                    },
532                );
533                Arc::new(RepartitionStrategy::new(
534                    zoned,
535                    RepartitionWriterOptions {
536                        block_size_minimum: 0,
537                        block_len_multiple: row_block_size.get(),
538                        block_size_target: None,
539                        canonicalize: false,
540                    },
541                )) as Arc<dyn LayoutStrategy>
542            },
543        );
544
545        let layout = write(&dispatcher, list).await?;
546        let zoned = layout.as_::<Zoned>();
547        assert_eq!(zoned.zone_len(), 4);
548        assert_eq!(zoned.nzones(), 3);
549
550        let data = layout.child(0)?;
551        assert!(data.is::<List>());
552        assert_eq!(data.row_count(), 9);
553        Ok(())
554    }
555
556    /// A non-struct stream is not shredded; it is handed straight to the leaf strategy.
557    #[tokio::test]
558    async fn non_struct_input_uses_leaf() -> VortexResult<()> {
559        let primitive = PrimitiveArray::from_iter([1i32, 2, 3]).into_array();
560        let layout = write(&flat_table(), primitive).await?;
561        insta::assert_snapshot!(layout.display_tree(), @"vortex.flat, dtype: i32, segment: 0");
562        Ok(())
563    }
564
565    /// A multi-chunk struct is transposed per field; each column is written by a chunked leaf.
566    #[tokio::test]
567    async fn chunked_struct() -> VortexResult<()> {
568        let validity: Arc<dyn LayoutStrategy> = Arc::new(FlatLayoutStrategy::default());
569        let chunked_flat: Arc<dyn LayoutStrategy> =
570            Arc::new(ChunkedLayoutStrategy::new(FlatLayoutStrategy::default()));
571        let dispatcher = TableStrategy::new(validity, chunked_flat);
572
573        let c0 = StructArray::from_fields(
574            [
575                ("a", buffer![1i32, 2].into_array()),
576                ("b", buffer![10i32, 20].into_array()),
577            ]
578            .as_slice(),
579        )?
580        .into_array();
581        let c1 = StructArray::from_fields(
582            [
583                ("a", buffer![3i32].into_array()),
584                ("b", buffer![30i32].into_array()),
585            ]
586            .as_slice(),
587        )?
588        .into_array();
589        let dtype = c0.dtype().clone();
590        let chunked = ChunkedArray::try_new(vec![c0, c1], dtype)?.into_array();
591
592        let layout = write(&dispatcher, chunked).await?;
593        insta::assert_snapshot!(layout.display_tree(), @r"
594        vortex.struct, dtype: {a=i32, b=i32}, children: 2
595        ├── a: vortex.chunked, dtype: i32, children: 2
596        │   ├── [0]: vortex.flat, dtype: i32, segment: 0
597        │   └── [1]: vortex.flat, dtype: i32, segment: 1
598        └── b: vortex.chunked, dtype: i32, children: 2
599            ├── [0]: vortex.flat, dtype: i32, segment: 2
600            └── [1]: vortex.flat, dtype: i32, segment: 3
601        ");
602        Ok(())
603    }
604
605    /// A field override on a struct field is honored ahead of the default leaf strategy.
606    #[tokio::test]
607    async fn field_override_is_used() -> VortexResult<()> {
608        let struct_array = StructArray::from_fields(
609            [
610                ("a", buffer![1i32, 2, 3].into_array()),
611                ("b", buffer![10i32, 20, 30].into_array()),
612            ]
613            .as_slice(),
614        )?
615        .into_array();
616
617        let strategy =
618            flat_table().with_field_writer(field_path!(a), Arc::new(FlatLayoutStrategy::default()));
619        let layout = write(&strategy, struct_array).await?;
620        insta::assert_snapshot!(layout.display_tree(), @r"
621        vortex.struct, dtype: {a=i32, b=i32}, children: 2
622        ├── a: vortex.flat, dtype: i32, segment: 0
623        └── b: vortex.flat, dtype: i32, segment: 1
624        ");
625        Ok(())
626    }
627
628    #[test]
629    #[should_panic(
630        expected = "Override for field_path $a.$b conflicts with existing override for $a.$b.$c"
631    )]
632    fn test_overlapping_paths_fail() {
633        let flat = Arc::new(FlatLayoutStrategy::default());
634
635        // Success
636        let path = TableStrategy::new(
637            Arc::<FlatLayoutStrategy>::clone(&flat),
638            Arc::<FlatLayoutStrategy>::clone(&flat),
639        )
640        .with_field_writer(field_path!(a.b.c), Arc::<FlatLayoutStrategy>::clone(&flat));
641
642        // Should panic right here.
643        let _path = path.with_field_writer(field_path!(a.b), flat);
644    }
645
646    #[test]
647    #[should_panic(
648        expected = "Do not set override as a root strategy, instead set the default strategy"
649    )]
650    fn test_root_override() {
651        let flat = Arc::new(FlatLayoutStrategy::default());
652        let _strategy = TableStrategy::new(
653            Arc::<FlatLayoutStrategy>::clone(&flat),
654            Arc::<FlatLayoutStrategy>::clone(&flat),
655        )
656        .with_field_writer(FieldPath::root(), flat);
657    }
658
659    #[tokio::test]
660    #[should_panic(expected = "panic while transposing table stream")]
661    async fn table_fanout_panic_propagates() {
662        let ctx = ArrayContext::empty();
663        let segments = Arc::new(TestSegments::default());
664        let (_, eof) = SequenceId::root().split();
665        let dtype = DType::Struct(
666            StructFields::from_iter([(
667                "a",
668                DType::Primitive(PType::I32, Nullability::NonNullable),
669            )]),
670            Nullability::NonNullable,
671        );
672        let stream =
673            futures::stream::poll_fn(|_| -> Poll<Option<VortexResult<(SequenceId, ArrayRef)>>> {
674                panic!("panic while transposing table stream");
675            });
676        let strategy = TableStrategy::new(
677            Arc::new(FlatLayoutStrategy::default()),
678            Arc::new(FlatLayoutStrategy::default()),
679        );
680
681        strategy
682            .write_stream(
683                ctx,
684                segments,
685                SequentialStreamAdapter::new(dtype, stream).sendable(),
686                eof,
687                &SESSION,
688            )
689            .await
690            .unwrap();
691    }
692}