Skip to main content

chronix_core/
schema.rs

1//! Schema registry with schema-on-write and additive evolution.
2//!
3//! The schema registry tracks the column definitions for each measurement.
4//! Schemas are created automatically on first write and evolve additively:
5//! new fields/tags are added automatically, but type changes are rejected.
6//!
7//! # Schema Evolution Strategy
8//!
9//! Chronix uses a **schema-on-write** approach to schema evolution:
10//!
11//! - **Automatic creation**: The first write to a measurement creates the
12//!   schema with the timestamp column plus all tags and fields from that point.
13//! - **Additive evolution**: Subsequent writes may introduce new tag or field
14//!   columns.  These are appended to the schema automatically — no DDL
15//!   statement is required.
16//! - **Type safety**: If a field name already exists with a different type,
17//!   the write is rejected with [`SchemaError::TypeConflict`].
18//! - **Durability**: the catalog manifest is the only durable record of a
19//!   schema; the database persists every action `register_batch` returns
20//!   before it writes the data that needs it
21//! - **Query-time safety**: Queries that reference a column not present in
22//!   older segments return zero rows for those segments (no panic).
23//!
24//! ## Current Limitations
25//!
26//! - **No column removal**: Once a tag or field is added, it cannot be
27//!   dropped.  Obsolete columns simply receive null values in new writes.
28//! - **No column rename**: Renaming requires writing a migration that copies
29//!   data from the old column to a new one and stops writing the old name.
30//! - **No type change**: A field's type is immutable once established.  To
31//!   change a field from `i64` to `f64`, create a new field name.
32//! - **No column reorder**: Columns are appended in arrival order.
33
34use std::collections::HashMap;
35use std::fmt;
36use std::sync::Arc;
37
38use dashmap::DashMap;
39use serde::{Deserialize, Serialize};
40
41use crate::error::SchemaError;
42use crate::types::{FieldValue, SeriesKey};
43
44/// The role of a column within a measurement.
45#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
46pub enum ColumnRole {
47    /// The timestamp column (exactly one per measurement).
48    Timestamp,
49    /// A tag column (string-typed, indexed for filtering).
50    Tag,
51    /// A field/value column (typed, stores the actual metrics).
52    Field,
53}
54
55impl fmt::Display for ColumnRole {
56    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
57        match self {
58            Self::Timestamp => write!(f, "timestamp"),
59            Self::Tag => write!(f, "tag"),
60            Self::Field => write!(f, "field"),
61        }
62    }
63}
64
65/// The data type of a column.
66#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
67pub enum ColumnType {
68    /// Nanosecond timestamp (i64).
69    Timestamp,
70    /// UTF-8 string (for tags and string fields).
71    String,
72    /// 64-bit floating point.
73    F64,
74    /// Signed 64-bit integer.
75    I64,
76    /// Unsigned 64-bit integer.
77    U64,
78    /// Boolean.
79    Bool,
80    /// A Prometheus native histogram — a whole distribution per sample.
81    ///
82    /// Unlike every other column type this one is a **composite**: a value
83    /// carries its own count, sum, resolution and sparse buckets. It is
84    /// stored as an opaque encoded blob and read through the `histogram_*`
85    /// family rather than through arithmetic, which is also how Prometheus
86    /// models it.
87    ///
88    /// The *resolution* is deliberately **not** part of the column type, as
89    /// the decimal scale is. A scale must be fixed at the column because two
90    /// scales produce two incompatible Arrow types; two histogram schemas
91    /// produce the same Arrow type and are merged by re-bucketing to the
92    /// coarser, so a target that changes resolution stays readable.
93    Histogram,
94
95    /// Exact fixed-point decimal with a fixed number of fractional digits.
96    ///
97    /// The scale is part of the column's type, not of each value, and it is
98    /// fixed the first time the column is written (or declared). Every later
99    /// write is rescaled to it losslessly — `1.5` into a scale-4 column is
100    /// stored as `1.5000` — and refused if that would drop a digit.
101    ///
102    /// # Why the scale cannot drift
103    ///
104    /// A decimal column is read back as Arrow `Decimal128(38, scale)`. Two
105    /// segments of the same column whose scales differ would produce two
106    /// incompatible Arrow schemas, and the batches from a scan that touched
107    /// both could not be concatenated. Fixing the scale at the column makes
108    /// that unrepresentable rather than a read-time failure.
109    Decimal {
110        /// Digits after the decimal point, `0..=38`.
111        scale: u8,
112    },
113}
114
115impl ColumnType {
116    /// Derive the column type from a [`FieldValue`].
117    ///
118    /// For a decimal this reports the *value's* scale, which is what a
119    /// column created by this value would carry. Use
120    /// [`accepts_value`](Self::accepts_value) to ask whether an existing
121    /// column can store it.
122    #[inline]
123    #[must_use]
124    pub fn from_field_value(value: &FieldValue) -> Self {
125        match value {
126            FieldValue::F64(_) => Self::F64,
127            FieldValue::I64(_) => Self::I64,
128            FieldValue::U64(_) => Self::U64,
129            FieldValue::Bool(_) => Self::Bool,
130            FieldValue::String(_) => Self::String,
131            FieldValue::Decimal(d) => Self::Decimal { scale: d.scale() },
132            FieldValue::Histogram(_) => Self::Histogram,
133        }
134    }
135
136    /// Can a column of this type store `value` without losing anything?
137    ///
138    /// Every type but [`Decimal`](Self::Decimal) is a plain type-identity
139    /// check. A decimal column accepts any value that
140    /// [`rescale`](crate::Decimal::rescale)s to the column's scale exactly —
141    /// so `1.5` and `1.50000` both fit a scale-4 column, and `1.50001` does
142    /// not.
143    #[must_use]
144    pub fn accepts_value(&self, value: &FieldValue) -> bool {
145        match (self, value) {
146            (Self::Decimal { scale }, FieldValue::Decimal(d)) => d.rescale(*scale).is_ok(),
147            _ => *self == Self::from_field_value(value),
148        }
149    }
150
151    /// The scale of a decimal column, or `None` for every other type.
152    #[inline]
153    #[must_use]
154    pub const fn decimal_scale(&self) -> Option<u8> {
155        match self {
156            Self::Decimal { scale } => Some(*scale),
157            _ => None,
158        }
159    }
160}
161
162impl fmt::Display for ColumnType {
163    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
164        match self {
165            Self::Timestamp => write!(f, "timestamp"),
166            Self::String => write!(f, "string"),
167            Self::F64 => write!(f, "f64"),
168            Self::I64 => write!(f, "i64"),
169            Self::U64 => write!(f, "u64"),
170            Self::Bool => write!(f, "bool"),
171            Self::Histogram => write!(f, "histogram"),
172            // The precision is fixed at 38 for every decimal column, so the
173            // scale is the only part worth printing — but printing it in
174            // SQL's own `decimal(p,s)` shape keeps the error message and the
175            // `SHOW COLUMNS` output readable as a type.
176            Self::Decimal { scale } => {
177                write!(f, "decimal({}, {scale})", crate::decimal::DECIMAL_PRECISION)
178            }
179        }
180    }
181}
182
183impl std::str::FromStr for ColumnType {
184    type Err = SchemaError;
185
186    /// Parse the form [`Display`](fmt::Display) produces, plus the longer
187    /// aliases the HTTP schema endpoint reports (`float64`, `int64`,
188    /// `uint64`, `boolean`).
189    ///
190    /// A parser exists so that a column type can travel as text — a schema
191    /// response a client reads and a declaration it posts back are then the
192    /// same vocabulary, rather than one shape to read and another to write.
193    fn from_str(s: &str) -> Result<Self, Self::Err> {
194        let lower = s.trim().to_ascii_lowercase();
195        match lower.as_str() {
196            "timestamp" => return Ok(Self::Timestamp),
197            "string" | "utf8" => return Ok(Self::String),
198            "f64" | "float64" | "double" => return Ok(Self::F64),
199            "i64" | "int64" => return Ok(Self::I64),
200            "u64" | "uint64" => return Ok(Self::U64),
201            "bool" | "boolean" => return Ok(Self::Bool),
202            "decimal" => return Ok(Self::Decimal { scale: 0 }),
203            _ => {}
204        }
205
206        // `decimal(38, 4)` — the precision is fixed, so it is checked
207        // rather than honoured: a caller who writes another one is asking
208        // for something this format cannot store, and should be told so.
209        let invalid = || SchemaError::InvalidName {
210            name: s.to_string(),
211            reason: "not a column type: expected timestamp, string, f64, i64, u64, bool, \
212                     or decimal(38, <scale>)"
213                .to_string(),
214        };
215        let args = lower
216            .strip_prefix("decimal(")
217            .and_then(|rest| rest.strip_suffix(')'))
218            .ok_or_else(invalid)?;
219        let (precision, scale) = args.split_once(',').ok_or_else(invalid)?;
220        let precision: u8 = precision.trim().parse().map_err(|_| invalid())?;
221        if precision != crate::decimal::DECIMAL_PRECISION {
222            return Err(SchemaError::InvalidName {
223                name: s.to_string(),
224                reason: format!(
225                    "decimal precision must be {}, the only one this format stores",
226                    crate::decimal::DECIMAL_PRECISION
227                ),
228            });
229        }
230        let scale: u8 = scale.trim().parse().map_err(|_| invalid())?;
231        if scale > crate::decimal::MAX_DECIMAL_SCALE {
232            return Err(SchemaError::InvalidName {
233                name: s.to_string(),
234                reason: format!(
235                    "decimal scale {scale} exceeds the maximum of {}",
236                    crate::decimal::MAX_DECIMAL_SCALE
237                ),
238            });
239        }
240        Ok(Self::Decimal { scale })
241    }
242}
243
244/// A column definition within a measurement schema.
245#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
246pub struct ColumnDef {
247    /// Column name.
248    pub name: String,
249    /// Data type.
250    pub column_type: ColumnType,
251    /// Role: timestamp, tag, or field.
252    pub role: ColumnRole,
253}
254
255/// Schema for a single measurement (table).
256///
257/// Contains an ordered list of column definitions. The first column is always
258/// the timestamp. Tags and fields follow. Columns are private to preserve
259/// invariants — use [`add_tag()`](Self::add_tag) and
260/// [`add_field()`](Self::add_field) for modification.
261#[derive(Debug, Clone, Serialize)]
262pub struct MeasurementSchema {
263    measurement: String,
264    columns: Vec<ColumnDef>,
265    /// O(1) column lookup index (name → position in `columns`).
266    #[serde(skip)]
267    column_index: HashMap<String, usize>,
268}
269
270impl PartialEq for MeasurementSchema {
271    fn eq(&self, other: &Self) -> bool {
272        self.measurement == other.measurement && self.columns == other.columns
273    }
274}
275
276impl Eq for MeasurementSchema {}
277
278impl<'de> serde::Deserialize<'de> for MeasurementSchema {
279    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
280    where
281        D: serde::Deserializer<'de>,
282    {
283        #[derive(Deserialize)]
284        struct Helper {
285            measurement: String,
286            columns: Vec<ColumnDef>,
287        }
288        let h = Helper::deserialize(deserializer)?;
289        let column_index = h
290            .columns
291            .iter()
292            .enumerate()
293            .map(|(i, c)| (c.name.clone(), i))
294            .collect();
295        Ok(Self {
296            measurement: h.measurement,
297            columns: h.columns,
298            column_index,
299        })
300    }
301}
302
303impl MeasurementSchema {
304    /// Create a new schema with just the timestamp column.
305    #[must_use]
306    pub fn new(measurement: impl Into<String>) -> Self {
307        let ts_col = ColumnDef {
308            name: crate::TIME_COLUMN.to_string(),
309            column_type: ColumnType::Timestamp,
310            role: ColumnRole::Timestamp,
311        };
312        let mut column_index = HashMap::new();
313        column_index.insert(ts_col.name.clone(), 0);
314        Self {
315            measurement: measurement.into(),
316            columns: vec![ts_col],
317            column_index,
318        }
319    }
320
321    /// Returns the measurement name.
322    #[inline]
323    #[must_use]
324    pub fn measurement(&self) -> &str {
325        &self.measurement
326    }
327
328    /// Returns the column definitions (timestamp first, then tags, then fields).
329    #[inline]
330    #[must_use]
331    pub fn columns(&self) -> &[ColumnDef] {
332        &self.columns
333    }
334
335    /// Add a tag column if it doesn't already exist.
336    ///
337    /// Returns `true` if the tag was newly added, `false` if it already
338    /// exists as a tag. Logs a warning if the name conflicts
339    /// with an existing field column.
340    #[must_use]
341    pub fn add_tag(&mut self, name: &str) -> bool {
342        if let Some(existing) = self.columns.iter().find(|c| c.name == name) {
343            if existing.role != ColumnRole::Tag {
344                // Reject writes with conflicting roles
345                // instead of silently ignoring them.
346                tracing::error!(
347                    measurement = %self.measurement,
348                    column = name,
349                    existing_role = ?existing.role,
350                    "tag name conflicts with existing column role — rejecting"
351                );
352            }
353            return false;
354        }
355        let idx = self.columns.len();
356        self.columns.push(ColumnDef {
357            name: name.to_string(),
358            column_type: ColumnType::String,
359            role: ColumnRole::Tag,
360        });
361        self.column_index.insert(name.to_string(), idx);
362        true
363    }
364
365    /// Add a field column if it doesn't already exist.
366    ///
367    /// # Errors
368    ///
369    /// Returns [`SchemaError::TypeConflict`] if the field already exists with a
370    /// different type.
371    pub fn add_field(&mut self, name: &str, field_value: &FieldValue) -> Result<bool, SchemaError> {
372        let new_type = ColumnType::from_field_value(field_value);
373
374        if let Some(existing) = self.columns.iter().find(|c| c.name == name) {
375            // Check for role conflict (tag with same name as field)
376            if existing.role != ColumnRole::Field {
377                return Err(SchemaError::TypeConflict {
378                    measurement: self.measurement.clone(),
379                    field: name.to_string(),
380                    expected: format!("{:?}", existing.role),
381                    got: "Field".to_string(),
382                });
383            }
384            if !existing.column_type.accepts_value(field_value) {
385                return Err(Self::mismatch(
386                    &self.measurement,
387                    name,
388                    existing.column_type,
389                    field_value,
390                ));
391            }
392            return Ok(false);
393        }
394
395        let idx = self.columns.len();
396        self.columns.push(ColumnDef {
397            name: name.to_string(),
398            column_type: new_type,
399            role: ColumnRole::Field,
400        });
401        self.column_index.insert(name.to_string(), idx);
402        Ok(true)
403    }
404
405    /// The error a value that does not fit `existing` should be rejected
406    /// with.
407    ///
408    /// Two shapes, because they have two different fixes: a decimal whose
409    /// only problem is carrying more fractional digits than the column
410    /// declares is a [`SchemaError::DecimalScaleConflict`], and everything
411    /// else is a [`SchemaError::TypeConflict`].
412    fn mismatch(
413        measurement: &str,
414        field: &str,
415        existing: ColumnType,
416        value: &FieldValue,
417    ) -> SchemaError {
418        match (existing, value) {
419            // A decimal that fits the column's *scale* and still cannot be
420            // stored has run out of digits, not places: widening `10³⁷` from
421            // scale 0 to scale 4 needs 42 of them. Reporting that as a scale
422            // conflict prints "the column stores 4 places and the value
423            // needs 0", which is true and useless.
424            (ColumnType::Decimal { scale }, FieldValue::Decimal(d)) if d.scale() <= scale => {
425                SchemaError::InvalidFieldValue {
426                    field: field.to_string(),
427                    reason: format!(
428                        "{d} does not fit decimal({precision}, {scale}): it needs {needed} \
429                         significant digits at that scale, and the maximum is {precision}",
430                        precision = crate::decimal::DECIMAL_PRECISION,
431                        needed = d.mantissa().unsigned_abs().to_string().len()
432                            + usize::from(scale - d.scale()),
433                    ),
434                }
435            }
436            (ColumnType::Decimal { scale }, FieldValue::Decimal(d)) => {
437                SchemaError::DecimalScaleConflict {
438                    measurement: measurement.to_string(),
439                    field: field.to_string(),
440                    declared: scale,
441                    got: d.scale(),
442                    value: d.to_string(),
443                }
444            }
445            _ => SchemaError::TypeConflict {
446                measurement: measurement.to_string(),
447                field: field.to_string(),
448                expected: existing.to_string(),
449                got: ColumnType::from_field_value(value).to_string(),
450            },
451        }
452    }
453
454    /// Add a field column with an explicit type.
455    ///
456    /// The type-first form of [`add_field`](Self::add_field): it is how a
457    /// decimal column gets the scale it needs *before* the first value
458    /// arrives, and how a batch that introduces a decimal field creates the
459    /// column at the widest scale the batch carries rather than at whatever
460    /// scale the first point happened to have.
461    ///
462    /// Returns `true` if the column was newly added, `false` if it already
463    /// existed with exactly this type.
464    ///
465    /// # Errors
466    ///
467    /// [`SchemaError::TypeConflict`] if the name already exists as a tag or
468    /// with a different type — including a decimal with a different scale,
469    /// which is a different type.
470    pub fn declare_field(
471        &mut self,
472        name: &str,
473        column_type: ColumnType,
474    ) -> Result<bool, SchemaError> {
475        if let Some(existing) = self.columns.iter().find(|c| c.name == name) {
476            if existing.role != ColumnRole::Field {
477                return Err(SchemaError::TypeConflict {
478                    measurement: self.measurement.clone(),
479                    field: name.to_string(),
480                    expected: format!("{:?}", existing.role),
481                    got: "Field".to_string(),
482                });
483            }
484            if existing.column_type != column_type {
485                return Err(SchemaError::TypeConflict {
486                    measurement: self.measurement.clone(),
487                    field: name.to_string(),
488                    expected: existing.column_type.to_string(),
489                    got: column_type.to_string(),
490                });
491            }
492            return Ok(false);
493        }
494        SeriesKey::validate_name(name, "field name")?;
495        if let ColumnType::Decimal { scale } = column_type
496            && scale > crate::decimal::MAX_DECIMAL_SCALE
497        {
498            return Err(SchemaError::InvalidFieldValue {
499                field: name.to_string(),
500                reason: format!(
501                    "decimal scale {scale} exceeds the maximum of {}",
502                    crate::decimal::MAX_DECIMAL_SCALE
503                ),
504            });
505        }
506        let idx = self.columns.len();
507        self.columns.push(ColumnDef {
508            name: name.to_string(),
509            column_type,
510            role: ColumnRole::Field,
511        });
512        self.column_index.insert(name.to_string(), idx);
513        Ok(true)
514    }
515
516    /// Look up a column by name — O(1) via the column index.
517    #[inline]
518    #[must_use]
519    pub fn column(&self, name: &str) -> Option<&ColumnDef> {
520        self.column_index
521            .get(name)
522            .and_then(|&i| self.columns.get(i))
523    }
524
525    /// Push a pre-built column definition (e.g. during WAL replay).
526    ///
527    /// Silently ignores duplicates.
528    pub fn push_column(&mut self, col: ColumnDef) {
529        if self.column_index.contains_key(&col.name) {
530            return; // duplicate — already present
531        }
532        let idx = self.columns.len();
533        self.column_index.insert(col.name.clone(), idx);
534        self.columns.push(col);
535    }
536
537    /// Returns the number of tag columns.
538    #[must_use]
539    pub fn tag_count(&self) -> usize {
540        self.columns
541            .iter()
542            .filter(|c| c.role == ColumnRole::Tag)
543            .count()
544    }
545
546    /// Returns the number of field columns.
547    #[must_use]
548    pub fn field_count(&self) -> usize {
549        self.columns
550            .iter()
551            .filter(|c| c.role == ColumnRole::Field)
552            .count()
553    }
554
555    /// Returns the names of all tag columns.
556    #[must_use]
557    pub fn tag_names(&self) -> Vec<&str> {
558        self.columns
559            .iter()
560            .filter(|c| c.role == ColumnRole::Tag)
561            .map(|c| c.name.as_str())
562            .collect()
563    }
564
565    /// Returns the names of all field columns.
566    #[must_use]
567    pub fn field_names(&self) -> Vec<&str> {
568        self.columns
569            .iter()
570            .filter(|c| c.role == ColumnRole::Field)
571            .map(|c| c.name.as_str())
572            .collect()
573    }
574}
575
576/// A schema action that can be applied to the registry.
577#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
578pub enum SchemaAction {
579    /// Create a new measurement with the given schema.
580    CreateMeasurement(MeasurementSchema),
581    /// Add a column to an existing measurement.
582    AddColumn {
583        /// Measurement name.
584        measurement: String,
585        /// Column definition to add.
586        column: ColumnDef,
587    },
588}
589
590/// Thread-safe, in-memory schema registry.
591///
592/// Tracks the schema for every measurement. Schemas are created automatically
593/// on first write and evolve additively (new tags/fields added, type conflicts
594/// rejected).
595///
596/// # Schema Evolution
597///
598/// The registry follows a **schema-on-write** model:
599///
600/// | Operation          | Supported | Mechanism           |
601/// |--------------------|-----------|---------------------|
602/// | Add tag/field      | Yes       | Automatic on write  |
603/// | Type conflict      | Rejected  | `SchemaError`       |
604/// | Remove column      | No        | N/A (append-only)   |
605/// | Rename column      | No        | N/A                 |
606/// | Change column type | No        | Write new field     |
607///
608/// Durability is the catalog manifest's job: the database persists every
609/// action this registry returns before it writes the data that needs it.
610#[derive(Debug, Clone)]
611pub struct SchemaRegistry {
612    /// `DashMap` for per-key concurrency without
613    /// global `RwLock` contention or TOCTOU races.
614    schemas: Arc<DashMap<String, Arc<MeasurementSchema>>>,
615    /// Serialises schema *changes*. Reads never take it.
616    ///
617    /// A batch that adds columns to several measurements must be validated
618    /// as a whole and applied as a whole; a per-measurement entry lock
619    /// cannot express that without an ordering rule every caller has to
620    /// remember. Changes are rare — a measurement gains a column a handful
621    /// of times in its life — so one mutex costs nothing on the write path,
622    /// which takes the lock-free fast path below whenever nothing changes.
623    changes: Arc<std::sync::Mutex<()>>,
624}
625
626impl SchemaRegistry {
627    /// Create an empty schema registry.
628    #[must_use]
629    pub fn new() -> Self {
630        Self {
631            schemas: Arc::new(DashMap::new()),
632            changes: Arc::new(std::sync::Mutex::new(())),
633        }
634    }
635
636    /// Register every point of a batch, or none of them.
637    ///
638    /// Validates the whole batch against the current schemas *and* against
639    /// the columns earlier points of the same batch introduce, and only then
640    /// applies the additions. A type conflict anywhere in the batch rejects
641    /// it with the registry untouched — which is the property that lets the
642    /// caller persist every returned action unconditionally. Registering
643    /// point by point left the columns of the first points behind when a
644    /// later point was refused, and nothing ever persisted them.
645    ///
646    /// Returns the actions the batch caused, in application order. Empty
647    /// when the batch fits the existing schemas, which is the common case
648    /// and takes no lock.
649    ///
650    /// # Errors
651    ///
652    /// Returns [`SchemaError::TypeConflict`] if any field's type conflicts
653    /// with the schema, or with the type another point of the batch gives
654    /// the same field.
655    pub fn register_batch(
656        &self,
657        points: &[&crate::types::Point],
658    ) -> Result<Vec<SchemaAction>, SchemaError> {
659        // Fast path: every point fits an existing schema. Read-only.
660        let mut all_known = true;
661        for point in points {
662            match self.schemas.get(point.series_key().measurement()) {
663                Some(schema) => {
664                    if !Self::point_fits(&schema, point)? {
665                        all_known = false;
666                    }
667                }
668                None => all_known = false,
669            }
670        }
671        if all_known {
672            return Ok(Vec::new());
673        }
674
675        // Slow path: build the new schemas on private copies, validating as
676        // we go, and commit only once the whole batch has been accepted.
677        let _guard = self
678            .changes
679            .lock()
680            .unwrap_or_else(std::sync::PoisonError::into_inner);
681        let mut pending: std::collections::BTreeMap<String, MeasurementSchema> =
682            std::collections::BTreeMap::new();
683        let mut actions = Vec::new();
684
685        // A decimal column's scale is fixed when the column is created, so
686        // *which* point of the batch creates it must not decide it. One
687        // pre-pass takes the widest scale the batch carries for each new
688        // field, and the column is created at that scale — a batch of
689        // `1.5` then `1.4999` behaves the same as the reverse.
690        let widest = Self::widest_decimal_scales(points);
691
692        for point in points {
693            let name = point.series_key().measurement();
694            let schema = match pending.get_mut(name) {
695                Some(s) => s,
696                None => {
697                    let fresh = match self.schemas.get(name) {
698                        Some(existing) => (**existing).clone(),
699                        None => {
700                            let s = MeasurementSchema::new(name);
701                            actions.push(SchemaAction::CreateMeasurement(s.clone()));
702                            s
703                        }
704                    };
705                    pending.entry(name.to_string()).or_insert(fresh)
706                }
707            };
708            for tag_key in point.series_key().tag_keys() {
709                if schema.add_tag(tag_key) {
710                    actions.push(SchemaAction::AddColumn {
711                        measurement: name.to_string(),
712                        column: ColumnDef {
713                            name: tag_key.to_string(),
714                            column_type: ColumnType::String,
715                            role: ColumnRole::Tag,
716                        },
717                    });
718                }
719            }
720            for (field_name, field_value) in point.fields() {
721                let column_type = match ColumnType::from_field_value(field_value) {
722                    ColumnType::Decimal { scale } => ColumnType::Decimal {
723                        scale: widest
724                            .get(&(name, field_name.as_ref()))
725                            .copied()
726                            .unwrap_or(scale),
727                    },
728                    other => other,
729                };
730                match schema.column(field_name.as_ref()) {
731                    Some(existing) => {
732                        if !existing.column_type.accepts_value(field_value) {
733                            return Err(MeasurementSchema::mismatch(
734                                name,
735                                field_name.as_ref(),
736                                existing.column_type,
737                                field_value,
738                            ));
739                        }
740                    }
741                    None => {
742                        if schema.declare_field(field_name.as_ref(), column_type)? {
743                            actions.push(SchemaAction::AddColumn {
744                                measurement: name.to_string(),
745                                column: ColumnDef {
746                                    name: field_name.to_string(),
747                                    column_type,
748                                    role: ColumnRole::Field,
749                                },
750                            });
751                        }
752                    }
753                }
754            }
755        }
756
757        for (name, schema) in pending {
758            self.schemas.insert(name, Arc::new(schema));
759        }
760        Ok(actions)
761    }
762
763    /// The widest decimal scale each `(measurement, field)` in the batch
764    /// carries. Empty — and free — for a batch with no decimal fields.
765    fn widest_decimal_scales<'a>(
766        points: &[&'a crate::types::Point],
767    ) -> HashMap<(&'a str, &'a str), u8> {
768        let mut widest: HashMap<(&str, &str), u8> = HashMap::new();
769        for point in points {
770            for (field_name, field_value) in point.fields() {
771                if let FieldValue::Decimal(d) = field_value {
772                    let key = (point.series_key().measurement(), field_name.as_ref());
773                    let entry = widest.entry(key).or_insert(0);
774                    *entry = (*entry).max(d.scale());
775                }
776            }
777        }
778        widest
779    }
780
781    /// Rescale every decimal field of `points` to its column's scale.
782    ///
783    /// A decimal column stores one number of fractional digits, and this is
784    /// where a value that arrived with fewer is widened to it — `1.5` into a
785    /// scale-4 column becomes `1.5000`. Without it the memtable, the WAL and
786    /// two segments of the same column could each hold a different scale,
787    /// and a scan across them could not produce one Arrow schema.
788    ///
789    /// Returns `None` when nothing needed changing, which is the common case
790    /// and copies nothing: a batch with no decimal fields never allocates.
791    /// Otherwise returns the whole batch, with the affected points rewritten.
792    ///
793    /// Call *after* [`register_batch`](Self::register_batch), so the columns
794    /// the batch introduces already exist.
795    ///
796    /// # Errors
797    ///
798    /// [`SchemaError::DecimalScaleConflict`] if a value carries more
799    /// fractional digits than its column stores. `register_batch` refuses
800    /// the same batch for the same reason, so reaching this is a sign the
801    /// two were called out of order.
802    pub fn normalize_decimals(
803        &self,
804        points: &[&crate::types::Point],
805    ) -> Result<Option<Vec<crate::types::Point>>, SchemaError> {
806        // Nothing to do for the overwhelming majority of batches: one pass
807        // that touches no schema and allocates nothing.
808        if !points
809            .iter()
810            .any(|p| p.fields().iter().any(|(_, v)| v.is_decimal()))
811        {
812            return Ok(None);
813        }
814
815        let mut out: Vec<crate::types::Point> = Vec::with_capacity(points.len());
816        for point in points {
817            let mut rewritten: Option<crate::types::Point> = None;
818            let schema = self.schemas.get(point.series_key().measurement());
819            for (field_name, field_value) in point.fields() {
820                let FieldValue::Decimal(value) = field_value else {
821                    continue;
822                };
823                let Some(scale) = schema
824                    .as_ref()
825                    .and_then(|s| s.column(field_name.as_ref()))
826                    .and_then(|c| c.column_type.decimal_scale())
827                else {
828                    continue;
829                };
830                if scale == value.scale() {
831                    continue;
832                }
833                let rescaled = value.rescale(scale).map_err(|_| {
834                    // The same two failures, told apart the same way as in
835                    // `MeasurementSchema::mismatch`: too many decimal places
836                    // for the column, or too many significant digits once
837                    // the places are added.
838                    MeasurementSchema::mismatch(
839                        point.series_key().measurement(),
840                        field_name.as_ref(),
841                        ColumnType::Decimal { scale },
842                        field_value,
843                    )
844                })?;
845                rewritten
846                    .get_or_insert_with(|| (*point).clone())
847                    .set_field(field_name.as_ref(), FieldValue::Decimal(rescaled));
848            }
849            out.push(rewritten.unwrap_or_else(|| (*point).clone()));
850        }
851        Ok(Some(out))
852    }
853
854    /// Declare a field column before anything is written to it.
855    ///
856    /// The reason this exists is decimals: a decimal column's scale is fixed
857    /// by whatever creates the column, and letting the first meter reading
858    /// decide how many fractional digits a settlement register keeps is a
859    /// coin toss. Declaring `Decimal { scale: 4 }` up front makes it a
860    /// decision.
861    ///
862    /// Returns the actions to persist — empty when the column already
863    /// existed with exactly this type.
864    ///
865    /// # Errors
866    ///
867    /// [`SchemaError::TypeConflict`] if the column exists with a different
868    /// type, a different decimal scale, or as a tag.
869    pub fn declare_field(
870        &self,
871        measurement: &str,
872        field: &str,
873        column_type: ColumnType,
874    ) -> Result<Vec<SchemaAction>, SchemaError> {
875        let _guard = self
876            .changes
877            .lock()
878            .unwrap_or_else(std::sync::PoisonError::into_inner);
879
880        let mut actions = Vec::new();
881        let mut schema = match self.schemas.get(measurement) {
882            Some(existing) => (**existing).clone(),
883            None => {
884                let fresh = MeasurementSchema::new(measurement);
885                actions.push(SchemaAction::CreateMeasurement(fresh.clone()));
886                fresh
887            }
888        };
889        if schema.declare_field(field, column_type)? {
890            actions.push(SchemaAction::AddColumn {
891                measurement: measurement.to_string(),
892                column: ColumnDef {
893                    name: field.to_string(),
894                    column_type,
895                    role: ColumnRole::Field,
896                },
897            });
898        } else if actions.is_empty() {
899            // Nothing changed and nothing was created: no action to persist.
900            return Ok(Vec::new());
901        }
902        self.schemas
903            .insert(measurement.to_string(), Arc::new(schema));
904        Ok(actions)
905    }
906
907    /// Does `point` fit `schema` without any change? A type conflict is an
908    /// error; a missing column is `Ok(false)`.
909    fn point_fits(
910        schema: &MeasurementSchema,
911        point: &crate::types::Point,
912    ) -> Result<bool, SchemaError> {
913        for tag_key in point.series_key().tag_keys() {
914            if schema.column(tag_key).is_none() {
915                return Ok(false);
916            }
917        }
918        for (field_name, field_value) in point.fields() {
919            match schema.column(field_name.as_ref()) {
920                Some(existing) => {
921                    if !existing.column_type.accepts_value(field_value) {
922                        return Err(MeasurementSchema::mismatch(
923                            schema.measurement(),
924                            field_name.as_ref(),
925                            existing.column_type,
926                            field_value,
927                        ));
928                    }
929                }
930                None => return Ok(false),
931            }
932        }
933        Ok(true)
934    }
935
936    /// Register a single point — [`register_batch`](Self::register_batch)
937    /// for one point.
938    ///
939    /// # Errors
940    ///
941    /// Returns [`SchemaError::TypeConflict`] if a field's type doesn't match
942    /// the existing schema.
943    pub fn register_point(
944        &self,
945        point: &crate::types::Point,
946    ) -> Result<Vec<SchemaAction>, SchemaError> {
947        self.register_batch(&[point])
948    }
949
950    /// Look up the schema for a measurement.
951    ///
952    /// Returns `Arc<MeasurementSchema>` for an O(1) clone.
953    #[must_use]
954    pub fn lookup(&self, measurement: &str) -> Option<Arc<MeasurementSchema>> {
955        self.schemas.get(measurement).map(|r| r.value().clone())
956    }
957
958    /// Register a pre-built schema directly (e.g. during catalog replay on
959    /// startup).  If the measurement already has a schema, the existing one
960    /// is kept unchanged.
961    pub fn register_schema(&self, name: &str, schema: MeasurementSchema) {
962        self.schemas
963            .entry(name.to_string())
964            .or_insert(Arc::new(schema));
965    }
966
967    /// Register a measurement from a [`MeasurementSchema`] (WAL replay).
968    ///
969    /// Idempotent: if the measurement already exists the call is a no-op.
970    pub fn register_measurement(&self, ms: MeasurementSchema) {
971        self.schemas
972            .entry(ms.measurement().to_string())
973            .or_insert(Arc::new(ms));
974    }
975
976    /// Apply an `AddColumn` action from WAL replay.
977    ///
978    /// Idempotent: if the column already exists it is not duplicated.
979    /// Checks for role conflicts (e.g., tag vs field with same name).
980    pub fn apply_add_column(&self, measurement: &str, column: ColumnDef) {
981        if let Some(mut schema_arc) = self.schemas.get_mut(measurement) {
982            if let Some(existing) = schema_arc
983                .value()
984                .columns()
985                .iter()
986                .find(|c| c.name == column.name)
987            {
988                // Column exists — check for role conflict
989                if existing.role != column.role {
990                    tracing::warn!(
991                        measurement,
992                        column = %column.name,
993                        existing_role = ?existing.role,
994                        new_role = ?column.role,
995                        "WAL replay column role conflict — skipping"
996                    );
997                }
998                // Already exists (same or conflicting role) — skip
999                return;
1000            }
1001            let schema = Arc::make_mut(schema_arc.value_mut());
1002            match column.role {
1003                ColumnRole::Tag => {
1004                    let _ = schema.add_tag(&column.name);
1005                }
1006                ColumnRole::Field => {
1007                    // For WAL replay, we push the column definition directly
1008                    // since we know the type is correct.
1009                    schema.push_column(column);
1010                }
1011                ColumnRole::Timestamp => {
1012                    // Timestamp column already exists in every schema.
1013                }
1014            }
1015        }
1016    }
1017
1018    /// Returns the number of registered measurements.
1019    #[must_use]
1020    pub fn measurement_count(&self) -> usize {
1021        self.schemas.len()
1022    }
1023
1024    /// Returns all registered measurement names.
1025    #[must_use]
1026    pub fn measurement_names(&self) -> Vec<String> {
1027        self.schemas.iter().map(|r| r.key().clone()).collect()
1028    }
1029
1030    /// Remove a measurement schema. Returns the removed schema if it existed.
1031    #[must_use]
1032    pub fn remove(&self, measurement: &str) -> Option<Arc<MeasurementSchema>> {
1033        self.schemas.remove(measurement).map(|(_, v)| v)
1034    }
1035}
1036
1037impl Default for SchemaRegistry {
1038    fn default() -> Self {
1039        Self::new()
1040    }
1041}
1042
1043#[cfg(test)]
1044mod tests {
1045    use super::*;
1046    use crate::types::{FieldValue, Point, SeriesKey};
1047    use std::collections::BTreeMap;
1048
1049    fn make_point(
1050        measurement: &str,
1051        tags: &[(&str, &str)],
1052        fields: &[(&str, FieldValue)],
1053        ts: i64,
1054    ) -> Point {
1055        let tag_map: BTreeMap<String, String> = tags
1056            .iter()
1057            .map(|(k, v)| ((*k).to_string(), (*v).to_string()))
1058            .collect();
1059        let field_map: BTreeMap<String, FieldValue> = fields
1060            .iter()
1061            .map(|(k, v)| ((*k).to_string(), v.clone()))
1062            .collect();
1063        let key = SeriesKey::new(measurement, tag_map).unwrap();
1064        Point::new(key, field_map, ts).unwrap()
1065    }
1066
1067    #[test]
1068    fn measurement_schema_new() {
1069        let schema = MeasurementSchema::new("cpu");
1070        assert_eq!(schema.measurement(), "cpu");
1071        assert_eq!(schema.columns().len(), 1);
1072        assert_eq!(schema.columns()[0].name, crate::TIME_COLUMN);
1073        assert_eq!(schema.columns()[0].role, ColumnRole::Timestamp);
1074    }
1075
1076    #[test]
1077    fn measurement_schema_add_tag() {
1078        let mut schema = MeasurementSchema::new("cpu");
1079        assert!(schema.add_tag("host"));
1080        assert!(!schema.add_tag("host")); // duplicate
1081        assert_eq!(schema.tag_count(), 1);
1082        assert_eq!(schema.columns()[1].name, "host");
1083        assert_eq!(schema.columns()[1].role, ColumnRole::Tag);
1084    }
1085
1086    #[test]
1087    fn measurement_schema_add_field() {
1088        let mut schema = MeasurementSchema::new("cpu");
1089        let added = schema.add_field("value", &FieldValue::F64(1.0)).unwrap();
1090        assert!(added);
1091        let added = schema.add_field("value", &FieldValue::F64(2.0)).unwrap();
1092        assert!(!added); // same type, no error
1093        assert_eq!(schema.field_count(), 1);
1094    }
1095
1096    #[test]
1097    fn measurement_schema_type_conflict() {
1098        let mut schema = MeasurementSchema::new("cpu");
1099        schema.add_field("value", &FieldValue::F64(1.0)).unwrap();
1100        let err = schema.add_field("value", &FieldValue::I64(1)).unwrap_err();
1101        assert!(matches!(err, SchemaError::TypeConflict { .. }));
1102    }
1103
1104    #[test]
1105    fn schema_registry_auto_create() {
1106        let registry = SchemaRegistry::new();
1107        let point = make_point(
1108            "cpu",
1109            &[("host", "srv1")],
1110            &[("value", FieldValue::F64(72.5))],
1111            1_000_000_000,
1112        );
1113        let actions = registry.register_point(&point).unwrap();
1114        // Should create measurement + add host tag + add value field
1115        assert_eq!(actions.len(), 3);
1116        assert!(matches!(&actions[0], SchemaAction::CreateMeasurement(_)));
1117
1118        let schema = registry.lookup("cpu").unwrap();
1119        assert_eq!(schema.measurement(), "cpu");
1120        assert_eq!(schema.tag_count(), 1);
1121        assert_eq!(schema.field_count(), 1);
1122    }
1123
1124    #[test]
1125    fn schema_registry_additive_evolution() {
1126        let registry = SchemaRegistry::new();
1127
1128        // First write: creates schema with "value" field
1129        let p1 = make_point(
1130            "cpu",
1131            &[("host", "srv1")],
1132            &[("value", FieldValue::F64(72.5))],
1133            1,
1134        );
1135        registry.register_point(&p1).unwrap();
1136
1137        // Second write: adds new "cores" field and new "dc" tag
1138        let p2 = make_point(
1139            "cpu",
1140            &[("host", "srv1"), ("dc", "eu")],
1141            &[
1142                ("value", FieldValue::F64(73.0)),
1143                ("cores", FieldValue::I64(8)),
1144            ],
1145            2,
1146        );
1147        let actions = registry.register_point(&p2).unwrap();
1148        // Should only add new tag "dc" and new field "cores" (no create, "host" + "value" already exist)
1149        assert_eq!(actions.len(), 2);
1150
1151        let schema = registry.lookup("cpu").unwrap();
1152        assert_eq!(schema.tag_count(), 2);
1153        assert_eq!(schema.field_count(), 2);
1154    }
1155
1156    #[test]
1157    fn schema_registry_type_conflict_rejected() {
1158        let registry = SchemaRegistry::new();
1159
1160        let p1 = make_point("cpu", &[], &[("value", FieldValue::F64(72.5))], 1);
1161        registry.register_point(&p1).unwrap();
1162
1163        let p2 = make_point("cpu", &[], &[("value", FieldValue::I64(72))], 2);
1164        let err = registry.register_point(&p2).unwrap_err();
1165        assert!(matches!(err, SchemaError::TypeConflict { .. }));
1166    }
1167
1168    #[test]
1169    fn schema_registry_multiple_measurements() {
1170        let registry = SchemaRegistry::new();
1171
1172        let p1 = make_point("cpu", &[], &[("value", FieldValue::F64(72.5))], 1);
1173        let p2 = make_point("mem", &[], &[("used", FieldValue::U64(1024))], 1);
1174
1175        registry.register_point(&p1).unwrap();
1176        registry.register_point(&p2).unwrap();
1177
1178        assert_eq!(registry.measurement_count(), 2);
1179        assert!(registry.lookup("cpu").is_some());
1180        assert!(registry.lookup("mem").is_some());
1181        assert!(registry.lookup("disk").is_none());
1182    }
1183
1184    #[test]
1185    fn schema_registry_remove() {
1186        let registry = SchemaRegistry::new();
1187        let p = make_point("cpu", &[], &[("value", FieldValue::F64(1.0))], 1);
1188        registry.register_point(&p).unwrap();
1189        assert!(registry.lookup("cpu").is_some());
1190
1191        let removed = registry.remove("cpu");
1192        assert!(removed.is_some());
1193        assert!(registry.lookup("cpu").is_none());
1194        assert_eq!(registry.measurement_count(), 0);
1195    }
1196
1197    #[test]
1198    fn schema_registry_measurement_names() {
1199        let registry = SchemaRegistry::new();
1200        let p1 = make_point("cpu", &[], &[("v", FieldValue::F64(1.0))], 1);
1201        let p2 = make_point("mem", &[], &[("v", FieldValue::U64(1))], 1);
1202        registry.register_point(&p1).unwrap();
1203        registry.register_point(&p2).unwrap();
1204
1205        let mut names = registry.measurement_names();
1206        names.sort();
1207        assert_eq!(names, vec!["cpu", "mem"]);
1208    }
1209
1210    #[test]
1211    fn schema_serde_roundtrip() {
1212        let mut schema = MeasurementSchema::new("cpu");
1213        let _ = schema.add_tag("host");
1214        schema.add_field("value", &FieldValue::F64(1.0)).unwrap();
1215
1216        let json = serde_json::to_string(&schema).unwrap();
1217        let back: MeasurementSchema = serde_json::from_str(&json).unwrap();
1218        assert_eq!(schema, back);
1219    }
1220
1221    #[test]
1222    fn a_decimal_column_accepts_a_narrower_value_and_refuses_a_finer_one() {
1223        let mut schema = MeasurementSchema::new("meter");
1224        schema
1225            .declare_field("z1nb", ColumnType::Decimal { scale: 4 })
1226            .unwrap();
1227        // Fewer places: widened losslessly at write time, no schema change.
1228        assert!(
1229            !schema
1230                .add_field("z1nb", &FieldValue::Decimal("1.5".parse().unwrap()))
1231                .unwrap()
1232        );
1233        // Trailing zeros past the column's scale are still exact.
1234        assert!(
1235            !schema
1236                .add_field("z1nb", &FieldValue::Decimal("1.50000".parse().unwrap()))
1237                .unwrap()
1238        );
1239        // A real fifth digit would have to be rounded.
1240        let err = schema
1241            .add_field("z1nb", &FieldValue::Decimal("1.00005".parse().unwrap()))
1242            .unwrap_err();
1243        assert!(
1244            matches!(
1245                err,
1246                SchemaError::DecimalScaleConflict {
1247                    declared: 4,
1248                    got: 5,
1249                    ..
1250                }
1251            ),
1252            "{err:?}"
1253        );
1254    }
1255
1256    #[test]
1257    fn running_out_of_digits_is_not_reported_as_a_scale_conflict() {
1258        // `10^37` at scale 0 fits; the same value at scale 4 does not, and
1259        // saying "the column stores 4 places and the value needs 0" would be
1260        // true and useless.
1261        let mut schema = MeasurementSchema::new("m");
1262        schema
1263            .declare_field("v", ColumnType::Decimal { scale: 4 })
1264            .unwrap();
1265        let huge = crate::decimal::Decimal::new(10_i128.pow(37), 0).unwrap();
1266        let err = schema
1267            .add_field("v", &FieldValue::Decimal(huge))
1268            .unwrap_err();
1269        match err {
1270            SchemaError::InvalidFieldValue { ref reason, .. } => {
1271                assert!(reason.contains("significant digits"), "{reason}");
1272            }
1273            other => panic!("expected a precision error, got {other:?}"),
1274        }
1275    }
1276
1277    #[test]
1278    fn a_decimal_and_another_type_are_still_a_type_conflict() {
1279        let mut schema = MeasurementSchema::new("m");
1280        schema
1281            .declare_field("v", ColumnType::Decimal { scale: 2 })
1282            .unwrap();
1283        let err = schema.add_field("v", &FieldValue::F64(1.5)).unwrap_err();
1284        assert!(matches!(err, SchemaError::TypeConflict { .. }), "{err:?}");
1285    }
1286
1287    #[test]
1288    fn a_batch_creating_a_decimal_column_takes_its_widest_scale() {
1289        let registry = SchemaRegistry::new();
1290        // Coarse first, fine second — and the reverse — must agree.
1291        for order in [["1.5", "1.4999"], ["1.4999", "1.5"]] {
1292            let registry = registry.clone();
1293            let points: Vec<Point> = order
1294                .iter()
1295                .enumerate()
1296                .map(|(i, text)| {
1297                    make_point(
1298                        "meter",
1299                        &[],
1300                        &[("v", FieldValue::Decimal(text.parse().unwrap()))],
1301                        i as i64,
1302                    )
1303                })
1304                .collect();
1305            let refs: Vec<&Point> = points.iter().collect();
1306            registry.register_batch(&refs).unwrap();
1307            assert_eq!(
1308                registry
1309                    .lookup("meter")
1310                    .unwrap()
1311                    .column("v")
1312                    .unwrap()
1313                    .column_type,
1314                ColumnType::Decimal { scale: 4 },
1315                "order {order:?}"
1316            );
1317        }
1318    }
1319
1320    #[test]
1321    fn normalize_decimals_widens_to_the_column_and_copies_nothing_otherwise() {
1322        let registry = SchemaRegistry::new();
1323        let point = make_point(
1324            "meter",
1325            &[],
1326            &[("v", FieldValue::Decimal("1.5000".parse().unwrap()))],
1327            1,
1328        );
1329        registry.register_batch(&[&point]).unwrap();
1330
1331        // A batch with no decimal fields never allocates.
1332        let plain = make_point("cpu", &[], &[("v", FieldValue::F64(1.0))], 1);
1333        assert!(registry.normalize_decimals(&[&plain]).unwrap().is_none());
1334
1335        // A narrower value is rewritten at the column's scale.
1336        let narrow = make_point(
1337            "meter",
1338            &[],
1339            &[("v", FieldValue::Decimal("1.5".parse().unwrap()))],
1340            2,
1341        );
1342        let out = registry.normalize_decimals(&[&narrow]).unwrap().unwrap();
1343        match out[0].field("v") {
1344            Some(FieldValue::Decimal(d)) => {
1345                assert_eq!(d.scale(), 4);
1346                assert_eq!(d.to_string(), "1.5000");
1347            }
1348            other => panic!("expected a decimal, got {other:?}"),
1349        }
1350    }
1351
1352    #[test]
1353    fn column_type_from_field_value() {
1354        assert_eq!(
1355            ColumnType::from_field_value(&FieldValue::F64(1.0)),
1356            ColumnType::F64
1357        );
1358        assert_eq!(
1359            ColumnType::from_field_value(&FieldValue::I64(1)),
1360            ColumnType::I64
1361        );
1362        assert_eq!(
1363            ColumnType::from_field_value(&FieldValue::U64(1)),
1364            ColumnType::U64
1365        );
1366        assert_eq!(
1367            ColumnType::from_field_value(&FieldValue::Bool(true)),
1368            ColumnType::Bool
1369        );
1370        assert_eq!(
1371            ColumnType::from_field_value(&FieldValue::String("x".into())),
1372            ColumnType::String
1373        );
1374    }
1375
1376    #[test]
1377    fn column_type_display() {
1378        assert_eq!(ColumnType::Timestamp.to_string(), "timestamp");
1379        assert_eq!(ColumnType::String.to_string(), "string");
1380        assert_eq!(ColumnType::F64.to_string(), "f64");
1381        assert_eq!(ColumnType::I64.to_string(), "i64");
1382        assert_eq!(ColumnType::U64.to_string(), "u64");
1383        assert_eq!(ColumnType::Bool.to_string(), "bool");
1384    }
1385
1386    #[test]
1387    fn column_role_display() {
1388        assert_eq!(ColumnRole::Timestamp.to_string(), "timestamp");
1389        assert_eq!(ColumnRole::Tag.to_string(), "tag");
1390        assert_eq!(ColumnRole::Field.to_string(), "field");
1391    }
1392
1393    #[test]
1394    fn measurement_schema_column_not_found() {
1395        let schema = MeasurementSchema::new("cpu");
1396        assert!(schema.column("nonexistent").is_none());
1397    }
1398
1399    #[test]
1400    fn schema_registry_remove_nonexistent() {
1401        let registry = SchemaRegistry::new();
1402        let removed = registry.remove("nope");
1403        assert!(removed.is_none());
1404    }
1405
1406    #[test]
1407    fn schema_action_serde_roundtrip() {
1408        let action = SchemaAction::CreateMeasurement(MeasurementSchema::new("cpu"));
1409        let json = serde_json::to_string(&action).unwrap();
1410        let back: SchemaAction = serde_json::from_str(&json).unwrap();
1411        assert_eq!(action, back);
1412
1413        let action = SchemaAction::AddColumn {
1414            measurement: "cpu".into(),
1415            column: ColumnDef {
1416                name: "value".into(),
1417                column_type: ColumnType::F64,
1418                role: ColumnRole::Field,
1419            },
1420        };
1421        let json = serde_json::to_string(&action).unwrap();
1422        let back: SchemaAction = serde_json::from_str(&json).unwrap();
1423        assert_eq!(action, back);
1424    }
1425
1426    #[test]
1427    fn measurement_schema_accessors() {
1428        let mut schema = MeasurementSchema::new("cpu");
1429        assert!(schema.add_tag("host"));
1430        assert_eq!(schema.measurement(), "cpu");
1431        assert_eq!(schema.columns().len(), 2);
1432    }
1433}