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;
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}
81
82impl ColumnType {
83    /// Derive the column type from a [`FieldValue`].
84    #[inline]
85    #[must_use]
86    pub fn from_field_value(value: &FieldValue) -> Self {
87        match value {
88            FieldValue::F64(_) => Self::F64,
89            FieldValue::I64(_) => Self::I64,
90            FieldValue::U64(_) => Self::U64,
91            FieldValue::Bool(_) => Self::Bool,
92            FieldValue::String(_) => Self::String,
93        }
94    }
95}
96
97impl fmt::Display for ColumnType {
98    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
99        match self {
100            Self::Timestamp => write!(f, "timestamp"),
101            Self::String => write!(f, "string"),
102            Self::F64 => write!(f, "f64"),
103            Self::I64 => write!(f, "i64"),
104            Self::U64 => write!(f, "u64"),
105            Self::Bool => write!(f, "bool"),
106        }
107    }
108}
109
110/// A column definition within a measurement schema.
111#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
112pub struct ColumnDef {
113    /// Column name.
114    pub name: String,
115    /// Data type.
116    pub column_type: ColumnType,
117    /// Role: timestamp, tag, or field.
118    pub role: ColumnRole,
119}
120
121/// Schema for a single measurement (table).
122///
123/// Contains an ordered list of column definitions. The first column is always
124/// the timestamp. Tags and fields follow. Columns are private to preserve
125/// invariants — use [`add_tag()`](Self::add_tag) and
126/// [`add_field()`](Self::add_field) for modification.
127#[derive(Debug, Clone, Serialize)]
128pub struct MeasurementSchema {
129    measurement: String,
130    columns: Vec<ColumnDef>,
131    /// O(1) column lookup index (name → position in `columns`).
132    #[serde(skip)]
133    column_index: HashMap<String, usize>,
134}
135
136impl PartialEq for MeasurementSchema {
137    fn eq(&self, other: &Self) -> bool {
138        self.measurement == other.measurement && self.columns == other.columns
139    }
140}
141
142impl Eq for MeasurementSchema {}
143
144impl<'de> serde::Deserialize<'de> for MeasurementSchema {
145    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
146    where
147        D: serde::Deserializer<'de>,
148    {
149        #[derive(Deserialize)]
150        struct Helper {
151            measurement: String,
152            columns: Vec<ColumnDef>,
153        }
154        let h = Helper::deserialize(deserializer)?;
155        let column_index = h
156            .columns
157            .iter()
158            .enumerate()
159            .map(|(i, c)| (c.name.clone(), i))
160            .collect();
161        Ok(Self {
162            measurement: h.measurement,
163            columns: h.columns,
164            column_index,
165        })
166    }
167}
168
169impl MeasurementSchema {
170    /// Create a new schema with just the timestamp column.
171    #[must_use]
172    pub fn new(measurement: impl Into<String>) -> Self {
173        let ts_col = ColumnDef {
174            name: crate::TIME_COLUMN.to_string(),
175            column_type: ColumnType::Timestamp,
176            role: ColumnRole::Timestamp,
177        };
178        let mut column_index = HashMap::new();
179        column_index.insert(ts_col.name.clone(), 0);
180        Self {
181            measurement: measurement.into(),
182            columns: vec![ts_col],
183            column_index,
184        }
185    }
186
187    /// Returns the measurement name.
188    #[inline]
189    #[must_use]
190    pub fn measurement(&self) -> &str {
191        &self.measurement
192    }
193
194    /// Returns the column definitions (timestamp first, then tags, then fields).
195    #[inline]
196    #[must_use]
197    pub fn columns(&self) -> &[ColumnDef] {
198        &self.columns
199    }
200
201    /// Add a tag column if it doesn't already exist.
202    ///
203    /// Returns `true` if the tag was newly added, `false` if it already
204    /// exists as a tag. Logs a warning if the name conflicts
205    /// with an existing field column.
206    #[must_use]
207    pub fn add_tag(&mut self, name: &str) -> bool {
208        if let Some(existing) = self.columns.iter().find(|c| c.name == name) {
209            if existing.role != ColumnRole::Tag {
210                // Reject writes with conflicting roles
211                // instead of silently ignoring them.
212                tracing::error!(
213                    measurement = %self.measurement,
214                    column = name,
215                    existing_role = ?existing.role,
216                    "tag name conflicts with existing column role — rejecting"
217                );
218            }
219            return false;
220        }
221        let idx = self.columns.len();
222        self.columns.push(ColumnDef {
223            name: name.to_string(),
224            column_type: ColumnType::String,
225            role: ColumnRole::Tag,
226        });
227        self.column_index.insert(name.to_string(), idx);
228        true
229    }
230
231    /// Add a field column if it doesn't already exist.
232    ///
233    /// # Errors
234    ///
235    /// Returns [`SchemaError::TypeConflict`] if the field already exists with a
236    /// different type.
237    pub fn add_field(&mut self, name: &str, field_value: &FieldValue) -> Result<bool, SchemaError> {
238        let new_type = ColumnType::from_field_value(field_value);
239
240        if let Some(existing) = self.columns.iter().find(|c| c.name == name) {
241            // Check for role conflict (tag with same name as field)
242            if existing.role != ColumnRole::Field {
243                return Err(SchemaError::TypeConflict {
244                    measurement: self.measurement.clone(),
245                    field: name.to_string(),
246                    expected: format!("{:?}", existing.role),
247                    got: "Field".to_string(),
248                });
249            }
250            if existing.column_type != new_type {
251                return Err(SchemaError::TypeConflict {
252                    measurement: self.measurement.clone(),
253                    field: name.to_string(),
254                    expected: existing.column_type.to_string(),
255                    got: new_type.to_string(),
256                });
257            }
258            return Ok(false);
259        }
260
261        let idx = self.columns.len();
262        self.columns.push(ColumnDef {
263            name: name.to_string(),
264            column_type: new_type,
265            role: ColumnRole::Field,
266        });
267        self.column_index.insert(name.to_string(), idx);
268        Ok(true)
269    }
270
271    /// Look up a column by name — O(1) via the column index.
272    #[inline]
273    #[must_use]
274    pub fn column(&self, name: &str) -> Option<&ColumnDef> {
275        self.column_index
276            .get(name)
277            .and_then(|&i| self.columns.get(i))
278    }
279
280    /// Push a pre-built column definition (e.g. during WAL replay).
281    ///
282    /// Silently ignores duplicates.
283    pub fn push_column(&mut self, col: ColumnDef) {
284        if self.column_index.contains_key(&col.name) {
285            return; // duplicate — already present
286        }
287        let idx = self.columns.len();
288        self.column_index.insert(col.name.clone(), idx);
289        self.columns.push(col);
290    }
291
292    /// Returns the number of tag columns.
293    #[must_use]
294    pub fn tag_count(&self) -> usize {
295        self.columns
296            .iter()
297            .filter(|c| c.role == ColumnRole::Tag)
298            .count()
299    }
300
301    /// Returns the number of field columns.
302    #[must_use]
303    pub fn field_count(&self) -> usize {
304        self.columns
305            .iter()
306            .filter(|c| c.role == ColumnRole::Field)
307            .count()
308    }
309
310    /// Returns the names of all tag columns.
311    #[must_use]
312    pub fn tag_names(&self) -> Vec<&str> {
313        self.columns
314            .iter()
315            .filter(|c| c.role == ColumnRole::Tag)
316            .map(|c| c.name.as_str())
317            .collect()
318    }
319
320    /// Returns the names of all field columns.
321    #[must_use]
322    pub fn field_names(&self) -> Vec<&str> {
323        self.columns
324            .iter()
325            .filter(|c| c.role == ColumnRole::Field)
326            .map(|c| c.name.as_str())
327            .collect()
328    }
329}
330
331/// A schema action that can be applied to the registry.
332#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
333pub enum SchemaAction {
334    /// Create a new measurement with the given schema.
335    CreateMeasurement(MeasurementSchema),
336    /// Add a column to an existing measurement.
337    AddColumn {
338        /// Measurement name.
339        measurement: String,
340        /// Column definition to add.
341        column: ColumnDef,
342    },
343}
344
345/// Thread-safe, in-memory schema registry.
346///
347/// Tracks the schema for every measurement. Schemas are created automatically
348/// on first write and evolve additively (new tags/fields added, type conflicts
349/// rejected).
350///
351/// # Schema Evolution
352///
353/// The registry follows a **schema-on-write** model:
354///
355/// | Operation          | Supported | Mechanism           |
356/// |--------------------|-----------|---------------------|
357/// | Add tag/field      | Yes       | Automatic on write  |
358/// | Type conflict      | Rejected  | `SchemaError`       |
359/// | Remove column      | No        | N/A (append-only)   |
360/// | Rename column      | No        | N/A                 |
361/// | Change column type | No        | Write new field     |
362///
363/// Durability is the catalog manifest's job: the database persists every
364/// action this registry returns before it writes the data that needs it.
365#[derive(Debug, Clone)]
366pub struct SchemaRegistry {
367    /// `DashMap` for per-key concurrency without
368    /// global `RwLock` contention or TOCTOU races.
369    schemas: Arc<DashMap<String, Arc<MeasurementSchema>>>,
370    /// Serialises schema *changes*. Reads never take it.
371    ///
372    /// A batch that adds columns to several measurements must be validated
373    /// as a whole and applied as a whole; a per-measurement entry lock
374    /// cannot express that without an ordering rule every caller has to
375    /// remember. Changes are rare — a measurement gains a column a handful
376    /// of times in its life — so one mutex costs nothing on the write path,
377    /// which takes the lock-free fast path below whenever nothing changes.
378    changes: Arc<std::sync::Mutex<()>>,
379}
380
381impl SchemaRegistry {
382    /// Create an empty schema registry.
383    #[must_use]
384    pub fn new() -> Self {
385        Self {
386            schemas: Arc::new(DashMap::new()),
387            changes: Arc::new(std::sync::Mutex::new(())),
388        }
389    }
390
391    /// Register every point of a batch, or none of them.
392    ///
393    /// Validates the whole batch against the current schemas *and* against
394    /// the columns earlier points of the same batch introduce, and only then
395    /// applies the additions. A type conflict anywhere in the batch rejects
396    /// it with the registry untouched — which is the property that lets the
397    /// caller persist every returned action unconditionally. Registering
398    /// point by point left the columns of the first points behind when a
399    /// later point was refused, and nothing ever persisted them.
400    ///
401    /// Returns the actions the batch caused, in application order. Empty
402    /// when the batch fits the existing schemas, which is the common case
403    /// and takes no lock.
404    ///
405    /// # Errors
406    ///
407    /// Returns [`SchemaError::TypeConflict`] if any field's type conflicts
408    /// with the schema, or with the type another point of the batch gives
409    /// the same field.
410    pub fn register_batch(
411        &self,
412        points: &[&crate::types::Point],
413    ) -> Result<Vec<SchemaAction>, SchemaError> {
414        // Fast path: every point fits an existing schema. Read-only.
415        let mut all_known = true;
416        for point in points {
417            match self.schemas.get(point.series_key().measurement()) {
418                Some(schema) => {
419                    if !Self::point_fits(&schema, point)? {
420                        all_known = false;
421                    }
422                }
423                None => all_known = false,
424            }
425        }
426        if all_known {
427            return Ok(Vec::new());
428        }
429
430        // Slow path: build the new schemas on private copies, validating as
431        // we go, and commit only once the whole batch has been accepted.
432        let _guard = self
433            .changes
434            .lock()
435            .unwrap_or_else(std::sync::PoisonError::into_inner);
436        let mut pending: std::collections::BTreeMap<String, MeasurementSchema> =
437            std::collections::BTreeMap::new();
438        let mut actions = Vec::new();
439
440        for point in points {
441            let name = point.series_key().measurement();
442            let schema = match pending.get_mut(name) {
443                Some(s) => s,
444                None => {
445                    let fresh = match self.schemas.get(name) {
446                        Some(existing) => (**existing).clone(),
447                        None => {
448                            let s = MeasurementSchema::new(name);
449                            actions.push(SchemaAction::CreateMeasurement(s.clone()));
450                            s
451                        }
452                    };
453                    pending.entry(name.to_string()).or_insert(fresh)
454                }
455            };
456            for tag_key in point.series_key().tag_keys() {
457                if schema.add_tag(tag_key) {
458                    actions.push(SchemaAction::AddColumn {
459                        measurement: name.to_string(),
460                        column: ColumnDef {
461                            name: tag_key.to_string(),
462                            column_type: ColumnType::String,
463                            role: ColumnRole::Tag,
464                        },
465                    });
466                }
467            }
468            for (field_name, field_value) in point.fields() {
469                if schema.add_field(field_name.as_ref(), field_value)? {
470                    actions.push(SchemaAction::AddColumn {
471                        measurement: name.to_string(),
472                        column: ColumnDef {
473                            name: field_name.to_string(),
474                            column_type: ColumnType::from_field_value(field_value),
475                            role: ColumnRole::Field,
476                        },
477                    });
478                }
479            }
480        }
481
482        for (name, schema) in pending {
483            self.schemas.insert(name, Arc::new(schema));
484        }
485        Ok(actions)
486    }
487
488    /// Does `point` fit `schema` without any change? A type conflict is an
489    /// error; a missing column is `Ok(false)`.
490    fn point_fits(
491        schema: &MeasurementSchema,
492        point: &crate::types::Point,
493    ) -> Result<bool, SchemaError> {
494        for tag_key in point.series_key().tag_keys() {
495            if schema.column(tag_key).is_none() {
496                return Ok(false);
497            }
498        }
499        for (field_name, field_value) in point.fields() {
500            match schema.column(field_name.as_ref()) {
501                Some(existing) => {
502                    let new_type = ColumnType::from_field_value(field_value);
503                    if existing.column_type != new_type {
504                        return Err(SchemaError::TypeConflict {
505                            measurement: schema.measurement().to_string(),
506                            field: field_name.to_string(),
507                            expected: existing.column_type.to_string(),
508                            got: new_type.to_string(),
509                        });
510                    }
511                }
512                None => return Ok(false),
513            }
514        }
515        Ok(true)
516    }
517
518    /// Register a single point — [`register_batch`](Self::register_batch)
519    /// for one point.
520    ///
521    /// # Errors
522    ///
523    /// Returns [`SchemaError::TypeConflict`] if a field's type doesn't match
524    /// the existing schema.
525    pub fn register_point(
526        &self,
527        point: &crate::types::Point,
528    ) -> Result<Vec<SchemaAction>, SchemaError> {
529        self.register_batch(&[point])
530    }
531
532    /// Look up the schema for a measurement.
533    ///
534    /// Returns `Arc<MeasurementSchema>` for an O(1) clone.
535    #[must_use]
536    pub fn lookup(&self, measurement: &str) -> Option<Arc<MeasurementSchema>> {
537        self.schemas.get(measurement).map(|r| r.value().clone())
538    }
539
540    /// Register a pre-built schema directly (e.g. during catalog replay on
541    /// startup).  If the measurement already has a schema, the existing one
542    /// is kept unchanged.
543    pub fn register_schema(&self, name: &str, schema: MeasurementSchema) {
544        self.schemas
545            .entry(name.to_string())
546            .or_insert(Arc::new(schema));
547    }
548
549    /// Register a measurement from a [`MeasurementSchema`] (WAL replay).
550    ///
551    /// Idempotent: if the measurement already exists the call is a no-op.
552    pub fn register_measurement(&self, ms: MeasurementSchema) {
553        self.schemas
554            .entry(ms.measurement().to_string())
555            .or_insert(Arc::new(ms));
556    }
557
558    /// Apply an `AddColumn` action from WAL replay.
559    ///
560    /// Idempotent: if the column already exists it is not duplicated.
561    /// Checks for role conflicts (e.g., tag vs field with same name).
562    pub fn apply_add_column(&self, measurement: &str, column: ColumnDef) {
563        if let Some(mut schema_arc) = self.schemas.get_mut(measurement) {
564            if let Some(existing) = schema_arc
565                .value()
566                .columns()
567                .iter()
568                .find(|c| c.name == column.name)
569            {
570                // Column exists — check for role conflict
571                if existing.role != column.role {
572                    tracing::warn!(
573                        measurement,
574                        column = %column.name,
575                        existing_role = ?existing.role,
576                        new_role = ?column.role,
577                        "WAL replay column role conflict — skipping"
578                    );
579                }
580                // Already exists (same or conflicting role) — skip
581                return;
582            }
583            let schema = Arc::make_mut(schema_arc.value_mut());
584            match column.role {
585                ColumnRole::Tag => {
586                    let _ = schema.add_tag(&column.name);
587                }
588                ColumnRole::Field => {
589                    // For WAL replay, we push the column definition directly
590                    // since we know the type is correct.
591                    schema.push_column(column);
592                }
593                ColumnRole::Timestamp => {
594                    // Timestamp column already exists in every schema.
595                }
596            }
597        }
598    }
599
600    /// Returns the number of registered measurements.
601    #[must_use]
602    pub fn measurement_count(&self) -> usize {
603        self.schemas.len()
604    }
605
606    /// Returns all registered measurement names.
607    #[must_use]
608    pub fn measurement_names(&self) -> Vec<String> {
609        self.schemas.iter().map(|r| r.key().clone()).collect()
610    }
611
612    /// Remove a measurement schema. Returns the removed schema if it existed.
613    #[must_use]
614    pub fn remove(&self, measurement: &str) -> Option<Arc<MeasurementSchema>> {
615        self.schemas.remove(measurement).map(|(_, v)| v)
616    }
617}
618
619impl Default for SchemaRegistry {
620    fn default() -> Self {
621        Self::new()
622    }
623}
624
625#[cfg(test)]
626mod tests {
627    use super::*;
628    use crate::types::{FieldValue, Point, SeriesKey};
629    use std::collections::BTreeMap;
630
631    fn make_point(
632        measurement: &str,
633        tags: &[(&str, &str)],
634        fields: &[(&str, FieldValue)],
635        ts: i64,
636    ) -> Point {
637        let tag_map: BTreeMap<String, String> = tags
638            .iter()
639            .map(|(k, v)| ((*k).to_string(), (*v).to_string()))
640            .collect();
641        let field_map: BTreeMap<String, FieldValue> = fields
642            .iter()
643            .map(|(k, v)| ((*k).to_string(), v.clone()))
644            .collect();
645        let key = SeriesKey::new(measurement, tag_map).unwrap();
646        Point::new(key, field_map, ts).unwrap()
647    }
648
649    #[test]
650    fn measurement_schema_new() {
651        let schema = MeasurementSchema::new("cpu");
652        assert_eq!(schema.measurement(), "cpu");
653        assert_eq!(schema.columns().len(), 1);
654        assert_eq!(schema.columns()[0].name, crate::TIME_COLUMN);
655        assert_eq!(schema.columns()[0].role, ColumnRole::Timestamp);
656    }
657
658    #[test]
659    fn measurement_schema_add_tag() {
660        let mut schema = MeasurementSchema::new("cpu");
661        assert!(schema.add_tag("host"));
662        assert!(!schema.add_tag("host")); // duplicate
663        assert_eq!(schema.tag_count(), 1);
664        assert_eq!(schema.columns()[1].name, "host");
665        assert_eq!(schema.columns()[1].role, ColumnRole::Tag);
666    }
667
668    #[test]
669    fn measurement_schema_add_field() {
670        let mut schema = MeasurementSchema::new("cpu");
671        let added = schema.add_field("value", &FieldValue::F64(1.0)).unwrap();
672        assert!(added);
673        let added = schema.add_field("value", &FieldValue::F64(2.0)).unwrap();
674        assert!(!added); // same type, no error
675        assert_eq!(schema.field_count(), 1);
676    }
677
678    #[test]
679    fn measurement_schema_type_conflict() {
680        let mut schema = MeasurementSchema::new("cpu");
681        schema.add_field("value", &FieldValue::F64(1.0)).unwrap();
682        let err = schema.add_field("value", &FieldValue::I64(1)).unwrap_err();
683        assert!(matches!(err, SchemaError::TypeConflict { .. }));
684    }
685
686    #[test]
687    fn schema_registry_auto_create() {
688        let registry = SchemaRegistry::new();
689        let point = make_point(
690            "cpu",
691            &[("host", "srv1")],
692            &[("value", FieldValue::F64(72.5))],
693            1_000_000_000,
694        );
695        let actions = registry.register_point(&point).unwrap();
696        // Should create measurement + add host tag + add value field
697        assert_eq!(actions.len(), 3);
698        assert!(matches!(&actions[0], SchemaAction::CreateMeasurement(_)));
699
700        let schema = registry.lookup("cpu").unwrap();
701        assert_eq!(schema.measurement(), "cpu");
702        assert_eq!(schema.tag_count(), 1);
703        assert_eq!(schema.field_count(), 1);
704    }
705
706    #[test]
707    fn schema_registry_additive_evolution() {
708        let registry = SchemaRegistry::new();
709
710        // First write: creates schema with "value" field
711        let p1 = make_point(
712            "cpu",
713            &[("host", "srv1")],
714            &[("value", FieldValue::F64(72.5))],
715            1,
716        );
717        registry.register_point(&p1).unwrap();
718
719        // Second write: adds new "cores" field and new "dc" tag
720        let p2 = make_point(
721            "cpu",
722            &[("host", "srv1"), ("dc", "eu")],
723            &[
724                ("value", FieldValue::F64(73.0)),
725                ("cores", FieldValue::I64(8)),
726            ],
727            2,
728        );
729        let actions = registry.register_point(&p2).unwrap();
730        // Should only add new tag "dc" and new field "cores" (no create, "host" + "value" already exist)
731        assert_eq!(actions.len(), 2);
732
733        let schema = registry.lookup("cpu").unwrap();
734        assert_eq!(schema.tag_count(), 2);
735        assert_eq!(schema.field_count(), 2);
736    }
737
738    #[test]
739    fn schema_registry_type_conflict_rejected() {
740        let registry = SchemaRegistry::new();
741
742        let p1 = make_point("cpu", &[], &[("value", FieldValue::F64(72.5))], 1);
743        registry.register_point(&p1).unwrap();
744
745        let p2 = make_point("cpu", &[], &[("value", FieldValue::I64(72))], 2);
746        let err = registry.register_point(&p2).unwrap_err();
747        assert!(matches!(err, SchemaError::TypeConflict { .. }));
748    }
749
750    #[test]
751    fn schema_registry_multiple_measurements() {
752        let registry = SchemaRegistry::new();
753
754        let p1 = make_point("cpu", &[], &[("value", FieldValue::F64(72.5))], 1);
755        let p2 = make_point("mem", &[], &[("used", FieldValue::U64(1024))], 1);
756
757        registry.register_point(&p1).unwrap();
758        registry.register_point(&p2).unwrap();
759
760        assert_eq!(registry.measurement_count(), 2);
761        assert!(registry.lookup("cpu").is_some());
762        assert!(registry.lookup("mem").is_some());
763        assert!(registry.lookup("disk").is_none());
764    }
765
766    #[test]
767    fn schema_registry_remove() {
768        let registry = SchemaRegistry::new();
769        let p = make_point("cpu", &[], &[("value", FieldValue::F64(1.0))], 1);
770        registry.register_point(&p).unwrap();
771        assert!(registry.lookup("cpu").is_some());
772
773        let removed = registry.remove("cpu");
774        assert!(removed.is_some());
775        assert!(registry.lookup("cpu").is_none());
776        assert_eq!(registry.measurement_count(), 0);
777    }
778
779    #[test]
780    fn schema_registry_measurement_names() {
781        let registry = SchemaRegistry::new();
782        let p1 = make_point("cpu", &[], &[("v", FieldValue::F64(1.0))], 1);
783        let p2 = make_point("mem", &[], &[("v", FieldValue::U64(1))], 1);
784        registry.register_point(&p1).unwrap();
785        registry.register_point(&p2).unwrap();
786
787        let mut names = registry.measurement_names();
788        names.sort();
789        assert_eq!(names, vec!["cpu", "mem"]);
790    }
791
792    #[test]
793    fn schema_serde_roundtrip() {
794        let mut schema = MeasurementSchema::new("cpu");
795        let _ = schema.add_tag("host");
796        schema.add_field("value", &FieldValue::F64(1.0)).unwrap();
797
798        let json = serde_json::to_string(&schema).unwrap();
799        let back: MeasurementSchema = serde_json::from_str(&json).unwrap();
800        assert_eq!(schema, back);
801    }
802
803    #[test]
804    fn column_type_from_field_value() {
805        assert_eq!(
806            ColumnType::from_field_value(&FieldValue::F64(1.0)),
807            ColumnType::F64
808        );
809        assert_eq!(
810            ColumnType::from_field_value(&FieldValue::I64(1)),
811            ColumnType::I64
812        );
813        assert_eq!(
814            ColumnType::from_field_value(&FieldValue::U64(1)),
815            ColumnType::U64
816        );
817        assert_eq!(
818            ColumnType::from_field_value(&FieldValue::Bool(true)),
819            ColumnType::Bool
820        );
821        assert_eq!(
822            ColumnType::from_field_value(&FieldValue::String("x".into())),
823            ColumnType::String
824        );
825    }
826
827    #[test]
828    fn column_type_display() {
829        assert_eq!(ColumnType::Timestamp.to_string(), "timestamp");
830        assert_eq!(ColumnType::String.to_string(), "string");
831        assert_eq!(ColumnType::F64.to_string(), "f64");
832        assert_eq!(ColumnType::I64.to_string(), "i64");
833        assert_eq!(ColumnType::U64.to_string(), "u64");
834        assert_eq!(ColumnType::Bool.to_string(), "bool");
835    }
836
837    #[test]
838    fn column_role_display() {
839        assert_eq!(ColumnRole::Timestamp.to_string(), "timestamp");
840        assert_eq!(ColumnRole::Tag.to_string(), "tag");
841        assert_eq!(ColumnRole::Field.to_string(), "field");
842    }
843
844    #[test]
845    fn measurement_schema_column_not_found() {
846        let schema = MeasurementSchema::new("cpu");
847        assert!(schema.column("nonexistent").is_none());
848    }
849
850    #[test]
851    fn schema_registry_remove_nonexistent() {
852        let registry = SchemaRegistry::new();
853        let removed = registry.remove("nope");
854        assert!(removed.is_none());
855    }
856
857    #[test]
858    fn schema_action_serde_roundtrip() {
859        let action = SchemaAction::CreateMeasurement(MeasurementSchema::new("cpu"));
860        let json = serde_json::to_string(&action).unwrap();
861        let back: SchemaAction = serde_json::from_str(&json).unwrap();
862        assert_eq!(action, back);
863
864        let action = SchemaAction::AddColumn {
865            measurement: "cpu".into(),
866            column: ColumnDef {
867                name: "value".into(),
868                column_type: ColumnType::F64,
869                role: ColumnRole::Field,
870            },
871        };
872        let json = serde_json::to_string(&action).unwrap();
873        let back: SchemaAction = serde_json::from_str(&json).unwrap();
874        assert_eq!(action, back);
875    }
876
877    #[test]
878    fn measurement_schema_accessors() {
879        let mut schema = MeasurementSchema::new("cpu");
880        assert!(schema.add_tag("host"));
881        assert_eq!(schema.measurement(), "cpu");
882        assert_eq!(schema.columns().len(), 2);
883    }
884}