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::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.
697        let (lookup_keyspace, bare_name) = match udt_name.split_once('.') {
698            Some((ks, name)) => (ks, name),
699            None => (self.keyspace.as_str(), udt_name),
700        };
701
702        if registry.contains_udt(lookup_keyspace, bare_name)
703            || registry.contains_udt("system", bare_name)
704        {
705            return Ok(());
706        }
707
708        Err(Error::schema(format!(
709            "Column '{}' references undefined UDT '{}' in keyspace '{}'",
710            column_name, udt_name, lookup_keyspace
711        )))
712    }
713
714    /// Get column by name
715    pub fn get_column(&self, name: &str) -> Option<&Column> {
716        self.columns.iter().find(|c| c.name == name)
717    }
718
719    /// Check if column is a partition key
720    pub fn is_partition_key(&self, name: &str) -> bool {
721        self.partition_keys.iter().any(|k| k.name == name)
722    }
723
724    /// Check if column is a clustering key
725    pub fn is_clustering_key(&self, name: &str) -> bool {
726        self.clustering_keys.iter().any(|k| k.name == name)
727    }
728
729    // `ordered_partition_keys` / `ordered_clustering_keys` live in the
730    // `key_ordering` submodule (issue #1677): they memoize-avoid the per-call
731    // re-sort via a "sort only if not already ordered" fast path.
732
733    /// Create a minimal test schema (for testing only)
734    #[cfg(test)]
735    pub fn new_for_testing(keyspace: &str, table: &str) -> Self {
736        Self {
737            keyspace: keyspace.to_string(),
738            table: table.to_string(),
739            partition_keys: vec![KeyColumn {
740                name: "id".to_string(),
741                data_type: "int".to_string(),
742                position: 0,
743            }],
744            clustering_keys: vec![],
745            columns: vec![Column {
746                name: "id".to_string(),
747                data_type: "int".to_string(),
748                nullable: false,
749                default: None,
750                is_static: false,
751            }],
752            comments: HashMap::new(),
753            dropped_columns: HashMap::new(),
754        }
755    }
756}
757
758/// Schema management service for handling table schemas and UDT definitions.
759///
760/// Issue #1708 (one schema source of truth): the manager holds an
761/// `Arc<RwLock<SchemaRegistry>>` and RESOLVES every schema/UDT lookup THROUGH
762/// that registry (and REGISTERS manager-side loads INTO it). It keeps NO
763/// by-value schema/UDT copy of its own, so registry-side TTL-refresh /
764/// auto-discovery and manager-side loads are always mutually visible — the two
765/// can never silently diverge. The registry owns freshness (TTL/refresh); the
766/// manager delegates.
767#[derive(Debug)]
768pub struct SchemaManager {
769    #[allow(dead_code)]
770    storage: Arc<StorageEngine>,
771    /// The single source of truth for table schemas and UDTs.
772    registry: Arc<RwLock<registry::SchemaRegistry>>,
773}
774
775impl SchemaManager {
776    /// Build a default schema registry sharing the given platform/config.
777    async fn default_registry(
778        platform: Arc<crate::platform::Platform>,
779        config: &Config,
780    ) -> Result<Arc<RwLock<registry::SchemaRegistry>>> {
781        let registry = registry::SchemaRegistry::new(
782            registry::SchemaRegistryConfig::default(),
783            platform,
784            config.clone(),
785        )
786        .await?;
787        Ok(Arc::new(RwLock::new(registry)))
788    }
789
790    /// Create a new schema manager from a path
791    pub async fn new<P: AsRef<Path>>(path: P) -> Result<Self> {
792        // Create temporary storage engine (not actually used in this context)
793        let config = Config::default();
794        let platform = Arc::new(crate::platform::Platform::new(&config).await?);
795        let storage = Arc::new(
796            StorageEngine::open(
797                path.as_ref(),
798                &config,
799                platform.clone(),
800                #[cfg(feature = "state_machine")]
801                None,
802            )
803            .await?,
804        );
805
806        let registry = Self::default_registry(platform, &config).await?;
807        Ok(Self { storage, registry })
808    }
809
810    /// Create a new schema manager with storage
811    pub async fn new_with_storage(storage: Arc<StorageEngine>, config: &Config) -> Result<Self> {
812        let platform = Arc::new(crate::platform::Platform::new(config).await?);
813        let registry = Self::default_registry(platform, config).await?;
814        let manager = Self { storage, registry };
815
816        // Load built-in UDT definitions for Cassandra 5.0 compatibility
817        manager.load_default_udts().await;
818
819        Ok(manager)
820    }
821
822    /// Create a new schema manager with a pre-loaded SchemaRegistry
823    ///
824    /// This constructor is used when schemas are loaded from external .cql files
825    /// during ingestion, allowing the pre-loaded schemas to be used by the query engine.
826    ///
827    /// Issue #1708: the registry is SHARED by reference (not snapshotted). Every
828    /// subsequent registry-side update is immediately visible to this manager,
829    /// and every manager-side load is registered back into this same registry.
830    ///
831    /// # Arguments
832    ///
833    /// * `storage` - The storage engine instance
834    /// * `registry` - Pre-loaded schema registry from ingestion
835    /// * `_config` - Database configuration (currently unused)
836    pub async fn new_with_registry(
837        storage: Arc<StorageEngine>,
838        registry: Arc<tokio::sync::RwLock<registry::SchemaRegistry>>,
839        _config: &Config,
840    ) -> Result<Self> {
841        Ok(Self { storage, registry })
842    }
843
844    /// Access the shared schema registry (test-only).
845    ///
846    /// Lets tests register schemas/UDTs into the single source of truth the
847    /// manager resolves through, to assert cross-visibility (issue #1708).
848    #[cfg(test)]
849    pub(crate) fn registry(&self) -> Arc<RwLock<registry::SchemaRegistry>> {
850        self.registry.clone()
851    }
852
853    /// Load default UDT definitions that are commonly used in Cassandra
854    async fn load_default_udts(&self) {
855        // Common address UDT used in many Cassandra schemas
856        let address_udt = UdtTypeDef::new("test_keyspace".to_string(), "address".to_string())
857            .with_field("street".to_string(), CqlType::Text, true)
858            .with_field("city".to_string(), CqlType::Text, true)
859            .with_field("state".to_string(), CqlType::Text, true)
860            .with_field("zip_code".to_string(), CqlType::Text, true)
861            .with_field("country".to_string(), CqlType::Text, true);
862
863        self.register_udt(address_udt).await;
864
865        // Enhanced person UDT with nested address
866        let person_udt = UdtTypeDef::new("test_keyspace".to_string(), "person".to_string())
867            .with_field("name".to_string(), CqlType::Text, true)
868            .with_field("age".to_string(), CqlType::Int, true)
869            .with_field("email".to_string(), CqlType::Text, true)
870            .with_field(
871                "addresses".to_string(),
872                CqlType::List(Box::new(CqlType::Udt(
873                    "address".to_string(),
874                    vec![
875                        ("street".to_string(), CqlType::Text),
876                        ("city".to_string(), CqlType::Text),
877                        ("state".to_string(), CqlType::Text),
878                        ("zip_code".to_string(), CqlType::Text),
879                        ("country".to_string(), CqlType::Text),
880                    ],
881                ))),
882                true,
883            )
884            .with_field(
885                "contact_info".to_string(),
886                CqlType::Map(Box::new(CqlType::Text), Box::new(CqlType::Text)),
887                true,
888            );
889
890        self.register_udt(person_udt).await;
891
892        // Company UDT with nested person and address relationships
893        let company_udt = UdtTypeDef::new("test_keyspace".to_string(), "company".to_string())
894            .with_field("name".to_string(), CqlType::Text, false)
895            .with_field(
896                "headquarters".to_string(),
897                CqlType::Udt(
898                    "address".to_string(),
899                    vec![
900                        ("street".to_string(), CqlType::Text),
901                        ("city".to_string(), CqlType::Text),
902                        ("state".to_string(), CqlType::Text),
903                        ("zip_code".to_string(), CqlType::Text),
904                        ("country".to_string(), CqlType::Text),
905                    ],
906                ),
907                true,
908            )
909            .with_field(
910                "employees".to_string(),
911                CqlType::Set(Box::new(CqlType::Udt("person".to_string(), vec![]))),
912                true,
913            )
914            .with_field("founded_year".to_string(), CqlType::Int, true);
915
916        self.register_udt(company_udt).await;
917    }
918
919    /// Register a new UDT type definition into the shared registry (issue #1708).
920    pub async fn register_udt(&self, udt_def: UdtTypeDef) {
921        // `SchemaRegistry::register_udt` is infallible in practice (it only
922        // inserts into the in-memory UDT registry); ignore the `Ok(())`.
923        let _ = self.registry.read().await.register_udt(udt_def).await;
924    }
925
926    /// Get a UDT definition from the shared registry (returns a cloned UdtTypeDef).
927    pub async fn get_udt(&self, keyspace: &str, name: &str) -> Option<UdtTypeDef> {
928        self.registry
929            .read()
930            .await
931            .get_udt(keyspace, name)
932            .await
933            .ok()
934            .flatten()
935    }
936
937    /// Load schema for a table.
938    ///
939    /// Returns `Err(Error::Schema(..))` for an unknown table. CQLite never
940    /// fabricates a schema for a table nobody defined — doing so would return
941    /// fabricated-shape rows for undefined tables, violating the no-heuristics
942    /// mandate. This mirrors the I3 (#1626) hard-fail precedent (issue #1710).
943    ///
944    /// This resolves THROUGH the shared registry (issue #1708), so a
945    /// registry-side TTL-refresh / auto-discovery is always observed and a
946    /// stale by-value snapshot can never be served. No write happens on the
947    /// unknown-table path.
948    pub async fn load_schema(&self, table_name: &str) -> Result<TableSchema> {
949        // A `keyspace.table` name resolves as an exact scoped lookup; a bare
950        // name matches by table name across keyspaces.
951        let (keyspace, table) = match table_name.split_once('.') {
952            Some((ks, tbl)) => (Some(ks.to_string()), tbl),
953            None => (None, table_name),
954        };
955        self.find_schema_by_table(&keyspace, table)
956            .await?
957            .ok_or_else(|| {
958                Error::schema(format!(
959                    "unknown table {}; no schema registered or discovered",
960                    table_name
961                ))
962            })
963    }
964
965    /// Parse and register a schema from a CQL CREATE TABLE statement.
966    ///
967    /// Registers INTO the shared registry (issue #1708) so the parsed schema is
968    /// immediately visible to every other holder of the registry (parsing paths,
969    /// other managers), not stored in a private manager-only map.
970    pub async fn parse_and_register_cql_schema(&self, cql: &str) -> Result<TableSchema> {
971        let schema = cql_parser::parse_cql_schema(cql)?;
972        self.registry
973            .read()
974            .await
975            .register_schema(schema.clone(), registry::SchemaSource::Cql(cql.to_string()))
976            .await?;
977        Ok(schema)
978    }
979
980    /// Find schema by table name with optional keyspace matching.
981    ///
982    /// Resolves THROUGH the shared registry (issue #1708); the registry owns
983    /// freshness (issue #1708 roborev Medium): a fresh matched entry is cloned
984    /// once (issue #1587), an EXPIRED entry is refreshed rather than served
985    /// stale, and an unregistered table yields `Ok(None)` with NO fabrication
986    /// (issue #1710). The registry's refresh error (if any) propagates as `Err`.
987    pub async fn find_schema_by_table(
988        &self,
989        keyspace: &Option<String>,
990        table: &str,
991    ) -> Result<Option<TableSchema>> {
992        let found = self
993            .registry
994            .read()
995            .await
996            .find_schema_by_table(keyspace, table)
997            .await?;
998        // Preserve the issue #1587 (E5) once-per-query deep-clone accounting: the
999        // registry performs exactly one `TableSchema` clone on a hit.
1000        #[cfg(test)]
1001        if found.is_some() {
1002            TABLE_SCHEMA_CLONES.with(|c| c.set(c.get() + 1));
1003        }
1004        Ok(found)
1005    }
1006
1007    /// Extract table information from CQL without full parsing
1008    pub fn extract_table_info(&self, cql: &str) -> Result<(Option<String>, String)> {
1009        cql_parser::extract_table_name(cql)
1010    }
1011
1012    /// Convert CQL type string to internal type ID
1013    pub fn cql_type_to_internal(&self, cql_type: &str) -> Result<CqlTypeId> {
1014        cql_parser::cql_type_to_type_id(cql_type)
1015    }
1016
1017    /// Get table schema by name (async for compatibility)
1018    pub async fn get_table_schema(&self, table_name: &str) -> Result<TableSchema> {
1019        // Try to find schema by table name
1020        if let Some(schema) = self.find_schema_by_table(&None, table_name).await? {
1021            Ok(schema)
1022        } else {
1023            Err(Error::Schema(format!(
1024                "Table schema not found: {}",
1025                table_name
1026            )))
1027        }
1028    }
1029}
1030
1031#[cfg(test)]
1032mod tests {
1033    use super::*;
1034
1035    #[test]
1036    fn test_schema_validation() {
1037        let schema_json = r#"
1038        {
1039            "keyspace": "test",
1040            "table": "users",
1041            "partition_keys": [
1042                {"name": "id", "type": "bigint", "position": 0}
1043            ],
1044            "clustering_keys": [],
1045            "columns": [
1046                {"name": "id", "type": "bigint", "nullable": false},
1047                {"name": "name", "type": "text", "nullable": true}
1048            ]
1049        }
1050        "#;
1051
1052        let schema = TableSchema::from_json(schema_json).unwrap();
1053        assert_eq!(schema.keyspace, "test");
1054        assert_eq!(schema.table, "users");
1055        assert_eq!(schema.partition_keys.len(), 1);
1056        assert_eq!(schema.columns.len(), 2);
1057    }
1058
1059    #[test]
1060    fn test_schema_validation_failures() {
1061        // Missing partition key
1062        let invalid_schema = r#"
1063        {
1064            "keyspace": "test",
1065            "table": "users", 
1066            "partition_keys": [],
1067            "clustering_keys": [],
1068            "columns": []
1069        }
1070        "#;
1071
1072        assert!(TableSchema::from_json(invalid_schema).is_err());
1073
1074        // Invalid type
1075        let invalid_type = r#"
1076        {
1077            "keyspace": "test",
1078            "table": "users",
1079            "partition_keys": [
1080                {"name": "id", "type": "invalid_type", "position": 0}
1081            ],
1082            "clustering_keys": [],
1083            "columns": [
1084                {"name": "id", "type": "invalid_type", "nullable": false}
1085            ]
1086        }
1087        "#;
1088
1089        // This should succeed as we allow custom types
1090        assert!(TableSchema::from_json(invalid_type).is_ok());
1091    }
1092
1093    fn udt_schema(column_type: &str) -> TableSchema {
1094        TableSchema {
1095            keyspace: "test_ks".to_string(),
1096            table: "t".to_string(),
1097            partition_keys: vec![KeyColumn {
1098                name: "id".to_string(),
1099                data_type: "uuid".to_string(),
1100                position: 0,
1101            }],
1102            clustering_keys: vec![],
1103            columns: vec![
1104                Column {
1105                    name: "id".to_string(),
1106                    data_type: "uuid".to_string(),
1107                    nullable: false,
1108                    default: None,
1109                    is_static: false,
1110                },
1111                Column {
1112                    name: "value".to_string(),
1113                    data_type: column_type.to_string(),
1114                    nullable: true,
1115                    default: None,
1116                    is_static: false,
1117                },
1118            ],
1119            comments: HashMap::new(),
1120            dropped_columns: HashMap::new(),
1121        }
1122    }
1123
1124    #[test]
1125    fn test_udt_reference_undefined_top_level_errors() {
1126        let registry = UdtRegistry::new();
1127        let schema = udt_schema("MyMissingType");
1128
1129        let err = schema
1130            .validate_udt_references(&registry)
1131            .expect_err("undefined UDT reference must fail validation");
1132        let msg = err.to_string();
1133        assert!(
1134            matches!(err, Error::Schema(_)),
1135            "expected schema-category error, got {err:?}"
1136        );
1137        assert!(
1138            msg.contains("MyMissingType"),
1139            "error must name the missing UDT, got: {msg}"
1140        );
1141    }
1142
1143    #[test]
1144    fn test_udt_reference_undefined_lowercase_errors() {
1145        // Regression (roborev job 39): a UDT name that is purely lowercase
1146        // letters (no underscore/digit) parses to a bare `Custom("<name>")`
1147        // with no `udt:` prefix, so validation must still catch it —
1148        // top-level and nested.
1149        let registry = UdtRegistry::new();
1150        for col_type in ["address", "list<frozen<address>>"] {
1151            let schema = udt_schema(col_type);
1152            let err = schema
1153                .validate_udt_references(&registry)
1154                .expect_err("undefined lowercase UDT must fail validation");
1155            assert!(matches!(err, Error::Schema(_)), "got {err:?}");
1156            assert!(
1157                err.to_string().contains("address"),
1158                "error must name the missing UDT, got: {err}"
1159            );
1160        }
1161    }
1162
1163    #[test]
1164    fn test_uppercase_collection_of_primitives_is_not_a_udt() {
1165        // Regression (collections fixture): uppercase collections of primitives
1166        // must parse and not be mistaken for a UDT reference.
1167        let registry = UdtRegistry::new();
1168        for col_type in [
1169            "SET<TEXT>",
1170            "LIST<INT>",
1171            "MAP<TEXT, TEXT>",
1172            "FROZEN<LIST<INT>>",
1173        ] {
1174            let schema = udt_schema(col_type);
1175            schema
1176                .validate_udt_references(&registry)
1177                .unwrap_or_else(|e| panic!("'{col_type}' must not be flagged as a UDT: {e}"));
1178        }
1179    }
1180
1181    #[test]
1182    fn test_uppercase_collection_with_undefined_udt_errors() {
1183        // Regression (roborev job 51): an undefined UDT nested inside an
1184        // UPPERCASE collection must still be detected — case-insensitive parsing
1185        // means the nested reference is validated, not skipped.
1186        let registry = UdtRegistry::new();
1187        for col_type in [
1188            "LIST<MissingType>",
1189            "MAP<TEXT, MissingType>",
1190            "FROZEN<SET<MissingType>>",
1191        ] {
1192            let schema = udt_schema(col_type);
1193            let err = schema
1194                .validate_udt_references(&registry)
1195                .expect_err("undefined UDT in uppercase collection must fail");
1196            assert!(matches!(err, Error::Schema(_)), "got {err:?}");
1197            assert!(
1198                err.to_string().contains("MissingType"),
1199                "error must name the missing UDT, got: {err}"
1200            );
1201        }
1202    }
1203
1204    #[test]
1205    fn test_udt_reference_undefined_nested_in_collection_errors() {
1206        let registry = UdtRegistry::new();
1207        let schema = udt_schema("list<frozen<NestedMissing>>");
1208
1209        let err = schema
1210            .validate_udt_references(&registry)
1211            .expect_err("nested undefined UDT reference must fail validation");
1212        let msg = err.to_string();
1213        assert!(matches!(err, Error::Schema(_)));
1214        assert!(
1215            msg.contains("NestedMissing"),
1216            "error must name the nested missing UDT, got: {msg}"
1217        );
1218    }
1219
1220    #[test]
1221    fn test_udt_reference_undefined_nested_in_map_errors() {
1222        let registry = UdtRegistry::new();
1223        let schema = udt_schema("map<text, MapValueMissing>");
1224
1225        let err = schema
1226            .validate_udt_references(&registry)
1227            .expect_err("undefined UDT in map value must fail validation");
1228        assert!(matches!(err, Error::Schema(_)));
1229        assert!(err.to_string().contains("MapValueMissing"));
1230    }
1231
1232    #[test]
1233    fn test_udt_reference_defined_top_level_ok() {
1234        let mut registry = UdtRegistry::new();
1235        registry.register_udt(
1236            UdtTypeDef::new("test_ks".to_string(), "MyType".to_string()).with_field(
1237                "a".to_string(),
1238                CqlType::Text,
1239                true,
1240            ),
1241        );
1242        let schema = udt_schema("MyType");
1243        schema
1244            .validate_udt_references(&registry)
1245            .expect("defined UDT should validate");
1246    }
1247
1248    #[test]
1249    fn test_udt_reference_defined_nested_ok() {
1250        let mut registry = UdtRegistry::new();
1251        registry.register_udt(
1252            UdtTypeDef::new("test_ks".to_string(), "MyType".to_string()).with_field(
1253                "a".to_string(),
1254                CqlType::Text,
1255                true,
1256            ),
1257        );
1258        let schema = udt_schema("list<frozen<MyType>>");
1259        schema
1260            .validate_udt_references(&registry)
1261            .expect("defined nested UDT should validate");
1262    }
1263
1264    #[test]
1265    fn test_validate_udt_references_no_udts_ok() {
1266        // Schemas without any UDT columns must validate against an empty registry.
1267        let registry = UdtRegistry::new();
1268        let schema = udt_schema("map<text, list<int>>");
1269        schema
1270            .validate_udt_references(&registry)
1271            .expect("primitive/collection-only schema should validate");
1272    }
1273
1274    async fn build_storage() -> Arc<StorageEngine> {
1275        let config = Config::default();
1276        let platform = Arc::new(crate::platform::Platform::new(&config).await.unwrap());
1277        let temp_dir = tempfile::tempdir().unwrap();
1278        Arc::new(
1279            StorageEngine::open(
1280                temp_dir.path(),
1281                &config,
1282                platform,
1283                #[cfg(feature = "state_machine")]
1284                None,
1285            )
1286            .await
1287            .unwrap(),
1288        )
1289    }
1290
1291    async fn build_shared_registry() -> Arc<RwLock<registry::SchemaRegistry>> {
1292        let config = Config::default();
1293        let platform = Arc::new(crate::platform::Platform::new(&config).await.unwrap());
1294        Arc::new(RwLock::new(
1295            registry::SchemaRegistry::new(
1296                registry::SchemaRegistryConfig::default(),
1297                platform,
1298                config,
1299            )
1300            .await
1301            .unwrap(),
1302        ))
1303    }
1304
1305    async fn test_manager() -> Arc<SchemaManager> {
1306        let config = Config::default();
1307        let storage = build_storage().await;
1308        Arc::new(
1309            SchemaManager::new_with_storage(storage, &config)
1310                .await
1311                .unwrap(),
1312        )
1313    }
1314
1315    fn table_schema_named(table_name: &str) -> TableSchema {
1316        let mut schema = udt_schema("text");
1317        schema.table = table_name.to_string();
1318        schema
1319    }
1320
1321    /// Issue #1710: `load_schema` for a table nobody defined must return `Err`,
1322    /// never a fabricated `uuid id` schema (no-heuristics mandate; I3 #1626
1323    /// hard-fail precedent). RED on main, which fabricated a default schema.
1324    #[tokio::test]
1325    async fn test_load_schema_unknown_table_errs() {
1326        let manager = test_manager().await;
1327
1328        let err = manager
1329            .load_schema("never_defined_table")
1330            .await
1331            .expect_err("unknown table must error, not fabricate a schema");
1332        assert!(matches!(err, Error::Schema(_)), "got {err:?}");
1333        assert!(
1334            err.to_string().contains("never_defined_table"),
1335            "error must name the unknown table, got: {err}"
1336        );
1337
1338        // And the failed lookup must NOT register a fabricated entry.
1339        let stats = manager
1340            .registry()
1341            .read()
1342            .await
1343            .get_statistics()
1344            .await
1345            .unwrap();
1346        assert_eq!(
1347            stats.total_schemas, 0,
1348            "unknown-table lookup must not mutate the registry"
1349        );
1350    }
1351
1352    /// Issue #1710: concurrent `load_schema` of a registered table returns the
1353    /// real schema from every task, and never mutates the registry.
1354    #[tokio::test]
1355    async fn test_concurrent_schema_access() {
1356        let manager = test_manager().await;
1357
1358        // Register 3 real schemas up front THROUGH the shared registry (issue
1359        // #1708 single source of truth; authoritative metadata only).
1360        for i in 0..3 {
1361            let name = format!("table_{}", i);
1362            manager
1363                .registry()
1364                .read()
1365                .await
1366                .register_schema(table_schema_named(&name), registry::SchemaSource::Manual)
1367                .await
1368                .unwrap();
1369        }
1370
1371        // Spawn 10 concurrent tasks loading the 3 tables.
1372        let mut handles = vec![];
1373        for i in 0..10 {
1374            let m = Arc::clone(&manager);
1375            let handle = tokio::spawn(async move {
1376                let table = format!("table_{}", i % 3);
1377                let schema = m.load_schema(&table).await.expect("registered table loads");
1378                assert_eq!(schema.table, table);
1379            });
1380            handles.push(handle);
1381        }
1382        for handle in handles {
1383            handle.await.unwrap();
1384        }
1385
1386        // Registry is unchanged: exactly the 3 registered entries, no fabrication.
1387        let stats = manager
1388            .registry()
1389            .read()
1390            .await
1391            .get_statistics()
1392            .await
1393            .unwrap();
1394        assert_eq!(stats.total_schemas, 3);
1395        for i in 0..3 {
1396            assert!(manager.load_schema(&format!("table_{}", i)).await.is_ok());
1397        }
1398    }
1399
1400    /// Issue #1708: the manager must resolve THROUGH the shared registry, never a
1401    /// stale by-value snapshot captured at construction. Register v1, build the
1402    /// manager, then update the schema THROUGH the registry (simulating a
1403    /// TTL-refresh / auto-discovery re-registration) and confirm the manager
1404    /// observes v2. RED on main (returned the snapshotted v1).
1405    #[tokio::test]
1406    async fn test_manager_reflects_registry_updates_no_stale_snapshot() {
1407        let registry = build_shared_registry().await;
1408
1409        // v1: two columns (id uuid, value text).
1410        let v1 = table_schema_named("evolving");
1411        assert_eq!(v1.columns.len(), 2);
1412        registry
1413            .read()
1414            .await
1415            .register_schema(v1.clone(), registry::SchemaSource::Manual)
1416            .await
1417            .unwrap();
1418
1419        let storage = build_storage().await;
1420        let config = Config::default();
1421        let manager = SchemaManager::new_with_registry(storage, registry.clone(), &config)
1422            .await
1423            .unwrap();
1424
1425        let got_v1 = manager.get_table_schema("evolving").await.unwrap();
1426        assert_eq!(got_v1.columns.len(), 2, "manager must see v1");
1427
1428        // Update THROUGH the registry: v2 adds a column.
1429        let mut v2 = v1.clone();
1430        v2.columns.push(Column {
1431            name: "extra".to_string(),
1432            data_type: "int".to_string(),
1433            nullable: true,
1434            default: None,
1435            is_static: false,
1436        });
1437        registry
1438            .read()
1439            .await
1440            .register_schema(v2, registry::SchemaSource::Manual)
1441            .await
1442            .unwrap();
1443
1444        let got_v2 = manager.get_table_schema("evolving").await.unwrap();
1445        assert_eq!(
1446            got_v2.columns.len(),
1447            3,
1448            "manager must resolve THROUGH the registry, not a stale by-value snapshot"
1449        );
1450    }
1451
1452    /// Issue #1708 (roborev Medium): the registry OWNS freshness and the manager
1453    /// merely delegates — an entry EXPIRED in the registry must NOT be served
1454    /// stale through the manager. With `cache_ttl_seconds = 0` the registered
1455    /// schema expires, so `load_schema`/`get_table_schema` must return the
1456    /// registry's refresh outcome (an `Err` here — no SSTables back a
1457    /// manually-registered schema), NOT `Ok(stale schema)`. RED on HEAD, which
1458    /// read the registry map directly and served the expired entry forever.
1459    #[tokio::test]
1460    async fn test_manager_does_not_serve_ttl_expired_schema() {
1461        let config = Config::default();
1462        let platform = Arc::new(crate::platform::Platform::new(&config).await.unwrap());
1463        let mut reg_config = registry::SchemaRegistryConfig::default();
1464        reg_config.cache_ttl_seconds = 0; // every registered entry expires immediately
1465        let registry = Arc::new(RwLock::new(
1466            registry::SchemaRegistry::new(reg_config, platform, config.clone())
1467                .await
1468                .unwrap(),
1469        ));
1470
1471        // Register a real schema THROUGH the shared registry.
1472        registry
1473            .read()
1474            .await
1475            .register_schema(
1476                table_schema_named("stale_tbl"),
1477                registry::SchemaSource::Manual,
1478            )
1479            .await
1480            .unwrap();
1481
1482        let storage = build_storage().await;
1483        let manager = SchemaManager::new_with_registry(storage, registry.clone(), &config)
1484            .await
1485            .unwrap();
1486
1487        // Let the zero-TTL entry expire.
1488        tokio::time::sleep(std::time::Duration::from_millis(10)).await;
1489
1490        // The manager must delegate to the registry's refresh path (which errors
1491        // for a manually-registered schema with no backing SSTables) rather than
1492        // serve the stale entry. On HEAD this returned `Ok(stale)`.
1493        let loaded = manager.load_schema("stale_tbl").await;
1494        assert!(
1495            loaded.is_err(),
1496            "manager must delegate TTL freshness to the registry, not serve a stale schema; got {loaded:?}"
1497        );
1498        let got = manager.get_table_schema("stale_tbl").await;
1499        assert!(
1500            got.is_err(),
1501            "get_table_schema must also honor registry expiry; got {got:?}"
1502        );
1503
1504        // The unknown-table no-fabrication contract (#1710) still holds.
1505        assert!(
1506            manager.load_schema("never_defined_here").await.is_err(),
1507            "unknown table still errors (no fabrication)"
1508        );
1509    }
1510
1511    /// Issue #1708: UDT-registry sharing must be constructor-INDEPENDENT. A UDT
1512    /// registered into the shared registry is visible via the manager's `get_udt`
1513    /// for EVERY constructor path, and a UDT registered via the manager is
1514    /// visible via the shared registry. RED on main for `new`/`new_with_storage`
1515    /// (they built an unshared private UDT-registry copy).
1516    #[tokio::test]
1517    async fn test_all_constructors_share_udt_registry() {
1518        // Manager built via `new`.
1519        let temp = tempfile::tempdir().unwrap();
1520        let via_new = SchemaManager::new(temp.path()).await.unwrap();
1521
1522        // Manager built via `new_with_storage`.
1523        let config = Config::default();
1524        let via_storage = {
1525            let storage = build_storage().await;
1526            SchemaManager::new_with_storage(storage, &config)
1527                .await
1528                .unwrap()
1529        };
1530
1531        // Manager built via `new_with_registry`.
1532        let via_registry = {
1533            let registry = build_shared_registry().await;
1534            let storage = build_storage().await;
1535            SchemaManager::new_with_registry(storage, registry, &config)
1536                .await
1537                .unwrap()
1538        };
1539
1540        for (label, manager) in [
1541            ("new", &via_new),
1542            ("new_with_storage", &via_storage),
1543            ("new_with_registry", &via_registry),
1544        ] {
1545            // registry -> manager visibility
1546            let point = UdtTypeDef::new("ks_share".to_string(), "point".to_string()).with_field(
1547                "x".to_string(),
1548                CqlType::Int,
1549                true,
1550            );
1551            manager
1552                .registry()
1553                .read()
1554                .await
1555                .register_udt(point)
1556                .await
1557                .unwrap();
1558            assert!(
1559                manager.get_udt("ks_share", "point").await.is_some(),
1560                "[{label}] UDT registered in the shared registry must be visible via the manager"
1561            );
1562
1563            // manager -> registry visibility
1564            let line = UdtTypeDef::new("ks_share".to_string(), "line".to_string()).with_field(
1565                "len".to_string(),
1566                CqlType::Int,
1567                true,
1568            );
1569            manager.register_udt(line).await;
1570            let seen = manager
1571                .registry()
1572                .read()
1573                .await
1574                .get_udt("ks_share", "line")
1575                .await
1576                .unwrap();
1577            assert!(
1578                seen.is_some(),
1579                "[{label}] UDT registered via the manager must be visible in the shared registry"
1580            );
1581        }
1582    }
1583
1584    #[test]
1585    fn test_schema_from_sstable_header() {
1586        use crate::parser::header::{
1587            CassandraVersion, ColumnInfo, CompressionInfo, SSTableHeader, SSTableStats,
1588        };
1589        use std::collections::HashMap;
1590
1591        let columns = vec![
1592            ColumnInfo {
1593                name: "id".to_string(),
1594                column_type: "int".to_string(),
1595                is_primary_key: true,
1596                key_position: Some(0),
1597                is_static: false,
1598                is_clustering: false,
1599                clustering_reversed: false,
1600            },
1601            ColumnInfo {
1602                name: "name".to_string(),
1603                column_type: "text".to_string(),
1604                is_primary_key: false,
1605                key_position: None,
1606                is_static: false,
1607                is_clustering: false,
1608                clustering_reversed: false,
1609            },
1610        ];
1611
1612        let header = SSTableHeader {
1613            cassandra_version: CassandraVersion::V5_0Bti,
1614            version: 1,
1615            table_id: [0; 16],
1616            keyspace: "test_ks".to_string(),
1617            table_name: "test_table".to_string(),
1618            generation: 1,
1619            compression: CompressionInfo {
1620                algorithm: "NONE".to_string(),
1621                chunk_size: 0,
1622                parameters: HashMap::new(),
1623            },
1624            stats: SSTableStats::default(),
1625            columns,
1626            properties: HashMap::new(),
1627        };
1628
1629        let schema = TableSchema::from_sstable_header(&header).unwrap();
1630
1631        assert_eq!(schema.keyspace, "test_ks");
1632        assert_eq!(schema.table, "test_table");
1633        assert_eq!(schema.partition_keys.len(), 1);
1634        assert_eq!(schema.partition_keys[0].name, "id");
1635        assert_eq!(schema.columns.len(), 2);
1636    }
1637}