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;