Skip to main content

cqlite_core/export/
delta_schema.rs

1//! Delta-scan Arrow schema derivation (Epic #696, Issue #703).
2//!
3//! Derives the Arrow envelope schema from a [`TableSchema`] for CDC-style
4//! Parquet projections of individual SSTable generations.
5//!
6//! ## Schema layout
7//!
8//! For a table `t (pk int, ck text, val text, st text STATIC, PRIMARY KEY (pk, ck))`:
9//!
10//! ```text
11//! pk          : Int32               -- partition key, plain type
12//! ck          : Utf8                -- clustering key, plain type (null on partition/static ops)
13//! val         : Struct(nullable) {  -- regular column cell struct
14//!                 value:      Utf8,
15//!                 writetime:  Int64,
16//!                 expires_at: Int64 (nullable),
17//!               }
18//! st          : Struct(nullable) {  -- static column cell struct (no `replaced`)
19//!                 value:      Utf8,
20//!                 writetime:  Int64,
21//!                 expires_at: Int64 (nullable),
22//!               }
23//! __op        : Dictionary(Int8, Utf8)   -- op discriminator, dictionary-encoded
24//! __ts        : Int64 (nullable)         -- deletion/liveness timestamp
25//! __range_start : Struct(nullable) {    -- range-delete lower bound
26//!                   ck:         Utf8,
27//!                   inclusive:  Boolean,
28//!                 }
29//! __range_end   : Struct(nullable) {    -- range-delete upper bound
30//!                   ck:         Utf8,
31//!                   inclusive:  Boolean,
32//!                 }
33//! ```
34//!
35//! ## Feature gate
36//!
37//! This module is compiled only when **both** `delta-scan` and `arrow` features
38//! are enabled.  It deliberately reuses [`cql_type_to_arrow_data_type`] from
39//! `export::arrow_convert` (the #673 mapping) for the cell `value` field, so
40//! there is no duplicated CQL → Arrow type logic.
41//!
42//! ## Fail-before-writing rules
43//!
44//! Both error conditions are raised at schema-derivation time, before any
45//! output bytes are produced:
46//!
47//! 1. **Counter tables** — rejected with a descriptive error.
48//! 2. **Column-name collisions** — a user column whose name matches an envelope
49//!    reserved name (e.g. `__op`) causes a hard error.  The caller may provide
50//!    a custom [`DeltaSchemaOpts::envelope_prefix`] (e.g. `"_cqlite_"`) to
51//!    choose a different prefix for all reserved names; the error message names
52//!    the option.
53
54use arrow::datatypes::{DataType as ArrowDataType, Field, Fields, Schema};
55use thiserror::Error;
56
57use crate::export::arrow_convert::cql_type_to_arrow_data_type;
58use crate::schema::{CqlType, TableSchema};
59
60// ============================================================================
61// Error type
62// ============================================================================
63
64/// Errors produced by [`derive_delta_schema`] at schema-derivation time.
65///
66/// All errors are raised **before** any output bytes are written
67/// (fail-before-writing guarantee, design §"Error handling").
68#[derive(Debug, Error)]
69pub enum DeltaSchemaError {
70    /// A user column name collides with one of the envelope reserved names.
71    ///
72    /// The error message names the colliding column, the reserved name it
73    /// conflicts with, and how to supply a different prefix via
74    /// [`DeltaSchemaOpts::envelope_prefix`].
75    #[error(
76        "Column '{column}' collides with envelope reserved name '{reserved}'. \
77         Use DeltaSchemaOpts::envelope_prefix to choose a different prefix \
78         (e.g. envelope_prefix = \"_cqlite_\" gives \"_cqlite_op\", \"_cqlite_ts\", etc.)."
79    )]
80    ColumnCollision {
81        /// The user column name that caused the collision.
82        column: String,
83        /// The reserved envelope name it collides with.
84        reserved: String,
85    },
86
87    /// Counter tables cannot be projected to the delta envelope.
88    ///
89    /// Cassandra counter tables use a fundamentally different on-disk format
90    /// (distributed counters) that cannot be represented as simple cell deltas.
91    /// Reject at schema-derivation time rather than silently producing wrong output.
92    #[error(
93        "Counter tables cannot be projected to the delta envelope. \
94         Table '{keyspace}.{table}' contains counter column(s): {columns}. \
95         Counter semantics (distributed add/subtract) are incompatible with \
96         the per-cell writetime delta model."
97    )]
98    CounterTable {
99        /// Keyspace of the rejected table.
100        keyspace: String,
101        /// Table name of the rejected table.
102        table: String,
103        /// Comma-separated list of counter column names.
104        columns: String,
105    },
106
107    /// CQL type parsing failed during schema derivation.
108    #[error("CQL type parse error for column '{column}': {source}")]
109    CqlTypeParse {
110        /// The column whose type could not be parsed.
111        column: String,
112        /// The underlying error message.
113        #[source]
114        source: crate::error::Error,
115    },
116}
117
118// ============================================================================
119// Options
120// ============================================================================
121
122/// Options for [`derive_delta_schema`].
123///
124/// All fields have sensible defaults via [`Default`].
125#[derive(Debug, Clone)]
126pub struct DeltaSchemaOpts {
127    /// Prefix used for the envelope's reserved column names.
128    ///
129    /// Defaults to `"__"`, yielding `__op`, `__ts`, `__range_start`,
130    /// `__range_end`.  If a user column collides with one of these names,
131    /// change this to a prefix that does not appear in the schema (e.g.
132    /// `"_cqlite_"`).
133    pub envelope_prefix: String,
134}
135
136impl Default for DeltaSchemaOpts {
137    fn default() -> Self {
138        Self {
139            envelope_prefix: "__".to_string(),
140        }
141    }
142}
143
144impl DeltaSchemaOpts {
145    /// Create options with a custom envelope prefix.
146    pub fn with_prefix(prefix: impl Into<String>) -> Self {
147        Self {
148            envelope_prefix: prefix.into(),
149        }
150    }
151
152    /// Return the name of the `__op` envelope column under the configured prefix.
153    pub fn op_col(&self) -> String {
154        format!("{}op", self.envelope_prefix)
155    }
156
157    /// Return the name of the `__ts` envelope column under the configured prefix.
158    pub fn ts_col(&self) -> String {
159        format!("{}ts", self.envelope_prefix)
160    }
161
162    /// Return the name of the `__range_start` envelope column under the configured prefix.
163    pub fn range_start_col(&self) -> String {
164        format!("{}range_start", self.envelope_prefix)
165    }
166
167    /// Return the name of the `__range_end` envelope column under the configured prefix.
168    pub fn range_end_col(&self) -> String {
169        format!("{}range_end", self.envelope_prefix)
170    }
171
172    /// Return all four reserved envelope names for collision checking.
173    fn reserved_names(&self) -> [String; 4] {
174        [
175            self.op_col(),
176            self.ts_col(),
177            self.range_start_col(),
178            self.range_end_col(),
179        ]
180    }
181}
182
183// ============================================================================
184// Public API
185// ============================================================================
186
187/// Derive the Arrow envelope schema for a [`TableSchema`].
188///
189/// Produces the complete Arrow [`Schema`] for the delta-scan Parquet envelope,
190/// including:
191///
192/// 1. **Key columns** — partition key and clustering key columns as plain Arrow
193///    types (using the #673 mapping via [`cql_type_to_arrow_data_type`]).
194/// 2. **Cell columns** — every non-key column becomes a nullable `Struct{
195///    value: <Arrow type>, writetime: i64, expires_at: i64|null }`.  Collection
196///    columns (`List`, `Set`, `Map`) additionally include `replaced: bool`.
197/// 3. **`{prefix}op`** — dictionary-encoded `Utf8` (default `__op`).
198/// 4. **`{prefix}ts`** — nullable `i64` (default `__ts`).
199/// 5. **`{prefix}range_start`** / **`{prefix}range_end`** — nullable
200///    `Struct{ <clustering columns...>, inclusive: bool }`.
201///
202/// # Errors
203///
204/// Returns [`DeltaSchemaError::CounterTable`] if any column has type `counter`.
205///
206/// Returns [`DeltaSchemaError::ColumnCollision`] if any user column name matches
207/// one of the reserved envelope names (see [`DeltaSchemaOpts::envelope_prefix`]).
208///
209/// Returns [`DeltaSchemaError::CqlTypeParse`] if a column's `data_type` string
210/// cannot be parsed into a [`CqlType`].
211pub fn derive_delta_schema(
212    table: &TableSchema,
213    opts: &DeltaSchemaOpts,
214) -> Result<Schema, DeltaSchemaError> {
215    // ------------------------------------------------------------------
216    // 1. Fail-before-writing: reject counter tables
217    // ------------------------------------------------------------------
218    let counter_cols: Vec<String> = table
219        .columns
220        .iter()
221        .filter(|col| {
222            // Parse the data type; on parse failure we'll catch it below.
223            CqlType::parse(&col.data_type)
224                .map(|t| is_counter_type(&t))
225                .unwrap_or(false)
226        })
227        .map(|col| col.name.clone())
228        .collect();
229
230    if !counter_cols.is_empty() {
231        return Err(DeltaSchemaError::CounterTable {
232            keyspace: table.keyspace.clone(),
233            table: table.table.clone(),
234            columns: counter_cols.join(", "),
235        });
236    }
237
238    // ------------------------------------------------------------------
239    // 2. Fail-before-writing: check for column-name collisions
240    //
241    // The collision check must cover ALL user-visible column names, including
242    // partition-key and clustering-key columns.  Key columns are emitted as
243    // plain Arrow fields (steps 3a/3b) just like regular columns, so a key
244    // column named e.g. `__op` would produce two Arrow fields with the same
245    // name — silently malformed output rather than the intended error.
246    // ------------------------------------------------------------------
247    let reserved = opts.reserved_names();
248
249    // Collect all column names: partition keys + clustering keys + regular columns.
250    let all_column_names = table
251        .partition_keys
252        .iter()
253        .map(|k| &k.name)
254        .chain(table.clustering_keys.iter().map(|k| &k.name))
255        .chain(table.columns.iter().map(|c| &c.name));
256
257    for col_name in all_column_names {
258        for res in &reserved {
259            if col_name == res {
260                return Err(DeltaSchemaError::ColumnCollision {
261                    column: col_name.clone(),
262                    reserved: res.clone(),
263                });
264            }
265        }
266    }
267
268    // ------------------------------------------------------------------
269    // 3. Build Arrow fields
270    // ------------------------------------------------------------------
271    let mut fields: Vec<Field> = Vec::new();
272
273    // 3a. Partition key columns — plain Arrow types, non-nullable.
274    let ordered_pk = table.ordered_partition_keys();
275    for key_col in &ordered_pk {
276        let cql_type =
277            CqlType::parse(&key_col.data_type).map_err(|e| DeltaSchemaError::CqlTypeParse {
278                column: key_col.name.clone(),
279                source: e,
280            })?;
281        let arrow_type = cql_type_to_arrow_data_type(&cql_type);
282        fields.push(Field::new(&key_col.name, arrow_type, false));
283    }
284
285    // 3b. Clustering key columns — plain Arrow types, nullable (null for
286    //     partition-scoped ops like partition_delete / static_upsert).
287    let ordered_ck = table.ordered_clustering_keys();
288    for ck_col in &ordered_ck {
289        let cql_type =
290            CqlType::parse(&ck_col.data_type).map_err(|e| DeltaSchemaError::CqlTypeParse {
291                column: ck_col.name.clone(),
292                source: e,
293            })?;
294        let arrow_type = cql_type_to_arrow_data_type(&cql_type);
295        fields.push(Field::new(&ck_col.name, arrow_type, true));
296    }
297
298    // 3c. Non-key columns — cell structs.
299    //
300    // Key column names for quick membership check.
301    let pk_names: std::collections::HashSet<&str> =
302        ordered_pk.iter().map(|k| k.name.as_str()).collect();
303    let ck_names: std::collections::HashSet<&str> =
304        ordered_ck.iter().map(|k| k.name.as_str()).collect();
305
306    for col in &table.columns {
307        if pk_names.contains(col.name.as_str()) || ck_names.contains(col.name.as_str()) {
308            // Already emitted as a plain key field above.
309            continue;
310        }
311
312        let cql_type =
313            CqlType::parse(&col.data_type).map_err(|e| DeltaSchemaError::CqlTypeParse {
314                column: col.name.clone(),
315                source: e,
316            })?;
317
318        let cell_field = build_cell_struct_field(&col.name, &cql_type);
319        fields.push(cell_field);
320    }
321
322    // 3d. Envelope columns.
323    fields.push(build_op_field(&opts.op_col()));
324    fields.push(Field::new(opts.ts_col(), ArrowDataType::Int64, true));
325    fields.push(build_range_bound_field(
326        &opts.range_start_col(),
327        &ordered_ck,
328    )?);
329    fields.push(build_range_bound_field(&opts.range_end_col(), &ordered_ck)?);
330
331    Ok(Schema::new(fields))
332}
333
334// ============================================================================
335// Internal helpers
336// ============================================================================
337
338/// Returns `true` if the CQL type is `Counter` (including through `Frozen`).
339fn is_counter_type(cql_type: &CqlType) -> bool {
340    match cql_type {
341        CqlType::Counter => true,
342        CqlType::Frozen(inner) => is_counter_type(inner),
343        _ => false,
344    }
345}
346
347/// Returns `true` if the CQL type is a non-frozen collection (`List`, `Set`, `Map`).
348///
349/// Frozen collections do NOT get the `replaced` field — they behave like scalars.
350fn is_collection_type(cql_type: &CqlType) -> bool {
351    match cql_type {
352        CqlType::List(_) | CqlType::Set(_) | CqlType::Map(_, _) => true,
353        // All other types, including Frozen<collection>, are treated as scalars.
354        _ => false,
355    }
356}
357
358/// Build the nullable cell `Struct` field for a non-key column.
359///
360/// ```text
361/// Struct(nullable) {
362///   value:      <Arrow type per #673>,
363///   writetime:  Int64,
364///   expires_at: Int64 (nullable),
365///   replaced:   Boolean  -- collection columns ONLY
366/// }
367/// ```
368///
369/// Reuses [`cql_type_to_arrow_data_type`] (the #673 mapping) for `value`.
370fn build_cell_struct_field(col_name: &str, cql_type: &CqlType) -> Field {
371    let value_arrow_type = cql_type_to_arrow_data_type(cql_type);
372    let is_collection = is_collection_type(cql_type);
373
374    let mut struct_fields = vec![
375        // value: nullable — `None` encodes a cell tombstone.
376        Field::new("value", value_arrow_type, true),
377        // writetime: always present (i64 µs since epoch).
378        Field::new("writetime", ArrowDataType::Int64, false),
379        // expires_at: nullable — `None` means no TTL.
380        Field::new("expires_at", ArrowDataType::Int64, true),
381    ];
382
383    if is_collection {
384        // replaced: present only for non-frozen collection columns (v1 design).
385        struct_fields.push(Field::new("replaced", ArrowDataType::Boolean, false));
386    }
387
388    // The struct itself is nullable: null struct = column not present in this delta.
389    Field::new(
390        col_name,
391        ArrowDataType::Struct(Fields::from(struct_fields)),
392        true, // nullable struct
393    )
394}
395
396/// Build the `__op` field: `Dictionary(Int8, Utf8)`.
397///
398/// Dictionary-encoded so that the five op strings (`upsert`, `static_upsert`,
399/// `row_delete`, `range_delete`, `partition_delete`) are stored once in the
400/// dictionary and referenced by small integer indices.
401fn build_op_field(col_name: &str) -> Field {
402    Field::new(
403        col_name,
404        ArrowDataType::Dictionary(Box::new(ArrowDataType::Int8), Box::new(ArrowDataType::Utf8)),
405        false,
406    )
407}
408
409/// Build a `__range_start` or `__range_end` field.
410///
411/// ```text
412/// Struct(nullable) {
413///   <ck_1>:    <Arrow type of first clustering column>,
414///   <ck_2>:    <Arrow type of second clustering column>,
415///   ...
416///   inclusive: Boolean,
417/// }
418/// ```
419///
420/// The struct is nullable: null means "no range bound" (only non-null on
421/// `range_delete` records).  Clustering-key columns within the struct are
422/// individually nullable to support prefix bounds.
423///
424/// Tables with no clustering key produce an empty-struct with just `inclusive`
425/// (degenerate but well-formed for the writer).
426fn build_range_bound_field(
427    col_name: &str,
428    clustering_keys: &[&crate::schema::ClusteringColumn],
429) -> Result<Field, DeltaSchemaError> {
430    let mut struct_fields: Vec<Field> = Vec::with_capacity(clustering_keys.len() + 1);
431
432    for ck_col in clustering_keys {
433        let cql_type =
434            CqlType::parse(&ck_col.data_type).map_err(|e| DeltaSchemaError::CqlTypeParse {
435                column: ck_col.name.clone(),
436                source: e,
437            })?;
438        let arrow_type = cql_type_to_arrow_data_type(&cql_type);
439        // Nullable: trailing components absent in a prefix bound become null.
440        struct_fields.push(Field::new(&ck_col.name, arrow_type, true));
441    }
442
443    struct_fields.push(Field::new("inclusive", ArrowDataType::Boolean, false));
444
445    Ok(Field::new(
446        col_name,
447        ArrowDataType::Struct(Fields::from(struct_fields)),
448        true, // nullable — null except on range_delete records
449    ))
450}
451
452// ============================================================================
453// Tests
454// ============================================================================
455
456#[cfg(test)]
457mod tests {
458    use super::*;
459    use crate::schema::{ClusteringColumn, ClusteringOrder, Column, KeyColumn, TableSchema};
460    use std::collections::HashMap;
461
462    // ------------------------------------------------------------------
463    // Helper: build the design's example table
464    //   t (pk int, ck text, val text, st text STATIC, PRIMARY KEY (pk, ck))
465    // ------------------------------------------------------------------
466    fn example_table() -> TableSchema {
467        TableSchema {
468            keyspace: "example_ks".to_string(),
469            table: "t".to_string(),
470            partition_keys: vec![KeyColumn {
471                name: "pk".to_string(),
472                data_type: "int".to_string(),
473                position: 0,
474            }],
475            clustering_keys: vec![ClusteringColumn {
476                name: "ck".to_string(),
477                data_type: "text".to_string(),
478                position: 0,
479                order: ClusteringOrder::Asc,
480            }],
481            columns: vec![
482                Column {
483                    name: "pk".to_string(),
484                    data_type: "int".to_string(),
485                    nullable: false,
486                    default: None,
487                    is_static: false,
488                },
489                Column {
490                    name: "ck".to_string(),
491                    data_type: "text".to_string(),
492                    nullable: true,
493                    default: None,
494                    is_static: false,
495                },
496                Column {
497                    name: "val".to_string(),
498                    data_type: "text".to_string(),
499                    nullable: true,
500                    default: None,
501                    is_static: false,
502                },
503                Column {
504                    name: "st".to_string(),
505                    data_type: "text".to_string(),
506                    nullable: true,
507                    default: None,
508                    is_static: true,
509                },
510            ],
511            comments: HashMap::new(),
512            dropped_columns: HashMap::new(),
513        }
514    }
515
516    // ------------------------------------------------------------------
517    // Snapshot test: schema for the design's example table
518    // ------------------------------------------------------------------
519
520    #[test]
521    fn snapshot_example_table_schema() {
522        let table = example_table();
523        let opts = DeltaSchemaOpts::default();
524        let schema = derive_delta_schema(&table, &opts).expect("derive_delta_schema failed");
525
526        // --- Verify field count ---
527        // pk, ck, val, st, __op, __ts, __range_start, __range_end = 8
528        assert_eq!(
529            schema.fields().len(),
530            8,
531            "expected 8 fields, got {}",
532            schema.fields().len()
533        );
534
535        // --- 1. pk: Int32, non-nullable ---
536        let pk = schema.field_with_name("pk").expect("no pk field");
537        assert_eq!(*pk.data_type(), ArrowDataType::Int32, "pk should be Int32");
538        assert!(!pk.is_nullable(), "pk should be non-nullable");
539
540        // --- 2. ck: Utf8, nullable (null for partition-scoped ops) ---
541        let ck = schema.field_with_name("ck").expect("no ck field");
542        assert_eq!(*ck.data_type(), ArrowDataType::Utf8, "ck should be Utf8");
543        assert!(ck.is_nullable(), "ck should be nullable");
544
545        // --- 3. val: Struct(nullable) { value: Utf8, writetime: i64, expires_at: i64|null } ---
546        //    val has no `replaced` (it is not a collection)
547        let val = schema.field_with_name("val").expect("no val field");
548        assert!(val.is_nullable(), "val cell struct should be nullable");
549        if let ArrowDataType::Struct(val_fields) = val.data_type() {
550            assert_eq!(
551                val_fields.len(),
552                3,
553                "val struct should have 3 fields (no replaced)"
554            );
555            let vf = val_fields
556                .find("value")
557                .expect("no value field in val struct");
558            assert_eq!(*vf.1.data_type(), ArrowDataType::Utf8);
559            assert!(
560                vf.1.is_nullable(),
561                "value should be nullable (cell tombstone = null)"
562            );
563            let wt = val_fields.find("writetime").expect("no writetime field");
564            assert_eq!(*wt.1.data_type(), ArrowDataType::Int64);
565            assert!(!wt.1.is_nullable(), "writetime should be non-nullable");
566            let ea = val_fields.find("expires_at").expect("no expires_at field");
567            assert_eq!(*ea.1.data_type(), ArrowDataType::Int64);
568            assert!(ea.1.is_nullable(), "expires_at should be nullable");
569        } else {
570            panic!("val should be a Struct, got {:?}", val.data_type());
571        }
572
573        // --- 4. st: same struct shape as val (static but same cell struct) ---
574        let st = schema.field_with_name("st").expect("no st field");
575        assert!(st.is_nullable(), "st cell struct should be nullable");
576        if let ArrowDataType::Struct(st_fields) = st.data_type() {
577            assert_eq!(
578                st_fields.len(),
579                3,
580                "st struct should have 3 fields (no replaced)"
581            );
582        } else {
583            panic!("st should be a Struct");
584        }
585
586        // --- 5. __op: Dictionary(Int8, Utf8), non-nullable ---
587        let op = schema.field_with_name("__op").expect("no __op field");
588        assert!(!op.is_nullable(), "__op should be non-nullable");
589        assert!(
590            matches!(op.data_type(), ArrowDataType::Dictionary(key, val)
591                if **key == ArrowDataType::Int8 && **val == ArrowDataType::Utf8),
592            "__op should be Dictionary(Int8, Utf8), got {:?}",
593            op.data_type()
594        );
595
596        // --- 6. __ts: Int64, nullable ---
597        let ts = schema.field_with_name("__ts").expect("no __ts field");
598        assert_eq!(*ts.data_type(), ArrowDataType::Int64);
599        assert!(ts.is_nullable(), "__ts should be nullable");
600
601        // --- 7. __range_start: Struct(nullable) { ck: Utf8, inclusive: Boolean } ---
602        let rs = schema
603            .field_with_name("__range_start")
604            .expect("no __range_start field");
605        assert!(rs.is_nullable(), "__range_start should be nullable");
606        if let ArrowDataType::Struct(rs_fields) = rs.data_type() {
607            assert_eq!(
608                rs_fields.len(),
609                2,
610                "__range_start struct should have 2 fields (ck + inclusive)"
611            );
612            let ck_f = rs_fields.find("ck").expect("no ck in __range_start");
613            assert_eq!(*ck_f.1.data_type(), ArrowDataType::Utf8);
614            assert!(
615                ck_f.1.is_nullable(),
616                "ck in range bound should be nullable (prefix)"
617            );
618            let inc_f = rs_fields
619                .find("inclusive")
620                .expect("no inclusive in __range_start");
621            assert_eq!(*inc_f.1.data_type(), ArrowDataType::Boolean);
622            assert!(!inc_f.1.is_nullable(), "inclusive should be non-nullable");
623        } else {
624            panic!("__range_start should be a Struct");
625        }
626
627        // --- 8. __range_end: same shape as __range_start ---
628        let re = schema
629            .field_with_name("__range_end")
630            .expect("no __range_end field");
631        assert!(re.is_nullable(), "__range_end should be nullable");
632        if let ArrowDataType::Struct(re_fields) = re.data_type() {
633            assert_eq!(
634                re_fields.len(),
635                2,
636                "__range_end struct should have 2 fields"
637            );
638        } else {
639            panic!("__range_end should be a Struct");
640        }
641    }
642
643    // ------------------------------------------------------------------
644    // Collection column: `replaced` field present ONLY for collections
645    // ------------------------------------------------------------------
646
647    #[test]
648    fn collection_column_has_replaced_field() {
649        let table = TableSchema {
650            keyspace: "ks".to_string(),
651            table: "with_collection".to_string(),
652            partition_keys: vec![KeyColumn {
653                name: "pk".to_string(),
654                data_type: "int".to_string(),
655                position: 0,
656            }],
657            clustering_keys: vec![],
658            columns: vec![
659                Column {
660                    name: "pk".to_string(),
661                    data_type: "int".to_string(),
662                    nullable: false,
663                    default: None,
664                    is_static: false,
665                },
666                Column {
667                    name: "tags".to_string(),
668                    data_type: "set<text>".to_string(),
669                    nullable: true,
670                    default: None,
671                    is_static: false,
672                },
673                Column {
674                    name: "name".to_string(),
675                    data_type: "text".to_string(),
676                    nullable: true,
677                    default: None,
678                    is_static: false,
679                },
680            ],
681            comments: HashMap::new(),
682            dropped_columns: HashMap::new(),
683        };
684
685        let schema =
686            derive_delta_schema(&table, &DeltaSchemaOpts::default()).expect("derive failed");
687
688        // `tags` is a set — should have `replaced`.
689        let tags = schema.field_with_name("tags").expect("no tags field");
690        if let ArrowDataType::Struct(fields) = tags.data_type() {
691            assert_eq!(
692                fields.len(),
693                4,
694                "set column struct should have 4 fields (incl. replaced)"
695            );
696            assert!(
697                fields.find("replaced").is_some(),
698                "set column should have `replaced` field"
699            );
700        } else {
701            panic!("tags should be Struct");
702        }
703
704        // `name` is text — should NOT have `replaced`.
705        let name = schema.field_with_name("name").expect("no name field");
706        if let ArrowDataType::Struct(fields) = name.data_type() {
707            assert_eq!(
708                fields.len(),
709                3,
710                "scalar column struct should have 3 fields (no replaced)"
711            );
712            assert!(
713                fields.find("replaced").is_none(),
714                "scalar column should NOT have `replaced`"
715            );
716        } else {
717            panic!("name should be Struct");
718        }
719    }
720
721    // ------------------------------------------------------------------
722    // frozen<list<text>> is NOT a collection (frozen = scalar)
723    // ------------------------------------------------------------------
724
725    #[test]
726    fn frozen_collection_is_scalar_no_replaced() {
727        let table = TableSchema {
728            keyspace: "ks".to_string(),
729            table: "frozen_test".to_string(),
730            partition_keys: vec![KeyColumn {
731                name: "pk".to_string(),
732                data_type: "int".to_string(),
733                position: 0,
734            }],
735            clustering_keys: vec![],
736            columns: vec![
737                Column {
738                    name: "pk".to_string(),
739                    data_type: "int".to_string(),
740                    nullable: false,
741                    default: None,
742                    is_static: false,
743                },
744                Column {
745                    name: "frozen_list".to_string(),
746                    data_type: "frozen<list<text>>".to_string(),
747                    nullable: true,
748                    default: None,
749                    is_static: false,
750                },
751            ],
752            comments: HashMap::new(),
753            dropped_columns: HashMap::new(),
754        };
755
756        let schema =
757            derive_delta_schema(&table, &DeltaSchemaOpts::default()).expect("derive failed");
758
759        let frozen_col = schema
760            .field_with_name("frozen_list")
761            .expect("no frozen_list field");
762        if let ArrowDataType::Struct(fields) = frozen_col.data_type() {
763            assert_eq!(
764                fields.len(),
765                3,
766                "frozen<list> should be treated as scalar: 3 fields"
767            );
768            assert!(
769                fields.find("replaced").is_none(),
770                "frozen<list> should NOT have `replaced`"
771            );
772        } else {
773            panic!("frozen_list should be Struct");
774        }
775    }
776
777    // ------------------------------------------------------------------
778    // Collision detection: __op column → error
779    // ------------------------------------------------------------------
780
781    #[test]
782    fn column_collision_default_prefix() {
783        let table = TableSchema {
784            keyspace: "ks".to_string(),
785            table: "bad".to_string(),
786            partition_keys: vec![KeyColumn {
787                name: "pk".to_string(),
788                data_type: "int".to_string(),
789                position: 0,
790            }],
791            clustering_keys: vec![],
792            columns: vec![
793                Column {
794                    name: "pk".to_string(),
795                    data_type: "int".to_string(),
796                    nullable: false,
797                    default: None,
798                    is_static: false,
799                },
800                // Legal CQL column name that collides with the envelope.
801                Column {
802                    name: "__op".to_string(),
803                    data_type: "text".to_string(),
804                    nullable: true,
805                    default: None,
806                    is_static: false,
807                },
808            ],
809            comments: HashMap::new(),
810            dropped_columns: HashMap::new(),
811        };
812
813        let err = derive_delta_schema(&table, &DeltaSchemaOpts::default())
814            .expect_err("expected collision error");
815
816        match err {
817            DeltaSchemaError::ColumnCollision { column, reserved } => {
818                assert_eq!(column, "__op");
819                assert_eq!(reserved, "__op");
820            }
821            other => panic!("expected ColumnCollision, got {:?}", other),
822        }
823    }
824
825    #[test]
826    fn column_collision_ts_column() {
827        let table = TableSchema {
828            keyspace: "ks".to_string(),
829            table: "bad_ts".to_string(),
830            partition_keys: vec![KeyColumn {
831                name: "pk".to_string(),
832                data_type: "int".to_string(),
833                position: 0,
834            }],
835            clustering_keys: vec![],
836            columns: vec![
837                Column {
838                    name: "pk".to_string(),
839                    data_type: "int".to_string(),
840                    nullable: false,
841                    default: None,
842                    is_static: false,
843                },
844                Column {
845                    name: "__ts".to_string(),
846                    data_type: "bigint".to_string(),
847                    nullable: true,
848                    default: None,
849                    is_static: false,
850                },
851            ],
852            comments: HashMap::new(),
853            dropped_columns: HashMap::new(),
854        };
855
856        let err = derive_delta_schema(&table, &DeltaSchemaOpts::default())
857            .expect_err("expected collision error");
858
859        assert!(
860            matches!(err, DeltaSchemaError::ColumnCollision { .. }),
861            "expected ColumnCollision"
862        );
863    }
864
865    // ------------------------------------------------------------------
866    // Collision detection: key columns (partition / clustering) named __op
867    // ------------------------------------------------------------------
868
869    /// A partition key column named `__op` must trigger `ColumnCollision`.
870    ///
871    /// Before the fix, the check only iterated `table.columns`, so a key column
872    /// with a reserved name escaped detection and produced a malformed Arrow
873    /// `Schema` with two fields named `__op`.
874    #[test]
875    fn partition_key_collision_default_prefix() {
876        let table = TableSchema {
877            keyspace: "ks".to_string(),
878            table: "pk_collision".to_string(),
879            partition_keys: vec![KeyColumn {
880                // Partition key named after the envelope discriminator.
881                name: "__op".to_string(),
882                data_type: "int".to_string(),
883                position: 0,
884            }],
885            clustering_keys: vec![],
886            // `table.columns` deliberately does NOT contain a column named `__op`
887            // (simulating a schema where keys are not duplicated in the columns
888            // list), so the pre-fix check over `table.columns` alone would have
889            // missed this collision entirely.
890            columns: vec![Column {
891                name: "__op".to_string(),
892                data_type: "int".to_string(),
893                nullable: false,
894                default: None,
895                is_static: false,
896            }],
897            comments: HashMap::new(),
898            dropped_columns: HashMap::new(),
899        };
900
901        let err = derive_delta_schema(&table, &DeltaSchemaOpts::default())
902            .expect_err("expected ColumnCollision for partition key named __op");
903
904        match err {
905            DeltaSchemaError::ColumnCollision { column, reserved } => {
906                assert_eq!(column, "__op");
907                assert_eq!(reserved, "__op");
908            }
909            other => panic!("expected ColumnCollision, got {:?}", other),
910        }
911    }
912
913    /// A clustering key column named `__ts` must trigger `ColumnCollision`.
914    #[test]
915    fn clustering_key_collision_default_prefix() {
916        let table = TableSchema {
917            keyspace: "ks".to_string(),
918            table: "ck_collision".to_string(),
919            partition_keys: vec![KeyColumn {
920                name: "pk".to_string(),
921                data_type: "int".to_string(),
922                position: 0,
923            }],
924            clustering_keys: vec![ClusteringColumn {
925                // Clustering key named after the envelope timestamp column.
926                name: "__ts".to_string(),
927                data_type: "text".to_string(),
928                position: 0,
929                order: ClusteringOrder::Asc,
930            }],
931            columns: vec![
932                Column {
933                    name: "pk".to_string(),
934                    data_type: "int".to_string(),
935                    nullable: false,
936                    default: None,
937                    is_static: false,
938                },
939                Column {
940                    name: "__ts".to_string(),
941                    data_type: "text".to_string(),
942                    nullable: true,
943                    default: None,
944                    is_static: false,
945                },
946            ],
947            comments: HashMap::new(),
948            dropped_columns: HashMap::new(),
949        };
950
951        let err = derive_delta_schema(&table, &DeltaSchemaOpts::default())
952            .expect_err("expected ColumnCollision for clustering key named __ts");
953
954        match err {
955            DeltaSchemaError::ColumnCollision { column, reserved } => {
956                assert_eq!(column, "__ts");
957                assert_eq!(reserved, "__ts");
958            }
959            other => panic!("expected ColumnCollision, got {:?}", other),
960        }
961    }
962
963    // ------------------------------------------------------------------
964    // Configurable envelope_prefix changes the reserved names
965    // ------------------------------------------------------------------
966
967    #[test]
968    fn custom_prefix_avoids_collision() {
969        let table = TableSchema {
970            keyspace: "ks".to_string(),
971            table: "custom_prefix".to_string(),
972            partition_keys: vec![KeyColumn {
973                name: "pk".to_string(),
974                data_type: "int".to_string(),
975                position: 0,
976            }],
977            clustering_keys: vec![],
978            columns: vec![
979                Column {
980                    name: "pk".to_string(),
981                    data_type: "int".to_string(),
982                    nullable: false,
983                    default: None,
984                    is_static: false,
985                },
986                // Column named `__op` — collides with default prefix but not
987                // with the custom `_cqlite_` prefix.
988                Column {
989                    name: "__op".to_string(),
990                    data_type: "text".to_string(),
991                    nullable: true,
992                    default: None,
993                    is_static: false,
994                },
995            ],
996            comments: HashMap::new(),
997            dropped_columns: HashMap::new(),
998        };
999
1000        // Default prefix → collision.
1001        assert!(derive_delta_schema(&table, &DeltaSchemaOpts::default()).is_err());
1002
1003        // Custom prefix → success; envelope col is `_cqlite_op`, not `__op`.
1004        let opts = DeltaSchemaOpts::with_prefix("_cqlite_");
1005        let schema = derive_delta_schema(&table, &opts).expect("should succeed with custom prefix");
1006
1007        // User column `__op` is present as a cell struct.
1008        let op_col = schema.field_with_name("__op").expect("no __op field");
1009        assert!(
1010            matches!(op_col.data_type(), ArrowDataType::Struct(_)),
1011            "__op should be a cell Struct under custom prefix"
1012        );
1013
1014        // Envelope column is `_cqlite_op` (dictionary-encoded Utf8).
1015        let cqlite_op = schema
1016            .field_with_name("_cqlite_op")
1017            .expect("no _cqlite_op field");
1018        assert!(
1019            matches!(cqlite_op.data_type(), ArrowDataType::Dictionary(..)),
1020            "_cqlite_op should be Dictionary-encoded"
1021        );
1022    }
1023
1024    // ------------------------------------------------------------------
1025    // Custom prefix also changes the error message
1026    // ------------------------------------------------------------------
1027
1028    #[test]
1029    fn custom_prefix_collision_error_names_custom_reserved() {
1030        let table = TableSchema {
1031            keyspace: "ks".to_string(),
1032            table: "t".to_string(),
1033            partition_keys: vec![KeyColumn {
1034                name: "pk".to_string(),
1035                data_type: "int".to_string(),
1036                position: 0,
1037            }],
1038            clustering_keys: vec![],
1039            columns: vec![
1040                Column {
1041                    name: "pk".to_string(),
1042                    data_type: "int".to_string(),
1043                    nullable: false,
1044                    default: None,
1045                    is_static: false,
1046                },
1047                // Column named `_cqlite_op` — collides with the custom prefix.
1048                Column {
1049                    name: "_cqlite_op".to_string(),
1050                    data_type: "text".to_string(),
1051                    nullable: true,
1052                    default: None,
1053                    is_static: false,
1054                },
1055            ],
1056            comments: HashMap::new(),
1057            dropped_columns: HashMap::new(),
1058        };
1059
1060        let opts = DeltaSchemaOpts::with_prefix("_cqlite_");
1061        let err = derive_delta_schema(&table, &opts).expect_err("expected collision");
1062        match err {
1063            DeltaSchemaError::ColumnCollision { column, reserved } => {
1064                assert_eq!(column, "_cqlite_op");
1065                assert_eq!(reserved, "_cqlite_op");
1066            }
1067            other => panic!("expected ColumnCollision, got {:?}", other),
1068        }
1069    }
1070
1071    // ------------------------------------------------------------------
1072    // Counter table rejection
1073    // ------------------------------------------------------------------
1074
1075    #[test]
1076    fn counter_table_rejected() {
1077        let table = TableSchema {
1078            keyspace: "ks".to_string(),
1079            table: "counters".to_string(),
1080            partition_keys: vec![KeyColumn {
1081                name: "pk".to_string(),
1082                data_type: "int".to_string(),
1083                position: 0,
1084            }],
1085            clustering_keys: vec![],
1086            columns: vec![
1087                Column {
1088                    name: "pk".to_string(),
1089                    data_type: "int".to_string(),
1090                    nullable: false,
1091                    default: None,
1092                    is_static: false,
1093                },
1094                Column {
1095                    name: "views".to_string(),
1096                    data_type: "counter".to_string(),
1097                    nullable: true,
1098                    default: None,
1099                    is_static: false,
1100                },
1101            ],
1102            comments: HashMap::new(),
1103            dropped_columns: HashMap::new(),
1104        };
1105
1106        let err = derive_delta_schema(&table, &DeltaSchemaOpts::default())
1107            .expect_err("expected counter error");
1108
1109        match &err {
1110            DeltaSchemaError::CounterTable {
1111                keyspace,
1112                table: tbl,
1113                columns,
1114            } => {
1115                assert_eq!(keyspace, "ks");
1116                assert_eq!(tbl, "counters");
1117                assert!(columns.contains("views"), "error should name 'views'");
1118            }
1119            other => panic!("expected CounterTable, got {:?}", other),
1120        }
1121
1122        // Error message should be descriptive.
1123        let msg = err.to_string();
1124        assert!(
1125            msg.contains("counter"),
1126            "message should mention counter: {}",
1127            msg
1128        );
1129        assert!(
1130            msg.contains("ks.counters"),
1131            "message should name the table: {}",
1132            msg
1133        );
1134    }
1135
1136    // ------------------------------------------------------------------
1137    // Value types come from the #673 mapping (no duplicated mapping code)
1138    // ------------------------------------------------------------------
1139
1140    #[test]
1141    fn value_types_from_673_mapping() {
1142        // Verify that the Arrow type assigned to the `value` field of each cell
1143        // struct matches what `cql_type_to_arrow_data_type` returns for the
1144        // same CQL type.  This is the proof that we are reusing the #673 mapping
1145        // rather than forking a second CQL→Arrow mapping.
1146        let types_under_test = vec![
1147            ("bigint", CqlType::BigInt),
1148            ("boolean", CqlType::Boolean),
1149            ("float", CqlType::Float),
1150            ("double", CqlType::Double),
1151            ("uuid", CqlType::Uuid),
1152            ("timeuuid", CqlType::TimeUuid),
1153            ("timestamp", CqlType::Timestamp),
1154            ("date", CqlType::Date),
1155            ("time", CqlType::Time),
1156            ("blob", CqlType::Blob),
1157            ("inet", CqlType::Inet),
1158            ("decimal", CqlType::Decimal),
1159            ("varint", CqlType::Varint),
1160        ];
1161
1162        for (type_str, expected_cql_type) in types_under_test {
1163            let table = TableSchema {
1164                keyspace: "ks".to_string(),
1165                table: "t".to_string(),
1166                partition_keys: vec![KeyColumn {
1167                    name: "pk".to_string(),
1168                    data_type: "int".to_string(),
1169                    position: 0,
1170                }],
1171                clustering_keys: vec![],
1172                columns: vec![
1173                    Column {
1174                        name: "pk".to_string(),
1175                        data_type: "int".to_string(),
1176                        nullable: false,
1177                        default: None,
1178                        is_static: false,
1179                    },
1180                    Column {
1181                        name: "col".to_string(),
1182                        data_type: type_str.to_string(),
1183                        nullable: true,
1184                        default: None,
1185                        is_static: false,
1186                    },
1187                ],
1188                comments: HashMap::new(),
1189                dropped_columns: HashMap::new(),
1190            };
1191
1192            let schema = derive_delta_schema(&table, &DeltaSchemaOpts::default())
1193                .unwrap_or_else(|e| panic!("derive failed for {}: {:?}", type_str, e));
1194
1195            let col_field = schema.field_with_name("col").expect("no col field");
1196            if let ArrowDataType::Struct(struct_fields) = col_field.data_type() {
1197                let value_field = struct_fields
1198                    .find("value")
1199                    .expect("no value field in struct");
1200                let expected_arrow_type = cql_type_to_arrow_data_type(&expected_cql_type);
1201                assert_eq!(
1202                    *value_field.1.data_type(),
1203                    expected_arrow_type,
1204                    "value Arrow type mismatch for CQL type '{}': expected {:?}, got {:?}",
1205                    type_str,
1206                    expected_arrow_type,
1207                    value_field.1.data_type()
1208                );
1209            } else {
1210                panic!("col should be Struct for type {}", type_str);
1211            }
1212        }
1213    }
1214
1215    // ------------------------------------------------------------------
1216    // No-clustering-key table: range bounds have only `inclusive`
1217    // ------------------------------------------------------------------
1218
1219    #[test]
1220    fn no_clustering_keys_range_bound_has_only_inclusive() {
1221        let table = TableSchema {
1222            keyspace: "ks".to_string(),
1223            table: "no_ck".to_string(),
1224            partition_keys: vec![KeyColumn {
1225                name: "pk".to_string(),
1226                data_type: "int".to_string(),
1227                position: 0,
1228            }],
1229            clustering_keys: vec![],
1230            columns: vec![Column {
1231                name: "pk".to_string(),
1232                data_type: "int".to_string(),
1233                nullable: false,
1234                default: None,
1235                is_static: false,
1236            }],
1237            comments: HashMap::new(),
1238            dropped_columns: HashMap::new(),
1239        };
1240
1241        let schema =
1242            derive_delta_schema(&table, &DeltaSchemaOpts::default()).expect("derive failed");
1243
1244        let rs = schema
1245            .field_with_name("__range_start")
1246            .expect("no __range_start");
1247        if let ArrowDataType::Struct(fields) = rs.data_type() {
1248            assert_eq!(
1249                fields.len(),
1250                1,
1251                "no-CK range bound should only have `inclusive`"
1252            );
1253            assert!(fields.find("inclusive").is_some());
1254        } else {
1255            panic!("__range_start should be Struct");
1256        }
1257    }
1258
1259    // ------------------------------------------------------------------
1260    // Multiple clustering keys: range bound has typed fields + inclusive
1261    // ------------------------------------------------------------------
1262
1263    #[test]
1264    fn multi_ck_range_bound_has_all_ck_fields() {
1265        let table = TableSchema {
1266            keyspace: "ks".to_string(),
1267            table: "multi_ck".to_string(),
1268            partition_keys: vec![KeyColumn {
1269                name: "pk".to_string(),
1270                data_type: "int".to_string(),
1271                position: 0,
1272            }],
1273            clustering_keys: vec![
1274                ClusteringColumn {
1275                    name: "year".to_string(),
1276                    data_type: "int".to_string(),
1277                    position: 0,
1278                    order: ClusteringOrder::Asc,
1279                },
1280                ClusteringColumn {
1281                    name: "month".to_string(),
1282                    data_type: "int".to_string(),
1283                    position: 1,
1284                    order: ClusteringOrder::Asc,
1285                },
1286            ],
1287            columns: vec![
1288                Column {
1289                    name: "pk".to_string(),
1290                    data_type: "int".to_string(),
1291                    nullable: false,
1292                    default: None,
1293                    is_static: false,
1294                },
1295                Column {
1296                    name: "year".to_string(),
1297                    data_type: "int".to_string(),
1298                    nullable: true,
1299                    default: None,
1300                    is_static: false,
1301                },
1302                Column {
1303                    name: "month".to_string(),
1304                    data_type: "int".to_string(),
1305                    nullable: true,
1306                    default: None,
1307                    is_static: false,
1308                },
1309            ],
1310            comments: HashMap::new(),
1311            dropped_columns: HashMap::new(),
1312        };
1313
1314        let schema =
1315            derive_delta_schema(&table, &DeltaSchemaOpts::default()).expect("derive failed");
1316
1317        let rs = schema
1318            .field_with_name("__range_start")
1319            .expect("no __range_start");
1320        if let ArrowDataType::Struct(fields) = rs.data_type() {
1321            // year + month + inclusive = 3 fields
1322            assert_eq!(fields.len(), 3);
1323            assert!(fields.find("year").is_some());
1324            assert!(fields.find("month").is_some());
1325            assert!(fields.find("inclusive").is_some());
1326            // Clustering fields are nullable (prefix bounds).
1327            let year_f = fields.find("year").unwrap();
1328            assert!(
1329                year_f.1.is_nullable(),
1330                "year in range bound should be nullable"
1331            );
1332        } else {
1333            panic!("__range_start should be Struct");
1334        }
1335    }
1336
1337    // ------------------------------------------------------------------
1338    // Map column has `replaced` field
1339    // ------------------------------------------------------------------
1340
1341    #[test]
1342    fn map_column_has_replaced_field() {
1343        let table = TableSchema {
1344            keyspace: "ks".to_string(),
1345            table: "with_map".to_string(),
1346            partition_keys: vec![KeyColumn {
1347                name: "pk".to_string(),
1348                data_type: "int".to_string(),
1349                position: 0,
1350            }],
1351            clustering_keys: vec![],
1352            columns: vec![
1353                Column {
1354                    name: "pk".to_string(),
1355                    data_type: "int".to_string(),
1356                    nullable: false,
1357                    default: None,
1358                    is_static: false,
1359                },
1360                Column {
1361                    name: "attrs".to_string(),
1362                    data_type: "map<text, text>".to_string(),
1363                    nullable: true,
1364                    default: None,
1365                    is_static: false,
1366                },
1367            ],
1368            comments: HashMap::new(),
1369            dropped_columns: HashMap::new(),
1370        };
1371
1372        let schema =
1373            derive_delta_schema(&table, &DeltaSchemaOpts::default()).expect("derive failed");
1374        let attrs = schema.field_with_name("attrs").expect("no attrs field");
1375        if let ArrowDataType::Struct(fields) = attrs.data_type() {
1376            assert_eq!(
1377                fields.len(),
1378                4,
1379                "map column should have 4 fields (incl. replaced)"
1380            );
1381            assert!(fields.find("replaced").is_some());
1382        } else {
1383            panic!("attrs should be Struct");
1384        }
1385    }
1386
1387    // ------------------------------------------------------------------
1388    // List column has `replaced` field
1389    // ------------------------------------------------------------------
1390
1391    #[test]
1392    fn list_column_has_replaced_field() {
1393        let table = TableSchema {
1394            keyspace: "ks".to_string(),
1395            table: "with_list".to_string(),
1396            partition_keys: vec![KeyColumn {
1397                name: "pk".to_string(),
1398                data_type: "int".to_string(),
1399                position: 0,
1400            }],
1401            clustering_keys: vec![],
1402            columns: vec![
1403                Column {
1404                    name: "pk".to_string(),
1405                    data_type: "int".to_string(),
1406                    nullable: false,
1407                    default: None,
1408                    is_static: false,
1409                },
1410                Column {
1411                    name: "events".to_string(),
1412                    data_type: "list<bigint>".to_string(),
1413                    nullable: true,
1414                    default: None,
1415                    is_static: false,
1416                },
1417            ],
1418            comments: HashMap::new(),
1419            dropped_columns: HashMap::new(),
1420        };
1421
1422        let schema =
1423            derive_delta_schema(&table, &DeltaSchemaOpts::default()).expect("derive failed");
1424        let events = schema.field_with_name("events").expect("no events field");
1425        if let ArrowDataType::Struct(fields) = events.data_type() {
1426            assert_eq!(
1427                fields.len(),
1428                4,
1429                "list column should have 4 fields (incl. replaced)"
1430            );
1431            assert!(fields.find("replaced").is_some());
1432        } else {
1433            panic!("events should be Struct");
1434        }
1435    }
1436}