1use crate::stage::PageFnBatchBox;
20use crate::transform::RecordTransform;
21
22#[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
69pub 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 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 #[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#[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#[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#[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#[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#[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 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#[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#[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"))]
268fn 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 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 Value::Null => (
298 DataType::Utf8,
299 Arc::new(StringArray::from(vec![None::<String>; rows])),
300 ),
301 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 #[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 #[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 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 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 fields: vec!["name".into(), "id".into(), "missing".into()],
395 },
396 );
397 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")); values.insert("ok".into(), json!(false)); values.insert("n".into(), json!(42)); 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 #[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 #[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 #[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)); values.insert("label".into(), json!("prod")); values.insert("flag".into(), json!(true)); values.insert("cleared".into(), Value::Null); assert_parity(corpus(), RecordTransform::Set { values });
491 }
492
493 #[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 #[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 #[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()); 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 #[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}