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