Skip to main content

chronix_engine/segment/
metadata.rs

1//! Per-column metadata within a segment file.
2//!
3//! Column metadata enables selective reading: the reader can determine which
4//! columns exist, their data types, and where their data is stored without
5//! reading any row group data.
6
7use chronix_encoding::EncodingType;
8use serde::{Deserialize, Serialize};
9
10use crate::segment::error::{Result, SegmentError};
11use crate::segment::stats::{COLUMN_STATS_SIZE, ColumnStats};
12use crate::segment::to_array;
13
14/// Metadata for a single column within a segment.
15///
16/// # Segment-level statistics
17///
18/// The [`stats`](Self::stats) field stores aggregate statistics across all
19/// row groups: `min_value`, `max_value`, `null_count`, `value_count`,
20/// `sum`, and `distinct_count`.  These are computed by the segment writer
21/// during encoding and merged via `merge_stats()`.  Query-time code can
22/// use these to prune entire segments without reading row-group data.
23///
24/// Metadata is kept proportional to the data: no fixed-size sketches or
25/// histograms, which cost ~49 KiB per segment however little it holds, and
26/// which the planner does not need — `distinct_count` above is exact.
27#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
28pub struct ColumnMeta {
29    /// Column name.
30    pub name: String,
31    /// Column data type tag (maps to `ColumnType`).
32    pub data_type: u8,
33    /// Column role tag: 0 = timestamp, 1 = tag, 2 = field.
34    pub role: u8,
35    /// The encoding the writer *intended* for this column, chosen from the
36    /// column's type before any data was seen.
37    ///
38    /// This is a hint only. The adaptive selector may fall back to a different
39    /// codec per block (e.g. Chimp → Gorilla → plain), so the authoritative
40    /// encoding for reading is always
41    /// [`ColumnBlockMeta::encoding`](crate::segment::metadata::ColumnBlockMeta::encoding).
42    /// The field was previously named `encoding`, which read as a statement of
43    /// fact; nothing decodes from it, but the name invited that mistake.
44    pub default_encoding: u8,
45    /// Global statistics across all row groups (min, max, null_count,
46    /// value_count, sum, distinct_count).  Enables segment-level pruning
47    /// at query time.
48    pub stats: ColumnStats,
49    /// Bloom filter for tag columns.
50    ///
51    /// Contains the serialized bloom filter bit array for tag columns,
52    /// enabling segment-level predicate pushdown: if a queried tag value
53    /// is definitely not in the bloom filter, the segment can be skipped.
54    /// `None` for non-tag columns, and for segments written before per-tag
55    /// row-group blooms existed.
56    #[serde(default)]
57    pub bloom_filter: Option<Vec<u8>>,
58    /// Whether this column is encrypted at rest (field-level encryption).
59    ///
60    /// When `true`, all column blocks for this column are AES-256-GCM
61    /// encrypted and `key_id` identifies the encryption key.
62    /// Statistics (min/max/sum/distinct) are zeroed to prevent leakage.
63    #[serde(default)]
64    pub encrypted: bool,
65    /// Encryption key ID for this column. Used by the reader to look up
66    /// the decryption key from a `FieldKeyProvider`.
67    #[serde(default)]
68    pub key_id: Option<String>,
69    /// Digits after the decimal point, for a decimal column.
70    ///
71    /// `None` for every other type. The scale lives here, once per column,
72    /// rather than beside each value: it is a property of the column, fixed
73    /// when the column was created, and the write path rescales every value
74    /// to it before the memtable ever sees it. Storing it per value would
75    /// cost a byte a row to say the same thing 65 536 times, and would make
76    /// a segment whose rows disagreed representable.
77    #[serde(default)]
78    pub decimal_scale: Option<u8>,
79    /// Per-row-group bloom filters for string columns .
80    ///
81    /// `row_group_blooms[rg_idx]` is a serialized bloom filter containing
82    /// all distinct string values for this column in that row group.
83    /// Enables row-group-level predicate pushdown: if a queried value is
84    /// definitely not in the bloom filter, the row group can be skipped.
85    /// `None` for non-string columns, encrypted columns, or segments
86    /// written before .
87    #[serde(default)]
88    pub row_group_blooms: Option<Vec<Vec<u8>>>,
89}
90
91impl ColumnMeta {
92    /// The Arrow type this column decodes to.
93    ///
94    /// The single answer to a question four places in the reader used to
95    /// answer separately, each ending in `_ => DataType::Utf8`. That
96    /// fallback was fine while every non-numeric column was a string; it
97    /// silently turned a decimal column into `Utf8` the moment one existed.
98    #[must_use]
99    pub fn arrow_data_type(&self) -> arrow::datatypes::DataType {
100        use arrow::datatypes::DataType;
101        match self.data_type {
102            data_types::TIMESTAMP | data_types::I64 => DataType::Int64,
103            data_types::U64 => DataType::UInt64,
104            data_types::F64 => DataType::Float64,
105            data_types::BOOL => DataType::Boolean,
106            data_types::DECIMAL => DataType::Decimal128(
107                chronix_core::DECIMAL_PRECISION,
108                // A decimal column always carries its scale; a segment whose
109                // metadata lost it reads as scale 0, which is wrong by a
110                // power of ten but not a panic.
111                self.decimal_scale.unwrap_or(0) as i8,
112            ),
113            // A composite value, carried as its own encoding. Added here and
114            // not only in the reader because this comment's own warning came
115            // true a second time: the fallback below is what silently turned a
116            // decimal column into `Utf8`, and it would have done the same to a
117            // histogram. `every_data_type_has_an_arrow_type` now refuses to let
118            // a new code reach the fallback at all.
119            data_types::HISTOGRAM => DataType::Binary,
120            data_types::STRING => DataType::Utf8,
121            // Unknown code from a segment this build does not understand.
122            // Reading it as text is the least-wrong guess and the reason the
123            // test above exists.
124            _ => DataType::Utf8,
125        }
126    }
127
128    /// An empty array of this column's Arrow type, for a pruned row group.
129    #[must_use]
130    pub fn empty_array(&self) -> arrow::array::ArrayRef {
131        use arrow::array::{
132            ArrayRef, BooleanArray, Decimal128Array, Float64Array, Int64Array, StringArray,
133            UInt64Array,
134        };
135        match self.data_type {
136            data_types::TIMESTAMP | data_types::I64 => {
137                std::sync::Arc::new(Int64Array::from(Vec::<i64>::new())) as ArrayRef
138            }
139            data_types::U64 => std::sync::Arc::new(UInt64Array::from(Vec::<u64>::new())),
140            data_types::F64 => std::sync::Arc::new(Float64Array::from(Vec::<f64>::new())),
141            data_types::BOOL => std::sync::Arc::new(BooleanArray::from(Vec::<bool>::new())),
142            data_types::DECIMAL => {
143                let array = Decimal128Array::from(Vec::<i128>::new());
144                // Empty, so there is no value to fail validation; the scale
145                // still has to match the field the schema declares.
146                match array.with_precision_and_scale(
147                    chronix_core::DECIMAL_PRECISION,
148                    self.decimal_scale.unwrap_or(0) as i8,
149                ) {
150                    Ok(a) => std::sync::Arc::new(a),
151                    Err(_) => std::sync::Arc::new(Decimal128Array::from(Vec::<i128>::new())),
152                }
153            }
154            data_types::HISTOGRAM => {
155                std::sync::Arc::new(arrow::array::BinaryArray::from(Vec::<&[u8]>::new()))
156            }
157            _ => std::sync::Arc::new(StringArray::from(Vec::<&str>::new())),
158        }
159    }
160
161    /// The Arrow field for this column, carrying its role as metadata.
162    #[must_use]
163    pub fn arrow_field(&self) -> arrow::datatypes::Field {
164        arrow::datatypes::Field::new(&self.name, self.arrow_data_type(), true)
165            .with_metadata(roles::arrow_metadata(self.role))
166    }
167}
168
169/// Column data type tags.
170pub mod data_types {
171    /// Timestamp column (i64 nanoseconds).
172    pub const TIMESTAMP: u8 = 0;
173    /// String column (tags or string fields).
174    pub const STRING: u8 = 1;
175    /// 64-bit float column.
176    pub const F64: u8 = 2;
177    /// Signed 64-bit integer column.
178    pub const I64: u8 = 3;
179    /// Unsigned 64-bit integer column.
180    pub const U64: u8 = 4;
181    /// Boolean column.
182    pub const BOOL: u8 = 5;
183    /// Exact decimal column: an `i128` mantissa with the scale held in
184    /// [`ColumnMeta::decimal_scale`](super::ColumnMeta::decimal_scale).
185    pub const DECIMAL: u8 = 6;
186    /// Native histogram column: each value is a whole distribution, stored
187    /// as an opaque encoded blob and read through the `histogram_*` family.
188    ///
189    /// It is the only **composite** column type — a value carries its own
190    /// count, sum, resolution and sparse buckets — which is why it is a blob
191    /// rather than a numeric column: there is no single number to zone-map,
192    /// and a predicate on it is not a comparison.
193    pub const HISTOGRAM: u8 = 7;
194}
195
196/// Column role tags.
197pub mod roles {
198    /// Timestamp column.
199    pub const TIMESTAMP: u8 = 0;
200    /// Tag column (string, indexed, low cardinality).
201    pub const TAG: u8 = 1;
202    /// Field column (any data type).
203    pub const FIELD: u8 = 2;
204
205    /// The Arrow field-metadata key a decoded batch carries its role under.
206    ///
207    /// A tag and a string field are the same Arrow type, so a schema built
208    /// from types alone loses the distinction — and every consumer then
209    /// guesses, differently.
210    pub const ARROW_ROLE_KEY: &str = "role";
211
212    /// Arrow field metadata for one stored role.
213    #[must_use]
214    pub fn arrow_metadata(role: u8) -> std::collections::HashMap<String, String> {
215        // The *role*, not the column name: a consumer reads `role=timestamp`,
216        // `role=tag` or `role=field` off the field's metadata.
217        let name = match role {
218            TIMESTAMP => "timestamp",
219            TAG => "tag",
220            _ => "field",
221        };
222        [(ARROW_ROLE_KEY.to_string(), name.to_string())].into()
223    }
224}
225
226/// Per-row-group column block location and metadata.
227#[derive(Debug, Clone, PartialEq)]
228pub struct ColumnBlockMeta {
229    /// Index of the column in the column metadata table.
230    pub column_index: u16,
231    /// Encoding type for this specific block.
232    pub encoding: EncodingType,
233    /// Whether the block is LZ4-compressed.
234    pub compressed: bool,
235    /// Byte offset within the segment file.
236    pub offset: u64,
237    /// Length of the (possibly compressed) block in bytes.
238    pub length: u32,
239    /// Number of values in this block.
240    pub value_count: u32,
241    /// CRC32c checksum of the block data for integrity verification.
242    /// A value of 0 indicates no checksum (backward compatibility with old segments).
243    pub block_crc: u32,
244    /// Whether this block is encrypted (AES-256-GCM).
245    pub encrypted: bool,
246    /// Byte offset of this block's validity bitmap within the segment file.
247    ///
248    /// See [`validity_length`](Self::validity_length). Meaningless when
249    /// `validity_length == 0`.
250    pub validity_offset: u64,
251    /// Length in bytes of this block's validity bitmap, or `0` when the block
252    /// contains no nulls (`.csx` v2, D-NULL).
253    ///
254    /// The bitmap is an Arrow-compatible LSB-first packed bitmap of
255    /// `value_count` bits: bit *i* set means row *i* holds a real value, bit
256    /// clear means SQL `NULL`. It is stored uncompressed and unencrypted
257    /// immediately after the block's value bytes.
258    ///
259    /// Before v2, absent values were encoded as type-specific sentinels
260    /// (`0`/`""`/`false`) and only an aggregate `null_count` survived, so a
261    /// stored zero and an absent field were indistinguishable at read time.
262    pub validity_length: u32,
263    /// Block-level statistics.
264    pub stats: ColumnStats,
265}
266
267impl ColumnBlockMeta {
268    /// Serialized size of a column block metadata entry.
269    pub const SIZE: usize = 2 + 1 + 1 + 8 + 4 + 4 + 4 + 1 + 8 + 4 + COLUMN_STATS_SIZE;
270    // column_index(2) + encoding(1) + compressed(1) + offset(8) + length(4)
271    // + value_count(4) + block_crc(4) + encrypted(1) + validity_offset(8)
272    // + validity_length(4) + stats(COLUMN_STATS_SIZE)
273
274    /// Serialize to bytes.
275    #[must_use]
276    pub fn to_bytes(&self) -> Vec<u8> {
277        let mut buf = Vec::with_capacity(Self::SIZE);
278        buf.extend_from_slice(&self.column_index.to_le_bytes());
279        buf.push(self.encoding.tag());
280        buf.push(u8::from(self.compressed));
281        buf.extend_from_slice(&self.offset.to_le_bytes());
282        buf.extend_from_slice(&self.length.to_le_bytes());
283        buf.extend_from_slice(&self.value_count.to_le_bytes());
284        buf.extend_from_slice(&self.block_crc.to_le_bytes());
285        buf.push(u8::from(self.encrypted));
286        buf.extend_from_slice(&self.validity_offset.to_le_bytes());
287        buf.extend_from_slice(&self.validity_length.to_le_bytes());
288        buf.extend_from_slice(&self.stats.to_bytes());
289        buf
290    }
291
292    /// Deserialize from bytes.
293    ///
294    /// # Errors
295    ///
296    /// Returns an error if the data is too short or contains invalid values.
297    ///
298    pub fn from_bytes(data: &[u8]) -> Result<Self> {
299        if data.len() < Self::SIZE {
300            return Err(SegmentError::CorruptFile {
301                detail: format!(
302                    "column block meta too short: expected {}, got {}",
303                    Self::SIZE,
304                    data.len()
305                ),
306            });
307        }
308
309        let column_index = u16::from_le_bytes([data[0], data[1]]);
310        let encoding = EncodingType::from_tag(data[2]).map_err(|e| SegmentError::CorruptFile {
311            detail: format!("invalid encoding tag: {e}"),
312        })?;
313        let compressed = data[3] != 0;
314        let offset = u64::from_le_bytes(to_array!(data[4..12], "block meta offset")?);
315        let length = u32::from_le_bytes(to_array!(data[12..16], "block meta length")?);
316        let value_count = u32::from_le_bytes(to_array!(data[16..20], "block meta value_count")?);
317        let block_crc = u32::from_le_bytes(to_array!(data[20..24], "block meta block_crc")?);
318        let encrypted = data[24] != 0;
319        let validity_offset =
320            u64::from_le_bytes(to_array!(data[25..33], "block meta validity_offset")?);
321        let validity_length =
322            u32::from_le_bytes(to_array!(data[33..37], "block meta validity_length")?);
323        let stats = ColumnStats::from_bytes(&data[37..])?;
324
325        Ok(Self {
326            column_index,
327            encoding,
328            compressed,
329            offset,
330            length,
331            value_count,
332            block_crc,
333            encrypted,
334            validity_offset,
335            validity_length,
336            stats,
337        })
338    }
339}
340
341/// Metadata section stored after all row groups, before the footer.
342#[derive(Debug, Clone, PartialEq)]
343pub struct SegmentMetadata {
344    /// Column metadata table.
345    pub columns: Vec<ColumnMeta>,
346    /// Per-row-group, per-column block metadata.
347    pub row_group_blocks: Vec<Vec<ColumnBlockMeta>>,
348    /// Optional Zstd compression dictionary trained from this segment's
349    /// data. When present, blocks tagged with `CODEC_TAG_ZSTD_DICT`
350    /// must be decompressed using this dictionary.
351    pub zstd_dictionary: Option<Vec<u8>>,
352}
353
354impl SegmentMetadata {
355    /// Serialize the metadata section to bytes.
356    ///
357    /// # Errors
358    ///
359    /// Returns an error if column metadata serialization fails.
360    pub fn to_bytes(&self) -> Result<Vec<u8>> {
361        let encoded =
362            postcard::to_stdvec(&self.columns).map_err(|e| SegmentError::CorruptFile {
363                detail: format!("column metadata serialization failed: {e}"),
364            })?;
365
366        // Format: [column_meta_len: u32][column_meta_postcard]
367        //         [rg_count: u32]
368        //         for each rg: [block_count: u16][block_meta...][block_meta...]
369        //         [zstd_dict_len: u32][dict_bytes]  (0 = no dict)
370        let mut buf = Vec::new();
371
372        // Column metadata (postcard)
373        buf.extend_from_slice(&(encoded.len() as u32).to_le_bytes());
374        buf.extend_from_slice(&encoded);
375
376        // Row group block metadata
377        buf.extend_from_slice(&(self.row_group_blocks.len() as u32).to_le_bytes());
378        for rg_blocks in &self.row_group_blocks {
379            buf.extend_from_slice(&(rg_blocks.len() as u16).to_le_bytes());
380            for block in rg_blocks {
381                buf.extend_from_slice(&block.to_bytes());
382            }
383        }
384
385        // Zstd dictionary (trailing, backward-compatible)
386        if let Some(dict) = &self.zstd_dictionary {
387            buf.extend_from_slice(&(dict.len() as u32).to_le_bytes());
388            buf.extend_from_slice(dict);
389        } else {
390            buf.extend_from_slice(&0u32.to_le_bytes());
391        }
392
393        Ok(buf)
394    }
395
396    /// Deserialize the metadata section from bytes.
397    ///
398    /// # Errors
399    ///
400    /// Returns an error if the data is malformed.
401    pub fn from_bytes(data: &[u8]) -> Result<Self> {
402        if data.len() < 4 {
403            return Err(SegmentError::CorruptFile {
404                detail: "metadata section too short".to_string(),
405            });
406        }
407
408        let json_len = u32::from_le_bytes([data[0], data[1], data[2], data[3]]) as usize;
409        let mut offset = 4;
410
411        if data.len() < offset + json_len {
412            return Err(SegmentError::CorruptFile {
413                detail: "metadata column section truncated".to_string(),
414            });
415        }
416
417        let columns: Vec<ColumnMeta> = postcard::from_bytes(&data[offset..offset + json_len])
418            .map_err(|e| SegmentError::CorruptFile {
419                detail: format!("invalid column metadata: {e}"),
420            })?;
421        offset += json_len;
422
423        if data.len() < offset + 4 {
424            return Err(SegmentError::CorruptFile {
425                detail: "metadata missing row group count".to_string(),
426            });
427        }
428
429        let rg_count = u32::from_le_bytes(to_array!(
430            data[offset..offset + 4],
431            "metadata row group count"
432        )?) as usize;
433        offset += 4;
434
435        // Validate that the claimed rg_count is plausible given remaining data.
436        // Each row group needs at least 2 bytes (block_count), so the total
437        // remaining data must be at least rg_count * 2.
438        let remaining = data.len().saturating_sub(offset);
439        if rg_count > remaining / 2 {
440            return Err(SegmentError::CorruptFile {
441                detail: format!(
442                    "row group count ({rg_count}) exceeds available metadata ({remaining} bytes)"
443                ),
444            });
445        }
446
447        let mut row_group_blocks = Vec::with_capacity(rg_count);
448        for _ in 0..rg_count {
449            if data.len() < offset + 2 {
450                return Err(SegmentError::CorruptFile {
451                    detail: "metadata missing block count".to_string(),
452                });
453            }
454            let block_count = u16::from_le_bytes([data[offset], data[offset + 1]]) as usize;
455            offset += 2;
456
457            let mut blocks = Vec::with_capacity(block_count);
458            for _ in 0..block_count {
459                let block = ColumnBlockMeta::from_bytes(&data[offset..])?;
460                offset += ColumnBlockMeta::SIZE;
461                blocks.push(block);
462            }
463            row_group_blocks.push(blocks);
464        }
465
466        // Zstd dictionary (trailing, optional for backward compat).
467        let zstd_dictionary = if data.len() >= offset + 4 {
468            let dict_len = u32::from_le_bytes(to_array!(
469                data[offset..offset + 4],
470                "metadata zstd_dict_len"
471            )?) as usize;
472            offset += 4;
473            if dict_len > 0 {
474                if data.len() < offset + dict_len {
475                    return Err(SegmentError::CorruptFile {
476                        detail: "metadata zstd dictionary truncated".to_string(),
477                    });
478                }
479                let dict = data[offset..offset + dict_len].to_vec();
480                Some(dict)
481            } else {
482                None
483            }
484        } else {
485            // Old segment without dictionary section — backward compatible.
486            None
487        };
488
489        Ok(Self {
490            columns,
491            row_group_blocks,
492            zstd_dictionary,
493        })
494    }
495}
496
497#[cfg(test)]
498mod tests {
499    use super::*;
500
501    #[test]
502    fn column_block_meta_roundtrip() {
503        let meta = ColumnBlockMeta {
504            column_index: 3,
505            encoding: EncodingType::Chimp,
506            compressed: true,
507            offset: 1024,
508            length: 512,
509            value_count: 1000,
510            block_crc: 0xDEAD_BEEF,
511            encrypted: false,
512            validity_offset: 0,
513            validity_length: 0,
514            stats: ColumnStats::empty(),
515        };
516        let bytes = meta.to_bytes();
517        assert_eq!(bytes.len(), ColumnBlockMeta::SIZE);
518        let recovered = ColumnBlockMeta::from_bytes(&bytes).unwrap();
519        assert_eq!(meta, recovered);
520    }
521
522    #[test]
523    fn segment_metadata_roundtrip() {
524        let meta = SegmentMetadata {
525            columns: vec![
526                ColumnMeta {
527                    name: chronix_core::TIME_COLUMN.to_string(),
528                    data_type: data_types::TIMESTAMP,
529                    role: roles::TIMESTAMP,
530                    default_encoding: EncodingType::DeltaOfDelta.tag(),
531                    stats: ColumnStats::empty(),
532                    bloom_filter: None,
533                    encrypted: false,
534                    key_id: None,
535                    decimal_scale: None,
536                    row_group_blooms: None,
537                },
538                ColumnMeta {
539                    name: "host".to_string(),
540                    data_type: data_types::STRING,
541                    role: roles::TAG,
542                    default_encoding: EncodingType::Dictionary.tag(),
543                    stats: ColumnStats::empty(),
544                    bloom_filter: None,
545                    encrypted: false,
546                    key_id: None,
547                    decimal_scale: None,
548                    row_group_blooms: None,
549                },
550                ColumnMeta {
551                    name: "cpu".to_string(),
552                    data_type: data_types::F64,
553                    role: roles::FIELD,
554                    default_encoding: EncodingType::Chimp.tag(),
555                    stats: ColumnStats::empty(),
556                    bloom_filter: None,
557                    encrypted: false,
558                    key_id: None,
559                    decimal_scale: None,
560                    row_group_blooms: None,
561                },
562            ],
563            row_group_blocks: vec![vec![
564                ColumnBlockMeta {
565                    column_index: 0,
566                    encoding: EncodingType::DeltaOfDelta,
567                    compressed: false,
568                    offset: 48,
569                    length: 200,
570                    value_count: 1000,
571                    block_crc: 0,
572                    encrypted: false,
573                    validity_offset: 0,
574                    validity_length: 0,
575                    stats: ColumnStats::empty(),
576                },
577                ColumnBlockMeta {
578                    column_index: 1,
579                    encoding: EncodingType::Dictionary,
580                    compressed: true,
581                    offset: 248,
582                    length: 50,
583                    value_count: 1000,
584                    block_crc: 0,
585                    encrypted: false,
586                    validity_offset: 0,
587                    validity_length: 0,
588                    stats: ColumnStats::empty(),
589                },
590            ]],
591            zstd_dictionary: None,
592        };
593
594        let bytes = meta.to_bytes().unwrap();
595        let recovered = SegmentMetadata::from_bytes(&bytes).unwrap();
596        assert_eq!(meta, recovered);
597    }
598
599    #[test]
600    fn corrupt_metadata_detected() {
601        assert!(SegmentMetadata::from_bytes(&[0; 2]).is_err());
602    }
603}
604
605#[cfg(test)]
606mod data_type_coverage {
607    use super::*;
608
609    /// Every declared column type maps to an Arrow type deliberately.
610    ///
611    /// `arrow_data_type` ends in `_ => DataType::Utf8`, and its own doc
612    /// comment records what that cost: it "silently turned a decimal column
613    /// into `Utf8` the moment one existed". Adding the histogram type was
614    /// about to do it a second time — the column wrote as `Binary` and read
615    /// back as `Utf8`, and the only reason it surfaced was a `RecordBatch`
616    /// refusing the mismatch.
617    ///
618    /// So the fallback stays — an unknown code from a future segment has to
619    /// become *something* — and this refuses to let a code we declare reach
620    /// it. The list is the `data_types` module itself; a constant added
621    /// without an arm fails here rather than in a user's query.
622    #[test]
623    fn every_declared_data_type_has_its_own_arrow_type() {
624        use arrow::datatypes::DataType;
625        let declared: &[(&str, u8)] = &[
626            ("TIMESTAMP", data_types::TIMESTAMP),
627            ("STRING", data_types::STRING),
628            ("F64", data_types::F64),
629            ("I64", data_types::I64),
630            ("U64", data_types::U64),
631            ("BOOL", data_types::BOOL),
632            ("DECIMAL", data_types::DECIMAL),
633            ("HISTOGRAM", data_types::HISTOGRAM),
634        ];
635
636        // The source is the authority on how many there are: a constant added
637        // to `data_types` without an entry above is caught here too, so this
638        // test cannot go stale by omission.
639        let source = include_str!("metadata.rs");
640        let marker = "pub mod data_types {";
641        let body = &source[source.find(marker).expect("the module") + marker.len()..];
642        let body = &body[..body.find("\n}").expect("its end")];
643        let declared_in_source = body.matches("pub const ").count();
644        assert_eq!(
645            declared_in_source,
646            declared.len(),
647            "`data_types` declares {declared_in_source} codes and this test lists \
648             {}. A column type with no arm in `arrow_data_type` reads back as \
649             text, which is how a decimal column became `Utf8` once already.",
650            declared.len()
651        );
652
653        for &(name, code) in declared {
654            let meta = ColumnMeta {
655                name: name.to_string(),
656                data_type: code,
657                role: roles::FIELD,
658                default_encoding: 0,
659                stats: ColumnStats::empty(),
660                bloom_filter: None,
661                encrypted: false,
662                key_id: None,
663                decimal_scale: Some(2),
664                row_group_blooms: None,
665            };
666            let arrow = meta.arrow_data_type();
667            if code != data_types::STRING {
668                assert_ne!(
669                    arrow,
670                    DataType::Utf8,
671                    "{name} falls through to the `Utf8` fallback"
672                );
673            }
674            // And the empty array must agree with the declared type, or a
675            // pruned row group produces a batch the schema refuses.
676            assert_eq!(
677                meta.empty_array().data_type(),
678                &arrow,
679                "{name}: empty_array and arrow_data_type disagree, so a pruned \
680                 row group would not fit the schema it is being put into"
681            );
682        }
683    }
684}