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}