Skip to main content

cqlite_core/schema/
mod.rs

1//! Schema definition and parsing for CQLite
2//!
3//! This module handles schema definitions that describe the structure of
4//! Cassandra tables for schema-aware SSTable reading. It supports both
5//! JSON-based schema definitions and CQL CREATE TABLE statement parsing.
6
7pub mod aggregator;
8pub mod cql_parser;
9pub mod discovery;
10#[cfg(feature = "experimental")]
11pub mod json_exporter;
12pub mod parser;
13pub mod registry;
14
15// Concern submodules split out of this file (issue #1134, source-split
16// doctrine). They add inherent impls on the types defined here and own the
17// `UdtRegistry` type; the `pub use` below preserves the `schema::UdtRegistry`
18// public path.
19mod cql_type_parser;
20mod key_ordering;
21mod schema_comparator;
22mod udt_registry;
23
24pub use udt_registry::{split_qualified_udt, udt_registry_from_cql, UdtRegistry};
25
26/// Test-only work counters for the derived-comparator caching path (issue #1709).
27///
28/// Compiled out entirely in non-test builds (`#[cfg(test)]`), so they impose
29/// zero cost on production code. They let the registry unit tests assert that,
30/// after a schema is registered, [`registry::SchemaRegistry::get_parsing_context`]
31/// performs ZERO `CqlType::parse` calls and ZERO `TableSchema` deep clones on the
32/// request path (both were `O(columns)` / `4` per call before caching).
33///
34/// The counters are **thread-local**, not global atomics: with the default
35/// `#[tokio::test]` current-thread runtime, a test's awaited async work runs on
36/// the test's own OS thread, so each test observes only its own parse/clone work
37/// and is immune to pollution from tests running concurrently on other threads.
38#[cfg(test)]
39pub(crate) mod work_counters {
40    use std::cell::Cell;
41
42    thread_local! {
43        /// Incremented once per [`super::CqlType::parse`] invocation.
44        static PARSE_CALLS: Cell<usize> = const { Cell::new(0) };
45        /// Incremented once per `TableSchema` deep clone performed by
46        /// [`super::registry::SchemaRegistry::get_schema`].
47        static SCHEMA_CLONES: Cell<usize> = const { Cell::new(0) };
48    }
49
50    pub(crate) fn record_parse_call() {
51        PARSE_CALLS.with(|c| c.set(c.get() + 1));
52    }
53
54    pub(crate) fn record_schema_clone() {
55        SCHEMA_CLONES.with(|c| c.set(c.get() + 1));
56    }
57
58    /// Reset both counters to zero (call before the measured request path).
59    pub(crate) fn reset() {
60        PARSE_CALLS.with(|c| c.set(0));
61        SCHEMA_CLONES.with(|c| c.set(0));
62    }
63
64    pub(crate) fn parse_calls() -> usize {
65        PARSE_CALLS.with(|c| c.get())
66    }
67
68    pub(crate) fn schema_clones() -> usize {
69        SCHEMA_CLONES.with(|c| c.get())
70    }
71}
72
73// Re-export aggregator components
74pub use aggregator::{
75    AggregatorConfig, LoadErrorType, LoadResult, SchemaAggregator, SchemaLoadError,
76    SchemaLoadWarning,
77};
78
79// Re-export CQL parsing functions
80pub use cql_parser::{
81    cql_type_to_type_id, extract_table_name, parse_cql_schema, parse_cql_schema_with_visitor,
82    parse_create_table, table_name_matches,
83};
84
85// Re-export discovery and registry components
86pub use discovery::{
87    ColumnDefinition, DiscoveryMethod, IndexDefinition, SchemaDiscoveryConfig,
88    SchemaDiscoveryEngine, SchemaInfo, SchemaMetadata, TableOptions, TypeInfo, UDTDefinition,
89    ValidationError, ValidationResults, ValidationStatus, ValidationWarning,
90};
91
92pub use registry::{
93    ParsingContext, RegistryStatistics, SchemaChange, SchemaChangeType, SchemaQuery,
94    SchemaRegistry, SchemaRegistryConfig, SchemaSource, SchemaValidationStatus, SchemaValidator,
95    SchemaVersion, ValidationReport,
96};
97
98// Test-only counter for the number of times `find_schema_by_table` deep-clones a
99// `TableSchema` out of the registry (issue #1587, E5). A query must resolve its
100// schema ONCE and share it by `Arc`, so this stays `== 1` per query (it was 2–4
101// when each planning/execution step re-resolved). Same thread-local rationale as
102// the query executor's other work counters.
103#[cfg(test)]
104thread_local! {
105    pub(crate) static TABLE_SCHEMA_CLONES: std::cell::Cell<usize> =
106        const { std::cell::Cell::new(0) };
107}
108
109pub use parser::SchemaParser;
110
111#[cfg(feature = "experimental")]
112pub use json_exporter::{
113    JsonClusteringKey, JsonColumn, JsonExportConfig, JsonExporter, JsonFormat, JsonIndex,
114    JsonMetadata, JsonPerformanceMetrics, JsonPrimaryKey, JsonSchema, JsonTable, JsonTableOptions,
115    JsonUDT, JsonValidationResults,
116};
117
118// Type alias for backward compatibility
119pub type ColumnSpec = Column;
120
121use crate::error::{Error, Result};
122use crate::parser::header::SSTableHeader;
123use crate::parser::types::CqlTypeId;
124use crate::storage::StorageEngine;
125use crate::types::UdtTypeDef;
126use crate::Config;
127use serde::{Deserialize, Serialize};
128use std::collections::HashMap;
129use std::fs;
130use std::path::Path;
131use std::sync::Arc;
132use tokio::sync::RwLock;
133
134/// Table schema definition loaded from JSON
135#[derive(Debug, Clone, Serialize, Deserialize)]
136pub struct TableSchema {
137    /// Keyspace name
138    pub keyspace: String,
139
140    /// Table name
141    pub table: String,
142
143    /// Partition key columns (ordered)
144    pub partition_keys: Vec<KeyColumn>,
145
146    /// Clustering key columns (ordered)  
147    pub clustering_keys: Vec<ClusteringColumn>,
148
149    /// All columns in the table
150    pub columns: Vec<Column>,
151
152    /// Optional metadata
153    #[serde(default)]
154    pub comments: HashMap<String, String>,
155
156    /// Dropped-column drop times in microseconds (column name → drop_time_micros).
157    ///
158    /// Populated from the schema-loading surface (JSON `dropped_columns`, or set
159    /// programmatically) since drop times are assigned at DDL-execution by the
160    /// cluster catalog (`system_schema.dropped_columns`) and are not recorded in
161    /// local SSTable files or the CQL `DROP COLUMN` text. Used during compaction
162    /// to discard cells of a dropped column whose timestamp ≤ the drop time
163    /// (Cassandra `cb34ad47`). See issues #904 (this plumbing) and #847 (the
164    /// merge-side filter).
165    ///
166    /// Scope (#847): this map carries only the drop time, so the dropped column's
167    /// pre-drop cells are decoded using its CURRENT type in [`Self::columns`] (the
168    /// decode contract enforced by [`Self::validate_dropped_columns`]). That is
169    /// byte-correct when the column's type is unchanged — the common case. A
170    /// column dropped and later RE-ADDED with a DIFFERENT type is out of scope:
171    /// the historical cells would be decoded with the new type and could
172    /// misparse. Supporting per-version types requires carrying type metadata
173    /// here (or decoding from the SSTable serialization-header type) and is
174    /// follow-up work alongside the element-level representation in #899.
175    ///
176    /// Filtering is also at row-timestamp granularity (the merge stream surfaces
177    /// only the row write-time per cell); exact per-cell purging is tracked as
178    /// follow-up #922.
179    #[serde(default)]
180    pub dropped_columns: HashMap<String, i64>,
181}
182
183/// Partition key column definition
184#[derive(Debug, Clone, Serialize, Deserialize)]
185pub struct KeyColumn {
186    /// Column name
187    pub name: String,
188
189    /// CQL data type
190    #[serde(rename = "type")]
191    pub data_type: String,
192
193    /// Position in composite key (0-based)
194    pub position: usize,
195}
196
197/// Clustering key column with ordering
198#[derive(Debug, Clone, Serialize, Deserialize)]
199pub struct ClusteringColumn {
200    /// Column name
201    pub name: String,
202
203    /// CQL data type
204    #[serde(rename = "type")]
205    pub data_type: String,
206
207    /// Position in clustering key (0-based)
208    pub position: usize,
209
210    /// Sort order (ASC or DESC)
211    #[serde(default)]
212    pub order: ClusteringOrder,
213}
214
215/// Clustering order enum for sorting
216#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
217pub enum ClusteringOrder {
218    /// Ascending order
219    #[default]
220    Asc,
221    /// Descending order
222    Desc,
223}
224
225impl From<&str> for ClusteringOrder {
226    fn from(s: &str) -> Self {
227        match s.to_uppercase().as_str() {
228            "DESC" => ClusteringOrder::Desc,
229            _ => ClusteringOrder::Asc,
230        }
231    }
232}
233
234impl std::fmt::Display for ClusteringOrder {
235    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
236        match self {
237            ClusteringOrder::Asc => write!(f, "ASC"),
238            ClusteringOrder::Desc => write!(f, "DESC"),
239        }
240    }
241}
242
243/// Regular column definition
244#[derive(Debug, Clone, Serialize, Deserialize)]
245pub struct Column {
246    /// Column name
247    pub name: String,
248
249    /// CQL data type (e.g., "text", "bigint", "list<int>")
250    #[serde(rename = "type")]
251    pub data_type: String,
252
253    /// Whether column can be null
254    #[serde(default)]
255    pub nullable: bool,
256
257    /// Default value (if any)
258    #[serde(default)]
259    pub default: Option<serde_json::Value>,
260
261    /// Whether this is a STATIC column
262    #[serde(default)]
263    pub is_static: bool,
264}
265
266/// Parsed CQL data type
267#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
268pub enum CqlType {
269    // Primitive types
270    Boolean,
271    TinyInt,
272    SmallInt,
273    Int,
274    BigInt,
275    Counter,
276    Float,
277    Double,
278    Decimal,
279    Text,
280    Ascii,
281    Varchar,
282    Blob,
283    Timestamp,
284    Date,
285    Time,
286    Uuid,
287    TimeUuid,
288    Inet,
289    Duration,
290    Varint,
291
292    // Collection types (implemented as tuples)
293    List(Box<CqlType>),
294    Set(Box<CqlType>),
295    Map(Box<CqlType>, Box<CqlType>),
296
297    // Complex types
298    Tuple(Vec<CqlType>),
299    Udt(String, Vec<(String, CqlType)>), // name, fields
300    Frozen(Box<CqlType>),
301
302    // Custom/Unknown
303    Custom(String),
304}
305
306/// Whether a de-prefixed `Custom` type name is a plausible UDT reference.
307///
308/// `CqlType::parse` returns `Custom(..)` both for real UDT names and for type
309/// strings it cannot structurally parse (e.g. an uppercase `SET<TEXT>` whose
310/// collection prefix it doesn't recognize). Only simple identifiers
311/// (alphanumeric / `_` / `.`) can name a UDT, so structural fragments
312/// containing `<`, `>`, `,` or whitespace are excluded from UDT validation.
313pub(crate) fn is_udt_identifier(name: &str) -> bool {
314    !name.is_empty()
315        && name
316            .chars()
317            .all(|c| c.is_alphanumeric() || c == '_' || c == '.')
318}
319
320impl TableSchema {
321    /// Extract schema from SSTable header column metadata
322    ///
323    /// This method constructs a TableSchema from the column information
324    /// embedded in the SSTable header's SerializationHeader.
325    pub fn from_sstable_header(header: &SSTableHeader) -> Result<Self> {
326        // Separate columns by role
327        let mut partition_keys = Vec::new();
328        let mut clustering_keys = Vec::new();
329        let mut regular_columns = Vec::new();
330
331        for col_info in &header.columns {
332            if col_info.is_primary_key {
333                if col_info.is_clustering {
334                    clustering_keys.push(col_info);
335                } else {
336                    partition_keys.push(col_info);
337                }
338            } else {
339                regular_columns.push(col_info);
340            }
341        }
342
343        // Validate all partition keys have positions
344        for col_info in &partition_keys {
345            if col_info.key_position.is_none() {
346                return Err(Error::schema(format!(
347                    "Partition key column '{}' missing key_position in SSTable header",
348                    col_info.name
349                )));
350            }
351        }
352
353        // Validate all clustering keys have positions
354        for col_info in &clustering_keys {
355            if col_info.key_position.is_none() {
356                return Err(Error::schema(format!(
357                    "Clustering key column '{}' missing key_position in SSTable header",
358                    col_info.name
359                )));
360            }
361        }
362
363        // Sort by header's key_position to establish canonical ordering
364        partition_keys.sort_by_key(|c| c.key_position.unwrap());
365        clustering_keys.sort_by_key(|c| c.key_position.unwrap());
366
367        // Build KeyColumn with contiguous 0-based positions for CQLite's internal representation
368        // (SSTable key_position values may have gaps; we normalize to [0,1,2,...])
369        let partition_keys: Vec<KeyColumn> = partition_keys
370            .iter()
371            .enumerate()
372            .map(|(pos, col)| KeyColumn {
373                name: col.name.clone(),
374                data_type: col.column_type.clone(),
375                position: pos, // Contiguous internal position, not header key_position
376            })
377            .collect();
378
379        // Build ClusteringColumn with contiguous positions
380        let clustering_keys: Vec<ClusteringColumn> = clustering_keys
381            .iter()
382            .enumerate()
383            .map(|(pos, col)| ClusteringColumn {
384                name: col.name.clone(),
385                data_type: col.column_type.clone(),
386                position: pos, // Contiguous internal position, not header key_position
387                // Issue #759: the serialization header wraps a DESC clustering
388                // column's comparator in `ReversedType(...)`. That authoritative
389                // signal is captured in `ColumnInfo::clustering_reversed` during
390                // Statistics.db parsing (no heuristics). `data_type` already holds
391                // the unwrapped inner CQL type, so deserialization is undisturbed.
392                order: if col.clustering_reversed {
393                    ClusteringOrder::Desc
394                } else {
395                    ClusteringOrder::Asc
396                },
397            })
398            .collect();
399
400        // All columns including keys
401        let columns: Vec<Column> = header
402            .columns
403            .iter()
404            .map(|col| Column {
405                name: col.name.clone(),
406                data_type: col.column_type.clone(),
407                nullable: !col.is_primary_key, // Primary keys are non-nullable
408                default: None,
409                // Static-column classification is authoritative metadata from the
410                // Statistics.db SerializationHeader (definitive guide Ch.7 / Appendix B),
411                // surfaced on ColumnInfo.is_static. Issue #758 / Epic #756.
412                is_static: col.is_static,
413            })
414            .collect();
415
416        if partition_keys.is_empty() {
417            return Err(Error::schema(
418                "No partition keys found in SSTable header".to_string(),
419            ));
420        }
421
422        let schema = TableSchema {
423            keyspace: header.keyspace.clone(),
424            table: header.table_name.clone(),
425            partition_keys,
426            clustering_keys,
427            columns,
428            comments: HashMap::new(),
429            dropped_columns: HashMap::new(),
430        };
431
432        schema.validate()?;
433        Ok(schema)
434    }
435
436    /// Load schema from JSON file
437    pub fn from_file<P: AsRef<Path>>(path: P) -> Result<Self> {
438        let content = fs::read_to_string(path)
439            .map_err(|e| Error::schema(format!("Failed to read schema file: {}", e)))?;
440
441        Self::from_json(&content)
442    }
443
444    /// Parse schema from JSON string
445    pub fn from_json(json: &str) -> Result<Self> {
446        let schema: TableSchema = serde_json::from_str(json)
447            .map_err(|e| Error::schema(format!("Invalid JSON schema: {}", e)))?;
448
449        schema.validate()?;
450        Ok(schema)
451    }
452
453    /// Save schema to JSON file
454    pub fn to_file<P: AsRef<Path>>(&self, path: P) -> Result<()> {
455        let json = serde_json::to_string_pretty(self)
456            .map_err(|e| Error::serialization(format!("Failed to serialize schema: {}", e)))?;
457
458        fs::write(path, json)
459            .map_err(|e| Error::schema(format!("Failed to write schema file: {}", e)))?;
460
461        Ok(())
462    }
463
464    /// Validate schema consistency
465    pub fn validate(&self) -> Result<()> {
466        // Validate keyspace and table names
467        if self.keyspace.is_empty() {
468            return Err(Error::schema("Keyspace name cannot be empty".to_string()));
469        }
470
471        if self.table.is_empty() {
472            return Err(Error::schema("Table name cannot be empty".to_string()));
473        }
474
475        // Must have at least one partition key
476        if self.partition_keys.is_empty() {
477            return Err(Error::schema(
478                "Table must have at least one partition key".to_string(),
479            ));
480        }
481
482        // Validate partition key positions are contiguous
483        let mut positions: Vec<_> = self.partition_keys.iter().map(|k| k.position).collect();
484        positions.sort();
485        for (i, &pos) in positions.iter().enumerate() {
486            if pos != i {
487                return Err(Error::schema(format!(
488                    "Partition key positions must be contiguous starting from 0, found gap at position {}",
489                    i
490                )));
491            }
492        }
493
494        // Validate clustering key positions (if any)
495        if !self.clustering_keys.is_empty() {
496            let mut positions: Vec<_> = self.clustering_keys.iter().map(|k| k.position).collect();
497            positions.sort();
498            for (i, &pos) in positions.iter().enumerate() {
499                if pos != i {
500                    return Err(Error::schema(format!(
501                        "Clustering key positions must be contiguous starting from 0, found gap at position {}",
502                        i
503                    )));
504                }
505            }
506        }
507
508        // Validate data types
509        for column in &self.columns {
510            CqlType::parse(&column.data_type).map_err(|e| {
511                Error::schema(format!(
512                    "Invalid data type '{}' for column '{}': {}",
513                    column.data_type, column.name, e
514                ))
515            })?;
516        }
517
518        // NOTE: UDT-reference validation requires a registry and is not done here
519        // (a TableSchema is self-contained). At schema-load time, call
520        // `validate_udt_references(&registry)` to fail fast on undefined UDTs.
521
522        // Validate all key columns exist in columns list
523        for key in &self.partition_keys {
524            if !self.columns.iter().any(|c| c.name == key.name) {
525                return Err(Error::schema(format!(
526                    "Partition key '{}' not found in columns list",
527                    key.name
528                )));
529            }
530        }
531
532        for key in &self.clustering_keys {
533            if !self.columns.iter().any(|c| c.name == key.name) {
534                return Err(Error::schema(format!(
535                    "Clustering key '{}' not found in columns list",
536                    key.name
537                )));
538            }
539        }
540
541        self.validate_dropped_columns()?;
542
543        Ok(())
544    }
545
546    /// The **post-drop** schema that compaction uses to *write* its output.
547    ///
548    /// The decode schema retains dropped columns (carrying their type) so input
549    /// cells can be parsed and then purged by the merge filter (see
550    /// [`Self::validate_dropped_columns`]). The compaction *output* must keep its
551    /// serialization header / row column bitmap consistent with the cells that
552    /// actually survive the merge:
553    ///
554    /// - A dropped column with **no surviving cells** (all cells were at or
555    ///   before its drop time) is removed from `columns` so it does not appear in
556    ///   the output header. This lets a natural post-drop reader schema (which
557    ///   omits the column) read the output without the header-column /
558    ///   bitmap-index misalignment that retaining it would cause (roborev #847).
559    /// - A dropped column with **surviving cells** (re-added: cells written after
560    ///   `drop_time`) is RETAINED in `columns`, because the merge still emits
561    ///   those cells and the writer needs a matching header column — otherwise
562    ///   the cell would be serialized with no header entry and corrupt the row.
563    ///
564    /// `retained` is the set of dropped-column names that had surviving cells
565    /// (computed by `compact_sstables` from a merge pre-pass). The returned
566    /// schema carries an empty `dropped_columns` map: the purge has already
567    /// happened, so the output must not re-purge the surviving cells on a later
568    /// compaction.
569    pub fn for_compaction_output(
570        &self,
571        retained: &std::collections::HashSet<String>,
572    ) -> TableSchema {
573        TableSchema {
574            keyspace: self.keyspace.clone(),
575            table: self.table.clone(),
576            partition_keys: self.partition_keys.clone(),
577            clustering_keys: self.clustering_keys.clone(),
578            columns: self
579                .columns
580                .iter()
581                .filter(|c| {
582                    // Keep a column unless it is a dropped column with no
583                    // surviving cells.
584                    !self.dropped_columns.contains_key(&c.name) || retained.contains(&c.name)
585                })
586                .cloned()
587                .collect(),
588            comments: self.comments.clone(),
589            dropped_columns: HashMap::new(),
590        }
591    }
592
593    /// Validate the dropped-column decode contract (#904/#847).
594    ///
595    /// Dropped-column filtering during compaction discards a dropped column's
596    /// cells *after they are decoded*. The schema-driven reader only decodes a
597    /// column whose name is present in [`Self::columns`] (it intersects the
598    /// on-disk serialization-header columns with the schema); a column absent
599    /// from `columns` is skipped without consuming its bytes, so its cells would
600    /// never reach the filter and surrounding columns could misalign.
601    ///
602    /// Therefore every column named in `dropped_columns` MUST remain declared in
603    /// `columns` (carrying its type) so its cells decode and can be purged. This
604    /// mirrors Cassandra retaining a dropped column's type in
605    /// `system_schema.dropped_columns`. Decoding a dropped column that is absent
606    /// from `columns` (purely from header type metadata) is follow-up work
607    /// related to #899 and intentionally out of scope here.
608    pub fn validate_dropped_columns(&self) -> Result<()> {
609        for name in self.dropped_columns.keys() {
610            if !self.columns.iter().any(|c| &c.name == name) {
611                return Err(Error::schema(format!(
612                    "dropped column '{}' must remain declared in `columns` (with its type) so \
613                     its cells can be decoded and purged during compaction; a dropped column \
614                     present only in `dropped_columns` cannot be decoded (see #904/#847)",
615                    name
616                )));
617            }
618        }
619        Ok(())
620    }
621
622    /// Validate that every UDT referenced by a column exists in the registry.
623    ///
624    /// This is a schema-load-time pass that fails fast with a schema-category
625    /// error naming the missing UDT, instead of surfacing the problem later as a
626    /// confusing parse/deserialization error (issue #761). Nested references are
627    /// validated recursively: a UDT inside a collection, `frozen<>`, a tuple, or
628    /// another UDT is checked just like a top-level reference.
629    ///
630    /// UDTs are looked up in the schema's own keyspace; the `system` keyspace is
631    /// also consulted so built-in/system UDTs resolve regardless of the table's
632    /// keyspace.
633    pub fn validate_udt_references(&self, registry: &UdtRegistry) -> Result<()> {
634        for column in &self.columns {
635            // Reuse the same parse the rest of validation uses; parse errors are
636            // reported by `validate()`, so ignore them here.
637            if let Ok(cql_type) = CqlType::parse(&column.data_type) {
638                self.check_type_udt_references(&cql_type, &column.name, registry)?;
639            }
640        }
641        Ok(())
642    }
643
644    /// Recursively check a single CQL type for UDT references that are not
645    /// present in the registry.
646    fn check_type_udt_references(
647        &self,
648        cql_type: &CqlType,
649        column_name: &str,
650        registry: &UdtRegistry,
651    ) -> Result<()> {
652        match cql_type {
653            CqlType::Udt(name, _) => {
654                self.ensure_udt_exists(name, column_name, registry)?;
655            }
656            // `CqlType::parse` represents UDT references as `Custom("udt:<name>")`
657            // for names with mixed case / underscores / digits, but as a bare
658            // `Custom("<name>")` for purely-lowercase names (e.g. `address`).
659            // Validate the de-prefixed name *only* when it is a simple type
660            // identifier: `parse` also yields a bare `Custom` for type strings it
661            // can't structurally parse (e.g. an uppercase `SET<TEXT>` collection),
662            // which must NOT be mistaken for a UDT (roborev job 39 + the
663            // collections-fixture regression).
664            CqlType::Custom(name) => {
665                let udt_name = name.strip_prefix("udt:").unwrap_or(name);
666                if is_udt_identifier(udt_name) {
667                    self.ensure_udt_exists(udt_name, column_name, registry)?;
668                }
669            }
670            CqlType::List(inner) | CqlType::Set(inner) | CqlType::Frozen(inner) => {
671                self.check_type_udt_references(inner, column_name, registry)?;
672            }
673            CqlType::Map(key_type, value_type) => {
674                self.check_type_udt_references(key_type, column_name, registry)?;
675                self.check_type_udt_references(value_type, column_name, registry)?;
676            }
677            CqlType::Tuple(field_types) => {
678                for field_type in field_types {
679                    self.check_type_udt_references(field_type, column_name, registry)?;
680                }
681            }
682            _ => {} // Primitive types reference no UDTs.
683        }
684        Ok(())
685    }
686
687    /// Confirm a referenced UDT exists in the table's keyspace (or the `system`
688    /// keyspace), returning a schema-category error naming the missing UDT.
689    fn ensure_udt_exists(
690        &self,
691        udt_name: &str,
692        column_name: &str,
693        registry: &UdtRegistry,
694    ) -> Result<()> {
695        // A reference may be qualified as `keyspace.udt`; honor an explicit
696        // keyspace, otherwise resolve against the table's keyspace. Routes through
697        // the single shared splitter (issue #2807).
698        let (lookup_keyspace, bare_name) =
699            crate::schema::split_qualified_udt(udt_name, self.keyspace.as_str());
700
701        if registry.contains_udt(lookup_keyspace, bare_name)
702            || registry.contains_udt("system", bare_name)
703        {
704            return Ok(());
705        }
706
707        Err(Error::schema(format!(
708            "Column '{}' references undefined UDT '{}' in keyspace '{}'",
709            column_name, udt_name, lookup_keyspace
710        )))
711    }
712
713    /// Get column by name
714    pub fn get_column(&self, name: &str) -> Option<&Column> {
715        self.columns.iter().find(|c| c.name == name)
716    }
717
718    /// Check if column is a partition key
719    pub fn is_partition_key(&self, name: &str) -> bool {
720        self.partition_keys.iter().any(|k| k.name == name)
721    }
722
723    /// Check if column is a clustering key
724    pub fn is_clustering_key(&self, name: &str) -> bool {
725        self.clustering_keys.iter().any(|k| k.name == name)
726    }
727
728    // `ordered_partition_keys` / `ordered_clustering_keys` live in the
729    // `key_ordering` submodule (issue #1677): they memoize-avoid the per-call
730    // re-sort via a "sort only if not already ordered" fast path.
731
732    /// Create a minimal test schema (for testing only)
733    #[cfg(test)]
734    pub fn new_for_testing(keyspace: &str, table: &str) -> Self {
735        Self {
736            keyspace: keyspace.to_string(),
737            table: table.to_string(),
738            partition_keys: vec![KeyColumn {
739                name: "id".to_string(),
740                data_type: "int".to_string(),
741                position: 0,
742            }],
743            clustering_keys: vec![],
744            columns: vec![Column {
745                name: "id".to_string(),
746                data_type: "int".to_string(),
747                nullable: false,
748                default: None,
749                is_static: false,
750            }],
751            comments: HashMap::new(),
752            dropped_columns: HashMap::new(),
753        }
754    }
755}
756
757/// Schema management service for handling table schemas and UDT definitions.
758///
759/// Issue #1708 (one schema source of truth): the manager holds an
760/// `Arc<RwLock<SchemaRegistry>>` and RESOLVES every schema/UDT lookup THROUGH
761/// that registry (and REGISTERS manager-side loads INTO it). It keeps NO
762/// by-value schema/UDT copy of its own, so registry-side TTL-refresh /
763/// auto-discovery and manager-side loads are always mutually visible — the two
764/// can never silently diverge. The registry owns freshness (TTL/refresh); the
765/// manager delegates.
766#[derive(Debug)]
767pub struct SchemaManager {
768    #[allow(dead_code)]
769    storage: Arc<StorageEngine>,
770    /// The single source of truth for table schemas and UDTs.
771    registry: Arc<RwLock<registry::SchemaRegistry>>,
772}
773
774impl SchemaManager {
775    /// Build a default schema registry sharing the given platform/config.
776    async fn default_registry(
777        platform: Arc<crate::platform::Platform>,
778        config: &Config,
779    ) -> Result<Arc<RwLock<registry::SchemaRegistry>>> {
780        let registry = registry::SchemaRegistry::new(
781            registry::SchemaRegistryConfig::default(),
782            platform,
783            config.clone(),
784        )
785        .await?;
786        Ok(Arc::new(RwLock::new(registry)))
787    }
788
789    /// Create a new schema manager from a path
790    pub async fn new<P: AsRef<Path>>(path: P) -> Result<Self> {
791        // Create temporary storage engine (not actually used in this context)
792        let config = Config::default();
793        let platform = Arc::new(crate::platform::Platform::new(&config).await?);
794        let storage = Arc::new(
795            StorageEngine::open(
796                path.as_ref(),
797                &config,
798                platform.clone(),
799                #[cfg(feature = "state_machine")]
800                None,
801            )
802            .await?,
803        );
804
805        let registry = Self::default_registry(platform, &config).await?;
806        Ok(Self { storage, registry })
807    }
808
809    /// Create a new schema manager with storage
810    pub async fn new_with_storage(storage: Arc<StorageEngine>, config: &Config) -> Result<Self> {
811        let platform = Arc::new(crate::platform::Platform::new(config).await?);
812        let registry = Self::default_registry(platform, config).await?;
813        let manager = Self { storage, registry };
814
815        // Load built-in UDT definitions for Cassandra 5.0 compatibility
816        manager.load_default_udts().await;
817
818        Ok(manager)
819    }
820
821    /// Create a new schema manager with a pre-loaded SchemaRegistry
822    ///
823    /// This constructor is used when schemas are loaded from external .cql files
824    /// during ingestion, allowing the pre-loaded schemas to be used by the query engine.
825    ///
826    /// Issue #1708: the registry is SHARED by reference (not snapshotted). Every
827    /// subsequent registry-side update is immediately visible to this manager,
828    /// and every manager-side load is registered back into this same registry.
829    ///
830    /// # Arguments
831    ///
832    /// * `storage` - The storage engine instance
833    /// * `registry` - Pre-loaded schema registry from ingestion
834    /// * `_config` - Database configuration (currently unused)
835    pub async fn new_with_registry(
836        storage: Arc<StorageEngine>,
837        registry: Arc<tokio::sync::RwLock<registry::SchemaRegistry>>,
838        _config: &Config,
839    ) -> Result<Self> {
840        Ok(Self { storage, registry })
841    }
842
843    /// Access the shared schema registry (test-only).
844    ///
845    /// Lets tests register schemas/UDTs into the single source of truth the
846    /// manager resolves through, to assert cross-visibility (issue #1708).
847    #[cfg(test)]
848    pub(crate) fn registry(&self) -> Arc<RwLock<registry::SchemaRegistry>> {
849        self.registry.clone()
850    }
851
852    /// Load default UDT definitions that are commonly used in Cassandra
853    async fn load_default_udts(&self) {
854        // Common address UDT used in many Cassandra schemas
855        let address_udt = UdtTypeDef::new("test_keyspace".to_string(), "address".to_string())
856            .with_field("street".to_string(), CqlType::Text, true)
857            .with_field("city".to_string(), CqlType::Text, true)
858            .with_field("state".to_string(), CqlType::Text, true)
859            .with_field("zip_code".to_string(), CqlType::Text, true)
860            .with_field("country".to_string(), CqlType::Text, true);
861
862        self.register_udt(address_udt).await;
863
864        // Enhanced person UDT with nested address
865        let person_udt = UdtTypeDef::new("test_keyspace".to_string(), "person".to_string())
866            .with_field("name".to_string(), CqlType::Text, true)
867            .with_field("age".to_string(), CqlType::Int, true)
868            .with_field("email".to_string(), CqlType::Text, true)
869            .with_field(
870                "addresses".to_string(),
871                CqlType::List(Box::new(CqlType::Udt(
872                    "address".to_string(),
873                    vec![
874                        ("street".to_string(), CqlType::Text),
875                        ("city".to_string(), CqlType::Text),
876                        ("state".to_string(), CqlType::Text),
877                        ("zip_code".to_string(), CqlType::Text),
878                        ("country".to_string(), CqlType::Text),
879                    ],
880                ))),
881                true,
882            )
883            .with_field(
884                "contact_info".to_string(),
885                CqlType::Map(Box::new(CqlType::Text), Box::new(CqlType::Text)),
886                true,
887            );
888
889        self.register_udt(person_udt).await;
890
891        // Company UDT with nested person and address relationships
892        let company_udt = UdtTypeDef::new("test_keyspace".to_string(), "company".to_string())
893            .with_field("name".to_string(), CqlType::Text, false)
894            .with_field(
895                "headquarters".to_string(),
896                CqlType::Udt(
897                    "address".to_string(),
898                    vec![
899                        ("street".to_string(), CqlType::Text),
900                        ("city".to_string(), CqlType::Text),
901                        ("state".to_string(), CqlType::Text),
902                        ("zip_code".to_string(), CqlType::Text),
903                        ("country".to_string(), CqlType::Text),
904                    ],
905                ),
906                true,
907            )
908            .with_field(
909                "employees".to_string(),
910                CqlType::Set(Box::new(CqlType::Udt("person".to_string(), vec![]))),
911                true,
912            )
913            .with_field("founded_year".to_string(), CqlType::Int, true);
914
915        self.register_udt(company_udt).await;
916    }
917
918    /// Register a new UDT type definition into the shared registry (issue #1708).
919    pub async fn register_udt(&self, udt_def: UdtTypeDef) {
920        // `SchemaRegistry::register_udt` is infallible in practice (it only
921        // inserts into the in-memory UDT registry); ignore the `Ok(())`.
922        let _ = self.registry.read().await.register_udt(udt_def).await;
923    }
924
925    /// Get a UDT definition from the shared registry (returns a cloned UdtTypeDef).
926    pub async fn get_udt(&self, keyspace: &str, name: &str) -> Option<UdtTypeDef> {
927        self.registry
928            .read()
929            .await
930            .get_udt(keyspace, name)
931            .await
932            .ok()
933            .flatten()
934    }
935
936    /// Load schema for a table.
937    ///
938    /// Returns `Err(Error::Schema(..))` for an unknown table. CQLite never
939    /// fabricates a schema for a table nobody defined — doing so would return
940    /// fabricated-shape rows for undefined tables, violating the no-heuristics
941    /// mandate. This mirrors the I3 (#1626) hard-fail precedent (issue #1710).
942    ///
943    /// This resolves THROUGH the shared registry (issue #1708), so a
944    /// registry-side TTL-refresh / auto-discovery is always observed and a
945    /// stale by-value snapshot can never be served. No write happens on the
946    /// unknown-table path.
947    pub async fn load_schema(&self, table_name: &str) -> Result<TableSchema> {
948        // A `keyspace.table` name resolves as an exact scoped lookup; a bare
949        // name matches by table name across keyspaces.
950        let (keyspace, table) = match table_name.split_once('.') {
951            Some((ks, tbl)) => (Some(ks.to_string()), tbl),
952            None => (None, table_name),
953        };
954        self.find_schema_by_table(&keyspace, table)
955            .await?
956            .ok_or_else(|| {
957                Error::schema(format!(
958                    "unknown table {}; no schema registered or discovered",
959                    table_name
960                ))
961            })
962    }
963
964    /// Parse and register a schema from a CQL CREATE TABLE statement.
965    ///
966    /// Registers INTO the shared registry (issue #1708) so the parsed schema is
967    /// immediately visible to every other holder of the registry (parsing paths,
968    /// other managers), not stored in a private manager-only map.
969    pub async fn parse_and_register_cql_schema(&self, cql: &str) -> Result<TableSchema> {
970        let schema = cql_parser::parse_cql_schema(cql)?;
971        self.registry
972            .read()
973            .await
974            .register_schema(schema.clone(), registry::SchemaSource::Cql(cql.to_string()))
975            .await?;
976        Ok(schema)
977    }
978
979    /// Find schema by table name with optional keyspace matching.
980    ///
981    /// Resolves THROUGH the shared registry (issue #1708); the registry owns
982    /// freshness (issue #1708 roborev Medium): a fresh matched entry is cloned
983    /// once (issue #1587), an EXPIRED entry is refreshed rather than served
984    /// stale, and an unregistered table yields `Ok(None)` with NO fabrication
985    /// (issue #1710). The registry's refresh error (if any) propagates as `Err`.
986    pub async fn find_schema_by_table(
987        &self,
988        keyspace: &Option<String>,
989        table: &str,
990    ) -> Result<Option<TableSchema>> {
991        let found = self
992            .registry
993            .read()
994            .await
995            .find_schema_by_table(keyspace, table)
996            .await?;
997        // Preserve the issue #1587 (E5) once-per-query deep-clone accounting: the
998        // registry performs exactly one `TableSchema` clone on a hit.
999        #[cfg(test)]
1000        if found.is_some() {
1001            TABLE_SCHEMA_CLONES.with(|c| c.set(c.get() + 1));
1002        }
1003        Ok(found)
1004    }
1005
1006    /// Extract table information from CQL without full parsing
1007    pub fn extract_table_info(&self, cql: &str) -> Result<(Option<String>, String)> {
1008        cql_parser::extract_table_name(cql)
1009    }
1010
1011    /// Convert CQL type string to internal type ID
1012    pub fn cql_type_to_internal(&self, cql_type: &str) -> Result<CqlTypeId> {
1013        cql_parser::cql_type_to_type_id(cql_type)
1014    }
1015
1016    /// Get table schema by name (async for compatibility)
1017    pub async fn get_table_schema(&self, table_name: &str) -> Result<TableSchema> {
1018        // Try to find schema by table name
1019        if let Some(schema) = self.find_schema_by_table(&None, table_name).await? {
1020            Ok(schema)
1021        } else {
1022            Err(Error::Schema(format!(
1023                "Table schema not found: {}",
1024                table_name
1025            )))
1026        }
1027    }
1028}
1029
1030#[cfg(test)]
1031mod tests {
1032    use super::*;
1033
1034    #[test]
1035    fn test_schema_validation() {
1036        let schema_json = r#"
1037        {
1038            "keyspace": "test",
1039            "table": "users",
1040            "partition_keys": [
1041                {"name": "id", "type": "bigint", "position": 0}
1042            ],
1043            "clustering_keys": [],
1044            "columns": [
1045                {"name": "id", "type": "bigint", "nullable": false},
1046                {"name": "name", "type": "text", "nullable": true}
1047            ]
1048        }
1049        "#;
1050
1051        let schema = TableSchema::from_json(schema_json).unwrap();
1052        assert_eq!(schema.keyspace, "test");
1053        assert_eq!(schema.table, "users");
1054        assert_eq!(schema.partition_keys.len(), 1);
1055        assert_eq!(schema.columns.len(), 2);
1056    }
1057
1058    #[test]
1059    fn test_schema_validation_failures() {
1060        // Missing partition key
1061        let invalid_schema = r#"
1062        {
1063            "keyspace": "test",
1064            "table": "users", 
1065            "partition_keys": [],
1066            "clustering_keys": [],
1067            "columns": []
1068        }
1069        "#;
1070
1071        assert!(TableSchema::from_json(invalid_schema).is_err());
1072
1073        // Invalid type
1074        let invalid_type = r#"
1075        {
1076            "keyspace": "test",
1077            "table": "users",
1078            "partition_keys": [
1079                {"name": "id", "type": "invalid_type", "position": 0}
1080            ],
1081            "clustering_keys": [],
1082            "columns": [
1083                {"name": "id", "type": "invalid_type", "nullable": false}
1084            ]
1085        }
1086        "#;
1087
1088        // This should succeed as we allow custom types
1089        assert!(TableSchema::from_json(invalid_type).is_ok());
1090    }
1091
1092    fn udt_schema(column_type: &str) -> TableSchema {
1093        TableSchema {
1094            keyspace: "test_ks".to_string(),
1095            table: "t".to_string(),
1096            partition_keys: vec![KeyColumn {
1097                name: "id".to_string(),
1098                data_type: "uuid".to_string(),
1099                position: 0,
1100            }],
1101            clustering_keys: vec![],
1102            columns: vec![
1103                Column {
1104                    name: "id".to_string(),
1105                    data_type: "uuid".to_string(),
1106                    nullable: false,
1107                    default: None,
1108                    is_static: false,
1109                },
1110                Column {
1111                    name: "value".to_string(),
1112                    data_type: column_type.to_string(),
1113                    nullable: true,
1114                    default: None,
1115                    is_static: false,
1116                },
1117            ],
1118            comments: HashMap::new(),
1119            dropped_columns: HashMap::new(),
1120        }
1121    }
1122
1123    #[test]
1124    fn test_udt_reference_undefined_top_level_errors() {
1125        let registry = UdtRegistry::new();
1126        let schema = udt_schema("MyMissingType");
1127
1128        let err = schema
1129            .validate_udt_references(&registry)
1130            .expect_err("undefined UDT reference must fail validation");
1131        let msg = err.to_string();
1132        assert!(
1133            matches!(err, Error::Schema(_)),
1134            "expected schema-category error, got {err:?}"
1135        );
1136        assert!(
1137            msg.contains("MyMissingType"),
1138            "error must name the missing UDT, got: {msg}"
1139        );
1140    }
1141
1142    #[test]
1143    fn test_udt_reference_undefined_lowercase_errors() {
1144        // Regression (roborev job 39): a UDT name that is purely lowercase
1145        // letters (no underscore/digit) parses to a bare `Custom("<name>")`
1146        // with no `udt:` prefix, so validation must still catch it —
1147        // top-level and nested.
1148        let registry = UdtRegistry::new();
1149        for col_type in ["address", "list<frozen<address>>"] {
1150            let schema = udt_schema(col_type);
1151            let err = schema
1152                .validate_udt_references(&registry)
1153                .expect_err("undefined lowercase UDT must fail validation");
1154            assert!(matches!(err, Error::Schema(_)), "got {err:?}");
1155            assert!(
1156                err.to_string().contains("address"),
1157                "error must name the missing UDT, got: {err}"
1158            );
1159        }
1160    }
1161
1162    #[test]
1163    fn test_uppercase_collection_of_primitives_is_not_a_udt() {
1164        // Regression (collections fixture): uppercase collections of primitives
1165        // must parse and not be mistaken for a UDT reference.
1166        let registry = UdtRegistry::new();
1167        for col_type in [
1168            "SET<TEXT>",
1169            "LIST<INT>",
1170            "MAP<TEXT, TEXT>",
1171            "FROZEN<LIST<INT>>",
1172        ] {
1173            let schema = udt_schema(col_type);
1174            schema
1175                .validate_udt_references(&registry)
1176                .unwrap_or_else(|e| panic!("'{col_type}' must not be flagged as a UDT: {e}"));
1177        }
1178    }
1179
1180    #[test]
1181    fn test_uppercase_collection_with_undefined_udt_errors() {
1182        // Regression (roborev job 51): an undefined UDT nested inside an
1183        // UPPERCASE collection must still be detected — case-insensitive parsing
1184        // means the nested reference is validated, not skipped.
1185        let registry = UdtRegistry::new();
1186        for col_type in [
1187            "LIST<MissingType>",
1188            "MAP<TEXT, MissingType>",
1189            "FROZEN<SET<MissingType>>",
1190        ] {
1191            let schema = udt_schema(col_type);
1192            let err = schema
1193                .validate_udt_references(&registry)
1194                .expect_err("undefined UDT in uppercase collection must fail");
1195            assert!(matches!(err, Error::Schema(_)), "got {err:?}");
1196            assert!(
1197                err.to_string().contains("MissingType"),
1198                "error must name the missing UDT, got: {err}"
1199            );
1200        }
1201    }
1202
1203    #[test]
1204    fn test_udt_reference_undefined_nested_in_collection_errors() {
1205        let registry = UdtRegistry::new();
1206        let schema = udt_schema("list<frozen<NestedMissing>>");
1207
1208        let err = schema
1209            .validate_udt_references(&registry)
1210            .expect_err("nested undefined UDT reference must fail validation");
1211        let msg = err.to_string();
1212        assert!(matches!(err, Error::Schema(_)));
1213        assert!(
1214            msg.contains("NestedMissing"),
1215            "error must name the nested missing UDT, got: {msg}"
1216        );
1217    }
1218
1219    #[test]
1220    fn test_udt_reference_undefined_nested_in_map_errors() {
1221        let registry = UdtRegistry::new();
1222        let schema = udt_schema("map<text, MapValueMissing>");
1223
1224        let err = schema
1225            .validate_udt_references(&registry)
1226            .expect_err("undefined UDT in map value must fail validation");
1227        assert!(matches!(err, Error::Schema(_)));
1228        assert!(err.to_string().contains("MapValueMissing"));
1229    }
1230
1231    #[test]
1232    fn test_udt_reference_defined_top_level_ok() {
1233        let mut registry = UdtRegistry::new();
1234        registry.register_udt(
1235            UdtTypeDef::new("test_ks".to_string(), "MyType".to_string()).with_field(
1236                "a".to_string(),
1237                CqlType::Text,
1238                true,
1239            ),
1240        );
1241        let schema = udt_schema("MyType");
1242        schema
1243            .validate_udt_references(&registry)
1244            .expect("defined UDT should validate");
1245    }
1246
1247    #[test]
1248    fn test_udt_reference_defined_nested_ok() {
1249        let mut registry = UdtRegistry::new();
1250        registry.register_udt(
1251            UdtTypeDef::new("test_ks".to_string(), "MyType".to_string()).with_field(
1252                "a".to_string(),
1253                CqlType::Text,
1254                true,
1255            ),
1256        );
1257        let schema = udt_schema("list<frozen<MyType>>");
1258        schema
1259            .validate_udt_references(&registry)
1260            .expect("defined nested UDT should validate");
1261    }
1262
1263    #[test]
1264    fn test_validate_udt_references_no_udts_ok() {
1265        // Schemas without any UDT columns must validate against an empty registry.
1266        let registry = UdtRegistry::new();
1267        let schema = udt_schema("map<text, list<int>>");
1268        schema
1269            .validate_udt_references(&registry)
1270            .expect("primitive/collection-only schema should validate");
1271    }
1272
1273    async fn build_storage() -> Arc<StorageEngine> {
1274        let config = Config::default();
1275        let platform = Arc::new(crate::platform::Platform::new(&config).await.unwrap());
1276        let temp_dir = tempfile::tempdir().unwrap();
1277        Arc::new(
1278            StorageEngine::open(
1279                temp_dir.path(),
1280                &config,
1281                platform,
1282                #[cfg(feature = "state_machine")]
1283                None,
1284            )
1285            .await
1286            .unwrap(),
1287        )
1288    }
1289
1290    async fn build_shared_registry() -> Arc<RwLock<registry::SchemaRegistry>> {
1291        let config = Config::default();
1292        let platform = Arc::new(crate::platform::Platform::new(&config).await.unwrap());
1293        Arc::new(RwLock::new(
1294            registry::SchemaRegistry::new(
1295                registry::SchemaRegistryConfig::default(),
1296                platform,
1297                config,
1298            )
1299            .await
1300            .unwrap(),
1301        ))
1302    }
1303
1304    async fn test_manager() -> Arc<SchemaManager> {
1305        let config = Config::default();
1306        let storage = build_storage().await;
1307        Arc::new(
1308            SchemaManager::new_with_storage(storage, &config)
1309                .await
1310                .unwrap(),
1311        )
1312    }
1313
1314    fn table_schema_named(table_name: &str) -> TableSchema {
1315        let mut schema = udt_schema("text");
1316        schema.table = table_name.to_string();
1317        schema
1318    }
1319
1320    /// Issue #1710: `load_schema` for a table nobody defined must return `Err`,
1321    /// never a fabricated `uuid id` schema (no-heuristics mandate; I3 #1626
1322    /// hard-fail precedent). RED on main, which fabricated a default schema.
1323    #[tokio::test]
1324    async fn test_load_schema_unknown_table_errs() {
1325        let manager = test_manager().await;
1326
1327        let err = manager
1328            .load_schema("never_defined_table")
1329            .await
1330            .expect_err("unknown table must error, not fabricate a schema");
1331        assert!(matches!(err, Error::Schema(_)), "got {err:?}");
1332        assert!(
1333            err.to_string().contains("never_defined_table"),
1334            "error must name the unknown table, got: {err}"
1335        );
1336
1337        // And the failed lookup must NOT register a fabricated entry.
1338        let stats = manager
1339            .registry()
1340            .read()
1341            .await
1342            .get_statistics()
1343            .await
1344            .unwrap();
1345        assert_eq!(
1346            stats.total_schemas, 0,
1347            "unknown-table lookup must not mutate the registry"
1348        );
1349    }
1350
1351    /// Issue #1710: concurrent `load_schema` of a registered table returns the
1352    /// real schema from every task, and never mutates the registry.
1353    #[tokio::test]
1354    async fn test_concurrent_schema_access() {
1355        let manager = test_manager().await;
1356
1357        // Register 3 real schemas up front THROUGH the shared registry (issue
1358        // #1708 single source of truth; authoritative metadata only).
1359        for i in 0..3 {
1360            let name = format!("table_{}", i);
1361            manager
1362                .registry()
1363                .read()
1364                .await
1365                .register_schema(table_schema_named(&name), registry::SchemaSource::Manual)
1366                .await
1367                .unwrap();
1368        }
1369
1370        // Spawn 10 concurrent tasks loading the 3 tables.
1371        let mut handles = vec![];
1372        for i in 0..10 {
1373            let m = Arc::clone(&manager);
1374            let handle = tokio::spawn(async move {
1375                let table = format!("table_{}", i % 3);
1376                let schema = m.load_schema(&table).await.expect("registered table loads");
1377                assert_eq!(schema.table, table);
1378            });
1379            handles.push(handle);
1380        }
1381        for handle in handles {
1382            handle.await.unwrap();
1383        }
1384
1385        // Registry is unchanged: exactly the 3 registered entries, no fabrication.
1386        let stats = manager
1387            .registry()
1388            .read()
1389            .await
1390            .get_statistics()
1391            .await
1392            .unwrap();
1393        assert_eq!(stats.total_schemas, 3);
1394        for i in 0..3 {
1395            assert!(manager.load_schema(&format!("table_{}", i)).await.is_ok());
1396        }
1397    }
1398
1399    /// Issue #1708: the manager must resolve THROUGH the shared registry, never a
1400    /// stale by-value snapshot captured at construction. Register v1, build the
1401    /// manager, then update the schema THROUGH the registry (simulating a
1402    /// TTL-refresh / auto-discovery re-registration) and confirm the manager
1403    /// observes v2. RED on main (returned the snapshotted v1).
1404    #[tokio::test]
1405    async fn test_manager_reflects_registry_updates_no_stale_snapshot() {
1406        let registry = build_shared_registry().await;
1407
1408        // v1: two columns (id uuid, value text).
1409        let v1 = table_schema_named("evolving");
1410        assert_eq!(v1.columns.len(), 2);
1411        registry
1412            .read()
1413            .await
1414            .register_schema(v1.clone(), registry::SchemaSource::Manual)
1415            .await
1416            .unwrap();
1417
1418        let storage = build_storage().await;
1419        let config = Config::default();
1420        let manager = SchemaManager::new_with_registry(storage, registry.clone(), &config)
1421            .await
1422            .unwrap();
1423
1424        let got_v1 = manager.get_table_schema("evolving").await.unwrap();
1425        assert_eq!(got_v1.columns.len(), 2, "manager must see v1");
1426
1427        // Update THROUGH the registry: v2 adds a column.
1428        let mut v2 = v1.clone();
1429        v2.columns.push(Column {
1430            name: "extra".to_string(),
1431            data_type: "int".to_string(),
1432            nullable: true,
1433            default: None,
1434            is_static: false,
1435        });
1436        registry
1437            .read()
1438            .await
1439            .register_schema(v2, registry::SchemaSource::Manual)
1440            .await
1441            .unwrap();
1442
1443        let got_v2 = manager.get_table_schema("evolving").await.unwrap();
1444        assert_eq!(
1445            got_v2.columns.len(),
1446            3,
1447            "manager must resolve THROUGH the registry, not a stale by-value snapshot"
1448        );
1449    }
1450
1451    /// Issue #1708 (roborev Medium): the registry OWNS freshness and the manager
1452    /// merely delegates — an entry EXPIRED in the registry must NOT be served
1453    /// stale through the manager. With `cache_ttl_seconds = 0` the registered
1454    /// schema expires, so `load_schema`/`get_table_schema` must return the
1455    /// registry's refresh outcome (an `Err` here — no SSTables back a
1456    /// manually-registered schema), NOT `Ok(stale schema)`. RED on HEAD, which
1457    /// read the registry map directly and served the expired entry forever.
1458    #[tokio::test]
1459    async fn test_manager_does_not_serve_ttl_expired_schema() {
1460        let config = Config::default();
1461        let platform = Arc::new(crate::platform::Platform::new(&config).await.unwrap());
1462        let mut reg_config = registry::SchemaRegistryConfig::default();
1463        reg_config.cache_ttl_seconds = 0; // every registered entry expires immediately
1464        let registry = Arc::new(RwLock::new(
1465            registry::SchemaRegistry::new(reg_config, platform, config.clone())
1466                .await
1467                .unwrap(),
1468        ));
1469
1470        // Register a real schema THROUGH the shared registry.
1471        registry
1472            .read()
1473            .await
1474            .register_schema(
1475                table_schema_named("stale_tbl"),
1476                registry::SchemaSource::Manual,
1477            )
1478            .await
1479            .unwrap();
1480
1481        let storage = build_storage().await;
1482        let manager = SchemaManager::new_with_registry(storage, registry.clone(), &config)
1483            .await
1484            .unwrap();
1485
1486        // Let the zero-TTL entry expire.
1487        tokio::time::sleep(std::time::Duration::from_millis(10)).await;
1488
1489        // The manager must delegate to the registry's refresh path (which errors
1490        // for a manually-registered schema with no backing SSTables) rather than
1491        // serve the stale entry. On HEAD this returned `Ok(stale)`.
1492        let loaded = manager.load_schema("stale_tbl").await;
1493        assert!(
1494            loaded.is_err(),
1495            "manager must delegate TTL freshness to the registry, not serve a stale schema; got {loaded:?}"
1496        );
1497        let got = manager.get_table_schema("stale_tbl").await;
1498        assert!(
1499            got.is_err(),
1500            "get_table_schema must also honor registry expiry; got {got:?}"
1501        );
1502
1503        // The unknown-table no-fabrication contract (#1710) still holds.
1504        assert!(
1505            manager.load_schema("never_defined_here").await.is_err(),
1506            "unknown table still errors (no fabrication)"
1507        );
1508    }
1509
1510    /// Issue #1708: UDT-registry sharing must be constructor-INDEPENDENT. A UDT
1511    /// registered into the shared registry is visible via the manager's `get_udt`
1512    /// for EVERY constructor path, and a UDT registered via the manager is
1513    /// visible via the shared registry. RED on main for `new`/`new_with_storage`
1514    /// (they built an unshared private UDT-registry copy).
1515    #[tokio::test]
1516    async fn test_all_constructors_share_udt_registry() {
1517        // Manager built via `new`.
1518        let temp = tempfile::tempdir().unwrap();
1519        let via_new = SchemaManager::new(temp.path()).await.unwrap();
1520
1521        // Manager built via `new_with_storage`.
1522        let config = Config::default();
1523        let via_storage = {
1524            let storage = build_storage().await;
1525            SchemaManager::new_with_storage(storage, &config)
1526                .await
1527                .unwrap()
1528        };
1529
1530        // Manager built via `new_with_registry`.
1531        let via_registry = {
1532            let registry = build_shared_registry().await;
1533            let storage = build_storage().await;
1534            SchemaManager::new_with_registry(storage, registry, &config)
1535                .await
1536                .unwrap()
1537        };
1538
1539        for (label, manager) in [
1540            ("new", &via_new),
1541            ("new_with_storage", &via_storage),
1542            ("new_with_registry", &via_registry),
1543        ] {
1544            // registry -> manager visibility
1545            let point = UdtTypeDef::new("ks_share".to_string(), "point".to_string()).with_field(
1546                "x".to_string(),
1547                CqlType::Int,
1548                true,
1549            );
1550            manager
1551                .registry()
1552                .read()
1553                .await
1554                .register_udt(point)
1555                .await
1556                .unwrap();
1557            assert!(
1558                manager.get_udt("ks_share", "point").await.is_some(),
1559                "[{label}] UDT registered in the shared registry must be visible via the manager"
1560            );
1561
1562            // manager -> registry visibility
1563            let line = UdtTypeDef::new("ks_share".to_string(), "line".to_string()).with_field(
1564                "len".to_string(),
1565                CqlType::Int,
1566                true,
1567            );
1568            manager.register_udt(line).await;
1569            let seen = manager
1570                .registry()
1571                .read()
1572                .await
1573                .get_udt("ks_share", "line")
1574                .await
1575                .unwrap();
1576            assert!(
1577                seen.is_some(),
1578                "[{label}] UDT registered via the manager must be visible in the shared registry"
1579            );
1580        }
1581    }
1582
1583    #[test]
1584    fn test_schema_from_sstable_header() {
1585        use crate::parser::header::{
1586            CassandraVersion, ColumnInfo, CompressionInfo, SSTableHeader, SSTableStats,
1587        };
1588        use std::collections::HashMap;
1589
1590        let columns = vec![
1591            ColumnInfo {
1592                name: "id".to_string(),
1593                column_type: "int".to_string(),
1594                is_primary_key: true,
1595                key_position: Some(0),
1596                is_static: false,
1597                is_clustering: false,
1598                clustering_reversed: false,
1599            },
1600            ColumnInfo {
1601                name: "name".to_string(),
1602                column_type: "text".to_string(),
1603                is_primary_key: false,
1604                key_position: None,
1605                is_static: false,
1606                is_clustering: false,
1607                clustering_reversed: false,
1608            },
1609        ];
1610
1611        let header = SSTableHeader {
1612            cassandra_version: CassandraVersion::V5_0Bti,
1613            version: 1,
1614            table_id: [0; 16],
1615            keyspace: "test_ks".to_string(),
1616            table_name: "test_table".to_string(),
1617            generation: 1,
1618            compression: CompressionInfo {
1619                algorithm: "NONE".to_string(),
1620                chunk_size: 0,
1621                parameters: HashMap::new(),
1622            },
1623            stats: SSTableStats::default(),
1624            columns,
1625            properties: HashMap::new(),
1626        };
1627
1628        let schema = TableSchema::from_sstable_header(&header).unwrap();
1629
1630        assert_eq!(schema.keyspace, "test_ks");
1631        assert_eq!(schema.table, "test_table");
1632        assert_eq!(schema.partition_keys.len(), 1);
1633        assert_eq!(schema.partition_keys[0].name, "id");
1634        assert_eq!(schema.columns.len(), 2);
1635    }
1636}