1use crate::config::TransformSpec;
7use crate::error::{CliError, CliResult};
8#[cfg(feature = "transforms")]
9use faucet_core::{CastOnError, CastType, KeyCaseMode, ValueCaseMode};
10#[cfg(any(feature = "transforms", feature = "transform-cdc-unwrap"))]
11use faucet_core::{JsonSchema, schema_for};
12use faucet_core::{RecordTransform, TransformStage};
13#[cfg(any(feature = "transforms", feature = "transform-cdc-unwrap"))]
14use serde::Deserialize;
15use serde_json::Value;
16#[cfg(feature = "transforms")]
17use std::collections::HashMap;
18
19#[cfg(feature = "transforms")]
21#[derive(Debug, Deserialize, JsonSchema)]
22struct FlattenConfig {
23 #[serde(default = "default_separator")]
25 separator: String,
26}
27
28#[cfg(feature = "transforms")]
29fn default_separator() -> String {
30 "__".to_owned()
31}
32
33#[cfg(feature = "transforms")]
35#[derive(Debug, Deserialize, JsonSchema)]
36struct RenameKeysConfig {
37 pattern: String,
39 replacement: String,
41}
42
43#[cfg(feature = "transforms")]
44#[derive(Debug, Deserialize, JsonSchema)]
45struct FieldsConfig {
46 fields: Vec<String>,
48}
49
50#[cfg(feature = "transforms")]
51#[derive(Debug, Deserialize, JsonSchema)]
52struct SetConfig {
53 values: serde_json::Map<String, Value>,
55}
56
57#[cfg(feature = "transforms")]
58#[derive(Debug, Deserialize, JsonSchema)]
59struct RenameFieldConfig {
60 fields: HashMap<String, String>,
62}
63
64#[cfg(feature = "transforms")]
65#[derive(Debug, Deserialize, JsonSchema)]
66struct CastConfig {
67 fields: HashMap<String, CastType>,
69 #[serde(default)]
71 on_error: CastOnError,
72}
73
74#[cfg(feature = "transforms")]
75#[derive(Debug, Deserialize, JsonSchema)]
76struct RedactConfig {
77 fields: Vec<String>,
79 #[serde(default = "default_mask")]
81 mask: Value,
82}
83
84#[cfg(feature = "transforms")]
85fn default_mask() -> Value {
86 Value::String("***".to_owned())
87}
88
89#[cfg(feature = "transforms")]
90#[derive(Debug, Deserialize, JsonSchema)]
91struct ValueCaseConfig {
92 fields: Vec<String>,
94 mode: ValueCaseMode,
96}
97
98#[cfg(feature = "transforms")]
99#[derive(Debug, Deserialize, JsonSchema)]
100struct SpellSymbolsConfig {
101 #[serde(default)]
103 extra: HashMap<String, String>,
104 #[serde(default = "default_spell_separator")]
106 separator: String,
107}
108
109#[cfg(feature = "transforms")]
110fn default_spell_separator() -> String {
111 " ".to_owned()
112}
113
114#[cfg(feature = "transforms")]
115#[derive(Debug, Deserialize, JsonSchema)]
116struct KeysCaseConfig {
117 mode: KeyCaseMode,
119}
120
121#[cfg(feature = "transform-filter")]
122#[derive(Debug, Deserialize, JsonSchema)]
123struct FilterConfig {
124 path: String,
126 op: faucet_core::FilterOp,
128 #[serde(default, skip_serializing_if = "Option::is_none")]
130 value: Option<Value>,
131}
132
133#[cfg(feature = "transform-explode")]
134#[derive(Debug, Deserialize, JsonSchema)]
135struct ExplodeConfig {
136 path: String,
138 #[serde(default, skip_serializing_if = "Option::is_none")]
141 prefix: Option<String>,
142 #[serde(default = "default_explode_separator_cli")]
144 separator: String,
145 #[serde(default)]
148 on_missing: faucet_core::OnMissing,
149}
150
151#[cfg(feature = "transform-explode")]
152fn default_explode_separator_cli() -> String {
153 "_".to_owned()
154}
155
156#[cfg(feature = "transform-cdc-unwrap")]
157#[derive(Debug, Deserialize, JsonSchema)]
158struct CdcUnwrapConfig {
159 #[serde(default = "cdc_op_field")]
161 op_field: String,
162 #[serde(default = "cdc_after_field")]
164 after_field: String,
165 #[serde(default = "cdc_before_field")]
167 before_field: String,
168 #[serde(default = "cdc_key_field")]
170 key_field: String,
171 #[serde(default = "cdc_marker_field")]
173 marker_field: String,
174 #[serde(default = "cdc_delete_ops")]
176 delete_ops: Vec<String>,
177 #[serde(default = "cdc_drop_ops")]
179 drop_ops: Vec<String>,
180}
181
182#[cfg(feature = "transform-cdc-unwrap")]
183fn cdc_op_field() -> String {
184 "op".into()
185}
186#[cfg(feature = "transform-cdc-unwrap")]
187fn cdc_after_field() -> String {
188 "after".into()
189}
190#[cfg(feature = "transform-cdc-unwrap")]
191fn cdc_before_field() -> String {
192 "before".into()
193}
194#[cfg(feature = "transform-cdc-unwrap")]
195fn cdc_key_field() -> String {
196 "document_key".into()
197}
198#[cfg(feature = "transform-cdc-unwrap")]
199fn cdc_marker_field() -> String {
200 "__op".into()
201}
202#[cfg(feature = "transform-cdc-unwrap")]
203fn cdc_delete_ops() -> Vec<String> {
204 vec!["d".into(), "delete".into()]
205}
206#[cfg(feature = "transform-cdc-unwrap")]
207fn cdc_drop_ops() -> Vec<String> {
208 vec!["ddl".into(), "truncate".into()]
209}
210
211struct TransformDef {
218 kind: &'static str,
219 description: &'static str,
220 schema_fn: fn() -> Value,
221 compile_fn: fn(&str, Value) -> CliResult<TransformStage>,
222}
223
224fn registry() -> Vec<TransformDef> {
230 #[allow(unused_mut)]
231 let mut defs: Vec<TransformDef> = Vec::new();
232 #[cfg(feature = "transforms")]
233 {
234 defs.extend(vec![
235 TransformDef {
236 kind: "flatten",
237 description: "Flatten nested objects into a single level (configurable separator).",
238 schema_fn: || schema::<FlattenConfig>(),
239 compile_fn: |kind, config| {
240 let cfg = decode::<FlattenConfig>(kind, config)?;
241 Ok(TransformStage::Map(RecordTransform::Flatten {
242 separator: cfg.separator,
243 }))
244 },
245 },
246 TransformDef {
247 kind: "rename_keys",
248 description: "Rewrite every key via a regex pattern + replacement.",
249 schema_fn: || schema::<RenameKeysConfig>(),
250 compile_fn: |kind, config| {
251 let cfg = decode::<RenameKeysConfig>(kind, config)?;
252 Ok(TransformStage::Map(RecordTransform::RenameKeys {
253 pattern: cfg.pattern,
254 replacement: cfg.replacement,
255 }))
256 },
257 },
258 TransformDef {
259 kind: "keys_case",
260 description: "Re-case every key (snake / camel / pascal / kebab / screaming_snake).",
261 schema_fn: || schema::<KeysCaseConfig>(),
262 compile_fn: |kind, config| {
263 let cfg = decode::<KeysCaseConfig>(kind, config)?;
264 Ok(TransformStage::Map(RecordTransform::KeysCase {
265 mode: cfg.mode,
266 }))
267 },
268 },
269 TransformDef {
270 kind: "select",
271 description: "Keep only the listed top-level fields; drop the rest.",
272 schema_fn: || schema::<FieldsConfig>(),
273 compile_fn: |kind, config| {
274 let cfg = decode::<FieldsConfig>(kind, config)?;
275 Ok(TransformStage::Map(RecordTransform::Select {
276 fields: cfg.fields,
277 }))
278 },
279 },
280 TransformDef {
281 kind: "drop",
282 description: "Remove the listed top-level fields.",
283 schema_fn: || schema::<FieldsConfig>(),
284 compile_fn: |kind, config| {
285 let cfg = decode::<FieldsConfig>(kind, config)?;
286 Ok(TransformStage::Map(RecordTransform::Drop {
287 fields: cfg.fields,
288 }))
289 },
290 },
291 TransformDef {
292 kind: "set",
293 description: "Set named fields to constant values on every record.",
294 schema_fn: || schema::<SetConfig>(),
295 compile_fn: |kind, config| {
296 let cfg = decode::<SetConfig>(kind, config)?;
297 Ok(TransformStage::Map(RecordTransform::Set {
298 values: cfg.values,
299 }))
300 },
301 },
302 TransformDef {
303 kind: "rename_field",
304 description: "Rename specific top-level fields by name.",
305 schema_fn: || schema::<RenameFieldConfig>(),
306 compile_fn: |kind, config| {
307 let cfg = decode::<RenameFieldConfig>(kind, config)?;
308 Ok(TransformStage::Map(RecordTransform::RenameField {
309 fields: cfg.fields,
310 }))
311 },
312 },
313 TransformDef {
314 kind: "cast",
315 description: "Coerce named fields to int / float / bool / string / timestamp.",
316 schema_fn: || schema::<CastConfig>(),
317 compile_fn: |kind, config| {
318 let cfg = decode::<CastConfig>(kind, config)?;
319 Ok(TransformStage::Map(RecordTransform::Cast {
320 fields: cfg.fields,
321 on_error: cfg.on_error,
322 }))
323 },
324 },
325 TransformDef {
326 kind: "redact",
327 description: "Overwrite the listed fields with a mask value (default `***`).",
328 schema_fn: || schema::<RedactConfig>(),
329 compile_fn: |kind, config| {
330 let cfg = decode::<RedactConfig>(kind, config)?;
331 Ok(TransformStage::Map(RecordTransform::Redact {
332 fields: cfg.fields,
333 mask: cfg.mask,
334 }))
335 },
336 },
337 TransformDef {
338 kind: "value_case",
339 description: "Lowercase, uppercase, or trim the value of named string fields.",
340 schema_fn: || schema::<ValueCaseConfig>(),
341 compile_fn: |kind, config| {
342 let cfg = decode::<ValueCaseConfig>(kind, config)?;
343 Ok(TransformStage::Map(RecordTransform::ValueCase {
344 fields: cfg.fields,
345 mode: cfg.mode,
346 }))
347 },
348 },
349 TransformDef {
350 kind: "spell_symbols",
351 description: "Replace punctuation/symbols in string values with their spelled-out words.",
352 schema_fn: || schema::<SpellSymbolsConfig>(),
353 compile_fn: |kind, config| {
354 let cfg = decode::<SpellSymbolsConfig>(kind, config)?;
355 Ok(TransformStage::Map(RecordTransform::SpellSymbols {
356 extra: cfg.extra,
357 separator: cfg.separator,
358 }))
359 },
360 },
361 #[cfg(feature = "transform-filter")]
362 TransformDef {
363 kind: "filter",
364 description: "Keep records where a JSONPath predicate is true.",
365 schema_fn: || schema::<FilterConfig>(),
366 compile_fn: |kind, config| {
367 let cfg = decode::<FilterConfig>(kind, config)?;
368 let stage = TransformStage::Filter(faucet_core::FilterSpec {
370 path: cfg.path,
371 op: cfg.op,
372 value: cfg.value,
373 });
374 faucet_core::compile_stage(&stage).map_err(|e| match e {
375 faucet_core::FaucetError::Transform(msg) => CliError::InvalidTransform {
376 name: kind.to_owned(),
377 message: msg,
378 },
379 other => CliError::InvalidTransform {
380 name: kind.to_owned(),
381 message: format!("{other}"),
382 },
383 })?;
384 Ok(stage)
385 },
386 },
387 #[cfg(feature = "transform-explode")]
388 TransformDef {
389 kind: "explode",
390 description: "Expand an array field into one record per element.",
391 schema_fn: || schema::<ExplodeConfig>(),
392 compile_fn: |kind, config| {
393 let cfg = decode::<ExplodeConfig>(kind, config)?;
394 let stage = TransformStage::Explode(faucet_core::ExplodeSpec {
395 path: cfg.path,
396 prefix: cfg.prefix,
397 separator: cfg.separator,
398 on_missing: cfg.on_missing,
399 });
400 faucet_core::compile_stage(&stage).map_err(|e| match e {
401 faucet_core::FaucetError::Transform(msg) => CliError::InvalidTransform {
402 name: kind.to_owned(),
403 message: msg,
404 },
405 other => CliError::InvalidTransform {
406 name: kind.to_owned(),
407 message: format!("{other}"),
408 },
409 })?;
410 Ok(stage)
411 },
412 },
413 ]);
414 }
415 #[cfg(feature = "transform-sql")]
416 {
417 defs.push(TransformDef {
418 kind: "sql",
419 description: "Run DuckDB SQL over the whole page; records are the `batch` relation.",
420 schema_fn: || schema_sql(),
421 compile_fn: |kind, config| {
422 let cfg: faucet_transform_sql::SqlTransformConfig = decode_sql(kind, config)?;
423 let transform = faucet_transform_sql::SqlTransform::compile(&cfg).map_err(|e| {
424 let message = match &e {
425 faucet_core::FaucetError::Transform(m)
426 | faucet_core::FaucetError::Config(m) => m.clone(),
427 other => format!("{other}"),
428 };
429 CliError::InvalidTransform {
430 name: kind.to_owned(),
431 message,
432 }
433 })?;
434 Ok(transform.into_page_stage())
435 },
436 });
437 }
438 #[cfg(feature = "transform-cdc-unwrap")]
439 {
440 defs.push(TransformDef {
441 kind: "cdc_unwrap",
442 description: "Normalize a CDC envelope into a flat row + delete marker (for upsert sinks).",
443 schema_fn: || schema::<CdcUnwrapConfig>(),
444 compile_fn: |kind, config| {
445 let cfg = decode::<CdcUnwrapConfig>(kind, config)?;
446 Ok(faucet_core::TransformStage::CdcUnwrap(faucet_core::CdcUnwrapSpec {
447 op_field: cfg.op_field,
448 after_field: cfg.after_field,
449 before_field: cfg.before_field,
450 key_field: cfg.key_field,
451 marker_field: cfg.marker_field,
452 delete_ops: cfg.delete_ops,
453 drop_ops: cfg.drop_ops,
454 }))
455 },
456 });
457 }
458 defs
459}
460
461pub fn compile_transforms(specs: &[TransformSpec]) -> CliResult<Vec<TransformStage>> {
466 let mut out = Vec::with_capacity(specs.len());
467 for s in specs {
468 out.push(compile_one(s)?);
469 }
470 Ok(out)
471}
472
473fn compile_one(spec: &TransformSpec) -> CliResult<TransformStage> {
474 match registry().into_iter().find(|t| t.kind == spec.kind) {
475 Some(def) => (def.compile_fn)(&spec.kind, spec.config.clone()),
476 None => Err(unknown_transform(&spec.kind)),
477 }
478}
479
480#[cfg(feature = "arrow")]
486pub fn compile_transforms_columnar(
487 specs: &[TransformSpec],
488) -> CliResult<(
489 Vec<TransformStage>,
490 Vec<Option<faucet_core::stage::PageFnBatchBox>>,
491)> {
492 let mut stages = Vec::with_capacity(specs.len());
493 let mut batches = Vec::with_capacity(specs.len());
494 for s in specs {
495 #[cfg(feature = "transform-sql")]
496 if s.kind == "sql" {
497 let cfg = decode_sql("sql", s.config.clone())?;
498 let transform = faucet_transform_sql::SqlTransform::compile(&cfg).map_err(|e| {
499 let message = match &e {
500 faucet_core::FaucetError::Transform(m)
501 | faucet_core::FaucetError::Config(m) => m.clone(),
502 other => format!("{other}"),
503 };
504 CliError::InvalidTransform {
505 name: "sql".to_owned(),
506 message,
507 }
508 })?;
509 let (stage, batch) = transform.into_columnar_stage();
510 stages.push(stage);
511 batches.push(Some(batch));
512 continue;
513 }
514 stages.push(compile_one(s)?);
515 batches.push(None);
516 }
517 Ok((stages, batches))
518}
519
520pub fn transform_descriptions() -> Vec<(&'static str, &'static str)> {
523 registry()
524 .into_iter()
525 .map(|t| (t.kind, t.description))
526 .collect()
527}
528
529pub fn available_transforms() -> Vec<&'static str> {
531 registry().into_iter().map(|t| t.kind).collect()
532}
533
534#[cfg(feature = "quality")]
539pub fn quality_descriptions() -> Vec<(&'static str, &'static str)> {
540 let mut checks = vec![
541 ("not_null", "field present and non-null"),
542 ("not_empty", "string non-empty after trim"),
543 ("regex_match", "string matches a regex"),
544 ("value_in_set", "value is in an allowed set"),
545 ("not_in_set", "value is not in a forbidden set"),
546 ("compare", "numeric/scalar comparison (gt/gte/lt/lte/eq/ne)"),
547 ("type_is", "value is of an expected JSON type"),
548 ("string_length", "string length within [min,max]"),
549 ];
550 #[cfg(feature = "quality-jsonschema")]
551 checks.push((
552 "json_schema",
553 "record validates against a JSON Schema (feature-gated)",
554 ));
555 checks.extend([
556 ("row_count", "batch row count within [min,max]"),
557 ("null_rate", "batch null rate of a field <= max"),
558 ("unique", "composite key unique within the batch"),
559 (
560 "distinct_count",
561 "distinct values of a field within [min,max]",
562 ),
563 ]);
564 checks
565}
566
567pub fn transform_schema(name: &str) -> CliResult<Value> {
571 registry()
572 .into_iter()
573 .find(|t| t.kind == name)
574 .map(|t| (t.schema_fn)())
575 .ok_or_else(|| unknown_transform(name))
576}
577
578fn unknown_transform(name: &str) -> CliError {
579 let available = available_transforms();
580 CliError::UnknownTransform {
581 name: name.to_owned(),
582 available: if available.is_empty() {
583 "(none — rebuild faucet-cli with the `transforms` feature enabled)".to_owned()
584 } else {
585 available.join(", ")
586 },
587 }
588}
589
590#[cfg(any(feature = "transforms", feature = "transform-cdc-unwrap"))]
591fn schema<T: JsonSchema>() -> Value {
592 serde_json::to_value(schema_for!(T)).unwrap_or_else(|_| serde_json::json!({"type": "object"}))
593}
594
595#[cfg(any(feature = "transforms", feature = "transform-cdc-unwrap"))]
596fn decode<T: serde::de::DeserializeOwned>(name: &str, config: Value) -> CliResult<T> {
597 serde_json::from_value(config).map_err(|e| CliError::InvalidTransform {
598 name: name.to_owned(),
599 message: e.to_string(),
600 })
601}
602
603#[cfg(feature = "transform-sql")]
604fn schema_sql() -> Value {
605 serde_json::to_value(faucet_core::schema_for!(
606 faucet_transform_sql::SqlTransformConfig
607 ))
608 .unwrap_or(Value::Null)
609}
610
611#[cfg(feature = "transform-sql")]
612fn decode_sql(kind: &str, config: Value) -> CliResult<faucet_transform_sql::SqlTransformConfig> {
613 serde_json::from_value(config).map_err(|e| CliError::InvalidTransform {
614 name: kind.to_owned(),
615 message: e.to_string(),
616 })
617}
618
619#[cfg(test)]
620mod tests {
621 use super::*;
622 use serde_json::json;
623
624 #[test]
625 fn empty_list_compiles_to_empty() {
626 let out = compile_transforms(&[]).unwrap();
627 assert!(out.is_empty());
628 }
629
630 #[cfg(feature = "transforms")]
631 #[test]
632 fn compiles_keys_case_and_flatten() {
633 let specs = vec![
634 TransformSpec {
635 kind: "keys_case".into(),
636 config: json!({"mode": "snake"}),
637 },
638 TransformSpec {
639 kind: "flatten".into(),
640 config: json!({"separator": "."}),
641 },
642 ];
643 let out = compile_transforms(&specs).unwrap();
644 assert_eq!(out.len(), 2);
645 }
646
647 #[cfg(feature = "transforms")]
648 #[test]
649 fn keys_case_rejects_unknown_mode() {
650 let specs = vec![TransformSpec {
651 kind: "keys_case".into(),
652 config: json!({"mode": "spongebob"}),
653 }];
654 let err = compile_transforms(&specs).unwrap_err();
655 match err {
656 CliError::InvalidTransform { name, .. } => assert_eq!(name, "keys_case"),
657 other => panic!("expected InvalidTransform, got {other:?}"),
658 }
659 }
660
661 #[cfg(feature = "transforms")]
662 #[test]
663 fn keys_case_requires_mode() {
664 let specs = vec![TransformSpec {
665 kind: "keys_case".into(),
666 config: json!({}),
667 }];
668 let err = compile_transforms(&specs).unwrap_err();
669 match err {
670 CliError::InvalidTransform { name, .. } => assert_eq!(name, "keys_case"),
671 other => panic!("expected InvalidTransform, got {other:?}"),
672 }
673 }
674
675 #[cfg(feature = "transforms")]
676 #[test]
677 fn snake_case_kind_is_no_longer_recognized() {
678 let specs = vec![TransformSpec {
680 kind: "snake_case".into(),
681 config: json!({}),
682 }];
683 let err = compile_transforms(&specs).unwrap_err();
684 match err {
685 CliError::UnknownTransform { name, .. } => assert_eq!(name, "snake_case"),
686 other => panic!("expected UnknownTransform, got {other:?}"),
687 }
688 }
689
690 #[cfg(feature = "transforms")]
691 #[test]
692 fn rename_keys_requires_pattern_and_replacement() {
693 let specs = vec![TransformSpec {
694 kind: "rename_keys".into(),
695 config: json!({"pattern": "^_"}),
696 }];
697 let err = compile_transforms(&specs).unwrap_err();
698 match err {
699 CliError::InvalidTransform { name, .. } => assert_eq!(name, "rename_keys"),
700 other => panic!("expected InvalidTransform, got {other:?}"),
701 }
702 }
703
704 #[test]
705 fn unknown_transform_errors() {
706 let specs = vec![TransformSpec {
707 kind: "make_uppercase".into(),
708 config: json!({}),
709 }];
710 let err = compile_transforms(&specs).unwrap_err();
711 match err {
712 CliError::UnknownTransform { name, .. } => assert_eq!(name, "make_uppercase"),
713 other => panic!("expected UnknownTransform, got {other:?}"),
714 }
715 }
716
717 #[cfg(feature = "transforms")]
718 #[test]
719 fn compiles_select_and_drop() {
720 let specs = vec![
721 TransformSpec {
722 kind: "select".into(),
723 config: json!({"fields": ["id", "name"]}),
724 },
725 TransformSpec {
726 kind: "drop".into(),
727 config: json!({"fields": ["secret"]}),
728 },
729 ];
730 let out = compile_transforms(&specs).unwrap();
731 assert_eq!(out.len(), 2);
732 }
733
734 #[cfg(feature = "transforms")]
735 #[test]
736 fn compiles_set_with_object_values() {
737 let specs = vec![TransformSpec {
738 kind: "set".into(),
739 config: json!({"values": {"_source": "api", "version": 1}}),
740 }];
741 let out = compile_transforms(&specs).unwrap();
742 assert_eq!(out.len(), 1);
743 }
744
745 #[cfg(feature = "transforms")]
746 #[test]
747 fn compiles_rename_field() {
748 let specs = vec![TransformSpec {
749 kind: "rename_field".into(),
750 config: json!({"fields": {"old": "new"}}),
751 }];
752 let out = compile_transforms(&specs).unwrap();
753 assert_eq!(out.len(), 1);
754 }
755
756 #[cfg(feature = "transforms")]
757 #[test]
758 fn compiles_cast_with_default_on_error() {
759 let specs = vec![TransformSpec {
760 kind: "cast".into(),
761 config: json!({"fields": {"age": "int", "price": "float"}}),
762 }];
763 let out = compile_transforms(&specs).unwrap();
764 assert_eq!(out.len(), 1);
765 }
766
767 #[cfg(feature = "transforms")]
768 #[test]
769 fn cast_rejects_unknown_target_type() {
770 let specs = vec![TransformSpec {
771 kind: "cast".into(),
772 config: json!({"fields": {"x": "uuid"}}),
773 }];
774 let err = compile_transforms(&specs).unwrap_err();
775 match err {
776 CliError::InvalidTransform { name, .. } => assert_eq!(name, "cast"),
777 other => panic!("expected InvalidTransform, got {other:?}"),
778 }
779 }
780
781 #[cfg(feature = "transforms")]
782 #[test]
783 fn cast_rejects_unknown_on_error_mode() {
784 let specs = vec![TransformSpec {
785 kind: "cast".into(),
786 config: json!({"fields": {"x": "int"}, "on_error": "explode"}),
787 }];
788 let err = compile_transforms(&specs).unwrap_err();
789 match err {
790 CliError::InvalidTransform { name, .. } => assert_eq!(name, "cast"),
791 other => panic!("expected InvalidTransform, got {other:?}"),
792 }
793 }
794
795 #[cfg(feature = "transforms")]
796 #[test]
797 fn redact_uses_default_mask_when_omitted() {
798 let specs = vec![TransformSpec {
799 kind: "redact".into(),
800 config: json!({"fields": ["ssn"]}),
801 }];
802 let out = compile_transforms(&specs).unwrap();
803 assert_eq!(out.len(), 1);
804 }
805
806 #[cfg(feature = "transforms")]
807 #[test]
808 fn value_case_requires_mode() {
809 let specs = vec![TransformSpec {
810 kind: "value_case".into(),
811 config: json!({"fields": ["email"]}),
812 }];
813 let err = compile_transforms(&specs).unwrap_err();
814 match err {
815 CliError::InvalidTransform { name, .. } => assert_eq!(name, "value_case"),
816 other => panic!("expected InvalidTransform, got {other:?}"),
817 }
818 }
819
820 #[cfg(feature = "transforms")]
821 #[test]
822 fn available_transforms_lists_every_kind() {
823 let names = available_transforms();
824 for expected in [
825 "flatten",
826 "rename_keys",
827 "keys_case",
828 "select",
829 "drop",
830 "set",
831 "rename_field",
832 "cast",
833 "redact",
834 "value_case",
835 "spell_symbols",
836 "filter",
837 "explode",
838 ] {
839 assert!(names.contains(&expected), "missing {expected}");
840 }
841 assert!(
842 !names.contains(&"snake_case"),
843 "snake_case must be removed in favour of keys_case"
844 );
845 }
846
847 #[cfg(feature = "transforms")]
848 #[test]
849 fn transform_descriptions_covers_every_compiled_kind() {
850 let names = available_transforms();
853 let desc_names: Vec<&'static str> = transform_descriptions()
854 .into_iter()
855 .map(|(n, _)| n)
856 .collect();
857 assert_eq!(names, desc_names);
858 for (_, desc) in transform_descriptions() {
859 assert!(!desc.is_empty(), "every transform needs a description");
860 }
861 }
862
863 #[cfg(feature = "transforms")]
864 #[test]
865 fn transform_schema_returns_object_for_every_kind() {
866 for name in available_transforms() {
867 let schema = transform_schema(name).unwrap_or_else(|e| {
868 panic!("schema lookup failed for {name}: {e}");
869 });
870 assert!(schema.is_object(), "schema for {name} must be an object");
871 }
872 }
873
874 #[cfg(feature = "transforms")]
875 #[test]
876 fn transform_schema_select_and_drop_share_shape() {
877 let select = transform_schema("select").unwrap();
880 let drop = transform_schema("drop").unwrap();
881 assert_eq!(select, drop);
882 }
883
884 #[test]
885 fn transform_schema_unknown_errors_with_available_list() {
886 let err = transform_schema("make_uppercase").unwrap_err();
887 match err {
888 CliError::UnknownTransform { name, available } => {
889 assert_eq!(name, "make_uppercase");
890 #[cfg(feature = "transforms")]
891 assert!(available.contains("flatten"), "{available}");
892 #[cfg(not(feature = "transforms"))]
893 assert!(available.contains("rebuild"), "{available}");
894 }
895 other => panic!("expected UnknownTransform, got {other:?}"),
896 }
897 }
898
899 #[cfg(feature = "transform-filter")]
900 #[test]
901 fn compiles_filter_eq() {
902 let specs = vec![TransformSpec {
903 kind: "filter".into(),
904 config: json!({"path": "status", "op": "eq", "value": "active"}),
905 }];
906 let out = compile_transforms(&specs).unwrap();
907 assert_eq!(out.len(), 1);
908 assert!(matches!(out[0], TransformStage::Filter(_)));
909 }
910
911 #[cfg(feature = "transform-filter")]
912 #[test]
913 fn filter_rejects_in_with_non_array_value() {
914 let specs = vec![TransformSpec {
915 kind: "filter".into(),
916 config: json!({"path": "v", "op": "in", "value": "scalar"}),
917 }];
918 let err = compile_transforms(&specs).unwrap_err();
919 match err {
920 CliError::InvalidTransform { name, message } => {
921 assert_eq!(name, "filter");
922 assert!(message.contains("requires an array"), "{message}");
923 }
924 other => panic!("expected InvalidTransform, got {other:?}"),
925 }
926 }
927
928 #[cfg(feature = "transform-filter")]
929 #[test]
930 fn filter_rejects_exists_with_value() {
931 let specs = vec![TransformSpec {
932 kind: "filter".into(),
933 config: json!({"path": "v", "op": "exists", "value": "x"}),
934 }];
935 let err = compile_transforms(&specs).unwrap_err();
936 match err {
937 CliError::InvalidTransform { name, .. } => assert_eq!(name, "filter"),
938 other => panic!("expected InvalidTransform, got {other:?}"),
939 }
940 }
941
942 #[cfg(feature = "transform-filter")]
943 #[test]
944 fn filter_rejects_bad_path() {
945 let specs = vec![TransformSpec {
946 kind: "filter".into(),
947 config: json!({"path": "$..items", "op": "exists"}),
948 }];
949 let err = compile_transforms(&specs).unwrap_err();
950 match err {
951 CliError::InvalidTransform { name, .. } => assert_eq!(name, "filter"),
952 other => panic!("expected InvalidTransform, got {other:?}"),
953 }
954 }
955
956 #[cfg(feature = "transform-explode")]
957 #[test]
958 fn compiles_explode_with_defaults() {
959 let specs = vec![TransformSpec {
960 kind: "explode".into(),
961 config: json!({"path": "items"}),
962 }];
963 let out = compile_transforms(&specs).unwrap();
964 assert_eq!(out.len(), 1);
965 assert!(matches!(out[0], TransformStage::Explode(_)));
966 }
967
968 #[cfg(feature = "transform-explode")]
969 #[test]
970 fn compiles_explode_with_custom_prefix_and_on_missing() {
971 let specs = vec![TransformSpec {
972 kind: "explode".into(),
973 config: json!({
974 "path": "items",
975 "prefix": "item",
976 "separator": "_",
977 "on_missing": "drop"
978 }),
979 }];
980 let out = compile_transforms(&specs).unwrap();
981 assert_eq!(out.len(), 1);
982 }
983
984 #[cfg(feature = "transform-explode")]
985 #[test]
986 fn explode_rejects_bad_path() {
987 let specs = vec![TransformSpec {
988 kind: "explode".into(),
989 config: json!({"path": "$..items"}),
990 }];
991 let err = compile_transforms(&specs).unwrap_err();
992 match err {
993 CliError::InvalidTransform { name, .. } => assert_eq!(name, "explode"),
994 other => panic!("expected InvalidTransform, got {other:?}"),
995 }
996 }
997
998 #[cfg(feature = "transform-explode")]
999 #[test]
1000 fn explode_rejects_invalid_on_missing() {
1001 let specs = vec![TransformSpec {
1002 kind: "explode".into(),
1003 config: json!({"path": "items", "on_missing": "explode_harder"}),
1004 }];
1005 let err = compile_transforms(&specs).unwrap_err();
1006 match err {
1007 CliError::InvalidTransform { name, .. } => assert_eq!(name, "explode"),
1008 other => panic!("expected InvalidTransform, got {other:?}"),
1009 }
1010 }
1011
1012 #[cfg(feature = "transform-sql")]
1013 #[test]
1014 fn compiles_sql_to_page_fn() {
1015 let specs = vec![TransformSpec {
1016 kind: "sql".into(),
1017 config: json!({"query": "SELECT * FROM batch"}),
1018 }];
1019 let out = compile_transforms(&specs).unwrap();
1020 assert_eq!(out.len(), 1);
1021 assert!(matches!(out[0], faucet_core::TransformStage::PageFn(_)));
1022 }
1023
1024 #[cfg(feature = "transform-sql")]
1025 #[test]
1026 fn sql_bad_query_is_invalid_transform() {
1027 let specs = vec![TransformSpec {
1028 kind: "sql".into(),
1029 config: json!({"query": "SELEKT bad"}),
1030 }];
1031 let err = compile_transforms(&specs).unwrap_err();
1032 match err {
1033 CliError::InvalidTransform { name, .. } => assert_eq!(name, "sql"),
1034 other => panic!("expected InvalidTransform, got {other:?}"),
1035 }
1036 }
1037
1038 #[cfg(feature = "transform-sql")]
1039 #[test]
1040 fn sql_schema_and_listing_present() {
1041 assert!(transform_schema("sql").is_ok());
1042 assert!(available_transforms().contains(&"sql"));
1043 }
1044
1045 #[cfg(feature = "transform-cdc-unwrap")]
1046 #[test]
1047 fn compiles_cdc_unwrap_with_defaults() {
1048 let specs = vec![TransformSpec {
1049 kind: "cdc_unwrap".into(),
1050 config: json!({}),
1051 }];
1052 let out = compile_transforms(&specs).unwrap();
1053 assert_eq!(out.len(), 1);
1054 assert!(matches!(out[0], faucet_core::TransformStage::CdcUnwrap(_)));
1055 }
1056
1057 #[cfg(feature = "transform-cdc-unwrap")]
1058 #[test]
1059 fn cdc_unwrap_schema_and_listing_present() {
1060 assert!(transform_schema("cdc_unwrap").is_ok());
1061 assert!(available_transforms().contains(&"cdc_unwrap"));
1062 }
1063
1064 #[cfg(feature = "quality")]
1065 #[test]
1066 fn quality_descriptions_has_one_entry_per_check() {
1067 #[cfg(feature = "quality-jsonschema")]
1073 assert_eq!(quality_descriptions().len(), 13);
1074 #[cfg(not(feature = "quality-jsonschema"))]
1075 assert_eq!(quality_descriptions().len(), 12);
1076 }
1077}