1use crate::error::FaucetError;
47#[cfg(any(
48 feature = "transform-flatten",
49 feature = "transform-rename-keys",
50 feature = "transform-keys-case",
51 feature = "transform-set",
52))]
53use serde_json::Map;
54use serde_json::Value;
55use std::fmt;
56use std::sync::Arc;
57
58#[cfg(any(
59 feature = "transform-cast",
60 feature = "transform-rename-field",
61 feature = "transform-value-case",
62 feature = "transform-spell-symbols",
63 feature = "transform-lookup",
64))]
65use std::collections::HashMap;
66
67#[cfg(feature = "transform-rename-keys")]
68use regex::Regex;
69
70#[cfg(feature = "transform-cast")]
79#[derive(
80 Debug, Clone, Copy, PartialEq, Eq, serde::Deserialize, serde::Serialize, schemars::JsonSchema,
81)]
82#[serde(rename_all = "lowercase")]
83pub enum CastType {
84 Int,
86 Float,
88 Bool,
91 String,
93 Timestamp,
95}
96
97#[cfg(feature = "transform-cast")]
99#[derive(
100 Debug,
101 Clone,
102 Copy,
103 PartialEq,
104 Eq,
105 serde::Deserialize,
106 serde::Serialize,
107 schemars::JsonSchema,
108 Default,
109)]
110#[serde(rename_all = "lowercase")]
111pub enum CastOnError {
112 #[default]
114 Error,
115 Null,
117 Skip,
119}
120
121#[cfg(feature = "transform-keys-case")]
127#[derive(
128 Debug, Clone, Copy, PartialEq, Eq, serde::Deserialize, serde::Serialize, schemars::JsonSchema,
129)]
130#[serde(rename_all = "snake_case")]
131#[non_exhaustive]
134pub enum KeyCaseMode {
135 Snake,
137 Camel,
140 Pascal,
142 Kebab,
144 ScreamingSnake,
146 Dot,
149}
150
151#[cfg(feature = "transform-value-case")]
153#[derive(
154 Debug, Clone, Copy, PartialEq, Eq, serde::Deserialize, serde::Serialize, schemars::JsonSchema,
155)]
156#[serde(rename_all = "lowercase")]
157#[non_exhaustive]
160pub enum ValueCaseMode {
161 Lower,
163 Upper,
165 Trim,
167 Title,
170 Capitalize,
173}
174
175#[cfg(feature = "transform-hash")]
177#[derive(
178 Debug,
179 Clone,
180 Copy,
181 PartialEq,
182 Eq,
183 serde::Deserialize,
184 serde::Serialize,
185 schemars::JsonSchema,
186 Default,
187)]
188#[serde(rename_all = "lowercase")]
189pub enum HashAlgorithm {
190 #[default]
192 Sha256,
193 Blake3,
195}
196
197#[cfg(feature = "transform-hash")]
199#[derive(
200 Debug,
201 Clone,
202 Copy,
203 PartialEq,
204 Eq,
205 serde::Deserialize,
206 serde::Serialize,
207 schemars::JsonSchema,
208 Default,
209)]
210#[serde(rename_all = "lowercase")]
211pub enum HashEncoding {
212 #[default]
214 Hex,
215 Base64,
217}
218
219#[cfg(feature = "transform-json-parse")]
225#[derive(
226 Debug,
227 Clone,
228 Copy,
229 PartialEq,
230 Eq,
231 serde::Deserialize,
232 serde::Serialize,
233 schemars::JsonSchema,
234 Default,
235)]
236#[serde(rename_all = "snake_case")]
237pub enum JsonParseOnError {
238 #[default]
240 Keep,
241 Null,
243 Error,
245}
246
247#[non_exhaustive]
263pub enum RecordTransform {
264 #[cfg(feature = "transform-flatten")]
277 Flatten { separator: String },
278
279 #[cfg(feature = "transform-rename-keys")]
294 RenameKeys {
295 pattern: String,
296 replacement: String,
297 },
298
299 #[cfg(feature = "transform-keys-case")]
314 KeysCase { mode: KeyCaseMode },
315
316 #[cfg(feature = "transform-select")]
323 Select { fields: Vec<String> },
324
325 #[cfg(feature = "transform-drop")]
331 Drop { fields: Vec<String> },
332
333 #[cfg(feature = "transform-set")]
340 Set { values: Map<String, Value> },
341
342 #[cfg(feature = "transform-rename-field")]
351 RenameField {
352 fields: HashMap<String, String>,
354 },
355
356 #[cfg(feature = "transform-cast")]
364 Cast {
365 fields: HashMap<String, CastType>,
366 on_error: CastOnError,
367 },
368
369 #[cfg(feature = "transform-redact")]
376 Redact { fields: Vec<String>, mask: Value },
377
378 #[cfg(feature = "transform-value-case")]
385 ValueCase {
386 fields: Vec<String>,
387 mode: ValueCaseMode,
388 },
389
390 #[cfg(feature = "transform-spell-symbols")]
411 SpellSymbols {
412 extra: HashMap<String, String>,
415 separator: String,
418 },
419
420 #[cfg(feature = "transform-hash")]
434 Hash {
435 fields: Vec<String>,
436 algorithm: HashAlgorithm,
437 encoding: HashEncoding,
438 salt: Option<String>,
439 into: Option<String>,
440 },
441
442 #[cfg(feature = "transform-json-parse")]
453 JsonParse {
454 fields: Vec<String>,
455 on_error: JsonParseOnError,
456 into: Option<String>,
457 },
458
459 #[cfg(feature = "transform-coalesce")]
469 Coalesce {
470 field: String,
471 default: Option<Value>,
473 from: Vec<String>,
476 treat_empty_string_as_null: bool,
478 },
479
480 #[cfg(feature = "transform-split-join")]
488 Split {
489 field: String,
490 delimiter: String,
491 trim: bool,
492 into: Option<String>,
493 },
494
495 #[cfg(feature = "transform-split-join")]
504 Join {
505 field: String,
506 delimiter: String,
507 into: Option<String>,
508 },
509
510 #[cfg(feature = "transform-json-encode")]
520 JsonEncode { fields: Vec<String> },
521
522 #[cfg(feature = "transform-lookup")]
536 Lookup {
537 reference: Vec<Map<String, Value>>,
539 on_record: String,
541 on_ref: String,
543 add: Vec<(String, String)>,
545 on_missing: LookupOnMissing,
547 },
548
549 Custom(Arc<dyn Fn(Value) -> Value + Send + Sync>),
556}
557
558#[cfg(feature = "transform-lookup")]
560#[derive(
561 Debug,
562 Clone,
563 Copy,
564 PartialEq,
565 Eq,
566 Default,
567 serde::Serialize,
568 serde::Deserialize,
569 schemars::JsonSchema,
570)]
571#[serde(rename_all = "snake_case")]
572pub enum LookupOnMissing {
573 #[default]
575 Null,
576 Keep,
578 Error,
580}
581
582impl fmt::Debug for RecordTransform {
583 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
584 match self {
585 #[cfg(feature = "transform-flatten")]
586 Self::Flatten { separator } => f
587 .debug_struct("Flatten")
588 .field("separator", separator)
589 .finish(),
590 #[cfg(feature = "transform-rename-keys")]
591 Self::RenameKeys {
592 pattern,
593 replacement,
594 } => f
595 .debug_struct("RenameKeys")
596 .field("pattern", pattern)
597 .field("replacement", replacement)
598 .finish(),
599 #[cfg(feature = "transform-keys-case")]
600 Self::KeysCase { mode } => f.debug_struct("KeysCase").field("mode", mode).finish(),
601 #[cfg(feature = "transform-select")]
602 Self::Select { fields } => f.debug_struct("Select").field("fields", fields).finish(),
603 #[cfg(feature = "transform-drop")]
604 Self::Drop { fields } => f.debug_struct("Drop").field("fields", fields).finish(),
605 #[cfg(feature = "transform-set")]
606 Self::Set { values } => f.debug_struct("Set").field("values", values).finish(),
607 #[cfg(feature = "transform-rename-field")]
608 Self::RenameField { fields } => f
609 .debug_struct("RenameField")
610 .field("fields", fields)
611 .finish(),
612 #[cfg(feature = "transform-cast")]
613 Self::Cast { fields, on_error } => f
614 .debug_struct("Cast")
615 .field("fields", fields)
616 .field("on_error", on_error)
617 .finish(),
618 #[cfg(feature = "transform-redact")]
619 Self::Redact { fields, mask } => f
620 .debug_struct("Redact")
621 .field("fields", fields)
622 .field("mask", mask)
623 .finish(),
624 #[cfg(feature = "transform-value-case")]
625 Self::ValueCase { fields, mode } => f
626 .debug_struct("ValueCase")
627 .field("fields", fields)
628 .field("mode", mode)
629 .finish(),
630 #[cfg(feature = "transform-spell-symbols")]
631 Self::SpellSymbols { extra, separator } => f
632 .debug_struct("SpellSymbols")
633 .field("extra", extra)
634 .field("separator", separator)
635 .finish(),
636 #[cfg(feature = "transform-hash")]
637 Self::Hash {
638 fields,
639 algorithm,
640 encoding,
641 salt,
642 into,
643 } => f
644 .debug_struct("Hash")
645 .field("fields", fields)
646 .field("algorithm", algorithm)
647 .field("encoding", encoding)
648 .field("salt", &salt.as_ref().map(|_| "<redacted>"))
650 .field("into", into)
651 .finish(),
652 #[cfg(feature = "transform-json-parse")]
653 Self::JsonParse {
654 fields,
655 on_error,
656 into,
657 } => f
658 .debug_struct("JsonParse")
659 .field("fields", fields)
660 .field("on_error", on_error)
661 .field("into", into)
662 .finish(),
663 #[cfg(feature = "transform-coalesce")]
664 Self::Coalesce {
665 field,
666 default,
667 from,
668 treat_empty_string_as_null,
669 } => f
670 .debug_struct("Coalesce")
671 .field("field", field)
672 .field("default", default)
673 .field("from", from)
674 .field("treat_empty_string_as_null", treat_empty_string_as_null)
675 .finish(),
676 #[cfg(feature = "transform-split-join")]
677 Self::Split {
678 field,
679 delimiter,
680 trim,
681 into,
682 } => f
683 .debug_struct("Split")
684 .field("field", field)
685 .field("delimiter", delimiter)
686 .field("trim", trim)
687 .field("into", into)
688 .finish(),
689 #[cfg(feature = "transform-split-join")]
690 Self::Join {
691 field,
692 delimiter,
693 into,
694 } => f
695 .debug_struct("Join")
696 .field("field", field)
697 .field("delimiter", delimiter)
698 .field("into", into)
699 .finish(),
700 #[cfg(feature = "transform-json-encode")]
701 Self::JsonEncode { fields } => f
702 .debug_struct("JsonEncode")
703 .field("fields", fields)
704 .finish(),
705 #[cfg(feature = "transform-lookup")]
706 Self::Lookup {
707 reference,
708 on_record,
709 on_ref,
710 add,
711 on_missing,
712 } => f
713 .debug_struct("Lookup")
714 .field("reference_rows", &reference.len())
715 .field("on_record", on_record)
716 .field("on_ref", on_ref)
717 .field("add", add)
718 .field("on_missing", on_missing)
719 .finish(),
720 Self::Custom(_) => write!(f, "Custom(<fn>)"),
721 }
722 }
723}
724
725impl Clone for RecordTransform {
728 fn clone(&self) -> Self {
729 match self {
730 #[cfg(feature = "transform-flatten")]
731 Self::Flatten { separator } => Self::Flatten {
732 separator: separator.clone(),
733 },
734 #[cfg(feature = "transform-rename-keys")]
735 Self::RenameKeys {
736 pattern,
737 replacement,
738 } => Self::RenameKeys {
739 pattern: pattern.clone(),
740 replacement: replacement.clone(),
741 },
742 #[cfg(feature = "transform-keys-case")]
743 Self::KeysCase { mode } => Self::KeysCase { mode: *mode },
744 #[cfg(feature = "transform-select")]
745 Self::Select { fields } => Self::Select {
746 fields: fields.clone(),
747 },
748 #[cfg(feature = "transform-drop")]
749 Self::Drop { fields } => Self::Drop {
750 fields: fields.clone(),
751 },
752 #[cfg(feature = "transform-set")]
753 Self::Set { values } => Self::Set {
754 values: values.clone(),
755 },
756 #[cfg(feature = "transform-rename-field")]
757 Self::RenameField { fields } => Self::RenameField {
758 fields: fields.clone(),
759 },
760 #[cfg(feature = "transform-cast")]
761 Self::Cast { fields, on_error } => Self::Cast {
762 fields: fields.clone(),
763 on_error: *on_error,
764 },
765 #[cfg(feature = "transform-redact")]
766 Self::Redact { fields, mask } => Self::Redact {
767 fields: fields.clone(),
768 mask: mask.clone(),
769 },
770 #[cfg(feature = "transform-value-case")]
771 Self::ValueCase { fields, mode } => Self::ValueCase {
772 fields: fields.clone(),
773 mode: *mode,
774 },
775 #[cfg(feature = "transform-spell-symbols")]
776 Self::SpellSymbols { extra, separator } => Self::SpellSymbols {
777 extra: extra.clone(),
778 separator: separator.clone(),
779 },
780 #[cfg(feature = "transform-hash")]
781 Self::Hash {
782 fields,
783 algorithm,
784 encoding,
785 salt,
786 into,
787 } => Self::Hash {
788 fields: fields.clone(),
789 algorithm: *algorithm,
790 encoding: *encoding,
791 salt: salt.clone(),
792 into: into.clone(),
793 },
794 #[cfg(feature = "transform-json-parse")]
795 Self::JsonParse {
796 fields,
797 on_error,
798 into,
799 } => Self::JsonParse {
800 fields: fields.clone(),
801 on_error: *on_error,
802 into: into.clone(),
803 },
804 #[cfg(feature = "transform-coalesce")]
805 Self::Coalesce {
806 field,
807 default,
808 from,
809 treat_empty_string_as_null,
810 } => Self::Coalesce {
811 field: field.clone(),
812 default: default.clone(),
813 from: from.clone(),
814 treat_empty_string_as_null: *treat_empty_string_as_null,
815 },
816 #[cfg(feature = "transform-split-join")]
817 Self::Split {
818 field,
819 delimiter,
820 trim,
821 into,
822 } => Self::Split {
823 field: field.clone(),
824 delimiter: delimiter.clone(),
825 trim: *trim,
826 into: into.clone(),
827 },
828 #[cfg(feature = "transform-split-join")]
829 Self::Join {
830 field,
831 delimiter,
832 into,
833 } => Self::Join {
834 field: field.clone(),
835 delimiter: delimiter.clone(),
836 into: into.clone(),
837 },
838 #[cfg(feature = "transform-json-encode")]
839 Self::JsonEncode { fields } => Self::JsonEncode {
840 fields: fields.clone(),
841 },
842 #[cfg(feature = "transform-lookup")]
843 Self::Lookup {
844 reference,
845 on_record,
846 on_ref,
847 add,
848 on_missing,
849 } => Self::Lookup {
850 reference: reference.clone(),
851 on_record: on_record.clone(),
852 on_ref: on_ref.clone(),
853 add: add.clone(),
854 on_missing: *on_missing,
855 },
856 Self::Custom(f) => Self::Custom(Arc::clone(f)),
857 }
858 }
859}
860
861impl Clone for CompiledTransform {
864 fn clone(&self) -> Self {
865 match self {
866 #[cfg(feature = "transform-flatten")]
867 Self::Flatten { separator } => Self::Flatten {
868 separator: separator.clone(),
869 },
870 #[cfg(feature = "transform-rename-keys")]
871 Self::RenameKeys { re, replacement } => Self::RenameKeys {
872 re: re.clone(),
873 replacement: replacement.clone(),
874 },
875 #[cfg(feature = "transform-keys-case")]
876 Self::KeysCase { mode } => Self::KeysCase { mode: *mode },
877 #[cfg(feature = "transform-select")]
878 Self::Select { fields } => Self::Select {
879 fields: fields.clone(),
880 },
881 #[cfg(feature = "transform-drop")]
882 Self::Drop { fields } => Self::Drop {
883 fields: fields.clone(),
884 },
885 #[cfg(feature = "transform-set")]
886 Self::Set { values } => Self::Set {
887 values: values.clone(),
888 },
889 #[cfg(feature = "transform-rename-field")]
890 Self::RenameField { fields } => Self::RenameField {
891 fields: fields.clone(),
892 },
893 #[cfg(feature = "transform-cast")]
894 Self::Cast { fields, on_error } => Self::Cast {
895 fields: fields.clone(),
896 on_error: *on_error,
897 },
898 #[cfg(feature = "transform-redact")]
899 Self::Redact { fields, mask } => Self::Redact {
900 fields: fields.clone(),
901 mask: mask.clone(),
902 },
903 #[cfg(feature = "transform-value-case")]
904 Self::ValueCase { fields, mode } => Self::ValueCase {
905 fields: fields.clone(),
906 mode: *mode,
907 },
908 #[cfg(feature = "transform-spell-symbols")]
909 Self::SpellSymbols {
910 replacements,
911 separator,
912 } => Self::SpellSymbols {
913 replacements: replacements.clone(),
914 separator: separator.clone(),
915 },
916 #[cfg(feature = "transform-hash")]
917 Self::Hash {
918 fields,
919 algorithm,
920 encoding,
921 salt,
922 into,
923 } => Self::Hash {
924 fields: fields.clone(),
925 algorithm: *algorithm,
926 encoding: *encoding,
927 salt: salt.clone(),
928 into: into.clone(),
929 },
930 #[cfg(feature = "transform-json-parse")]
931 Self::JsonParse {
932 fields,
933 on_error,
934 into,
935 } => Self::JsonParse {
936 fields: fields.clone(),
937 on_error: *on_error,
938 into: into.clone(),
939 },
940 #[cfg(feature = "transform-coalesce")]
941 Self::Coalesce {
942 field,
943 default,
944 from,
945 treat_empty_string_as_null,
946 } => Self::Coalesce {
947 field: field.clone(),
948 default: default.clone(),
949 from: from.clone(),
950 treat_empty_string_as_null: *treat_empty_string_as_null,
951 },
952 #[cfg(feature = "transform-split-join")]
953 Self::Split {
954 field,
955 delimiter,
956 trim,
957 into,
958 } => Self::Split {
959 field: field.clone(),
960 delimiter: delimiter.clone(),
961 trim: *trim,
962 into: into.clone(),
963 },
964 #[cfg(feature = "transform-split-join")]
965 Self::Join {
966 field,
967 delimiter,
968 into,
969 } => Self::Join {
970 field: field.clone(),
971 delimiter: delimiter.clone(),
972 into: into.clone(),
973 },
974 #[cfg(feature = "transform-json-encode")]
975 Self::JsonEncode { fields } => Self::JsonEncode {
976 fields: fields.clone(),
977 },
978 #[cfg(feature = "transform-lookup")]
979 Self::Lookup {
980 index,
981 on_record,
982 add,
983 on_missing,
984 } => Self::Lookup {
985 index: index.clone(),
986 on_record: on_record.clone(),
987 add: add.clone(),
988 on_missing: *on_missing,
989 },
990 Self::Custom(f) => Self::Custom(Arc::clone(f)),
991 }
992 }
993}
994
995impl RecordTransform {
996 pub fn custom<F>(f: F) -> Self
1020 where
1021 F: Fn(Value) -> Value + Send + Sync + 'static,
1022 {
1023 Self::Custom(Arc::new(f))
1024 }
1025}
1026
1027#[non_exhaustive]
1038pub enum CompiledTransform {
1039 #[cfg(feature = "transform-flatten")]
1040 Flatten {
1041 separator: String,
1042 },
1043 #[cfg(feature = "transform-rename-keys")]
1044 RenameKeys {
1045 re: Regex,
1046 replacement: String,
1047 },
1048 #[cfg(feature = "transform-keys-case")]
1049 KeysCase {
1050 mode: KeyCaseMode,
1051 },
1052 #[cfg(feature = "transform-select")]
1053 Select {
1054 fields: Vec<String>,
1055 },
1056 #[cfg(feature = "transform-drop")]
1057 Drop {
1058 fields: Vec<String>,
1059 },
1060 #[cfg(feature = "transform-set")]
1061 Set {
1062 values: Map<String, Value>,
1063 },
1064 #[cfg(feature = "transform-rename-field")]
1065 RenameField {
1066 fields: Vec<(String, String)>,
1069 },
1070 #[cfg(feature = "transform-cast")]
1071 Cast {
1072 fields: HashMap<String, CastType>,
1073 on_error: CastOnError,
1074 },
1075 #[cfg(feature = "transform-redact")]
1076 Redact {
1077 fields: Vec<String>,
1078 mask: Value,
1079 },
1080 #[cfg(feature = "transform-value-case")]
1081 ValueCase {
1082 fields: Vec<String>,
1083 mode: ValueCaseMode,
1084 },
1085 #[cfg(feature = "transform-spell-symbols")]
1086 SpellSymbols {
1087 replacements: Vec<(String, String)>,
1090 separator: String,
1091 },
1092 #[cfg(feature = "transform-hash")]
1093 Hash {
1094 fields: Vec<String>,
1095 algorithm: HashAlgorithm,
1096 encoding: HashEncoding,
1097 salt: Option<String>,
1098 into: Option<String>,
1099 },
1100 #[cfg(feature = "transform-json-parse")]
1101 JsonParse {
1102 fields: Vec<String>,
1103 on_error: JsonParseOnError,
1104 into: Option<String>,
1105 },
1106 #[cfg(feature = "transform-coalesce")]
1107 Coalesce {
1108 field: String,
1109 default: Option<Value>,
1110 from: Vec<String>,
1111 treat_empty_string_as_null: bool,
1112 },
1113 #[cfg(feature = "transform-split-join")]
1114 Split {
1115 field: String,
1116 delimiter: String,
1117 trim: bool,
1118 into: Option<String>,
1119 },
1120 #[cfg(feature = "transform-split-join")]
1121 Join {
1122 field: String,
1123 delimiter: String,
1124 into: Option<String>,
1125 },
1126 #[cfg(feature = "transform-json-encode")]
1127 JsonEncode {
1128 fields: Vec<String>,
1129 },
1130 #[cfg(feature = "transform-lookup")]
1131 Lookup {
1132 index: HashMap<String, Map<String, Value>>,
1135 on_record: String,
1136 add: Vec<(String, String)>,
1137 on_missing: LookupOnMissing,
1138 },
1139 Custom(Arc<dyn Fn(Value) -> Value + Send + Sync>),
1140}
1141
1142pub fn compile(t: &RecordTransform) -> Result<CompiledTransform, FaucetError> {
1146 match t {
1147 #[cfg(feature = "transform-flatten")]
1148 RecordTransform::Flatten { separator } => Ok(CompiledTransform::Flatten {
1149 separator: separator.clone(),
1150 }),
1151 #[cfg(feature = "transform-rename-keys")]
1152 RecordTransform::RenameKeys {
1153 pattern,
1154 replacement,
1155 } => {
1156 let re = Regex::new(pattern)
1157 .map_err(|e| FaucetError::Transform(format!("invalid regex '{pattern}': {e}")))?;
1158 Ok(CompiledTransform::RenameKeys {
1159 re,
1160 replacement: replacement.clone(),
1161 })
1162 }
1163 #[cfg(feature = "transform-keys-case")]
1164 RecordTransform::KeysCase { mode } => Ok(CompiledTransform::KeysCase { mode: *mode }),
1165 #[cfg(feature = "transform-select")]
1166 RecordTransform::Select { fields } => Ok(CompiledTransform::Select {
1167 fields: fields.clone(),
1168 }),
1169 #[cfg(feature = "transform-drop")]
1170 RecordTransform::Drop { fields } => Ok(CompiledTransform::Drop {
1171 fields: fields.clone(),
1172 }),
1173 #[cfg(feature = "transform-set")]
1174 RecordTransform::Set { values } => Ok(CompiledTransform::Set {
1175 values: values.clone(),
1176 }),
1177 #[cfg(feature = "transform-rename-field")]
1178 RecordTransform::RenameField { fields } => {
1179 let mut fields: Vec<(String, String)> =
1184 fields.iter().map(|(f, t)| (f.clone(), t.clone())).collect();
1185 fields.sort();
1186 Ok(CompiledTransform::RenameField { fields })
1187 }
1188 #[cfg(feature = "transform-cast")]
1189 RecordTransform::Cast { fields, on_error } => Ok(CompiledTransform::Cast {
1190 fields: fields.clone(),
1191 on_error: *on_error,
1192 }),
1193 #[cfg(feature = "transform-redact")]
1194 RecordTransform::Redact { fields, mask } => Ok(CompiledTransform::Redact {
1195 fields: fields.clone(),
1196 mask: mask.clone(),
1197 }),
1198 #[cfg(feature = "transform-value-case")]
1199 RecordTransform::ValueCase { fields, mode } => Ok(CompiledTransform::ValueCase {
1200 fields: fields.clone(),
1201 mode: *mode,
1202 }),
1203 #[cfg(feature = "transform-spell-symbols")]
1204 RecordTransform::SpellSymbols { extra, separator } => {
1205 let mut merged = default_symbol_map();
1208 for (k, v) in extra {
1209 merged.insert(k.clone(), v.clone());
1210 }
1211 let mut replacements: Vec<(String, String)> = merged.into_iter().collect();
1212 replacements.sort_by_key(|b| std::cmp::Reverse(b.0.len()));
1213 Ok(CompiledTransform::SpellSymbols {
1214 replacements,
1215 separator: separator.clone(),
1216 })
1217 }
1218 #[cfg(feature = "transform-hash")]
1219 RecordTransform::Hash {
1220 fields,
1221 algorithm,
1222 encoding,
1223 salt,
1224 into,
1225 } => {
1226 if fields.is_empty() {
1227 return Err(FaucetError::Config(
1228 "hash: `fields` must not be empty".to_owned(),
1229 ));
1230 }
1231 if into.is_some() && fields.len() != 1 {
1232 return Err(FaucetError::Config(
1233 "hash: `into` is only valid with exactly one field".to_owned(),
1234 ));
1235 }
1236 Ok(CompiledTransform::Hash {
1237 fields: fields.clone(),
1238 algorithm: *algorithm,
1239 encoding: *encoding,
1240 salt: salt.clone(),
1241 into: into.clone(),
1242 })
1243 }
1244 #[cfg(feature = "transform-json-parse")]
1245 RecordTransform::JsonParse {
1246 fields,
1247 on_error,
1248 into,
1249 } => {
1250 if fields.is_empty() {
1251 return Err(FaucetError::Config(
1252 "json_parse: `fields` must not be empty".to_owned(),
1253 ));
1254 }
1255 if into.is_some() && fields.len() != 1 {
1256 return Err(FaucetError::Config(
1257 "json_parse: `into` is only valid with exactly one field".to_owned(),
1258 ));
1259 }
1260 Ok(CompiledTransform::JsonParse {
1261 fields: fields.clone(),
1262 on_error: *on_error,
1263 into: into.clone(),
1264 })
1265 }
1266 #[cfg(feature = "transform-coalesce")]
1267 RecordTransform::Coalesce {
1268 field,
1269 default,
1270 from,
1271 treat_empty_string_as_null,
1272 } => {
1273 match (default.is_some(), from.is_empty()) {
1274 (true, true) => {}
1276 (false, false) => {}
1278 (true, false) => {
1279 return Err(FaucetError::Config(
1280 "coalesce: set exactly one of `default` or `from`, not both".to_owned(),
1281 ));
1282 }
1283 (false, true) => {
1284 return Err(FaucetError::Config(
1285 "coalesce: set exactly one of `default` or `from`".to_owned(),
1286 ));
1287 }
1288 }
1289 Ok(CompiledTransform::Coalesce {
1290 field: field.clone(),
1291 default: default.clone(),
1292 from: from.clone(),
1293 treat_empty_string_as_null: *treat_empty_string_as_null,
1294 })
1295 }
1296 #[cfg(feature = "transform-split-join")]
1297 RecordTransform::Split {
1298 field,
1299 delimiter,
1300 trim,
1301 into,
1302 } => Ok(CompiledTransform::Split {
1303 field: field.clone(),
1304 delimiter: delimiter.clone(),
1305 trim: *trim,
1306 into: into.clone(),
1307 }),
1308 #[cfg(feature = "transform-split-join")]
1309 RecordTransform::Join {
1310 field,
1311 delimiter,
1312 into,
1313 } => Ok(CompiledTransform::Join {
1314 field: field.clone(),
1315 delimiter: delimiter.clone(),
1316 into: into.clone(),
1317 }),
1318 #[cfg(feature = "transform-json-encode")]
1319 RecordTransform::JsonEncode { fields } => Ok(CompiledTransform::JsonEncode {
1320 fields: fields.clone(),
1321 }),
1322 #[cfg(feature = "transform-lookup")]
1323 RecordTransform::Lookup {
1324 reference,
1325 on_record,
1326 on_ref,
1327 add,
1328 on_missing,
1329 } => {
1330 if on_record.trim().is_empty() || on_ref.trim().is_empty() {
1331 return Err(FaucetError::Transform(
1332 "lookup: `on_record` and `on_ref` must be non-empty".into(),
1333 ));
1334 }
1335 if add.is_empty() {
1336 return Err(FaucetError::Transform(
1337 "lookup: `add` must name at least one output column".into(),
1338 ));
1339 }
1340 let mut index = HashMap::with_capacity(reference.len());
1343 for row in reference {
1344 if let Some(k) = row.get(on_ref).map(value_to_key) {
1345 index.entry(k).or_insert_with(|| row.clone());
1346 }
1347 }
1348 Ok(CompiledTransform::Lookup {
1349 index,
1350 on_record: on_record.clone(),
1351 add: add.clone(),
1352 on_missing: *on_missing,
1353 })
1354 }
1355 RecordTransform::Custom(f) => Ok(CompiledTransform::Custom(Arc::clone(f))),
1356 }
1357}
1358
1359#[cfg(feature = "transform-lookup")]
1362fn value_to_key(v: &Value) -> String {
1363 match v {
1364 Value::String(s) => s.clone(),
1365 Value::Null => String::new(),
1366 other => other.to_string(),
1367 }
1368}
1369
1370pub fn apply_all(record: Value, transforms: &[CompiledTransform]) -> Result<Value, FaucetError> {
1376 let mut acc = record;
1377 for t in transforms {
1378 acc = apply_one(acc, t)?;
1379 }
1380 Ok(acc)
1381}
1382
1383fn apply_one(value: Value, t: &CompiledTransform) -> Result<Value, FaucetError> {
1384 match t {
1385 #[cfg(feature = "transform-flatten")]
1386 CompiledTransform::Flatten { separator } => flatten(value, separator),
1387 #[cfg(feature = "transform-rename-keys")]
1388 CompiledTransform::RenameKeys { re, replacement } => {
1389 Ok(rename_keys(value, re, replacement))
1390 }
1391 #[cfg(feature = "transform-keys-case")]
1392 CompiledTransform::KeysCase { mode } => keys_case(value, *mode),
1393 #[cfg(feature = "transform-select")]
1394 CompiledTransform::Select { fields } => Ok(select_fields(value, fields)),
1395 #[cfg(feature = "transform-drop")]
1396 CompiledTransform::Drop { fields } => Ok(drop_fields(value, fields)),
1397 #[cfg(feature = "transform-set")]
1398 CompiledTransform::Set { values } => Ok(set_fields(value, values)),
1399 #[cfg(feature = "transform-rename-field")]
1400 CompiledTransform::RenameField { fields } => rename_field(value, fields),
1401 #[cfg(feature = "transform-cast")]
1402 CompiledTransform::Cast { fields, on_error } => cast_fields(value, fields, *on_error),
1403 #[cfg(feature = "transform-redact")]
1404 CompiledTransform::Redact { fields, mask } => Ok(redact_fields(value, fields, mask)),
1405 #[cfg(feature = "transform-value-case")]
1406 CompiledTransform::ValueCase { fields, mode } => Ok(value_case(value, fields, *mode)),
1407 #[cfg(feature = "transform-spell-symbols")]
1408 CompiledTransform::SpellSymbols {
1409 replacements,
1410 separator,
1411 } => spell_symbols(value, replacements, separator),
1412 #[cfg(feature = "transform-hash")]
1413 CompiledTransform::Hash {
1414 fields,
1415 algorithm,
1416 encoding,
1417 salt,
1418 into,
1419 } => Ok(hash_fields(
1420 value,
1421 fields,
1422 *algorithm,
1423 *encoding,
1424 salt.as_deref(),
1425 into.as_deref(),
1426 )),
1427 #[cfg(feature = "transform-json-parse")]
1428 CompiledTransform::JsonParse {
1429 fields,
1430 on_error,
1431 into,
1432 } => json_parse_fields(value, fields, *on_error, into.as_deref()),
1433 #[cfg(feature = "transform-coalesce")]
1434 CompiledTransform::Coalesce {
1435 field,
1436 default,
1437 from,
1438 treat_empty_string_as_null,
1439 } => Ok(coalesce_field(
1440 value,
1441 field,
1442 default.as_ref(),
1443 from,
1444 *treat_empty_string_as_null,
1445 )),
1446 #[cfg(feature = "transform-split-join")]
1447 CompiledTransform::Split {
1448 field,
1449 delimiter,
1450 trim,
1451 into,
1452 } => Ok(split_field(value, field, delimiter, *trim, into.as_deref())),
1453 #[cfg(feature = "transform-split-join")]
1454 CompiledTransform::Join {
1455 field,
1456 delimiter,
1457 into,
1458 } => Ok(join_field(value, field, delimiter, into.as_deref())),
1459 #[cfg(feature = "transform-json-encode")]
1460 CompiledTransform::JsonEncode { fields } => Ok(json_encode_fields(value, fields)),
1461 #[cfg(feature = "transform-lookup")]
1462 CompiledTransform::Lookup {
1463 index,
1464 on_record,
1465 add,
1466 on_missing,
1467 } => lookup_field(value, index, on_record, add, *on_missing),
1468 CompiledTransform::Custom(f) => Ok(f(value)),
1469 }
1470}
1471
1472#[cfg(feature = "transform-flatten")]
1475fn flatten(value: Value, separator: &str) -> Result<Value, FaucetError> {
1476 match value {
1477 Value::Object(_) => {
1478 let mut out = Map::new();
1479 flatten_into(value, "", separator, &mut out)?;
1480 Ok(Value::Object(out))
1481 }
1482 other => Ok(other),
1483 }
1484}
1485
1486#[cfg(feature = "transform-flatten")]
1487fn flatten_into(
1488 value: Value,
1489 prefix: &str,
1490 separator: &str,
1491 out: &mut Map<String, Value>,
1492) -> Result<(), FaucetError> {
1493 match value {
1494 Value::Object(map) => {
1495 for (k, v) in map {
1496 let key = if prefix.is_empty() {
1497 k
1498 } else {
1499 format!("{prefix}{separator}{k}")
1500 };
1501 flatten_into(v, &key, separator, out)?;
1502 }
1503 }
1504 other => {
1505 if out.contains_key(prefix) {
1509 return Err(FaucetError::Transform(format!(
1510 "flatten produced a duplicate key '{prefix}'; two distinct fields collapse \
1511 to the same flattened key (separator '{separator}')"
1512 )));
1513 }
1514 out.insert(prefix.to_string(), other);
1515 }
1516 }
1517 Ok(())
1518}
1519
1520#[cfg(feature = "transform-rename-keys")]
1523fn rename_keys(value: Value, re: &Regex, replacement: &str) -> Value {
1524 match value {
1525 Value::Object(map) => {
1526 let new_map: Map<String, Value> = map
1527 .into_iter()
1528 .map(|(k, v)| {
1529 let new_k = re.replace_all(&k, replacement).into_owned();
1530 (new_k, rename_keys(v, re, replacement))
1531 })
1532 .collect();
1533 Value::Object(new_map)
1534 }
1535 Value::Array(arr) => Value::Array(
1536 arr.into_iter()
1537 .map(|v| rename_keys(v, re, replacement))
1538 .collect(),
1539 ),
1540 other => other,
1541 }
1542}
1543
1544#[cfg(feature = "transform-keys-case")]
1548fn keys_case(value: Value, mode: KeyCaseMode) -> Result<Value, FaucetError> {
1549 match value {
1550 Value::Object(map) => {
1551 let mut new_map = Map::with_capacity(map.len());
1552 for (k, v) in map {
1553 let tokens = tokenize_key(&k);
1554 let recased = if tokens.is_empty() {
1555 k
1558 } else {
1559 apply_key_case(tokens, mode)
1560 };
1561 let new_v = keys_case(v, mode)?;
1562 if new_map.contains_key(&recased) {
1563 return Err(FaucetError::Transform(format!(
1564 "keys_case produced a duplicate key '{recased}'; two distinct keys \
1565 re-case to the same name under mode {mode:?}"
1566 )));
1567 }
1568 new_map.insert(recased, new_v);
1569 }
1570 Ok(Value::Object(new_map))
1571 }
1572 Value::Array(arr) => {
1573 let mut out = Vec::with_capacity(arr.len());
1574 for v in arr {
1575 out.push(keys_case(v, mode)?);
1576 }
1577 Ok(Value::Array(out))
1578 }
1579 other => Ok(other),
1580 }
1581}
1582
1583#[cfg(feature = "transform-keys-case")]
1589fn tokenize_key(key: &str) -> Vec<String> {
1590 let mut tokens: Vec<String> = Vec::new();
1591 let mut current = String::new();
1592 let mut prev_was_lower = false;
1593 for ch in key.chars() {
1594 if ch.is_alphanumeric() {
1595 if prev_was_lower && ch.is_uppercase() && !current.is_empty() {
1596 tokens.push(std::mem::take(&mut current));
1597 }
1598 current.push(ch);
1599 prev_was_lower = ch.is_lowercase();
1600 } else {
1601 if !current.is_empty() {
1602 tokens.push(std::mem::take(&mut current));
1603 }
1604 prev_was_lower = false;
1605 }
1606 }
1607 if !current.is_empty() {
1608 tokens.push(current);
1609 }
1610 tokens
1611}
1612
1613#[cfg(feature = "transform-keys-case")]
1614fn apply_key_case(tokens: Vec<String>, mode: KeyCaseMode) -> String {
1615 match mode {
1616 KeyCaseMode::Snake => tokens
1617 .iter()
1618 .map(|t| t.to_lowercase())
1619 .collect::<Vec<_>>()
1620 .join("_"),
1621 KeyCaseMode::ScreamingSnake => tokens
1622 .iter()
1623 .map(|t| t.to_uppercase())
1624 .collect::<Vec<_>>()
1625 .join("_"),
1626 KeyCaseMode::Kebab => tokens
1627 .iter()
1628 .map(|t| t.to_lowercase())
1629 .collect::<Vec<_>>()
1630 .join("-"),
1631 KeyCaseMode::Dot => tokens
1632 .iter()
1633 .map(|t| t.to_lowercase())
1634 .collect::<Vec<_>>()
1635 .join("."),
1636 KeyCaseMode::Camel => {
1637 let mut iter = tokens.into_iter();
1638 match iter.next() {
1639 None => String::new(),
1640 Some(first) => {
1641 let mut out = first.to_lowercase();
1642 for t in iter {
1643 out.push_str(&capitalize_token(&t));
1644 }
1645 out
1646 }
1647 }
1648 }
1649 KeyCaseMode::Pascal => tokens
1650 .into_iter()
1651 .map(|t| capitalize_token(&t))
1652 .collect::<String>(),
1653 }
1654}
1655
1656#[cfg(feature = "transform-keys-case")]
1658fn capitalize_token(s: &str) -> String {
1659 let lower = s.to_lowercase();
1660 let mut chars = lower.chars();
1661 match chars.next() {
1662 None => String::new(),
1663 Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
1664 }
1665}
1666
1667#[cfg(feature = "transform-select")]
1670fn select_fields(value: Value, fields: &[String]) -> Value {
1671 match value {
1672 Value::Object(map) => {
1673 let mut out = Map::with_capacity(fields.len().min(map.len()));
1674 for f in fields {
1676 if let Some(v) = map.get(f) {
1677 out.insert(f.clone(), v.clone());
1678 }
1679 }
1680 Value::Object(out)
1681 }
1682 other => other,
1683 }
1684}
1685
1686#[cfg(feature = "transform-drop")]
1689fn drop_fields(value: Value, fields: &[String]) -> Value {
1690 match value {
1691 Value::Object(mut map) => {
1692 for f in fields {
1693 map.remove(f);
1694 }
1695 Value::Object(map)
1696 }
1697 other => other,
1698 }
1699}
1700
1701#[cfg(feature = "transform-set")]
1704fn set_fields(value: Value, values: &Map<String, Value>) -> Value {
1705 match value {
1706 Value::Object(mut map) => {
1707 for (k, v) in values {
1708 map.insert(k.clone(), v.clone());
1709 }
1710 Value::Object(map)
1711 }
1712 other => other,
1713 }
1714}
1715
1716#[cfg(feature = "transform-rename-field")]
1719fn rename_field(value: Value, fields: &[(String, String)]) -> Result<Value, FaucetError> {
1720 match value {
1721 Value::Object(mut map) => {
1722 let renames: Vec<(&str, &str)> = fields
1728 .iter()
1729 .filter(|(from, to)| from != to && map.contains_key(from))
1730 .map(|(from, to)| (from.as_str(), to.as_str()))
1731 .collect();
1732 let sources: std::collections::HashSet<&str> =
1733 renames.iter().map(|(from, _)| *from).collect();
1734
1735 let mut seen_targets: std::collections::HashSet<&str> =
1737 std::collections::HashSet::new();
1738 for (from, to) in &renames {
1739 if !seen_targets.insert(to) {
1740 return Err(FaucetError::Transform(format!(
1741 "rename_field: two fields rename to the same target key '{to}'"
1742 )));
1743 }
1744 if map.contains_key(*to) && !sources.contains(to) {
1748 return Err(FaucetError::Transform(format!(
1749 "rename_field: target key '{to}' already exists on the record \
1750 (renaming from '{from}')"
1751 )));
1752 }
1753 }
1754
1755 let staged: Vec<(String, Value)> = renames
1756 .iter()
1757 .map(|(from, to)| {
1758 let v = map.remove(*from).expect("source presence checked above");
1759 (to.to_string(), v)
1760 })
1761 .collect();
1762 for (to, v) in staged {
1763 map.insert(to, v);
1764 }
1765 Ok(Value::Object(map))
1766 }
1767 other => Ok(other),
1768 }
1769}
1770
1771#[cfg(feature = "transform-cast")]
1774fn cast_fields(
1775 value: Value,
1776 fields: &HashMap<String, CastType>,
1777 on_error: CastOnError,
1778) -> Result<Value, FaucetError> {
1779 match value {
1780 Value::Object(mut map) => {
1781 for (field, target) in fields {
1782 let Some(current) = map.get(field) else {
1783 continue;
1784 };
1785 match cast_value(current, *target) {
1786 Ok(new_val) => {
1787 map.insert(field.clone(), new_val);
1788 }
1789 Err(msg) => match on_error {
1790 CastOnError::Error => {
1791 return Err(FaucetError::Transform(format!(
1792 "cast: field '{field}' to {target:?} failed: {msg}"
1793 )));
1794 }
1795 CastOnError::Null => {
1796 map.insert(field.clone(), Value::Null);
1797 }
1798 CastOnError::Skip => { }
1799 },
1800 }
1801 }
1802 Ok(Value::Object(map))
1803 }
1804 other => Ok(other),
1805 }
1806}
1807
1808#[cfg(feature = "transform-cast")]
1811fn cast_value(v: &Value, target: CastType) -> Result<Value, String> {
1812 match target {
1813 CastType::Int => match v {
1814 Value::Number(n) => {
1815 if let Some(i) = n.as_i64() {
1816 return Ok(Value::Number(i.into()));
1817 }
1818 match n.as_f64() {
1825 Some(f)
1826 if f.fract() == 0.0 && (-(2f64.powi(63))..2f64.powi(63)).contains(&f) =>
1827 {
1828 Ok(Value::Number((f as i64).into()))
1829 }
1830 Some(f) => Err(format!(
1831 "float '{f}' is not a whole number representable as i64"
1832 )),
1833 None => Err(format!("number '{n}' is not representable as i64")),
1834 }
1835 }
1836 Value::String(s) => s
1837 .trim()
1838 .parse::<i64>()
1839 .map(|i| Value::Number(i.into()))
1840 .map_err(|e| format!("'{s}' is not an integer: {e}")),
1841 Value::Bool(b) => Ok(Value::Number(i64::from(*b).into())),
1842 Value::Null => Err("null cannot be cast to int".to_owned()),
1843 Value::Array(_) | Value::Object(_) => {
1844 Err("composite values cannot be cast to int".to_owned())
1845 }
1846 },
1847 CastType::Float => match v {
1848 Value::Number(n) => n
1849 .as_f64()
1850 .and_then(|f| serde_json::Number::from_f64(f).map(Value::Number))
1851 .ok_or_else(|| format!("number '{n}' is not representable as f64")),
1852 Value::String(s) => s
1853 .trim()
1854 .parse::<f64>()
1855 .ok()
1856 .and_then(|f| serde_json::Number::from_f64(f).map(Value::Number))
1857 .ok_or_else(|| format!("'{s}' is not a float")),
1858 Value::Bool(b) => serde_json::Number::from_f64(if *b { 1.0 } else { 0.0 })
1859 .map(Value::Number)
1860 .ok_or_else(|| "could not encode bool as f64".to_owned()),
1861 Value::Null => Err("null cannot be cast to float".to_owned()),
1862 Value::Array(_) | Value::Object(_) => {
1863 Err("composite values cannot be cast to float".to_owned())
1864 }
1865 },
1866 CastType::Bool => match v {
1867 Value::Bool(b) => Ok(Value::Bool(*b)),
1868 Value::Number(n) => {
1869 if let Some(i) = n.as_i64() {
1870 match i {
1871 0 => Ok(Value::Bool(false)),
1872 1 => Ok(Value::Bool(true)),
1873 _ => Err(format!("integer {i} is not 0 or 1")),
1874 }
1875 } else {
1876 Err(format!("number '{n}' is not 0 or 1"))
1877 }
1878 }
1879 Value::String(s) => match s.trim().to_ascii_lowercase().as_str() {
1880 "true" | "1" | "yes" | "y" => Ok(Value::Bool(true)),
1881 "false" | "0" | "no" | "n" => Ok(Value::Bool(false)),
1882 other => Err(format!("'{other}' is not a recognised boolean")),
1883 },
1884 Value::Null => Err("null cannot be cast to bool".to_owned()),
1885 Value::Array(_) | Value::Object(_) => {
1886 Err("composite values cannot be cast to bool".to_owned())
1887 }
1888 },
1889 CastType::String => match v {
1890 Value::String(s) => Ok(Value::String(s.clone())),
1891 Value::Number(n) => Ok(Value::String(n.to_string())),
1892 Value::Bool(b) => Ok(Value::String(b.to_string())),
1893 Value::Null => Err("null cannot be cast to string".to_owned()),
1894 Value::Array(_) | Value::Object(_) => {
1895 Err("composite values cannot be cast to string".to_owned())
1896 }
1897 },
1898 CastType::Timestamp => match v {
1899 Value::String(s) => chrono::DateTime::parse_from_rfc3339(s)
1900 .map(|dt| Value::String(dt.to_rfc3339_opts(chrono::SecondsFormat::AutoSi, true)))
1901 .map_err(|e| format!("'{s}' is not a valid RFC 3339 timestamp: {e}")),
1902 other => Err(format!(
1903 "cannot cast {} to timestamp (expected RFC 3339 string)",
1904 value_type_name(other)
1905 )),
1906 },
1907 }
1908}
1909
1910#[cfg(feature = "transform-cast")]
1911fn value_type_name(v: &Value) -> &'static str {
1912 match v {
1913 Value::Null => "null",
1914 Value::Bool(_) => "bool",
1915 Value::Number(_) => "number",
1916 Value::String(_) => "string",
1917 Value::Array(_) => "array",
1918 Value::Object(_) => "object",
1919 }
1920}
1921
1922#[cfg(feature = "transform-redact")]
1925fn redact_fields(value: Value, fields: &[String], mask: &Value) -> Value {
1926 match value {
1927 Value::Object(mut map) => {
1928 for f in fields {
1929 if map.contains_key(f) {
1930 map.insert(f.clone(), mask.clone());
1931 }
1932 }
1933 Value::Object(map)
1934 }
1935 other => other,
1936 }
1937}
1938
1939#[cfg(feature = "transform-value-case")]
1942fn value_case(value: Value, fields: &[String], mode: ValueCaseMode) -> Value {
1943 match value {
1944 Value::Object(mut map) => {
1945 for f in fields {
1946 if let Some(Value::String(s)) = map.get(f) {
1947 let new_s = match mode {
1948 ValueCaseMode::Lower => s.to_lowercase(),
1949 ValueCaseMode::Upper => s.to_uppercase(),
1950 ValueCaseMode::Trim => s.trim().to_owned(),
1951 ValueCaseMode::Title => title_case(s),
1952 ValueCaseMode::Capitalize => capitalize_str(s),
1953 };
1954 map.insert(f.clone(), Value::String(new_s));
1955 }
1956 }
1957 Value::Object(map)
1958 }
1959 other => other,
1960 }
1961}
1962
1963#[cfg(feature = "transform-value-case")]
1968fn title_case(s: &str) -> String {
1969 let mut out = String::with_capacity(s.len());
1970 let mut at_word_start = true;
1971 for ch in s.chars() {
1972 if ch.is_whitespace() {
1973 at_word_start = true;
1974 out.push(ch);
1975 } else if at_word_start {
1976 out.extend(ch.to_uppercase());
1977 at_word_start = false;
1978 } else {
1979 out.extend(ch.to_lowercase());
1980 }
1981 }
1982 out
1983}
1984
1985#[cfg(feature = "transform-value-case")]
1988fn capitalize_str(s: &str) -> String {
1989 let mut chars = s.chars();
1990 match chars.next() {
1991 None => String::new(),
1992 Some(first) => {
1993 let mut out: String = first.to_uppercase().collect();
1994 out.push_str(&chars.as_str().to_lowercase());
1995 out
1996 }
1997 }
1998}
1999
2000#[cfg(feature = "transform-spell-symbols")]
2011pub fn default_symbol_map() -> HashMap<String, String> {
2012 let pairs: &[(&str, &str)] = &[
2013 ("%", "percent"),
2014 ("#", "number"),
2015 ("$", "dollar"),
2016 ("&", "and"),
2017 ("@", "at"),
2018 ("+", "plus"),
2019 ("*", "star"),
2020 ("=", "equals"),
2021 ("<", "lt"),
2022 (">", "gt"),
2023 ("/", "slash"),
2024 ("\\", "backslash"),
2025 ("|", "pipe"),
2026 ("^", "caret"),
2027 ("~", "tilde"),
2028 ];
2029 pairs
2030 .iter()
2031 .map(|(k, v)| ((*k).to_owned(), (*v).to_owned()))
2032 .collect()
2033}
2034
2035#[cfg(feature = "transform-spell-symbols")]
2036fn spell_symbols(
2037 value: Value,
2038 replacements: &[(String, String)],
2039 separator: &str,
2040) -> Result<Value, FaucetError> {
2041 match value {
2042 Value::Object(map) => {
2043 let mut new_map = Map::with_capacity(map.len());
2044 for (k, v) in map {
2045 let new_k = spell_symbols_in_key(&k, replacements, separator);
2046 let new_v = spell_symbols(v, replacements, separator)?;
2047 if new_map.contains_key(&new_k) {
2051 return Err(FaucetError::Transform(format!(
2052 "spell_symbols produced a duplicate key '{new_k}'; two distinct keys \
2053 expand to the same name"
2054 )));
2055 }
2056 new_map.insert(new_k, new_v);
2057 }
2058 Ok(Value::Object(new_map))
2059 }
2060 Value::Array(arr) => {
2061 let mut out = Vec::with_capacity(arr.len());
2062 for v in arr {
2063 out.push(spell_symbols(v, replacements, separator)?);
2064 }
2065 Ok(Value::Array(out))
2066 }
2067 other => Ok(other),
2068 }
2069}
2070
2071#[cfg(feature = "transform-spell-symbols")]
2075fn spell_symbols_in_key(key: &str, replacements: &[(String, String)], separator: &str) -> String {
2076 let bytes = key.as_bytes();
2080 let mut out = String::with_capacity(key.len());
2081 let mut i = 0;
2082 while i < bytes.len() {
2083 let mut matched = false;
2084 for (from, to) in replacements {
2085 let f = from.as_bytes();
2086 if !f.is_empty() && bytes[i..].starts_with(f) {
2087 out.push_str(separator);
2088 out.push_str(to);
2089 out.push_str(separator);
2090 i += f.len();
2091 matched = true;
2092 break;
2093 }
2094 }
2095 if !matched {
2096 let ch = key[i..]
2099 .chars()
2100 .next()
2101 .expect("non-empty slice yields at least one char");
2102 out.push(ch);
2103 i += ch.len_utf8();
2104 }
2105 }
2106 out
2107}
2108
2109#[cfg(feature = "transform-hash")]
2112fn hash_fields(
2113 value: Value,
2114 fields: &[String],
2115 algorithm: HashAlgorithm,
2116 encoding: HashEncoding,
2117 salt: Option<&str>,
2118 into: Option<&str>,
2119) -> Value {
2120 match value {
2121 Value::Object(mut map) => {
2122 for field in fields {
2123 let Some(current) = map.get(field) else {
2124 continue;
2125 };
2126 if current.is_null() {
2134 continue;
2135 }
2136 let input = match current {
2139 Value::String(s) => s.clone(),
2140 other => other.to_string(),
2141 };
2142 let digest = hash_string(&input, algorithm, encoding, salt);
2143 let target = into.unwrap_or(field.as_str());
2144 map.insert(target.to_owned(), Value::String(digest));
2145 }
2146 Value::Object(map)
2147 }
2148 other => other,
2149 }
2150}
2151
2152#[cfg(feature = "transform-hash")]
2153fn hash_string(
2154 input: &str,
2155 algorithm: HashAlgorithm,
2156 encoding: HashEncoding,
2157 salt: Option<&str>,
2158) -> String {
2159 let mut bytes: Vec<u8> = Vec::with_capacity(salt.map_or(0, str::len) + input.len());
2161 if let Some(s) = salt {
2162 bytes.extend_from_slice(s.as_bytes());
2163 }
2164 bytes.extend_from_slice(input.as_bytes());
2165 let digest: Vec<u8> = match algorithm {
2166 HashAlgorithm::Sha256 => {
2167 use sha2::{Digest, Sha256};
2168 let mut h = Sha256::new();
2169 h.update(&bytes);
2170 h.finalize().to_vec()
2171 }
2172 HashAlgorithm::Blake3 => blake3::hash(&bytes).as_bytes().to_vec(),
2173 };
2174 match encoding {
2175 HashEncoding::Hex => hex_encode(&digest),
2176 HashEncoding::Base64 => {
2177 use base64::Engine;
2178 base64::engine::general_purpose::STANDARD.encode(&digest)
2179 }
2180 }
2181}
2182
2183#[cfg(feature = "transform-hash")]
2184fn hex_encode(bytes: &[u8]) -> String {
2185 const HEX: &[u8; 16] = b"0123456789abcdef";
2186 let mut s = String::with_capacity(bytes.len() * 2);
2187 for &b in bytes {
2188 s.push(HEX[(b >> 4) as usize] as char);
2189 s.push(HEX[(b & 0x0f) as usize] as char);
2190 }
2191 s
2192}
2193
2194#[cfg(feature = "transform-json-encode")]
2199fn json_encode_fields(mut value: Value, fields: &[String]) -> Value {
2200 if let Value::Object(map) = &mut value {
2201 for f in fields {
2202 if let Some(v) = map.get_mut(f)
2203 && matches!(v, Value::Object(_) | Value::Array(_))
2204 {
2205 let s = serde_json::to_string(v).unwrap_or_else(|_| "null".to_string());
2206 *v = Value::String(s);
2207 }
2208 }
2209 }
2210 value
2211}
2212
2213#[cfg(feature = "transform-lookup")]
2215fn lookup_field(
2216 mut value: Value,
2217 index: &HashMap<String, Map<String, Value>>,
2218 on_record: &str,
2219 add: &[(String, String)],
2220 on_missing: LookupOnMissing,
2221) -> Result<Value, FaucetError> {
2222 let Value::Object(map) = &mut value else {
2223 return Ok(value);
2224 };
2225 let key = map.get(on_record).map(value_to_key);
2226 let matched = key.as_deref().and_then(|k| index.get(k));
2227 match matched {
2228 Some(row) => {
2229 for (out, src) in add {
2230 let v = row.get(src).cloned().unwrap_or(Value::Null);
2231 map.insert(out.clone(), v);
2232 }
2233 }
2234 None => match on_missing {
2235 LookupOnMissing::Null => {
2236 for (out, _) in add {
2237 map.insert(out.clone(), Value::Null);
2238 }
2239 }
2240 LookupOnMissing::Keep => {}
2241 LookupOnMissing::Error => {
2242 return Err(FaucetError::Transform(format!(
2243 "lookup: no reference row for {on_record}={:?}",
2244 key.unwrap_or_default()
2245 )));
2246 }
2247 },
2248 }
2249 Ok(value)
2250}
2251
2252#[cfg(feature = "transform-json-parse")]
2255fn json_parse_fields(
2256 value: Value,
2257 fields: &[String],
2258 on_error: JsonParseOnError,
2259 into: Option<&str>,
2260) -> Result<Value, FaucetError> {
2261 match value {
2262 Value::Object(mut map) => {
2263 for field in fields {
2264 let Some(Value::String(s)) = map.get(field) else {
2267 continue;
2268 };
2269 let s = s.clone();
2270 match serde_json::from_str::<Value>(&s) {
2271 Ok(parsed) => {
2272 let target = into.unwrap_or(field.as_str());
2273 map.insert(target.to_owned(), parsed);
2274 }
2275 Err(e) => match on_error {
2276 JsonParseOnError::Keep => { }
2277 JsonParseOnError::Null => {
2278 let target = into.unwrap_or(field.as_str());
2279 map.insert(target.to_owned(), Value::Null);
2280 }
2281 JsonParseOnError::Error => {
2282 return Err(FaucetError::Transform(format!(
2283 "json_parse: field '{field}' is not valid JSON: {e}"
2284 )));
2285 }
2286 },
2287 }
2288 }
2289 Ok(Value::Object(map))
2290 }
2291 other => Ok(other),
2292 }
2293}
2294
2295#[cfg(feature = "transform-coalesce")]
2298fn coalesce_field(
2299 value: Value,
2300 field: &str,
2301 default: Option<&Value>,
2302 from: &[String],
2303 treat_empty_string_as_null: bool,
2304) -> Value {
2305 match value {
2306 Value::Object(mut map) => {
2307 if is_nullish(map.get(field), treat_empty_string_as_null) {
2308 let replacement: Option<Value> = match default {
2309 Some(d) => Some(d.clone()),
2310 None => from.iter().find_map(|k| {
2311 let v = map.get(k);
2312 if is_nullish(v, treat_empty_string_as_null) {
2313 None
2314 } else {
2315 v.cloned()
2316 }
2317 }),
2318 };
2319 if let Some(v) = replacement {
2320 map.insert(field.to_owned(), v);
2321 }
2322 }
2323 Value::Object(map)
2324 }
2325 other => other,
2326 }
2327}
2328
2329#[cfg(feature = "transform-coalesce")]
2332fn is_nullish(v: Option<&Value>, treat_empty_string_as_null: bool) -> bool {
2333 match v {
2334 None | Some(Value::Null) => true,
2335 Some(Value::String(s)) => treat_empty_string_as_null && s.is_empty(),
2336 _ => false,
2337 }
2338}
2339
2340#[cfg(feature = "transform-split-join")]
2343fn split_field(
2344 value: Value,
2345 field: &str,
2346 delimiter: &str,
2347 trim: bool,
2348 into: Option<&str>,
2349) -> Value {
2350 match value {
2351 Value::Object(mut map) => {
2352 let Some(Value::String(s)) = map.get(field) else {
2353 return Value::Object(map);
2354 };
2355 let s = s.clone();
2356 let parts: Vec<Value> = if delimiter.is_empty() {
2360 vec![Value::String(if trim { s.trim().to_owned() } else { s })]
2361 } else {
2362 s.split(delimiter)
2363 .map(|part| {
2364 let p = if trim { part.trim() } else { part };
2365 Value::String(p.to_owned())
2366 })
2367 .collect()
2368 };
2369 let target = into.unwrap_or(field);
2370 map.insert(target.to_owned(), Value::Array(parts));
2371 Value::Object(map)
2372 }
2373 other => other,
2374 }
2375}
2376
2377#[cfg(feature = "transform-split-join")]
2378fn join_field(value: Value, field: &str, delimiter: &str, into: Option<&str>) -> Value {
2379 match value {
2380 Value::Object(mut map) => {
2381 let Some(Value::Array(arr)) = map.get(field) else {
2382 return Value::Object(map);
2383 };
2384 let joined = arr
2385 .iter()
2386 .map(scalar_to_string)
2387 .collect::<Vec<_>>()
2388 .join(delimiter);
2389 let target = into.unwrap_or(field);
2390 map.insert(target.to_owned(), Value::String(joined));
2391 Value::Object(map)
2392 }
2393 other => other,
2394 }
2395}
2396
2397#[cfg(feature = "transform-split-join")]
2400fn scalar_to_string(v: &Value) -> String {
2401 match v {
2402 Value::String(s) => s.clone(),
2403 Value::Null => String::new(),
2404 other => other.to_string(),
2405 }
2406}
2407
2408#[cfg(test)]
2411mod tests {
2412 use super::*;
2413 use serde_json::json;
2414
2415 fn apply_all(record: Value, transforms: &[CompiledTransform]) -> Value {
2420 super::apply_all(record, transforms).expect("transform should succeed in this test")
2421 }
2422
2423 fn compiled(transforms: &[RecordTransform]) -> Vec<CompiledTransform> {
2424 transforms.iter().map(|t| compile(t).unwrap()).collect()
2425 }
2426
2427 #[test]
2430 fn test_custom_adds_field() {
2431 let record = json!({"id": 1});
2432 let result = apply_all(
2433 record,
2434 &compiled(&[RecordTransform::custom(|mut v| {
2435 if let Value::Object(ref mut m) = v {
2436 m.insert("added".to_string(), json!(true));
2437 }
2438 v
2439 })]),
2440 );
2441 assert_eq!(result["id"], 1);
2442 assert_eq!(result["added"], true);
2443 }
2444
2445 #[test]
2446 fn test_custom_removes_field() {
2447 let record = json!({"id": 1, "secret": "drop_me"});
2448 let result = apply_all(
2449 record,
2450 &compiled(&[RecordTransform::custom(|mut v| {
2451 if let Value::Object(ref mut m) = v {
2452 m.remove("secret");
2453 }
2454 v
2455 })]),
2456 );
2457 assert_eq!(result["id"], 1);
2458 assert!(result.get("secret").is_none());
2459 }
2460
2461 #[test]
2462 fn test_no_transforms_is_identity() {
2463 let record = json!({"id": 1, "name": "Alice"});
2464 let result = apply_all(record.clone(), &[]);
2465 assert_eq!(result, record);
2466 }
2467
2468 #[cfg(feature = "transform-flatten")]
2471 #[test]
2472 fn test_flatten_nested_object() {
2473 let record = json!({"a": {"b": 1, "c": {"d": 2}}, "e": 3});
2474 let result = apply_all(
2475 record,
2476 &compiled(&[RecordTransform::Flatten {
2477 separator: "__".into(),
2478 }]),
2479 );
2480 assert_eq!(result["a__b"], 1);
2481 assert_eq!(result["a__c__d"], 2);
2482 assert_eq!(result["e"], 3);
2483 assert!(result.get("a").is_none(), "nested key should be removed");
2484 }
2485
2486 #[cfg(feature = "transform-flatten")]
2487 #[test]
2488 fn test_flatten_leaves_arrays_intact() {
2489 let record = json!({"tags": ["rust", "api"], "meta": {"count": 2}});
2490 let result = apply_all(
2491 record,
2492 &compiled(&[RecordTransform::Flatten {
2493 separator: ".".into(),
2494 }]),
2495 );
2496 assert_eq!(result["tags"], json!(["rust", "api"]));
2497 assert_eq!(result["meta.count"], 2);
2498 }
2499
2500 #[cfg(feature = "transform-flatten")]
2501 #[test]
2502 fn test_flatten_already_flat() {
2503 let record = json!({"id": 1, "name": "Alice"});
2504 let result = apply_all(
2505 record.clone(),
2506 &compiled(&[RecordTransform::Flatten {
2507 separator: "__".into(),
2508 }]),
2509 );
2510 assert_eq!(result, record);
2511 }
2512
2513 #[cfg(feature = "transform-flatten")]
2514 #[test]
2515 fn test_flatten_empty_separator() {
2516 let record = json!({"a": {"b": 1}});
2517 let result = apply_all(
2518 record,
2519 &compiled(&[RecordTransform::Flatten {
2520 separator: "".into(),
2521 }]),
2522 );
2523 assert_eq!(result["ab"], 1);
2524 }
2525
2526 #[cfg(feature = "transform-rename-keys")]
2529 #[test]
2530 fn test_rename_keys_strips_prefix() {
2531 let record = json!({"_prefix_id": 1, "_prefix_name": "Alice"});
2532 let result = apply_all(
2533 record,
2534 &compiled(&[RecordTransform::RenameKeys {
2535 pattern: r"^_prefix_".into(),
2536 replacement: "".into(),
2537 }]),
2538 );
2539 assert_eq!(result["id"], 1);
2540 assert_eq!(result["name"], "Alice");
2541 }
2542
2543 #[cfg(feature = "transform-rename-keys")]
2544 #[test]
2545 fn test_rename_keys_uppercase_to_placeholder() {
2546 let record = json!({"OUTER": {"INNER": 42}});
2547 let result = apply_all(
2548 record,
2549 &compiled(&[RecordTransform::RenameKeys {
2550 pattern: r"[A-Z]+".into(),
2551 replacement: "x".into(),
2552 }]),
2553 );
2554 assert_eq!(result["x"]["x"], 42);
2555 }
2556
2557 #[cfg(feature = "transform-rename-keys")]
2558 #[test]
2559 fn test_rename_keys_in_array_elements() {
2560 let record = json!({"items": [{"KEY": 1}, {"KEY": 2}]});
2561 let result = apply_all(
2562 record,
2563 &compiled(&[RecordTransform::RenameKeys {
2564 pattern: r"KEY".into(),
2565 replacement: "key".into(),
2566 }]),
2567 );
2568 assert_eq!(result["items"][0]["key"], 1);
2569 assert_eq!(result["items"][1]["key"], 2);
2570 }
2571
2572 #[cfg(feature = "transform-rename-keys")]
2573 #[test]
2574 fn test_rename_keys_invalid_regex_errors_at_compile() {
2575 let err = compile(&RecordTransform::RenameKeys {
2576 pattern: "[invalid".into(),
2577 replacement: "".into(),
2578 });
2579 assert!(err.is_err());
2580 assert!(matches!(err, Err(FaucetError::Transform(_))));
2581 }
2582
2583 #[cfg(feature = "transform-rename-keys")]
2584 #[test]
2585 fn test_rename_keys_chained() {
2586 let record = json!({"__camelCase__": 1});
2587 let result = apply_all(
2588 record,
2589 &compiled(&[
2590 RecordTransform::RenameKeys {
2591 pattern: r"^_+|_+$".into(),
2592 replacement: "".into(),
2593 },
2594 RecordTransform::RenameKeys {
2595 pattern: r"[A-Z]".into(),
2596 replacement: "_".into(),
2597 },
2598 ]),
2599 );
2600 let key = result.as_object().unwrap().keys().next().unwrap().clone();
2601 assert_eq!(key, "camel_ase");
2602 }
2603
2604 #[cfg(all(feature = "transform-keys-case", feature = "transform-flatten"))]
2607 #[test]
2608 fn test_keys_case_then_flatten() {
2609 let record = json!({"User Info": {"First Name": "Alice", "Last Name": "Smith"}});
2610 let result = apply_all(
2611 record,
2612 &compiled(&[
2613 RecordTransform::KeysCase {
2614 mode: KeyCaseMode::Snake,
2615 },
2616 RecordTransform::Flatten {
2617 separator: "_".into(),
2618 },
2619 ]),
2620 );
2621 assert_eq!(result["user_info_first_name"], "Alice");
2622 assert_eq!(result["user_info_last_name"], "Smith");
2623 }
2624
2625 #[test]
2626 fn test_custom_chained_with_builtin() {
2627 let record = json!({"id": 1, "raw_value": 100});
2629 let result = apply_all(
2630 record,
2631 &compiled(&[
2632 RecordTransform::custom(|mut v| {
2634 if let Some(n) = v.get("raw_value").and_then(|n| n.as_i64())
2635 && let Value::Object(ref mut m) = v
2636 {
2637 m.insert("raw_value".to_string(), json!(n * 2));
2638 }
2639 v
2640 }),
2641 RecordTransform::custom(|mut v| {
2643 if let Value::Object(ref mut m) = v
2644 && let Some(val) = m.remove("raw_value")
2645 {
2646 m.insert("value".to_string(), val);
2647 }
2648 v
2649 }),
2650 ]),
2651 );
2652 assert_eq!(result["id"], 1);
2653 assert_eq!(result["value"], 200);
2654 assert!(result.get("raw_value").is_none());
2655 }
2656
2657 #[cfg(feature = "transform-flatten")]
2660 #[test]
2661 fn flatten_key_collision_errors() {
2662 let record = json!({"a__b": 1, "a": {"b": 2}});
2664 let err = super::apply_all(
2665 record,
2666 &compiled(&[RecordTransform::Flatten {
2667 separator: "__".into(),
2668 }]),
2669 )
2670 .expect_err("colliding flattened keys must error, not drop a value");
2671 assert!(matches!(err, FaucetError::Transform(_)));
2672 assert!(format!("{err}").contains("a__b"), "{err}");
2673 }
2674
2675 #[cfg(feature = "transform-select")]
2678 #[test]
2679 fn select_keeps_only_listed_fields() {
2680 let record = json!({"id": 1, "name": "Alice", "secret": "drop"});
2681 let result = apply_all(
2682 record,
2683 &compiled(&[RecordTransform::Select {
2684 fields: vec!["id".into(), "name".into()],
2685 }]),
2686 );
2687 assert_eq!(result["id"], 1);
2688 assert_eq!(result["name"], "Alice");
2689 assert!(result.get("secret").is_none());
2690 }
2691
2692 #[cfg(feature = "transform-select")]
2693 #[test]
2694 fn select_missing_field_is_no_op() {
2695 let record = json!({"id": 1});
2697 let result = apply_all(
2698 record,
2699 &compiled(&[RecordTransform::Select {
2700 fields: vec!["id".into(), "missing".into()],
2701 }]),
2702 );
2703 assert_eq!(result["id"], 1);
2704 assert!(result.get("missing").is_none());
2705 }
2706
2707 #[cfg(feature = "transform-select")]
2708 #[test]
2709 fn select_passes_through_non_object() {
2710 let record = json!([1, 2, 3]);
2711 let result = apply_all(
2712 record.clone(),
2713 &compiled(&[RecordTransform::Select {
2714 fields: vec!["id".into()],
2715 }]),
2716 );
2717 assert_eq!(result, record);
2718 }
2719
2720 #[cfg(feature = "transform-drop")]
2723 #[test]
2724 fn drop_removes_listed_fields() {
2725 let record = json!({"id": 1, "ssn": "111-22-3333", "name": "Alice"});
2726 let result = apply_all(
2727 record,
2728 &compiled(&[RecordTransform::Drop {
2729 fields: vec!["ssn".into()],
2730 }]),
2731 );
2732 assert_eq!(result["id"], 1);
2733 assert_eq!(result["name"], "Alice");
2734 assert!(result.get("ssn").is_none());
2735 }
2736
2737 #[cfg(feature = "transform-drop")]
2738 #[test]
2739 fn drop_missing_field_is_no_op() {
2740 let record = json!({"id": 1});
2741 let result = apply_all(
2742 record,
2743 &compiled(&[RecordTransform::Drop {
2744 fields: vec!["missing".into()],
2745 }]),
2746 );
2747 assert_eq!(result["id"], 1);
2748 }
2749
2750 #[cfg(feature = "transform-set")]
2753 #[test]
2754 fn set_inserts_new_fields() {
2755 let record = json!({"id": 1});
2756 let mut values = Map::new();
2757 values.insert("_source".into(), json!("api"));
2758 values.insert("ingested_at".into(), json!("2026-01-01"));
2759 let result = apply_all(record, &compiled(&[RecordTransform::Set { values }]));
2760 assert_eq!(result["id"], 1);
2761 assert_eq!(result["_source"], "api");
2762 assert_eq!(result["ingested_at"], "2026-01-01");
2763 }
2764
2765 #[cfg(feature = "transform-set")]
2766 #[test]
2767 fn set_overwrites_existing_field() {
2768 let record = json!({"_source": "old", "id": 1});
2769 let mut values = Map::new();
2770 values.insert("_source".into(), json!("new"));
2771 let result = apply_all(record, &compiled(&[RecordTransform::Set { values }]));
2772 assert_eq!(result["_source"], "new");
2773 assert_eq!(result["id"], 1);
2774 }
2775
2776 #[cfg(feature = "transform-set")]
2777 #[test]
2778 fn set_supports_any_json_value() {
2779 let record = json!({});
2780 let mut values = Map::new();
2781 values.insert("n".into(), json!(42));
2782 values.insert("b".into(), json!(true));
2783 values.insert("arr".into(), json!([1, 2]));
2784 values.insert("obj".into(), json!({"k": "v"}));
2785 values.insert("null".into(), Value::Null);
2786 let result = apply_all(record, &compiled(&[RecordTransform::Set { values }]));
2787 assert_eq!(result["n"], 42);
2788 assert_eq!(result["b"], true);
2789 assert_eq!(result["arr"], json!([1, 2]));
2790 assert_eq!(result["obj"]["k"], "v");
2791 assert_eq!(result["null"], Value::Null);
2792 }
2793
2794 #[cfg(feature = "transform-rename-field")]
2797 #[test]
2798 fn rename_field_renames_exact_key() {
2799 let record = json!({"old_name": 1, "keep": 2});
2800 let mut fields = HashMap::new();
2801 fields.insert("old_name".to_owned(), "new_name".to_owned());
2802 let result = apply_all(
2803 record,
2804 &compiled(&[RecordTransform::RenameField { fields }]),
2805 );
2806 assert_eq!(result["new_name"], 1);
2807 assert_eq!(result["keep"], 2);
2808 assert!(result.get("old_name").is_none());
2809 }
2810
2811 #[cfg(feature = "transform-rename-field")]
2812 #[test]
2813 fn rename_field_missing_source_is_no_op() {
2814 let record = json!({"id": 1});
2815 let mut fields = HashMap::new();
2816 fields.insert("missing".to_owned(), "renamed".to_owned());
2817 let result = apply_all(
2818 record,
2819 &compiled(&[RecordTransform::RenameField { fields }]),
2820 );
2821 assert_eq!(result["id"], 1);
2822 assert!(result.get("renamed").is_none());
2823 }
2824
2825 #[cfg(feature = "transform-rename-field")]
2826 #[test]
2827 fn rename_field_target_collision_errors() {
2828 let record = json!({"a": 1, "b": 2});
2829 let mut fields = HashMap::new();
2830 fields.insert("a".to_owned(), "b".to_owned());
2831 let err = super::apply_all(
2832 record,
2833 &compiled(&[RecordTransform::RenameField { fields }]),
2834 )
2835 .expect_err("collision must error, not overwrite");
2836 assert!(matches!(err, FaucetError::Transform(_)));
2837 assert!(format!("{err}").contains("'b'"), "{err}");
2838 }
2839
2840 #[cfg(feature = "transform-rename-field")]
2841 #[test]
2842 fn rename_field_swap_is_deterministic() {
2843 for _ in 0..50 {
2846 let record = json!({"a": 1, "b": 2, "keep": 3});
2847 let mut fields = HashMap::new();
2848 fields.insert("a".to_owned(), "b".to_owned());
2849 fields.insert("b".to_owned(), "a".to_owned());
2850 let result = apply_all(
2851 record,
2852 &compiled(&[RecordTransform::RenameField { fields }]),
2853 );
2854 assert_eq!(result["a"], 2, "{result}");
2855 assert_eq!(result["b"], 1, "{result}");
2856 assert_eq!(result["keep"], 3);
2857 }
2858 }
2859
2860 #[cfg(feature = "transform-rename-field")]
2861 #[test]
2862 fn rename_field_chain_applies_against_original_snapshot() {
2863 for _ in 0..50 {
2866 let record = json!({"a": 1, "b": 2});
2867 let mut fields = HashMap::new();
2868 fields.insert("a".to_owned(), "b".to_owned());
2869 fields.insert("b".to_owned(), "c".to_owned());
2870 let result = apply_all(
2871 record,
2872 &compiled(&[RecordTransform::RenameField { fields }]),
2873 );
2874 assert_eq!(result["b"], 1, "{result}");
2875 assert_eq!(result["c"], 2, "{result}");
2876 assert!(result.get("a").is_none(), "{result}");
2877 }
2878 }
2879
2880 #[cfg(feature = "transform-rename-field")]
2881 #[test]
2882 fn rename_field_two_sources_one_target_errors() {
2883 let record = json!({"a": 1, "b": 2});
2884 let mut fields = HashMap::new();
2885 fields.insert("a".to_owned(), "c".to_owned());
2886 fields.insert("b".to_owned(), "c".to_owned());
2887 let err = super::apply_all(
2888 record,
2889 &compiled(&[RecordTransform::RenameField { fields }]),
2890 )
2891 .expect_err("two renames to the same target must error");
2892 assert!(format!("{err}").contains("same target"), "{err}");
2893 }
2894
2895 #[cfg(feature = "transform-cast")]
2898 fn cast_specs(field: &str, ty: CastType, on_error: CastOnError) -> Vec<RecordTransform> {
2899 let mut fields = HashMap::new();
2900 fields.insert(field.to_owned(), ty);
2901 vec![RecordTransform::Cast { fields, on_error }]
2902 }
2903
2904 #[cfg(feature = "transform-cast")]
2905 #[test]
2906 fn cast_string_to_int() {
2907 let record = json!({"age": "42"});
2908 let result = apply_all(
2909 record,
2910 &compiled(&cast_specs("age", CastType::Int, CastOnError::Error)),
2911 );
2912 assert_eq!(result["age"], 42);
2913 }
2914
2915 #[cfg(feature = "transform-cast")]
2916 #[test]
2917 fn cast_whole_number_float_to_int_succeeds() {
2918 let record = json!({"n": 5.0});
2920 let result = apply_all(
2921 record,
2922 &compiled(&cast_specs("n", CastType::Int, CastOnError::Error)),
2923 );
2924 assert_eq!(result["n"], 5);
2925 }
2926
2927 #[cfg(feature = "transform-cast")]
2928 #[test]
2929 fn cast_fractional_float_to_int_errors_under_on_error_error() {
2930 let record = json!({"n": 3.9});
2932 let err = super::apply_all(
2933 record,
2934 &compiled(&cast_specs("n", CastType::Int, CastOnError::Error)),
2935 )
2936 .expect_err("a fractional float must not silently truncate to int");
2937 assert!(matches!(err, FaucetError::Transform(_)), "{err}");
2938 }
2939
2940 #[cfg(feature = "transform-cast")]
2941 #[test]
2942 fn cast_out_of_range_float_to_int_errors_under_on_error_error() {
2943 let record = json!({"n": 1e30});
2945 let err = super::apply_all(
2946 record,
2947 &compiled(&cast_specs("n", CastType::Int, CastOnError::Error)),
2948 )
2949 .expect_err("an out-of-range float must not silently saturate to i64::MAX");
2950 assert!(matches!(err, FaucetError::Transform(_)), "{err}");
2951 }
2952
2953 #[cfg(feature = "transform-cast")]
2954 #[test]
2955 fn cast_fractional_float_to_int_nulls_under_on_error_null() {
2956 let record = json!({"n": 3.9});
2957 let result = apply_all(
2958 record,
2959 &compiled(&cast_specs("n", CastType::Int, CastOnError::Null)),
2960 );
2961 assert_eq!(result["n"], Value::Null);
2962 }
2963
2964 #[cfg(feature = "transform-cast")]
2965 #[test]
2966 fn cast_string_to_float() {
2967 let record = json!({"price": "9.99"});
2968 let result = apply_all(
2969 record,
2970 &compiled(&cast_specs("price", CastType::Float, CastOnError::Error)),
2971 );
2972 assert_eq!(result["price"], 9.99);
2973 }
2974
2975 #[cfg(feature = "transform-cast")]
2976 #[test]
2977 fn cast_string_to_bool() {
2978 for input in ["true", "TRUE", "1", "yes"] {
2979 let record = json!({"flag": input});
2980 let result = apply_all(
2981 record,
2982 &compiled(&cast_specs("flag", CastType::Bool, CastOnError::Error)),
2983 );
2984 assert_eq!(result["flag"], true, "input was {input:?}");
2985 }
2986 for input in ["false", "0", "no"] {
2987 let record = json!({"flag": input});
2988 let result = apply_all(
2989 record,
2990 &compiled(&cast_specs("flag", CastType::Bool, CastOnError::Error)),
2991 );
2992 assert_eq!(result["flag"], false, "input was {input:?}");
2993 }
2994 }
2995
2996 #[cfg(feature = "transform-cast")]
2997 #[test]
2998 fn cast_number_to_string() {
2999 let record = json!({"id": 42});
3000 let result = apply_all(
3001 record,
3002 &compiled(&cast_specs("id", CastType::String, CastOnError::Error)),
3003 );
3004 assert_eq!(result["id"], "42");
3005 }
3006
3007 #[cfg(feature = "transform-cast")]
3008 #[test]
3009 fn cast_string_to_timestamp_normalises() {
3010 let record = json!({"ts": "2026-05-28T12:34:56+00:00"});
3011 let result = apply_all(
3012 record,
3013 &compiled(&cast_specs("ts", CastType::Timestamp, CastOnError::Error)),
3014 );
3015 assert_eq!(result["ts"], "2026-05-28T12:34:56Z");
3017 }
3018
3019 #[cfg(feature = "transform-cast")]
3020 #[test]
3021 fn cast_on_error_error_propagates() {
3022 let record = json!({"age": "not a number"});
3023 let err = super::apply_all(
3024 record,
3025 &compiled(&cast_specs("age", CastType::Int, CastOnError::Error)),
3026 )
3027 .expect_err("uncastable value must error under on_error=error");
3028 assert!(matches!(err, FaucetError::Transform(_)));
3029 assert!(format!("{err}").contains("'age'"), "{err}");
3030 }
3031
3032 #[cfg(feature = "transform-cast")]
3033 #[test]
3034 fn cast_on_error_null_replaces() {
3035 let record = json!({"age": "not a number"});
3036 let result = apply_all(
3037 record,
3038 &compiled(&cast_specs("age", CastType::Int, CastOnError::Null)),
3039 );
3040 assert_eq!(result["age"], Value::Null);
3041 }
3042
3043 #[cfg(feature = "transform-cast")]
3044 #[test]
3045 fn cast_on_error_skip_leaves_value() {
3046 let record = json!({"age": "not a number"});
3047 let result = apply_all(
3048 record,
3049 &compiled(&cast_specs("age", CastType::Int, CastOnError::Skip)),
3050 );
3051 assert_eq!(result["age"], "not a number");
3052 }
3053
3054 #[cfg(feature = "transform-cast")]
3055 #[test]
3056 fn cast_missing_field_is_no_op() {
3057 let record = json!({"id": 1});
3058 let result = apply_all(
3059 record,
3060 &compiled(&cast_specs("missing", CastType::Int, CastOnError::Error)),
3061 );
3062 assert_eq!(result["id"], 1);
3063 assert!(result.get("missing").is_none());
3064 }
3065
3066 #[cfg(feature = "transform-redact")]
3069 #[test]
3070 fn redact_replaces_value_with_mask() {
3071 let record = json!({"id": 1, "ssn": "111-22-3333", "email": "x@y.z"});
3072 let result = apply_all(
3073 record,
3074 &compiled(&[RecordTransform::Redact {
3075 fields: vec!["ssn".into(), "email".into()],
3076 mask: json!("***"),
3077 }]),
3078 );
3079 assert_eq!(result["id"], 1);
3080 assert_eq!(result["ssn"], "***");
3081 assert_eq!(result["email"], "***");
3082 }
3083
3084 #[cfg(feature = "transform-redact")]
3085 #[test]
3086 fn redact_missing_field_does_not_insert_mask() {
3087 let record = json!({"id": 1});
3088 let result = apply_all(
3089 record,
3090 &compiled(&[RecordTransform::Redact {
3091 fields: vec!["ssn".into()],
3092 mask: json!("***"),
3093 }]),
3094 );
3095 assert_eq!(result["id"], 1);
3096 assert!(result.get("ssn").is_none());
3097 }
3098
3099 #[cfg(feature = "transform-value-case")]
3102 #[test]
3103 fn value_case_lower() {
3104 let record = json!({"email": "User@Example.COM", "id": 1});
3105 let result = apply_all(
3106 record,
3107 &compiled(&[RecordTransform::ValueCase {
3108 fields: vec!["email".into()],
3109 mode: ValueCaseMode::Lower,
3110 }]),
3111 );
3112 assert_eq!(result["email"], "user@example.com");
3113 assert_eq!(result["id"], 1);
3114 }
3115
3116 #[cfg(feature = "transform-value-case")]
3117 #[test]
3118 fn value_case_upper() {
3119 let record = json!({"code": "abc"});
3120 let result = apply_all(
3121 record,
3122 &compiled(&[RecordTransform::ValueCase {
3123 fields: vec!["code".into()],
3124 mode: ValueCaseMode::Upper,
3125 }]),
3126 );
3127 assert_eq!(result["code"], "ABC");
3128 }
3129
3130 #[cfg(feature = "transform-value-case")]
3131 #[test]
3132 fn value_case_trim() {
3133 let record = json!({"name": " Alice "});
3134 let result = apply_all(
3135 record,
3136 &compiled(&[RecordTransform::ValueCase {
3137 fields: vec!["name".into()],
3138 mode: ValueCaseMode::Trim,
3139 }]),
3140 );
3141 assert_eq!(result["name"], "Alice");
3142 }
3143
3144 #[cfg(feature = "transform-value-case")]
3145 #[test]
3146 fn value_case_passes_non_string_through() {
3147 let record = json!({"id": 42});
3148 let result = apply_all(
3149 record,
3150 &compiled(&[RecordTransform::ValueCase {
3151 fields: vec!["id".into()],
3152 mode: ValueCaseMode::Upper,
3153 }]),
3154 );
3155 assert_eq!(result["id"], 42);
3156 }
3157
3158 #[cfg(feature = "transform-spell-symbols")]
3161 fn spell_default() -> Vec<RecordTransform> {
3162 vec![RecordTransform::SpellSymbols {
3163 extra: HashMap::new(),
3164 separator: " ".into(),
3165 }]
3166 }
3167
3168 #[cfg(feature = "transform-spell-symbols")]
3169 #[test]
3170 fn spell_symbols_replaces_common_symbols() {
3171 let record = json!({"%sold": 1, "C#course": 2, "$amount": 3});
3172 let result = apply_all(record, &compiled(&spell_default()));
3173 assert!(result.get(" percent sold").is_some());
3176 assert!(result.get("C number course").is_some());
3177 assert!(result.get(" dollar amount").is_some());
3178 }
3179
3180 #[cfg(all(feature = "transform-spell-symbols", feature = "transform-keys-case"))]
3181 #[test]
3182 fn spell_symbols_then_keys_case_pipeline() {
3183 let record = json!({"% sold": 10, "C# courses": 20});
3184 let result = super::apply_all(
3185 record,
3186 &compiled(&[
3187 RecordTransform::SpellSymbols {
3188 extra: HashMap::new(),
3189 separator: " ".into(),
3190 },
3191 RecordTransform::KeysCase {
3192 mode: KeyCaseMode::Snake,
3193 },
3194 ]),
3195 )
3196 .expect("pipeline must succeed");
3197 assert_eq!(result["percent_sold"], 10);
3198 assert_eq!(result["c_number_courses"], 20);
3199 }
3200
3201 #[cfg(feature = "transform-spell-symbols")]
3202 #[test]
3203 fn spell_symbols_extra_overrides_defaults() {
3204 let mut extra = HashMap::new();
3205 extra.insert("#".to_owned(), "hash".to_owned());
3206 extra.insert("©".to_owned(), "copyright".to_owned());
3207 let record = json!({"#tag": 1, "©2026": 2});
3208 let result = apply_all(
3209 record,
3210 &compiled(&[RecordTransform::SpellSymbols {
3211 extra,
3212 separator: " ".into(),
3213 }]),
3214 );
3215 assert!(result.get(" hash tag").is_some());
3217 assert!(result.get(" copyright 2026").is_some());
3219 }
3220
3221 #[cfg(feature = "transform-spell-symbols")]
3222 #[test]
3223 fn spell_symbols_longest_match_wins() {
3224 let mut extra = HashMap::new();
3226 extra.insert("<=".to_owned(), "lte".to_owned());
3227 let record = json!({"a<=b": 1});
3228 let result = apply_all(
3229 record,
3230 &compiled(&[RecordTransform::SpellSymbols {
3231 extra,
3232 separator: " ".into(),
3233 }]),
3234 );
3235 assert!(result.get("a lte b").is_some());
3236 assert!(result.get("a lt = b").is_none());
3238 }
3239
3240 #[cfg(feature = "transform-spell-symbols")]
3241 #[test]
3242 fn spell_symbols_recursive_into_objects_and_arrays() {
3243 let record = json!({"outer&": {"inner%": [{"deep#": 1}]}});
3244 let result = apply_all(record, &compiled(&spell_default()));
3245 let outer_key = result.as_object().unwrap().keys().next().unwrap().clone();
3246 assert!(outer_key.contains("and"), "outer key was {outer_key:?}");
3247 let inner = &result[&outer_key];
3248 let inner_key = inner.as_object().unwrap().keys().next().unwrap().clone();
3249 assert!(inner_key.contains("percent"), "inner key was {inner_key:?}");
3250 let deep = &inner[&inner_key][0];
3251 let deep_key = deep.as_object().unwrap().keys().next().unwrap().clone();
3252 assert!(deep_key.contains("number"), "deep key was {deep_key:?}");
3253 }
3254
3255 #[cfg(feature = "transform-spell-symbols")]
3256 #[test]
3257 fn spell_symbols_key_collision_errors() {
3258 let record = json!({"%": 1, "percent": 2});
3260 let err = super::apply_all(
3261 record,
3262 &compiled(&[RecordTransform::SpellSymbols {
3263 extra: HashMap::new(),
3264 separator: "".into(),
3265 }]),
3266 )
3267 .expect_err("colliding spelled keys must error, not drop a value");
3268 assert!(matches!(err, FaucetError::Transform(_)));
3269 assert!(format!("{err}").contains("percent"), "{err}");
3270 }
3271
3272 #[cfg(feature = "transform-keys-case")]
3275 fn keys_case_specs(mode: KeyCaseMode) -> Vec<RecordTransform> {
3276 vec![RecordTransform::KeysCase { mode }]
3277 }
3278
3279 #[cfg(feature = "transform-keys-case")]
3280 #[test]
3281 fn keys_case_snake() {
3282 let record = json!({"First Name": 1, "last-name": 2, "ID": 3});
3283 let result = apply_all(record, &compiled(&keys_case_specs(KeyCaseMode::Snake)));
3284 assert_eq!(result["first_name"], 1);
3285 assert_eq!(result["last_name"], 2);
3286 assert_eq!(result["id"], 3);
3287 }
3288
3289 #[cfg(feature = "transform-keys-case")]
3290 #[test]
3291 fn keys_case_camel_from_various_inputs() {
3292 let record = json!({"first_name": 1, "User ID": 2, "kebab-case": 3, "PascalCase": 4});
3294 let result = apply_all(record, &compiled(&keys_case_specs(KeyCaseMode::Camel)));
3295 assert_eq!(result["firstName"], 1);
3296 assert_eq!(result["userId"], 2);
3297 assert_eq!(result["kebabCase"], 3);
3298 assert_eq!(result["pascalCase"], 4);
3299 }
3300
3301 #[cfg(feature = "transform-keys-case")]
3302 #[test]
3303 fn keys_case_pascal() {
3304 let record = json!({"first_name": 1, "second name": 2});
3305 let result = apply_all(record, &compiled(&keys_case_specs(KeyCaseMode::Pascal)));
3306 assert_eq!(result["FirstName"], 1);
3307 assert_eq!(result["SecondName"], 2);
3308 }
3309
3310 #[cfg(feature = "transform-keys-case")]
3311 #[test]
3312 fn keys_case_kebab() {
3313 let record = json!({"firstName": 1, "second_name": 2});
3314 let result = apply_all(record, &compiled(&keys_case_specs(KeyCaseMode::Kebab)));
3315 assert_eq!(result["first-name"], 1);
3316 assert_eq!(result["second-name"], 2);
3317 }
3318
3319 #[cfg(feature = "transform-keys-case")]
3320 #[test]
3321 fn keys_case_screaming_snake() {
3322 let record = json!({"firstName": 1, "second name": 2});
3323 let result = apply_all(
3324 record,
3325 &compiled(&keys_case_specs(KeyCaseMode::ScreamingSnake)),
3326 );
3327 assert_eq!(result["FIRST_NAME"], 1);
3328 assert_eq!(result["SECOND_NAME"], 2);
3329 }
3330
3331 #[cfg(feature = "transform-keys-case")]
3332 #[test]
3333 fn keys_case_recursive_into_nested() {
3334 let record = json!({"User Info": {"First Name": "Alice", "items": [{"Tag Name": "x"}]}});
3335 let result = apply_all(record, &compiled(&keys_case_specs(KeyCaseMode::Snake)));
3336 assert_eq!(result["user_info"]["first_name"], "Alice");
3337 assert_eq!(result["user_info"]["items"][0]["tag_name"], "x");
3338 }
3339
3340 #[cfg(feature = "transform-keys-case")]
3341 #[test]
3342 fn keys_case_collision_errors() {
3343 let record = json!({"firstName": 1, "first_name": 2});
3345 let err = super::apply_all(record, &compiled(&keys_case_specs(KeyCaseMode::Snake)))
3346 .expect_err("colliding re-cased keys must error, not drop a value");
3347 assert!(matches!(err, FaucetError::Transform(_)));
3348 assert!(format!("{err}").contains("first_name"), "{err}");
3349 }
3350
3351 #[cfg(feature = "transform-keys-case")]
3352 #[test]
3353 fn keys_case_all_symbol_key_kept_as_is() {
3354 let record = json!({"!@#": 1, "id": 2});
3357 let result = apply_all(record, &compiled(&keys_case_specs(KeyCaseMode::Snake)));
3358 assert_eq!(result["!@#"], 1);
3359 assert_eq!(result["id"], 2);
3360 }
3361
3362 #[cfg(feature = "transform-keys-case")]
3363 #[test]
3364 fn keys_case_idempotent_in_target_mode() {
3365 let record = json!({"first_name": 1});
3368 let once = apply_all(record, &compiled(&keys_case_specs(KeyCaseMode::Snake)));
3369 let twice = apply_all(
3370 once.clone(),
3371 &compiled(&keys_case_specs(KeyCaseMode::Snake)),
3372 );
3373 assert_eq!(once, twice);
3374 }
3375
3376 #[cfg(feature = "transform-spell-symbols")]
3377 #[test]
3378 fn spell_symbols_handles_unicode_keys() {
3379 let record = json!({"café%": 1});
3381 let result = apply_all(record, &compiled(&spell_default()));
3382 let key = result.as_object().unwrap().keys().next().unwrap().clone();
3383 assert!(key.contains("café"), "key was {key:?}");
3384 assert!(key.contains("percent"), "key was {key:?}");
3385 }
3386
3387 #[test]
3390 fn debug_record_transform_all_variants() {
3391 let dbg = format!("{:?}", RecordTransform::custom(|v| v));
3393 assert_eq!(dbg, "Custom(<fn>)");
3394
3395 #[cfg(feature = "transform-flatten")]
3396 {
3397 let dbg = format!(
3398 "{:?}",
3399 RecordTransform::Flatten {
3400 separator: "__".into()
3401 }
3402 );
3403 assert!(dbg.starts_with("Flatten"), "{dbg}");
3404 assert!(dbg.contains("separator"), "{dbg}");
3405 assert!(dbg.contains("__"), "{dbg}");
3406 }
3407 #[cfg(feature = "transform-rename-keys")]
3408 {
3409 let dbg = format!(
3410 "{:?}",
3411 RecordTransform::RenameKeys {
3412 pattern: "p".into(),
3413 replacement: "r".into(),
3414 }
3415 );
3416 assert!(dbg.starts_with("RenameKeys"), "{dbg}");
3417 assert!(dbg.contains("pattern"), "{dbg}");
3418 assert!(dbg.contains("replacement"), "{dbg}");
3419 }
3420 #[cfg(feature = "transform-keys-case")]
3421 {
3422 let dbg = format!(
3423 "{:?}",
3424 RecordTransform::KeysCase {
3425 mode: KeyCaseMode::Snake
3426 }
3427 );
3428 assert!(dbg.starts_with("KeysCase"), "{dbg}");
3429 assert!(dbg.contains("Snake"), "{dbg}");
3430 }
3431 #[cfg(feature = "transform-select")]
3432 {
3433 let dbg = format!(
3434 "{:?}",
3435 RecordTransform::Select {
3436 fields: vec!["a".into()]
3437 }
3438 );
3439 assert!(dbg.starts_with("Select"), "{dbg}");
3440 assert!(dbg.contains("fields"), "{dbg}");
3441 }
3442 #[cfg(feature = "transform-drop")]
3443 {
3444 let dbg = format!(
3445 "{:?}",
3446 RecordTransform::Drop {
3447 fields: vec!["a".into()]
3448 }
3449 );
3450 assert!(dbg.starts_with("Drop"), "{dbg}");
3451 }
3452 #[cfg(feature = "transform-set")]
3453 {
3454 let mut values = Map::new();
3455 values.insert("k".into(), json!("v"));
3456 let dbg = format!("{:?}", RecordTransform::Set { values });
3457 assert!(dbg.starts_with("Set"), "{dbg}");
3458 assert!(dbg.contains("values"), "{dbg}");
3459 }
3460 #[cfg(feature = "transform-rename-field")]
3461 {
3462 let mut fields = HashMap::new();
3463 fields.insert("a".to_owned(), "b".to_owned());
3464 let dbg = format!("{:?}", RecordTransform::RenameField { fields });
3465 assert!(dbg.starts_with("RenameField"), "{dbg}");
3466 }
3467 #[cfg(feature = "transform-cast")]
3468 {
3469 let mut fields = HashMap::new();
3470 fields.insert("a".to_owned(), CastType::Int);
3471 let dbg = format!(
3472 "{:?}",
3473 RecordTransform::Cast {
3474 fields,
3475 on_error: CastOnError::Error,
3476 }
3477 );
3478 assert!(dbg.starts_with("Cast"), "{dbg}");
3479 assert!(dbg.contains("on_error"), "{dbg}");
3480 }
3481 #[cfg(feature = "transform-redact")]
3482 {
3483 let dbg = format!(
3484 "{:?}",
3485 RecordTransform::Redact {
3486 fields: vec!["a".into()],
3487 mask: json!("***"),
3488 }
3489 );
3490 assert!(dbg.starts_with("Redact"), "{dbg}");
3491 assert!(dbg.contains("mask"), "{dbg}");
3492 }
3493 #[cfg(feature = "transform-value-case")]
3494 {
3495 let dbg = format!(
3496 "{:?}",
3497 RecordTransform::ValueCase {
3498 fields: vec!["a".into()],
3499 mode: ValueCaseMode::Lower,
3500 }
3501 );
3502 assert!(dbg.starts_with("ValueCase"), "{dbg}");
3503 assert!(dbg.contains("mode"), "{dbg}");
3504 }
3505 #[cfg(feature = "transform-spell-symbols")]
3506 {
3507 let dbg = format!(
3508 "{:?}",
3509 RecordTransform::SpellSymbols {
3510 extra: HashMap::new(),
3511 separator: " ".into(),
3512 }
3513 );
3514 assert!(dbg.starts_with("SpellSymbols"), "{dbg}");
3515 assert!(dbg.contains("separator"), "{dbg}");
3516 }
3517 #[cfg(feature = "transform-hash")]
3518 {
3519 let dbg = format!(
3520 "{:?}",
3521 RecordTransform::Hash {
3522 fields: vec!["a".into()],
3523 algorithm: HashAlgorithm::Blake3,
3524 encoding: HashEncoding::Base64,
3525 salt: Some("s".into()),
3526 into: Some("a_hash".into()),
3527 }
3528 );
3529 assert!(dbg.starts_with("Hash"), "{dbg}");
3530 assert!(dbg.contains("algorithm"), "{dbg}");
3531 assert!(dbg.contains("encoding"), "{dbg}");
3532 }
3533 #[cfg(feature = "transform-json-parse")]
3534 {
3535 let dbg = format!(
3536 "{:?}",
3537 RecordTransform::JsonParse {
3538 fields: vec!["a".into()],
3539 on_error: JsonParseOnError::Null,
3540 into: None,
3541 }
3542 );
3543 assert!(dbg.starts_with("JsonParse"), "{dbg}");
3544 assert!(dbg.contains("on_error"), "{dbg}");
3545 }
3546 #[cfg(feature = "transform-coalesce")]
3547 {
3548 let dbg = format!(
3549 "{:?}",
3550 RecordTransform::Coalesce {
3551 field: "a".into(),
3552 default: Some(json!("x")),
3553 from: vec![],
3554 treat_empty_string_as_null: true,
3555 }
3556 );
3557 assert!(dbg.starts_with("Coalesce"), "{dbg}");
3558 assert!(dbg.contains("treat_empty_string_as_null"), "{dbg}");
3559 }
3560 #[cfg(feature = "transform-split-join")]
3561 {
3562 let dbg = format!(
3563 "{:?}",
3564 RecordTransform::Split {
3565 field: "a".into(),
3566 delimiter: ",".into(),
3567 trim: true,
3568 into: None,
3569 }
3570 );
3571 assert!(dbg.starts_with("Split"), "{dbg}");
3572 assert!(dbg.contains("delimiter"), "{dbg}");
3573 let dbg = format!(
3574 "{:?}",
3575 RecordTransform::Join {
3576 field: "a".into(),
3577 delimiter: ",".into(),
3578 into: None,
3579 }
3580 );
3581 assert!(dbg.starts_with("Join"), "{dbg}");
3582 }
3583 }
3584
3585 #[test]
3588 fn clone_record_transform_custom_preserves_behaviour() {
3589 let original = RecordTransform::custom(|mut v| {
3590 if let Value::Object(ref mut m) = v {
3591 m.insert("cloned".into(), json!(true));
3592 }
3593 v
3594 });
3595 let cloned = original.clone();
3596 assert_eq!(format!("{cloned:?}"), "Custom(<fn>)");
3597 let out = apply_all(json!({"id": 1}), &compiled(&[cloned]));
3598 assert_eq!(out["cloned"], true);
3599 assert_eq!(out["id"], 1);
3600 }
3601
3602 #[test]
3603 #[allow(clippy::vec_init_then_push)]
3606 fn clone_record_transform_all_builtin_variants() {
3607 let mut variants: Vec<RecordTransform> = Vec::new();
3608 #[cfg(feature = "transform-flatten")]
3609 variants.push(RecordTransform::Flatten {
3610 separator: "__".into(),
3611 });
3612 #[cfg(feature = "transform-rename-keys")]
3613 variants.push(RecordTransform::RenameKeys {
3614 pattern: "p".into(),
3615 replacement: "r".into(),
3616 });
3617 #[cfg(feature = "transform-keys-case")]
3618 variants.push(RecordTransform::KeysCase {
3619 mode: KeyCaseMode::Snake,
3620 });
3621 #[cfg(feature = "transform-select")]
3622 variants.push(RecordTransform::Select {
3623 fields: vec!["a".into()],
3624 });
3625 #[cfg(feature = "transform-drop")]
3626 variants.push(RecordTransform::Drop {
3627 fields: vec!["a".into()],
3628 });
3629 #[cfg(feature = "transform-set")]
3630 {
3631 let mut values = Map::new();
3632 values.insert("k".into(), json!("v"));
3633 variants.push(RecordTransform::Set { values });
3634 }
3635 #[cfg(feature = "transform-rename-field")]
3636 {
3637 let mut fields = HashMap::new();
3638 fields.insert("a".to_owned(), "b".to_owned());
3639 variants.push(RecordTransform::RenameField { fields });
3640 }
3641 #[cfg(feature = "transform-cast")]
3642 {
3643 let mut fields = HashMap::new();
3644 fields.insert("a".to_owned(), CastType::Int);
3645 variants.push(RecordTransform::Cast {
3646 fields,
3647 on_error: CastOnError::Error,
3648 });
3649 }
3650 #[cfg(feature = "transform-redact")]
3651 variants.push(RecordTransform::Redact {
3652 fields: vec!["a".into()],
3653 mask: json!("***"),
3654 });
3655 #[cfg(feature = "transform-value-case")]
3656 variants.push(RecordTransform::ValueCase {
3657 fields: vec!["a".into()],
3658 mode: ValueCaseMode::Lower,
3659 });
3660 #[cfg(feature = "transform-spell-symbols")]
3661 variants.push(RecordTransform::SpellSymbols {
3662 extra: HashMap::new(),
3663 separator: " ".into(),
3664 });
3665 #[cfg(feature = "transform-hash")]
3666 variants.push(RecordTransform::Hash {
3667 fields: vec!["a".into()],
3668 algorithm: HashAlgorithm::Sha256,
3669 encoding: HashEncoding::Hex,
3670 salt: Some("s".into()),
3671 into: None,
3672 });
3673 #[cfg(feature = "transform-json-parse")]
3674 variants.push(RecordTransform::JsonParse {
3675 fields: vec!["a".into()],
3676 on_error: JsonParseOnError::Keep,
3677 into: Some("b".into()),
3678 });
3679 #[cfg(feature = "transform-coalesce")]
3680 variants.push(RecordTransform::Coalesce {
3681 field: "a".into(),
3682 default: None,
3683 from: vec!["b".into()],
3684 treat_empty_string_as_null: false,
3685 });
3686 #[cfg(feature = "transform-split-join")]
3687 {
3688 variants.push(RecordTransform::Split {
3689 field: "a".into(),
3690 delimiter: ",".into(),
3691 trim: true,
3692 into: None,
3693 });
3694 variants.push(RecordTransform::Join {
3695 field: "a".into(),
3696 delimiter: ",".into(),
3697 into: Some("b".into()),
3698 });
3699 }
3700
3701 for v in &variants {
3703 let cloned = v.clone();
3704 assert_eq!(format!("{v:?}"), format!("{cloned:?}"));
3705 }
3706 }
3707
3708 #[test]
3709 fn clone_compiled_transform_all_variants() {
3710 let mut specs: Vec<RecordTransform> = vec![RecordTransform::custom(|v| v)];
3711 #[cfg(feature = "transform-flatten")]
3712 specs.push(RecordTransform::Flatten {
3713 separator: "__".into(),
3714 });
3715 #[cfg(feature = "transform-rename-keys")]
3716 specs.push(RecordTransform::RenameKeys {
3717 pattern: "p".into(),
3718 replacement: "r".into(),
3719 });
3720 #[cfg(feature = "transform-keys-case")]
3721 specs.push(RecordTransform::KeysCase {
3722 mode: KeyCaseMode::Camel,
3723 });
3724 #[cfg(feature = "transform-select")]
3725 specs.push(RecordTransform::Select {
3726 fields: vec!["a".into()],
3727 });
3728 #[cfg(feature = "transform-drop")]
3729 specs.push(RecordTransform::Drop {
3730 fields: vec!["a".into()],
3731 });
3732 #[cfg(feature = "transform-set")]
3733 {
3734 let mut values = Map::new();
3735 values.insert("k".into(), json!("v"));
3736 specs.push(RecordTransform::Set { values });
3737 }
3738 #[cfg(feature = "transform-rename-field")]
3739 {
3740 let mut fields = HashMap::new();
3741 fields.insert("a".to_owned(), "b".to_owned());
3742 specs.push(RecordTransform::RenameField { fields });
3743 }
3744 #[cfg(feature = "transform-cast")]
3745 {
3746 let mut fields = HashMap::new();
3747 fields.insert("a".to_owned(), CastType::Int);
3748 specs.push(RecordTransform::Cast {
3749 fields,
3750 on_error: CastOnError::Null,
3751 });
3752 }
3753 #[cfg(feature = "transform-redact")]
3754 specs.push(RecordTransform::Redact {
3755 fields: vec!["a".into()],
3756 mask: json!("***"),
3757 });
3758 #[cfg(feature = "transform-value-case")]
3759 specs.push(RecordTransform::ValueCase {
3760 fields: vec!["a".into()],
3761 mode: ValueCaseMode::Upper,
3762 });
3763 #[cfg(feature = "transform-spell-symbols")]
3764 specs.push(RecordTransform::SpellSymbols {
3765 extra: HashMap::new(),
3766 separator: " ".into(),
3767 });
3768 #[cfg(feature = "transform-hash")]
3769 specs.push(RecordTransform::Hash {
3770 fields: vec!["a".into()],
3771 algorithm: HashAlgorithm::Blake3,
3772 encoding: HashEncoding::Base64,
3773 salt: None,
3774 into: None,
3775 });
3776 #[cfg(feature = "transform-json-parse")]
3777 specs.push(RecordTransform::JsonParse {
3778 fields: vec!["a".into()],
3779 on_error: JsonParseOnError::Error,
3780 into: None,
3781 });
3782 #[cfg(feature = "transform-coalesce")]
3783 specs.push(RecordTransform::Coalesce {
3784 field: "a".into(),
3785 default: Some(json!("x")),
3786 from: vec![],
3787 treat_empty_string_as_null: true,
3788 });
3789 #[cfg(feature = "transform-split-join")]
3790 {
3791 specs.push(RecordTransform::Split {
3792 field: "a".into(),
3793 delimiter: ",".into(),
3794 trim: false,
3795 into: None,
3796 });
3797 specs.push(RecordTransform::Join {
3798 field: "a".into(),
3799 delimiter: ",".into(),
3800 into: None,
3801 });
3802 }
3803
3804 let original = compiled(&specs);
3807 let cloned: Vec<CompiledTransform> = original.to_vec();
3808 assert_eq!(original.len(), cloned.len());
3809 let record = json!({"a": "1", "k": "x"});
3810 let out_orig = super::apply_all(record.clone(), &original);
3811 let out_clone = super::apply_all(record, &cloned);
3812 assert_eq!(
3813 out_orig.is_ok(),
3814 out_clone.is_ok(),
3815 "clone must transform identically"
3816 );
3817 if let (Ok(a), Ok(b)) = (out_orig, out_clone) {
3818 assert_eq!(a, b);
3819 }
3820 }
3821
3822 #[cfg(feature = "transform-flatten")]
3825 #[test]
3826 fn flatten_passes_through_non_object() {
3827 let record = json!([1, 2, 3]);
3828 let result = apply_all(
3829 record.clone(),
3830 &compiled(&[RecordTransform::Flatten {
3831 separator: "__".into(),
3832 }]),
3833 );
3834 assert_eq!(result, record);
3835 let scalar = json!(42);
3837 let result = apply_all(
3838 scalar.clone(),
3839 &compiled(&[RecordTransform::Flatten {
3840 separator: "__".into(),
3841 }]),
3842 );
3843 assert_eq!(result, scalar);
3844 }
3845
3846 #[cfg(feature = "transform-drop")]
3847 #[test]
3848 fn drop_passes_through_non_object() {
3849 let record = json!([1, 2]);
3850 let result = apply_all(
3851 record.clone(),
3852 &compiled(&[RecordTransform::Drop {
3853 fields: vec!["a".into()],
3854 }]),
3855 );
3856 assert_eq!(result, record);
3857 }
3858
3859 #[cfg(feature = "transform-set")]
3860 #[test]
3861 fn set_passes_through_non_object() {
3862 let mut values = Map::new();
3863 values.insert("k".into(), json!("v"));
3864 let record = json!("scalar");
3865 let result = apply_all(
3866 record.clone(),
3867 &compiled(&[RecordTransform::Set { values }]),
3868 );
3869 assert_eq!(result, record);
3870 }
3871
3872 #[cfg(feature = "transform-rename-field")]
3873 #[test]
3874 fn rename_field_passes_through_non_object() {
3875 let mut fields = HashMap::new();
3876 fields.insert("a".to_owned(), "b".to_owned());
3877 let record = json!([1, 2]);
3878 let result = apply_all(
3879 record.clone(),
3880 &compiled(&[RecordTransform::RenameField { fields }]),
3881 );
3882 assert_eq!(result, record);
3883 }
3884
3885 #[cfg(feature = "transform-rename-field")]
3886 #[test]
3887 fn rename_field_same_name_is_skipped() {
3888 let mut fields = HashMap::new();
3890 fields.insert("a".to_owned(), "a".to_owned());
3891 let record = json!({"a": 1});
3892 let result = apply_all(
3893 record,
3894 &compiled(&[RecordTransform::RenameField { fields }]),
3895 );
3896 assert_eq!(result["a"], 1);
3897 }
3898
3899 #[cfg(feature = "transform-cast")]
3900 #[test]
3901 fn cast_passes_through_non_object() {
3902 let record = json!([1, 2]);
3903 let result = apply_all(
3904 record.clone(),
3905 &compiled(&cast_specs("a", CastType::Int, CastOnError::Error)),
3906 );
3907 assert_eq!(result, record);
3908 }
3909
3910 #[cfg(feature = "transform-redact")]
3911 #[test]
3912 fn redact_passes_through_non_object() {
3913 let record = json!("scalar");
3914 let result = apply_all(
3915 record.clone(),
3916 &compiled(&[RecordTransform::Redact {
3917 fields: vec!["a".into()],
3918 mask: json!("***"),
3919 }]),
3920 );
3921 assert_eq!(result, record);
3922 }
3923
3924 #[cfg(feature = "transform-value-case")]
3925 #[test]
3926 fn value_case_passes_through_non_object() {
3927 let record = json!([1, 2]);
3928 let result = apply_all(
3929 record.clone(),
3930 &compiled(&[RecordTransform::ValueCase {
3931 fields: vec!["a".into()],
3932 mode: ValueCaseMode::Lower,
3933 }]),
3934 );
3935 assert_eq!(result, record);
3936 }
3937
3938 #[cfg(feature = "transform-cast")]
3941 #[test]
3942 fn cast_integer_number_to_int_is_identity() {
3943 let record = json!({"n": 7});
3945 let result = apply_all(
3946 record,
3947 &compiled(&cast_specs("n", CastType::Int, CastOnError::Error)),
3948 );
3949 assert_eq!(result["n"], 7);
3950 }
3951
3952 #[cfg(feature = "transform-cast")]
3953 #[test]
3954 fn cast_bool_to_int() {
3955 let record = json!({"t": true, "f": false});
3956 let mut fields = HashMap::new();
3957 fields.insert("t".to_owned(), CastType::Int);
3958 fields.insert("f".to_owned(), CastType::Int);
3959 let result = apply_all(
3960 record,
3961 &compiled(&[RecordTransform::Cast {
3962 fields,
3963 on_error: CastOnError::Error,
3964 }]),
3965 );
3966 assert_eq!(result["t"], 1);
3967 assert_eq!(result["f"], 0);
3968 }
3969
3970 #[cfg(feature = "transform-cast")]
3971 #[test]
3972 fn cast_null_to_int_errors() {
3973 let record = json!({"n": null});
3974 let err = super::apply_all(
3975 record,
3976 &compiled(&cast_specs("n", CastType::Int, CastOnError::Error)),
3977 )
3978 .expect_err("null cannot become int");
3979 assert!(
3980 format!("{err}").contains("null cannot be cast to int"),
3981 "{err}"
3982 );
3983 }
3984
3985 #[cfg(feature = "transform-cast")]
3986 #[test]
3987 fn cast_composite_to_int_errors() {
3988 let record = json!({"n": [1, 2]});
3989 let err = super::apply_all(
3990 record,
3991 &compiled(&cast_specs("n", CastType::Int, CastOnError::Error)),
3992 )
3993 .expect_err("array cannot become int");
3994 assert!(format!("{err}").contains("composite"), "{err}");
3995 }
3996
3997 #[cfg(feature = "transform-cast")]
3998 #[test]
3999 fn cast_number_to_float() {
4000 let record = json!({"n": 5});
4001 let result = apply_all(
4002 record,
4003 &compiled(&cast_specs("n", CastType::Float, CastOnError::Error)),
4004 );
4005 assert_eq!(result["n"], 5.0);
4006 }
4007
4008 #[cfg(feature = "transform-cast")]
4009 #[test]
4010 fn cast_bool_to_float() {
4011 let record = json!({"t": true, "f": false});
4012 let mut fields = HashMap::new();
4013 fields.insert("t".to_owned(), CastType::Float);
4014 fields.insert("f".to_owned(), CastType::Float);
4015 let result = apply_all(
4016 record,
4017 &compiled(&[RecordTransform::Cast {
4018 fields,
4019 on_error: CastOnError::Error,
4020 }]),
4021 );
4022 assert_eq!(result["t"], 1.0);
4023 assert_eq!(result["f"], 0.0);
4024 }
4025
4026 #[cfg(feature = "transform-cast")]
4027 #[test]
4028 fn cast_null_to_float_errors() {
4029 let record = json!({"n": null});
4030 let err = super::apply_all(
4031 record,
4032 &compiled(&cast_specs("n", CastType::Float, CastOnError::Error)),
4033 )
4034 .expect_err("null cannot become float");
4035 assert!(
4036 format!("{err}").contains("null cannot be cast to float"),
4037 "{err}"
4038 );
4039 }
4040
4041 #[cfg(feature = "transform-cast")]
4042 #[test]
4043 fn cast_composite_to_float_errors() {
4044 let record = json!({"n": {"x": 1}});
4045 let err = super::apply_all(
4046 record,
4047 &compiled(&cast_specs("n", CastType::Float, CastOnError::Error)),
4048 )
4049 .expect_err("object cannot become float");
4050 assert!(format!("{err}").contains("composite"), "{err}");
4051 }
4052
4053 #[cfg(feature = "transform-cast")]
4054 #[test]
4055 fn cast_string_to_float_invalid_errors() {
4056 let record = json!({"n": "not a float"});
4057 let err = super::apply_all(
4058 record,
4059 &compiled(&cast_specs("n", CastType::Float, CastOnError::Error)),
4060 )
4061 .expect_err("non-numeric string cannot become float");
4062 assert!(format!("{err}").contains("is not a float"), "{err}");
4063 }
4064
4065 #[cfg(feature = "transform-cast")]
4066 #[test]
4067 fn cast_bool_to_bool_is_identity() {
4068 let record = json!({"b": true});
4069 let result = apply_all(
4070 record,
4071 &compiled(&cast_specs("b", CastType::Bool, CastOnError::Error)),
4072 );
4073 assert_eq!(result["b"], true);
4074 }
4075
4076 #[cfg(feature = "transform-cast")]
4077 #[test]
4078 fn cast_number_to_bool() {
4079 let record = json!({"on": 1, "off": 0});
4080 let mut fields = HashMap::new();
4081 fields.insert("on".to_owned(), CastType::Bool);
4082 fields.insert("off".to_owned(), CastType::Bool);
4083 let result = apply_all(
4084 record,
4085 &compiled(&[RecordTransform::Cast {
4086 fields,
4087 on_error: CastOnError::Error,
4088 }]),
4089 );
4090 assert_eq!(result["on"], true);
4091 assert_eq!(result["off"], false);
4092 }
4093
4094 #[cfg(feature = "transform-cast")]
4095 #[test]
4096 fn cast_integer_other_than_zero_one_to_bool_errors() {
4097 let record = json!({"n": 7});
4098 let err = super::apply_all(
4099 record,
4100 &compiled(&cast_specs("n", CastType::Bool, CastOnError::Error)),
4101 )
4102 .expect_err("only 0/1 convert to bool");
4103 assert!(format!("{err}").contains("not 0 or 1"), "{err}");
4104 }
4105
4106 #[cfg(feature = "transform-cast")]
4107 #[test]
4108 fn cast_float_number_to_bool_errors() {
4109 let record = json!({"n": 1.5});
4111 let err = super::apply_all(
4112 record,
4113 &compiled(&cast_specs("n", CastType::Bool, CastOnError::Error)),
4114 )
4115 .expect_err("fractional number cannot become bool");
4116 assert!(format!("{err}").contains("not 0 or 1"), "{err}");
4117 }
4118
4119 #[cfg(feature = "transform-cast")]
4120 #[test]
4121 fn cast_unrecognised_string_to_bool_errors() {
4122 let record = json!({"flag": "maybe"});
4123 let err = super::apply_all(
4124 record,
4125 &compiled(&cast_specs("flag", CastType::Bool, CastOnError::Error)),
4126 )
4127 .expect_err("'maybe' is not a boolean");
4128 assert!(
4129 format!("{err}").contains("not a recognised boolean"),
4130 "{err}"
4131 );
4132 }
4133
4134 #[cfg(feature = "transform-cast")]
4135 #[test]
4136 fn cast_null_to_bool_errors() {
4137 let record = json!({"b": null});
4138 let err = super::apply_all(
4139 record,
4140 &compiled(&cast_specs("b", CastType::Bool, CastOnError::Error)),
4141 )
4142 .expect_err("null cannot become bool");
4143 assert!(
4144 format!("{err}").contains("null cannot be cast to bool"),
4145 "{err}"
4146 );
4147 }
4148
4149 #[cfg(feature = "transform-cast")]
4150 #[test]
4151 fn cast_composite_to_bool_errors() {
4152 let record = json!({"b": [true]});
4153 let err = super::apply_all(
4154 record,
4155 &compiled(&cast_specs("b", CastType::Bool, CastOnError::Error)),
4156 )
4157 .expect_err("array cannot become bool");
4158 assert!(format!("{err}").contains("composite"), "{err}");
4159 }
4160
4161 #[cfg(feature = "transform-cast")]
4162 #[test]
4163 fn cast_string_to_string_is_identity() {
4164 let record = json!({"s": "hello"});
4165 let result = apply_all(
4166 record,
4167 &compiled(&cast_specs("s", CastType::String, CastOnError::Error)),
4168 );
4169 assert_eq!(result["s"], "hello");
4170 }
4171
4172 #[cfg(feature = "transform-cast")]
4173 #[test]
4174 fn cast_bool_to_string() {
4175 let record = json!({"b": true});
4176 let result = apply_all(
4177 record,
4178 &compiled(&cast_specs("b", CastType::String, CastOnError::Error)),
4179 );
4180 assert_eq!(result["b"], "true");
4181 }
4182
4183 #[cfg(feature = "transform-cast")]
4184 #[test]
4185 fn cast_null_to_string_errors() {
4186 let record = json!({"s": null});
4187 let err = super::apply_all(
4188 record,
4189 &compiled(&cast_specs("s", CastType::String, CastOnError::Error)),
4190 )
4191 .expect_err("null cannot become string");
4192 assert!(
4193 format!("{err}").contains("null cannot be cast to string"),
4194 "{err}"
4195 );
4196 }
4197
4198 #[cfg(feature = "transform-cast")]
4199 #[test]
4200 fn cast_composite_to_string_errors() {
4201 let record = json!({"s": {"a": 1}});
4202 let err = super::apply_all(
4203 record,
4204 &compiled(&cast_specs("s", CastType::String, CastOnError::Error)),
4205 )
4206 .expect_err("object cannot become string");
4207 assert!(format!("{err}").contains("composite"), "{err}");
4208 }
4209
4210 #[cfg(feature = "transform-cast")]
4211 #[test]
4212 fn cast_invalid_timestamp_string_errors() {
4213 let record = json!({"ts": "not a date"});
4214 let err = super::apply_all(
4215 record,
4216 &compiled(&cast_specs("ts", CastType::Timestamp, CastOnError::Error)),
4217 )
4218 .expect_err("invalid timestamp string");
4219 assert!(format!("{err}").contains("RFC 3339"), "{err}");
4220 }
4221
4222 #[cfg(feature = "transform-cast")]
4223 #[test]
4224 fn cast_non_string_to_timestamp_names_the_type() {
4225 for (val, ty_name) in [
4227 (json!(null), "null"),
4228 (json!(true), "bool"),
4229 (json!(42), "number"),
4230 (json!([1, 2]), "array"),
4231 (json!({"a": 1}), "object"),
4232 ] {
4233 let record = json!({ "ts": val });
4234 let err = super::apply_all(
4235 record,
4236 &compiled(&cast_specs("ts", CastType::Timestamp, CastOnError::Error)),
4237 )
4238 .expect_err("non-string cannot become timestamp");
4239 let msg = format!("{err}");
4240 assert!(msg.contains("timestamp"), "{msg}");
4241 assert!(
4242 msg.contains(ty_name),
4243 "expected type name {ty_name:?} in: {msg}"
4244 );
4245 }
4246 }
4247
4248 #[cfg(feature = "transform-hash")]
4251 fn hash_spec(fields: &[&str], enc: HashEncoding, salt: Option<&str>) -> Vec<RecordTransform> {
4252 vec![RecordTransform::Hash {
4253 fields: fields.iter().map(|s| (*s).to_owned()).collect(),
4254 algorithm: HashAlgorithm::Sha256,
4255 encoding: enc,
4256 salt: salt.map(str::to_owned),
4257 into: None,
4258 }]
4259 }
4260
4261 #[cfg(feature = "transform-hash")]
4262 #[test]
4263 fn hash_replaces_in_place_and_is_stable() {
4264 let a = apply_all(
4265 json!({"email": "a@b.com", "id": 1}),
4266 &compiled(&hash_spec(&["email"], HashEncoding::Hex, None)),
4267 );
4268 let b = apply_all(
4269 json!({"email": "a@b.com", "id": 1}),
4270 &compiled(&hash_spec(&["email"], HashEncoding::Hex, None)),
4271 );
4272 assert_eq!(a["email"], b["email"]);
4274 assert_eq!(a["id"], 1);
4275 assert_eq!(a["email"].as_str().unwrap().len(), 64);
4277 assert_ne!(a["email"], json!("a@b.com"));
4278 }
4279
4280 #[cfg(feature = "transform-hash")]
4281 #[test]
4282 fn hash_salt_changes_output() {
4283 let unsalted = apply_all(
4284 json!({"email": "a@b.com"}),
4285 &compiled(&hash_spec(&["email"], HashEncoding::Hex, None)),
4286 );
4287 let salted = apply_all(
4288 json!({"email": "a@b.com"}),
4289 &compiled(&hash_spec(&["email"], HashEncoding::Hex, Some("pepper"))),
4290 );
4291 assert_ne!(unsalted["email"], salted["email"]);
4292 }
4293
4294 #[cfg(feature = "transform-hash")]
4295 #[test]
4296 fn hash_hex_vs_base64_differ_and_both_decode() {
4297 let hex = apply_all(
4298 json!({"v": "x"}),
4299 &compiled(&hash_spec(&["v"], HashEncoding::Hex, None)),
4300 );
4301 let b64 = apply_all(
4302 json!({"v": "x"}),
4303 &compiled(&hash_spec(&["v"], HashEncoding::Base64, None)),
4304 );
4305 assert_ne!(hex["v"], b64["v"]);
4306 assert_eq!(hex["v"].as_str().unwrap().len(), 64);
4308 assert_eq!(b64["v"].as_str().unwrap().len(), 44);
4309 }
4310
4311 #[cfg(feature = "transform-hash")]
4312 #[test]
4313 fn hash_into_preserves_source() {
4314 let out = apply_all(
4315 json!({"email": "a@b.com"}),
4316 &compiled(&[RecordTransform::Hash {
4317 fields: vec!["email".into()],
4318 algorithm: HashAlgorithm::Sha256,
4319 encoding: HashEncoding::Hex,
4320 salt: None,
4321 into: Some("email_hash".into()),
4322 }]),
4323 );
4324 assert_eq!(out["email"], "a@b.com");
4325 assert_eq!(out["email_hash"].as_str().unwrap().len(), 64);
4326 }
4327
4328 #[cfg(feature = "transform-hash")]
4329 #[test]
4330 fn hash_missing_field_is_no_op() {
4331 let out = apply_all(
4332 json!({"id": 1}),
4333 &compiled(&hash_spec(&["email"], HashEncoding::Hex, None)),
4334 );
4335 assert_eq!(out, json!({"id": 1}));
4336 }
4337
4338 #[cfg(feature = "transform-hash")]
4343 #[test]
4344 fn hash_leaves_null_alone() {
4345 let out = apply_all(
4346 json!({"email": null, "other": "x"}),
4347 &compiled(&hash_spec(&["email"], HashEncoding::Hex, None)),
4348 );
4349 assert_eq!(out["email"], Value::Null, "null must not be hashed");
4350 assert_eq!(out["other"], json!("x"));
4351
4352 let a = apply_all(
4355 json!({"id": 1, "email": null}),
4356 &compiled(&hash_spec(&["email"], HashEncoding::Hex, None)),
4357 );
4358 let b = apply_all(
4359 json!({"id": 2, "email": null}),
4360 &compiled(&hash_spec(&["email"], HashEncoding::Hex, None)),
4361 );
4362 assert_ne!(a, b);
4363 assert!(a["email"].is_null() && b["email"].is_null());
4364 }
4365
4366 #[cfg(feature = "transform-hash")]
4367 #[test]
4368 fn hash_non_string_hashes_canonical_json() {
4369 let out = apply_all(
4371 json!({"n": 42}),
4372 &compiled(&hash_spec(&["n"], HashEncoding::Hex, None)),
4373 );
4374 let expected = hash_string("42", HashAlgorithm::Sha256, HashEncoding::Hex, None);
4375 assert_eq!(out["n"], Value::String(expected));
4376 }
4377
4378 #[cfg(feature = "transform-hash")]
4379 #[test]
4380 fn hash_blake3_differs_from_sha256() {
4381 let sha = apply_all(
4382 json!({"v": "x"}),
4383 &compiled(&[RecordTransform::Hash {
4384 fields: vec!["v".into()],
4385 algorithm: HashAlgorithm::Sha256,
4386 encoding: HashEncoding::Hex,
4387 salt: None,
4388 into: None,
4389 }]),
4390 );
4391 let b3 = apply_all(
4392 json!({"v": "x"}),
4393 &compiled(&[RecordTransform::Hash {
4394 fields: vec!["v".into()],
4395 algorithm: HashAlgorithm::Blake3,
4396 encoding: HashEncoding::Hex,
4397 salt: None,
4398 into: None,
4399 }]),
4400 );
4401 assert_ne!(sha["v"], b3["v"]);
4402 assert_eq!(b3["v"].as_str().unwrap().len(), 64);
4403 }
4404
4405 #[cfg(feature = "transform-hash")]
4406 #[test]
4407 fn hash_empty_fields_is_config_error() {
4408 let res = compile(&RecordTransform::Hash {
4409 fields: vec![],
4410 algorithm: HashAlgorithm::Sha256,
4411 encoding: HashEncoding::Hex,
4412 salt: None,
4413 into: None,
4414 });
4415 assert!(matches!(res, Err(FaucetError::Config(_))));
4416 }
4417
4418 #[cfg(feature = "transform-hash")]
4419 #[test]
4420 fn hash_into_with_multiple_fields_is_config_error() {
4421 let res = compile(&RecordTransform::Hash {
4422 fields: vec!["a".into(), "b".into()],
4423 algorithm: HashAlgorithm::Sha256,
4424 encoding: HashEncoding::Hex,
4425 salt: None,
4426 into: Some("x".into()),
4427 });
4428 assert!(matches!(res, Err(FaucetError::Config(_))));
4429 }
4430
4431 #[cfg(feature = "transform-hash")]
4432 #[test]
4433 fn hash_debug_redacts_salt() {
4434 let dbg = format!(
4435 "{:?}",
4436 RecordTransform::Hash {
4437 fields: vec!["a".into()],
4438 algorithm: HashAlgorithm::Sha256,
4439 encoding: HashEncoding::Hex,
4440 salt: Some("supersecret".into()),
4441 into: None,
4442 }
4443 );
4444 assert!(!dbg.contains("supersecret"), "{dbg}");
4445 assert!(dbg.contains("redacted"), "{dbg}");
4446 }
4447
4448 #[cfg(feature = "transform-json-parse")]
4451 fn json_parse_spec(field: &str, on_error: JsonParseOnError) -> Vec<RecordTransform> {
4452 vec![RecordTransform::JsonParse {
4453 fields: vec![field.to_owned()],
4454 on_error,
4455 into: None,
4456 }]
4457 }
4458
4459 #[cfg(feature = "transform-json-parse")]
4460 #[test]
4461 fn json_parse_object_string_becomes_object() {
4462 let out = apply_all(
4463 json!({"payload": "{\"a\":1,\"b\":[2,3]}"}),
4464 &compiled(&json_parse_spec("payload", JsonParseOnError::Keep)),
4465 );
4466 assert_eq!(out["payload"], json!({"a": 1, "b": [2, 3]}));
4467 }
4468
4469 #[cfg(feature = "transform-json-parse")]
4470 #[test]
4471 fn json_parse_already_parsed_is_no_op() {
4472 let record = json!({"payload": {"a": 1}});
4473 let out = apply_all(
4474 record.clone(),
4475 &compiled(&json_parse_spec("payload", JsonParseOnError::Error)),
4476 );
4477 assert_eq!(out, record);
4478 }
4479
4480 #[cfg(feature = "transform-json-parse")]
4481 #[test]
4482 fn json_parse_missing_field_is_no_op() {
4483 let out = apply_all(
4484 json!({"id": 1}),
4485 &compiled(&json_parse_spec("payload", JsonParseOnError::Error)),
4486 );
4487 assert_eq!(out, json!({"id": 1}));
4488 }
4489
4490 #[cfg(feature = "transform-json-parse")]
4491 #[test]
4492 fn json_parse_invalid_keep_leaves_string() {
4493 let out = apply_all(
4494 json!({"payload": "not json"}),
4495 &compiled(&json_parse_spec("payload", JsonParseOnError::Keep)),
4496 );
4497 assert_eq!(out["payload"], "not json");
4498 }
4499
4500 #[cfg(feature = "transform-json-parse")]
4501 #[test]
4502 fn json_parse_invalid_null_replaces() {
4503 let out = apply_all(
4504 json!({"payload": "not json"}),
4505 &compiled(&json_parse_spec("payload", JsonParseOnError::Null)),
4506 );
4507 assert_eq!(out["payload"], Value::Null);
4508 }
4509
4510 #[cfg(feature = "transform-json-parse")]
4511 #[test]
4512 fn json_parse_invalid_error_propagates() {
4513 let err = super::apply_all(
4514 json!({"payload": "not json"}),
4515 &compiled(&json_parse_spec("payload", JsonParseOnError::Error)),
4516 )
4517 .expect_err("invalid JSON under on_error=error must fail");
4518 assert!(matches!(err, FaucetError::Transform(_)), "{err}");
4519 }
4520
4521 #[cfg(feature = "transform-json-parse")]
4522 #[test]
4523 fn json_parse_into_writes_target() {
4524 let out = apply_all(
4525 json!({"payload": "{\"a\":1}"}),
4526 &compiled(&[RecordTransform::JsonParse {
4527 fields: vec!["payload".into()],
4528 on_error: JsonParseOnError::Error,
4529 into: Some("parsed".into()),
4530 }]),
4531 );
4532 assert_eq!(out["payload"], "{\"a\":1}");
4533 assert_eq!(out["parsed"], json!({"a": 1}));
4534 }
4535
4536 #[cfg(feature = "transform-coalesce")]
4539 #[test]
4540 fn coalesce_default_fills_null_and_absent() {
4541 let spec = |field: &str| {
4542 vec![RecordTransform::Coalesce {
4543 field: field.to_owned(),
4544 default: Some(json!("unknown")),
4545 from: vec![],
4546 treat_empty_string_as_null: false,
4547 }]
4548 };
4549 let a = apply_all(json!({"status": null}), &compiled(&spec("status")));
4551 assert_eq!(a["status"], "unknown");
4552 let b = apply_all(json!({"id": 1}), &compiled(&spec("status")));
4554 assert_eq!(b["status"], "unknown");
4555 }
4556
4557 #[cfg(feature = "transform-coalesce")]
4558 #[test]
4559 fn coalesce_non_null_target_untouched() {
4560 let out = apply_all(
4561 json!({"status": "active"}),
4562 &compiled(&[RecordTransform::Coalesce {
4563 field: "status".into(),
4564 default: Some(json!("unknown")),
4565 from: vec![],
4566 treat_empty_string_as_null: false,
4567 }]),
4568 );
4569 assert_eq!(out["status"], "active");
4570 }
4571
4572 #[cfg(feature = "transform-coalesce")]
4573 #[test]
4574 fn coalesce_from_picks_first_non_null() {
4575 let out = apply_all(
4576 json!({"status": null, "state": null, "phase": "running"}),
4577 &compiled(&[RecordTransform::Coalesce {
4578 field: "status".into(),
4579 default: None,
4580 from: vec!["status".into(), "state".into(), "phase".into()],
4581 treat_empty_string_as_null: false,
4582 }]),
4583 );
4584 assert_eq!(out["status"], "running");
4585 }
4586
4587 #[cfg(feature = "transform-coalesce")]
4588 #[test]
4589 fn coalesce_empty_string_toggle() {
4590 let spec = |treat: bool| {
4591 vec![RecordTransform::Coalesce {
4592 field: "status".into(),
4593 default: Some(json!("unknown")),
4594 from: vec![],
4595 treat_empty_string_as_null: treat,
4596 }]
4597 };
4598 let off = apply_all(json!({"status": ""}), &compiled(&spec(false)));
4600 assert_eq!(off["status"], "");
4601 let on = apply_all(json!({"status": ""}), &compiled(&spec(true)));
4603 assert_eq!(on["status"], "unknown");
4604 }
4605
4606 #[cfg(feature = "transform-coalesce")]
4607 #[test]
4608 fn coalesce_from_all_null_leaves_target() {
4609 let out = apply_all(
4610 json!({"status": null, "state": null}),
4611 &compiled(&[RecordTransform::Coalesce {
4612 field: "status".into(),
4613 default: None,
4614 from: vec!["status".into(), "state".into()],
4615 treat_empty_string_as_null: false,
4616 }]),
4617 );
4618 assert_eq!(out["status"], Value::Null);
4619 }
4620
4621 #[cfg(feature = "transform-coalesce")]
4622 #[test]
4623 fn coalesce_both_default_and_from_is_config_error() {
4624 let res = compile(&RecordTransform::Coalesce {
4625 field: "status".into(),
4626 default: Some(json!("x")),
4627 from: vec!["state".into()],
4628 treat_empty_string_as_null: false,
4629 });
4630 assert!(matches!(res, Err(FaucetError::Config(_))));
4631 }
4632
4633 #[cfg(feature = "transform-coalesce")]
4634 #[test]
4635 fn coalesce_neither_default_nor_from_is_config_error() {
4636 let res = compile(&RecordTransform::Coalesce {
4637 field: "status".into(),
4638 default: None,
4639 from: vec![],
4640 treat_empty_string_as_null: false,
4641 });
4642 assert!(matches!(res, Err(FaucetError::Config(_))));
4643 }
4644
4645 #[cfg(feature = "transform-split-join")]
4648 #[test]
4649 fn split_basic_no_trim() {
4650 let out = apply_all(
4651 json!({"tags": "a, b ,c"}),
4652 &compiled(&[RecordTransform::Split {
4653 field: "tags".into(),
4654 delimiter: ",".into(),
4655 trim: false,
4656 into: None,
4657 }]),
4658 );
4659 assert_eq!(out["tags"], json!(["a", " b ", "c"]));
4660 }
4661
4662 #[cfg(feature = "transform-split-join")]
4663 #[test]
4664 fn split_with_trim_keeps_empty_segments() {
4665 let out = apply_all(
4666 json!({"tags": "a, ,c,"}),
4667 &compiled(&[RecordTransform::Split {
4668 field: "tags".into(),
4669 delimiter: ",".into(),
4670 trim: true,
4671 into: None,
4672 }]),
4673 );
4674 assert_eq!(out["tags"], json!(["a", "", "c", ""]));
4676 }
4677
4678 #[cfg(feature = "transform-split-join")]
4679 #[test]
4680 fn split_empty_input_yields_single_empty() {
4681 let out = apply_all(
4682 json!({"tags": ""}),
4683 &compiled(&[RecordTransform::Split {
4684 field: "tags".into(),
4685 delimiter: ",".into(),
4686 trim: false,
4687 into: None,
4688 }]),
4689 );
4690 assert_eq!(out["tags"], json!([""]));
4691 }
4692
4693 #[cfg(feature = "transform-split-join")]
4694 #[test]
4695 fn split_non_string_is_no_op() {
4696 let record = json!({"tags": [1, 2]});
4697 let out = apply_all(
4698 record.clone(),
4699 &compiled(&[RecordTransform::Split {
4700 field: "tags".into(),
4701 delimiter: ",".into(),
4702 trim: false,
4703 into: None,
4704 }]),
4705 );
4706 assert_eq!(out, record);
4707 }
4708
4709 #[cfg(feature = "transform-split-join")]
4710 #[test]
4711 fn split_into_writes_target() {
4712 let out = apply_all(
4713 json!({"csv": "a,b"}),
4714 &compiled(&[RecordTransform::Split {
4715 field: "csv".into(),
4716 delimiter: ",".into(),
4717 trim: false,
4718 into: Some("arr".into()),
4719 }]),
4720 );
4721 assert_eq!(out["csv"], "a,b");
4722 assert_eq!(out["arr"], json!(["a", "b"]));
4723 }
4724
4725 #[cfg(feature = "transform-split-join")]
4726 #[test]
4727 fn join_basic_and_non_string_elements() {
4728 let out = apply_all(
4729 json!({"parts": ["a", 2, true, null]}),
4730 &compiled(&[RecordTransform::Join {
4731 field: "parts".into(),
4732 delimiter: ",".into(),
4733 into: None,
4734 }]),
4735 );
4736 assert_eq!(out["parts"], "a,2,true,");
4738 }
4739
4740 #[cfg(feature = "transform-split-join")]
4741 #[test]
4742 fn join_non_array_is_no_op() {
4743 let record = json!({"parts": "already a string"});
4744 let out = apply_all(
4745 record.clone(),
4746 &compiled(&[RecordTransform::Join {
4747 field: "parts".into(),
4748 delimiter: ",".into(),
4749 into: None,
4750 }]),
4751 );
4752 assert_eq!(out, record);
4753 }
4754
4755 #[cfg(feature = "transform-split-join")]
4756 #[test]
4757 fn split_then_join_round_trips() {
4758 let out = apply_all(
4759 json!({"tags": "a,b,c"}),
4760 &compiled(&[
4761 RecordTransform::Split {
4762 field: "tags".into(),
4763 delimiter: ",".into(),
4764 trim: false,
4765 into: None,
4766 },
4767 RecordTransform::Join {
4768 field: "tags".into(),
4769 delimiter: ",".into(),
4770 into: None,
4771 },
4772 ]),
4773 );
4774 assert_eq!(out["tags"], "a,b,c");
4775 }
4776
4777 #[cfg(feature = "transform-value-case")]
4780 #[test]
4781 fn value_case_title() {
4782 let out = apply_all(
4783 json!({"city": "new york", "id": 1}),
4784 &compiled(&[RecordTransform::ValueCase {
4785 fields: vec!["city".into()],
4786 mode: ValueCaseMode::Title,
4787 }]),
4788 );
4789 assert_eq!(out["city"], "New York");
4790 assert_eq!(out["id"], 1);
4791 }
4792
4793 #[cfg(feature = "transform-value-case")]
4794 #[test]
4795 fn value_case_title_lowercases_rest_of_word() {
4796 let out = apply_all(
4797 json!({"s": "hELLO WORLD"}),
4798 &compiled(&[RecordTransform::ValueCase {
4799 fields: vec!["s".into()],
4800 mode: ValueCaseMode::Title,
4801 }]),
4802 );
4803 assert_eq!(out["s"], "Hello World");
4804 }
4805
4806 #[cfg(feature = "transform-value-case")]
4807 #[test]
4808 fn value_case_capitalize() {
4809 let out = apply_all(
4810 json!({"s": "hELLO wORLD"}),
4811 &compiled(&[RecordTransform::ValueCase {
4812 fields: vec!["s".into()],
4813 mode: ValueCaseMode::Capitalize,
4814 }]),
4815 );
4816 assert_eq!(out["s"], "Hello world");
4817 }
4818
4819 #[cfg(feature = "transform-value-case")]
4820 #[test]
4821 fn value_case_title_is_idempotent() {
4822 let once = apply_all(
4823 json!({"s": "new york"}),
4824 &compiled(&[RecordTransform::ValueCase {
4825 fields: vec!["s".into()],
4826 mode: ValueCaseMode::Title,
4827 }]),
4828 );
4829 let twice = apply_all(
4830 once.clone(),
4831 &compiled(&[RecordTransform::ValueCase {
4832 fields: vec!["s".into()],
4833 mode: ValueCaseMode::Title,
4834 }]),
4835 );
4836 assert_eq!(once, twice);
4837 }
4838
4839 #[cfg(feature = "transform-value-case")]
4840 #[test]
4841 fn value_case_title_non_string_no_op() {
4842 let out = apply_all(
4843 json!({"n": 42}),
4844 &compiled(&[RecordTransform::ValueCase {
4845 fields: vec!["n".into()],
4846 mode: ValueCaseMode::Title,
4847 }]),
4848 );
4849 assert_eq!(out["n"], 42);
4850 }
4851
4852 #[cfg(feature = "transform-keys-case")]
4855 #[test]
4856 fn keys_case_dot() {
4857 let out = apply_all(
4858 json!({"userId": 1, "First Name": 2, "kebab-case": 3}),
4859 &compiled(&keys_case_specs(KeyCaseMode::Dot)),
4860 );
4861 assert_eq!(out["user.id"], 1);
4862 assert_eq!(out["first.name"], 2);
4863 assert_eq!(out["kebab.case"], 3);
4864 }
4865
4866 #[cfg(feature = "transform-keys-case")]
4867 #[test]
4868 fn keys_case_dot_is_idempotent() {
4869 let once = apply_all(
4870 json!({"userId": 1}),
4871 &compiled(&keys_case_specs(KeyCaseMode::Dot)),
4872 );
4873 let twice = apply_all(once.clone(), &compiled(&keys_case_specs(KeyCaseMode::Dot)));
4874 assert_eq!(once, twice);
4875 assert_eq!(twice["user.id"], 1);
4876 }
4877
4878 #[cfg(feature = "transform-keys-case")]
4879 #[test]
4880 fn keys_case_dot_matches_snake_tokenization() {
4881 let record = json!({"XMLHttpRequest": 1, "second name": 2});
4883 let dot = apply_all(
4884 record.clone(),
4885 &compiled(&keys_case_specs(KeyCaseMode::Dot)),
4886 );
4887 let snake = apply_all(record, &compiled(&keys_case_specs(KeyCaseMode::Snake)));
4888 let dot_keys: Vec<String> = dot.as_object().unwrap().keys().cloned().collect();
4890 let snake_keys: Vec<String> = snake.as_object().unwrap().keys().cloned().collect();
4891 let converted: Vec<String> = snake_keys.iter().map(|k| k.replace('_', ".")).collect();
4892 assert_eq!(dot_keys, converted);
4893 }
4894
4895 #[cfg(feature = "transform-json-parse")]
4898 #[test]
4899 fn json_parse_empty_fields_is_config_error() {
4900 let res = compile(&RecordTransform::JsonParse {
4901 fields: vec![],
4902 on_error: JsonParseOnError::Keep,
4903 into: None,
4904 });
4905 assert!(matches!(res, Err(FaucetError::Config(_))));
4906 }
4907
4908 #[cfg(feature = "transform-json-parse")]
4909 #[test]
4910 fn json_parse_into_with_multiple_fields_is_config_error() {
4911 let res = compile(&RecordTransform::JsonParse {
4912 fields: vec!["a".into(), "b".into()],
4913 on_error: JsonParseOnError::Keep,
4914 into: Some("x".into()),
4915 });
4916 assert!(matches!(res, Err(FaucetError::Config(_))));
4917 }
4918
4919 #[cfg(feature = "transform-hash")]
4920 #[test]
4921 fn hash_passes_through_non_object() {
4922 let record = json!([1, 2, 3]);
4923 let result = apply_all(
4924 record.clone(),
4925 &compiled(&hash_spec(&["v"], HashEncoding::Hex, None)),
4926 );
4927 assert_eq!(result, record);
4928 }
4929
4930 #[cfg(feature = "transform-json-parse")]
4931 #[test]
4932 fn json_parse_passes_through_non_object() {
4933 let record = json!("scalar");
4934 let result = apply_all(
4935 record.clone(),
4936 &compiled(&json_parse_spec("v", JsonParseOnError::Error)),
4937 );
4938 assert_eq!(result, record);
4939 }
4940
4941 #[cfg(feature = "transform-json-encode")]
4942 #[test]
4943 fn json_encode_stringifies_nested_only() {
4944 let out = apply_all(
4945 json!({"id": 1, "addr": {"city": "NYC"}, "tags": [1, 2], "name": "a"}),
4946 &compiled(&[RecordTransform::JsonEncode {
4947 fields: vec![
4948 "addr".into(),
4949 "tags".into(),
4950 "name".into(),
4951 "missing".into(),
4952 ],
4953 }]),
4954 );
4955 assert_eq!(out["addr"], json!("{\"city\":\"NYC\"}"));
4956 assert_eq!(out["tags"], json!("[1,2]"));
4957 assert_eq!(out["name"], json!("a")); assert_eq!(out["id"], json!(1));
4959 }
4960
4961 #[cfg(feature = "transform-lookup")]
4962 fn ref_rows(v: Value) -> Vec<Map<String, Value>> {
4963 v.as_array()
4964 .unwrap()
4965 .iter()
4966 .map(|r| r.as_object().unwrap().clone())
4967 .collect()
4968 }
4969
4970 #[cfg(feature = "transform-lookup")]
4971 #[test]
4972 fn lookup_enriches_on_hit_and_nulls_on_miss() {
4973 let spec = RecordTransform::Lookup {
4974 reference: ref_rows(json!([{"id": 1, "name": "Alice"}, {"id": 2, "name": "Bob"}])),
4975 on_record: "user_id".into(),
4976 on_ref: "id".into(),
4977 add: vec![("user_name".into(), "name".into())],
4978 on_missing: LookupOnMissing::Null,
4979 };
4980 let hit = apply_all(
4981 json!({"user_id": 1}),
4982 &compiled(std::slice::from_ref(&spec)),
4983 );
4984 assert_eq!(hit["user_name"], json!("Alice"));
4985 let miss = apply_all(json!({"user_id": 9}), &compiled(&[spec]));
4986 assert_eq!(miss["user_name"], json!(null));
4987 }
4988
4989 #[cfg(feature = "transform-lookup")]
4990 #[test]
4991 fn lookup_matches_number_against_string_key() {
4992 let spec = RecordTransform::Lookup {
4993 reference: ref_rows(json!([{"code": "42", "label": "answer"}])),
4994 on_record: "code".into(),
4995 on_ref: "code".into(),
4996 add: vec![("label".into(), "label".into())],
4997 on_missing: LookupOnMissing::Keep,
4998 };
4999 let out = apply_all(json!({"code": 42}), &compiled(&[spec]));
5001 assert_eq!(out["label"], json!("answer"));
5002 }
5003
5004 #[cfg(feature = "transform-lookup")]
5005 #[test]
5006 fn lookup_on_missing_error_fails() {
5007 let c = compile(&RecordTransform::Lookup {
5008 reference: Vec::new(),
5009 on_record: "k".into(),
5010 on_ref: "k".into(),
5011 add: vec![("x".into(), "y".into())],
5012 on_missing: LookupOnMissing::Error,
5013 })
5014 .unwrap();
5015 let res = super::apply_all(json!({"k": "z"}), std::slice::from_ref(&c));
5016 assert!(res.is_err());
5017 }
5018
5019 #[cfg(feature = "transform-lookup")]
5020 #[test]
5021 fn lookup_empty_add_is_rejected_at_compile() {
5022 assert!(
5023 compile(&RecordTransform::Lookup {
5024 reference: Vec::new(),
5025 on_record: "k".into(),
5026 on_ref: "k".into(),
5027 add: Vec::new(),
5028 on_missing: LookupOnMissing::Null,
5029 })
5030 .is_err()
5031 );
5032 }
5033
5034 #[cfg(all(feature = "transform-json-encode", feature = "transform-lookup"))]
5035 #[test]
5036 fn json_encode_and_lookup_debug_and_clone() {
5037 let je = RecordTransform::JsonEncode {
5039 fields: vec!["meta".into()],
5040 };
5041 assert!(format!("{je:?}").contains("JsonEncode"));
5042 assert!(format!("{:?}", je.clone()).contains("JsonEncode"));
5043
5044 let mut row = serde_json::Map::new();
5045 row.insert("id".into(), Value::String("1".into()));
5046 let lk = RecordTransform::Lookup {
5047 reference: vec![row],
5048 on_record: "rid".into(),
5049 on_ref: "id".into(),
5050 add: vec![("name".into(), "name".into())],
5051 on_missing: LookupOnMissing::Null,
5052 };
5053 assert!(format!("{lk:?}").contains("Lookup"));
5054 assert!(format!("{:?}", lk.clone()).contains("Lookup"));
5055
5056 let _ = compile(&je).unwrap().clone();
5058 let _ = compile(&lk).unwrap().clone();
5059 }
5060
5061 #[cfg(feature = "transform-coalesce")]
5062 #[test]
5063 fn coalesce_non_null_non_string_target_untouched() {
5064 let out = apply_all(
5067 json!({"n": 0}),
5068 &compiled(&[RecordTransform::Coalesce {
5069 field: "n".into(),
5070 default: Some(json!(99)),
5071 from: vec![],
5072 treat_empty_string_as_null: true,
5073 }]),
5074 );
5075 assert_eq!(out["n"], 0);
5076 }
5077
5078 #[cfg(feature = "transform-coalesce")]
5079 #[test]
5080 fn coalesce_passes_through_non_object() {
5081 let record = json!([1, 2]);
5082 let result = apply_all(
5083 record.clone(),
5084 &compiled(&[RecordTransform::Coalesce {
5085 field: "a".into(),
5086 default: Some(json!("x")),
5087 from: vec![],
5088 treat_empty_string_as_null: false,
5089 }]),
5090 );
5091 assert_eq!(result, record);
5092 }
5093
5094 #[cfg(feature = "transform-split-join")]
5095 #[test]
5096 fn split_and_join_pass_through_non_object() {
5097 let record = json!(42);
5098 let split = apply_all(
5099 record.clone(),
5100 &compiled(&[RecordTransform::Split {
5101 field: "a".into(),
5102 delimiter: ",".into(),
5103 trim: false,
5104 into: None,
5105 }]),
5106 );
5107 assert_eq!(split, record);
5108 let join = apply_all(
5109 record.clone(),
5110 &compiled(&[RecordTransform::Join {
5111 field: "a".into(),
5112 delimiter: ",".into(),
5113 into: None,
5114 }]),
5115 );
5116 assert_eq!(join, record);
5117 }
5118
5119 #[cfg(feature = "transform-split-join")]
5120 #[test]
5121 fn split_empty_delimiter_yields_single_element() {
5122 let out = apply_all(
5123 json!({"s": " hi "}),
5124 &compiled(&[RecordTransform::Split {
5125 field: "s".into(),
5126 delimiter: String::new(),
5127 trim: true,
5128 into: None,
5129 }]),
5130 );
5131 assert_eq!(out["s"], json!(["hi"]));
5133 }
5134
5135 #[cfg(feature = "transform-value-case")]
5136 #[test]
5137 fn value_case_title_and_capitalize_handle_empty_string() {
5138 for mode in [ValueCaseMode::Title, ValueCaseMode::Capitalize] {
5139 let out = apply_all(
5140 json!({"s": ""}),
5141 &compiled(&[RecordTransform::ValueCase {
5142 fields: vec!["s".into()],
5143 mode,
5144 }]),
5145 );
5146 assert_eq!(out["s"], "");
5147 }
5148 }
5149}