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