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/// Name of the hardcoded boolean sub-field appended to the range-bound structs
61/// (`{prefix}range_start` / `{prefix}range_end`).  Unlike the top-level
62/// envelope columns, this sub-field name is NOT namespaced by
63/// [`DeltaSchemaOpts::envelope_prefix`], so a clustering-key column with this
64/// exact name collides regardless of the chosen prefix.
65const RANGE_BOUND_INCLUSIVE_FIELD: &str = "inclusive";
66
67// ============================================================================
68// Error type
69// ============================================================================
70
71/// Errors produced by [`derive_delta_schema`] at schema-derivation time.
72///
73/// All errors are raised **before** any output bytes are written
74/// (fail-before-writing guarantee, design §"Error handling").
75#[derive(Debug, Error)]
76pub enum DeltaSchemaError {
77    /// A user column name collides with one of the envelope reserved names.
78    ///
79    /// The error message names the colliding column, the reserved name it
80    /// conflicts with, and how to supply a different prefix via
81    /// [`DeltaSchemaOpts::envelope_prefix`].
82    #[error(
83        "Column '{column}' collides with envelope reserved name '{reserved}'. \
84         Use DeltaSchemaOpts::envelope_prefix to choose a different prefix \
85         (e.g. envelope_prefix = \"_cqlite_\" gives \"_cqlite_op\", \"_cqlite_ts\", etc.)."
86    )]
87    ColumnCollision {
88        /// The user column name that caused the collision.
89        column: String,
90        /// The reserved envelope name it collides with.
91        reserved: String,
92    },
93
94    /// Counter tables cannot be projected to the delta envelope.
95    ///
96    /// Cassandra counter tables use a fundamentally different on-disk format
97    /// (distributed counters) that cannot be represented as simple cell deltas.
98    /// Reject at schema-derivation time rather than silently producing wrong output.
99    #[error(
100        "Counter tables cannot be projected to the delta envelope. \
101         Table '{keyspace}.{table}' contains counter column(s): {columns}. \
102         Counter semantics (distributed add/subtract) are incompatible with \
103         the per-cell writetime delta model."
104    )]
105    CounterTable {
106        /// Keyspace of the rejected table.
107        keyspace: String,
108        /// Table name of the rejected table.
109        table: String,
110        /// Comma-separated list of counter column names.
111        columns: String,
112    },
113
114    /// CQL type parsing failed during schema derivation.
115    #[error("CQL type parse error for column '{column}': {source}")]
116    CqlTypeParse {
117        /// The column whose type could not be parsed.
118        column: String,
119        /// The underlying error message.
120        #[source]
121        source: crate::error::Error,
122    },
123}
124
125// ============================================================================
126// Options
127// ============================================================================
128
129/// Options for [`derive_delta_schema`].
130///
131/// All fields have sensible defaults via [`Default`].
132#[derive(Debug, Clone)]
133pub struct DeltaSchemaOpts {
134    /// Prefix used for the envelope's reserved column names.
135    ///
136    /// Defaults to `"__"`, yielding `__op`, `__ts`, `__range_start`,
137    /// `__range_end`.  If a user column collides with one of these names,
138    /// change this to a prefix that does not appear in the schema (e.g.
139    /// `"_cqlite_"`).
140    pub envelope_prefix: String,
141}
142
143impl Default for DeltaSchemaOpts {
144    fn default() -> Self {
145        Self {
146            envelope_prefix: "__".to_string(),
147        }
148    }
149}
150
151impl DeltaSchemaOpts {
152    /// Create options with a custom envelope prefix.
153    pub fn with_prefix(prefix: impl Into<String>) -> Self {
154        Self {
155            envelope_prefix: prefix.into(),
156        }
157    }
158
159    /// Return the name of the `__op` envelope column under the configured prefix.
160    pub fn op_col(&self) -> String {
161        format!("{}op", self.envelope_prefix)
162    }
163
164    /// Return the name of the `__ts` envelope column under the configured prefix.
165    pub fn ts_col(&self) -> String {
166        format!("{}ts", self.envelope_prefix)
167    }
168
169    /// Return the name of the `__range_start` envelope column under the configured prefix.
170    pub fn range_start_col(&self) -> String {
171        format!("{}range_start", self.envelope_prefix)
172    }
173
174    /// Return the name of the `__range_end` envelope column under the configured prefix.
175    pub fn range_end_col(&self) -> String {
176        format!("{}range_end", self.envelope_prefix)
177    }
178
179    /// Return all four reserved envelope names for collision checking.
180    fn reserved_names(&self) -> [String; 4] {
181        [
182            self.op_col(),
183            self.ts_col(),
184            self.range_start_col(),
185            self.range_end_col(),
186        ]
187    }
188}
189
190// ============================================================================
191// Public API
192// ============================================================================
193
194/// Derive the Arrow envelope schema for a [`TableSchema`].
195///
196/// Produces the complete Arrow [`Schema`] for the delta-scan Parquet envelope,
197/// including:
198///
199/// 1. **Key columns** — partition key and clustering key columns as plain Arrow
200///    types (using the #673 mapping via [`cql_type_to_arrow_data_type`]).
201/// 2. **Cell columns** — every non-key column becomes a nullable `Struct{
202///    value: <Arrow type>, writetime: i64, expires_at: i64|null }`.  Collection
203///    columns (`List`, `Set`, `Map`) additionally include `replaced: bool`.
204/// 3. **`{prefix}op`** — dictionary-encoded `Utf8` (default `__op`).
205/// 4. **`{prefix}ts`** — nullable `i64` (default `__ts`).
206/// 5. **`{prefix}range_start`** / **`{prefix}range_end`** — nullable
207///    `Struct{ <clustering columns...>, inclusive: bool }`.
208///
209/// # Errors
210///
211/// Returns [`DeltaSchemaError::CounterTable`] if any column has type `counter`.
212///
213/// Returns [`DeltaSchemaError::ColumnCollision`] if any user column name matches
214/// one of the reserved envelope names (see [`DeltaSchemaOpts::envelope_prefix`]).
215///
216/// Returns [`DeltaSchemaError::CqlTypeParse`] if a column's `data_type` string
217/// cannot be parsed into a [`CqlType`].
218pub fn derive_delta_schema(
219    table: &TableSchema,
220    opts: &DeltaSchemaOpts,
221) -> Result<Schema, DeltaSchemaError> {
222    // ------------------------------------------------------------------
223    // 1. Fail-before-writing: reject counter tables
224    // ------------------------------------------------------------------
225    let counter_cols: Vec<String> = table
226        .columns
227        .iter()
228        .filter(|col| {
229            // Parse the data type; on parse failure we'll catch it below.
230            CqlType::parse(&col.data_type)
231                .map(|t| is_counter_type(&t))
232                .unwrap_or(false)
233        })
234        .map(|col| col.name.clone())
235        .collect();
236
237    if !counter_cols.is_empty() {
238        return Err(DeltaSchemaError::CounterTable {
239            keyspace: table.keyspace.clone(),
240            table: table.table.clone(),
241            columns: counter_cols.join(", "),
242        });
243    }
244
245    // ------------------------------------------------------------------
246    // 2. Fail-before-writing: check for column-name collisions
247    //
248    // The collision check must cover ALL user-visible column names, including
249    // partition-key and clustering-key columns.  Key columns are emitted as
250    // plain Arrow fields (steps 3a/3b) just like regular columns, so a key
251    // column named e.g. `__op` would produce two Arrow fields with the same
252    // name — silently malformed output rather than the intended error.
253    // ------------------------------------------------------------------
254    let reserved = opts.reserved_names();
255
256    // Collect all column names: partition keys + clustering keys + regular columns.
257    let all_column_names = table
258        .partition_keys
259        .iter()
260        .map(|k| &k.name)
261        .chain(table.clustering_keys.iter().map(|k| &k.name))
262        .chain(table.columns.iter().map(|c| &c.name));
263
264    for col_name in all_column_names {
265        for res in &reserved {
266            if col_name == res {
267                return Err(DeltaSchemaError::ColumnCollision {
268                    column: col_name.clone(),
269                    reserved: res.clone(),
270                });
271            }
272        }
273    }
274
275    // Additionally reject any CLUSTERING-key column named after the range-bound
276    // struct's hardcoded `inclusive` sub-field.  Clustering columns are emitted
277    // as sub-fields of `{prefix}range_start`/`{prefix}range_end` alongside a
278    // hardcoded `inclusive: bool` (see `build_range_bound_field`), so a
279    // clustering column literally named `inclusive` would produce two
280    // `inclusive` fields in that struct — a malformed Arrow schema that
281    // otherwise fails LATE at `StructArray::try_new` (after the output file is
282    // created).  Fail here, at derivation time, before any bytes are written.
283    //
284    // The `inclusive` sub-field is hardcoded and NOT namespaced by
285    // `envelope_prefix`, so this collision fires regardless of the prefix.
286    // Only clustering keys enter the range-bound struct; partition-key and
287    // regular columns named `inclusive` are harmless and remain allowed.
288    for ck in &table.clustering_keys {
289        if ck.name == RANGE_BOUND_INCLUSIVE_FIELD {
290            return Err(DeltaSchemaError::ColumnCollision {
291                column: ck.name.clone(),
292                reserved: RANGE_BOUND_INCLUSIVE_FIELD.to_string(),
293            });
294        }
295    }
296
297    // ------------------------------------------------------------------
298    // 3. Build Arrow fields
299    // ------------------------------------------------------------------
300    let mut fields: Vec<Field> = Vec::new();
301
302    // 3a. Partition key columns — plain Arrow types, non-nullable.
303    let ordered_pk = table.ordered_partition_keys();
304    for key_col in &ordered_pk {
305        let cql_type =
306            CqlType::parse(&key_col.data_type).map_err(|e| DeltaSchemaError::CqlTypeParse {
307                column: key_col.name.clone(),
308                source: e,
309            })?;
310        let arrow_type = cql_type_to_arrow_data_type(&cql_type);
311        fields.push(Field::new(&key_col.name, arrow_type, false));
312    }
313
314    // 3b. Clustering key columns — plain Arrow types, nullable (null for
315    //     partition-scoped ops like partition_delete / static_upsert).
316    let ordered_ck = table.ordered_clustering_keys();
317    for ck_col in &ordered_ck {
318        let cql_type =
319            CqlType::parse(&ck_col.data_type).map_err(|e| DeltaSchemaError::CqlTypeParse {
320                column: ck_col.name.clone(),
321                source: e,
322            })?;
323        let arrow_type = cql_type_to_arrow_data_type(&cql_type);
324        fields.push(Field::new(&ck_col.name, arrow_type, true));
325    }
326
327    // 3c. Non-key columns — cell structs.
328    //
329    // Key column names for quick membership check.
330    let pk_names: std::collections::HashSet<&str> =
331        ordered_pk.iter().map(|k| k.name.as_str()).collect();
332    let ck_names: std::collections::HashSet<&str> =
333        ordered_ck.iter().map(|k| k.name.as_str()).collect();
334
335    for col in &table.columns {
336        if pk_names.contains(col.name.as_str()) || ck_names.contains(col.name.as_str()) {
337            // Already emitted as a plain key field above.
338            continue;
339        }
340
341        let cql_type =
342            CqlType::parse(&col.data_type).map_err(|e| DeltaSchemaError::CqlTypeParse {
343                column: col.name.clone(),
344                source: e,
345            })?;
346
347        let cell_field = build_cell_struct_field(&col.name, &cql_type);
348        fields.push(cell_field);
349    }
350
351    // 3d. Envelope columns.
352    fields.push(build_op_field(&opts.op_col()));
353    fields.push(Field::new(opts.ts_col(), ArrowDataType::Int64, true));
354    fields.push(build_range_bound_field(
355        &opts.range_start_col(),
356        &ordered_ck,
357    )?);
358    fields.push(build_range_bound_field(&opts.range_end_col(), &ordered_ck)?);
359
360    Ok(Schema::new(fields))
361}
362
363// ============================================================================
364// Internal helpers
365// ============================================================================
366
367/// Returns `true` if the CQL type is `Counter` (including through `Frozen`).
368fn is_counter_type(cql_type: &CqlType) -> bool {
369    match cql_type {
370        CqlType::Counter => true,
371        CqlType::Frozen(inner) => is_counter_type(inner),
372        _ => false,
373    }
374}
375
376/// Returns `true` if the CQL type is a non-frozen collection (`List`, `Set`, `Map`).
377///
378/// Frozen collections do NOT get the `replaced` field — they behave like scalars.
379fn is_collection_type(cql_type: &CqlType) -> bool {
380    match cql_type {
381        CqlType::List(_) | CqlType::Set(_) | CqlType::Map(_, _) => true,
382        // All other types, including Frozen<collection>, are treated as scalars.
383        _ => false,
384    }
385}
386
387/// Build the nullable cell `Struct` field for a non-key column.
388///
389/// ```text
390/// Struct(nullable) {
391///   value:      <Arrow type per #673>,
392///   writetime:  Int64,
393///   expires_at: Int64 (nullable),
394///   replaced:   Boolean  -- collection columns ONLY
395/// }
396/// ```
397///
398/// Reuses [`cql_type_to_arrow_data_type`] (the #673 mapping) for `value`.
399fn build_cell_struct_field(col_name: &str, cql_type: &CqlType) -> Field {
400    let value_arrow_type = cql_type_to_arrow_data_type(cql_type);
401    let is_collection = is_collection_type(cql_type);
402
403    let mut struct_fields = vec![
404        // value: nullable — `None` encodes a cell tombstone.
405        Field::new("value", value_arrow_type, true),
406        // writetime: always present (i64 µs since epoch).
407        Field::new("writetime", ArrowDataType::Int64, false),
408        // expires_at: nullable — `None` means no TTL.
409        Field::new("expires_at", ArrowDataType::Int64, true),
410    ];
411
412    if is_collection {
413        // replaced: present only for non-frozen collection columns (v1 design).
414        struct_fields.push(Field::new("replaced", ArrowDataType::Boolean, false));
415    }
416
417    // The struct itself is nullable: null struct = column not present in this delta.
418    Field::new(
419        col_name,
420        ArrowDataType::Struct(Fields::from(struct_fields)),
421        true, // nullable struct
422    )
423}
424
425/// Build the `__op` field: `Dictionary(Int8, Utf8)`.
426///
427/// Dictionary-encoded so that the five op strings (`upsert`, `static_upsert`,
428/// `row_delete`, `range_delete`, `partition_delete`) are stored once in the
429/// dictionary and referenced by small integer indices.
430fn build_op_field(col_name: &str) -> Field {
431    Field::new(
432        col_name,
433        ArrowDataType::Dictionary(Box::new(ArrowDataType::Int8), Box::new(ArrowDataType::Utf8)),
434        false,
435    )
436}
437
438/// Build a `__range_start` or `__range_end` field.
439///
440/// ```text
441/// Struct(nullable) {
442///   <ck_1>:    <Arrow type of first clustering column>,
443///   <ck_2>:    <Arrow type of second clustering column>,
444///   ...
445///   inclusive: Boolean,
446/// }
447/// ```
448///
449/// The struct is nullable: null means "no range bound" (only non-null on
450/// `range_delete` records).  Clustering-key columns within the struct are
451/// individually nullable to support prefix bounds.
452///
453/// Tables with no clustering key produce an empty-struct with just `inclusive`
454/// (degenerate but well-formed for the writer).
455fn build_range_bound_field(
456    col_name: &str,
457    clustering_keys: &[&crate::schema::ClusteringColumn],
458) -> Result<Field, DeltaSchemaError> {
459    let mut struct_fields: Vec<Field> = Vec::with_capacity(clustering_keys.len() + 1);
460
461    for ck_col in clustering_keys {
462        let cql_type =
463            CqlType::parse(&ck_col.data_type).map_err(|e| DeltaSchemaError::CqlTypeParse {
464                column: ck_col.name.clone(),
465                source: e,
466            })?;
467        let arrow_type = cql_type_to_arrow_data_type(&cql_type);
468        // Nullable: trailing components absent in a prefix bound become null.
469        struct_fields.push(Field::new(&ck_col.name, arrow_type, true));
470    }
471
472    struct_fields.push(Field::new(
473        RANGE_BOUND_INCLUSIVE_FIELD,
474        ArrowDataType::Boolean,
475        false,
476    ));
477
478    Ok(Field::new(
479        col_name,
480        ArrowDataType::Struct(Fields::from(struct_fields)),
481        true, // nullable — null except on range_delete records
482    ))
483}
484
485// ============================================================================
486// Tests
487// ============================================================================
488
489// Unit tests live in a sibling file (extracted per epic #1135 to keep this
490// source file under the campsite-rule threshold).
491#[cfg(test)]
492#[path = "delta_schema_tests.rs"]
493mod tests;