Skip to main content

faucet_core/
columnar_transform.rs

1//! Arrow-native (vectorized) forms of the built-in record transforms (#636).
2//!
3//! The `Value` transforms in [`crate::transform`] iterate records as
4//! `serde_json::Value` — a boxed map + boxed string per field per row. On the
5//! columnar fast path (#375) that means a `parquet → select → parquet` chain
6//! disqualifies the fast path and re-materializes every record. This module
7//! supplies the same transforms as **whole-column** operations on an Arrow
8//! [`RecordBatch`], so the chain stays columnar end-to-end.
9//!
10//! Only the transforms that are genuinely a column operation live here —
11//! `select`/`drop` (projection), `rename_field` (schema rename), `set`
12//! (constant column), `redact` (constant overwrite). They touch the record's
13//! *shape*, never parse or re-type a value, so each is byte-identical to
14//! running the `Value` form on the same batch — which is exactly what the
15//! parity tests assert. Value-inspecting transforms (`cast`, `value_case`,
16//! `hash`, …) and the opaque ones (`flatten`, `explode`, …) return `None` and
17//! keep the chain on the `Value` path until they gain their own kernels.
18
19use crate::stage::PageFnBatchBox;
20use crate::transform::RecordTransform;
21
22// Everything below the always-present `batch_form` signature is used only when a
23// vectorizable transform feature is on. Under bare `arrow` (how the rest and
24// bigquery crates pull core) `batch_form` is just `_ => None`, so these imports
25// and helpers would otherwise be unused. Each gate matches its real usage.
26#[cfg(any(
27    feature = "transform-select",
28    feature = "transform-drop",
29    feature = "transform-rename-field",
30    feature = "transform-set",
31    feature = "transform-redact"
32))]
33use crate::FaucetError;
34#[cfg(any(feature = "transform-set", feature = "transform-redact"))]
35use arrow::array::ArrayRef;
36#[cfg(any(
37    feature = "transform-select",
38    feature = "transform-drop",
39    feature = "transform-rename-field",
40    feature = "transform-set",
41    feature = "transform-redact"
42))]
43use arrow::array::RecordBatch;
44#[cfg(any(feature = "transform-set", feature = "transform-redact"))]
45use arrow::datatypes::DataType;
46#[cfg(any(
47    feature = "transform-rename-field",
48    feature = "transform-set",
49    feature = "transform-redact"
50))]
51use arrow::datatypes::Field;
52#[cfg(any(
53    feature = "transform-select",
54    feature = "transform-drop",
55    feature = "transform-rename-field",
56    feature = "transform-set",
57    feature = "transform-redact"
58))]
59use arrow::datatypes::Schema;
60#[cfg(any(
61    feature = "transform-select",
62    feature = "transform-drop",
63    feature = "transform-rename-field",
64    feature = "transform-set",
65    feature = "transform-redact"
66))]
67use std::sync::Arc;
68
69/// The Arrow `RecordBatch → RecordBatch` form of `t`, or `None` when `t` has no
70/// vectorized kernel yet (the chain then stays on the `Value` path).
71pub fn batch_form(t: &RecordTransform) -> Option<PageFnBatchBox> {
72    match t {
73        #[cfg(feature = "transform-select")]
74        RecordTransform::Select { fields } => {
75            let fields = fields.clone();
76            Some(Arc::new(move |b| select(b, &fields)))
77        }
78        #[cfg(feature = "transform-drop")]
79        RecordTransform::Drop { fields } => {
80            let fields = fields.clone();
81            Some(Arc::new(move |b| drop(b, &fields)))
82        }
83        #[cfg(feature = "transform-rename-field")]
84        RecordTransform::RenameField { fields } => {
85            // Sort into a stable Vec, exactly as `compile_stage` does — a
86            // HashMap's order is randomized, and interacting renames
87            // (chains/swaps) must resolve identically to the Value path.
88            let mut fields: Vec<(String, String)> =
89                fields.iter().map(|(f, t)| (f.clone(), t.clone())).collect();
90            fields.sort();
91            Some(Arc::new(move |b| rename_field(b, &fields)))
92        }
93        // `set`/`redact` only vectorize when every constant is an
94        // exactly-representable scalar. A float, a u64 above i64::MAX, or a
95        // container has no simple constant Arrow column that round-trips
96        // byte-identically to the `Value` path (a u64 would go lossy through
97        // f64; an object would become JSON text instead of staying an object),
98        // so those fall back to the `Value` path where they are handled
99        // correctly. Common cases — a status string, a flag, an int, a null —
100        // stay columnar.
101        #[cfg(feature = "transform-set")]
102        RecordTransform::Set { values } if values.values().all(is_columnar_safe_scalar) => {
103            let values = values.clone();
104            Some(Arc::new(move |b| set(b, &values)))
105        }
106        #[cfg(feature = "transform-redact")]
107        RecordTransform::Redact { fields, mask } if is_columnar_safe_scalar(mask) => {
108            let fields = fields.clone();
109            let mask = mask.clone();
110            Some(Arc::new(move |b| redact(b, &fields, &mask)))
111        }
112        _ => None,
113    }
114}
115
116/// Whether `v` is a scalar that [`constant_column`] can represent as an Arrow
117/// column that round-trips byte-identically to the `Value` path: a bool, an
118/// integer that fits `i64`, a string, or null. A float, a `u64` above
119/// `i64::MAX`, or any container is **not** safe (see the gate in
120/// [`batch_form`]).
121#[cfg(any(feature = "transform-set", feature = "transform-redact"))]
122fn is_columnar_safe_scalar(v: &serde_json::Value) -> bool {
123    use serde_json::Value;
124    match v {
125        Value::Bool(_) | Value::Null | Value::String(_) => true,
126        Value::Number(n) => n.is_i64(),
127        _ => false,
128    }
129}
130
131/// Column index of `name` in `schema`, if present.
132#[cfg(any(
133    feature = "transform-select",
134    feature = "transform-drop",
135    feature = "transform-rename-field",
136    feature = "transform-set",
137    feature = "transform-redact"
138))]
139fn index_of(schema: &Schema, name: &str) -> Option<usize> {
140    schema.fields().iter().position(|f| f.name() == name)
141}
142
143/// `select`: keep the named columns in `fields` order, skipping any not in the
144/// batch — matching [`crate::transform`]'s `select_fields`.
145#[cfg(feature = "transform-select")]
146fn select(batch: RecordBatch, fields: &[String]) -> Result<RecordBatch, FaucetError> {
147    let schema = batch.schema();
148    let indices: Vec<usize> = fields.iter().filter_map(|f| index_of(&schema, f)).collect();
149    batch
150        .project(&indices)
151        .map_err(|e| FaucetError::Transform(format!("columnar select: {e}")))
152}
153
154/// `drop`: remove the named columns, keeping the rest in their original order —
155/// matching `drop_fields`.
156#[cfg(feature = "transform-drop")]
157fn drop(batch: RecordBatch, fields: &[String]) -> Result<RecordBatch, FaucetError> {
158    let schema = batch.schema();
159    let remove: std::collections::HashSet<&str> = fields.iter().map(String::as_str).collect();
160    let keep: Vec<usize> = schema
161        .fields()
162        .iter()
163        .enumerate()
164        .filter(|(_, f)| !remove.contains(f.name().as_str()))
165        .map(|(i, _)| i)
166        .collect();
167    batch
168        .project(&keep)
169        .map_err(|e| FaucetError::Transform(format!("columnar drop: {e}")))
170}
171
172/// `rename_field`: rename columns against a snapshot of the original schema, so
173/// chains (`{a:b, b:c}`) and swaps (`{a:b, b:a}`) are order-independent and the
174/// same collision rules as `rename_field` in [`crate::transform`] apply.
175#[cfg(feature = "transform-rename-field")]
176fn rename_field(
177    batch: RecordBatch,
178    fields: &[(String, String)],
179) -> Result<RecordBatch, FaucetError> {
180    let schema = batch.schema();
181    // Only renames whose source column exists and that actually change the name.
182    let renames: Vec<(&str, &str)> = fields
183        .iter()
184        .filter(|(from, to)| from != to && index_of(&schema, from).is_some())
185        .map(|(from, to)| (from.as_str(), to.as_str()))
186        .collect();
187    let sources: std::collections::HashSet<&str> = renames.iter().map(|(f, _)| *f).collect();
188
189    let mut seen_targets: std::collections::HashSet<&str> = std::collections::HashSet::new();
190    for (from, to) in &renames {
191        if !seen_targets.insert(to) {
192            return Err(FaucetError::Transform(format!(
193                "rename_field: two fields rename to the same target key '{to}'"
194            )));
195        }
196        if index_of(&schema, to).is_some() && !sources.contains(to) {
197            return Err(FaucetError::Transform(format!(
198                "rename_field: target key '{to}' already exists on the record \
199                 (renaming from '{from}')"
200            )));
201        }
202    }
203    let map: std::collections::HashMap<&str, &str> = renames.iter().copied().collect();
204    let new_fields: Vec<Arc<Field>> = schema
205        .fields()
206        .iter()
207        .map(|f| {
208            let name = map.get(f.name().as_str()).copied().unwrap_or(f.name());
209            Arc::new(Field::new(name, f.data_type().clone(), f.is_nullable()))
210        })
211        .collect();
212    let new_schema = Arc::new(Schema::new(new_fields));
213    RecordBatch::try_new(new_schema, batch.columns().to_vec())
214        .map_err(|e| FaucetError::Transform(format!("columnar rename_field: {e}")))
215}
216
217/// `set`: add or overwrite each `values` key as a constant column (the same
218/// literal in every row) — matching `set_fields`. A key that already exists is
219/// overwritten in place, preserving column position; a new key is appended.
220#[cfg(feature = "transform-set")]
221fn set(
222    batch: RecordBatch,
223    values: &serde_json::Map<String, serde_json::Value>,
224) -> Result<RecordBatch, FaucetError> {
225    let mut schema_fields: Vec<Arc<Field>> = batch.schema().fields().iter().cloned().collect();
226    let mut columns: Vec<ArrayRef> = batch.columns().to_vec();
227    let rows = batch.num_rows();
228    for (k, v) in values {
229        let (field, array) = constant_column(k, v, rows)?;
230        match index_of(&batch.schema(), k) {
231            Some(i) => {
232                schema_fields[i] = Arc::new(field);
233                columns[i] = array;
234            }
235            None => {
236                schema_fields.push(Arc::new(field));
237                columns.push(array);
238            }
239        }
240    }
241    RecordBatch::try_new(Arc::new(Schema::new(schema_fields)), columns)
242        .map_err(|e| FaucetError::Transform(format!("columnar set: {e}")))
243}
244
245/// `redact`: overwrite each named column with the constant `mask` value, only
246/// for columns present in the batch — matching `redact_fields`.
247#[cfg(feature = "transform-redact")]
248fn redact(
249    batch: RecordBatch,
250    fields: &[String],
251    mask: &serde_json::Value,
252) -> Result<RecordBatch, FaucetError> {
253    let mut schema_fields: Vec<Arc<Field>> = batch.schema().fields().iter().cloned().collect();
254    let mut columns: Vec<ArrayRef> = batch.columns().to_vec();
255    let rows = batch.num_rows();
256    for f in fields {
257        if let Some(i) = index_of(&batch.schema(), f) {
258            let (field, array) = constant_column(f, mask, rows)?;
259            schema_fields[i] = Arc::new(field);
260            columns[i] = array;
261        }
262    }
263    RecordBatch::try_new(Arc::new(Schema::new(schema_fields)), columns)
264        .map_err(|e| FaucetError::Transform(format!("columnar redact: {e}")))
265}
266
267#[cfg(any(feature = "transform-set", feature = "transform-redact"))]
268/// Build a length-`rows` column holding the constant JSON scalar `v`, typed so
269/// that `record_batch_to_values` reproduces `v` exactly. Non-scalar or null
270/// values are rendered as their JSON text in a Utf8 column — the same shape the
271/// `Value` path would round-trip through arrow-json, and never a silent type
272/// change of a neighbouring column.
273fn constant_column(
274    name: &str,
275    v: &serde_json::Value,
276    rows: usize,
277) -> Result<(Field, ArrayRef), FaucetError> {
278    use arrow::array::{BooleanArray, Int64Array, StringArray};
279    use serde_json::Value;
280    // Only the exactly-representable scalar kinds reach here — `batch_form`
281    // gates `set`/`redact` on `is_columnar_safe_scalar`, so a float, a wide
282    // integer, or a container has already fallen back to the `Value` path.
283    let (dt, arr): (DataType, ArrayRef) = match v {
284        Value::Bool(b) => (
285            DataType::Boolean,
286            Arc::new(BooleanArray::from(vec![*b; rows])),
287        ),
288        Value::Number(n) if n.is_i64() => (
289            DataType::Int64,
290            Arc::new(Int64Array::from(vec![n.as_i64().unwrap(); rows])),
291        ),
292        Value::String(s) => (
293            DataType::Utf8,
294            Arc::new(StringArray::from(vec![s.clone(); rows])),
295        ),
296        // Null → an all-null Utf8 column, which round-trips to `key: null`.
297        Value::Null => (
298            DataType::Utf8,
299            Arc::new(StringArray::from(vec![None::<String>; rows])),
300        ),
301        // Unreachable: gated out by `is_columnar_safe_scalar`. Fail loudly
302        // rather than silently corrupt if that gate is ever weakened.
303        other => {
304            return Err(FaucetError::Transform(format!(
305                "columnar set/redact: value {other} is not a columnar-safe scalar                  (should have fallen back to the Value path)"
306            )));
307        }
308    };
309    Ok((Field::new(name, dt, true), arr))
310}
311
312#[cfg(test)]
313mod tests {
314    use super::*;
315    // Only the feature-gated kernel tests below use these; a bare `arrow` build
316    // compiles none of them.
317    #[cfg(any(
318        feature = "transform-select",
319        feature = "transform-drop",
320        feature = "transform-rename-field",
321        feature = "transform-set",
322        feature = "transform-redact"
323    ))]
324    use crate::columnar::{record_batch_to_values, values_to_record_batch_inferred};
325    #[cfg(any(
326        feature = "transform-select",
327        feature = "transform-drop",
328        feature = "transform-rename-field",
329        feature = "transform-set",
330        feature = "transform-redact"
331    ))]
332    use crate::stage::{CompiledStage, TransformStage, apply_stages_to_page, compile_stage};
333    use crate::transform::RecordTransform;
334    #[cfg(any(
335        feature = "transform-select",
336        feature = "transform-drop",
337        feature = "transform-rename-field",
338        feature = "transform-set",
339        feature = "transform-redact"
340    ))]
341    use serde_json::{Value, json};
342
343    /// The whole point of this module: the columnar kernel must produce exactly
344    /// what running the `Value` stage on the same batch produces. Both start
345    /// from `records`, so any divergence is the kernel's.
346    #[cfg(any(
347        feature = "transform-select",
348        feature = "transform-drop",
349        feature = "transform-rename-field",
350        feature = "transform-set",
351        feature = "transform-redact"
352    ))]
353    fn assert_parity(records: Vec<Value>, t: RecordTransform) {
354        let batch = values_to_record_batch_inferred(&records).expect("to batch");
355
356        // Columnar path: kernel over the batch, then materialize.
357        let kernel = batch_form(&t).expect("t has a batch form");
358        let columnar = record_batch_to_values(&kernel(batch.clone()).expect("kernel"))
359            .expect("kernel result to values");
360
361        // Value path: materialize the SAME batch, then run the Value stage.
362        let via_value_input = record_batch_to_values(&batch).expect("batch to values");
363        let compiled: Vec<CompiledStage> =
364            vec![compile_stage(&TransformStage::Map(t)).expect("compile")];
365        let value = apply_stages_to_page(via_value_input, &compiled).expect("value stage");
366
367        assert_eq!(
368            columnar, value,
369            "columnar kernel diverged from the Value stage"
370        );
371    }
372
373    #[cfg(any(
374        feature = "transform-select",
375        feature = "transform-drop",
376        feature = "transform-rename-field",
377        feature = "transform-set",
378        feature = "transform-redact"
379    ))]
380    fn corpus() -> Vec<Value> {
381        vec![
382            json!({ "id": 1, "name": "ada", "email": "a@x.io", "score": 1.5, "ok": true }),
383            json!({ "id": 2, "name": "grace", "email": null, "score": 2.5, "ok": false }),
384        ]
385    }
386
387    #[cfg(feature = "transform-select")]
388    #[test]
389    fn select_matches_the_value_path_and_keeps_field_order() {
390        assert_parity(
391            corpus(),
392            RecordTransform::Select {
393                // Reordered vs the batch, and one column that does not exist.
394                fields: vec!["name".into(), "id".into(), "missing".into()],
395            },
396        );
397        // The projection really is in `fields` order, not schema order.
398        let batch = values_to_record_batch_inferred(&corpus()).unwrap();
399        let kernel = batch_form(&RecordTransform::Select {
400            fields: vec!["name".into(), "id".into()],
401        })
402        .unwrap();
403        let out = kernel(batch).unwrap();
404        let out_schema = out.schema();
405        let names: Vec<&str> = out_schema
406            .fields()
407            .iter()
408            .map(|f| f.name().as_str())
409            .collect();
410        assert_eq!(names, vec!["name", "id"]);
411    }
412
413    #[cfg(feature = "transform-drop")]
414    #[test]
415    fn drop_matches_the_value_path() {
416        assert_parity(
417            corpus(),
418            RecordTransform::Drop {
419                fields: vec!["email".into(), "score".into(), "missing".into()],
420            },
421        );
422    }
423
424    #[cfg(feature = "transform-set")]
425    #[test]
426    fn set_matches_the_value_path_for_new_and_overwritten_keys() {
427        let mut values = serde_json::Map::new();
428        values.insert("stage".into(), json!("prod")); // new column
429        values.insert("ok".into(), json!(false)); // overwrite existing
430        values.insert("n".into(), json!(42)); // new int column
431        assert_parity(corpus(), RecordTransform::Set { values });
432    }
433
434    #[cfg(feature = "transform-redact")]
435    #[test]
436    fn redact_matches_the_value_path() {
437        assert_parity(
438            corpus(),
439            RecordTransform::Redact {
440                fields: vec!["email".into(), "name".into(), "missing".into()],
441                mask: json!("***"),
442            },
443        );
444    }
445
446    #[cfg(feature = "transform-rename-field")]
447    #[test]
448    fn rename_field_matches_the_value_path() {
449        let mut fields = std::collections::HashMap::new();
450        fields.insert("name".to_string(), "full_name".to_string());
451        fields.insert("email".to_string(), "contact".to_string());
452        assert_parity(corpus(), RecordTransform::RenameField { fields });
453    }
454
455    /// A swap (`{a:b, b:a}`) must be order-independent and match the Value
456    /// path's snapshot semantics — the case a naive sequential rename corrupts.
457    #[cfg(feature = "transform-rename-field")]
458    #[test]
459    fn rename_field_swap_matches_the_value_path() {
460        let mut fields = std::collections::HashMap::new();
461        fields.insert("id".to_string(), "name".to_string());
462        fields.insert("name".to_string(), "id".to_string());
463        assert_parity(corpus(), RecordTransform::RenameField { fields });
464    }
465
466    /// Two fields renaming to the *same* target must error, matching the Value
467    /// path — otherwise one silently clobbers the other.
468    #[cfg(feature = "transform-rename-field")]
469    #[test]
470    fn rename_field_two_to_one_target_errors_like_the_value_path() {
471        let batch = values_to_record_batch_inferred(&corpus()).unwrap();
472        let mut fields = std::collections::HashMap::new();
473        fields.insert("id".to_string(), "merged".to_string());
474        fields.insert("name".to_string(), "merged".to_string());
475        let err = batch_form(&RecordTransform::RenameField { fields }).unwrap()(batch)
476            .expect_err("two renames to one target must error");
477        assert!(err.to_string().contains("same target key"), "{err}");
478    }
479
480    /// `set` vectorizes every exactly-representable scalar kind — int, string,
481    /// bool, null — byte-identically to the Value path.
482    #[cfg(feature = "transform-set")]
483    #[test]
484    fn set_covers_every_columnar_safe_scalar_kind() {
485        let mut values = serde_json::Map::new();
486        values.insert("n".into(), json!(7)); // Int64
487        values.insert("label".into(), json!("prod")); // Utf8
488        values.insert("flag".into(), json!(true)); // Boolean
489        values.insert("cleared".into(), Value::Null); // null → nullable Utf8
490        assert_parity(corpus(), RecordTransform::Set { values });
491    }
492
493    /// The correctness gate: a `set` whose value is NOT an exactly-representable
494    /// scalar (a float, a u64 above i64::MAX, or a container) must NOT vectorize
495    /// — it falls back to the Value path, which handles it losslessly. Without
496    /// this gate the columnar kernel silently made u64::MAX a lossy float and a
497    /// nested object a JSON string.
498    #[cfg(feature = "transform-set")]
499    #[test]
500    fn set_with_an_unsafe_constant_falls_back_to_the_value_path() {
501        for v in [
502            json!(1.5),
503            json!(u64::MAX),
504            json!({ "a": [1, 2] }),
505            json!([1, 2]),
506        ] {
507            let mut values = serde_json::Map::new();
508            values.insert("x".into(), v.clone());
509            assert!(
510                batch_form(&RecordTransform::Set { values }).is_none(),
511                "set of {v} must fall back to Value, not vectorize losslessly"
512            );
513        }
514    }
515
516    /// `redact` likewise only vectorizes a safe scalar mask.
517    #[cfg(feature = "transform-redact")]
518    #[test]
519    fn redact_with_an_unsafe_mask_falls_back_to_the_value_path() {
520        assert!(
521            batch_form(&RecordTransform::Redact {
522                fields: vec!["email".into()],
523                mask: json!({ "hidden": true }),
524            })
525            .is_none()
526        );
527    }
528
529    /// A rename onto an occupied, non-renamed key must error identically to the
530    /// Value path rather than silently clobbering a column.
531    #[cfg(feature = "transform-rename-field")]
532    #[test]
533    fn rename_field_collision_errors_like_the_value_path() {
534        let batch = values_to_record_batch_inferred(&corpus()).unwrap();
535        let mut fields = std::collections::HashMap::new();
536        fields.insert("id".to_string(), "name".to_string()); // `name` already exists, not renamed away
537        let err = batch_form(&RecordTransform::RenameField { fields }).unwrap()(batch)
538            .expect_err("collision must error");
539        assert!(err.to_string().contains("already exists"), "{err}");
540    }
541
542    /// Value-inspecting and opaque transforms have no kernel yet, so they must
543    /// return `None` and hold the chain on the `Value` path — a wrong `Some`
544    /// here would silently run an unimplemented columnar transform.
545    #[test]
546    fn non_vectorizable_transforms_have_no_batch_form() {
547        assert!(
548            batch_form(&RecordTransform::Flatten {
549                separator: "_".into()
550            })
551            .is_none()
552        );
553        #[cfg(feature = "transform-cast")]
554        assert!(
555            batch_form(&RecordTransform::Cast {
556                fields: std::collections::HashMap::new(),
557                on_error: Default::default(),
558            })
559            .is_none(),
560            "cast inspects values; it must not claim a kernel until it has one"
561        );
562    }
563}