use crate::stage::PageFnBatchBox;
use crate::transform::RecordTransform;
#[cfg(any(
feature = "transform-select",
feature = "transform-drop",
feature = "transform-rename-field",
feature = "transform-set",
feature = "transform-redact"
))]
use crate::FaucetError;
#[cfg(any(feature = "transform-set", feature = "transform-redact"))]
use arrow::array::ArrayRef;
#[cfg(any(
feature = "transform-select",
feature = "transform-drop",
feature = "transform-rename-field",
feature = "transform-set",
feature = "transform-redact"
))]
use arrow::array::RecordBatch;
#[cfg(any(feature = "transform-set", feature = "transform-redact"))]
use arrow::datatypes::DataType;
#[cfg(any(
feature = "transform-rename-field",
feature = "transform-set",
feature = "transform-redact"
))]
use arrow::datatypes::Field;
#[cfg(any(
feature = "transform-select",
feature = "transform-drop",
feature = "transform-rename-field",
feature = "transform-set",
feature = "transform-redact"
))]
use arrow::datatypes::Schema;
#[cfg(any(
feature = "transform-select",
feature = "transform-drop",
feature = "transform-rename-field",
feature = "transform-set",
feature = "transform-redact"
))]
use std::sync::Arc;
pub fn batch_form(t: &RecordTransform) -> Option<PageFnBatchBox> {
match t {
#[cfg(feature = "transform-select")]
RecordTransform::Select { fields } => {
let fields = fields.clone();
Some(Arc::new(move |b| select(b, &fields)))
}
#[cfg(feature = "transform-drop")]
RecordTransform::Drop { fields } => {
let fields = fields.clone();
Some(Arc::new(move |b| drop(b, &fields)))
}
#[cfg(feature = "transform-rename-field")]
RecordTransform::RenameField { fields } => {
let mut fields: Vec<(String, String)> =
fields.iter().map(|(f, t)| (f.clone(), t.clone())).collect();
fields.sort();
Some(Arc::new(move |b| rename_field(b, &fields)))
}
#[cfg(feature = "transform-set")]
RecordTransform::Set { values } if values.values().all(is_columnar_safe_scalar) => {
let values = values.clone();
Some(Arc::new(move |b| set(b, &values)))
}
#[cfg(feature = "transform-redact")]
RecordTransform::Redact { fields, mask } if is_columnar_safe_scalar(mask) => {
let fields = fields.clone();
let mask = mask.clone();
Some(Arc::new(move |b| redact(b, &fields, &mask)))
}
_ => None,
}
}
#[cfg(any(feature = "transform-set", feature = "transform-redact"))]
fn is_columnar_safe_scalar(v: &serde_json::Value) -> bool {
use serde_json::Value;
match v {
Value::Bool(_) | Value::Null | Value::String(_) => true,
Value::Number(n) => n.is_i64(),
_ => false,
}
}
#[cfg(any(
feature = "transform-select",
feature = "transform-drop",
feature = "transform-rename-field",
feature = "transform-set",
feature = "transform-redact"
))]
fn index_of(schema: &Schema, name: &str) -> Option<usize> {
schema.fields().iter().position(|f| f.name() == name)
}
#[cfg(feature = "transform-select")]
fn select(batch: RecordBatch, fields: &[String]) -> Result<RecordBatch, FaucetError> {
let schema = batch.schema();
let indices: Vec<usize> = fields.iter().filter_map(|f| index_of(&schema, f)).collect();
batch
.project(&indices)
.map_err(|e| FaucetError::Transform(format!("columnar select: {e}")))
}
#[cfg(feature = "transform-drop")]
fn drop(batch: RecordBatch, fields: &[String]) -> Result<RecordBatch, FaucetError> {
let schema = batch.schema();
let remove: std::collections::HashSet<&str> = fields.iter().map(String::as_str).collect();
let keep: Vec<usize> = schema
.fields()
.iter()
.enumerate()
.filter(|(_, f)| !remove.contains(f.name().as_str()))
.map(|(i, _)| i)
.collect();
batch
.project(&keep)
.map_err(|e| FaucetError::Transform(format!("columnar drop: {e}")))
}
#[cfg(feature = "transform-rename-field")]
fn rename_field(
batch: RecordBatch,
fields: &[(String, String)],
) -> Result<RecordBatch, FaucetError> {
let schema = batch.schema();
let renames: Vec<(&str, &str)> = fields
.iter()
.filter(|(from, to)| from != to && index_of(&schema, from).is_some())
.map(|(from, to)| (from.as_str(), to.as_str()))
.collect();
let sources: std::collections::HashSet<&str> = renames.iter().map(|(f, _)| *f).collect();
let mut seen_targets: std::collections::HashSet<&str> = std::collections::HashSet::new();
for (from, to) in &renames {
if !seen_targets.insert(to) {
return Err(FaucetError::Transform(format!(
"rename_field: two fields rename to the same target key '{to}'"
)));
}
if index_of(&schema, to).is_some() && !sources.contains(to) {
return Err(FaucetError::Transform(format!(
"rename_field: target key '{to}' already exists on the record \
(renaming from '{from}')"
)));
}
}
let map: std::collections::HashMap<&str, &str> = renames.iter().copied().collect();
let new_fields: Vec<Arc<Field>> = schema
.fields()
.iter()
.map(|f| {
let name = map.get(f.name().as_str()).copied().unwrap_or(f.name());
Arc::new(Field::new(name, f.data_type().clone(), f.is_nullable()))
})
.collect();
let new_schema = Arc::new(Schema::new(new_fields));
RecordBatch::try_new(new_schema, batch.columns().to_vec())
.map_err(|e| FaucetError::Transform(format!("columnar rename_field: {e}")))
}
#[cfg(feature = "transform-set")]
fn set(
batch: RecordBatch,
values: &serde_json::Map<String, serde_json::Value>,
) -> Result<RecordBatch, FaucetError> {
let mut schema_fields: Vec<Arc<Field>> = batch.schema().fields().iter().cloned().collect();
let mut columns: Vec<ArrayRef> = batch.columns().to_vec();
let rows = batch.num_rows();
for (k, v) in values {
let (field, array) = constant_column(k, v, rows)?;
match index_of(&batch.schema(), k) {
Some(i) => {
schema_fields[i] = Arc::new(field);
columns[i] = array;
}
None => {
schema_fields.push(Arc::new(field));
columns.push(array);
}
}
}
RecordBatch::try_new(Arc::new(Schema::new(schema_fields)), columns)
.map_err(|e| FaucetError::Transform(format!("columnar set: {e}")))
}
#[cfg(feature = "transform-redact")]
fn redact(
batch: RecordBatch,
fields: &[String],
mask: &serde_json::Value,
) -> Result<RecordBatch, FaucetError> {
let mut schema_fields: Vec<Arc<Field>> = batch.schema().fields().iter().cloned().collect();
let mut columns: Vec<ArrayRef> = batch.columns().to_vec();
let rows = batch.num_rows();
for f in fields {
if let Some(i) = index_of(&batch.schema(), f) {
let (field, array) = constant_column(f, mask, rows)?;
schema_fields[i] = Arc::new(field);
columns[i] = array;
}
}
RecordBatch::try_new(Arc::new(Schema::new(schema_fields)), columns)
.map_err(|e| FaucetError::Transform(format!("columnar redact: {e}")))
}
#[cfg(any(feature = "transform-set", feature = "transform-redact"))]
fn constant_column(
name: &str,
v: &serde_json::Value,
rows: usize,
) -> Result<(Field, ArrayRef), FaucetError> {
use arrow::array::{BooleanArray, Int64Array, StringArray};
use serde_json::Value;
let (dt, arr): (DataType, ArrayRef) = match v {
Value::Bool(b) => (
DataType::Boolean,
Arc::new(BooleanArray::from(vec![*b; rows])),
),
Value::Number(n) if n.is_i64() => (
DataType::Int64,
Arc::new(Int64Array::from(vec![n.as_i64().unwrap(); rows])),
),
Value::String(s) => (
DataType::Utf8,
Arc::new(StringArray::from(vec![s.clone(); rows])),
),
Value::Null => (
DataType::Utf8,
Arc::new(StringArray::from(vec![None::<String>; rows])),
),
other => {
return Err(FaucetError::Transform(format!(
"columnar set/redact: value {other} is not a columnar-safe scalar (should have fallen back to the Value path)"
)));
}
};
Ok((Field::new(name, dt, true), arr))
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(any(
feature = "transform-select",
feature = "transform-drop",
feature = "transform-rename-field",
feature = "transform-set",
feature = "transform-redact"
))]
use crate::columnar::{record_batch_to_values, values_to_record_batch_inferred};
#[cfg(any(
feature = "transform-select",
feature = "transform-drop",
feature = "transform-rename-field",
feature = "transform-set",
feature = "transform-redact"
))]
use crate::stage::{CompiledStage, TransformStage, apply_stages_to_page, compile_stage};
use crate::transform::RecordTransform;
#[cfg(any(
feature = "transform-select",
feature = "transform-drop",
feature = "transform-rename-field",
feature = "transform-set",
feature = "transform-redact"
))]
use serde_json::{Value, json};
#[cfg(any(
feature = "transform-select",
feature = "transform-drop",
feature = "transform-rename-field",
feature = "transform-set",
feature = "transform-redact"
))]
fn assert_parity(records: Vec<Value>, t: RecordTransform) {
let batch = values_to_record_batch_inferred(&records).expect("to batch");
let kernel = batch_form(&t).expect("t has a batch form");
let columnar = record_batch_to_values(&kernel(batch.clone()).expect("kernel"))
.expect("kernel result to values");
let via_value_input = record_batch_to_values(&batch).expect("batch to values");
let compiled: Vec<CompiledStage> =
vec![compile_stage(&TransformStage::Map(t)).expect("compile")];
let value = apply_stages_to_page(via_value_input, &compiled).expect("value stage");
assert_eq!(
columnar, value,
"columnar kernel diverged from the Value stage"
);
}
#[cfg(any(
feature = "transform-select",
feature = "transform-drop",
feature = "transform-rename-field",
feature = "transform-set",
feature = "transform-redact"
))]
fn corpus() -> Vec<Value> {
vec![
json!({ "id": 1, "name": "ada", "email": "a@x.io", "score": 1.5, "ok": true }),
json!({ "id": 2, "name": "grace", "email": null, "score": 2.5, "ok": false }),
]
}
#[cfg(feature = "transform-select")]
#[test]
fn select_matches_the_value_path_and_keeps_field_order() {
assert_parity(
corpus(),
RecordTransform::Select {
fields: vec!["name".into(), "id".into(), "missing".into()],
},
);
let batch = values_to_record_batch_inferred(&corpus()).unwrap();
let kernel = batch_form(&RecordTransform::Select {
fields: vec!["name".into(), "id".into()],
})
.unwrap();
let out = kernel(batch).unwrap();
let out_schema = out.schema();
let names: Vec<&str> = out_schema
.fields()
.iter()
.map(|f| f.name().as_str())
.collect();
assert_eq!(names, vec!["name", "id"]);
}
#[cfg(feature = "transform-drop")]
#[test]
fn drop_matches_the_value_path() {
assert_parity(
corpus(),
RecordTransform::Drop {
fields: vec!["email".into(), "score".into(), "missing".into()],
},
);
}
#[cfg(feature = "transform-set")]
#[test]
fn set_matches_the_value_path_for_new_and_overwritten_keys() {
let mut values = serde_json::Map::new();
values.insert("stage".into(), json!("prod")); values.insert("ok".into(), json!(false)); values.insert("n".into(), json!(42)); assert_parity(corpus(), RecordTransform::Set { values });
}
#[cfg(feature = "transform-redact")]
#[test]
fn redact_matches_the_value_path() {
assert_parity(
corpus(),
RecordTransform::Redact {
fields: vec!["email".into(), "name".into(), "missing".into()],
mask: json!("***"),
},
);
}
#[cfg(feature = "transform-rename-field")]
#[test]
fn rename_field_matches_the_value_path() {
let mut fields = std::collections::HashMap::new();
fields.insert("name".to_string(), "full_name".to_string());
fields.insert("email".to_string(), "contact".to_string());
assert_parity(corpus(), RecordTransform::RenameField { fields });
}
#[cfg(feature = "transform-rename-field")]
#[test]
fn rename_field_swap_matches_the_value_path() {
let mut fields = std::collections::HashMap::new();
fields.insert("id".to_string(), "name".to_string());
fields.insert("name".to_string(), "id".to_string());
assert_parity(corpus(), RecordTransform::RenameField { fields });
}
#[cfg(feature = "transform-rename-field")]
#[test]
fn rename_field_two_to_one_target_errors_like_the_value_path() {
let batch = values_to_record_batch_inferred(&corpus()).unwrap();
let mut fields = std::collections::HashMap::new();
fields.insert("id".to_string(), "merged".to_string());
fields.insert("name".to_string(), "merged".to_string());
let err = batch_form(&RecordTransform::RenameField { fields }).unwrap()(batch)
.expect_err("two renames to one target must error");
assert!(err.to_string().contains("same target key"), "{err}");
}
#[cfg(feature = "transform-set")]
#[test]
fn set_covers_every_columnar_safe_scalar_kind() {
let mut values = serde_json::Map::new();
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 });
}
#[cfg(feature = "transform-set")]
#[test]
fn set_with_an_unsafe_constant_falls_back_to_the_value_path() {
for v in [
json!(1.5),
json!(u64::MAX),
json!({ "a": [1, 2] }),
json!([1, 2]),
] {
let mut values = serde_json::Map::new();
values.insert("x".into(), v.clone());
assert!(
batch_form(&RecordTransform::Set { values }).is_none(),
"set of {v} must fall back to Value, not vectorize losslessly"
);
}
}
#[cfg(feature = "transform-redact")]
#[test]
fn redact_with_an_unsafe_mask_falls_back_to_the_value_path() {
assert!(
batch_form(&RecordTransform::Redact {
fields: vec!["email".into()],
mask: json!({ "hidden": true }),
})
.is_none()
);
}
#[cfg(feature = "transform-rename-field")]
#[test]
fn rename_field_collision_errors_like_the_value_path() {
let batch = values_to_record_batch_inferred(&corpus()).unwrap();
let mut fields = std::collections::HashMap::new();
fields.insert("id".to_string(), "name".to_string()); let err = batch_form(&RecordTransform::RenameField { fields }).unwrap()(batch)
.expect_err("collision must error");
assert!(err.to_string().contains("already exists"), "{err}");
}
#[test]
fn non_vectorizable_transforms_have_no_batch_form() {
assert!(
batch_form(&RecordTransform::Flatten {
separator: "_".into()
})
.is_none()
);
#[cfg(feature = "transform-cast")]
assert!(
batch_form(&RecordTransform::Cast {
fields: std::collections::HashMap::new(),
on_error: Default::default(),
})
.is_none(),
"cast inspects values; it must not claim a kernel until it has one"
);
}
}