Skip to main content

cqlite_core/schema/
registry.rs

1//! Schema Registry for Centralized Schema Management
2//!
3//! This module provides a centralized registry for managing table schemas, UDTs,
4//! and other schema-related information with support for schema discovery,
5//! validation, caching, and version management.
6
7use std::collections::HashMap;
8use std::path::PathBuf;
9use std::sync::Arc;
10use std::time::SystemTime;
11
12use serde::{Deserialize, Serialize};
13use tokio::sync::RwLock;
14
15use crate::{
16    platform::Platform,
17    schema::{
18        discovery::{SchemaDiscoveryConfig, SchemaDiscoveryEngine, SchemaInfo},
19        CqlType, TableSchema, UdtRegistry,
20    },
21    types::{ComparatorType, UdtTypeDef},
22    Config, Error, Result,
23};
24
25/// Configuration for schema registry
26#[derive(Debug, Clone)]
27pub struct SchemaRegistryConfig {
28    /// Enable automatic schema discovery
29    pub enable_auto_discovery: bool,
30    /// Enable schema caching
31    pub enable_caching: bool,
32    /// Cache TTL in seconds
33    pub cache_ttl_seconds: u64,
34    /// Enable schema versioning
35    pub enable_versioning: bool,
36    /// Maximum versions to keep per schema
37    pub max_versions_per_schema: usize,
38    /// Enable schema validation
39    pub enable_validation: bool,
40    /// Auto-refresh schemas on SSTable changes
41    pub auto_refresh_on_changes: bool,
42    /// Discovery configuration
43    pub discovery_config: SchemaDiscoveryConfig,
44}
45
46impl Default for SchemaRegistryConfig {
47    fn default() -> Self {
48        Self {
49            enable_auto_discovery: true,
50            enable_caching: true,
51            cache_ttl_seconds: 3600, // 1 hour
52            enable_versioning: true,
53            max_versions_per_schema: 5,
54            enable_validation: true,
55            auto_refresh_on_changes: false, // Disabled by default for performance
56            discovery_config: SchemaDiscoveryConfig::default(),
57        }
58    }
59}
60
61/// Centralized schema registry
62#[derive(Debug)]
63pub struct SchemaRegistry {
64    /// Configuration
65    config: SchemaRegistryConfig,
66    /// Platform abstraction
67    _platform: Arc<Platform>,
68    /// Core configuration
69    _core_config: Config,
70    /// Registered table schemas by keyspace.table
71    schemas: Arc<RwLock<HashMap<String, SchemaEntry>>>,
72    /// UDT registry for managing user-defined types
73    udt_registry: Arc<RwLock<UdtRegistry>>,
74    /// Schema discovery engine
75    discovery_engine: Arc<SchemaDiscoveryEngine>,
76    /// Schema validator
77    validator: Arc<SchemaValidator>,
78    /// Schema version history
79    version_history: Arc<RwLock<HashMap<String, Vec<SchemaVersion>>>>,
80    /// Per-table update-serialization locks (issue #1710).
81    ///
82    /// Maps `table_id` -> an async mutex whose guard is held across the whole
83    /// `register_schema` sequence {decide `existed` -> upsert schema -> append
84    /// version-history} for that table. This serializes concurrent updates of
85    /// the SAME table so the `schemas` map and `version_history` can never
86    /// diverge (e.g. registry ends with task A's schema while history records
87    /// task B's as newest), WITHOUT holding the global `schemas` write lock
88    /// across the `create_schema_version(...).await`.
89    ///
90    /// Lock ordering (deadlock-free): the per-table lock is ALWAYS acquired
91    /// BEFORE the `schemas`/`version_history` locks; those inner locks are only
92    /// ever held briefly and never across an `.await`. The `std::sync::Mutex`
93    /// guarding this map itself is held only long enough to clone the per-table
94    /// `Arc` — never across an `.await`.
95    update_locks: std::sync::Mutex<HashMap<String, Arc<tokio::sync::Mutex<()>>>>,
96}
97
98/// Derived parsing state precomputed once at schema registration (issue #1709).
99///
100/// [`SchemaRegistry::get_parsing_context`] is on the read path (called per
101/// Index.db lookup via `key_digest`). Re-deriving the comparators — which are
102/// CONSTANT for a registered schema — on every call cost four `TableSchema`
103/// deep clones and one `CqlType::parse` per column per call. We instead compute
104/// these once when the [`SchemaEntry`] is created and hand them out as `Arc`
105/// clones (a pointer bump). The state lives ON the entry so it is invalidated
106/// atomically whenever the entry is replaced.
107#[derive(Debug, Clone)]
108struct DerivedParsingState {
109    /// The complete table schema, shared.
110    schema: Arc<TableSchema>,
111    /// Comparators for partition key components, in key order.
112    partition_comparators: Arc<Vec<ComparatorType>>,
113    /// Comparators for clustering key components, in key order.
114    clustering_comparators: Arc<Vec<ComparatorType>>,
115    /// Comparators for all columns, keyed by column name.
116    column_comparators: Arc<HashMap<String, ComparatorType>>,
117}
118
119impl DerivedParsingState {
120    /// Compute the derived comparators for a schema. Runs `CqlType::parse` per
121    /// key/column exactly once, at registration time.
122    fn compute(schema: &TableSchema) -> Result<Self> {
123        let mut partition_comparators = Vec::new();
124        for key_column in schema.ordered_partition_keys() {
125            let cql_type = CqlType::parse(&key_column.data_type)?;
126            partition_comparators.push(ComparatorType::from_cql_type(&cql_type)?);
127        }
128
129        let mut clustering_comparators = Vec::new();
130        for key_column in schema.ordered_clustering_keys() {
131            let cql_type = CqlType::parse(&key_column.data_type)?;
132            clustering_comparators.push(ComparatorType::from_cql_type(&cql_type)?);
133        }
134
135        let mut column_comparators = HashMap::new();
136        for column in &schema.columns {
137            let cql_type = CqlType::parse(&column.data_type)?;
138            column_comparators.insert(
139                column.name.clone(),
140                ComparatorType::from_cql_type(&cql_type)?,
141            );
142        }
143
144        Ok(Self {
145            schema: Arc::new(schema.clone()),
146            partition_comparators: Arc::new(partition_comparators),
147            clustering_comparators: Arc::new(clustering_comparators),
148            column_comparators: Arc::new(column_comparators),
149        })
150    }
151
152    /// Build a [`ParsingContext`] by cloning the shared `Arc`s (pointer bumps,
153    /// no deep clone, no parse).
154    fn to_parsing_context(&self) -> ParsingContext {
155        ParsingContext {
156            schema: self.schema.clone(),
157            partition_comparators: self.partition_comparators.clone(),
158            clustering_comparators: self.clustering_comparators.clone(),
159            column_comparators: self.column_comparators.clone(),
160        }
161    }
162}
163
164/// Schema entry in the registry
165#[derive(Debug, Clone)]
166struct SchemaEntry {
167    /// The table schema
168    schema: TableSchema,
169    /// Precomputed derived parsing state (comparators), or `None` if it could
170    /// not be derived at registration (e.g. a malformed type string). When
171    /// `None`, `get_parsing_context` recomputes on demand, preserving the exact
172    /// original error behavior without deep-cloning the schema.
173    derived: Option<DerivedParsingState>,
174    /// Extended schema information if available
175    extended_info: Option<SchemaInfo>,
176    /// When the schema was registered/updated
177    registered_at: SystemTime,
178    /// Source of the schema
179    source: SchemaSource,
180    /// Validation status
181    validation_status: SchemaValidationStatus,
182    /// Associated SSTable files
183    _associated_files: Vec<PathBuf>,
184}
185
186/// Source of schema information
187#[derive(Debug, Clone)]
188pub enum SchemaSource {
189    /// Discovered from SSTable files
190    Discovered(Vec<PathBuf>),
191    /// Loaded from external definition
192    External(PathBuf),
193    /// Parsed from CQL DDL
194    Cql(String),
195    /// Manually registered
196    Manual,
197}
198
199/// Schema validation status
200#[derive(Debug, Clone, PartialEq, Eq)]
201pub enum SchemaValidationStatus {
202    /// Schema is valid
203    Valid,
204    /// Schema has warnings but is usable
205    ValidWithWarnings,
206    /// Schema is invalid
207    Invalid,
208    /// Not yet validated
209    NotValidated,
210}
211
212/// Schema version information
213#[derive(Debug, Clone, Serialize, Deserialize)]
214pub struct SchemaVersion {
215    /// Version number
216    pub version: u32,
217    /// When this version was created
218    pub created_at: SystemTime,
219    /// Schema at this version
220    pub schema: TableSchema,
221    /// Changes from previous version
222    pub changes: Vec<SchemaChange>,
223    /// Source of this version
224    pub source: String,
225}
226
227/// Schema change description
228#[derive(Debug, Clone, Serialize, Deserialize)]
229pub struct SchemaChange {
230    /// Type of change
231    pub change_type: SchemaChangeType,
232    /// Component affected
233    pub component: String,
234    /// Description of the change
235    pub description: String,
236    /// Old value (if applicable)
237    pub old_value: Option<String>,
238    /// New value (if applicable)
239    pub new_value: Option<String>,
240}
241
242/// Types of schema changes
243#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
244pub enum SchemaChangeType {
245    /// Column added
246    ColumnAdded,
247    /// Column removed
248    ColumnRemoved,
249    /// Column type changed
250    ColumnTypeChanged,
251    /// Column renamed
252    ColumnRenamed,
253    /// Index added
254    IndexAdded,
255    /// Index removed
256    IndexRemoved,
257    /// UDT added
258    UdtAdded,
259    /// UDT modified
260    UdtModified,
261    /// UDT removed
262    UdtRemoved,
263    /// Table option changed
264    TableOptionChanged,
265}
266
267/// Schema validation report
268#[derive(Debug, Clone)]
269pub struct ValidationReport {
270    /// Table identifier
271    pub table_id: String,
272    /// Overall validation status
273    pub status: SchemaValidationStatus,
274    /// Validation errors
275    pub errors: Vec<ValidationError>,
276    /// Validation warnings
277    pub warnings: Vec<ValidationWarning>,
278    /// Recommendations
279    pub recommendations: Vec<String>,
280    /// Validation timestamp
281    pub validated_at: SystemTime,
282}
283
284/// Validation error details
285#[derive(Debug, Clone)]
286pub struct ValidationError {
287    /// Error code
288    pub code: String,
289    /// Error message
290    pub message: String,
291    /// Affected component
292    pub component: Option<String>,
293    /// Severity level
294    pub severity: ErrorSeverity,
295}
296
297/// Error severity levels
298#[derive(Debug, Clone, PartialEq, Eq)]
299pub enum ErrorSeverity {
300    Critical,
301    High,
302    Medium,
303    Low,
304}
305
306/// Validation warning details
307#[derive(Debug, Clone)]
308pub struct ValidationWarning {
309    /// Warning code
310    pub code: String,
311    /// Warning message
312    pub message: String,
313    /// Affected component
314    pub component: Option<String>,
315}
316
317/// Schema search query
318#[derive(Debug, Clone)]
319pub struct SchemaQuery {
320    /// Keyspace filter (optional)
321    pub keyspace: Option<String>,
322    /// Table name pattern (supports wildcards)
323    pub table_pattern: Option<String>,
324    /// Include schemas with specific source types
325    pub source_types: Option<Vec<SchemaSource>>,
326    /// Include only validated schemas
327    pub validated_only: bool,
328    /// Include version history
329    pub include_history: bool,
330}
331
332impl SchemaRegistry {
333    /// Create a new schema registry
334    pub async fn new(
335        config: SchemaRegistryConfig,
336        platform: Arc<Platform>,
337        core_config: Config,
338    ) -> Result<Self> {
339        let discovery_engine = Arc::new(
340            SchemaDiscoveryEngine::new(
341                config.discovery_config.clone(),
342                platform.clone(),
343                core_config.clone(),
344            )
345            .await?,
346        );
347
348        let validator = Arc::new(SchemaValidator::new());
349        let udt_registry = Arc::new(RwLock::new(UdtRegistry::new()));
350
351        Ok(Self {
352            config,
353            _platform: platform,
354            _core_config: core_config,
355            schemas: Arc::new(RwLock::new(HashMap::new())),
356            udt_registry,
357            discovery_engine,
358            validator,
359            version_history: Arc::new(RwLock::new(HashMap::new())),
360            update_locks: std::sync::Mutex::new(HashMap::new()),
361        })
362    }
363
364    /// Fetch (creating if absent) the per-table update-serialization lock.
365    ///
366    /// The map's `std::sync::Mutex` is held only long enough to clone the
367    /// per-table `Arc<tokio::sync::Mutex<()>>` and is NEVER held across an
368    /// `.await`. On poisoning we recover the inner map rather than panic
369    /// (no `unwrap()`/`expect()` in library code).
370    fn table_update_lock(&self, table_id: &str) -> Arc<tokio::sync::Mutex<()>> {
371        let mut locks = self
372            .update_locks
373            .lock()
374            .unwrap_or_else(|poisoned| poisoned.into_inner());
375        locks
376            .entry(table_id.to_string())
377            .or_insert_with(|| Arc::new(tokio::sync::Mutex::new(())))
378            .clone()
379    }
380
381    /// Discover and register schema from SSTable files
382    pub async fn discover_schema(
383        &self,
384        keyspace: &str,
385        table: &str,
386        sstable_files: &[PathBuf],
387    ) -> Result<TableSchema> {
388        if !self.config.enable_auto_discovery {
389            return Err(Error::Schema("Auto-discovery is disabled".to_string()));
390        }
391
392        // Use discovery engine to analyze SSTable files
393        let schema_info = self
394            .discovery_engine
395            .discover_schema(keyspace, table, sstable_files)
396            .await?;
397
398        // Convert to TableSchema format for compatibility
399        let table_schema = self.convert_schema_info_to_table_schema(&schema_info)?;
400
401        // Register the discovered schema
402        self.register_discovered_schema(
403            table_schema.clone(),
404            Some(schema_info),
405            sstable_files.to_vec(),
406        )
407        .await?;
408
409        Ok(table_schema)
410    }
411
412    /// Register a schema from external source
413    pub async fn register_schema(&self, schema: TableSchema, source: SchemaSource) -> Result<()> {
414        let table_id = format!("{}.{}", schema.keyspace, schema.table);
415
416        // Serialize the WHOLE update for this table (issue #1710).
417        //
418        // The upsert of `schemas` and the append to `version_history` cannot be
419        // performed under one held lock: `create_schema_version` awaits the
420        // independent `version_history` lock, and holding the `schemas` write
421        // guard across that `.await` was the lock-order hazard fixed earlier.
422        // But if the upsert and the history-append are not serialized *together*
423        // per table, two concurrent updates of the SAME table with DIFFERENT
424        // schemas can interleave so the registry ends with one task's schema
425        // while history records the other's as newest (registry/history
426        // divergence).
427        //
428        // Fix: hold a per-table async mutex across the entire
429        // {decide existed -> upsert -> append version} sequence below. It is
430        // acquired FIRST — before any `schemas`/`version_history` lock — and
431        // those inner locks are still taken only briefly and never across an
432        // `.await`, so there is no lock-order cycle (no deadlock). Two
433        // concurrent `register_schema` calls for the same table thus run
434        // strictly one-after-another and stay consistent; calls for different
435        // tables still proceed in parallel.
436        let table_lock = self.table_update_lock(&table_id);
437        let _update_guard = table_lock.lock().await;
438
439        // Validate schema if validation is enabled
440        let validation_status = if self.config.enable_validation {
441            match self.validator.validate_table_schema(&schema).await {
442                Ok(_) => SchemaValidationStatus::Valid,
443                Err(_) => SchemaValidationStatus::Invalid,
444            }
445        } else {
446            SchemaValidationStatus::NotValidated
447        };
448
449        // Create schema entry, precomputing the derived comparators once so the
450        // read-path `get_parsing_context` is a pointer bump (issue #1709). A
451        // malformed type leaves `derived = None`; the error then surfaces on the
452        // context path exactly as before (never fails registration).
453        let derived = DerivedParsingState::compute(&schema).ok();
454        let entry = SchemaEntry {
455            schema: schema.clone(),
456            derived,
457            extended_info: None,
458            registered_at: SystemTime::now(),
459            source,
460            validation_status,
461            _associated_files: Vec::new(),
462        };
463
464        // Store in registry.
465        //
466        // The existence check and the insert MUST be a single write-locked
467        // critical section: two concurrent FIRST registrations of the same
468        // table must not both observe "absent". A check-then-act split (read
469        // lock to check, later write lock to insert) is a TOCTOU race — both
470        // callers would skip `create_schema_version`, then one insert would
471        // overwrite the other, silently dropping the second registration's
472        // update-ness (a lost version). Here we capture `existed` and insert
473        // atomically under one write guard.
474        //
475        // The guard is DROPPED before any `.await`: `create_schema_version`
476        // acquires the independent `version_history` lock and must never run
477        // while the `schemas` guard is held — that combination was the latent
478        // lock-order hazard on the cold registration path (issue #1710).
479        let existed = {
480            let mut schemas = self.schemas.write().await;
481            let existed = schemas.contains_key(&table_id);
482            schemas.insert(table_id.clone(), entry);
483            existed
484        }; // `schemas` write guard released here — no `.await` occurred under it.
485
486        if self.config.enable_versioning && existed {
487            self.create_schema_version(&table_id, &schema).await?;
488        }
489
490        Ok(())
491    }
492
493    /// Get schema by keyspace and table name
494    pub async fn get_schema(&self, keyspace: &str, table: &str) -> Result<TableSchema> {
495        let table_id = format!("{}.{}", keyspace, table);
496        let schemas = self.schemas.read().await;
497
498        match schemas.get(&table_id) {
499            Some(entry) => {
500                // Check if schema is still valid (cache TTL)
501                if self.is_entry_expired(entry) {
502                    drop(schemas); // Release read lock
503                    return self.refresh_schema(keyspace, table).await;
504                }
505                #[cfg(test)]
506                crate::schema::work_counters::record_schema_clone();
507                Ok(entry.schema.clone())
508            }
509            None => {
510                drop(schemas); // Release read lock
511                               // Try to discover schema if auto-discovery is enabled
512                if self.config.enable_auto_discovery {
513                    self.auto_discover_schema(keyspace, table).await
514                } else {
515                    Err(Error::Schema(format!(
516                        "Schema not found: {}.{}",
517                        keyspace, table
518                    )))
519                }
520            }
521        }
522    }
523
524    /// Get extended schema information
525    pub async fn get_schema_info(&self, keyspace: &str, table: &str) -> Result<Option<SchemaInfo>> {
526        let table_id = format!("{}.{}", keyspace, table);
527        let schemas = self.schemas.read().await;
528
529        match schemas.get(&table_id) {
530            Some(entry) => Ok(entry.extended_info.clone()),
531            None => Ok(None),
532        }
533    }
534
535    /// List all registered schemas
536    pub async fn list_schemas(&self, query: Option<SchemaQuery>) -> Result<Vec<TableSchema>> {
537        let schemas = self.schemas.read().await;
538        let mut results = Vec::new();
539
540        for (_table_id, entry) in schemas.iter() {
541            // Apply query filters if provided
542            if let Some(ref q) = query {
543                if !self.matches_query(&entry.schema, q) {
544                    continue;
545                }
546            }
547
548            results.push(entry.schema.clone());
549        }
550
551        // Sort by keyspace, then table name
552        results.sort_by(|a, b| {
553            a.keyspace
554                .cmp(&b.keyspace)
555                .then_with(|| a.table.cmp(&b.table))
556        });
557
558        Ok(results)
559    }
560
561    /// Find a schema by table name, optionally scoped to a keyspace, cloning
562    /// only the matched schema (issue #1708).
563    ///
564    /// This is the single freshness-owning lookup that [`crate::schema::SchemaManager`]
565    /// delegates to, so the registry — not the manager — owns freshness:
566    ///
567    /// * A **fresh** matched entry is returned by value with exactly ONE
568    ///   `TableSchema` deep clone (issue #1587).
569    /// * An **expired** matched entry is NOT served stale: it is routed through
570    ///   the SAME TTL-refresh seam [`Self::get_schema`] uses for the matched
571    ///   entry ([`Self::refresh_schema`]), and the refreshed schema (or that
572    ///   seam's error) is returned. This closes the roborev Medium where an
573    ///   entry expired in the registry was served stale indefinitely through the
574    ///   manager.
575    /// * **No match** returns `Ok(None)` with NO auto-discovery and NO
576    ///   fabrication (issue #1710): a table nobody registered must surface as
577    ///   not-found (the manager maps `None` to its own not-found error), never a
578    ///   synthesized schema.
579    pub async fn find_schema_by_table(
580        &self,
581        keyspace: &Option<String>,
582        table: &str,
583    ) -> Result<Option<TableSchema>> {
584        // Resolve the matched entry under a read guard. Clone the schema ONLY
585        // when it is fresh (the single #1587 clone); when it is EXPIRED, capture
586        // just its identity so the guard can be dropped BEFORE running the
587        // refresh seam (no `.await` is ever held under the read guard).
588        enum Matched {
589            Fresh(TableSchema),
590            Expired { keyspace: String, table: String },
591        }
592
593        let matched = {
594            let schemas = self.schemas.read().await;
595
596            // Exact `keyspace.table` match first when a keyspace is provided.
597            let mut hit = keyspace
598                .as_ref()
599                .and_then(|ks| schemas.get(&format!("{}.{}", ks, table)));
600
601            // Otherwise match any registered schema by table name (keyspace-aware).
602            if hit.is_none() {
603                hit = schemas.values().find(|entry| {
604                    crate::schema::table_name_matches(
605                        &Some(entry.schema.keyspace.clone()),
606                        &entry.schema.table,
607                        keyspace,
608                        table,
609                    )
610                });
611            }
612
613            match hit {
614                None => None,
615                Some(entry) if self.is_entry_expired(entry) => Some(Matched::Expired {
616                    keyspace: entry.schema.keyspace.clone(),
617                    table: entry.schema.table.clone(),
618                }),
619                Some(entry) => Some(Matched::Fresh(entry.schema.clone())),
620            }
621        }; // read guard dropped here — no `.await` occurred under it.
622
623        match matched {
624            // No match: no fabrication, no auto-discovery (issue #1710).
625            None => Ok(None),
626            Some(Matched::Fresh(schema)) => Ok(Some(schema)),
627            // Expired: delegate freshness through the same refresh seam
628            // `get_schema` uses for the matched entry — never serve the stale
629            // copy.
630            Some(Matched::Expired { keyspace, table }) => {
631                self.refresh_schema(&keyspace, &table).await.map(Some)
632            }
633        }
634    }
635
636    /// Validate a schema
637    #[allow(dead_code)]
638    pub async fn validate_schema(&self, keyspace: &str, table: &str) -> Result<ValidationReport> {
639        let schema = self.get_schema(keyspace, table).await?;
640        let table_id = format!("{}.{}", keyspace, table);
641
642        // Perform comprehensive validation
643        let mut errors = Vec::new();
644        let mut warnings = Vec::new();
645        let mut recommendations = Vec::new();
646
647        // Basic schema structure validation
648        if let Err(e) = schema.validate() {
649            errors.push(ValidationError {
650                code: "SCHEMA_INVALID".to_string(),
651                message: e.to_string(),
652                component: None,
653                severity: ErrorSeverity::Critical,
654            });
655        }
656
657        // UDT validation
658        self.validate_schema_udts(&schema, &mut errors, &mut warnings)
659            .await;
660
661        // Column type validation
662        self.validate_column_types(&schema, &mut errors, &mut warnings)
663            .await;
664
665        // Performance recommendations
666        self.generate_performance_recommendations(&schema, &mut recommendations)
667            .await;
668
669        // Determine overall status
670        let status = if !errors.is_empty() {
671            SchemaValidationStatus::Invalid
672        } else if !warnings.is_empty() {
673            SchemaValidationStatus::ValidWithWarnings
674        } else {
675            SchemaValidationStatus::Valid
676        };
677
678        // Update validation status in registry
679        {
680            let mut schemas = self.schemas.write().await;
681            if let Some(entry) = schemas.get_mut(&table_id) {
682                entry.validation_status = status.clone();
683            }
684        }
685
686        Ok(ValidationReport {
687            table_id,
688            status,
689            errors,
690            warnings,
691            recommendations,
692            validated_at: SystemTime::now(),
693        })
694    }
695
696    /// Get schema version history
697    pub async fn get_schema_history(
698        &self,
699        keyspace: &str,
700        table: &str,
701    ) -> Result<Vec<SchemaVersion>> {
702        if !self.config.enable_versioning {
703            return Err(Error::Schema("Schema versioning is disabled".to_string()));
704        }
705
706        let table_id = format!("{}.{}", keyspace, table);
707        let history = self.version_history.read().await;
708
709        Ok(history.get(&table_id).cloned().unwrap_or_default())
710    }
711
712    /// Remove schema from registry
713    pub async fn remove_schema(&self, keyspace: &str, table: &str) -> Result<()> {
714        let table_id = format!("{}.{}", keyspace, table);
715
716        {
717            let mut schemas = self.schemas.write().await;
718            schemas.remove(&table_id);
719        }
720
721        // Also remove from version history if versioning is enabled
722        if self.config.enable_versioning {
723            let mut history = self.version_history.write().await;
724            history.remove(&table_id);
725        }
726
727        Ok(())
728    }
729
730    /// Generate CQL CREATE statement for schema
731    pub async fn generate_cql(&self, keyspace: &str, table: &str) -> Result<String> {
732        // First try to get extended schema info for better CQL generation
733        if let Some(schema_info) = self.get_schema_info(keyspace, table).await? {
734            return self.discovery_engine.generate_cql(&schema_info).await;
735        }
736
737        // Fallback to basic TableSchema CQL generation
738        let schema = self.get_schema(keyspace, table).await?;
739        Ok(self.generate_basic_cql(&schema))
740    }
741
742    /// Export schema as JSON
743    #[cfg(feature = "experimental")]
744    pub async fn export_schema_json(&self, keyspace: &str, table: &str) -> Result<String> {
745        self.export_schema_json_with_config(
746            keyspace,
747            table,
748            &crate::schema::json_exporter::JsonExportConfig::default(),
749        )
750        .await
751    }
752
753    #[cfg(not(feature = "experimental"))]
754    pub async fn export_schema_json(&self, _keyspace: &str, _table: &str) -> Result<String> {
755        Err(crate::error::Error::unsupported_format(
756            "JSON export requires experimental feature",
757        ))
758    }
759
760    /// Export schema as JSON with custom configuration
761    #[cfg(feature = "experimental")]
762    pub async fn export_schema_json_with_config(
763        &self,
764        keyspace: &str,
765        table: &str,
766        config: &crate::schema::json_exporter::JsonExportConfig,
767    ) -> Result<String> {
768        // Try extended schema info first
769        if let Some(schema_info) = self.get_schema_info(keyspace, table).await? {
770            return self
771                .discovery_engine
772                .export_json_with_config(&schema_info, config)
773                .await;
774        }
775
776        // Fallback to basic TableSchema JSON
777        let schema = self.get_schema(keyspace, table).await?;
778        let exporter = crate::schema::json_exporter::JsonExporter::with_config(config.clone());
779        exporter.export_table_schema(&schema)
780    }
781
782    #[cfg(not(feature = "experimental"))]
783    pub async fn export_schema_json_with_config<T>(
784        &self,
785        _keyspace: &str,
786        _table: &str,
787        _config: &T,
788    ) -> Result<String> {
789        Err(crate::error::Error::unsupported_format(
790            "JSON export requires experimental feature",
791        ))
792    }
793
794    /// Export schema as compact JSON (minimal format)
795    #[cfg(feature = "experimental")]
796    pub async fn export_schema_json_compact(&self, keyspace: &str, table: &str) -> Result<String> {
797        let config = crate::schema::json_exporter::JsonExportConfig {
798            format_variant: crate::schema::json_exporter::JsonFormat::Compact,
799            include_metadata: false,
800            include_performance_metrics: false,
801            include_type_details: false,
802            pretty_format: false,
803            ..Default::default()
804        };
805        self.export_schema_json_with_config(keyspace, table, &config)
806            .await
807    }
808
809    #[cfg(not(feature = "experimental"))]
810    pub async fn export_schema_json_compact(
811        &self,
812        _keyspace: &str,
813        _table: &str,
814    ) -> Result<String> {
815        Err(crate::error::Error::unsupported_format(
816            "JSON export requires experimental feature",
817        ))
818    }
819
820    /// Export schema for API documentation (OpenAPI-compatible format)
821    #[cfg(feature = "experimental")]
822    pub async fn export_schema_json_openapi(&self, keyspace: &str, table: &str) -> Result<String> {
823        let config = crate::schema::json_exporter::JsonExportConfig {
824            format_variant: crate::schema::json_exporter::JsonFormat::OpenApi,
825            include_documentation: true,
826            include_type_details: true,
827            include_metadata: false,
828            ..Default::default()
829        };
830        self.export_schema_json_with_config(keyspace, table, &config)
831            .await
832    }
833
834    #[cfg(not(feature = "experimental"))]
835    pub async fn export_schema_json_openapi(
836        &self,
837        _keyspace: &str,
838        _table: &str,
839    ) -> Result<String> {
840        Err(crate::error::Error::unsupported_format(
841            "JSON export requires experimental feature",
842        ))
843    }
844
845    /// Export schema for data pipeline tools
846    #[cfg(feature = "experimental")]
847    pub async fn export_schema_json_pipeline(&self, keyspace: &str, table: &str) -> Result<String> {
848        let config = crate::schema::json_exporter::JsonExportConfig {
849            format_variant: crate::schema::json_exporter::JsonFormat::DataPipeline,
850            include_type_details: true,
851            include_table_options: false,
852            include_performance_metrics: true,
853            ..Default::default()
854        };
855        self.export_schema_json_with_config(keyspace, table, &config)
856            .await
857    }
858
859    #[cfg(not(feature = "experimental"))]
860    pub async fn export_schema_json_pipeline(
861        &self,
862        _keyspace: &str,
863        _table: &str,
864    ) -> Result<String> {
865        Err(crate::error::Error::unsupported_format(
866            "JSON export requires experimental feature",
867        ))
868    }
869
870    /// Export multiple schemas as a JSON collection
871    #[cfg(feature = "experimental")]
872    pub async fn export_multiple_schemas_json(
873        &self,
874        schema_infos: &[SchemaInfo],
875    ) -> Result<String> {
876        let exporter = crate::schema::json_exporter::JsonExporter::new();
877        exporter.export_multiple_schemas(schema_infos)
878    }
879
880    #[cfg(not(feature = "experimental"))]
881    pub async fn export_multiple_schemas_json(
882        &self,
883        _schema_infos: &[SchemaInfo],
884    ) -> Result<String> {
885        Err(crate::error::Error::unsupported_format(
886            "JSON export requires experimental feature",
887        ))
888    }
889
890    /// Export all schemas in a keyspace as JSON collection
891    #[cfg(feature = "experimental")]
892    pub async fn export_keyspace_schemas_json(&self, keyspace: &str) -> Result<String> {
893        let mut schema_infos = Vec::new();
894
895        // Get all schemas in the keyspace
896        for (_table_id, entry) in self.schemas.read().await.iter() {
897            if entry.schema.keyspace == keyspace {
898                // Try to get extended schema info
899                if let Ok(Some(schema_info)) = self
900                    .get_schema_info(&entry.schema.keyspace, &entry.schema.table)
901                    .await
902                {
903                    schema_infos.push(schema_info);
904                }
905            }
906        }
907
908        if schema_infos.is_empty() {
909            return Err(Error::NotFound(format!(
910                "No schemas found in keyspace '{}'",
911                keyspace
912            )));
913        }
914
915        self.export_multiple_schemas_json(&schema_infos).await
916    }
917
918    #[cfg(not(feature = "experimental"))]
919    pub async fn export_keyspace_schemas_json(&self, _keyspace: &str) -> Result<String> {
920        Err(crate::error::Error::unsupported_format(
921            "JSON export requires experimental feature",
922        ))
923    }
924
925    /// Register UDT in the registry
926    pub async fn register_udt(&self, udt_def: UdtTypeDef) -> Result<()> {
927        let mut registry = self.udt_registry.write().await;
928        registry.register_udt(udt_def);
929        Ok(())
930    }
931
932    /// Get UDT definition
933    pub async fn get_udt(&self, keyspace: &str, name: &str) -> Result<Option<UdtTypeDef>> {
934        let registry = self.udt_registry.read().await;
935        Ok(registry.get_udt(keyspace, name).cloned())
936    }
937
938    /// Get the internal UDT registry (crate-only access for schema manager)
939    ///
940    /// This method is used by SchemaManager when initialized with a pre-loaded registry
941    /// to preserve the UDT definitions loaded during ingestion.
942    ///
943    /// Gated on `state_machine`: both callers (`storage::sstable::refresh` and the
944    /// `cli-helpers`-gated ingestion module) compile out without it, so it would be
945    /// dead code under `-D warnings` in the minimal-features build (issue #1972).
946    #[cfg(feature = "state_machine")]
947    pub(crate) fn get_udt_registry(&self) -> Arc<RwLock<UdtRegistry>> {
948        self.udt_registry.clone()
949    }
950
951    /// Get ComparatorType for a specific column in a table
952    pub async fn get_column_comparator(
953        &self,
954        keyspace: &str,
955        table: &str,
956        column: &str,
957    ) -> Result<ComparatorType> {
958        let schema = self.get_schema(keyspace, table).await?;
959
960        // Find the column
961        let column_def = schema
962            .columns
963            .iter()
964            .find(|c| c.name == column)
965            .ok_or_else(|| {
966                Error::Schema(format!(
967                    "Column '{}' not found in table '{}.{}'",
968                    column, keyspace, table
969                ))
970            })?;
971
972        // Parse the column type and create comparator
973        let cql_type = CqlType::parse(&column_def.data_type)?;
974        ComparatorType::from_cql_type(&cql_type)
975    }
976
977    /// Get ComparatorType for all columns in a table
978    pub async fn get_table_comparators(
979        &self,
980        keyspace: &str,
981        table: &str,
982    ) -> Result<HashMap<String, ComparatorType>> {
983        let schema = self.get_schema(keyspace, table).await?;
984        let mut comparators = HashMap::new();
985
986        for column in &schema.columns {
987            let cql_type = CqlType::parse(&column.data_type)?;
988            let comparator = ComparatorType::from_cql_type(&cql_type)?;
989            comparators.insert(column.name.clone(), comparator);
990        }
991
992        Ok(comparators)
993    }
994
995    /// Get ComparatorType for partition key columns (for key comparison)
996    pub async fn get_partition_key_comparator(
997        &self,
998        keyspace: &str,
999        table: &str,
1000    ) -> Result<Vec<ComparatorType>> {
1001        let schema = self.get_schema(keyspace, table).await?;
1002        let mut comparators = Vec::new();
1003
1004        // Get partition keys in order
1005        let ordered_keys = schema.ordered_partition_keys();
1006        for key_column in ordered_keys {
1007            let cql_type = CqlType::parse(&key_column.data_type)?;
1008            let comparator = ComparatorType::from_cql_type(&cql_type)?;
1009            comparators.push(comparator);
1010        }
1011
1012        Ok(comparators)
1013    }
1014
1015    /// Get the complete schema context for parsing operations.
1016    ///
1017    /// This is on the read path (per Index.db lookup). For a registered, fresh
1018    /// entry it does ZERO `TableSchema` deep clones and ZERO `CqlType::parse`
1019    /// calls: it takes ONE read guard and hands out `Arc` clones of the derived
1020    /// state precomputed at registration (issue #1709).
1021    pub async fn get_parsing_context(&self, keyspace: &str, table: &str) -> Result<ParsingContext> {
1022        let table_id = format!("{}.{}", keyspace, table);
1023
1024        // Fast path: present, fresh, precomputed. One guard, no await under it,
1025        // no deep clone, no parse.
1026        {
1027            let schemas = self.schemas.read().await;
1028            if let Some(entry) = schemas.get(&table_id) {
1029                if !self.is_entry_expired(entry) {
1030                    match &entry.derived {
1031                        Some(derived) => return Ok(derived.to_parsing_context()),
1032                        // Precompute failed (malformed type): reproduce the
1033                        // original error via on-demand recompute. No await held.
1034                        None => {
1035                            return DerivedParsingState::compute(&entry.schema)
1036                                .map(|d| d.to_parsing_context())
1037                        }
1038                    }
1039                }
1040            }
1041        }
1042
1043        // Cold path: missing or TTL-expired. Trigger the refresh/auto-discovery
1044        // seam (which reinserts the entry with derived state), then read it
1045        // back. This is not the post-registration request path.
1046        self.get_schema(keyspace, table).await?;
1047
1048        let schemas = self.schemas.read().await;
1049        match schemas.get(&table_id) {
1050            Some(entry) => match &entry.derived {
1051                Some(derived) => Ok(derived.to_parsing_context()),
1052                None => DerivedParsingState::compute(&entry.schema).map(|d| d.to_parsing_context()),
1053            },
1054            None => Err(Error::Schema(format!(
1055                "Schema not found: {}.{}",
1056                keyspace, table
1057            ))),
1058        }
1059    }
1060
1061    /// Get ComparatorType for clustering key columns (for clustering comparison)
1062    pub async fn get_clustering_key_comparator(
1063        &self,
1064        keyspace: &str,
1065        table: &str,
1066    ) -> Result<Vec<ComparatorType>> {
1067        let schema = self.get_schema(keyspace, table).await?;
1068        let mut comparators = Vec::new();
1069
1070        // Get clustering keys in order
1071        let ordered_keys = schema.ordered_clustering_keys();
1072        for key_column in ordered_keys {
1073            let cql_type = CqlType::parse(&key_column.data_type)?;
1074            let comparator = ComparatorType::from_cql_type(&cql_type)?;
1075            comparators.push(comparator);
1076        }
1077
1078        Ok(comparators)
1079    }
1080
1081    /// Validate column type compatibility using ComparatorType
1082    pub async fn validate_column_type_compatibility(
1083        &self,
1084        keyspace: &str,
1085        table: &str,
1086        column: &str,
1087        expected_type: &str,
1088    ) -> Result<bool> {
1089        let column_comparator = self.get_column_comparator(keyspace, table, column).await?;
1090        let expected_cql_type = CqlType::parse(expected_type)?;
1091        let expected_comparator = ComparatorType::from_cql_type(&expected_cql_type)?;
1092
1093        // Check if comparators are compatible (same type structure)
1094        Ok(self.comparators_are_compatible(&column_comparator, &expected_comparator))
1095    }
1096
1097    /// Check if two ComparatorTypes are compatible
1098    #[allow(clippy::only_used_in_recursion)]
1099    fn comparators_are_compatible(&self, left: &ComparatorType, right: &ComparatorType) -> bool {
1100        match (left, right) {
1101            // Exact matches
1102            (ComparatorType::Boolean, ComparatorType::Boolean) => true,
1103            (ComparatorType::TinyInt, ComparatorType::TinyInt) => true,
1104            (ComparatorType::SmallInt, ComparatorType::SmallInt) => true,
1105            (ComparatorType::Int, ComparatorType::Int) => true,
1106            (ComparatorType::BigInt, ComparatorType::BigInt) => true,
1107            (ComparatorType::Float32, ComparatorType::Float32) => true,
1108            (ComparatorType::Float, ComparatorType::Float) => true,
1109            (ComparatorType::Text, ComparatorType::Text) => true,
1110            (ComparatorType::Blob, ComparatorType::Blob) => true,
1111            (ComparatorType::Timestamp, ComparatorType::Timestamp) => true,
1112            (ComparatorType::Uuid, ComparatorType::Uuid) => true,
1113            (ComparatorType::Json, ComparatorType::Json) => true,
1114
1115            // Collection types
1116            (ComparatorType::List(l_elem), ComparatorType::List(r_elem)) => {
1117                self.comparators_are_compatible(l_elem, r_elem)
1118            }
1119            (ComparatorType::Set(l_elem), ComparatorType::Set(r_elem)) => {
1120                self.comparators_are_compatible(l_elem, r_elem)
1121            }
1122            (ComparatorType::Map(l_key, l_val), ComparatorType::Map(r_key, r_val)) => {
1123                self.comparators_are_compatible(l_key, r_key)
1124                    && self.comparators_are_compatible(l_val, r_val)
1125            }
1126
1127            // Tuple types
1128            (ComparatorType::Tuple(l_fields), ComparatorType::Tuple(r_fields)) => {
1129                l_fields.len() == r_fields.len()
1130                    && l_fields
1131                        .iter()
1132                        .zip(r_fields.iter())
1133                        .all(|(l, r)| self.comparators_are_compatible(l, r))
1134            }
1135
1136            // UDT types
1137            (
1138                ComparatorType::Udt {
1139                    type_name: l_name,
1140                    keyspace: l_ks,
1141                    ..
1142                },
1143                ComparatorType::Udt {
1144                    type_name: r_name,
1145                    keyspace: r_ks,
1146                    ..
1147                },
1148            ) => l_name == r_name && l_ks == r_ks,
1149
1150            // Frozen types
1151            (ComparatorType::Frozen(l_inner), ComparatorType::Frozen(r_inner)) => {
1152                self.comparators_are_compatible(l_inner, r_inner)
1153            }
1154
1155            // Custom types
1156            (ComparatorType::Custom(l_name), ComparatorType::Custom(r_name)) => l_name == r_name,
1157
1158            // No other combinations are compatible
1159            _ => false,
1160        }
1161    }
1162
1163    /// Get registry statistics
1164    pub async fn get_statistics(&self) -> Result<RegistryStatistics> {
1165        let schemas = self.schemas.read().await;
1166        let udt_registry = self.udt_registry.read().await;
1167        let version_history = self.version_history.read().await;
1168
1169        let mut stats = RegistryStatistics {
1170            total_schemas: schemas.len(),
1171            schemas_by_keyspace: HashMap::new(),
1172            validated_schemas: 0,
1173            schemas_with_warnings: 0,
1174            invalid_schemas: 0,
1175            total_udts: udt_registry.total_udts(),
1176            total_versions: version_history.values().map(|v| v.len()).sum(),
1177            auto_discovered_schemas: 0,
1178            manually_registered_schemas: 0,
1179            cache_hit_rate: 0.0, // TODO: Implement cache metrics
1180        };
1181
1182        // Analyze schema distribution and status
1183        for entry in schemas.values() {
1184            let keyspace = &entry.schema.keyspace;
1185            *stats
1186                .schemas_by_keyspace
1187                .entry(keyspace.clone())
1188                .or_insert(0) += 1;
1189
1190            match entry.validation_status {
1191                SchemaValidationStatus::Valid => stats.validated_schemas += 1,
1192                SchemaValidationStatus::ValidWithWarnings => stats.schemas_with_warnings += 1,
1193                SchemaValidationStatus::Invalid => stats.invalid_schemas += 1,
1194                SchemaValidationStatus::NotValidated => {}
1195            }
1196
1197            match entry.source {
1198                SchemaSource::Discovered(_) => stats.auto_discovered_schemas += 1,
1199                _ => stats.manually_registered_schemas += 1,
1200            }
1201        }
1202
1203        Ok(stats)
1204    }
1205
1206    // Private helper methods
1207
1208    async fn register_discovered_schema(
1209        &self,
1210        schema: TableSchema,
1211        schema_info: Option<SchemaInfo>,
1212        sstable_files: Vec<PathBuf>,
1213    ) -> Result<()> {
1214        let table_id = format!("{}.{}", schema.keyspace, schema.table);
1215        let source = SchemaSource::Discovered(sstable_files.clone());
1216
1217        // Precompute derived comparators once (issue #1709); this is the
1218        // TTL-refresh / auto-discovery seam, so recompute here too.
1219        let derived = DerivedParsingState::compute(&schema).ok();
1220        let entry = SchemaEntry {
1221            schema,
1222            derived,
1223            extended_info: schema_info,
1224            registered_at: SystemTime::now(),
1225            source,
1226            validation_status: SchemaValidationStatus::Valid, // Discovery implies validation
1227            _associated_files: sstable_files,
1228        };
1229
1230        let mut schemas = self.schemas.write().await;
1231        schemas.insert(table_id, entry);
1232
1233        Ok(())
1234    }
1235
1236    fn convert_schema_info_to_table_schema(&self, schema_info: &SchemaInfo) -> Result<TableSchema> {
1237        let mut columns = Vec::new();
1238        let mut partition_keys = Vec::new();
1239        let mut clustering_keys = Vec::new();
1240
1241        // Convert partition keys
1242        for (pos, pk) in schema_info.partition_key.iter().enumerate() {
1243            partition_keys.push(crate::schema::KeyColumn {
1244                name: pk.name.clone(),
1245                data_type: pk.data_type.clone(),
1246                position: pos,
1247            });
1248        }
1249
1250        // Convert clustering keys
1251        for ck in &schema_info.clustering_keys {
1252            clustering_keys.push(ck.clone());
1253        }
1254
1255        // Convert all columns
1256        for col in &schema_info.regular_columns {
1257            columns.push(crate::schema::Column {
1258                name: col.name.clone(),
1259                data_type: col.data_type.clone(),
1260                nullable: col.nullable,
1261                default: None, // ColumnDefinition doesn't have default_value
1262                is_static: false,
1263            });
1264        }
1265
1266        // Add static columns
1267        for col in &schema_info.static_columns {
1268            columns.push(crate::schema::Column {
1269                name: col.name.clone(),
1270                data_type: col.data_type.clone(),
1271                nullable: col.nullable,
1272                default: None, // ColumnDefinition doesn't have default_value
1273                is_static: true,
1274            });
1275        }
1276
1277        Ok(TableSchema {
1278            keyspace: schema_info.keyspace.clone(),
1279            table: schema_info.table.clone(),
1280            partition_keys,
1281            clustering_keys,
1282            columns,
1283            comments: HashMap::new(),
1284            dropped_columns: HashMap::new(),
1285        })
1286    }
1287
1288    fn is_entry_expired(&self, entry: &SchemaEntry) -> bool {
1289        if !self.config.enable_caching {
1290            return false;
1291        }
1292
1293        let ttl = std::time::Duration::from_secs(self.config.cache_ttl_seconds);
1294        entry
1295            .registered_at
1296            .elapsed()
1297            .unwrap_or(std::time::Duration::ZERO)
1298            > ttl
1299    }
1300
1301    async fn refresh_schema(&self, keyspace: &str, table: &str) -> Result<TableSchema> {
1302        // Implementation for refreshing expired schema
1303        // For now, just try auto-discovery
1304        self.auto_discover_schema(keyspace, table).await
1305    }
1306
1307    async fn auto_discover_schema(&self, keyspace: &str, table: &str) -> Result<TableSchema> {
1308        // Try to find SSTable files for this table
1309        // This is a placeholder - in practice, you'd scan the data directory
1310        let sstable_files = self.find_sstable_files(keyspace, table).await?;
1311
1312        if sstable_files.is_empty() {
1313            return Err(Error::Schema(format!(
1314                "No SSTables found for {}.{}",
1315                keyspace, table
1316            )));
1317        }
1318
1319        self.discover_schema(keyspace, table, &sstable_files).await
1320    }
1321
1322    async fn find_sstable_files(&self, _keyspace: &str, _table: &str) -> Result<Vec<PathBuf>> {
1323        // Placeholder implementation
1324        // In practice, this would scan the data directory structure
1325        Ok(Vec::new())
1326    }
1327
1328    fn matches_query(&self, schema: &TableSchema, query: &SchemaQuery) -> bool {
1329        // Apply keyspace filter
1330        if let Some(ref ks) = query.keyspace {
1331            if &schema.keyspace != ks {
1332                return false;
1333            }
1334        }
1335
1336        // Apply table pattern filter
1337        if let Some(ref pattern) = query.table_pattern {
1338            if !self.matches_pattern(&schema.table, pattern) {
1339                return false;
1340            }
1341        }
1342
1343        // Other filters would be applied here
1344        true
1345    }
1346
1347    fn matches_pattern(&self, text: &str, pattern: &str) -> bool {
1348        // Simple wildcard matching (can be enhanced)
1349        if pattern == "*" {
1350            return true;
1351        }
1352
1353        // For now, just exact match or contains
1354        text == pattern || text.contains(pattern)
1355    }
1356
1357    async fn create_schema_version(&self, table_id: &str, new_schema: &TableSchema) -> Result<()> {
1358        let mut version_history = self.version_history.write().await;
1359        let versions = version_history
1360            .entry(table_id.to_string())
1361            .or_insert_with(Vec::new);
1362
1363        let version_number = versions.len() as u32 + 1;
1364        let changes = if versions.is_empty() {
1365            vec![SchemaChange {
1366                change_type: SchemaChangeType::ColumnAdded,
1367                component: "initial".to_string(),
1368                description: "Initial schema version".to_string(),
1369                old_value: None,
1370                new_value: None,
1371            }]
1372        } else {
1373            // Compare with previous version to detect changes
1374            self.detect_schema_changes(&versions.last().unwrap().schema, new_schema)
1375        };
1376
1377        let new_version = SchemaVersion {
1378            version: version_number,
1379            created_at: SystemTime::now(),
1380            schema: new_schema.clone(),
1381            changes,
1382            source: "registry".to_string(),
1383        };
1384
1385        versions.push(new_version);
1386
1387        // Limit version history size
1388        if versions.len() > self.config.max_versions_per_schema {
1389            versions.remove(0);
1390        }
1391
1392        Ok(())
1393    }
1394
1395    fn detect_schema_changes(
1396        &self,
1397        old_schema: &TableSchema,
1398        new_schema: &TableSchema,
1399    ) -> Vec<SchemaChange> {
1400        let mut changes = Vec::new();
1401
1402        // Compare columns
1403        let old_columns: HashMap<_, _> = old_schema.columns.iter().map(|c| (&c.name, c)).collect();
1404        let new_columns: HashMap<_, _> = new_schema.columns.iter().map(|c| (&c.name, c)).collect();
1405
1406        // Find added columns
1407        for (name, column) in &new_columns {
1408            if !old_columns.contains_key(name) {
1409                changes.push(SchemaChange {
1410                    change_type: SchemaChangeType::ColumnAdded,
1411                    component: name.to_string(),
1412                    description: format!(
1413                        "Column '{}' added with type '{}'",
1414                        name, column.data_type
1415                    ),
1416                    old_value: None,
1417                    new_value: Some(column.data_type.clone()),
1418                });
1419            }
1420        }
1421
1422        // Find removed columns
1423        for name in old_columns.keys() {
1424            if !new_columns.contains_key(name) {
1425                changes.push(SchemaChange {
1426                    change_type: SchemaChangeType::ColumnRemoved,
1427                    component: name.to_string(),
1428                    description: format!("Column '{}' removed", name),
1429                    old_value: None,
1430                    new_value: None,
1431                });
1432            }
1433        }
1434
1435        // Find type changes
1436        for (name, new_column) in &new_columns {
1437            if let Some(old_column) = old_columns.get(name) {
1438                if old_column.data_type != new_column.data_type {
1439                    changes.push(SchemaChange {
1440                        change_type: SchemaChangeType::ColumnTypeChanged,
1441                        component: name.to_string(),
1442                        description: format!("Column '{}' type changed", name),
1443                        old_value: Some(old_column.data_type.clone()),
1444                        new_value: Some(new_column.data_type.clone()),
1445                    });
1446                }
1447            }
1448        }
1449
1450        changes
1451    }
1452
1453    async fn validate_schema_udts(
1454        &self,
1455        schema: &TableSchema,
1456        errors: &mut Vec<ValidationError>,
1457        warnings: &mut Vec<ValidationWarning>,
1458    ) {
1459        let udt_registry = self.udt_registry.read().await;
1460
1461        for column in &schema.columns {
1462            // Check if column type references a UDT
1463            if let Ok(cql_type) = CqlType::parse(&column.data_type) {
1464                self.validate_cql_type_udts(
1465                    &cql_type,
1466                    &schema.keyspace,
1467                    &udt_registry,
1468                    errors,
1469                    warnings,
1470                );
1471            }
1472        }
1473    }
1474
1475    #[allow(clippy::only_used_in_recursion)]
1476    fn validate_cql_type_udts(
1477        &self,
1478        cql_type: &CqlType,
1479        keyspace: &str,
1480        udt_registry: &UdtRegistry,
1481        errors: &mut Vec<ValidationError>,
1482        _warnings: &mut Vec<ValidationWarning>,
1483    ) {
1484        match cql_type {
1485            CqlType::Udt(udt_name, _) => {
1486                if !udt_registry.contains_udt(keyspace, udt_name)
1487                    && !udt_registry.contains_udt("system", udt_name)
1488                {
1489                    errors.push(ValidationError {
1490                        code: "UDT_NOT_FOUND".to_string(),
1491                        message: format!("UDT '{}' not found in keyspace '{}'", udt_name, keyspace),
1492                        component: Some(udt_name.clone()),
1493                        severity: ErrorSeverity::High,
1494                    });
1495                }
1496            }
1497            // `CqlType::parse` represents UDT references as `Custom("udt:<name>")`
1498            // for mixed-case/underscore/digit names but as a bare `Custom("<name>")`
1499            // for purely-lowercase names (e.g. `address`). Either way `parse` only
1500            // yields `Custom` for non-primitive, non-collection type strings — i.e.
1501            // UDT references — so validate the de-prefixed name (issue #761,
1502            // roborev job 44).
1503            CqlType::Custom(name) => {
1504                let udt_name = name.strip_prefix("udt:").unwrap_or(name);
1505                let (lookup_keyspace, bare_name) = match udt_name.split_once('.') {
1506                    Some((ks, n)) => (ks, n),
1507                    None => (keyspace, udt_name),
1508                };
1509                // Skip structural fragments parse couldn't resolve (e.g. an
1510                // uppercase `SET<TEXT>`); only simple identifiers name a UDT.
1511                if crate::schema::is_udt_identifier(udt_name)
1512                    && !udt_registry.contains_udt(lookup_keyspace, bare_name)
1513                    && !udt_registry.contains_udt("system", bare_name)
1514                {
1515                    errors.push(ValidationError {
1516                        code: "UDT_NOT_FOUND".to_string(),
1517                        message: format!(
1518                            "UDT '{}' not found in keyspace '{}'",
1519                            udt_name, lookup_keyspace
1520                        ),
1521                        component: Some(udt_name.to_string()),
1522                        severity: ErrorSeverity::High,
1523                    });
1524                }
1525            }
1526            CqlType::List(inner) | CqlType::Set(inner) | CqlType::Frozen(inner) => {
1527                self.validate_cql_type_udts(inner, keyspace, udt_registry, errors, _warnings);
1528            }
1529            CqlType::Map(key_type, value_type) => {
1530                self.validate_cql_type_udts(key_type, keyspace, udt_registry, errors, _warnings);
1531                self.validate_cql_type_udts(value_type, keyspace, udt_registry, errors, _warnings);
1532            }
1533            CqlType::Tuple(types) => {
1534                for t in types {
1535                    self.validate_cql_type_udts(t, keyspace, udt_registry, errors, _warnings);
1536                }
1537            }
1538            _ => {} // Primitive types don't need UDT validation
1539        }
1540    }
1541
1542    async fn validate_column_types(
1543        &self,
1544        schema: &TableSchema,
1545        errors: &mut Vec<ValidationError>,
1546        _warnings: &mut [ValidationWarning],
1547    ) {
1548        for column in &schema.columns {
1549            if let Err(e) = CqlType::parse(&column.data_type) {
1550                errors.push(ValidationError {
1551                    code: "INVALID_COLUMN_TYPE".to_string(),
1552                    message: format!("Invalid column type '{}': {}", column.data_type, e),
1553                    component: Some(column.name.clone()),
1554                    severity: ErrorSeverity::High,
1555                });
1556            }
1557        }
1558    }
1559
1560    async fn generate_performance_recommendations(
1561        &self,
1562        schema: &TableSchema,
1563        recommendations: &mut Vec<String>,
1564    ) {
1565        // Check for potential performance issues
1566
1567        // Large partition keys
1568        if schema.partition_keys.len() > 3 {
1569            recommendations.push(
1570                "Consider reducing the number of partition key columns for better performance"
1571                    .to_string(),
1572            );
1573        }
1574
1575        // Many clustering keys
1576        if schema.clustering_keys.len() > 5 {
1577            recommendations
1578                .push("Large number of clustering keys may impact query performance".to_string());
1579        }
1580
1581        // Column count
1582        if schema.columns.len() > 50 {
1583            recommendations.push(
1584                "Consider using UDTs or denormalizing wide tables for better performance"
1585                    .to_string(),
1586            );
1587        }
1588    }
1589
1590    fn generate_basic_cql(&self, schema: &TableSchema) -> String {
1591        let mut cql = format!("CREATE TABLE {}.{} (\n", schema.keyspace, schema.table);
1592
1593        // Add columns
1594        for (i, column) in schema.columns.iter().enumerate() {
1595            if i > 0 {
1596                cql.push_str(",\n");
1597            }
1598            cql.push_str(&format!("  {} {}", column.name, column.data_type));
1599        }
1600
1601        // Add primary key
1602        if !schema.partition_keys.is_empty() {
1603            cql.push_str(",\n  PRIMARY KEY (");
1604
1605            if schema.partition_keys.len() == 1 && schema.clustering_keys.is_empty() {
1606                cql.push_str(&schema.partition_keys[0].name);
1607            } else {
1608                // Composite primary key
1609                cql.push('(');
1610                for (i, pk) in schema.partition_keys.iter().enumerate() {
1611                    if i > 0 {
1612                        cql.push_str(", ");
1613                    }
1614                    cql.push_str(&pk.name);
1615                }
1616                cql.push(')');
1617
1618                if !schema.clustering_keys.is_empty() {
1619                    for ck in &schema.clustering_keys {
1620                        cql.push_str(", ");
1621                        cql.push_str(&ck.name);
1622                    }
1623                }
1624            }
1625
1626            cql.push(')');
1627        }
1628
1629        cql.push_str("\n);");
1630        cql
1631    }
1632}
1633
1634/// Registry statistics
1635#[derive(Debug, Clone)]
1636pub struct RegistryStatistics {
1637    /// Total number of registered schemas
1638    pub total_schemas: usize,
1639    /// Schemas grouped by keyspace
1640    pub schemas_by_keyspace: HashMap<String, usize>,
1641    /// Number of validated schemas
1642    pub validated_schemas: usize,
1643    /// Schemas with validation warnings
1644    pub schemas_with_warnings: usize,
1645    /// Invalid schemas
1646    pub invalid_schemas: usize,
1647    /// Total UDTs registered
1648    pub total_udts: usize,
1649    /// Total schema versions stored
1650    pub total_versions: usize,
1651    /// Auto-discovered schemas
1652    pub auto_discovered_schemas: usize,
1653    /// Manually registered schemas
1654    pub manually_registered_schemas: usize,
1655    /// Cache hit rate
1656    pub cache_hit_rate: f64,
1657}
1658
1659/// Schema-driven parsing context containing all necessary type information.
1660///
1661/// Fields are `Arc`-shared with the registry's cached [`SchemaEntry`] derived
1662/// state (issue #1709), so constructing a context is a set of pointer bumps
1663/// rather than deep clones + per-column type parsing.
1664#[derive(Debug, Clone)]
1665pub struct ParsingContext {
1666    /// The complete table schema
1667    pub schema: Arc<TableSchema>,
1668    /// Comparators for partition key components
1669    pub partition_comparators: Arc<Vec<ComparatorType>>,
1670    /// Comparators for clustering key components
1671    pub clustering_comparators: Arc<Vec<ComparatorType>>,
1672    /// Comparators for all columns by name
1673    pub column_comparators: Arc<HashMap<String, ComparatorType>>,
1674}
1675
1676impl ParsingContext {
1677    /// Construct a context from owned parts, sharing each behind an `Arc`.
1678    ///
1679    /// The registry hands out contexts from precomputed `Arc`s (issue #1709);
1680    /// this constructor covers callers that derive the parts ad hoc (e.g.
1681    /// `schema_aware_reader`) and keeps the `Arc` wrapping an implementation
1682    /// detail of `ParsingContext`.
1683    pub fn from_owned(
1684        schema: TableSchema,
1685        partition_comparators: Vec<ComparatorType>,
1686        clustering_comparators: Vec<ComparatorType>,
1687        column_comparators: HashMap<String, ComparatorType>,
1688    ) -> Self {
1689        Self {
1690            schema: Arc::new(schema),
1691            partition_comparators: Arc::new(partition_comparators),
1692            clustering_comparators: Arc::new(clustering_comparators),
1693            column_comparators: Arc::new(column_comparators),
1694        }
1695    }
1696
1697    /// Get comparator for a specific column
1698    pub fn get_column_comparator(&self, column_name: &str) -> Option<&ComparatorType> {
1699        self.column_comparators.get(column_name)
1700    }
1701
1702    /// Check if schema-driven parsing is fully configured
1703    pub fn is_complete(&self) -> bool {
1704        !self.partition_comparators.is_empty() || !self.schema.partition_keys.is_empty()
1705    }
1706
1707    /// Get all key columns (partition + clustering) names in order
1708    pub fn get_all_key_column_names(&self) -> Vec<String> {
1709        let mut names = Vec::new();
1710        names.extend(
1711            self.schema
1712                .ordered_partition_keys()
1713                .iter()
1714                .map(|k| k.name.clone()),
1715        );
1716        names.extend(
1717            self.schema
1718                .ordered_clustering_keys()
1719                .iter()
1720                .map(|k| k.name.clone()),
1721        );
1722        names
1723    }
1724}
1725
1726/// Schema validator for comprehensive validation
1727#[derive(Debug)]
1728pub struct SchemaValidator;
1729
1730impl Default for SchemaValidator {
1731    fn default() -> Self {
1732        Self::new()
1733    }
1734}
1735
1736impl SchemaValidator {
1737    pub fn new() -> Self {
1738        Self
1739    }
1740
1741    pub async fn validate_table_schema(&self, schema: &TableSchema) -> Result<()> {
1742        schema.validate()
1743    }
1744}
1745
1746#[cfg(test)]
1747mod tests {
1748    use super::*;
1749    use std::collections::HashMap;
1750
1751    async fn make_registry(mut reg_config: SchemaRegistryConfig) -> SchemaRegistry {
1752        reg_config.enable_auto_discovery = false;
1753        let core_config = Config::default();
1754        let platform = Arc::new(Platform::new(&core_config).await.expect("platform"));
1755        SchemaRegistry::new(reg_config, platform, core_config)
1756            .await
1757            .expect("registry")
1758    }
1759
1760    fn simple_schema(name: &str) -> TableSchema {
1761        TableSchema {
1762            keyspace: "test_ks".to_string(),
1763            table: name.to_string(),
1764            partition_keys: vec![crate::schema::KeyColumn {
1765                name: "id".to_string(),
1766                data_type: "uuid".to_string(),
1767                position: 0,
1768            }],
1769            clustering_keys: vec![],
1770            columns: vec![crate::schema::Column {
1771                name: "id".to_string(),
1772                data_type: "uuid".to_string(),
1773                nullable: false,
1774                default: None,
1775                is_static: false,
1776            }],
1777            comments: HashMap::new(),
1778            dropped_columns: HashMap::new(),
1779        }
1780    }
1781
1782    #[tokio::test]
1783    async fn test_schema_registry_creation() {
1784        let registry = make_registry(SchemaRegistryConfig::default()).await;
1785        let stats = registry.get_statistics().await.unwrap();
1786        assert_eq!(stats.total_schemas, 0);
1787    }
1788
1789    /// Regression (roborev job 44): the registry's `validate_cql_type_udts` must
1790    /// flag an undefined purely-lowercase UDT (bare `Custom("address")`, no
1791    /// `udt:` prefix), matching `TableSchema::check_type_udt_references`.
1792    #[tokio::test]
1793    async fn test_registry_validate_lowercase_udt_not_found() {
1794        let registry = make_registry(SchemaRegistryConfig::default()).await;
1795        let udt_registry = UdtRegistry::new();
1796        let mut errors = Vec::new();
1797        let mut warnings = Vec::new();
1798
1799        let cql_type = CqlType::parse("address").expect("parse");
1800        registry.validate_cql_type_udts(
1801            &cql_type,
1802            "test_ks",
1803            &udt_registry,
1804            &mut errors,
1805            &mut warnings,
1806        );
1807
1808        assert!(
1809            errors
1810                .iter()
1811                .any(|e| e.code == "UDT_NOT_FOUND" && e.message.contains("address")),
1812            "undefined lowercase UDT must be reported, got: {errors:?}"
1813        );
1814    }
1815
1816    /// Regression (roborev job 55): the registry validation path must also catch
1817    /// an undefined UDT nested inside an UPPERCASE collection, mirroring
1818    /// `TableSchema::validate_udt_references`.
1819    #[tokio::test]
1820    async fn test_registry_validate_uppercase_collection_udt_not_found() {
1821        let registry = make_registry(SchemaRegistryConfig::default()).await;
1822        let udt_registry = UdtRegistry::new();
1823
1824        for ty in ["LIST<MissingType>", "MAP<TEXT, MissingType>"] {
1825            let mut errors = Vec::new();
1826            let mut warnings = Vec::new();
1827            let cql_type = CqlType::parse(ty).expect("parse");
1828            registry.validate_cql_type_udts(
1829                &cql_type,
1830                "test_ks",
1831                &udt_registry,
1832                &mut errors,
1833                &mut warnings,
1834            );
1835            assert!(
1836                errors
1837                    .iter()
1838                    .any(|e| e.code == "UDT_NOT_FOUND" && e.message.contains("MissingType")),
1839                "undefined UDT in '{ty}' must be reported, got: {errors:?}"
1840            );
1841        }
1842    }
1843
1844    #[test]
1845    fn test_schema_query_creation() {
1846        let query = SchemaQuery {
1847            keyspace: Some("test_ks".to_string()),
1848            table_pattern: Some("user_*".to_string()),
1849            source_types: None,
1850            validated_only: false,
1851            include_history: false,
1852        };
1853
1854        assert_eq!(query.keyspace.as_ref().unwrap(), "test_ks");
1855        assert_eq!(query.table_pattern.as_ref().unwrap(), "user_*");
1856    }
1857
1858    #[tokio::test]
1859    async fn register_and_retrieve_schema() {
1860        let registry = make_registry(SchemaRegistryConfig::default()).await;
1861        let schema = simple_schema("users");
1862
1863        registry
1864            .register_schema(schema.clone(), SchemaSource::Manual)
1865            .await
1866            .expect("register schema");
1867
1868        let fetched = registry
1869            .get_schema("test_ks", "users")
1870            .await
1871            .expect("fetch schema");
1872        assert_eq!(fetched.table, "users");
1873        assert_eq!(fetched.partition_keys.len(), 1);
1874    }
1875
1876    /// Issue #1710: `register_schema` must not hold the `schemas` write guard
1877    /// across the `create_schema_version(...).await` (that acquires the
1878    /// `version_history` lock — a latent lock-order hazard). Verified by
1879    /// structure (version computation is now hoisted before the `schemas`
1880    /// write in `register_schema`) and behaviorally here: many concurrent
1881    /// re-registrations of the same table (versioning ON, forcing the version
1882    /// path every time) must complete without deadlock and leave exactly one
1883    /// final entry (idempotent, no lost update).
1884    #[tokio::test]
1885    async fn test_concurrent_register_schema_idempotent_no_deadlock() {
1886        let mut cfg = SchemaRegistryConfig::default();
1887        cfg.enable_versioning = true; // force the create_schema_version path
1888        let registry = Arc::new(make_registry(cfg).await);
1889
1890        // Seed once so every concurrent call takes the versioning branch.
1891        registry
1892            .register_schema(simple_schema("users"), SchemaSource::Manual)
1893            .await
1894            .expect("seed register");
1895
1896        let mut handles = vec![];
1897        for _ in 0..16 {
1898            let r = Arc::clone(&registry);
1899            handles.push(tokio::spawn(async move {
1900                r.register_schema(simple_schema("users"), SchemaSource::Manual)
1901                    .await
1902                    .expect("concurrent register");
1903            }));
1904        }
1905        for h in handles {
1906            h.await.expect("task joins (no deadlock)");
1907        }
1908
1909        // Exactly one final entry for the table (idempotent upsert).
1910        let all = registry.list_schemas(None).await.expect("list");
1911        assert_eq!(
1912            all.iter().filter(|s| s.table == "users").count(),
1913            1,
1914            "concurrent re-registration must leave exactly one entry"
1915        );
1916    }
1917
1918    /// Issue #1710 (roborev follow-up): the existence-check and insert in
1919    /// `register_schema` must be a SINGLE write-locked critical section.
1920    /// Here N tasks concurrently register the SAME previously-absent table.
1921    /// With an atomic check/insert, exactly ONE task observes "absent"
1922    /// (creates no version) and the other N-1 observe "present" (each records
1923    /// one version) — so version history holds exactly N-1 entries and there
1924    /// is exactly one final schema entry. The pre-fix check-then-act shape
1925    /// (read-lock check, later write-lock insert) let several tasks observe
1926    /// "absent" at once, dropping version-creation calls (a lost update), so
1927    /// history would hold FEWER than N-1 entries. A barrier releases all tasks
1928    /// simultaneously and we repeat to make that concurrent-absent window real.
1929    #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
1930    async fn test_concurrent_first_registration_no_lost_version() {
1931        const N: usize = 8;
1932        for _ in 0..40 {
1933            let mut cfg = SchemaRegistryConfig::default();
1934            cfg.enable_versioning = true;
1935            // Large cap so create_schema_version never trims history and the
1936            // N-1 assertion measures every recorded version.
1937            cfg.max_versions_per_schema = 10_000;
1938            let registry = Arc::new(make_registry(cfg).await);
1939            let barrier = Arc::new(tokio::sync::Barrier::new(N));
1940
1941            let mut handles = vec![];
1942            for _ in 0..N {
1943                let r = Arc::clone(&registry);
1944                let b = Arc::clone(&barrier);
1945                handles.push(tokio::spawn(async move {
1946                    // Release all registrations at the same instant so the
1947                    // "table absent" window is genuinely contended.
1948                    b.wait().await;
1949                    r.register_schema(simple_schema("users"), SchemaSource::Manual)
1950                        .await
1951                        .expect("concurrent register");
1952                }));
1953            }
1954            for h in handles {
1955                h.await.expect("task joins (no deadlock)");
1956            }
1957
1958            // Exactly one final entry (idempotent upsert, no lost insert).
1959            let all = registry.list_schemas(None).await.expect("list");
1960            assert_eq!(
1961                all.iter().filter(|s| s.table == "users").count(),
1962                1,
1963                "concurrent first registration must leave exactly one entry"
1964            );
1965
1966            // Exactly one task saw "absent" (skips versioning); the other N-1
1967            // each recorded a version. Fewer than N-1 => a lost update.
1968            let history = registry
1969                .get_schema_history("test_ks", "users")
1970                .await
1971                .expect("history");
1972            assert_eq!(
1973                history.len(),
1974                N - 1,
1975                "exactly one first-registration + (N-1) versioned updates: no lost update"
1976            );
1977        }
1978    }
1979
1980    /// A `simple_schema` plus one distinguishing regular column, so different
1981    /// tasks can register genuinely DIFFERENT schemas for the same table.
1982    fn schema_with_marker(table: &str, marker: &str) -> TableSchema {
1983        let mut s = simple_schema(table);
1984        s.columns.push(crate::schema::Column {
1985            name: marker.to_string(),
1986            data_type: "text".to_string(),
1987            nullable: true,
1988            default: None,
1989            is_static: false,
1990        });
1991        s
1992    }
1993
1994    fn column_names(schema: &TableSchema) -> Vec<String> {
1995        schema.columns.iter().map(|c| c.name.clone()).collect()
1996    }
1997
1998    /// Issue #1710 (roborev Medium): registry and version-history must not
1999    /// diverge under concurrent updates of the SAME table with DIFFERENT
2000    /// schemas. Each task registers `users` with a distinct marker column, so
2001    /// its schema is unique. With the per-table update lock, the whole
2002    /// {upsert schema -> append version} sequence runs atomically per table, so
2003    /// the final registry schema ALWAYS equals the newest `version_history`
2004    /// entry. Without the lock, task A's upsert and task B's history-append can
2005    /// interleave (registry=A, newest history=B) — divergence this test
2006    /// detects. A barrier releases all tasks at once and we repeat many
2007    /// iterations to make the interleaving window real; no deadlock either.
2008    #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
2009    async fn test_concurrent_distinct_schemas_registry_history_consistent() {
2010        const N: usize = 16;
2011        for _ in 0..2000 {
2012            let mut cfg = SchemaRegistryConfig::default();
2013            cfg.enable_versioning = true;
2014            cfg.max_versions_per_schema = 10_000; // never trim; keep true newest
2015            let registry = Arc::new(make_registry(cfg).await);
2016
2017            // Seed so every concurrent call takes the versioning (update) path.
2018            registry
2019                .register_schema(schema_with_marker("users", "seed"), SchemaSource::Manual)
2020                .await
2021                .expect("seed register");
2022
2023            let barrier = Arc::new(tokio::sync::Barrier::new(N));
2024            let mut handles = vec![];
2025            for i in 0..N {
2026                let r = Arc::clone(&registry);
2027                let b = Arc::clone(&barrier);
2028                handles.push(tokio::spawn(async move {
2029                    let marker = format!("c{i}");
2030                    b.wait().await;
2031                    r.register_schema(schema_with_marker("users", &marker), SchemaSource::Manual)
2032                        .await
2033                        .expect("concurrent register");
2034                }));
2035            }
2036            for h in handles {
2037                h.await.expect("task joins (no deadlock)");
2038            }
2039
2040            // The registry's current schema must equal the newest recorded
2041            // version — the two cannot have been left describing different
2042            // updates.
2043            let current = registry.get_schema("test_ks", "users").await.expect("get");
2044            let history = registry
2045                .get_schema_history("test_ks", "users")
2046                .await
2047                .expect("history");
2048            let newest = history.last().expect("at least one version recorded");
2049            assert_eq!(
2050                column_names(&current),
2051                column_names(&newest.schema),
2052                "registry schema and newest version_history entry must agree \
2053                 (registry={:?}, newest_version={:?})",
2054                column_names(&current),
2055                column_names(&newest.schema),
2056            );
2057        }
2058    }
2059
2060    #[tokio::test]
2061    async fn schema_version_history_tracks_changes() {
2062        let registry = make_registry(SchemaRegistryConfig::default()).await;
2063        let mut schema = simple_schema("accounts");
2064
2065        registry
2066            .register_schema(schema.clone(), SchemaSource::Manual)
2067            .await
2068            .expect("register v1");
2069
2070        schema.columns.push(crate::schema::Column {
2071            name: "status".to_string(),
2072            data_type: "text".to_string(),
2073            nullable: true,
2074            default: None,
2075            is_static: false,
2076        });
2077
2078        registry
2079            .register_schema(schema.clone(), SchemaSource::Manual)
2080            .await
2081            .expect("register v2");
2082
2083        let history = registry
2084            .get_schema_history("test_ks", "accounts")
2085            .await
2086            .expect("history");
2087
2088        assert_eq!(
2089            history.len(),
2090            1,
2091            "Second registration should emit first version"
2092        );
2093        assert!(history[0]
2094            .changes
2095            .iter()
2096            .any(|change| matches!(change.change_type, SchemaChangeType::ColumnAdded)));
2097    }
2098
2099    /// A schema exercising every derived vector: a 2-component partition key, a
2100    /// 2-component clustering key, and several regular/collection columns.
2101    fn multi_column_schema(name: &str) -> TableSchema {
2102        use crate::schema::{ClusteringColumn, ClusteringOrder, Column, KeyColumn};
2103        TableSchema {
2104            keyspace: "test_ks".to_string(),
2105            table: name.to_string(),
2106            partition_keys: vec![
2107                KeyColumn {
2108                    name: "region".to_string(),
2109                    data_type: "text".to_string(),
2110                    position: 0,
2111                },
2112                KeyColumn {
2113                    name: "shard".to_string(),
2114                    data_type: "int".to_string(),
2115                    position: 1,
2116                },
2117            ],
2118            clustering_keys: vec![
2119                ClusteringColumn {
2120                    name: "ts".to_string(),
2121                    data_type: "timestamp".to_string(),
2122                    position: 0,
2123                    order: ClusteringOrder::Asc,
2124                },
2125                ClusteringColumn {
2126                    name: "seq".to_string(),
2127                    data_type: "bigint".to_string(),
2128                    position: 1,
2129                    order: ClusteringOrder::Desc,
2130                },
2131            ],
2132            columns: vec![
2133                Column {
2134                    name: "region".to_string(),
2135                    data_type: "text".to_string(),
2136                    nullable: false,
2137                    default: None,
2138                    is_static: false,
2139                },
2140                Column {
2141                    name: "shard".to_string(),
2142                    data_type: "int".to_string(),
2143                    nullable: false,
2144                    default: None,
2145                    is_static: false,
2146                },
2147                Column {
2148                    name: "ts".to_string(),
2149                    data_type: "timestamp".to_string(),
2150                    nullable: false,
2151                    default: None,
2152                    is_static: false,
2153                },
2154                Column {
2155                    name: "seq".to_string(),
2156                    data_type: "bigint".to_string(),
2157                    nullable: false,
2158                    default: None,
2159                    is_static: false,
2160                },
2161                Column {
2162                    name: "payload".to_string(),
2163                    data_type: "text".to_string(),
2164                    nullable: true,
2165                    default: None,
2166                    is_static: false,
2167                },
2168                Column {
2169                    name: "tags".to_string(),
2170                    data_type: "list<text>".to_string(),
2171                    nullable: true,
2172                    default: None,
2173                    is_static: false,
2174                },
2175            ],
2176            comments: HashMap::new(),
2177            dropped_columns: HashMap::new(),
2178        }
2179    }
2180
2181    /// Issue #1709 (red on main): after registration, `get_parsing_context` must
2182    /// perform ZERO `CqlType::parse` calls and ZERO `TableSchema` deep clones on
2183    /// the request path. Before caching this was `O(columns)` parses and 4 deep
2184    /// clones per call.
2185    #[tokio::test]
2186    async fn get_parsing_context_does_zero_work_after_registration() {
2187        let registry = make_registry(SchemaRegistryConfig::default()).await;
2188        registry
2189            .register_schema(multi_column_schema("metrics"), SchemaSource::Manual)
2190            .await
2191            .expect("register");
2192
2193        // Measure only the request path.
2194        crate::schema::work_counters::reset();
2195        let _ctx = registry
2196            .get_parsing_context("test_ks", "metrics")
2197            .await
2198            .expect("context");
2199
2200        assert_eq!(
2201            crate::schema::work_counters::parse_calls(),
2202            0,
2203            "get_parsing_context must not parse any CQL types after registration"
2204        );
2205        assert_eq!(
2206            crate::schema::work_counters::schema_clones(),
2207            0,
2208            "get_parsing_context must not deep-clone the schema after registration"
2209        );
2210
2211        // A second call is likewise free.
2212        crate::schema::work_counters::reset();
2213        let _ctx2 = registry
2214            .get_parsing_context("test_ks", "metrics")
2215            .await
2216            .expect("context2");
2217        assert_eq!(crate::schema::work_counters::parse_calls(), 0);
2218        assert_eq!(crate::schema::work_counters::schema_clones(), 0);
2219    }
2220
2221    /// The cached context must be field-by-field IDENTICAL to the pre-cache path
2222    /// (schema + the three comparator collections derived on demand).
2223    #[tokio::test]
2224    async fn cached_parsing_context_matches_on_demand_derivation() {
2225        for schema in [simple_schema("simple"), multi_column_schema("wide")] {
2226            let table = schema.table.clone();
2227            let registry = make_registry(SchemaRegistryConfig::default()).await;
2228            registry
2229                .register_schema(schema, SchemaSource::Manual)
2230                .await
2231                .expect("register");
2232
2233            let ctx = registry
2234                .get_parsing_context("test_ks", &table)
2235                .await
2236                .expect("context");
2237
2238            // Reference: the original per-call derivation helpers.
2239            let ref_schema = registry
2240                .get_schema("test_ks", &table)
2241                .await
2242                .expect("schema");
2243            let ref_partition = registry
2244                .get_partition_key_comparator("test_ks", &table)
2245                .await
2246                .expect("partition");
2247            let ref_clustering = registry
2248                .get_clustering_key_comparator("test_ks", &table)
2249                .await
2250                .expect("clustering");
2251            let ref_columns = registry
2252                .get_table_comparators("test_ks", &table)
2253                .await
2254                .expect("columns");
2255
2256            // TableSchema has no PartialEq; compare structurally via serde_json
2257            // (order-independent for maps), which is a true field-by-field check.
2258            assert_eq!(
2259                serde_json::to_value(&*ctx.schema).expect("ctx schema json"),
2260                serde_json::to_value(&ref_schema).expect("ref schema json"),
2261                "schema mismatch for {}",
2262                table
2263            );
2264            assert_eq!(
2265                *ctx.partition_comparators, ref_partition,
2266                "partition comparators mismatch for {}",
2267                table
2268            );
2269            assert_eq!(
2270                *ctx.clustering_comparators, ref_clustering,
2271                "clustering comparators mismatch for {}",
2272                table
2273            );
2274            assert_eq!(
2275                *ctx.column_comparators, ref_columns,
2276                "column comparators mismatch for {}",
2277                table
2278            );
2279        }
2280    }
2281
2282    /// Two contexts handed out for the same registered schema share the same
2283    /// underlying `Arc` allocations (pointer bump, not deep copy).
2284    #[tokio::test]
2285    async fn parsing_contexts_share_arc_backing() {
2286        let registry = make_registry(SchemaRegistryConfig::default()).await;
2287        registry
2288            .register_schema(multi_column_schema("shared"), SchemaSource::Manual)
2289            .await
2290            .expect("register");
2291
2292        let a = registry
2293            .get_parsing_context("test_ks", "shared")
2294            .await
2295            .expect("a");
2296        let b = registry
2297            .get_parsing_context("test_ks", "shared")
2298            .await
2299            .expect("b");
2300
2301        assert!(Arc::ptr_eq(&a.schema, &b.schema));
2302        assert!(Arc::ptr_eq(
2303            &a.partition_comparators,
2304            &b.partition_comparators
2305        ));
2306        assert!(Arc::ptr_eq(
2307            &a.clustering_comparators,
2308            &b.clustering_comparators
2309        ));
2310        assert!(Arc::ptr_eq(&a.column_comparators, &b.column_comparators));
2311    }
2312
2313    #[tokio::test]
2314    async fn expired_cached_schema_invokes_discovery_path() {
2315        let mut config = SchemaRegistryConfig::default();
2316        config.cache_ttl_seconds = 0;
2317        config.enable_auto_discovery = true;
2318        let registry = make_registry(config).await;
2319
2320        registry
2321            .register_schema(simple_schema("events"), SchemaSource::Manual)
2322            .await
2323            .expect("register events schema");
2324
2325        tokio::time::sleep(std::time::Duration::from_millis(10)).await;
2326
2327        let err = registry
2328            .get_schema("test_ks", "events")
2329            .await
2330            .expect_err("expired schema should attempt discovery");
2331
2332        assert!(matches!(err, Error::Schema(message) if message.contains("No SSTables found")));
2333    }
2334
2335    /// Roborev Medium (issue #1708 follow-up): `find_schema_by_table` — the
2336    /// single lookup the manager delegates to — must apply the SAME
2337    /// TTL-expiry/refresh handling `get_schema` uses for the matched entry, and
2338    /// must NOT serve an expired entry stale. With `cache_ttl_seconds = 0` the
2339    /// registered entry expires, so the lookup delegates to the refresh seam
2340    /// (which errors here because no SSTables back a manually-registered schema)
2341    /// EXACTLY as `get_schema` does. RED on HEAD (read the map directly and
2342    /// returned `Ok(Some(stale))`).
2343    #[tokio::test]
2344    async fn find_schema_by_table_honors_ttl_expiry_like_get_schema() {
2345        let mut config = SchemaRegistryConfig::default();
2346        config.cache_ttl_seconds = 0;
2347        let registry = make_registry(config).await;
2348
2349        registry
2350            .register_schema(simple_schema("events"), SchemaSource::Manual)
2351            .await
2352            .expect("register events schema");
2353
2354        tokio::time::sleep(std::time::Duration::from_millis(10)).await;
2355
2356        // `get_schema` triggers the refresh seam and errors on the expired entry.
2357        let get_err = registry
2358            .get_schema("test_ks", "events")
2359            .await
2360            .expect_err("expired schema attempts refresh");
2361        assert!(matches!(&get_err, Error::Schema(m) if m.contains("No SSTables found")));
2362
2363        // Scoped `keyspace.table` lookup on the EXPIRED entry must behave
2364        // identically: delegate to refresh (error), never `Ok(Some(stale))`.
2365        let scoped = registry
2366            .find_schema_by_table(&Some("test_ks".to_string()), "events")
2367            .await;
2368        assert!(
2369            matches!(&scoped, Err(Error::Schema(m)) if m.contains("No SSTables found")),
2370            "scoped lookup on an EXPIRED entry must delegate to refresh (not serve stale), got {scoped:?}"
2371        );
2372
2373        // The bare-table-name path must honor expiry too.
2374        let bare = registry.find_schema_by_table(&None, "events").await;
2375        assert!(
2376            matches!(&bare, Err(Error::Schema(m)) if m.contains("No SSTables found")),
2377            "bare lookup on an EXPIRED entry must delegate to refresh, got {bare:?}"
2378        );
2379    }
2380
2381    /// A FRESH matched entry is still returned by value, and an unknown table
2382    /// returns `Ok(None)` with NO fabrication/auto-discovery (issue #1710
2383    /// preserved) — the two non-expiry outcomes the refresh path must not alter.
2384    #[tokio::test]
2385    async fn find_schema_by_table_returns_fresh_and_none_for_unknown() {
2386        let registry = make_registry(SchemaRegistryConfig::default()).await;
2387        registry
2388            .register_schema(simple_schema("events"), SchemaSource::Manual)
2389            .await
2390            .expect("register");
2391
2392        let found = registry
2393            .find_schema_by_table(&Some("test_ks".to_string()), "events")
2394            .await
2395            .expect("a fresh entry never triggers the refresh error path");
2396        assert!(
2397            matches!(found, Some(s) if s.table == "events"),
2398            "fresh matched entry must be returned by value"
2399        );
2400
2401        let missing = registry
2402            .find_schema_by_table(&None, "never_registered")
2403            .await
2404            .expect("unknown table is Ok(None), not an error");
2405        assert!(
2406            missing.is_none(),
2407            "unknown table must be None (no fabrication, no auto-discovery)"
2408        );
2409    }
2410}