1use crate::config::TransformSpec;
7use crate::error::{CliError, CliResult};
8#[cfg(feature = "transform-zip-columns")]
9use faucet_core::ZipColumnsSpec;
10#[cfg(feature = "transforms")]
11use faucet_core::{
12 CastOnError, CastType, CrossJoinSpec, HashAlgorithm, HashEncoding, JsonParseOnError,
13 KeyCaseMode, LookupOnMissing, TreeFlattenSpec, UnpivotSpec, ValueCaseMode,
14};
15#[cfg(any(feature = "transforms", feature = "transform-cdc-unwrap"))]
16use faucet_core::{JsonSchema, schema_for};
17use faucet_core::{RecordTransform, TransformStage};
18#[cfg(any(feature = "transforms", feature = "transform-cdc-unwrap"))]
19use serde::Deserialize;
20use serde_json::Value;
21#[cfg(feature = "transforms")]
22use std::collections::HashMap;
23
24#[cfg(feature = "transforms")]
26#[derive(Debug, Deserialize, JsonSchema)]
27struct FlattenConfig {
28 #[serde(default = "default_separator")]
30 separator: String,
31}
32
33#[cfg(feature = "transforms")]
34fn default_separator() -> String {
35 "__".to_owned()
36}
37
38#[cfg(feature = "transforms")]
40#[derive(Debug, Deserialize, JsonSchema)]
41struct RenameKeysConfig {
42 pattern: String,
44 replacement: String,
46}
47
48#[cfg(feature = "transforms")]
49#[derive(Debug, Deserialize, JsonSchema)]
50struct FieldsConfig {
51 fields: Vec<String>,
53}
54
55#[cfg(feature = "transforms")]
56#[derive(Debug, Deserialize, JsonSchema)]
57struct SetConfig {
58 values: serde_json::Map<String, Value>,
60}
61
62#[cfg(feature = "transforms")]
63#[derive(Debug, Deserialize, JsonSchema)]
64struct RenameFieldConfig {
65 fields: HashMap<String, String>,
67}
68
69#[cfg(feature = "transforms")]
70#[derive(Debug, Deserialize, JsonSchema)]
71struct CastConfig {
72 fields: HashMap<String, CastType>,
74 #[serde(default)]
76 on_error: CastOnError,
77}
78
79#[cfg(feature = "transforms")]
80#[derive(Debug, Deserialize, JsonSchema)]
81struct RedactConfig {
82 fields: Vec<String>,
84 #[serde(default = "default_mask")]
86 mask: Value,
87}
88
89#[cfg(feature = "transforms")]
90fn default_mask() -> Value {
91 Value::String("***".to_owned())
92}
93
94#[cfg(feature = "transforms")]
95#[derive(Debug, Deserialize, JsonSchema)]
96struct ValueCaseConfig {
97 fields: Vec<String>,
99 mode: ValueCaseMode,
101}
102
103#[cfg(feature = "transforms")]
104#[derive(Debug, Deserialize, JsonSchema)]
105struct SpellSymbolsConfig {
106 #[serde(default)]
108 extra: HashMap<String, String>,
109 #[serde(default = "default_spell_separator")]
111 separator: String,
112}
113
114#[cfg(feature = "transforms")]
115fn default_spell_separator() -> String {
116 " ".to_owned()
117}
118
119#[cfg(feature = "transforms")]
120#[derive(Debug, Deserialize, JsonSchema)]
121struct KeysCaseConfig {
122 mode: KeyCaseMode,
124}
125
126#[cfg(feature = "transforms")]
127#[derive(Debug, Deserialize, JsonSchema)]
128struct HashConfig {
129 fields: Vec<String>,
131 #[serde(default)]
133 algorithm: HashAlgorithm,
134 #[serde(default)]
136 encoding: HashEncoding,
137 #[serde(default, skip_serializing_if = "Option::is_none")]
139 salt: Option<String>,
140 #[serde(default, skip_serializing_if = "Option::is_none")]
143 into: Option<String>,
144}
145
146#[cfg(feature = "transforms")]
147#[derive(Debug, Deserialize, JsonSchema)]
148struct JsonParseConfig {
149 fields: Vec<String>,
151 #[serde(default)]
153 on_error: JsonParseOnError,
154 #[serde(default, skip_serializing_if = "Option::is_none")]
157 into: Option<String>,
158}
159
160#[cfg(feature = "transforms")]
161#[derive(Debug, Deserialize, JsonSchema)]
162struct CoalesceConfig {
163 field: String,
165 #[serde(default, skip_serializing_if = "Option::is_none")]
167 default: Option<Value>,
168 #[serde(default)]
170 from: Vec<String>,
171 #[serde(default)]
173 treat_empty_string_as_null: bool,
174}
175
176#[cfg(feature = "transforms")]
177#[derive(Debug, Deserialize, JsonSchema)]
178struct SplitConfig {
179 field: String,
181 delimiter: String,
183 #[serde(default)]
185 trim: bool,
186 #[serde(default, skip_serializing_if = "Option::is_none")]
188 into: Option<String>,
189}
190
191#[cfg(feature = "transforms")]
192#[derive(Debug, Deserialize, JsonSchema)]
193struct JoinConfig {
194 field: String,
196 delimiter: String,
198 #[serde(default, skip_serializing_if = "Option::is_none")]
200 into: Option<String>,
201}
202
203#[cfg(feature = "transform-filter")]
204#[derive(Debug, Deserialize, JsonSchema)]
205struct FilterConfig {
206 path: String,
208 op: faucet_core::FilterOp,
210 #[serde(default, skip_serializing_if = "Option::is_none")]
212 value: Option<Value>,
213}
214
215#[cfg(feature = "transform-explode")]
216#[derive(Debug, Deserialize, JsonSchema)]
217struct ExplodeConfig {
218 path: String,
220 #[serde(default, skip_serializing_if = "Option::is_none")]
223 prefix: Option<String>,
224 #[serde(default = "default_explode_separator_cli")]
226 separator: String,
227 #[serde(default)]
230 on_missing: faucet_core::OnMissing,
231 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
237 carry: HashMap<String, String>,
238}
239
240#[cfg(feature = "transform-explode")]
241fn default_explode_separator_cli() -> String {
242 "_".to_owned()
243}
244
245#[cfg(feature = "transform-explode")]
249fn get_dot_path(rec: &Value, path: &str) -> Option<Value> {
250 let p = path.trim().trim_start_matches('$').trim_start_matches('.');
251 let mut cur = rec;
252 for seg in p.split('.').filter(|s| !s.is_empty()) {
253 cur = cur.get(seg)?;
254 }
255 Some(cur.clone())
256}
257
258#[cfg(feature = "transform-cdc-unwrap")]
259#[derive(Debug, Deserialize, JsonSchema)]
260struct CdcUnwrapConfig {
261 #[serde(default = "cdc_op_field")]
263 op_field: String,
264 #[serde(default = "cdc_after_field")]
266 after_field: String,
267 #[serde(default = "cdc_before_field")]
269 before_field: String,
270 #[serde(default = "cdc_key_field")]
272 key_field: String,
273 #[serde(default = "cdc_marker_field")]
275 marker_field: String,
276 #[serde(default = "cdc_delete_ops")]
278 delete_ops: Vec<String>,
279 #[serde(default = "cdc_drop_ops")]
281 drop_ops: Vec<String>,
282}
283
284#[cfg(feature = "transform-cdc-unwrap")]
285fn cdc_op_field() -> String {
286 "op".into()
287}
288#[cfg(feature = "transform-cdc-unwrap")]
289fn cdc_after_field() -> String {
290 "after".into()
291}
292#[cfg(feature = "transform-cdc-unwrap")]
293fn cdc_before_field() -> String {
294 "before".into()
295}
296#[cfg(feature = "transform-cdc-unwrap")]
297fn cdc_key_field() -> String {
298 "document_key".into()
299}
300#[cfg(feature = "transform-cdc-unwrap")]
301fn cdc_marker_field() -> String {
302 "__op".into()
303}
304#[cfg(feature = "transform-cdc-unwrap")]
305fn cdc_delete_ops() -> Vec<String> {
306 vec!["d".into(), "delete".into()]
307}
308#[cfg(feature = "transform-cdc-unwrap")]
309fn cdc_drop_ops() -> Vec<String> {
310 vec!["ddl".into(), "truncate".into()]
311}
312
313#[cfg(feature = "transforms")]
318#[derive(Debug, Deserialize, JsonSchema)]
319struct LookupOnConfig {
320 record: String,
322 #[serde(rename = "ref")]
324 ref_field: String,
325}
326
327#[cfg(feature = "transforms")]
329#[derive(Debug, Deserialize, JsonSchema)]
330struct LookupConfig {
331 #[serde(default)]
333 values: Option<Vec<serde_json::Map<String, Value>>>,
334 #[serde(default)]
337 jsonl: Option<String>,
338 on: LookupOnConfig,
340 add: HashMap<String, String>,
342 #[serde(default)]
344 on_missing: LookupOnMissing,
345}
346
347#[cfg(feature = "transforms")]
350fn load_lookup_reference(
351 kind: &str,
352 cfg: &LookupConfig,
353) -> CliResult<Vec<serde_json::Map<String, Value>>> {
354 match (&cfg.values, &cfg.jsonl) {
355 (Some(_), Some(_)) => Err(CliError::InvalidTransform {
356 name: kind.to_owned(),
357 message: "set exactly one of `values` or `jsonl`, not both".to_owned(),
358 }),
359 (Some(rows), None) => Ok(rows.clone()),
360 (None, Some(path)) => {
361 let text = std::fs::read_to_string(path).map_err(|e| CliError::InvalidTransform {
362 name: kind.to_owned(),
363 message: format!("reading lookup jsonl '{path}': {e}"),
364 })?;
365 let mut rows = Vec::new();
366 for (i, line) in text.lines().enumerate() {
367 if line.trim().is_empty() {
368 continue;
369 }
370 let row: serde_json::Map<String, Value> =
371 serde_json::from_str(line).map_err(|e| CliError::InvalidTransform {
372 name: kind.to_owned(),
373 message: format!("lookup jsonl '{path}' line {}: {e}", i + 1),
374 })?;
375 rows.push(row);
376 }
377 Ok(rows)
378 }
379 (None, None) => Err(CliError::InvalidTransform {
380 name: kind.to_owned(),
381 message: "a reference set is required: set `values` or `jsonl`".to_owned(),
382 }),
383 }
384}
385
386struct TransformDef {
390 kind: &'static str,
391 description: &'static str,
392 schema_fn: fn() -> Value,
393 compile_fn: fn(&str, Value) -> CliResult<TransformStage>,
394}
395
396fn registry() -> Vec<TransformDef> {
402 #[allow(unused_mut)]
403 let mut defs: Vec<TransformDef> = Vec::new();
404 #[cfg(feature = "transforms")]
405 {
406 defs.extend(vec![
407 TransformDef {
408 kind: "flatten",
409 description: "Flatten nested objects into a single level (configurable separator).",
410 schema_fn: || schema::<FlattenConfig>(),
411 compile_fn: |kind, config| {
412 let cfg = decode::<FlattenConfig>(kind, config)?;
413 Ok(TransformStage::Map(RecordTransform::Flatten {
414 separator: cfg.separator,
415 }))
416 },
417 },
418 TransformDef {
419 kind: "rename_keys",
420 description: "Rewrite every key via a regex pattern + replacement.",
421 schema_fn: || schema::<RenameKeysConfig>(),
422 compile_fn: |kind, config| {
423 let cfg = decode::<RenameKeysConfig>(kind, config)?;
424 Ok(TransformStage::Map(RecordTransform::RenameKeys {
425 pattern: cfg.pattern,
426 replacement: cfg.replacement,
427 }))
428 },
429 },
430 TransformDef {
431 kind: "keys_case",
432 description: "Re-case every key (snake / camel / pascal / kebab / screaming_snake).",
433 schema_fn: || schema::<KeysCaseConfig>(),
434 compile_fn: |kind, config| {
435 let cfg = decode::<KeysCaseConfig>(kind, config)?;
436 Ok(TransformStage::Map(RecordTransform::KeysCase {
437 mode: cfg.mode,
438 }))
439 },
440 },
441 TransformDef {
442 kind: "select",
443 description: "Keep only the listed top-level fields; drop the rest.",
444 schema_fn: || schema::<FieldsConfig>(),
445 compile_fn: |kind, config| {
446 let cfg = decode::<FieldsConfig>(kind, config)?;
447 Ok(TransformStage::Map(RecordTransform::Select {
448 fields: cfg.fields,
449 }))
450 },
451 },
452 TransformDef {
453 kind: "drop",
454 description: "Remove the listed top-level fields.",
455 schema_fn: || schema::<FieldsConfig>(),
456 compile_fn: |kind, config| {
457 let cfg = decode::<FieldsConfig>(kind, config)?;
458 Ok(TransformStage::Map(RecordTransform::Drop {
459 fields: cfg.fields,
460 }))
461 },
462 },
463 TransformDef {
464 kind: "set",
465 description: "Set named fields to constant values on every record.",
466 schema_fn: || schema::<SetConfig>(),
467 compile_fn: |kind, config| {
468 let cfg = decode::<SetConfig>(kind, config)?;
469 Ok(TransformStage::Map(RecordTransform::Set {
470 values: cfg.values,
471 }))
472 },
473 },
474 TransformDef {
475 kind: "rename_field",
476 description: "Rename specific top-level fields by name.",
477 schema_fn: || schema::<RenameFieldConfig>(),
478 compile_fn: |kind, config| {
479 let cfg = decode::<RenameFieldConfig>(kind, config)?;
480 Ok(TransformStage::Map(RecordTransform::RenameField {
481 fields: cfg.fields,
482 }))
483 },
484 },
485 TransformDef {
486 kind: "cast",
487 description: "Coerce named fields to int / float / bool / string / timestamp.",
488 schema_fn: || schema::<CastConfig>(),
489 compile_fn: |kind, config| {
490 let cfg = decode::<CastConfig>(kind, config)?;
491 Ok(TransformStage::Map(RecordTransform::Cast {
492 fields: cfg.fields,
493 on_error: cfg.on_error,
494 }))
495 },
496 },
497 TransformDef {
498 kind: "redact",
499 description: "Overwrite the listed fields with a mask value (default `***`).",
500 schema_fn: || schema::<RedactConfig>(),
501 compile_fn: |kind, config| {
502 let cfg = decode::<RedactConfig>(kind, config)?;
503 Ok(TransformStage::Map(RecordTransform::Redact {
504 fields: cfg.fields,
505 mask: cfg.mask,
506 }))
507 },
508 },
509 TransformDef {
510 kind: "value_case",
511 description: "Lowercase, uppercase, or trim the value of named string fields.",
512 schema_fn: || schema::<ValueCaseConfig>(),
513 compile_fn: |kind, config| {
514 let cfg = decode::<ValueCaseConfig>(kind, config)?;
515 Ok(TransformStage::Map(RecordTransform::ValueCase {
516 fields: cfg.fields,
517 mode: cfg.mode,
518 }))
519 },
520 },
521 TransformDef {
522 kind: "spell_symbols",
523 description: "Replace punctuation/symbols in string values with their spelled-out words.",
524 schema_fn: || schema::<SpellSymbolsConfig>(),
525 compile_fn: |kind, config| {
526 let cfg = decode::<SpellSymbolsConfig>(kind, config)?;
527 Ok(TransformStage::Map(RecordTransform::SpellSymbols {
528 extra: cfg.extra,
529 separator: cfg.separator,
530 }))
531 },
532 },
533 TransformDef {
534 kind: "hash",
535 description: "Hash listed fields (SHA-256 / BLAKE3) into stable, join-able tokens.",
536 schema_fn: || schema::<HashConfig>(),
537 compile_fn: |kind, config| {
538 let cfg = decode::<HashConfig>(kind, config)?;
539 let stage = TransformStage::Map(RecordTransform::Hash {
540 fields: cfg.fields,
541 algorithm: cfg.algorithm,
542 encoding: cfg.encoding,
543 salt: cfg.salt,
544 into: cfg.into,
545 });
546 validate_stage(kind, &stage)?;
547 Ok(stage)
548 },
549 },
550 TransformDef {
551 kind: "json_parse",
552 description: "Parse a stringified-JSON field into a real nested JSON value.",
553 schema_fn: || schema::<JsonParseConfig>(),
554 compile_fn: |kind, config| {
555 let cfg = decode::<JsonParseConfig>(kind, config)?;
556 let stage = TransformStage::Map(RecordTransform::JsonParse {
557 fields: cfg.fields,
558 on_error: cfg.on_error,
559 into: cfg.into,
560 });
561 validate_stage(kind, &stage)?;
562 Ok(stage)
563 },
564 },
565 TransformDef {
566 kind: "coalesce",
567 description: "Fill a missing/null field from a default or first non-null fallback key.",
568 schema_fn: || schema::<CoalesceConfig>(),
569 compile_fn: |kind, config| {
570 let cfg = decode::<CoalesceConfig>(kind, config)?;
571 let stage = TransformStage::Map(RecordTransform::Coalesce {
572 field: cfg.field,
573 default: cfg.default,
574 from: cfg.from,
575 treat_empty_string_as_null: cfg.treat_empty_string_as_null,
576 });
577 validate_stage(kind, &stage)?;
578 Ok(stage)
579 },
580 },
581 TransformDef {
582 kind: "split",
583 description: "Split a string field into an array on a delimiter.",
584 schema_fn: || schema::<SplitConfig>(),
585 compile_fn: |kind, config| {
586 let cfg = decode::<SplitConfig>(kind, config)?;
587 Ok(TransformStage::Map(RecordTransform::Split {
588 field: cfg.field,
589 delimiter: cfg.delimiter,
590 trim: cfg.trim,
591 into: cfg.into,
592 }))
593 },
594 },
595 TransformDef {
596 kind: "join",
597 description: "Join an array field into a string with a delimiter.",
598 schema_fn: || schema::<JoinConfig>(),
599 compile_fn: |kind, config| {
600 let cfg = decode::<JoinConfig>(kind, config)?;
601 Ok(TransformStage::Map(RecordTransform::Join {
602 field: cfg.field,
603 delimiter: cfg.delimiter,
604 into: cfg.into,
605 }))
606 },
607 },
608 TransformDef {
609 kind: "json_encode",
610 description: "Serialize a nested field to a JSON string (inverse of json_parse).",
611 schema_fn: || schema::<FieldsConfig>(),
612 compile_fn: |kind, config| {
613 let cfg = decode::<FieldsConfig>(kind, config)?;
614 Ok(TransformStage::Map(RecordTransform::JsonEncode {
615 fields: cfg.fields,
616 }))
617 },
618 },
619 TransformDef {
620 kind: "unpivot",
621 description: "Reshape wide columns or a map field into long key/value rows.",
622 schema_fn: || schema::<UnpivotSpec>(),
623 compile_fn: |kind, config| {
624 let spec = decode::<UnpivotSpec>(kind, config)?;
629 spec.into_stage().map_err(|e| CliError::InvalidTransform {
630 name: kind.to_owned(),
631 message: e.to_string(),
632 })
633 },
634 },
635 TransformDef {
636 kind: "tree_flatten",
637 description: "Flatten a recursive report tree / matrix (nested Rows) into one row per leaf.",
638 schema_fn: || schema::<TreeFlattenSpec>(),
639 compile_fn: |kind, config| {
640 let spec = decode::<TreeFlattenSpec>(kind, config)?;
644 spec.into_stage().map_err(|e| CliError::InvalidTransform {
645 name: kind.to_owned(),
646 message: e.to_string(),
647 })
648 },
649 },
650 TransformDef {
651 kind: "cross_join",
652 description: "Expand a record into the cartesian product of two or more of its sibling array fields.",
653 schema_fn: || schema::<CrossJoinSpec>(),
654 compile_fn: |kind, config| {
655 let spec = decode::<CrossJoinSpec>(kind, config)?;
659 spec.into_stage().map_err(|e| CliError::InvalidTransform {
660 name: kind.to_owned(),
661 message: e.to_string(),
662 })
663 },
664 },
665 #[cfg(feature = "transform-zip-columns")]
666 TransformDef {
667 kind: "zip_columns",
668 description: "Zip a columnar payload ({columns, rows}) into one object per row.",
669 schema_fn: || schema::<ZipColumnsSpec>(),
670 compile_fn: |kind, config| {
671 let spec = decode::<ZipColumnsSpec>(kind, config)?;
675 spec.into_stage().map_err(|e| CliError::InvalidTransform {
676 name: kind.to_owned(),
677 message: e.to_string(),
678 })
679 },
680 },
681 TransformDef {
682 kind: "lookup",
683 description: "Enrich records by joining against an inline/JSONL reference table.",
684 schema_fn: || schema::<LookupConfig>(),
685 compile_fn: |kind, config| {
686 let cfg = decode::<LookupConfig>(kind, config)?;
687 let reference = load_lookup_reference(kind, &cfg)?;
688 let add: Vec<(String, String)> = cfg.add.into_iter().collect();
689 let stage = TransformStage::Map(RecordTransform::Lookup {
690 reference,
691 on_record: cfg.on.record,
692 on_ref: cfg.on.ref_field,
693 add,
694 on_missing: cfg.on_missing,
695 });
696 validate_stage(kind, &stage)?;
697 Ok(stage)
698 },
699 },
700 #[cfg(feature = "transform-filter")]
701 TransformDef {
702 kind: "filter",
703 description: "Keep records where a JSONPath predicate is true.",
704 schema_fn: || schema::<FilterConfig>(),
705 compile_fn: |kind, config| {
706 let cfg = decode::<FilterConfig>(kind, config)?;
707 let stage = TransformStage::Filter(faucet_core::FilterSpec {
709 path: cfg.path,
710 op: cfg.op,
711 value: cfg.value,
712 });
713 faucet_core::compile_stage(&stage).map_err(|e| match e {
714 faucet_core::FaucetError::Transform(msg) => CliError::InvalidTransform {
715 name: kind.to_owned(),
716 message: msg,
717 },
718 other => CliError::InvalidTransform {
719 name: kind.to_owned(),
720 message: format!("{other}"),
721 },
722 })?;
723 Ok(stage)
724 },
725 },
726 #[cfg(feature = "transform-explode")]
727 TransformDef {
728 kind: "explode",
729 description: "Expand an array field into one record per element.",
730 schema_fn: || schema::<ExplodeConfig>(),
731 compile_fn: |kind, config| {
732 let cfg = decode::<ExplodeConfig>(kind, config)?;
733 let carry = cfg.carry.clone();
734 let stage = TransformStage::Explode(faucet_core::ExplodeSpec {
735 path: cfg.path,
736 prefix: cfg.prefix,
737 separator: cfg.separator,
738 on_missing: cfg.on_missing,
739 });
740 let compiled =
741 faucet_core::compile_stage(&stage).map_err(|e| match e {
742 faucet_core::FaucetError::Transform(msg) => {
743 CliError::InvalidTransform {
744 name: kind.to_owned(),
745 message: msg,
746 }
747 }
748 other => CliError::InvalidTransform {
749 name: kind.to_owned(),
750 message: format!("{other}"),
751 },
752 })?;
753 if carry.is_empty() {
754 return Ok(stage);
755 }
756 let carry: Vec<(String, String)> = carry.into_iter().collect();
762 Ok(TransformStage::PageFn(std::sync::Arc::new(
763 move |page: Vec<Value>| {
764 let mut out = Vec::with_capacity(page.len());
765 for rec in page {
766 let carried: Vec<(String, Value)> = carry
767 .iter()
768 .filter_map(|(dest, src)| {
769 get_dot_path(&rec, src).map(|v| (dest.clone(), v))
770 })
771 .collect();
772 let children = faucet_core::apply_stages(
773 rec,
774 std::slice::from_ref(&compiled),
775 )?;
776 for mut child in children {
777 if let Value::Object(map) = &mut child {
778 for (dest, val) in &carried {
779 map.insert(dest.clone(), val.clone());
780 }
781 }
782 out.push(child);
783 }
784 }
785 Ok(out)
786 },
787 )))
788 },
789 },
790 ]);
791 }
792 #[cfg(feature = "transform-sql")]
793 {
794 defs.push(TransformDef {
795 kind: "sql",
796 description: "Run DuckDB SQL over the whole page; records are the `batch` relation.",
797 schema_fn: || schema_sql(),
798 compile_fn: |kind, config| {
799 let cfg: faucet_transform_sql::SqlTransformConfig = decode_sql(kind, config)?;
800 let transform = faucet_transform_sql::SqlTransform::compile(&cfg).map_err(|e| {
801 let message = match &e {
802 faucet_core::FaucetError::Transform(m)
803 | faucet_core::FaucetError::Config(m) => m.clone(),
804 other => format!("{other}"),
805 };
806 CliError::InvalidTransform {
807 name: kind.to_owned(),
808 message,
809 }
810 })?;
811 Ok(transform.into_page_stage())
812 },
813 });
814 }
815 #[cfg(feature = "transform-wasm")]
816 {
817 defs.push(TransformDef {
818 kind: "wasm",
819 description: "Run a user-provided sandboxed .wasm module over each record (wasmtime).",
820 schema_fn: || schema_wasm(),
821 compile_fn: |kind, config| {
822 let cfg: faucet_transform_wasm::WasmTransformConfig = decode_wasm(kind, config)?;
823 let transform =
824 faucet_transform_wasm::WasmTransform::compile(&cfg).map_err(|e| {
825 let message = match &e {
826 faucet_core::FaucetError::Transform(m)
827 | faucet_core::FaucetError::Config(m) => m.clone(),
828 other => format!("{other}"),
829 };
830 CliError::InvalidTransform {
831 name: kind.to_owned(),
832 message,
833 }
834 })?;
835 Ok(transform.into_page_stage())
836 },
837 });
838 }
839 #[cfg(feature = "transform-cdc-unwrap")]
840 {
841 defs.push(TransformDef {
842 kind: "cdc_unwrap",
843 description: "Normalize a CDC envelope into a flat row + delete marker (for upsert sinks).",
844 schema_fn: || schema::<CdcUnwrapConfig>(),
845 compile_fn: |kind, config| {
846 let cfg = decode::<CdcUnwrapConfig>(kind, config)?;
847 Ok(faucet_core::TransformStage::CdcUnwrap(faucet_core::CdcUnwrapSpec {
848 op_field: cfg.op_field,
849 after_field: cfg.after_field,
850 before_field: cfg.before_field,
851 key_field: cfg.key_field,
852 marker_field: cfg.marker_field,
853 delete_ops: cfg.delete_ops,
854 drop_ops: cfg.drop_ops,
855 }))
856 },
857 });
858 }
859 defs
860}
861
862pub fn compile_transforms(specs: &[TransformSpec]) -> CliResult<Vec<TransformStage>> {
867 let mut out = Vec::with_capacity(specs.len());
868 for s in specs {
869 out.push(compile_one(s)?);
870 }
871 Ok(out)
872}
873
874fn compile_one(spec: &TransformSpec) -> CliResult<TransformStage> {
875 match registry().into_iter().find(|t| t.kind == spec.kind) {
876 Some(def) => (def.compile_fn)(&spec.kind, spec.config.clone()),
877 None => Err(unknown_transform(&spec.kind)),
878 }
879}
880
881#[cfg(feature = "arrow")]
887pub fn compile_transforms_columnar(
888 specs: &[TransformSpec],
889) -> CliResult<(
890 Vec<TransformStage>,
891 Vec<Option<faucet_core::stage::PageFnBatchBox>>,
892)> {
893 let mut stages = Vec::with_capacity(specs.len());
894 let mut batches = Vec::with_capacity(specs.len());
895 for s in specs {
896 #[cfg(feature = "transform-sql")]
897 if s.kind == "sql" {
898 let cfg = decode_sql("sql", s.config.clone())?;
899 let transform = faucet_transform_sql::SqlTransform::compile(&cfg).map_err(|e| {
900 let message = match &e {
901 faucet_core::FaucetError::Transform(m)
902 | faucet_core::FaucetError::Config(m) => m.clone(),
903 other => format!("{other}"),
904 };
905 CliError::InvalidTransform {
906 name: "sql".to_owned(),
907 message,
908 }
909 })?;
910 let (stage, batch) = transform.into_columnar_stage();
911 stages.push(stage);
912 batches.push(Some(batch));
913 continue;
914 }
915 stages.push(compile_one(s)?);
916 batches.push(None);
917 }
918 Ok((stages, batches))
919}
920
921pub fn transform_descriptions() -> Vec<(&'static str, &'static str)> {
924 registry()
925 .into_iter()
926 .map(|t| (t.kind, t.description))
927 .collect()
928}
929
930pub fn available_transforms() -> Vec<&'static str> {
932 registry().into_iter().map(|t| t.kind).collect()
933}
934
935#[cfg(feature = "quality")]
940pub fn quality_descriptions() -> Vec<(&'static str, &'static str)> {
941 let mut checks = vec![
942 ("not_null", "field present and non-null"),
943 ("not_empty", "string non-empty after trim"),
944 ("regex_match", "string matches a regex"),
945 ("value_in_set", "value is in an allowed set"),
946 ("not_in_set", "value is not in a forbidden set"),
947 ("compare", "numeric/scalar comparison (gt/gte/lt/lte/eq/ne)"),
948 ("type_is", "value is of an expected JSON type"),
949 ("string_length", "string length within [min,max]"),
950 ];
951 #[cfg(feature = "quality-jsonschema")]
952 checks.push((
953 "json_schema",
954 "record validates against a JSON Schema (feature-gated)",
955 ));
956 checks.extend([
957 ("row_count", "batch row count within [min,max]"),
958 ("null_rate", "batch null rate of a field <= max"),
959 ("unique", "composite key unique within the batch"),
960 (
961 "distinct_count",
962 "distinct values of a field within [min,max]",
963 ),
964 ]);
965 checks
966}
967
968pub fn transform_schema(name: &str) -> CliResult<Value> {
972 registry()
973 .into_iter()
974 .find(|t| t.kind == name)
975 .map(|t| (t.schema_fn)())
976 .ok_or_else(|| unknown_transform(name))
977}
978
979fn unknown_transform(name: &str) -> CliError {
980 let available = available_transforms();
981 CliError::UnknownTransform {
982 name: name.to_owned(),
983 available: if available.is_empty() {
984 "(none — rebuild faucet-cli with the `transforms` feature enabled)".to_owned()
985 } else {
986 available.join(", ")
987 },
988 }
989}
990
991#[cfg(any(feature = "transforms", feature = "transform-cdc-unwrap"))]
992fn schema<T: JsonSchema>() -> Value {
993 serde_json::to_value(schema_for!(T)).unwrap_or_else(|_| serde_json::json!({"type": "object"}))
994}
995
996#[cfg(any(feature = "transforms", feature = "transform-cdc-unwrap"))]
997fn decode<T: serde::de::DeserializeOwned>(name: &str, config: Value) -> CliResult<T> {
998 serde_json::from_value(config).map_err(|e| CliError::InvalidTransform {
999 name: name.to_owned(),
1000 message: e.to_string(),
1001 })
1002}
1003
1004#[cfg(feature = "transforms")]
1009fn validate_stage(kind: &str, stage: &TransformStage) -> CliResult<()> {
1010 faucet_core::compile_stage(stage).map(|_| ()).map_err(|e| {
1011 let message = match e {
1012 faucet_core::FaucetError::Transform(m) | faucet_core::FaucetError::Config(m) => m,
1013 other => format!("{other}"),
1014 };
1015 CliError::InvalidTransform {
1016 name: kind.to_owned(),
1017 message,
1018 }
1019 })
1020}
1021
1022#[cfg(feature = "transform-sql")]
1023fn schema_sql() -> Value {
1024 serde_json::to_value(faucet_core::schema_for!(
1025 faucet_transform_sql::SqlTransformConfig
1026 ))
1027 .unwrap_or(Value::Null)
1028}
1029
1030#[cfg(feature = "transform-sql")]
1031fn decode_sql(kind: &str, config: Value) -> CliResult<faucet_transform_sql::SqlTransformConfig> {
1032 serde_json::from_value(config).map_err(|e| CliError::InvalidTransform {
1033 name: kind.to_owned(),
1034 message: e.to_string(),
1035 })
1036}
1037
1038#[cfg(feature = "transform-wasm")]
1039fn schema_wasm() -> Value {
1040 serde_json::to_value(faucet_core::schema_for!(
1041 faucet_transform_wasm::WasmTransformConfig
1042 ))
1043 .unwrap_or(Value::Null)
1044}
1045
1046#[cfg(feature = "transform-wasm")]
1047fn decode_wasm(kind: &str, config: Value) -> CliResult<faucet_transform_wasm::WasmTransformConfig> {
1048 serde_json::from_value(config).map_err(|e| CliError::InvalidTransform {
1049 name: kind.to_owned(),
1050 message: e.to_string(),
1051 })
1052}
1053
1054#[cfg(test)]
1055mod tests {
1056 use super::*;
1057 use serde_json::json;
1058
1059 #[test]
1060 fn empty_list_compiles_to_empty() {
1061 let out = compile_transforms(&[]).unwrap();
1062 assert!(out.is_empty());
1063 }
1064
1065 #[cfg(feature = "transforms")]
1066 #[test]
1067 fn compiles_keys_case_and_flatten() {
1068 let specs = vec![
1069 TransformSpec {
1070 kind: "keys_case".into(),
1071 config: json!({"mode": "snake"}),
1072 },
1073 TransformSpec {
1074 kind: "flatten".into(),
1075 config: json!({"separator": "."}),
1076 },
1077 ];
1078 let out = compile_transforms(&specs).unwrap();
1079 assert_eq!(out.len(), 2);
1080 }
1081
1082 #[cfg(feature = "transforms")]
1083 #[test]
1084 fn keys_case_rejects_unknown_mode() {
1085 let specs = vec![TransformSpec {
1086 kind: "keys_case".into(),
1087 config: json!({"mode": "spongebob"}),
1088 }];
1089 let err = compile_transforms(&specs).unwrap_err();
1090 match err {
1091 CliError::InvalidTransform { name, .. } => assert_eq!(name, "keys_case"),
1092 other => panic!("expected InvalidTransform, got {other:?}"),
1093 }
1094 }
1095
1096 #[cfg(feature = "transforms")]
1097 #[test]
1098 fn keys_case_requires_mode() {
1099 let specs = vec![TransformSpec {
1100 kind: "keys_case".into(),
1101 config: json!({}),
1102 }];
1103 let err = compile_transforms(&specs).unwrap_err();
1104 match err {
1105 CliError::InvalidTransform { name, .. } => assert_eq!(name, "keys_case"),
1106 other => panic!("expected InvalidTransform, got {other:?}"),
1107 }
1108 }
1109
1110 #[cfg(feature = "transforms")]
1111 #[test]
1112 fn snake_case_kind_is_no_longer_recognized() {
1113 let specs = vec![TransformSpec {
1115 kind: "snake_case".into(),
1116 config: json!({}),
1117 }];
1118 let err = compile_transforms(&specs).unwrap_err();
1119 match err {
1120 CliError::UnknownTransform { name, .. } => assert_eq!(name, "snake_case"),
1121 other => panic!("expected UnknownTransform, got {other:?}"),
1122 }
1123 }
1124
1125 #[cfg(feature = "transforms")]
1126 #[test]
1127 fn rename_keys_requires_pattern_and_replacement() {
1128 let specs = vec![TransformSpec {
1129 kind: "rename_keys".into(),
1130 config: json!({"pattern": "^_"}),
1131 }];
1132 let err = compile_transforms(&specs).unwrap_err();
1133 match err {
1134 CliError::InvalidTransform { name, .. } => assert_eq!(name, "rename_keys"),
1135 other => panic!("expected InvalidTransform, got {other:?}"),
1136 }
1137 }
1138
1139 #[test]
1140 fn unknown_transform_errors() {
1141 let specs = vec![TransformSpec {
1142 kind: "make_uppercase".into(),
1143 config: json!({}),
1144 }];
1145 let err = compile_transforms(&specs).unwrap_err();
1146 match err {
1147 CliError::UnknownTransform { name, .. } => assert_eq!(name, "make_uppercase"),
1148 other => panic!("expected UnknownTransform, got {other:?}"),
1149 }
1150 }
1151
1152 #[cfg(feature = "transforms")]
1153 #[test]
1154 fn compiles_select_and_drop() {
1155 let specs = vec![
1156 TransformSpec {
1157 kind: "select".into(),
1158 config: json!({"fields": ["id", "name"]}),
1159 },
1160 TransformSpec {
1161 kind: "drop".into(),
1162 config: json!({"fields": ["secret"]}),
1163 },
1164 ];
1165 let out = compile_transforms(&specs).unwrap();
1166 assert_eq!(out.len(), 2);
1167 }
1168
1169 #[cfg(feature = "transforms")]
1170 #[test]
1171 fn compiles_set_with_object_values() {
1172 let specs = vec![TransformSpec {
1173 kind: "set".into(),
1174 config: json!({"values": {"_source": "api", "version": 1}}),
1175 }];
1176 let out = compile_transforms(&specs).unwrap();
1177 assert_eq!(out.len(), 1);
1178 }
1179
1180 #[cfg(feature = "transforms")]
1181 #[test]
1182 fn compiles_rename_field() {
1183 let specs = vec![TransformSpec {
1184 kind: "rename_field".into(),
1185 config: json!({"fields": {"old": "new"}}),
1186 }];
1187 let out = compile_transforms(&specs).unwrap();
1188 assert_eq!(out.len(), 1);
1189 }
1190
1191 #[cfg(feature = "transforms")]
1192 #[test]
1193 fn compiles_cast_with_default_on_error() {
1194 let specs = vec![TransformSpec {
1195 kind: "cast".into(),
1196 config: json!({"fields": {"age": "int", "price": "float"}}),
1197 }];
1198 let out = compile_transforms(&specs).unwrap();
1199 assert_eq!(out.len(), 1);
1200 }
1201
1202 #[cfg(feature = "transforms")]
1203 #[test]
1204 fn cast_rejects_unknown_target_type() {
1205 let specs = vec![TransformSpec {
1206 kind: "cast".into(),
1207 config: json!({"fields": {"x": "uuid"}}),
1208 }];
1209 let err = compile_transforms(&specs).unwrap_err();
1210 match err {
1211 CliError::InvalidTransform { name, .. } => assert_eq!(name, "cast"),
1212 other => panic!("expected InvalidTransform, got {other:?}"),
1213 }
1214 }
1215
1216 #[cfg(feature = "transforms")]
1217 #[test]
1218 fn cast_rejects_unknown_on_error_mode() {
1219 let specs = vec![TransformSpec {
1220 kind: "cast".into(),
1221 config: json!({"fields": {"x": "int"}, "on_error": "explode"}),
1222 }];
1223 let err = compile_transforms(&specs).unwrap_err();
1224 match err {
1225 CliError::InvalidTransform { name, .. } => assert_eq!(name, "cast"),
1226 other => panic!("expected InvalidTransform, got {other:?}"),
1227 }
1228 }
1229
1230 #[cfg(feature = "transforms")]
1231 #[test]
1232 fn redact_uses_default_mask_when_omitted() {
1233 let specs = vec![TransformSpec {
1234 kind: "redact".into(),
1235 config: json!({"fields": ["ssn"]}),
1236 }];
1237 let out = compile_transforms(&specs).unwrap();
1238 assert_eq!(out.len(), 1);
1239 }
1240
1241 #[cfg(feature = "transforms")]
1242 #[test]
1243 fn compiles_hash_with_defaults_and_options() {
1244 let specs = vec![
1245 TransformSpec {
1246 kind: "hash".into(),
1247 config: json!({"fields": ["email"]}),
1248 },
1249 TransformSpec {
1250 kind: "hash".into(),
1251 config: json!({
1252 "fields": ["user_id"],
1253 "algorithm": "blake3",
1254 "encoding": "base64",
1255 "salt": "pepper",
1256 "into": "user_id_hash"
1257 }),
1258 },
1259 ];
1260 let out = compile_transforms(&specs).unwrap();
1261 assert_eq!(out.len(), 2);
1262 }
1263
1264 #[cfg(feature = "transforms")]
1265 #[test]
1266 fn compiles_reshape_transforms() {
1267 let specs = vec![
1268 TransformSpec {
1269 kind: "json_encode".into(),
1270 config: json!({"fields": ["addr", "line_items"]}),
1271 },
1272 TransformSpec {
1273 kind: "unpivot".into(),
1274 config: json!({"id_fields": ["id"], "key_name": "month", "value_name": "amount"}),
1275 },
1276 TransformSpec {
1277 kind: "lookup".into(),
1278 config: json!({
1279 "values": [{"id": 1, "name": "Alice"}],
1280 "on": {"record": "user_id", "ref": "id"},
1281 "add": {"user_name": "name"}
1282 }),
1283 },
1284 ];
1285 let out = compile_transforms(&specs).unwrap();
1286 assert_eq!(out.len(), 3);
1287 }
1288
1289 #[cfg(feature = "transforms")]
1290 #[test]
1291 fn compiles_cross_join() {
1292 let specs = vec![TransformSpec {
1293 kind: "cross_join".into(),
1294 config: json!({"arrays": ["jobs", "compensation"], "prefix": true}),
1295 }];
1296 let out = compile_transforms(&specs).unwrap();
1297 assert_eq!(out.len(), 1);
1298 assert!(matches!(out[0], TransformStage::PageFn(_)));
1299 }
1300
1301 #[cfg(feature = "transforms")]
1302 #[test]
1303 fn cross_join_single_array_rejected_at_load() {
1304 let specs = vec![TransformSpec {
1305 kind: "cross_join".into(),
1306 config: json!({"arrays": ["only"]}),
1307 }];
1308 assert!(compile_transforms(&specs).is_err());
1309 }
1310
1311 #[cfg(feature = "transform-zip-columns")]
1312 #[test]
1313 fn zip_columns_compiles_and_zips() {
1314 let specs = vec![TransformSpec {
1315 kind: "zip_columns".into(),
1316 config: json!({"columns_path": "columns[*].name", "rows_path": "rows"}),
1317 }];
1318 let out = compile_transforms(&specs).unwrap();
1319 let TransformStage::PageFn(f) = &out[0] else {
1320 panic!("expected PageFn");
1321 };
1322 let page = vec![json!({
1323 "columns": [{"name": "day"}, {"name": "n"}],
1324 "rows": [["2026-01-01", 3], ["2026-01-02", 5]],
1325 })];
1326 let got = f(page).unwrap();
1327 assert_eq!(
1328 got,
1329 vec![
1330 json!({"day": "2026-01-01", "n": 3}),
1331 json!({"day": "2026-01-02", "n": 5}),
1332 ]
1333 );
1334 }
1335
1336 #[cfg(feature = "transform-explode")]
1337 #[test]
1338 fn explode_carry_copies_parent_field_onto_children() {
1339 let specs = vec![TransformSpec {
1340 kind: "explode".into(),
1341 config: json!({
1342 "path": "values",
1343 "prefix": "",
1344 "carry": {"employee_id": "id"},
1345 }),
1346 }];
1347 let out = compile_transforms(&specs).unwrap();
1348 let TransformStage::PageFn(f) = &out[0] else {
1350 panic!("expected PageFn for explode+carry");
1351 };
1352 let page = vec![json!({"id": 7, "values": [{"v": "a"}, {"v": "b"}]})];
1353 let got = f(page).unwrap();
1354 assert_eq!(got.len(), 2);
1355 assert_eq!(got[0]["v"], json!("a"));
1357 assert_eq!(got[0]["employee_id"], json!(7));
1358 assert_eq!(got[1]["v"], json!("b"));
1359 assert_eq!(got[1]["employee_id"], json!(7));
1360 }
1361
1362 #[cfg(feature = "transform-explode")]
1363 #[test]
1364 fn explode_without_carry_stays_a_plain_explode_stage() {
1365 let specs = vec![TransformSpec {
1366 kind: "explode".into(),
1367 config: json!({"path": "values"}),
1368 }];
1369 let out = compile_transforms(&specs).unwrap();
1370 assert!(matches!(out[0], TransformStage::Explode(_)));
1371 }
1372
1373 #[cfg(feature = "transforms")]
1374 #[test]
1375 fn unpivot_empty_key_name_rejected_at_load() {
1376 let specs = vec![TransformSpec {
1377 kind: "unpivot".into(),
1378 config: json!({"key_name": "", "value_name": "v"}),
1379 }];
1380 let err = compile_transforms(&specs).unwrap_err();
1381 assert!(
1382 matches!(&err, CliError::InvalidTransform { name, .. } if name == "unpivot"),
1383 "got {err:?}"
1384 );
1385 }
1386
1387 #[cfg(feature = "transforms")]
1388 #[test]
1389 fn lookup_requires_a_reference_and_add() {
1390 let no_ref = vec![TransformSpec {
1392 kind: "lookup".into(),
1393 config: json!({"on": {"record": "k", "ref": "k"}, "add": {"x": "y"}}),
1394 }];
1395 assert!(matches!(
1396 compile_transforms(&no_ref).unwrap_err(),
1397 CliError::InvalidTransform { .. }
1398 ));
1399 let no_add = vec![TransformSpec {
1401 kind: "lookup".into(),
1402 config: json!({"values": [], "on": {"record": "k", "ref": "k"}, "add": {}}),
1403 }];
1404 assert!(matches!(
1405 compile_transforms(&no_add).unwrap_err(),
1406 CliError::InvalidTransform { .. }
1407 ));
1408 }
1409
1410 #[cfg(feature = "transforms")]
1411 #[test]
1412 fn lookup_reads_a_jsonl_reference_file() {
1413 let dir = tempfile::tempdir().unwrap();
1414 let path = dir.path().join("ref.jsonl");
1415 std::fs::write(
1416 &path,
1417 "{\"id\":\"1\",\"name\":\"Alice\"}\n\n{\"id\":\"2\",\"name\":\"Bob\"}\n",
1418 )
1419 .unwrap();
1420 let specs = vec![TransformSpec {
1421 kind: "lookup".into(),
1422 config: json!({
1423 "jsonl": path.to_str().unwrap(),
1424 "on": {"record": "uid", "ref": "id"},
1425 "add": {"uname": "name"}
1426 }),
1427 }];
1428 assert_eq!(compile_transforms(&specs).unwrap().len(), 1);
1429 }
1430
1431 #[cfg(feature = "transforms")]
1432 #[test]
1433 fn lookup_rejects_both_values_and_jsonl() {
1434 let specs = vec![TransformSpec {
1435 kind: "lookup".into(),
1436 config: json!({
1437 "values": [{"id": "1"}],
1438 "jsonl": "ref.jsonl",
1439 "on": {"record": "uid", "ref": "id"},
1440 "add": {"uname": "name"}
1441 }),
1442 }];
1443 let err = compile_transforms(&specs).unwrap_err();
1444 assert!(
1445 matches!(&err, CliError::InvalidTransform { name, message }
1446 if name == "lookup" && message.contains("exactly one")),
1447 "got {err:?}"
1448 );
1449 }
1450
1451 #[cfg(feature = "transforms")]
1452 #[test]
1453 fn lookup_jsonl_missing_file_is_rejected_at_load() {
1454 let specs = vec![TransformSpec {
1455 kind: "lookup".into(),
1456 config: json!({
1457 "jsonl": "/nonexistent/faucet-lookup-ref.jsonl",
1458 "on": {"record": "uid", "ref": "id"},
1459 "add": {"uname": "name"}
1460 }),
1461 }];
1462 let err = compile_transforms(&specs).unwrap_err();
1463 assert!(
1464 matches!(&err, CliError::InvalidTransform { name, message }
1465 if name == "lookup" && message.contains("reading lookup jsonl")),
1466 "got {err:?}"
1467 );
1468 }
1469
1470 #[cfg(feature = "transforms")]
1471 #[test]
1472 fn lookup_jsonl_malformed_line_is_rejected_at_load() {
1473 let dir = tempfile::tempdir().unwrap();
1474 let path = dir.path().join("bad.jsonl");
1475 std::fs::write(&path, "{\"id\":\"1\"}\nnot json\n").unwrap();
1476 let specs = vec![TransformSpec {
1477 kind: "lookup".into(),
1478 config: json!({
1479 "jsonl": path.to_str().unwrap(),
1480 "on": {"record": "uid", "ref": "id"},
1481 "add": {"uname": "name"}
1482 }),
1483 }];
1484 let err = compile_transforms(&specs).unwrap_err();
1485 assert!(
1486 matches!(&err, CliError::InvalidTransform { name, message }
1487 if name == "lookup" && message.contains("line 2")),
1488 "got {err:?}"
1489 );
1490 }
1491
1492 #[cfg(feature = "transforms")]
1493 #[test]
1494 fn reshape_transforms_have_schema_and_descriptions() {
1495 for k in ["json_encode", "unpivot", "lookup"] {
1496 assert!(transform_schema(k).is_ok(), "schema for {k}");
1497 assert!(
1498 transform_descriptions().iter().any(|(n, _)| *n == k),
1499 "description for {k}"
1500 );
1501 }
1502 }
1503
1504 #[cfg(feature = "transforms")]
1505 #[test]
1506 fn hash_empty_fields_is_rejected_at_load() {
1507 let specs = vec![TransformSpec {
1508 kind: "hash".into(),
1509 config: json!({"fields": []}),
1510 }];
1511 match compile_transforms(&specs).unwrap_err() {
1512 CliError::InvalidTransform { name, .. } => assert_eq!(name, "hash"),
1513 other => panic!("expected InvalidTransform, got {other:?}"),
1514 }
1515 }
1516
1517 #[cfg(feature = "transforms")]
1518 #[test]
1519 fn hash_into_with_multiple_fields_is_rejected_at_load() {
1520 let specs = vec![TransformSpec {
1521 kind: "hash".into(),
1522 config: json!({"fields": ["a", "b"], "into": "x"}),
1523 }];
1524 match compile_transforms(&specs).unwrap_err() {
1525 CliError::InvalidTransform { name, message } => {
1526 assert_eq!(name, "hash");
1527 assert!(message.contains("into"), "{message}");
1528 }
1529 other => panic!("expected InvalidTransform, got {other:?}"),
1530 }
1531 }
1532
1533 #[cfg(feature = "transforms")]
1534 #[test]
1535 fn compiles_json_parse_with_on_error() {
1536 let specs = vec![TransformSpec {
1537 kind: "json_parse".into(),
1538 config: json!({"fields": ["payload"], "on_error": "null", "into": "parsed"}),
1539 }];
1540 let out = compile_transforms(&specs).unwrap();
1541 assert_eq!(out.len(), 1);
1542 }
1543
1544 #[cfg(feature = "transforms")]
1545 #[test]
1546 fn json_parse_into_with_multiple_fields_is_rejected_at_load() {
1547 let specs = vec![TransformSpec {
1548 kind: "json_parse".into(),
1549 config: json!({"fields": ["a", "b"], "into": "x"}),
1550 }];
1551 match compile_transforms(&specs).unwrap_err() {
1552 CliError::InvalidTransform { name, .. } => assert_eq!(name, "json_parse"),
1553 other => panic!("expected InvalidTransform, got {other:?}"),
1554 }
1555 }
1556
1557 #[cfg(feature = "transforms")]
1558 #[test]
1559 fn compiles_coalesce_with_default() {
1560 let specs = vec![TransformSpec {
1561 kind: "coalesce".into(),
1562 config: json!({"field": "status", "default": "unknown"}),
1563 }];
1564 let out = compile_transforms(&specs).unwrap();
1565 assert_eq!(out.len(), 1);
1566 }
1567
1568 #[cfg(feature = "transforms")]
1569 #[test]
1570 fn compiles_coalesce_with_from() {
1571 let specs = vec![TransformSpec {
1572 kind: "coalesce".into(),
1573 config: json!({"field": "status", "from": ["status", "state"]}),
1574 }];
1575 let out = compile_transforms(&specs).unwrap();
1576 assert_eq!(out.len(), 1);
1577 }
1578
1579 #[cfg(feature = "transforms")]
1580 #[test]
1581 fn coalesce_both_default_and_from_is_rejected_at_load() {
1582 let specs = vec![TransformSpec {
1583 kind: "coalesce".into(),
1584 config: json!({"field": "s", "default": "x", "from": ["y"]}),
1585 }];
1586 match compile_transforms(&specs).unwrap_err() {
1587 CliError::InvalidTransform { name, .. } => assert_eq!(name, "coalesce"),
1588 other => panic!("expected InvalidTransform, got {other:?}"),
1589 }
1590 }
1591
1592 #[cfg(feature = "transforms")]
1593 #[test]
1594 fn coalesce_neither_default_nor_from_is_rejected_at_load() {
1595 let specs = vec![TransformSpec {
1596 kind: "coalesce".into(),
1597 config: json!({"field": "s"}),
1598 }];
1599 match compile_transforms(&specs).unwrap_err() {
1600 CliError::InvalidTransform { name, .. } => assert_eq!(name, "coalesce"),
1601 other => panic!("expected InvalidTransform, got {other:?}"),
1602 }
1603 }
1604
1605 #[cfg(feature = "transforms")]
1606 #[test]
1607 fn compiles_split_and_join() {
1608 let specs = vec![
1609 TransformSpec {
1610 kind: "split".into(),
1611 config: json!({"field": "tags", "delimiter": ",", "trim": true}),
1612 },
1613 TransformSpec {
1614 kind: "join".into(),
1615 config: json!({"field": "tags", "delimiter": ",", "into": "csv"}),
1616 },
1617 ];
1618 let out = compile_transforms(&specs).unwrap();
1619 assert_eq!(out.len(), 2);
1620 }
1621
1622 #[cfg(feature = "transforms")]
1623 #[test]
1624 fn value_case_requires_mode() {
1625 let specs = vec![TransformSpec {
1626 kind: "value_case".into(),
1627 config: json!({"fields": ["email"]}),
1628 }];
1629 let err = compile_transforms(&specs).unwrap_err();
1630 match err {
1631 CliError::InvalidTransform { name, .. } => assert_eq!(name, "value_case"),
1632 other => panic!("expected InvalidTransform, got {other:?}"),
1633 }
1634 }
1635
1636 #[cfg(feature = "transforms")]
1637 #[test]
1638 fn available_transforms_lists_every_kind() {
1639 let names = available_transforms();
1640 for expected in [
1641 "flatten",
1642 "rename_keys",
1643 "keys_case",
1644 "select",
1645 "drop",
1646 "set",
1647 "rename_field",
1648 "cast",
1649 "redact",
1650 "value_case",
1651 "spell_symbols",
1652 "hash",
1653 "json_parse",
1654 "coalesce",
1655 "split",
1656 "join",
1657 "filter",
1658 "explode",
1659 ] {
1660 assert!(names.contains(&expected), "missing {expected}");
1661 }
1662 assert!(
1663 !names.contains(&"snake_case"),
1664 "snake_case must be removed in favour of keys_case"
1665 );
1666 }
1667
1668 #[cfg(feature = "transforms")]
1669 #[test]
1670 fn transform_descriptions_covers_every_compiled_kind() {
1671 let names = available_transforms();
1674 let desc_names: Vec<&'static str> = transform_descriptions()
1675 .into_iter()
1676 .map(|(n, _)| n)
1677 .collect();
1678 assert_eq!(names, desc_names);
1679 for (_, desc) in transform_descriptions() {
1680 assert!(!desc.is_empty(), "every transform needs a description");
1681 }
1682 }
1683
1684 #[cfg(feature = "transforms")]
1685 #[test]
1686 fn transform_schema_returns_object_for_every_kind() {
1687 for name in available_transforms() {
1688 let schema = transform_schema(name).unwrap_or_else(|e| {
1689 panic!("schema lookup failed for {name}: {e}");
1690 });
1691 assert!(schema.is_object(), "schema for {name} must be an object");
1692 }
1693 }
1694
1695 #[cfg(feature = "transforms")]
1696 #[test]
1697 fn transform_schema_select_and_drop_share_shape() {
1698 let select = transform_schema("select").unwrap();
1701 let drop = transform_schema("drop").unwrap();
1702 assert_eq!(select, drop);
1703 }
1704
1705 #[test]
1706 fn transform_schema_unknown_errors_with_available_list() {
1707 let err = transform_schema("make_uppercase").unwrap_err();
1708 match err {
1709 CliError::UnknownTransform { name, available } => {
1710 assert_eq!(name, "make_uppercase");
1711 #[cfg(feature = "transforms")]
1712 assert!(available.contains("flatten"), "{available}");
1713 #[cfg(not(feature = "transforms"))]
1714 assert!(available.contains("rebuild"), "{available}");
1715 }
1716 other => panic!("expected UnknownTransform, got {other:?}"),
1717 }
1718 }
1719
1720 #[cfg(feature = "transform-filter")]
1721 #[test]
1722 fn compiles_filter_eq() {
1723 let specs = vec![TransformSpec {
1724 kind: "filter".into(),
1725 config: json!({"path": "status", "op": "eq", "value": "active"}),
1726 }];
1727 let out = compile_transforms(&specs).unwrap();
1728 assert_eq!(out.len(), 1);
1729 assert!(matches!(out[0], TransformStage::Filter(_)));
1730 }
1731
1732 #[cfg(feature = "transform-filter")]
1733 #[test]
1734 fn filter_rejects_in_with_non_array_value() {
1735 let specs = vec![TransformSpec {
1736 kind: "filter".into(),
1737 config: json!({"path": "v", "op": "in", "value": "scalar"}),
1738 }];
1739 let err = compile_transforms(&specs).unwrap_err();
1740 match err {
1741 CliError::InvalidTransform { name, message } => {
1742 assert_eq!(name, "filter");
1743 assert!(message.contains("requires an array"), "{message}");
1744 }
1745 other => panic!("expected InvalidTransform, got {other:?}"),
1746 }
1747 }
1748
1749 #[cfg(feature = "transform-filter")]
1750 #[test]
1751 fn filter_rejects_exists_with_value() {
1752 let specs = vec![TransformSpec {
1753 kind: "filter".into(),
1754 config: json!({"path": "v", "op": "exists", "value": "x"}),
1755 }];
1756 let err = compile_transforms(&specs).unwrap_err();
1757 match err {
1758 CliError::InvalidTransform { name, .. } => assert_eq!(name, "filter"),
1759 other => panic!("expected InvalidTransform, got {other:?}"),
1760 }
1761 }
1762
1763 #[cfg(feature = "transform-filter")]
1764 #[test]
1765 fn filter_rejects_bad_path() {
1766 let specs = vec![TransformSpec {
1767 kind: "filter".into(),
1768 config: json!({"path": "$..items", "op": "exists"}),
1769 }];
1770 let err = compile_transforms(&specs).unwrap_err();
1771 match err {
1772 CliError::InvalidTransform { name, .. } => assert_eq!(name, "filter"),
1773 other => panic!("expected InvalidTransform, got {other:?}"),
1774 }
1775 }
1776
1777 #[cfg(feature = "transform-explode")]
1778 #[test]
1779 fn compiles_explode_with_defaults() {
1780 let specs = vec![TransformSpec {
1781 kind: "explode".into(),
1782 config: json!({"path": "items"}),
1783 }];
1784 let out = compile_transforms(&specs).unwrap();
1785 assert_eq!(out.len(), 1);
1786 assert!(matches!(out[0], TransformStage::Explode(_)));
1787 }
1788
1789 #[cfg(feature = "transform-explode")]
1790 #[test]
1791 fn compiles_explode_with_custom_prefix_and_on_missing() {
1792 let specs = vec![TransformSpec {
1793 kind: "explode".into(),
1794 config: json!({
1795 "path": "items",
1796 "prefix": "item",
1797 "separator": "_",
1798 "on_missing": "drop"
1799 }),
1800 }];
1801 let out = compile_transforms(&specs).unwrap();
1802 assert_eq!(out.len(), 1);
1803 }
1804
1805 #[cfg(feature = "transform-explode")]
1806 #[test]
1807 fn explode_rejects_bad_path() {
1808 let specs = vec![TransformSpec {
1809 kind: "explode".into(),
1810 config: json!({"path": "$..items"}),
1811 }];
1812 let err = compile_transforms(&specs).unwrap_err();
1813 match err {
1814 CliError::InvalidTransform { name, .. } => assert_eq!(name, "explode"),
1815 other => panic!("expected InvalidTransform, got {other:?}"),
1816 }
1817 }
1818
1819 #[cfg(feature = "transform-explode")]
1820 #[test]
1821 fn explode_rejects_invalid_on_missing() {
1822 let specs = vec![TransformSpec {
1823 kind: "explode".into(),
1824 config: json!({"path": "items", "on_missing": "explode_harder"}),
1825 }];
1826 let err = compile_transforms(&specs).unwrap_err();
1827 match err {
1828 CliError::InvalidTransform { name, .. } => assert_eq!(name, "explode"),
1829 other => panic!("expected InvalidTransform, got {other:?}"),
1830 }
1831 }
1832
1833 #[cfg(feature = "transform-sql")]
1834 #[test]
1835 fn compiles_sql_to_page_fn() {
1836 let specs = vec![TransformSpec {
1837 kind: "sql".into(),
1838 config: json!({"query": "SELECT * FROM batch"}),
1839 }];
1840 let out = compile_transforms(&specs).unwrap();
1841 assert_eq!(out.len(), 1);
1842 assert!(matches!(out[0], faucet_core::TransformStage::PageFn(_)));
1843 }
1844
1845 #[cfg(feature = "transform-sql")]
1846 #[test]
1847 fn sql_bad_query_is_invalid_transform() {
1848 let specs = vec![TransformSpec {
1849 kind: "sql".into(),
1850 config: json!({"query": "SELEKT bad"}),
1851 }];
1852 let err = compile_transforms(&specs).unwrap_err();
1853 match err {
1854 CliError::InvalidTransform { name, .. } => assert_eq!(name, "sql"),
1855 other => panic!("expected InvalidTransform, got {other:?}"),
1856 }
1857 }
1858
1859 #[cfg(feature = "transform-sql")]
1860 #[test]
1861 fn sql_schema_and_listing_present() {
1862 assert!(transform_schema("sql").is_ok());
1863 assert!(available_transforms().contains(&"sql"));
1864 }
1865
1866 #[cfg(feature = "transform-wasm")]
1867 #[test]
1868 fn wasm_schema_and_listing_present() {
1869 assert!(transform_schema("wasm").is_ok());
1870 assert!(available_transforms().contains(&"wasm"));
1871 }
1872
1873 #[cfg(feature = "transform-wasm")]
1874 #[test]
1875 fn wasm_missing_module_field_is_invalid_transform() {
1876 let specs = vec![TransformSpec {
1877 kind: "wasm".into(),
1878 config: json!({}),
1879 }];
1880 let err = compile_transforms(&specs).unwrap_err();
1881 match err {
1882 CliError::InvalidTransform { name, .. } => assert_eq!(name, "wasm"),
1883 other => panic!("expected InvalidTransform, got {other:?}"),
1884 }
1885 }
1886
1887 #[cfg(feature = "transform-wasm")]
1888 #[test]
1889 fn wasm_nonexistent_module_is_invalid_transform() {
1890 let specs = vec![TransformSpec {
1891 kind: "wasm".into(),
1892 config: json!({"module": "/no/such/path/mod.wasm"}),
1893 }];
1894 let err = compile_transforms(&specs).unwrap_err();
1895 match err {
1896 CliError::InvalidTransform { name, message } => {
1897 assert_eq!(name, "wasm");
1898 assert!(message.contains("cannot read module"), "{message}");
1899 }
1900 other => panic!("expected InvalidTransform, got {other:?}"),
1901 }
1902 }
1903
1904 #[cfg(feature = "transform-cdc-unwrap")]
1905 #[test]
1906 fn compiles_cdc_unwrap_with_defaults() {
1907 let specs = vec![TransformSpec {
1908 kind: "cdc_unwrap".into(),
1909 config: json!({}),
1910 }];
1911 let out = compile_transforms(&specs).unwrap();
1912 assert_eq!(out.len(), 1);
1913 assert!(matches!(out[0], faucet_core::TransformStage::CdcUnwrap(_)));
1914 }
1915
1916 #[cfg(feature = "transform-cdc-unwrap")]
1917 #[test]
1918 fn cdc_unwrap_schema_and_listing_present() {
1919 assert!(transform_schema("cdc_unwrap").is_ok());
1920 assert!(available_transforms().contains(&"cdc_unwrap"));
1921 }
1922
1923 #[cfg(feature = "quality")]
1924 #[test]
1925 fn quality_descriptions_has_one_entry_per_check() {
1926 #[cfg(feature = "quality-jsonschema")]
1932 assert_eq!(quality_descriptions().len(), 13);
1933 #[cfg(not(feature = "quality-jsonschema"))]
1934 assert_eq!(quality_descriptions().len(), 12);
1935 }
1936}