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-keys-case")]
155#[derive(
156 Debug,
157 Clone,
158 Copy,
159 PartialEq,
160 Eq,
161 Default,
162 serde::Deserialize,
163 serde::Serialize,
164 schemars::JsonSchema,
165)]
166#[serde(rename_all = "snake_case")]
167pub enum KeyCollision {
168 #[default]
171 Error,
172 Suffix,
177}
178
179#[cfg(feature = "transform-value-case")]
181#[derive(
182 Debug, Clone, Copy, PartialEq, Eq, serde::Deserialize, serde::Serialize, schemars::JsonSchema,
183)]
184#[serde(rename_all = "lowercase")]
185#[non_exhaustive]
188pub enum ValueCaseMode {
189 Lower,
191 Upper,
193 Trim,
195 Title,
198 Capitalize,
201}
202
203#[cfg(feature = "transform-hash")]
205#[derive(
206 Debug,
207 Clone,
208 Copy,
209 PartialEq,
210 Eq,
211 serde::Deserialize,
212 serde::Serialize,
213 schemars::JsonSchema,
214 Default,
215)]
216#[serde(rename_all = "lowercase")]
217pub enum HashAlgorithm {
218 #[default]
220 Sha256,
221 Blake3,
223}
224
225#[cfg(feature = "transform-hash")]
227#[derive(
228 Debug,
229 Clone,
230 Copy,
231 PartialEq,
232 Eq,
233 serde::Deserialize,
234 serde::Serialize,
235 schemars::JsonSchema,
236 Default,
237)]
238#[serde(rename_all = "lowercase")]
239pub enum HashEncoding {
240 #[default]
242 Hex,
243 Base64,
245}
246
247#[cfg(feature = "transform-json-parse")]
253#[derive(
254 Debug,
255 Clone,
256 Copy,
257 PartialEq,
258 Eq,
259 serde::Deserialize,
260 serde::Serialize,
261 schemars::JsonSchema,
262 Default,
263)]
264#[serde(rename_all = "snake_case")]
265pub enum JsonParseOnError {
266 #[default]
268 Keep,
269 Null,
271 Error,
273}
274
275#[non_exhaustive]
291pub enum RecordTransform {
292 #[cfg(feature = "transform-flatten")]
305 Flatten { separator: String },
306
307 #[cfg(feature = "transform-rename-keys")]
322 RenameKeys {
323 pattern: String,
324 replacement: String,
325 },
326
327 #[cfg(feature = "transform-keys-case")]
342 KeysCase {
343 mode: KeyCaseMode,
345 on_collision: KeyCollision,
347 },
348
349 #[cfg(feature = "transform-select")]
356 Select { fields: Vec<String> },
357
358 #[cfg(feature = "transform-drop")]
364 Drop { fields: Vec<String> },
365
366 #[cfg(feature = "transform-set")]
373 Set { values: Map<String, Value> },
374
375 #[cfg(feature = "transform-rename-field")]
384 RenameField {
385 fields: HashMap<String, String>,
387 },
388
389 #[cfg(feature = "transform-cast")]
397 Cast {
398 fields: HashMap<String, CastType>,
399 on_error: CastOnError,
400 },
401
402 #[cfg(feature = "transform-redact")]
409 Redact { fields: Vec<String>, mask: Value },
410
411 #[cfg(feature = "transform-value-case")]
418 ValueCase {
419 fields: Vec<String>,
420 mode: ValueCaseMode,
421 },
422
423 #[cfg(feature = "transform-spell-symbols")]
444 SpellSymbols {
445 extra: HashMap<String, String>,
448 separator: String,
451 },
452
453 #[cfg(feature = "transform-hash")]
467 Hash {
468 fields: Vec<String>,
469 algorithm: HashAlgorithm,
470 encoding: HashEncoding,
471 salt: Option<String>,
472 into: Option<String>,
473 },
474
475 #[cfg(feature = "transform-json-parse")]
486 JsonParse {
487 fields: Vec<String>,
488 on_error: JsonParseOnError,
489 into: Option<String>,
490 },
491
492 #[cfg(feature = "transform-coalesce")]
502 Coalesce {
503 field: String,
504 default: Option<Value>,
506 from: Vec<String>,
509 treat_empty_string_as_null: bool,
511 },
512
513 #[cfg(feature = "transform-split-join")]
521 Split {
522 field: String,
523 delimiter: String,
524 trim: bool,
525 into: Option<String>,
526 },
527
528 #[cfg(feature = "transform-split-join")]
537 Join {
538 field: String,
539 delimiter: String,
540 into: Option<String>,
541 },
542
543 #[cfg(feature = "transform-json-encode")]
553 JsonEncode { fields: Vec<String> },
554
555 #[cfg(feature = "transform-lookup")]
569 Lookup {
570 reference: Vec<Map<String, Value>>,
572 on_record: String,
574 on_ref: String,
576 add: Vec<(String, String)>,
578 on_missing: LookupOnMissing,
580 },
581
582 Custom(Arc<dyn Fn(Value) -> Value + Send + Sync>),
589}
590
591#[cfg(feature = "transform-lookup")]
593#[derive(
594 Debug,
595 Clone,
596 Copy,
597 PartialEq,
598 Eq,
599 Default,
600 serde::Serialize,
601 serde::Deserialize,
602 schemars::JsonSchema,
603)]
604#[serde(rename_all = "snake_case")]
605pub enum LookupOnMissing {
606 #[default]
608 Null,
609 Keep,
611 Error,
613}
614
615impl fmt::Debug for RecordTransform {
616 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
617 match self {
618 #[cfg(feature = "transform-flatten")]
619 Self::Flatten { separator } => f
620 .debug_struct("Flatten")
621 .field("separator", separator)
622 .finish(),
623 #[cfg(feature = "transform-rename-keys")]
624 Self::RenameKeys {
625 pattern,
626 replacement,
627 } => f
628 .debug_struct("RenameKeys")
629 .field("pattern", pattern)
630 .field("replacement", replacement)
631 .finish(),
632 #[cfg(feature = "transform-keys-case")]
633 Self::KeysCase { mode, on_collision } => f
634 .debug_struct("KeysCase")
635 .field("mode", mode)
636 .field("on_collision", on_collision)
637 .finish(),
638 #[cfg(feature = "transform-select")]
639 Self::Select { fields } => f.debug_struct("Select").field("fields", fields).finish(),
640 #[cfg(feature = "transform-drop")]
641 Self::Drop { fields } => f.debug_struct("Drop").field("fields", fields).finish(),
642 #[cfg(feature = "transform-set")]
643 Self::Set { values } => f.debug_struct("Set").field("values", values).finish(),
644 #[cfg(feature = "transform-rename-field")]
645 Self::RenameField { fields } => f
646 .debug_struct("RenameField")
647 .field("fields", fields)
648 .finish(),
649 #[cfg(feature = "transform-cast")]
650 Self::Cast { fields, on_error } => f
651 .debug_struct("Cast")
652 .field("fields", fields)
653 .field("on_error", on_error)
654 .finish(),
655 #[cfg(feature = "transform-redact")]
656 Self::Redact { fields, mask } => f
657 .debug_struct("Redact")
658 .field("fields", fields)
659 .field("mask", mask)
660 .finish(),
661 #[cfg(feature = "transform-value-case")]
662 Self::ValueCase { fields, mode } => f
663 .debug_struct("ValueCase")
664 .field("fields", fields)
665 .field("mode", mode)
666 .finish(),
667 #[cfg(feature = "transform-spell-symbols")]
668 Self::SpellSymbols { extra, separator } => f
669 .debug_struct("SpellSymbols")
670 .field("extra", extra)
671 .field("separator", separator)
672 .finish(),
673 #[cfg(feature = "transform-hash")]
674 Self::Hash {
675 fields,
676 algorithm,
677 encoding,
678 salt,
679 into,
680 } => f
681 .debug_struct("Hash")
682 .field("fields", fields)
683 .field("algorithm", algorithm)
684 .field("encoding", encoding)
685 .field("salt", &salt.as_ref().map(|_| "<redacted>"))
687 .field("into", into)
688 .finish(),
689 #[cfg(feature = "transform-json-parse")]
690 Self::JsonParse {
691 fields,
692 on_error,
693 into,
694 } => f
695 .debug_struct("JsonParse")
696 .field("fields", fields)
697 .field("on_error", on_error)
698 .field("into", into)
699 .finish(),
700 #[cfg(feature = "transform-coalesce")]
701 Self::Coalesce {
702 field,
703 default,
704 from,
705 treat_empty_string_as_null,
706 } => f
707 .debug_struct("Coalesce")
708 .field("field", field)
709 .field("default", default)
710 .field("from", from)
711 .field("treat_empty_string_as_null", treat_empty_string_as_null)
712 .finish(),
713 #[cfg(feature = "transform-split-join")]
714 Self::Split {
715 field,
716 delimiter,
717 trim,
718 into,
719 } => f
720 .debug_struct("Split")
721 .field("field", field)
722 .field("delimiter", delimiter)
723 .field("trim", trim)
724 .field("into", into)
725 .finish(),
726 #[cfg(feature = "transform-split-join")]
727 Self::Join {
728 field,
729 delimiter,
730 into,
731 } => f
732 .debug_struct("Join")
733 .field("field", field)
734 .field("delimiter", delimiter)
735 .field("into", into)
736 .finish(),
737 #[cfg(feature = "transform-json-encode")]
738 Self::JsonEncode { fields } => f
739 .debug_struct("JsonEncode")
740 .field("fields", fields)
741 .finish(),
742 #[cfg(feature = "transform-lookup")]
743 Self::Lookup {
744 reference,
745 on_record,
746 on_ref,
747 add,
748 on_missing,
749 } => f
750 .debug_struct("Lookup")
751 .field("reference_rows", &reference.len())
752 .field("on_record", on_record)
753 .field("on_ref", on_ref)
754 .field("add", add)
755 .field("on_missing", on_missing)
756 .finish(),
757 Self::Custom(_) => write!(f, "Custom(<fn>)"),
758 }
759 }
760}
761
762impl Clone for RecordTransform {
765 fn clone(&self) -> Self {
766 match self {
767 #[cfg(feature = "transform-flatten")]
768 Self::Flatten { separator } => Self::Flatten {
769 separator: separator.clone(),
770 },
771 #[cfg(feature = "transform-rename-keys")]
772 Self::RenameKeys {
773 pattern,
774 replacement,
775 } => Self::RenameKeys {
776 pattern: pattern.clone(),
777 replacement: replacement.clone(),
778 },
779 #[cfg(feature = "transform-keys-case")]
780 Self::KeysCase { mode, on_collision } => Self::KeysCase {
781 mode: *mode,
782 on_collision: *on_collision,
783 },
784 #[cfg(feature = "transform-select")]
785 Self::Select { fields } => Self::Select {
786 fields: fields.clone(),
787 },
788 #[cfg(feature = "transform-drop")]
789 Self::Drop { fields } => Self::Drop {
790 fields: fields.clone(),
791 },
792 #[cfg(feature = "transform-set")]
793 Self::Set { values } => Self::Set {
794 values: values.clone(),
795 },
796 #[cfg(feature = "transform-rename-field")]
797 Self::RenameField { fields } => Self::RenameField {
798 fields: fields.clone(),
799 },
800 #[cfg(feature = "transform-cast")]
801 Self::Cast { fields, on_error } => Self::Cast {
802 fields: fields.clone(),
803 on_error: *on_error,
804 },
805 #[cfg(feature = "transform-redact")]
806 Self::Redact { fields, mask } => Self::Redact {
807 fields: fields.clone(),
808 mask: mask.clone(),
809 },
810 #[cfg(feature = "transform-value-case")]
811 Self::ValueCase { fields, mode } => Self::ValueCase {
812 fields: fields.clone(),
813 mode: *mode,
814 },
815 #[cfg(feature = "transform-spell-symbols")]
816 Self::SpellSymbols { extra, separator } => Self::SpellSymbols {
817 extra: extra.clone(),
818 separator: separator.clone(),
819 },
820 #[cfg(feature = "transform-hash")]
821 Self::Hash {
822 fields,
823 algorithm,
824 encoding,
825 salt,
826 into,
827 } => Self::Hash {
828 fields: fields.clone(),
829 algorithm: *algorithm,
830 encoding: *encoding,
831 salt: salt.clone(),
832 into: into.clone(),
833 },
834 #[cfg(feature = "transform-json-parse")]
835 Self::JsonParse {
836 fields,
837 on_error,
838 into,
839 } => Self::JsonParse {
840 fields: fields.clone(),
841 on_error: *on_error,
842 into: into.clone(),
843 },
844 #[cfg(feature = "transform-coalesce")]
845 Self::Coalesce {
846 field,
847 default,
848 from,
849 treat_empty_string_as_null,
850 } => Self::Coalesce {
851 field: field.clone(),
852 default: default.clone(),
853 from: from.clone(),
854 treat_empty_string_as_null: *treat_empty_string_as_null,
855 },
856 #[cfg(feature = "transform-split-join")]
857 Self::Split {
858 field,
859 delimiter,
860 trim,
861 into,
862 } => Self::Split {
863 field: field.clone(),
864 delimiter: delimiter.clone(),
865 trim: *trim,
866 into: into.clone(),
867 },
868 #[cfg(feature = "transform-split-join")]
869 Self::Join {
870 field,
871 delimiter,
872 into,
873 } => Self::Join {
874 field: field.clone(),
875 delimiter: delimiter.clone(),
876 into: into.clone(),
877 },
878 #[cfg(feature = "transform-json-encode")]
879 Self::JsonEncode { fields } => Self::JsonEncode {
880 fields: fields.clone(),
881 },
882 #[cfg(feature = "transform-lookup")]
883 Self::Lookup {
884 reference,
885 on_record,
886 on_ref,
887 add,
888 on_missing,
889 } => Self::Lookup {
890 reference: reference.clone(),
891 on_record: on_record.clone(),
892 on_ref: on_ref.clone(),
893 add: add.clone(),
894 on_missing: *on_missing,
895 },
896 Self::Custom(f) => Self::Custom(Arc::clone(f)),
897 }
898 }
899}
900
901impl Clone for CompiledTransform {
904 fn clone(&self) -> Self {
905 match self {
906 #[cfg(feature = "transform-flatten")]
907 Self::Flatten { separator } => Self::Flatten {
908 separator: separator.clone(),
909 },
910 #[cfg(feature = "transform-rename-keys")]
911 Self::RenameKeys { re, replacement } => Self::RenameKeys {
912 re: re.clone(),
913 replacement: replacement.clone(),
914 },
915 #[cfg(feature = "transform-keys-case")]
916 Self::KeysCase {
917 mode,
918 on_collision,
919 assignments,
920 } => Self::KeysCase {
921 mode: *mode,
922 on_collision: *on_collision,
923 assignments: assignments.clone(),
924 },
925 #[cfg(feature = "transform-select")]
926 Self::Select { fields } => Self::Select {
927 fields: fields.clone(),
928 },
929 #[cfg(feature = "transform-drop")]
930 Self::Drop { fields } => Self::Drop {
931 fields: fields.clone(),
932 },
933 #[cfg(feature = "transform-set")]
934 Self::Set { values } => Self::Set {
935 values: values.clone(),
936 },
937 #[cfg(feature = "transform-rename-field")]
938 Self::RenameField { fields } => Self::RenameField {
939 fields: fields.clone(),
940 },
941 #[cfg(feature = "transform-cast")]
942 Self::Cast { fields, on_error } => Self::Cast {
943 fields: fields.clone(),
944 on_error: *on_error,
945 },
946 #[cfg(feature = "transform-redact")]
947 Self::Redact { fields, mask } => Self::Redact {
948 fields: fields.clone(),
949 mask: mask.clone(),
950 },
951 #[cfg(feature = "transform-value-case")]
952 Self::ValueCase { fields, mode } => Self::ValueCase {
953 fields: fields.clone(),
954 mode: *mode,
955 },
956 #[cfg(feature = "transform-spell-symbols")]
957 Self::SpellSymbols {
958 replacements,
959 separator,
960 } => Self::SpellSymbols {
961 replacements: replacements.clone(),
962 separator: separator.clone(),
963 },
964 #[cfg(feature = "transform-hash")]
965 Self::Hash {
966 fields,
967 algorithm,
968 encoding,
969 salt,
970 into,
971 } => Self::Hash {
972 fields: fields.clone(),
973 algorithm: *algorithm,
974 encoding: *encoding,
975 salt: salt.clone(),
976 into: into.clone(),
977 },
978 #[cfg(feature = "transform-json-parse")]
979 Self::JsonParse {
980 fields,
981 on_error,
982 into,
983 } => Self::JsonParse {
984 fields: fields.clone(),
985 on_error: *on_error,
986 into: into.clone(),
987 },
988 #[cfg(feature = "transform-coalesce")]
989 Self::Coalesce {
990 field,
991 default,
992 from,
993 treat_empty_string_as_null,
994 } => Self::Coalesce {
995 field: field.clone(),
996 default: default.clone(),
997 from: from.clone(),
998 treat_empty_string_as_null: *treat_empty_string_as_null,
999 },
1000 #[cfg(feature = "transform-split-join")]
1001 Self::Split {
1002 field,
1003 delimiter,
1004 trim,
1005 into,
1006 } => Self::Split {
1007 field: field.clone(),
1008 delimiter: delimiter.clone(),
1009 trim: *trim,
1010 into: into.clone(),
1011 },
1012 #[cfg(feature = "transform-split-join")]
1013 Self::Join {
1014 field,
1015 delimiter,
1016 into,
1017 } => Self::Join {
1018 field: field.clone(),
1019 delimiter: delimiter.clone(),
1020 into: into.clone(),
1021 },
1022 #[cfg(feature = "transform-json-encode")]
1023 Self::JsonEncode { fields } => Self::JsonEncode {
1024 fields: fields.clone(),
1025 },
1026 #[cfg(feature = "transform-lookup")]
1027 Self::Lookup {
1028 index,
1029 on_record,
1030 add,
1031 on_missing,
1032 } => Self::Lookup {
1033 index: index.clone(),
1034 on_record: on_record.clone(),
1035 add: add.clone(),
1036 on_missing: *on_missing,
1037 },
1038 Self::Custom(f) => Self::Custom(Arc::clone(f)),
1039 }
1040 }
1041}
1042
1043impl RecordTransform {
1044 pub fn custom<F>(f: F) -> Self
1068 where
1069 F: Fn(Value) -> Value + Send + Sync + 'static,
1070 {
1071 Self::Custom(Arc::new(f))
1072 }
1073}
1074
1075#[non_exhaustive]
1086pub enum CompiledTransform {
1087 #[cfg(feature = "transform-flatten")]
1088 Flatten {
1089 separator: String,
1090 },
1091 #[cfg(feature = "transform-rename-keys")]
1092 RenameKeys {
1093 re: Regex,
1094 replacement: String,
1095 },
1096 #[cfg(feature = "transform-keys-case")]
1097 KeysCase {
1098 mode: KeyCaseMode,
1099 on_collision: KeyCollision,
1100 assignments: KeyCaseAssignments,
1103 },
1104 #[cfg(feature = "transform-select")]
1105 Select {
1106 fields: Vec<String>,
1107 },
1108 #[cfg(feature = "transform-drop")]
1109 Drop {
1110 fields: Vec<String>,
1111 },
1112 #[cfg(feature = "transform-set")]
1113 Set {
1114 values: Map<String, Value>,
1115 },
1116 #[cfg(feature = "transform-rename-field")]
1117 RenameField {
1118 fields: Vec<(String, String)>,
1121 },
1122 #[cfg(feature = "transform-cast")]
1123 Cast {
1124 fields: HashMap<String, CastType>,
1125 on_error: CastOnError,
1126 },
1127 #[cfg(feature = "transform-redact")]
1128 Redact {
1129 fields: Vec<String>,
1130 mask: Value,
1131 },
1132 #[cfg(feature = "transform-value-case")]
1133 ValueCase {
1134 fields: Vec<String>,
1135 mode: ValueCaseMode,
1136 },
1137 #[cfg(feature = "transform-spell-symbols")]
1138 SpellSymbols {
1139 replacements: Vec<(String, String)>,
1142 separator: String,
1143 },
1144 #[cfg(feature = "transform-hash")]
1145 Hash {
1146 fields: Vec<String>,
1147 algorithm: HashAlgorithm,
1148 encoding: HashEncoding,
1149 salt: Option<String>,
1150 into: Option<String>,
1151 },
1152 #[cfg(feature = "transform-json-parse")]
1153 JsonParse {
1154 fields: Vec<String>,
1155 on_error: JsonParseOnError,
1156 into: Option<String>,
1157 },
1158 #[cfg(feature = "transform-coalesce")]
1159 Coalesce {
1160 field: String,
1161 default: Option<Value>,
1162 from: Vec<String>,
1163 treat_empty_string_as_null: bool,
1164 },
1165 #[cfg(feature = "transform-split-join")]
1166 Split {
1167 field: String,
1168 delimiter: String,
1169 trim: bool,
1170 into: Option<String>,
1171 },
1172 #[cfg(feature = "transform-split-join")]
1173 Join {
1174 field: String,
1175 delimiter: String,
1176 into: Option<String>,
1177 },
1178 #[cfg(feature = "transform-json-encode")]
1179 JsonEncode {
1180 fields: Vec<String>,
1181 },
1182 #[cfg(feature = "transform-lookup")]
1183 Lookup {
1184 index: HashMap<String, Map<String, Value>>,
1187 on_record: String,
1188 add: Vec<(String, String)>,
1189 on_missing: LookupOnMissing,
1190 },
1191 Custom(Arc<dyn Fn(Value) -> Value + Send + Sync>),
1192}
1193
1194pub fn compile(t: &RecordTransform) -> Result<CompiledTransform, FaucetError> {
1198 match t {
1199 #[cfg(feature = "transform-flatten")]
1200 RecordTransform::Flatten { separator } => Ok(CompiledTransform::Flatten {
1201 separator: separator.clone(),
1202 }),
1203 #[cfg(feature = "transform-rename-keys")]
1204 RecordTransform::RenameKeys {
1205 pattern,
1206 replacement,
1207 } => {
1208 let re = Regex::new(pattern)
1209 .map_err(|e| FaucetError::Transform(format!("invalid regex '{pattern}': {e}")))?;
1210 Ok(CompiledTransform::RenameKeys {
1211 re,
1212 replacement: replacement.clone(),
1213 })
1214 }
1215 #[cfg(feature = "transform-keys-case")]
1216 RecordTransform::KeysCase { mode, on_collision } => Ok(CompiledTransform::KeysCase {
1217 mode: *mode,
1218 on_collision: *on_collision,
1219 assignments: KeyCaseAssignments::default(),
1220 }),
1221 #[cfg(feature = "transform-select")]
1222 RecordTransform::Select { fields } => Ok(CompiledTransform::Select {
1223 fields: fields.clone(),
1224 }),
1225 #[cfg(feature = "transform-drop")]
1226 RecordTransform::Drop { fields } => Ok(CompiledTransform::Drop {
1227 fields: fields.clone(),
1228 }),
1229 #[cfg(feature = "transform-set")]
1230 RecordTransform::Set { values } => Ok(CompiledTransform::Set {
1231 values: values.clone(),
1232 }),
1233 #[cfg(feature = "transform-rename-field")]
1234 RecordTransform::RenameField { fields } => {
1235 let mut fields: Vec<(String, String)> =
1240 fields.iter().map(|(f, t)| (f.clone(), t.clone())).collect();
1241 fields.sort();
1242 Ok(CompiledTransform::RenameField { fields })
1243 }
1244 #[cfg(feature = "transform-cast")]
1245 RecordTransform::Cast { fields, on_error } => Ok(CompiledTransform::Cast {
1246 fields: fields.clone(),
1247 on_error: *on_error,
1248 }),
1249 #[cfg(feature = "transform-redact")]
1250 RecordTransform::Redact { fields, mask } => Ok(CompiledTransform::Redact {
1251 fields: fields.clone(),
1252 mask: mask.clone(),
1253 }),
1254 #[cfg(feature = "transform-value-case")]
1255 RecordTransform::ValueCase { fields, mode } => Ok(CompiledTransform::ValueCase {
1256 fields: fields.clone(),
1257 mode: *mode,
1258 }),
1259 #[cfg(feature = "transform-spell-symbols")]
1260 RecordTransform::SpellSymbols { extra, separator } => {
1261 let mut merged = default_symbol_map();
1264 for (k, v) in extra {
1265 merged.insert(k.clone(), v.clone());
1266 }
1267 let mut replacements: Vec<(String, String)> = merged.into_iter().collect();
1268 replacements.sort_by_key(|b| std::cmp::Reverse(b.0.len()));
1269 Ok(CompiledTransform::SpellSymbols {
1270 replacements,
1271 separator: separator.clone(),
1272 })
1273 }
1274 #[cfg(feature = "transform-hash")]
1275 RecordTransform::Hash {
1276 fields,
1277 algorithm,
1278 encoding,
1279 salt,
1280 into,
1281 } => {
1282 if fields.is_empty() {
1283 return Err(FaucetError::Config(
1284 "hash: `fields` must not be empty".to_owned(),
1285 ));
1286 }
1287 if into.is_some() && fields.len() != 1 {
1288 return Err(FaucetError::Config(
1289 "hash: `into` is only valid with exactly one field".to_owned(),
1290 ));
1291 }
1292 Ok(CompiledTransform::Hash {
1293 fields: fields.clone(),
1294 algorithm: *algorithm,
1295 encoding: *encoding,
1296 salt: salt.clone(),
1297 into: into.clone(),
1298 })
1299 }
1300 #[cfg(feature = "transform-json-parse")]
1301 RecordTransform::JsonParse {
1302 fields,
1303 on_error,
1304 into,
1305 } => {
1306 if fields.is_empty() {
1307 return Err(FaucetError::Config(
1308 "json_parse: `fields` must not be empty".to_owned(),
1309 ));
1310 }
1311 if into.is_some() && fields.len() != 1 {
1312 return Err(FaucetError::Config(
1313 "json_parse: `into` is only valid with exactly one field".to_owned(),
1314 ));
1315 }
1316 Ok(CompiledTransform::JsonParse {
1317 fields: fields.clone(),
1318 on_error: *on_error,
1319 into: into.clone(),
1320 })
1321 }
1322 #[cfg(feature = "transform-coalesce")]
1323 RecordTransform::Coalesce {
1324 field,
1325 default,
1326 from,
1327 treat_empty_string_as_null,
1328 } => {
1329 match (default.is_some(), from.is_empty()) {
1330 (true, true) => {}
1332 (false, false) => {}
1334 (true, false) => {
1335 return Err(FaucetError::Config(
1336 "coalesce: set exactly one of `default` or `from`, not both".to_owned(),
1337 ));
1338 }
1339 (false, true) => {
1340 return Err(FaucetError::Config(
1341 "coalesce: set exactly one of `default` or `from`".to_owned(),
1342 ));
1343 }
1344 }
1345 Ok(CompiledTransform::Coalesce {
1346 field: field.clone(),
1347 default: default.clone(),
1348 from: from.clone(),
1349 treat_empty_string_as_null: *treat_empty_string_as_null,
1350 })
1351 }
1352 #[cfg(feature = "transform-split-join")]
1353 RecordTransform::Split {
1354 field,
1355 delimiter,
1356 trim,
1357 into,
1358 } => Ok(CompiledTransform::Split {
1359 field: field.clone(),
1360 delimiter: delimiter.clone(),
1361 trim: *trim,
1362 into: into.clone(),
1363 }),
1364 #[cfg(feature = "transform-split-join")]
1365 RecordTransform::Join {
1366 field,
1367 delimiter,
1368 into,
1369 } => Ok(CompiledTransform::Join {
1370 field: field.clone(),
1371 delimiter: delimiter.clone(),
1372 into: into.clone(),
1373 }),
1374 #[cfg(feature = "transform-json-encode")]
1375 RecordTransform::JsonEncode { fields } => Ok(CompiledTransform::JsonEncode {
1376 fields: fields.clone(),
1377 }),
1378 #[cfg(feature = "transform-lookup")]
1379 RecordTransform::Lookup {
1380 reference,
1381 on_record,
1382 on_ref,
1383 add,
1384 on_missing,
1385 } => {
1386 if on_record.trim().is_empty() || on_ref.trim().is_empty() {
1387 return Err(FaucetError::Transform(
1388 "lookup: `on_record` and `on_ref` must be non-empty".into(),
1389 ));
1390 }
1391 if add.is_empty() {
1392 return Err(FaucetError::Transform(
1393 "lookup: `add` must name at least one output column".into(),
1394 ));
1395 }
1396 let mut index = HashMap::with_capacity(reference.len());
1399 for row in reference {
1400 if let Some(k) = row.get(on_ref).and_then(value_to_key) {
1401 index.entry(k).or_insert_with(|| row.clone());
1402 }
1403 }
1404 Ok(CompiledTransform::Lookup {
1405 index,
1406 on_record: on_record.clone(),
1407 add: add.clone(),
1408 on_missing: *on_missing,
1409 })
1410 }
1411 RecordTransform::Custom(f) => Ok(CompiledTransform::Custom(Arc::clone(f))),
1412 }
1413}
1414
1415#[cfg(feature = "transform-lookup")]
1419fn value_to_key(v: &Value) -> Option<String> {
1420 match v {
1421 Value::String(s) => Some(s.clone()),
1422 Value::Null => None,
1423 Value::Number(n) if n.is_f64() => {
1424 let f = n.as_f64().unwrap_or(f64::NAN);
1425 if f.fract() == 0.0 && f.abs() < 9.007_199_254_740_992e15 {
1426 Some(format!("{}", f as i64))
1427 } else {
1428 Some(n.to_string())
1429 }
1430 }
1431 other => Some(crate::util::canonical_json(other)),
1432 }
1433}
1434
1435pub fn apply_all(record: Value, transforms: &[CompiledTransform]) -> Result<Value, FaucetError> {
1441 let mut acc = record;
1442 for t in transforms {
1443 acc = apply_one(acc, t)?;
1444 }
1445 Ok(acc)
1446}
1447
1448fn apply_one(value: Value, t: &CompiledTransform) -> Result<Value, FaucetError> {
1449 match t {
1450 #[cfg(feature = "transform-flatten")]
1451 CompiledTransform::Flatten { separator } => flatten(value, separator),
1452 #[cfg(feature = "transform-rename-keys")]
1453 CompiledTransform::RenameKeys { re, replacement } => rename_keys(value, re, replacement),
1454 #[cfg(feature = "transform-keys-case")]
1455 CompiledTransform::KeysCase {
1456 mode,
1457 on_collision,
1458 assignments,
1459 } => keys_case(value, *mode, *on_collision, assignments, ""),
1460 #[cfg(feature = "transform-select")]
1461 CompiledTransform::Select { fields } => Ok(select_fields(value, fields)),
1462 #[cfg(feature = "transform-drop")]
1463 CompiledTransform::Drop { fields } => Ok(drop_fields(value, fields)),
1464 #[cfg(feature = "transform-set")]
1465 CompiledTransform::Set { values } => Ok(set_fields(value, values)),
1466 #[cfg(feature = "transform-rename-field")]
1467 CompiledTransform::RenameField { fields } => rename_field(value, fields),
1468 #[cfg(feature = "transform-cast")]
1469 CompiledTransform::Cast { fields, on_error } => cast_fields(value, fields, *on_error),
1470 #[cfg(feature = "transform-redact")]
1471 CompiledTransform::Redact { fields, mask } => Ok(redact_fields(value, fields, mask)),
1472 #[cfg(feature = "transform-value-case")]
1473 CompiledTransform::ValueCase { fields, mode } => Ok(value_case(value, fields, *mode)),
1474 #[cfg(feature = "transform-spell-symbols")]
1475 CompiledTransform::SpellSymbols {
1476 replacements,
1477 separator,
1478 } => spell_symbols(value, replacements, separator),
1479 #[cfg(feature = "transform-hash")]
1480 CompiledTransform::Hash {
1481 fields,
1482 algorithm,
1483 encoding,
1484 salt,
1485 into,
1486 } => Ok(hash_fields(
1487 value,
1488 fields,
1489 *algorithm,
1490 *encoding,
1491 salt.as_deref(),
1492 into.as_deref(),
1493 )),
1494 #[cfg(feature = "transform-json-parse")]
1495 CompiledTransform::JsonParse {
1496 fields,
1497 on_error,
1498 into,
1499 } => json_parse_fields(value, fields, *on_error, into.as_deref()),
1500 #[cfg(feature = "transform-coalesce")]
1501 CompiledTransform::Coalesce {
1502 field,
1503 default,
1504 from,
1505 treat_empty_string_as_null,
1506 } => Ok(coalesce_field(
1507 value,
1508 field,
1509 default.as_ref(),
1510 from,
1511 *treat_empty_string_as_null,
1512 )),
1513 #[cfg(feature = "transform-split-join")]
1514 CompiledTransform::Split {
1515 field,
1516 delimiter,
1517 trim,
1518 into,
1519 } => Ok(split_field(value, field, delimiter, *trim, into.as_deref())),
1520 #[cfg(feature = "transform-split-join")]
1521 CompiledTransform::Join {
1522 field,
1523 delimiter,
1524 into,
1525 } => Ok(join_field(value, field, delimiter, into.as_deref())),
1526 #[cfg(feature = "transform-json-encode")]
1527 CompiledTransform::JsonEncode { fields } => Ok(json_encode_fields(value, fields)),
1528 #[cfg(feature = "transform-lookup")]
1529 CompiledTransform::Lookup {
1530 index,
1531 on_record,
1532 add,
1533 on_missing,
1534 } => lookup_field(value, index, on_record, add, *on_missing),
1535 CompiledTransform::Custom(f) => Ok(f(value)),
1536 }
1537}
1538
1539#[cfg(feature = "transform-flatten")]
1542fn flatten(value: Value, separator: &str) -> Result<Value, FaucetError> {
1543 match value {
1544 Value::Object(_) => {
1545 let mut out = Map::new();
1546 flatten_into(value, "", separator, &mut out)?;
1547 Ok(Value::Object(out))
1548 }
1549 other => Ok(other),
1550 }
1551}
1552
1553#[cfg(feature = "transform-flatten")]
1554fn flatten_into(
1555 value: Value,
1556 prefix: &str,
1557 separator: &str,
1558 out: &mut Map<String, Value>,
1559) -> Result<(), FaucetError> {
1560 match value {
1561 Value::Object(map) => {
1562 for (k, v) in map {
1563 let key = if prefix.is_empty() {
1564 k
1565 } else {
1566 format!("{prefix}{separator}{k}")
1567 };
1568 flatten_into(v, &key, separator, out)?;
1569 }
1570 }
1571 other => {
1572 if out.contains_key(prefix) {
1576 return Err(FaucetError::Transform(format!(
1577 "flatten produced a duplicate key '{prefix}'; two distinct fields collapse \
1578 to the same flattened key (separator '{separator}')"
1579 )));
1580 }
1581 out.insert(prefix.to_string(), other);
1582 }
1583 }
1584 Ok(())
1585}
1586
1587#[cfg(feature = "transform-rename-keys")]
1593fn rename_keys(value: Value, re: &Regex, replacement: &str) -> Result<Value, FaucetError> {
1594 match value {
1595 Value::Object(map) => {
1596 let mut new_map = Map::with_capacity(map.len());
1597 for (k, v) in map {
1598 let new_k = re.replace_all(&k, replacement).into_owned();
1599 let new_v = rename_keys(v, re, replacement)?;
1600 if new_map.contains_key(&new_k) {
1601 return Err(FaucetError::Transform(format!(
1602 "rename_keys produced a duplicate key '{new_k}' (from '{k}'); two \
1603 distinct keys rename to the same name"
1604 )));
1605 }
1606 new_map.insert(new_k, new_v);
1607 }
1608 Ok(Value::Object(new_map))
1609 }
1610 Value::Array(arr) => Ok(Value::Array(
1611 arr.into_iter()
1612 .map(|v| rename_keys(v, re, replacement))
1613 .collect::<Result<_, _>>()?,
1614 )),
1615 other => Ok(other),
1616 }
1617}
1618
1619#[cfg(feature = "transform-keys-case")]
1626#[derive(Debug, Clone, Default)]
1627pub struct KeyCaseAssignments(
1628 std::sync::Arc<std::sync::Mutex<std::collections::HashMap<String, KeyCaseScope>>>,
1629);
1630
1631#[cfg(feature = "transform-keys-case")]
1632#[derive(Debug, Default)]
1633struct KeyCaseScope {
1634 by_source: std::collections::HashMap<String, String>,
1635 taken: std::collections::HashSet<String>,
1636}
1637
1638#[cfg(feature = "transform-keys-case")]
1639impl KeyCaseAssignments {
1640 fn name_for(&self, path: &str, source: &str, recased: String) -> String {
1644 let mut guard = self.0.lock().unwrap_or_else(|p| p.into_inner());
1645 let scope = guard.entry(path.to_owned()).or_default();
1646 if let Some(name) = scope.by_source.get(source) {
1647 return name.clone();
1648 }
1649 let name = if scope.taken.contains(&recased) {
1650 let mut n = 2usize;
1651 let mut candidate = format!("{recased}_{n}");
1652 while scope.taken.contains(&candidate) {
1653 n += 1;
1654 candidate = format!("{recased}_{n}");
1655 }
1656 candidate
1657 } else {
1658 recased
1659 };
1660 scope.taken.insert(name.clone());
1661 scope.by_source.insert(source.to_owned(), name.clone());
1662 name
1663 }
1664}
1665
1666#[cfg(feature = "transform-keys-case")]
1674fn keys_case(
1675 value: Value,
1676 mode: KeyCaseMode,
1677 on_collision: KeyCollision,
1678 assignments: &KeyCaseAssignments,
1679 path: &str,
1680) -> Result<Value, FaucetError> {
1681 match value {
1682 Value::Object(map) => {
1683 let mut new_map = Map::with_capacity(map.len());
1684 for (k, v) in map {
1685 let tokens = tokenize_key(&k);
1686 let recased = if tokens.is_empty() {
1687 k.clone()
1690 } else {
1691 apply_key_case(tokens, mode)
1692 };
1693 let child_path = format!("{path}\u{1f}{k}");
1694 let new_v = keys_case(v, mode, on_collision, assignments, &child_path)?;
1695 let final_key = match on_collision {
1696 KeyCollision::Error if new_map.contains_key(&recased) => {
1697 return Err(FaucetError::Transform(format!(
1698 "keys_case produced a duplicate key '{recased}'; two distinct \
1699 keys re-case to the same name under mode {mode:?} (set \
1700 on_collision: suffix to disambiguate)"
1701 )));
1702 }
1703 KeyCollision::Error => recased,
1704 KeyCollision::Suffix => assignments.name_for(path, &k, recased),
1705 };
1706 new_map.insert(final_key, new_v);
1707 }
1708 Ok(Value::Object(new_map))
1709 }
1710 Value::Array(arr) => {
1711 let mut out = Vec::with_capacity(arr.len());
1712 for v in arr {
1713 out.push(keys_case(v, mode, on_collision, assignments, path)?);
1714 }
1715 Ok(Value::Array(out))
1716 }
1717 other => Ok(other),
1718 }
1719}
1720
1721#[cfg(feature = "transform-keys-case")]
1727fn tokenize_key(key: &str) -> Vec<String> {
1728 crate::util::tokenize_identifier(key)
1731}
1732
1733#[cfg(feature = "transform-keys-case")]
1734fn apply_key_case(tokens: Vec<String>, mode: KeyCaseMode) -> String {
1735 match mode {
1736 KeyCaseMode::Snake => tokens
1737 .iter()
1738 .map(|t| t.to_lowercase())
1739 .collect::<Vec<_>>()
1740 .join("_"),
1741 KeyCaseMode::ScreamingSnake => tokens
1742 .iter()
1743 .map(|t| t.to_uppercase())
1744 .collect::<Vec<_>>()
1745 .join("_"),
1746 KeyCaseMode::Kebab => tokens
1747 .iter()
1748 .map(|t| t.to_lowercase())
1749 .collect::<Vec<_>>()
1750 .join("-"),
1751 KeyCaseMode::Dot => tokens
1752 .iter()
1753 .map(|t| t.to_lowercase())
1754 .collect::<Vec<_>>()
1755 .join("."),
1756 KeyCaseMode::Camel => {
1757 let mut iter = tokens.into_iter();
1758 match iter.next() {
1759 None => String::new(),
1760 Some(first) => {
1761 let mut out = first.to_lowercase();
1762 for t in iter {
1763 out.push_str(&capitalize_token(&t));
1764 }
1765 out
1766 }
1767 }
1768 }
1769 KeyCaseMode::Pascal => tokens
1770 .into_iter()
1771 .map(|t| capitalize_token(&t))
1772 .collect::<String>(),
1773 }
1774}
1775
1776#[cfg(feature = "transform-keys-case")]
1778fn capitalize_token(s: &str) -> String {
1779 let lower = s.to_lowercase();
1780 let mut chars = lower.chars();
1781 match chars.next() {
1782 None => String::new(),
1783 Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
1784 }
1785}
1786
1787#[cfg(feature = "transform-select")]
1790fn select_fields(value: Value, fields: &[String]) -> Value {
1791 match value {
1792 Value::Object(map) => {
1793 let mut out = Map::with_capacity(fields.len().min(map.len()));
1794 for f in fields {
1796 if let Some(v) = map.get(f) {
1797 out.insert(f.clone(), v.clone());
1798 }
1799 }
1800 Value::Object(out)
1801 }
1802 other => other,
1803 }
1804}
1805
1806#[cfg(feature = "transform-drop")]
1809fn drop_fields(value: Value, fields: &[String]) -> Value {
1810 match value {
1811 Value::Object(mut map) => {
1812 for f in fields {
1813 map.remove(f);
1814 }
1815 Value::Object(map)
1816 }
1817 other => other,
1818 }
1819}
1820
1821#[cfg(feature = "transform-set")]
1824fn set_fields(value: Value, values: &Map<String, Value>) -> Value {
1825 match value {
1826 Value::Object(mut map) => {
1827 for (k, v) in values {
1828 map.insert(k.clone(), v.clone());
1829 }
1830 Value::Object(map)
1831 }
1832 other => other,
1833 }
1834}
1835
1836#[cfg(feature = "transform-rename-field")]
1839fn rename_field(value: Value, fields: &[(String, String)]) -> Result<Value, FaucetError> {
1840 match value {
1841 Value::Object(mut map) => {
1842 let renames: Vec<(&str, &str)> = fields
1848 .iter()
1849 .filter(|(from, to)| from != to && map.contains_key(from))
1850 .map(|(from, to)| (from.as_str(), to.as_str()))
1851 .collect();
1852 let sources: std::collections::HashSet<&str> =
1853 renames.iter().map(|(from, _)| *from).collect();
1854
1855 let mut seen_targets: std::collections::HashSet<&str> =
1857 std::collections::HashSet::new();
1858 for (from, to) in &renames {
1859 if !seen_targets.insert(to) {
1860 return Err(FaucetError::Transform(format!(
1861 "rename_field: two fields rename to the same target key '{to}'"
1862 )));
1863 }
1864 if map.contains_key(*to) && !sources.contains(to) {
1868 return Err(FaucetError::Transform(format!(
1869 "rename_field: target key '{to}' already exists on the record \
1870 (renaming from '{from}')"
1871 )));
1872 }
1873 }
1874
1875 let staged: Vec<(String, Value)> = renames
1876 .iter()
1877 .map(|(from, to)| {
1878 let v = map.remove(*from).expect("source presence checked above");
1879 (to.to_string(), v)
1880 })
1881 .collect();
1882 for (to, v) in staged {
1883 map.insert(to, v);
1884 }
1885 Ok(Value::Object(map))
1886 }
1887 other => Ok(other),
1888 }
1889}
1890
1891#[cfg(feature = "transform-cast")]
1894fn cast_fields(
1895 value: Value,
1896 fields: &HashMap<String, CastType>,
1897 on_error: CastOnError,
1898) -> Result<Value, FaucetError> {
1899 match value {
1900 Value::Object(mut map) => {
1901 for (field, target) in fields {
1902 let Some(current) = map.get(field) else {
1903 continue;
1904 };
1905 match cast_value(current, *target) {
1906 Ok(new_val) => {
1907 map.insert(field.clone(), new_val);
1908 }
1909 Err(msg) => match on_error {
1910 CastOnError::Error => {
1911 return Err(FaucetError::Transform(format!(
1912 "cast: field '{field}' to {target:?} failed: {msg}"
1913 )));
1914 }
1915 CastOnError::Null => {
1916 map.insert(field.clone(), Value::Null);
1917 }
1918 CastOnError::Skip => { }
1919 },
1920 }
1921 }
1922 Ok(Value::Object(map))
1923 }
1924 other => Ok(other),
1925 }
1926}
1927
1928#[cfg(feature = "transform-cast")]
1931fn cast_value(v: &Value, target: CastType) -> Result<Value, String> {
1932 match target {
1933 CastType::Int => match v {
1934 Value::Number(n) => {
1935 if let Some(i) = n.as_i64() {
1936 return Ok(Value::Number(i.into()));
1937 }
1938 match n.as_f64() {
1945 Some(f)
1946 if f.fract() == 0.0 && (-(2f64.powi(63))..2f64.powi(63)).contains(&f) =>
1947 {
1948 Ok(Value::Number((f as i64).into()))
1949 }
1950 Some(f) => Err(format!(
1951 "float '{f}' is not a whole number representable as i64"
1952 )),
1953 None => Err(format!("number '{n}' is not representable as i64")),
1954 }
1955 }
1956 Value::String(s) => s
1957 .trim()
1958 .parse::<i64>()
1959 .map(|i| Value::Number(i.into()))
1960 .map_err(|e| format!("'{s}' is not an integer: {e}")),
1961 Value::Bool(b) => Ok(Value::Number(i64::from(*b).into())),
1962 Value::Null => Err("null cannot be cast to int".to_owned()),
1963 Value::Array(_) | Value::Object(_) => {
1964 Err("composite values cannot be cast to int".to_owned())
1965 }
1966 },
1967 CastType::Float => match v {
1968 Value::Number(n) => n
1969 .as_f64()
1970 .and_then(|f| serde_json::Number::from_f64(f).map(Value::Number))
1971 .ok_or_else(|| format!("number '{n}' is not representable as f64")),
1972 Value::String(s) => s
1973 .trim()
1974 .parse::<f64>()
1975 .ok()
1976 .and_then(|f| serde_json::Number::from_f64(f).map(Value::Number))
1977 .ok_or_else(|| format!("'{s}' is not a float")),
1978 Value::Bool(b) => serde_json::Number::from_f64(if *b { 1.0 } else { 0.0 })
1979 .map(Value::Number)
1980 .ok_or_else(|| "could not encode bool as f64".to_owned()),
1981 Value::Null => Err("null cannot be cast to float".to_owned()),
1982 Value::Array(_) | Value::Object(_) => {
1983 Err("composite values cannot be cast to float".to_owned())
1984 }
1985 },
1986 CastType::Bool => match v {
1987 Value::Bool(b) => Ok(Value::Bool(*b)),
1988 Value::Number(n) => {
1989 if let Some(i) = n.as_i64() {
1990 match i {
1991 0 => Ok(Value::Bool(false)),
1992 1 => Ok(Value::Bool(true)),
1993 _ => Err(format!("integer {i} is not 0 or 1")),
1994 }
1995 } else {
1996 Err(format!("number '{n}' is not 0 or 1"))
1997 }
1998 }
1999 Value::String(s) => match s.trim().to_ascii_lowercase().as_str() {
2000 "true" | "1" | "yes" | "y" => Ok(Value::Bool(true)),
2001 "false" | "0" | "no" | "n" => Ok(Value::Bool(false)),
2002 other => Err(format!("'{other}' is not a recognised boolean")),
2003 },
2004 Value::Null => Err("null cannot be cast to bool".to_owned()),
2005 Value::Array(_) | Value::Object(_) => {
2006 Err("composite values cannot be cast to bool".to_owned())
2007 }
2008 },
2009 CastType::String => match v {
2010 Value::String(s) => Ok(Value::String(s.clone())),
2011 Value::Number(n) => Ok(Value::String(n.to_string())),
2012 Value::Bool(b) => Ok(Value::String(b.to_string())),
2013 Value::Null => Err("null cannot be cast to string".to_owned()),
2014 Value::Array(_) | Value::Object(_) => {
2015 Err("composite values cannot be cast to string".to_owned())
2016 }
2017 },
2018 CastType::Timestamp => match v {
2019 Value::String(s) => chrono::DateTime::parse_from_rfc3339(s)
2020 .map(|dt| Value::String(dt.to_rfc3339_opts(chrono::SecondsFormat::AutoSi, true)))
2021 .map_err(|e| format!("'{s}' is not a valid RFC 3339 timestamp: {e}")),
2022 other => Err(format!(
2023 "cannot cast {} to timestamp (expected RFC 3339 string)",
2024 value_type_name(other)
2025 )),
2026 },
2027 }
2028}
2029
2030#[cfg(feature = "transform-cast")]
2031fn value_type_name(v: &Value) -> &'static str {
2032 match v {
2033 Value::Null => "null",
2034 Value::Bool(_) => "bool",
2035 Value::Number(_) => "number",
2036 Value::String(_) => "string",
2037 Value::Array(_) => "array",
2038 Value::Object(_) => "object",
2039 }
2040}
2041
2042#[cfg(feature = "transform-redact")]
2045fn redact_fields(value: Value, fields: &[String], mask: &Value) -> Value {
2046 match value {
2047 Value::Object(mut map) => {
2048 for f in fields {
2049 if map.contains_key(f) {
2050 map.insert(f.clone(), mask.clone());
2051 }
2052 }
2053 Value::Object(map)
2054 }
2055 other => other,
2056 }
2057}
2058
2059#[cfg(feature = "transform-value-case")]
2062fn value_case(value: Value, fields: &[String], mode: ValueCaseMode) -> Value {
2063 match value {
2064 Value::Object(mut map) => {
2065 for f in fields {
2066 if let Some(Value::String(s)) = map.get(f) {
2067 let new_s = match mode {
2068 ValueCaseMode::Lower => s.to_lowercase(),
2069 ValueCaseMode::Upper => s.to_uppercase(),
2070 ValueCaseMode::Trim => s.trim().to_owned(),
2071 ValueCaseMode::Title => title_case(s),
2072 ValueCaseMode::Capitalize => capitalize_str(s),
2073 };
2074 map.insert(f.clone(), Value::String(new_s));
2075 }
2076 }
2077 Value::Object(map)
2078 }
2079 other => other,
2080 }
2081}
2082
2083#[cfg(feature = "transform-value-case")]
2088fn title_case(s: &str) -> String {
2089 let mut out = String::with_capacity(s.len());
2090 let mut at_word_start = true;
2091 for ch in s.chars() {
2092 if ch.is_whitespace() {
2093 at_word_start = true;
2094 out.push(ch);
2095 } else if at_word_start {
2096 out.extend(ch.to_uppercase());
2097 at_word_start = false;
2098 } else {
2099 out.extend(ch.to_lowercase());
2100 }
2101 }
2102 out
2103}
2104
2105#[cfg(feature = "transform-value-case")]
2108fn capitalize_str(s: &str) -> String {
2109 let mut chars = s.chars();
2110 match chars.next() {
2111 None => String::new(),
2112 Some(first) => {
2113 let mut out: String = first.to_uppercase().collect();
2114 out.push_str(&chars.as_str().to_lowercase());
2115 out
2116 }
2117 }
2118}
2119
2120#[cfg(feature = "transform-spell-symbols")]
2131pub fn default_symbol_map() -> HashMap<String, String> {
2132 let pairs: &[(&str, &str)] = &[
2133 ("%", "percent"),
2134 ("#", "number"),
2135 ("$", "dollar"),
2136 ("&", "and"),
2137 ("@", "at"),
2138 ("+", "plus"),
2139 ("*", "star"),
2140 ("=", "equals"),
2141 ("<", "lt"),
2142 (">", "gt"),
2143 ("/", "slash"),
2144 ("\\", "backslash"),
2145 ("|", "pipe"),
2146 ("^", "caret"),
2147 ("~", "tilde"),
2148 ];
2149 pairs
2150 .iter()
2151 .map(|(k, v)| ((*k).to_owned(), (*v).to_owned()))
2152 .collect()
2153}
2154
2155#[cfg(feature = "transform-spell-symbols")]
2156fn spell_symbols(
2157 value: Value,
2158 replacements: &[(String, String)],
2159 separator: &str,
2160) -> Result<Value, FaucetError> {
2161 match value {
2162 Value::Object(map) => {
2163 let mut new_map = Map::with_capacity(map.len());
2164 for (k, v) in map {
2165 let new_k = spell_symbols_in_key(&k, replacements, separator);
2166 let new_v = spell_symbols(v, replacements, separator)?;
2167 if new_map.contains_key(&new_k) {
2171 return Err(FaucetError::Transform(format!(
2172 "spell_symbols produced a duplicate key '{new_k}'; two distinct keys \
2173 expand to the same name"
2174 )));
2175 }
2176 new_map.insert(new_k, new_v);
2177 }
2178 Ok(Value::Object(new_map))
2179 }
2180 Value::Array(arr) => {
2181 let mut out = Vec::with_capacity(arr.len());
2182 for v in arr {
2183 out.push(spell_symbols(v, replacements, separator)?);
2184 }
2185 Ok(Value::Array(out))
2186 }
2187 other => Ok(other),
2188 }
2189}
2190
2191#[cfg(feature = "transform-spell-symbols")]
2195fn spell_symbols_in_key(key: &str, replacements: &[(String, String)], separator: &str) -> String {
2196 let bytes = key.as_bytes();
2200 let mut out = String::with_capacity(key.len());
2201 let mut i = 0;
2202 while i < bytes.len() {
2203 let mut matched = false;
2204 for (from, to) in replacements {
2205 let f = from.as_bytes();
2206 if !f.is_empty() && bytes[i..].starts_with(f) {
2207 out.push_str(separator);
2208 out.push_str(to);
2209 out.push_str(separator);
2210 i += f.len();
2211 matched = true;
2212 break;
2213 }
2214 }
2215 if !matched {
2216 let ch = key[i..]
2219 .chars()
2220 .next()
2221 .expect("non-empty slice yields at least one char");
2222 out.push(ch);
2223 i += ch.len_utf8();
2224 }
2225 }
2226 out
2227}
2228
2229#[cfg(feature = "transform-hash")]
2232fn hash_fields(
2233 value: Value,
2234 fields: &[String],
2235 algorithm: HashAlgorithm,
2236 encoding: HashEncoding,
2237 salt: Option<&str>,
2238 into: Option<&str>,
2239) -> Value {
2240 match value {
2241 Value::Object(mut map) => {
2242 for field in fields {
2243 let Some(current) = map.get(field) else {
2244 continue;
2245 };
2246 if current.is_null() {
2254 continue;
2255 }
2256 let input = match current {
2259 Value::String(s) => s.clone(),
2260 other => crate::util::canonical_json(other),
2261 };
2262 let digest = hash_string(&input, algorithm, encoding, salt);
2263 let target = into.unwrap_or(field.as_str());
2264 map.insert(target.to_owned(), Value::String(digest));
2265 }
2266 Value::Object(map)
2267 }
2268 other => other,
2269 }
2270}
2271
2272#[cfg(feature = "transform-hash")]
2273fn hash_string(
2274 input: &str,
2275 algorithm: HashAlgorithm,
2276 encoding: HashEncoding,
2277 salt: Option<&str>,
2278) -> String {
2279 let mut bytes: Vec<u8> = Vec::with_capacity(salt.map_or(0, str::len) + input.len());
2281 if let Some(s) = salt {
2282 bytes.extend_from_slice(s.as_bytes());
2283 }
2284 bytes.extend_from_slice(input.as_bytes());
2285 let digest: Vec<u8> = match algorithm {
2286 HashAlgorithm::Sha256 => {
2287 use sha2::{Digest, Sha256};
2288 let mut h = Sha256::new();
2289 h.update(&bytes);
2290 h.finalize().to_vec()
2291 }
2292 HashAlgorithm::Blake3 => blake3::hash(&bytes).as_bytes().to_vec(),
2293 };
2294 match encoding {
2295 HashEncoding::Hex => hex_encode(&digest),
2296 HashEncoding::Base64 => {
2297 use base64::Engine;
2298 base64::engine::general_purpose::STANDARD.encode(&digest)
2299 }
2300 }
2301}
2302
2303#[cfg(feature = "transform-hash")]
2304fn hex_encode(bytes: &[u8]) -> String {
2305 const HEX: &[u8; 16] = b"0123456789abcdef";
2306 let mut s = String::with_capacity(bytes.len() * 2);
2307 for &b in bytes {
2308 s.push(HEX[(b >> 4) as usize] as char);
2309 s.push(HEX[(b & 0x0f) as usize] as char);
2310 }
2311 s
2312}
2313
2314#[cfg(feature = "transform-json-encode")]
2319fn json_encode_fields(mut value: Value, fields: &[String]) -> Value {
2320 if let Value::Object(map) = &mut value {
2321 for f in fields {
2322 if let Some(v) = map.get_mut(f)
2323 && matches!(v, Value::Object(_) | Value::Array(_))
2324 {
2325 *v = Value::String(crate::util::canonical_json(v));
2326 }
2327 }
2328 }
2329 value
2330}
2331
2332#[cfg(feature = "transform-lookup")]
2334fn lookup_field(
2335 mut value: Value,
2336 index: &HashMap<String, Map<String, Value>>,
2337 on_record: &str,
2338 add: &[(String, String)],
2339 on_missing: LookupOnMissing,
2340) -> Result<Value, FaucetError> {
2341 let Value::Object(map) = &mut value else {
2342 return Ok(value);
2343 };
2344 let key = map.get(on_record).and_then(value_to_key);
2345 let matched = key.as_deref().and_then(|k| index.get(k));
2346 match matched {
2347 Some(row) => {
2348 for (out, src) in add {
2349 let v = row.get(src).cloned().unwrap_or(Value::Null);
2350 map.insert(out.clone(), v);
2351 }
2352 }
2353 None => match on_missing {
2354 LookupOnMissing::Null => {
2355 for (out, _) in add {
2356 map.insert(out.clone(), Value::Null);
2357 }
2358 }
2359 LookupOnMissing::Keep => {}
2360 LookupOnMissing::Error => {
2361 return Err(FaucetError::Transform(format!(
2362 "lookup: no reference row for {on_record}={:?}",
2363 key.unwrap_or_default()
2364 )));
2365 }
2366 },
2367 }
2368 Ok(value)
2369}
2370
2371#[cfg(feature = "transform-json-parse")]
2374fn json_parse_fields(
2375 value: Value,
2376 fields: &[String],
2377 on_error: JsonParseOnError,
2378 into: Option<&str>,
2379) -> Result<Value, FaucetError> {
2380 match value {
2381 Value::Object(mut map) => {
2382 for field in fields {
2383 let Some(Value::String(s)) = map.get(field) else {
2386 continue;
2387 };
2388 let s = s.clone();
2389 match crate::fidelity::from_str(&s) {
2390 Ok(parsed) => {
2391 let target = into.unwrap_or(field.as_str());
2392 map.insert(target.to_owned(), parsed);
2393 }
2394 Err(e) => match on_error {
2395 JsonParseOnError::Keep => { }
2396 JsonParseOnError::Null => {
2397 let target = into.unwrap_or(field.as_str());
2398 map.insert(target.to_owned(), Value::Null);
2399 }
2400 JsonParseOnError::Error => {
2401 return Err(FaucetError::Transform(format!(
2402 "json_parse: field '{field}' is not valid JSON: {e}"
2403 )));
2404 }
2405 },
2406 }
2407 }
2408 Ok(Value::Object(map))
2409 }
2410 other => Ok(other),
2411 }
2412}
2413
2414#[cfg(feature = "transform-coalesce")]
2417fn coalesce_field(
2418 value: Value,
2419 field: &str,
2420 default: Option<&Value>,
2421 from: &[String],
2422 treat_empty_string_as_null: bool,
2423) -> Value {
2424 match value {
2425 Value::Object(mut map) => {
2426 if is_nullish(map.get(field), treat_empty_string_as_null) {
2427 let replacement: Option<Value> = match default {
2428 Some(d) => Some(d.clone()),
2429 None => from.iter().find_map(|k| {
2430 let v = map.get(k);
2431 if is_nullish(v, treat_empty_string_as_null) {
2432 None
2433 } else {
2434 v.cloned()
2435 }
2436 }),
2437 };
2438 if let Some(v) = replacement {
2439 map.insert(field.to_owned(), v);
2440 }
2441 }
2442 Value::Object(map)
2443 }
2444 other => other,
2445 }
2446}
2447
2448#[cfg(feature = "transform-coalesce")]
2451fn is_nullish(v: Option<&Value>, treat_empty_string_as_null: bool) -> bool {
2452 match v {
2453 None | Some(Value::Null) => true,
2454 Some(Value::String(s)) => treat_empty_string_as_null && s.is_empty(),
2455 _ => false,
2456 }
2457}
2458
2459#[cfg(feature = "transform-split-join")]
2462fn split_field(
2463 value: Value,
2464 field: &str,
2465 delimiter: &str,
2466 trim: bool,
2467 into: Option<&str>,
2468) -> Value {
2469 match value {
2470 Value::Object(mut map) => {
2471 let Some(Value::String(s)) = map.get(field) else {
2472 return Value::Object(map);
2473 };
2474 let s = s.clone();
2475 let parts: Vec<Value> = if delimiter.is_empty() {
2479 vec![Value::String(if trim { s.trim().to_owned() } else { s })]
2480 } else {
2481 s.split(delimiter)
2482 .map(|part| {
2483 let p = if trim { part.trim() } else { part };
2484 Value::String(p.to_owned())
2485 })
2486 .collect()
2487 };
2488 let target = into.unwrap_or(field);
2489 map.insert(target.to_owned(), Value::Array(parts));
2490 Value::Object(map)
2491 }
2492 other => other,
2493 }
2494}
2495
2496#[cfg(feature = "transform-split-join")]
2497fn join_field(value: Value, field: &str, delimiter: &str, into: Option<&str>) -> Value {
2498 match value {
2499 Value::Object(mut map) => {
2500 let Some(Value::Array(arr)) = map.get(field) else {
2501 return Value::Object(map);
2502 };
2503 let joined = arr
2504 .iter()
2505 .map(scalar_to_string)
2506 .collect::<Vec<_>>()
2507 .join(delimiter);
2508 let target = into.unwrap_or(field);
2509 map.insert(target.to_owned(), Value::String(joined));
2510 Value::Object(map)
2511 }
2512 other => other,
2513 }
2514}
2515
2516#[cfg(feature = "transform-split-join")]
2519fn scalar_to_string(v: &Value) -> String {
2520 match v {
2521 Value::String(s) => s.clone(),
2522 Value::Null => String::new(),
2523 other => crate::util::canonical_json(other),
2524 }
2525}
2526
2527#[cfg(test)]
2530mod tests {
2531 use super::*;
2532 use serde_json::json;
2533
2534 fn apply_all(record: Value, transforms: &[CompiledTransform]) -> Value {
2539 super::apply_all(record, transforms).expect("transform should succeed in this test")
2540 }
2541
2542 fn compiled(transforms: &[RecordTransform]) -> Vec<CompiledTransform> {
2543 transforms.iter().map(|t| compile(t).unwrap()).collect()
2544 }
2545
2546 #[test]
2549 fn test_custom_adds_field() {
2550 let record = json!({"id": 1});
2551 let result = apply_all(
2552 record,
2553 &compiled(&[RecordTransform::custom(|mut v| {
2554 if let Value::Object(ref mut m) = v {
2555 m.insert("added".to_string(), json!(true));
2556 }
2557 v
2558 })]),
2559 );
2560 assert_eq!(result["id"], 1);
2561 assert_eq!(result["added"], true);
2562 }
2563
2564 #[test]
2565 fn test_custom_removes_field() {
2566 let record = json!({"id": 1, "secret": "drop_me"});
2567 let result = apply_all(
2568 record,
2569 &compiled(&[RecordTransform::custom(|mut v| {
2570 if let Value::Object(ref mut m) = v {
2571 m.remove("secret");
2572 }
2573 v
2574 })]),
2575 );
2576 assert_eq!(result["id"], 1);
2577 assert!(result.get("secret").is_none());
2578 }
2579
2580 #[test]
2581 fn test_no_transforms_is_identity() {
2582 let record = json!({"id": 1, "name": "Alice"});
2583 let result = apply_all(record.clone(), &[]);
2584 assert_eq!(result, record);
2585 }
2586
2587 #[cfg(feature = "transform-flatten")]
2590 #[test]
2591 fn test_flatten_nested_object() {
2592 let record = json!({"a": {"b": 1, "c": {"d": 2}}, "e": 3});
2593 let result = apply_all(
2594 record,
2595 &compiled(&[RecordTransform::Flatten {
2596 separator: "__".into(),
2597 }]),
2598 );
2599 assert_eq!(result["a__b"], 1);
2600 assert_eq!(result["a__c__d"], 2);
2601 assert_eq!(result["e"], 3);
2602 assert!(result.get("a").is_none(), "nested key should be removed");
2603 }
2604
2605 #[cfg(feature = "transform-flatten")]
2606 #[test]
2607 fn test_flatten_leaves_arrays_intact() {
2608 let record = json!({"tags": ["rust", "api"], "meta": {"count": 2}});
2609 let result = apply_all(
2610 record,
2611 &compiled(&[RecordTransform::Flatten {
2612 separator: ".".into(),
2613 }]),
2614 );
2615 assert_eq!(result["tags"], json!(["rust", "api"]));
2616 assert_eq!(result["meta.count"], 2);
2617 }
2618
2619 #[cfg(feature = "transform-flatten")]
2620 #[test]
2621 fn test_flatten_already_flat() {
2622 let record = json!({"id": 1, "name": "Alice"});
2623 let result = apply_all(
2624 record.clone(),
2625 &compiled(&[RecordTransform::Flatten {
2626 separator: "__".into(),
2627 }]),
2628 );
2629 assert_eq!(result, record);
2630 }
2631
2632 #[cfg(feature = "transform-flatten")]
2633 #[test]
2634 fn test_flatten_empty_separator() {
2635 let record = json!({"a": {"b": 1}});
2636 let result = apply_all(
2637 record,
2638 &compiled(&[RecordTransform::Flatten {
2639 separator: "".into(),
2640 }]),
2641 );
2642 assert_eq!(result["ab"], 1);
2643 }
2644
2645 #[cfg(feature = "transform-rename-keys")]
2648 #[test]
2649 fn test_rename_keys_strips_prefix() {
2650 let record = json!({"_prefix_id": 1, "_prefix_name": "Alice"});
2651 let result = apply_all(
2652 record,
2653 &compiled(&[RecordTransform::RenameKeys {
2654 pattern: r"^_prefix_".into(),
2655 replacement: "".into(),
2656 }]),
2657 );
2658 assert_eq!(result["id"], 1);
2659 assert_eq!(result["name"], "Alice");
2660 }
2661
2662 #[cfg(feature = "transform-rename-keys")]
2663 #[test]
2664 fn test_rename_keys_uppercase_to_placeholder() {
2665 let record = json!({"OUTER": {"INNER": 42}});
2666 let result = apply_all(
2667 record,
2668 &compiled(&[RecordTransform::RenameKeys {
2669 pattern: r"[A-Z]+".into(),
2670 replacement: "x".into(),
2671 }]),
2672 );
2673 assert_eq!(result["x"]["x"], 42);
2674 }
2675
2676 #[cfg(feature = "transform-rename-keys")]
2677 #[test]
2678 fn test_rename_keys_collision_is_an_error() {
2679 let t = compiled(&[RecordTransform::RenameKeys {
2680 pattern: r"[^a-z]".into(),
2681 replacement: "".into(),
2682 }]);
2683 let err = super::apply_all(json!({"a1": 1, "a2": 2}), &t).unwrap_err();
2684 assert!(err.to_string().contains("duplicate key 'a'"), "{err}");
2685 let err = super::apply_all(json!({"x": [{"b1": 1, "b2": 2}]}), &t).unwrap_err();
2686 assert!(err.to_string().contains("duplicate key 'b'"), "{err}");
2687 }
2688
2689 #[cfg(feature = "transform-rename-keys")]
2690 #[test]
2691 fn test_rename_keys_in_array_elements() {
2692 let record = json!({"items": [{"KEY": 1}, {"KEY": 2}]});
2693 let result = apply_all(
2694 record,
2695 &compiled(&[RecordTransform::RenameKeys {
2696 pattern: r"KEY".into(),
2697 replacement: "key".into(),
2698 }]),
2699 );
2700 assert_eq!(result["items"][0]["key"], 1);
2701 assert_eq!(result["items"][1]["key"], 2);
2702 }
2703
2704 #[cfg(feature = "transform-rename-keys")]
2705 #[test]
2706 fn test_rename_keys_invalid_regex_errors_at_compile() {
2707 let err = compile(&RecordTransform::RenameKeys {
2708 pattern: "[invalid".into(),
2709 replacement: "".into(),
2710 });
2711 assert!(err.is_err());
2712 assert!(matches!(err, Err(FaucetError::Transform(_))));
2713 }
2714
2715 #[cfg(feature = "transform-rename-keys")]
2716 #[test]
2717 fn test_rename_keys_chained() {
2718 let record = json!({"__camelCase__": 1});
2719 let result = apply_all(
2720 record,
2721 &compiled(&[
2722 RecordTransform::RenameKeys {
2723 pattern: r"^_+|_+$".into(),
2724 replacement: "".into(),
2725 },
2726 RecordTransform::RenameKeys {
2727 pattern: r"[A-Z]".into(),
2728 replacement: "_".into(),
2729 },
2730 ]),
2731 );
2732 let key = result.as_object().unwrap().keys().next().unwrap().clone();
2733 assert_eq!(key, "camel_ase");
2734 }
2735
2736 #[cfg(all(feature = "transform-keys-case", feature = "transform-flatten"))]
2739 #[test]
2740 fn test_keys_case_then_flatten() {
2741 let record = json!({"User Info": {"First Name": "Alice", "Last Name": "Smith"}});
2742 let result = apply_all(
2743 record,
2744 &compiled(&[
2745 RecordTransform::KeysCase {
2746 mode: KeyCaseMode::Snake,
2747 on_collision: KeyCollision::Error,
2748 },
2749 RecordTransform::Flatten {
2750 separator: "_".into(),
2751 },
2752 ]),
2753 );
2754 assert_eq!(result["user_info_first_name"], "Alice");
2755 assert_eq!(result["user_info_last_name"], "Smith");
2756 }
2757
2758 #[test]
2759 fn test_custom_chained_with_builtin() {
2760 let record = json!({"id": 1, "raw_value": 100});
2762 let result = apply_all(
2763 record,
2764 &compiled(&[
2765 RecordTransform::custom(|mut v| {
2767 if let Some(n) = v.get("raw_value").and_then(|n| n.as_i64())
2768 && let Value::Object(ref mut m) = v
2769 {
2770 m.insert("raw_value".to_string(), json!(n * 2));
2771 }
2772 v
2773 }),
2774 RecordTransform::custom(|mut v| {
2776 if let Value::Object(ref mut m) = v
2777 && let Some(val) = m.remove("raw_value")
2778 {
2779 m.insert("value".to_string(), val);
2780 }
2781 v
2782 }),
2783 ]),
2784 );
2785 assert_eq!(result["id"], 1);
2786 assert_eq!(result["value"], 200);
2787 assert!(result.get("raw_value").is_none());
2788 }
2789
2790 #[cfg(feature = "transform-flatten")]
2793 #[test]
2794 fn flatten_key_collision_errors() {
2795 let record = json!({"a__b": 1, "a": {"b": 2}});
2797 let err = super::apply_all(
2798 record,
2799 &compiled(&[RecordTransform::Flatten {
2800 separator: "__".into(),
2801 }]),
2802 )
2803 .expect_err("colliding flattened keys must error, not drop a value");
2804 assert!(matches!(err, FaucetError::Transform(_)));
2805 assert!(format!("{err}").contains("a__b"), "{err}");
2806 }
2807
2808 #[cfg(feature = "transform-keys-case")]
2809 #[test]
2810 fn keys_case_collision_errors_by_default() {
2811 let record = json!({"AccountId": 1, "account_id": 2});
2813 let err = super::apply_all(
2814 record,
2815 &compiled(&[RecordTransform::KeysCase {
2816 mode: KeyCaseMode::Snake,
2817 on_collision: KeyCollision::Error,
2818 }]),
2819 )
2820 .expect_err("colliding re-cased keys must error by default, not drop a value");
2821 assert!(matches!(err, FaucetError::Transform(_)));
2822 assert!(format!("{err}").contains("account_id"), "{err}");
2823 }
2824
2825 #[cfg(feature = "transform-keys-case")]
2826 #[test]
2827 fn keys_case_collision_suffix_disambiguates() {
2828 let record = json!({"AccountId": 1, "account_id": 2});
2829 let result = apply_all(
2830 record,
2831 &compiled(&[RecordTransform::KeysCase {
2832 mode: KeyCaseMode::Snake,
2833 on_collision: KeyCollision::Suffix,
2834 }]),
2835 );
2836 let obj = result.as_object().unwrap();
2837 let keys: std::collections::BTreeSet<&str> = obj.keys().map(String::as_str).collect();
2839 assert_eq!(
2840 keys,
2841 ["account_id", "account_id_2"].into_iter().collect(),
2842 "got {keys:?}"
2843 );
2844 let vals: std::collections::BTreeSet<i64> =
2845 obj.values().filter_map(Value::as_i64).collect();
2846 assert_eq!(vals, [1, 2].into_iter().collect(), "both values preserved");
2847 }
2848
2849 #[cfg(feature = "transform-keys-case")]
2850 #[test]
2851 fn keys_case_collision_suffix_three_way() {
2852 let record = json!({"Foo": 1, "foo": 2, "FOO": 3});
2854 let result = apply_all(
2855 record,
2856 &compiled(&[RecordTransform::KeysCase {
2857 mode: KeyCaseMode::Snake,
2858 on_collision: KeyCollision::Suffix,
2859 }]),
2860 );
2861 let obj = result.as_object().unwrap();
2862 let keys: std::collections::BTreeSet<&str> = obj.keys().map(String::as_str).collect();
2863 assert_eq!(
2864 keys,
2865 ["foo", "foo_2", "foo_3"].into_iter().collect(),
2866 "got {keys:?}"
2867 );
2868 let vals: std::collections::BTreeSet<i64> =
2869 obj.values().filter_map(Value::as_i64).collect();
2870 assert_eq!(vals, [1, 2, 3].into_iter().collect());
2871 }
2872
2873 #[cfg(feature = "transform-keys-case")]
2874 #[test]
2875 fn keys_case_suffix_keeps_each_source_key_in_one_column_across_records() {
2876 let t = compiled(&[RecordTransform::KeysCase {
2877 mode: KeyCaseMode::Snake,
2878 on_collision: KeyCollision::Suffix,
2879 }]);
2880 let first = apply_all(json!({"AccountId__c": "A", "Account_Id__c": "B"}), &t);
2881 let second = apply_all(json!({"Account_Id__c": "C"}), &t.clone());
2882 let third = apply_all(json!({"AccountId__c": "D", "n": {"Account_Id__c": 1}}), &t);
2883 let col = |v: &Value, want: &str| {
2884 v.as_object()
2885 .unwrap()
2886 .iter()
2887 .find(|(_, x)| *x == want)
2888 .map(|(k, _)| k.clone())
2889 .unwrap()
2890 };
2891 assert_eq!(
2892 col(&first, "B"),
2893 col(&second, "C"),
2894 "Account_Id__c moved columns"
2895 );
2896 assert_eq!(col(&first, "A"), col(&third, "D"));
2897 assert_ne!(col(&first, "A"), col(&first, "B"));
2898 assert_eq!(third["n"], json!({"account_id_c": 1}));
2900 }
2901
2902 #[cfg(feature = "transform-keys-case")]
2903 #[test]
2904 fn keys_case_collision_suffix_is_order_stable() {
2905 let record = json!({"AccountId": 1, "account_id": 2, "Account_Id": 3});
2906 let run = || {
2907 apply_all(
2908 record.clone(),
2909 &compiled(&[RecordTransform::KeysCase {
2910 mode: KeyCaseMode::Snake,
2911 on_collision: KeyCollision::Suffix,
2912 }]),
2913 )
2914 };
2915 assert_eq!(run(), run());
2917 }
2918
2919 #[cfg(feature = "transform-select")]
2922 #[test]
2923 fn select_keeps_only_listed_fields() {
2924 let record = json!({"id": 1, "name": "Alice", "secret": "drop"});
2925 let result = apply_all(
2926 record,
2927 &compiled(&[RecordTransform::Select {
2928 fields: vec!["id".into(), "name".into()],
2929 }]),
2930 );
2931 assert_eq!(result["id"], 1);
2932 assert_eq!(result["name"], "Alice");
2933 assert!(result.get("secret").is_none());
2934 }
2935
2936 #[cfg(feature = "transform-select")]
2937 #[test]
2938 fn select_missing_field_is_no_op() {
2939 let record = json!({"id": 1});
2941 let result = apply_all(
2942 record,
2943 &compiled(&[RecordTransform::Select {
2944 fields: vec!["id".into(), "missing".into()],
2945 }]),
2946 );
2947 assert_eq!(result["id"], 1);
2948 assert!(result.get("missing").is_none());
2949 }
2950
2951 #[cfg(feature = "transform-select")]
2952 #[test]
2953 fn select_passes_through_non_object() {
2954 let record = json!([1, 2, 3]);
2955 let result = apply_all(
2956 record.clone(),
2957 &compiled(&[RecordTransform::Select {
2958 fields: vec!["id".into()],
2959 }]),
2960 );
2961 assert_eq!(result, record);
2962 }
2963
2964 #[cfg(feature = "transform-drop")]
2967 #[test]
2968 fn drop_removes_listed_fields() {
2969 let record = json!({"id": 1, "ssn": "111-22-3333", "name": "Alice"});
2970 let result = apply_all(
2971 record,
2972 &compiled(&[RecordTransform::Drop {
2973 fields: vec!["ssn".into()],
2974 }]),
2975 );
2976 assert_eq!(result["id"], 1);
2977 assert_eq!(result["name"], "Alice");
2978 assert!(result.get("ssn").is_none());
2979 }
2980
2981 #[cfg(feature = "transform-drop")]
2982 #[test]
2983 fn drop_missing_field_is_no_op() {
2984 let record = json!({"id": 1});
2985 let result = apply_all(
2986 record,
2987 &compiled(&[RecordTransform::Drop {
2988 fields: vec!["missing".into()],
2989 }]),
2990 );
2991 assert_eq!(result["id"], 1);
2992 }
2993
2994 #[cfg(feature = "transform-set")]
2997 #[test]
2998 fn set_inserts_new_fields() {
2999 let record = json!({"id": 1});
3000 let mut values = Map::new();
3001 values.insert("_source".into(), json!("api"));
3002 values.insert("ingested_at".into(), json!("2026-01-01"));
3003 let result = apply_all(record, &compiled(&[RecordTransform::Set { values }]));
3004 assert_eq!(result["id"], 1);
3005 assert_eq!(result["_source"], "api");
3006 assert_eq!(result["ingested_at"], "2026-01-01");
3007 }
3008
3009 #[cfg(feature = "transform-set")]
3010 #[test]
3011 fn set_overwrites_existing_field() {
3012 let record = json!({"_source": "old", "id": 1});
3013 let mut values = Map::new();
3014 values.insert("_source".into(), json!("new"));
3015 let result = apply_all(record, &compiled(&[RecordTransform::Set { values }]));
3016 assert_eq!(result["_source"], "new");
3017 assert_eq!(result["id"], 1);
3018 }
3019
3020 #[cfg(feature = "transform-set")]
3021 #[test]
3022 fn set_supports_any_json_value() {
3023 let record = json!({});
3024 let mut values = Map::new();
3025 values.insert("n".into(), json!(42));
3026 values.insert("b".into(), json!(true));
3027 values.insert("arr".into(), json!([1, 2]));
3028 values.insert("obj".into(), json!({"k": "v"}));
3029 values.insert("null".into(), Value::Null);
3030 let result = apply_all(record, &compiled(&[RecordTransform::Set { values }]));
3031 assert_eq!(result["n"], 42);
3032 assert_eq!(result["b"], true);
3033 assert_eq!(result["arr"], json!([1, 2]));
3034 assert_eq!(result["obj"]["k"], "v");
3035 assert_eq!(result["null"], Value::Null);
3036 }
3037
3038 #[cfg(feature = "transform-rename-field")]
3041 #[test]
3042 fn rename_field_renames_exact_key() {
3043 let record = json!({"old_name": 1, "keep": 2});
3044 let mut fields = HashMap::new();
3045 fields.insert("old_name".to_owned(), "new_name".to_owned());
3046 let result = apply_all(
3047 record,
3048 &compiled(&[RecordTransform::RenameField { fields }]),
3049 );
3050 assert_eq!(result["new_name"], 1);
3051 assert_eq!(result["keep"], 2);
3052 assert!(result.get("old_name").is_none());
3053 }
3054
3055 #[cfg(feature = "transform-rename-field")]
3056 #[test]
3057 fn rename_field_missing_source_is_no_op() {
3058 let record = json!({"id": 1});
3059 let mut fields = HashMap::new();
3060 fields.insert("missing".to_owned(), "renamed".to_owned());
3061 let result = apply_all(
3062 record,
3063 &compiled(&[RecordTransform::RenameField { fields }]),
3064 );
3065 assert_eq!(result["id"], 1);
3066 assert!(result.get("renamed").is_none());
3067 }
3068
3069 #[cfg(feature = "transform-rename-field")]
3070 #[test]
3071 fn rename_field_target_collision_errors() {
3072 let record = json!({"a": 1, "b": 2});
3073 let mut fields = HashMap::new();
3074 fields.insert("a".to_owned(), "b".to_owned());
3075 let err = super::apply_all(
3076 record,
3077 &compiled(&[RecordTransform::RenameField { fields }]),
3078 )
3079 .expect_err("collision must error, not overwrite");
3080 assert!(matches!(err, FaucetError::Transform(_)));
3081 assert!(format!("{err}").contains("'b'"), "{err}");
3082 }
3083
3084 #[cfg(feature = "transform-rename-field")]
3085 #[test]
3086 fn rename_field_swap_is_deterministic() {
3087 for _ in 0..50 {
3090 let record = json!({"a": 1, "b": 2, "keep": 3});
3091 let mut fields = HashMap::new();
3092 fields.insert("a".to_owned(), "b".to_owned());
3093 fields.insert("b".to_owned(), "a".to_owned());
3094 let result = apply_all(
3095 record,
3096 &compiled(&[RecordTransform::RenameField { fields }]),
3097 );
3098 assert_eq!(result["a"], 2, "{result}");
3099 assert_eq!(result["b"], 1, "{result}");
3100 assert_eq!(result["keep"], 3);
3101 }
3102 }
3103
3104 #[cfg(feature = "transform-rename-field")]
3105 #[test]
3106 fn rename_field_chain_applies_against_original_snapshot() {
3107 for _ in 0..50 {
3110 let record = json!({"a": 1, "b": 2});
3111 let mut fields = HashMap::new();
3112 fields.insert("a".to_owned(), "b".to_owned());
3113 fields.insert("b".to_owned(), "c".to_owned());
3114 let result = apply_all(
3115 record,
3116 &compiled(&[RecordTransform::RenameField { fields }]),
3117 );
3118 assert_eq!(result["b"], 1, "{result}");
3119 assert_eq!(result["c"], 2, "{result}");
3120 assert!(result.get("a").is_none(), "{result}");
3121 }
3122 }
3123
3124 #[cfg(feature = "transform-rename-field")]
3125 #[test]
3126 fn rename_field_two_sources_one_target_errors() {
3127 let record = json!({"a": 1, "b": 2});
3128 let mut fields = HashMap::new();
3129 fields.insert("a".to_owned(), "c".to_owned());
3130 fields.insert("b".to_owned(), "c".to_owned());
3131 let err = super::apply_all(
3132 record,
3133 &compiled(&[RecordTransform::RenameField { fields }]),
3134 )
3135 .expect_err("two renames to the same target must error");
3136 assert!(format!("{err}").contains("same target"), "{err}");
3137 }
3138
3139 #[cfg(feature = "transform-cast")]
3142 fn cast_specs(field: &str, ty: CastType, on_error: CastOnError) -> Vec<RecordTransform> {
3143 let mut fields = HashMap::new();
3144 fields.insert(field.to_owned(), ty);
3145 vec![RecordTransform::Cast { fields, on_error }]
3146 }
3147
3148 #[cfg(feature = "transform-cast")]
3149 #[test]
3150 fn cast_string_to_int() {
3151 let record = json!({"age": "42"});
3152 let result = apply_all(
3153 record,
3154 &compiled(&cast_specs("age", CastType::Int, CastOnError::Error)),
3155 );
3156 assert_eq!(result["age"], 42);
3157 }
3158
3159 #[cfg(feature = "transform-cast")]
3160 #[test]
3161 fn cast_whole_number_float_to_int_succeeds() {
3162 let record = json!({"n": 5.0});
3164 let result = apply_all(
3165 record,
3166 &compiled(&cast_specs("n", CastType::Int, CastOnError::Error)),
3167 );
3168 assert_eq!(result["n"], 5);
3169 }
3170
3171 #[cfg(feature = "transform-cast")]
3172 #[test]
3173 fn cast_fractional_float_to_int_errors_under_on_error_error() {
3174 let record = json!({"n": 3.9});
3176 let err = super::apply_all(
3177 record,
3178 &compiled(&cast_specs("n", CastType::Int, CastOnError::Error)),
3179 )
3180 .expect_err("a fractional float must not silently truncate to int");
3181 assert!(matches!(err, FaucetError::Transform(_)), "{err}");
3182 }
3183
3184 #[cfg(feature = "transform-cast")]
3185 #[test]
3186 fn cast_out_of_range_float_to_int_errors_under_on_error_error() {
3187 let record = json!({"n": 1e30});
3189 let err = super::apply_all(
3190 record,
3191 &compiled(&cast_specs("n", CastType::Int, CastOnError::Error)),
3192 )
3193 .expect_err("an out-of-range float must not silently saturate to i64::MAX");
3194 assert!(matches!(err, FaucetError::Transform(_)), "{err}");
3195 }
3196
3197 #[cfg(feature = "transform-cast")]
3198 #[test]
3199 fn cast_fractional_float_to_int_nulls_under_on_error_null() {
3200 let record = json!({"n": 3.9});
3201 let result = apply_all(
3202 record,
3203 &compiled(&cast_specs("n", CastType::Int, CastOnError::Null)),
3204 );
3205 assert_eq!(result["n"], Value::Null);
3206 }
3207
3208 #[cfg(feature = "transform-cast")]
3209 #[test]
3210 fn cast_string_to_float() {
3211 let record = json!({"price": "9.99"});
3212 let result = apply_all(
3213 record,
3214 &compiled(&cast_specs("price", CastType::Float, CastOnError::Error)),
3215 );
3216 assert_eq!(result["price"], 9.99);
3217 }
3218
3219 #[cfg(feature = "transform-cast")]
3220 #[test]
3221 fn cast_string_to_bool() {
3222 for input in ["true", "TRUE", "1", "yes"] {
3223 let record = json!({"flag": input});
3224 let result = apply_all(
3225 record,
3226 &compiled(&cast_specs("flag", CastType::Bool, CastOnError::Error)),
3227 );
3228 assert_eq!(result["flag"], true, "input was {input:?}");
3229 }
3230 for input in ["false", "0", "no"] {
3231 let record = json!({"flag": input});
3232 let result = apply_all(
3233 record,
3234 &compiled(&cast_specs("flag", CastType::Bool, CastOnError::Error)),
3235 );
3236 assert_eq!(result["flag"], false, "input was {input:?}");
3237 }
3238 }
3239
3240 #[cfg(feature = "transform-cast")]
3241 #[test]
3242 fn cast_number_to_string() {
3243 let record = json!({"id": 42});
3244 let result = apply_all(
3245 record,
3246 &compiled(&cast_specs("id", CastType::String, CastOnError::Error)),
3247 );
3248 assert_eq!(result["id"], "42");
3249 }
3250
3251 #[cfg(feature = "transform-cast")]
3252 #[test]
3253 fn cast_string_to_timestamp_normalises() {
3254 let record = json!({"ts": "2026-05-28T12:34:56+00:00"});
3255 let result = apply_all(
3256 record,
3257 &compiled(&cast_specs("ts", CastType::Timestamp, CastOnError::Error)),
3258 );
3259 assert_eq!(result["ts"], "2026-05-28T12:34:56Z");
3261 }
3262
3263 #[cfg(feature = "transform-cast")]
3264 #[test]
3265 fn cast_on_error_error_propagates() {
3266 let record = json!({"age": "not a number"});
3267 let err = super::apply_all(
3268 record,
3269 &compiled(&cast_specs("age", CastType::Int, CastOnError::Error)),
3270 )
3271 .expect_err("uncastable value must error under on_error=error");
3272 assert!(matches!(err, FaucetError::Transform(_)));
3273 assert!(format!("{err}").contains("'age'"), "{err}");
3274 }
3275
3276 #[cfg(feature = "transform-cast")]
3277 #[test]
3278 fn cast_on_error_null_replaces() {
3279 let record = json!({"age": "not a number"});
3280 let result = apply_all(
3281 record,
3282 &compiled(&cast_specs("age", CastType::Int, CastOnError::Null)),
3283 );
3284 assert_eq!(result["age"], Value::Null);
3285 }
3286
3287 #[cfg(feature = "transform-cast")]
3288 #[test]
3289 fn cast_on_error_skip_leaves_value() {
3290 let record = json!({"age": "not a number"});
3291 let result = apply_all(
3292 record,
3293 &compiled(&cast_specs("age", CastType::Int, CastOnError::Skip)),
3294 );
3295 assert_eq!(result["age"], "not a number");
3296 }
3297
3298 #[cfg(feature = "transform-cast")]
3299 #[test]
3300 fn cast_missing_field_is_no_op() {
3301 let record = json!({"id": 1});
3302 let result = apply_all(
3303 record,
3304 &compiled(&cast_specs("missing", CastType::Int, CastOnError::Error)),
3305 );
3306 assert_eq!(result["id"], 1);
3307 assert!(result.get("missing").is_none());
3308 }
3309
3310 #[cfg(feature = "transform-redact")]
3313 #[test]
3314 fn redact_replaces_value_with_mask() {
3315 let record = json!({"id": 1, "ssn": "111-22-3333", "email": "x@y.z"});
3316 let result = apply_all(
3317 record,
3318 &compiled(&[RecordTransform::Redact {
3319 fields: vec!["ssn".into(), "email".into()],
3320 mask: json!("***"),
3321 }]),
3322 );
3323 assert_eq!(result["id"], 1);
3324 assert_eq!(result["ssn"], "***");
3325 assert_eq!(result["email"], "***");
3326 }
3327
3328 #[cfg(feature = "transform-redact")]
3329 #[test]
3330 fn redact_missing_field_does_not_insert_mask() {
3331 let record = json!({"id": 1});
3332 let result = apply_all(
3333 record,
3334 &compiled(&[RecordTransform::Redact {
3335 fields: vec!["ssn".into()],
3336 mask: json!("***"),
3337 }]),
3338 );
3339 assert_eq!(result["id"], 1);
3340 assert!(result.get("ssn").is_none());
3341 }
3342
3343 #[cfg(feature = "transform-value-case")]
3346 #[test]
3347 fn value_case_lower() {
3348 let record = json!({"email": "User@Example.COM", "id": 1});
3349 let result = apply_all(
3350 record,
3351 &compiled(&[RecordTransform::ValueCase {
3352 fields: vec!["email".into()],
3353 mode: ValueCaseMode::Lower,
3354 }]),
3355 );
3356 assert_eq!(result["email"], "user@example.com");
3357 assert_eq!(result["id"], 1);
3358 }
3359
3360 #[cfg(feature = "transform-value-case")]
3361 #[test]
3362 fn value_case_upper() {
3363 let record = json!({"code": "abc"});
3364 let result = apply_all(
3365 record,
3366 &compiled(&[RecordTransform::ValueCase {
3367 fields: vec!["code".into()],
3368 mode: ValueCaseMode::Upper,
3369 }]),
3370 );
3371 assert_eq!(result["code"], "ABC");
3372 }
3373
3374 #[cfg(feature = "transform-value-case")]
3375 #[test]
3376 fn value_case_trim() {
3377 let record = json!({"name": " Alice "});
3378 let result = apply_all(
3379 record,
3380 &compiled(&[RecordTransform::ValueCase {
3381 fields: vec!["name".into()],
3382 mode: ValueCaseMode::Trim,
3383 }]),
3384 );
3385 assert_eq!(result["name"], "Alice");
3386 }
3387
3388 #[cfg(feature = "transform-value-case")]
3389 #[test]
3390 fn value_case_passes_non_string_through() {
3391 let record = json!({"id": 42});
3392 let result = apply_all(
3393 record,
3394 &compiled(&[RecordTransform::ValueCase {
3395 fields: vec!["id".into()],
3396 mode: ValueCaseMode::Upper,
3397 }]),
3398 );
3399 assert_eq!(result["id"], 42);
3400 }
3401
3402 #[cfg(feature = "transform-spell-symbols")]
3405 fn spell_default() -> Vec<RecordTransform> {
3406 vec![RecordTransform::SpellSymbols {
3407 extra: HashMap::new(),
3408 separator: " ".into(),
3409 }]
3410 }
3411
3412 #[cfg(feature = "transform-spell-symbols")]
3413 #[test]
3414 fn spell_symbols_replaces_common_symbols() {
3415 let record = json!({"%sold": 1, "C#course": 2, "$amount": 3});
3416 let result = apply_all(record, &compiled(&spell_default()));
3417 assert!(result.get(" percent sold").is_some());
3420 assert!(result.get("C number course").is_some());
3421 assert!(result.get(" dollar amount").is_some());
3422 }
3423
3424 #[cfg(all(feature = "transform-spell-symbols", feature = "transform-keys-case"))]
3425 #[test]
3426 fn spell_symbols_then_keys_case_pipeline() {
3427 let record = json!({"% sold": 10, "C# courses": 20});
3428 let result = super::apply_all(
3429 record,
3430 &compiled(&[
3431 RecordTransform::SpellSymbols {
3432 extra: HashMap::new(),
3433 separator: " ".into(),
3434 },
3435 RecordTransform::KeysCase {
3436 mode: KeyCaseMode::Snake,
3437 on_collision: KeyCollision::Error,
3438 },
3439 ]),
3440 )
3441 .expect("pipeline must succeed");
3442 assert_eq!(result["percent_sold"], 10);
3443 assert_eq!(result["c_number_courses"], 20);
3444 }
3445
3446 #[cfg(feature = "transform-spell-symbols")]
3447 #[test]
3448 fn spell_symbols_extra_overrides_defaults() {
3449 let mut extra = HashMap::new();
3450 extra.insert("#".to_owned(), "hash".to_owned());
3451 extra.insert("©".to_owned(), "copyright".to_owned());
3452 let record = json!({"#tag": 1, "©2026": 2});
3453 let result = apply_all(
3454 record,
3455 &compiled(&[RecordTransform::SpellSymbols {
3456 extra,
3457 separator: " ".into(),
3458 }]),
3459 );
3460 assert!(result.get(" hash tag").is_some());
3462 assert!(result.get(" copyright 2026").is_some());
3464 }
3465
3466 #[cfg(feature = "transform-spell-symbols")]
3467 #[test]
3468 fn spell_symbols_longest_match_wins() {
3469 let mut extra = HashMap::new();
3471 extra.insert("<=".to_owned(), "lte".to_owned());
3472 let record = json!({"a<=b": 1});
3473 let result = apply_all(
3474 record,
3475 &compiled(&[RecordTransform::SpellSymbols {
3476 extra,
3477 separator: " ".into(),
3478 }]),
3479 );
3480 assert!(result.get("a lte b").is_some());
3481 assert!(result.get("a lt = b").is_none());
3483 }
3484
3485 #[cfg(feature = "transform-spell-symbols")]
3486 #[test]
3487 fn spell_symbols_recursive_into_objects_and_arrays() {
3488 let record = json!({"outer&": {"inner%": [{"deep#": 1}]}});
3489 let result = apply_all(record, &compiled(&spell_default()));
3490 let outer_key = result.as_object().unwrap().keys().next().unwrap().clone();
3491 assert!(outer_key.contains("and"), "outer key was {outer_key:?}");
3492 let inner = &result[&outer_key];
3493 let inner_key = inner.as_object().unwrap().keys().next().unwrap().clone();
3494 assert!(inner_key.contains("percent"), "inner key was {inner_key:?}");
3495 let deep = &inner[&inner_key][0];
3496 let deep_key = deep.as_object().unwrap().keys().next().unwrap().clone();
3497 assert!(deep_key.contains("number"), "deep key was {deep_key:?}");
3498 }
3499
3500 #[cfg(feature = "transform-spell-symbols")]
3501 #[test]
3502 fn spell_symbols_key_collision_errors() {
3503 let record = json!({"%": 1, "percent": 2});
3505 let err = super::apply_all(
3506 record,
3507 &compiled(&[RecordTransform::SpellSymbols {
3508 extra: HashMap::new(),
3509 separator: "".into(),
3510 }]),
3511 )
3512 .expect_err("colliding spelled keys must error, not drop a value");
3513 assert!(matches!(err, FaucetError::Transform(_)));
3514 assert!(format!("{err}").contains("percent"), "{err}");
3515 }
3516
3517 #[cfg(feature = "transform-keys-case")]
3520 fn keys_case_specs(mode: KeyCaseMode) -> Vec<RecordTransform> {
3521 vec![RecordTransform::KeysCase {
3522 mode,
3523 on_collision: KeyCollision::Error,
3524 }]
3525 }
3526
3527 #[cfg(feature = "transform-keys-case")]
3528 #[test]
3529 fn keys_case_snake() {
3530 let record = json!({"First Name": 1, "last-name": 2, "ID": 3});
3531 let result = apply_all(record, &compiled(&keys_case_specs(KeyCaseMode::Snake)));
3532 assert_eq!(result["first_name"], 1);
3533 assert_eq!(result["last_name"], 2);
3534 assert_eq!(result["id"], 3);
3535 }
3536
3537 #[cfg(feature = "transform-keys-case")]
3538 #[test]
3539 fn keys_case_camel_from_various_inputs() {
3540 let record = json!({"first_name": 1, "User ID": 2, "kebab-case": 3, "PascalCase": 4});
3542 let result = apply_all(record, &compiled(&keys_case_specs(KeyCaseMode::Camel)));
3543 assert_eq!(result["firstName"], 1);
3544 assert_eq!(result["userId"], 2);
3545 assert_eq!(result["kebabCase"], 3);
3546 assert_eq!(result["pascalCase"], 4);
3547 }
3548
3549 #[cfg(feature = "transform-keys-case")]
3550 #[test]
3551 fn keys_case_pascal() {
3552 let record = json!({"first_name": 1, "second name": 2});
3553 let result = apply_all(record, &compiled(&keys_case_specs(KeyCaseMode::Pascal)));
3554 assert_eq!(result["FirstName"], 1);
3555 assert_eq!(result["SecondName"], 2);
3556 }
3557
3558 #[cfg(feature = "transform-keys-case")]
3559 #[test]
3560 fn keys_case_kebab() {
3561 let record = json!({"firstName": 1, "second_name": 2});
3562 let result = apply_all(record, &compiled(&keys_case_specs(KeyCaseMode::Kebab)));
3563 assert_eq!(result["first-name"], 1);
3564 assert_eq!(result["second-name"], 2);
3565 }
3566
3567 #[cfg(feature = "transform-keys-case")]
3568 #[test]
3569 fn keys_case_screaming_snake() {
3570 let record = json!({"firstName": 1, "second name": 2});
3571 let result = apply_all(
3572 record,
3573 &compiled(&keys_case_specs(KeyCaseMode::ScreamingSnake)),
3574 );
3575 assert_eq!(result["FIRST_NAME"], 1);
3576 assert_eq!(result["SECOND_NAME"], 2);
3577 }
3578
3579 #[cfg(feature = "transform-keys-case")]
3580 #[test]
3581 fn keys_case_recursive_into_nested() {
3582 let record = json!({"User Info": {"First Name": "Alice", "items": [{"Tag Name": "x"}]}});
3583 let result = apply_all(record, &compiled(&keys_case_specs(KeyCaseMode::Snake)));
3584 assert_eq!(result["user_info"]["first_name"], "Alice");
3585 assert_eq!(result["user_info"]["items"][0]["tag_name"], "x");
3586 }
3587
3588 #[cfg(feature = "transform-keys-case")]
3589 #[test]
3590 fn keys_case_collision_errors() {
3591 let record = json!({"firstName": 1, "first_name": 2});
3593 let err = super::apply_all(record, &compiled(&keys_case_specs(KeyCaseMode::Snake)))
3594 .expect_err("colliding re-cased keys must error, not drop a value");
3595 assert!(matches!(err, FaucetError::Transform(_)));
3596 assert!(format!("{err}").contains("first_name"), "{err}");
3597 }
3598
3599 #[cfg(feature = "transform-keys-case")]
3600 #[test]
3601 fn keys_case_all_symbol_key_kept_as_is() {
3602 let record = json!({"!@#": 1, "id": 2});
3605 let result = apply_all(record, &compiled(&keys_case_specs(KeyCaseMode::Snake)));
3606 assert_eq!(result["!@#"], 1);
3607 assert_eq!(result["id"], 2);
3608 }
3609
3610 #[cfg(feature = "transform-keys-case")]
3611 #[test]
3612 fn keys_case_idempotent_in_target_mode() {
3613 let record = json!({"first_name": 1});
3616 let once = apply_all(record, &compiled(&keys_case_specs(KeyCaseMode::Snake)));
3617 let twice = apply_all(
3618 once.clone(),
3619 &compiled(&keys_case_specs(KeyCaseMode::Snake)),
3620 );
3621 assert_eq!(once, twice);
3622 }
3623
3624 #[cfg(feature = "transform-spell-symbols")]
3625 #[test]
3626 fn spell_symbols_handles_unicode_keys() {
3627 let record = json!({"café%": 1});
3629 let result = apply_all(record, &compiled(&spell_default()));
3630 let key = result.as_object().unwrap().keys().next().unwrap().clone();
3631 assert!(key.contains("café"), "key was {key:?}");
3632 assert!(key.contains("percent"), "key was {key:?}");
3633 }
3634
3635 #[test]
3638 fn debug_record_transform_all_variants() {
3639 let dbg = format!("{:?}", RecordTransform::custom(|v| v));
3641 assert_eq!(dbg, "Custom(<fn>)");
3642
3643 #[cfg(feature = "transform-flatten")]
3644 {
3645 let dbg = format!(
3646 "{:?}",
3647 RecordTransform::Flatten {
3648 separator: "__".into()
3649 }
3650 );
3651 assert!(dbg.starts_with("Flatten"), "{dbg}");
3652 assert!(dbg.contains("separator"), "{dbg}");
3653 assert!(dbg.contains("__"), "{dbg}");
3654 }
3655 #[cfg(feature = "transform-rename-keys")]
3656 {
3657 let dbg = format!(
3658 "{:?}",
3659 RecordTransform::RenameKeys {
3660 pattern: "p".into(),
3661 replacement: "r".into(),
3662 }
3663 );
3664 assert!(dbg.starts_with("RenameKeys"), "{dbg}");
3665 assert!(dbg.contains("pattern"), "{dbg}");
3666 assert!(dbg.contains("replacement"), "{dbg}");
3667 }
3668 #[cfg(feature = "transform-keys-case")]
3669 {
3670 let dbg = format!(
3671 "{:?}",
3672 RecordTransform::KeysCase {
3673 mode: KeyCaseMode::Snake,
3674 on_collision: Default::default(),
3675 }
3676 );
3677 assert!(dbg.starts_with("KeysCase"), "{dbg}");
3678 assert!(dbg.contains("Snake"), "{dbg}");
3679 }
3680 #[cfg(feature = "transform-select")]
3681 {
3682 let dbg = format!(
3683 "{:?}",
3684 RecordTransform::Select {
3685 fields: vec!["a".into()]
3686 }
3687 );
3688 assert!(dbg.starts_with("Select"), "{dbg}");
3689 assert!(dbg.contains("fields"), "{dbg}");
3690 }
3691 #[cfg(feature = "transform-drop")]
3692 {
3693 let dbg = format!(
3694 "{:?}",
3695 RecordTransform::Drop {
3696 fields: vec!["a".into()]
3697 }
3698 );
3699 assert!(dbg.starts_with("Drop"), "{dbg}");
3700 }
3701 #[cfg(feature = "transform-set")]
3702 {
3703 let mut values = Map::new();
3704 values.insert("k".into(), json!("v"));
3705 let dbg = format!("{:?}", RecordTransform::Set { values });
3706 assert!(dbg.starts_with("Set"), "{dbg}");
3707 assert!(dbg.contains("values"), "{dbg}");
3708 }
3709 #[cfg(feature = "transform-rename-field")]
3710 {
3711 let mut fields = HashMap::new();
3712 fields.insert("a".to_owned(), "b".to_owned());
3713 let dbg = format!("{:?}", RecordTransform::RenameField { fields });
3714 assert!(dbg.starts_with("RenameField"), "{dbg}");
3715 }
3716 #[cfg(feature = "transform-cast")]
3717 {
3718 let mut fields = HashMap::new();
3719 fields.insert("a".to_owned(), CastType::Int);
3720 let dbg = format!(
3721 "{:?}",
3722 RecordTransform::Cast {
3723 fields,
3724 on_error: CastOnError::Error,
3725 }
3726 );
3727 assert!(dbg.starts_with("Cast"), "{dbg}");
3728 assert!(dbg.contains("on_error"), "{dbg}");
3729 }
3730 #[cfg(feature = "transform-redact")]
3731 {
3732 let dbg = format!(
3733 "{:?}",
3734 RecordTransform::Redact {
3735 fields: vec!["a".into()],
3736 mask: json!("***"),
3737 }
3738 );
3739 assert!(dbg.starts_with("Redact"), "{dbg}");
3740 assert!(dbg.contains("mask"), "{dbg}");
3741 }
3742 #[cfg(feature = "transform-value-case")]
3743 {
3744 let dbg = format!(
3745 "{:?}",
3746 RecordTransform::ValueCase {
3747 fields: vec!["a".into()],
3748 mode: ValueCaseMode::Lower,
3749 }
3750 );
3751 assert!(dbg.starts_with("ValueCase"), "{dbg}");
3752 assert!(dbg.contains("mode"), "{dbg}");
3753 }
3754 #[cfg(feature = "transform-spell-symbols")]
3755 {
3756 let dbg = format!(
3757 "{:?}",
3758 RecordTransform::SpellSymbols {
3759 extra: HashMap::new(),
3760 separator: " ".into(),
3761 }
3762 );
3763 assert!(dbg.starts_with("SpellSymbols"), "{dbg}");
3764 assert!(dbg.contains("separator"), "{dbg}");
3765 }
3766 #[cfg(feature = "transform-hash")]
3767 {
3768 let dbg = format!(
3769 "{:?}",
3770 RecordTransform::Hash {
3771 fields: vec!["a".into()],
3772 algorithm: HashAlgorithm::Blake3,
3773 encoding: HashEncoding::Base64,
3774 salt: Some("s".into()),
3775 into: Some("a_hash".into()),
3776 }
3777 );
3778 assert!(dbg.starts_with("Hash"), "{dbg}");
3779 assert!(dbg.contains("algorithm"), "{dbg}");
3780 assert!(dbg.contains("encoding"), "{dbg}");
3781 }
3782 #[cfg(feature = "transform-json-parse")]
3783 {
3784 let dbg = format!(
3785 "{:?}",
3786 RecordTransform::JsonParse {
3787 fields: vec!["a".into()],
3788 on_error: JsonParseOnError::Null,
3789 into: None,
3790 }
3791 );
3792 assert!(dbg.starts_with("JsonParse"), "{dbg}");
3793 assert!(dbg.contains("on_error"), "{dbg}");
3794 }
3795 #[cfg(feature = "transform-coalesce")]
3796 {
3797 let dbg = format!(
3798 "{:?}",
3799 RecordTransform::Coalesce {
3800 field: "a".into(),
3801 default: Some(json!("x")),
3802 from: vec![],
3803 treat_empty_string_as_null: true,
3804 }
3805 );
3806 assert!(dbg.starts_with("Coalesce"), "{dbg}");
3807 assert!(dbg.contains("treat_empty_string_as_null"), "{dbg}");
3808 }
3809 #[cfg(feature = "transform-split-join")]
3810 {
3811 let dbg = format!(
3812 "{:?}",
3813 RecordTransform::Split {
3814 field: "a".into(),
3815 delimiter: ",".into(),
3816 trim: true,
3817 into: None,
3818 }
3819 );
3820 assert!(dbg.starts_with("Split"), "{dbg}");
3821 assert!(dbg.contains("delimiter"), "{dbg}");
3822 let dbg = format!(
3823 "{:?}",
3824 RecordTransform::Join {
3825 field: "a".into(),
3826 delimiter: ",".into(),
3827 into: None,
3828 }
3829 );
3830 assert!(dbg.starts_with("Join"), "{dbg}");
3831 }
3832 }
3833
3834 #[test]
3837 fn clone_record_transform_custom_preserves_behaviour() {
3838 let original = RecordTransform::custom(|mut v| {
3839 if let Value::Object(ref mut m) = v {
3840 m.insert("cloned".into(), json!(true));
3841 }
3842 v
3843 });
3844 let cloned = original.clone();
3845 assert_eq!(format!("{cloned:?}"), "Custom(<fn>)");
3846 let out = apply_all(json!({"id": 1}), &compiled(&[cloned]));
3847 assert_eq!(out["cloned"], true);
3848 assert_eq!(out["id"], 1);
3849 }
3850
3851 #[test]
3852 #[allow(clippy::vec_init_then_push)]
3855 fn clone_record_transform_all_builtin_variants() {
3856 let mut variants: Vec<RecordTransform> = Vec::new();
3857 #[cfg(feature = "transform-flatten")]
3858 variants.push(RecordTransform::Flatten {
3859 separator: "__".into(),
3860 });
3861 #[cfg(feature = "transform-rename-keys")]
3862 variants.push(RecordTransform::RenameKeys {
3863 pattern: "p".into(),
3864 replacement: "r".into(),
3865 });
3866 #[cfg(feature = "transform-keys-case")]
3867 variants.push(RecordTransform::KeysCase {
3868 mode: KeyCaseMode::Snake,
3869 on_collision: KeyCollision::Error,
3870 });
3871 #[cfg(feature = "transform-select")]
3872 variants.push(RecordTransform::Select {
3873 fields: vec!["a".into()],
3874 });
3875 #[cfg(feature = "transform-drop")]
3876 variants.push(RecordTransform::Drop {
3877 fields: vec!["a".into()],
3878 });
3879 #[cfg(feature = "transform-set")]
3880 {
3881 let mut values = Map::new();
3882 values.insert("k".into(), json!("v"));
3883 variants.push(RecordTransform::Set { values });
3884 }
3885 #[cfg(feature = "transform-rename-field")]
3886 {
3887 let mut fields = HashMap::new();
3888 fields.insert("a".to_owned(), "b".to_owned());
3889 variants.push(RecordTransform::RenameField { fields });
3890 }
3891 #[cfg(feature = "transform-cast")]
3892 {
3893 let mut fields = HashMap::new();
3894 fields.insert("a".to_owned(), CastType::Int);
3895 variants.push(RecordTransform::Cast {
3896 fields,
3897 on_error: CastOnError::Error,
3898 });
3899 }
3900 #[cfg(feature = "transform-redact")]
3901 variants.push(RecordTransform::Redact {
3902 fields: vec!["a".into()],
3903 mask: json!("***"),
3904 });
3905 #[cfg(feature = "transform-value-case")]
3906 variants.push(RecordTransform::ValueCase {
3907 fields: vec!["a".into()],
3908 mode: ValueCaseMode::Lower,
3909 });
3910 #[cfg(feature = "transform-spell-symbols")]
3911 variants.push(RecordTransform::SpellSymbols {
3912 extra: HashMap::new(),
3913 separator: " ".into(),
3914 });
3915 #[cfg(feature = "transform-hash")]
3916 variants.push(RecordTransform::Hash {
3917 fields: vec!["a".into()],
3918 algorithm: HashAlgorithm::Sha256,
3919 encoding: HashEncoding::Hex,
3920 salt: Some("s".into()),
3921 into: None,
3922 });
3923 #[cfg(feature = "transform-json-parse")]
3924 variants.push(RecordTransform::JsonParse {
3925 fields: vec!["a".into()],
3926 on_error: JsonParseOnError::Keep,
3927 into: Some("b".into()),
3928 });
3929 #[cfg(feature = "transform-coalesce")]
3930 variants.push(RecordTransform::Coalesce {
3931 field: "a".into(),
3932 default: None,
3933 from: vec!["b".into()],
3934 treat_empty_string_as_null: false,
3935 });
3936 #[cfg(feature = "transform-split-join")]
3937 {
3938 variants.push(RecordTransform::Split {
3939 field: "a".into(),
3940 delimiter: ",".into(),
3941 trim: true,
3942 into: None,
3943 });
3944 variants.push(RecordTransform::Join {
3945 field: "a".into(),
3946 delimiter: ",".into(),
3947 into: Some("b".into()),
3948 });
3949 }
3950
3951 for v in &variants {
3953 let cloned = v.clone();
3954 assert_eq!(format!("{v:?}"), format!("{cloned:?}"));
3955 }
3956 }
3957
3958 #[test]
3959 fn clone_compiled_transform_all_variants() {
3960 let mut specs: Vec<RecordTransform> = vec![RecordTransform::custom(|v| v)];
3961 #[cfg(feature = "transform-flatten")]
3962 specs.push(RecordTransform::Flatten {
3963 separator: "__".into(),
3964 });
3965 #[cfg(feature = "transform-rename-keys")]
3966 specs.push(RecordTransform::RenameKeys {
3967 pattern: "p".into(),
3968 replacement: "r".into(),
3969 });
3970 #[cfg(feature = "transform-keys-case")]
3971 specs.push(RecordTransform::KeysCase {
3972 mode: KeyCaseMode::Camel,
3973 on_collision: KeyCollision::Error,
3974 });
3975 #[cfg(feature = "transform-select")]
3976 specs.push(RecordTransform::Select {
3977 fields: vec!["a".into()],
3978 });
3979 #[cfg(feature = "transform-drop")]
3980 specs.push(RecordTransform::Drop {
3981 fields: vec!["a".into()],
3982 });
3983 #[cfg(feature = "transform-set")]
3984 {
3985 let mut values = Map::new();
3986 values.insert("k".into(), json!("v"));
3987 specs.push(RecordTransform::Set { values });
3988 }
3989 #[cfg(feature = "transform-rename-field")]
3990 {
3991 let mut fields = HashMap::new();
3992 fields.insert("a".to_owned(), "b".to_owned());
3993 specs.push(RecordTransform::RenameField { fields });
3994 }
3995 #[cfg(feature = "transform-cast")]
3996 {
3997 let mut fields = HashMap::new();
3998 fields.insert("a".to_owned(), CastType::Int);
3999 specs.push(RecordTransform::Cast {
4000 fields,
4001 on_error: CastOnError::Null,
4002 });
4003 }
4004 #[cfg(feature = "transform-redact")]
4005 specs.push(RecordTransform::Redact {
4006 fields: vec!["a".into()],
4007 mask: json!("***"),
4008 });
4009 #[cfg(feature = "transform-value-case")]
4010 specs.push(RecordTransform::ValueCase {
4011 fields: vec!["a".into()],
4012 mode: ValueCaseMode::Upper,
4013 });
4014 #[cfg(feature = "transform-spell-symbols")]
4015 specs.push(RecordTransform::SpellSymbols {
4016 extra: HashMap::new(),
4017 separator: " ".into(),
4018 });
4019 #[cfg(feature = "transform-hash")]
4020 specs.push(RecordTransform::Hash {
4021 fields: vec!["a".into()],
4022 algorithm: HashAlgorithm::Blake3,
4023 encoding: HashEncoding::Base64,
4024 salt: None,
4025 into: None,
4026 });
4027 #[cfg(feature = "transform-json-parse")]
4028 specs.push(RecordTransform::JsonParse {
4029 fields: vec!["a".into()],
4030 on_error: JsonParseOnError::Error,
4031 into: None,
4032 });
4033 #[cfg(feature = "transform-coalesce")]
4034 specs.push(RecordTransform::Coalesce {
4035 field: "a".into(),
4036 default: Some(json!("x")),
4037 from: vec![],
4038 treat_empty_string_as_null: true,
4039 });
4040 #[cfg(feature = "transform-split-join")]
4041 {
4042 specs.push(RecordTransform::Split {
4043 field: "a".into(),
4044 delimiter: ",".into(),
4045 trim: false,
4046 into: None,
4047 });
4048 specs.push(RecordTransform::Join {
4049 field: "a".into(),
4050 delimiter: ",".into(),
4051 into: None,
4052 });
4053 }
4054
4055 let original = compiled(&specs);
4058 let cloned: Vec<CompiledTransform> = original.to_vec();
4059 assert_eq!(original.len(), cloned.len());
4060 let record = json!({"a": "1", "k": "x"});
4061 let out_orig = super::apply_all(record.clone(), &original);
4062 let out_clone = super::apply_all(record, &cloned);
4063 assert_eq!(
4064 out_orig.is_ok(),
4065 out_clone.is_ok(),
4066 "clone must transform identically"
4067 );
4068 if let (Ok(a), Ok(b)) = (out_orig, out_clone) {
4069 assert_eq!(a, b);
4070 }
4071 }
4072
4073 #[cfg(feature = "transform-flatten")]
4076 #[test]
4077 fn flatten_passes_through_non_object() {
4078 let record = json!([1, 2, 3]);
4079 let result = apply_all(
4080 record.clone(),
4081 &compiled(&[RecordTransform::Flatten {
4082 separator: "__".into(),
4083 }]),
4084 );
4085 assert_eq!(result, record);
4086 let scalar = json!(42);
4088 let result = apply_all(
4089 scalar.clone(),
4090 &compiled(&[RecordTransform::Flatten {
4091 separator: "__".into(),
4092 }]),
4093 );
4094 assert_eq!(result, scalar);
4095 }
4096
4097 #[cfg(feature = "transform-drop")]
4098 #[test]
4099 fn drop_passes_through_non_object() {
4100 let record = json!([1, 2]);
4101 let result = apply_all(
4102 record.clone(),
4103 &compiled(&[RecordTransform::Drop {
4104 fields: vec!["a".into()],
4105 }]),
4106 );
4107 assert_eq!(result, record);
4108 }
4109
4110 #[cfg(feature = "transform-set")]
4111 #[test]
4112 fn set_passes_through_non_object() {
4113 let mut values = Map::new();
4114 values.insert("k".into(), json!("v"));
4115 let record = json!("scalar");
4116 let result = apply_all(
4117 record.clone(),
4118 &compiled(&[RecordTransform::Set { values }]),
4119 );
4120 assert_eq!(result, record);
4121 }
4122
4123 #[cfg(feature = "transform-rename-field")]
4124 #[test]
4125 fn rename_field_passes_through_non_object() {
4126 let mut fields = HashMap::new();
4127 fields.insert("a".to_owned(), "b".to_owned());
4128 let record = json!([1, 2]);
4129 let result = apply_all(
4130 record.clone(),
4131 &compiled(&[RecordTransform::RenameField { fields }]),
4132 );
4133 assert_eq!(result, record);
4134 }
4135
4136 #[cfg(feature = "transform-rename-field")]
4137 #[test]
4138 fn rename_field_same_name_is_skipped() {
4139 let mut fields = HashMap::new();
4141 fields.insert("a".to_owned(), "a".to_owned());
4142 let record = json!({"a": 1});
4143 let result = apply_all(
4144 record,
4145 &compiled(&[RecordTransform::RenameField { fields }]),
4146 );
4147 assert_eq!(result["a"], 1);
4148 }
4149
4150 #[cfg(feature = "transform-cast")]
4151 #[test]
4152 fn cast_passes_through_non_object() {
4153 let record = json!([1, 2]);
4154 let result = apply_all(
4155 record.clone(),
4156 &compiled(&cast_specs("a", CastType::Int, CastOnError::Error)),
4157 );
4158 assert_eq!(result, record);
4159 }
4160
4161 #[cfg(feature = "transform-redact")]
4162 #[test]
4163 fn redact_passes_through_non_object() {
4164 let record = json!("scalar");
4165 let result = apply_all(
4166 record.clone(),
4167 &compiled(&[RecordTransform::Redact {
4168 fields: vec!["a".into()],
4169 mask: json!("***"),
4170 }]),
4171 );
4172 assert_eq!(result, record);
4173 }
4174
4175 #[cfg(feature = "transform-value-case")]
4176 #[test]
4177 fn value_case_passes_through_non_object() {
4178 let record = json!([1, 2]);
4179 let result = apply_all(
4180 record.clone(),
4181 &compiled(&[RecordTransform::ValueCase {
4182 fields: vec!["a".into()],
4183 mode: ValueCaseMode::Lower,
4184 }]),
4185 );
4186 assert_eq!(result, record);
4187 }
4188
4189 #[cfg(feature = "transform-cast")]
4192 #[test]
4193 fn cast_integer_number_to_int_is_identity() {
4194 let record = json!({"n": 7});
4196 let result = apply_all(
4197 record,
4198 &compiled(&cast_specs("n", CastType::Int, CastOnError::Error)),
4199 );
4200 assert_eq!(result["n"], 7);
4201 }
4202
4203 #[cfg(feature = "transform-cast")]
4204 #[test]
4205 fn cast_bool_to_int() {
4206 let record = json!({"t": true, "f": false});
4207 let mut fields = HashMap::new();
4208 fields.insert("t".to_owned(), CastType::Int);
4209 fields.insert("f".to_owned(), CastType::Int);
4210 let result = apply_all(
4211 record,
4212 &compiled(&[RecordTransform::Cast {
4213 fields,
4214 on_error: CastOnError::Error,
4215 }]),
4216 );
4217 assert_eq!(result["t"], 1);
4218 assert_eq!(result["f"], 0);
4219 }
4220
4221 #[cfg(feature = "transform-cast")]
4222 #[test]
4223 fn cast_null_to_int_errors() {
4224 let record = json!({"n": null});
4225 let err = super::apply_all(
4226 record,
4227 &compiled(&cast_specs("n", CastType::Int, CastOnError::Error)),
4228 )
4229 .expect_err("null cannot become int");
4230 assert!(
4231 format!("{err}").contains("null cannot be cast to int"),
4232 "{err}"
4233 );
4234 }
4235
4236 #[cfg(feature = "transform-cast")]
4237 #[test]
4238 fn cast_composite_to_int_errors() {
4239 let record = json!({"n": [1, 2]});
4240 let err = super::apply_all(
4241 record,
4242 &compiled(&cast_specs("n", CastType::Int, CastOnError::Error)),
4243 )
4244 .expect_err("array cannot become int");
4245 assert!(format!("{err}").contains("composite"), "{err}");
4246 }
4247
4248 #[cfg(feature = "transform-cast")]
4249 #[test]
4250 fn cast_number_to_float() {
4251 let record = json!({"n": 5});
4252 let result = apply_all(
4253 record,
4254 &compiled(&cast_specs("n", CastType::Float, CastOnError::Error)),
4255 );
4256 assert_eq!(result["n"], 5.0);
4257 }
4258
4259 #[cfg(feature = "transform-cast")]
4260 #[test]
4261 fn cast_bool_to_float() {
4262 let record = json!({"t": true, "f": false});
4263 let mut fields = HashMap::new();
4264 fields.insert("t".to_owned(), CastType::Float);
4265 fields.insert("f".to_owned(), CastType::Float);
4266 let result = apply_all(
4267 record,
4268 &compiled(&[RecordTransform::Cast {
4269 fields,
4270 on_error: CastOnError::Error,
4271 }]),
4272 );
4273 assert_eq!(result["t"], 1.0);
4274 assert_eq!(result["f"], 0.0);
4275 }
4276
4277 #[cfg(feature = "transform-cast")]
4278 #[test]
4279 fn cast_null_to_float_errors() {
4280 let record = json!({"n": null});
4281 let err = super::apply_all(
4282 record,
4283 &compiled(&cast_specs("n", CastType::Float, CastOnError::Error)),
4284 )
4285 .expect_err("null cannot become float");
4286 assert!(
4287 format!("{err}").contains("null cannot be cast to float"),
4288 "{err}"
4289 );
4290 }
4291
4292 #[cfg(feature = "transform-cast")]
4293 #[test]
4294 fn cast_composite_to_float_errors() {
4295 let record = json!({"n": {"x": 1}});
4296 let err = super::apply_all(
4297 record,
4298 &compiled(&cast_specs("n", CastType::Float, CastOnError::Error)),
4299 )
4300 .expect_err("object cannot become float");
4301 assert!(format!("{err}").contains("composite"), "{err}");
4302 }
4303
4304 #[cfg(feature = "transform-cast")]
4305 #[test]
4306 fn cast_string_to_float_invalid_errors() {
4307 let record = json!({"n": "not a float"});
4308 let err = super::apply_all(
4309 record,
4310 &compiled(&cast_specs("n", CastType::Float, CastOnError::Error)),
4311 )
4312 .expect_err("non-numeric string cannot become float");
4313 assert!(format!("{err}").contains("is not a float"), "{err}");
4314 }
4315
4316 #[cfg(feature = "transform-cast")]
4317 #[test]
4318 fn cast_bool_to_bool_is_identity() {
4319 let record = json!({"b": true});
4320 let result = apply_all(
4321 record,
4322 &compiled(&cast_specs("b", CastType::Bool, CastOnError::Error)),
4323 );
4324 assert_eq!(result["b"], true);
4325 }
4326
4327 #[cfg(feature = "transform-cast")]
4328 #[test]
4329 fn cast_number_to_bool() {
4330 let record = json!({"on": 1, "off": 0});
4331 let mut fields = HashMap::new();
4332 fields.insert("on".to_owned(), CastType::Bool);
4333 fields.insert("off".to_owned(), CastType::Bool);
4334 let result = apply_all(
4335 record,
4336 &compiled(&[RecordTransform::Cast {
4337 fields,
4338 on_error: CastOnError::Error,
4339 }]),
4340 );
4341 assert_eq!(result["on"], true);
4342 assert_eq!(result["off"], false);
4343 }
4344
4345 #[cfg(feature = "transform-cast")]
4346 #[test]
4347 fn cast_integer_other_than_zero_one_to_bool_errors() {
4348 let record = json!({"n": 7});
4349 let err = super::apply_all(
4350 record,
4351 &compiled(&cast_specs("n", CastType::Bool, CastOnError::Error)),
4352 )
4353 .expect_err("only 0/1 convert to bool");
4354 assert!(format!("{err}").contains("not 0 or 1"), "{err}");
4355 }
4356
4357 #[cfg(feature = "transform-cast")]
4358 #[test]
4359 fn cast_float_number_to_bool_errors() {
4360 let record = json!({"n": 1.5});
4362 let err = super::apply_all(
4363 record,
4364 &compiled(&cast_specs("n", CastType::Bool, CastOnError::Error)),
4365 )
4366 .expect_err("fractional number cannot become bool");
4367 assert!(format!("{err}").contains("not 0 or 1"), "{err}");
4368 }
4369
4370 #[cfg(feature = "transform-cast")]
4371 #[test]
4372 fn cast_unrecognised_string_to_bool_errors() {
4373 let record = json!({"flag": "maybe"});
4374 let err = super::apply_all(
4375 record,
4376 &compiled(&cast_specs("flag", CastType::Bool, CastOnError::Error)),
4377 )
4378 .expect_err("'maybe' is not a boolean");
4379 assert!(
4380 format!("{err}").contains("not a recognised boolean"),
4381 "{err}"
4382 );
4383 }
4384
4385 #[cfg(feature = "transform-cast")]
4386 #[test]
4387 fn cast_null_to_bool_errors() {
4388 let record = json!({"b": null});
4389 let err = super::apply_all(
4390 record,
4391 &compiled(&cast_specs("b", CastType::Bool, CastOnError::Error)),
4392 )
4393 .expect_err("null cannot become bool");
4394 assert!(
4395 format!("{err}").contains("null cannot be cast to bool"),
4396 "{err}"
4397 );
4398 }
4399
4400 #[cfg(feature = "transform-cast")]
4401 #[test]
4402 fn cast_composite_to_bool_errors() {
4403 let record = json!({"b": [true]});
4404 let err = super::apply_all(
4405 record,
4406 &compiled(&cast_specs("b", CastType::Bool, CastOnError::Error)),
4407 )
4408 .expect_err("array cannot become bool");
4409 assert!(format!("{err}").contains("composite"), "{err}");
4410 }
4411
4412 #[cfg(feature = "transform-cast")]
4413 #[test]
4414 fn cast_string_to_string_is_identity() {
4415 let record = json!({"s": "hello"});
4416 let result = apply_all(
4417 record,
4418 &compiled(&cast_specs("s", CastType::String, CastOnError::Error)),
4419 );
4420 assert_eq!(result["s"], "hello");
4421 }
4422
4423 #[cfg(feature = "transform-cast")]
4424 #[test]
4425 fn cast_bool_to_string() {
4426 let record = json!({"b": true});
4427 let result = apply_all(
4428 record,
4429 &compiled(&cast_specs("b", CastType::String, CastOnError::Error)),
4430 );
4431 assert_eq!(result["b"], "true");
4432 }
4433
4434 #[cfg(feature = "transform-cast")]
4435 #[test]
4436 fn cast_null_to_string_errors() {
4437 let record = json!({"s": null});
4438 let err = super::apply_all(
4439 record,
4440 &compiled(&cast_specs("s", CastType::String, CastOnError::Error)),
4441 )
4442 .expect_err("null cannot become string");
4443 assert!(
4444 format!("{err}").contains("null cannot be cast to string"),
4445 "{err}"
4446 );
4447 }
4448
4449 #[cfg(feature = "transform-cast")]
4450 #[test]
4451 fn cast_composite_to_string_errors() {
4452 let record = json!({"s": {"a": 1}});
4453 let err = super::apply_all(
4454 record,
4455 &compiled(&cast_specs("s", CastType::String, CastOnError::Error)),
4456 )
4457 .expect_err("object cannot become string");
4458 assert!(format!("{err}").contains("composite"), "{err}");
4459 }
4460
4461 #[cfg(feature = "transform-cast")]
4462 #[test]
4463 fn cast_invalid_timestamp_string_errors() {
4464 let record = json!({"ts": "not a date"});
4465 let err = super::apply_all(
4466 record,
4467 &compiled(&cast_specs("ts", CastType::Timestamp, CastOnError::Error)),
4468 )
4469 .expect_err("invalid timestamp string");
4470 assert!(format!("{err}").contains("RFC 3339"), "{err}");
4471 }
4472
4473 #[cfg(feature = "transform-cast")]
4474 #[test]
4475 fn cast_non_string_to_timestamp_names_the_type() {
4476 for (val, ty_name) in [
4478 (json!(null), "null"),
4479 (json!(true), "bool"),
4480 (json!(42), "number"),
4481 (json!([1, 2]), "array"),
4482 (json!({"a": 1}), "object"),
4483 ] {
4484 let record = json!({ "ts": val });
4485 let err = super::apply_all(
4486 record,
4487 &compiled(&cast_specs("ts", CastType::Timestamp, CastOnError::Error)),
4488 )
4489 .expect_err("non-string cannot become timestamp");
4490 let msg = format!("{err}");
4491 assert!(msg.contains("timestamp"), "{msg}");
4492 assert!(
4493 msg.contains(ty_name),
4494 "expected type name {ty_name:?} in: {msg}"
4495 );
4496 }
4497 }
4498
4499 #[cfg(feature = "transform-hash")]
4502 fn hash_spec(fields: &[&str], enc: HashEncoding, salt: Option<&str>) -> Vec<RecordTransform> {
4503 vec![RecordTransform::Hash {
4504 fields: fields.iter().map(|s| (*s).to_owned()).collect(),
4505 algorithm: HashAlgorithm::Sha256,
4506 encoding: enc,
4507 salt: salt.map(str::to_owned),
4508 into: None,
4509 }]
4510 }
4511
4512 #[cfg(feature = "transform-hash")]
4513 #[test]
4514 fn hash_replaces_in_place_and_is_stable() {
4515 let a = apply_all(
4516 json!({"email": "a@b.com", "id": 1}),
4517 &compiled(&hash_spec(&["email"], HashEncoding::Hex, None)),
4518 );
4519 let b = apply_all(
4520 json!({"email": "a@b.com", "id": 1}),
4521 &compiled(&hash_spec(&["email"], HashEncoding::Hex, None)),
4522 );
4523 assert_eq!(a["email"], b["email"]);
4525 assert_eq!(a["id"], 1);
4526 assert_eq!(a["email"].as_str().unwrap().len(), 64);
4528 assert_ne!(a["email"], json!("a@b.com"));
4529 }
4530
4531 #[cfg(feature = "transform-hash")]
4532 #[test]
4533 fn hash_salt_changes_output() {
4534 let unsalted = apply_all(
4535 json!({"email": "a@b.com"}),
4536 &compiled(&hash_spec(&["email"], HashEncoding::Hex, None)),
4537 );
4538 let salted = apply_all(
4539 json!({"email": "a@b.com"}),
4540 &compiled(&hash_spec(&["email"], HashEncoding::Hex, Some("pepper"))),
4541 );
4542 assert_ne!(unsalted["email"], salted["email"]);
4543 }
4544
4545 #[cfg(feature = "transform-hash")]
4546 #[test]
4547 fn hash_hex_vs_base64_differ_and_both_decode() {
4548 let hex = apply_all(
4549 json!({"v": "x"}),
4550 &compiled(&hash_spec(&["v"], HashEncoding::Hex, None)),
4551 );
4552 let b64 = apply_all(
4553 json!({"v": "x"}),
4554 &compiled(&hash_spec(&["v"], HashEncoding::Base64, None)),
4555 );
4556 assert_ne!(hex["v"], b64["v"]);
4557 assert_eq!(hex["v"].as_str().unwrap().len(), 64);
4559 assert_eq!(b64["v"].as_str().unwrap().len(), 44);
4560 }
4561
4562 #[cfg(feature = "transform-hash")]
4563 #[test]
4564 fn hash_into_preserves_source() {
4565 let out = apply_all(
4566 json!({"email": "a@b.com"}),
4567 &compiled(&[RecordTransform::Hash {
4568 fields: vec!["email".into()],
4569 algorithm: HashAlgorithm::Sha256,
4570 encoding: HashEncoding::Hex,
4571 salt: None,
4572 into: Some("email_hash".into()),
4573 }]),
4574 );
4575 assert_eq!(out["email"], "a@b.com");
4576 assert_eq!(out["email_hash"].as_str().unwrap().len(), 64);
4577 }
4578
4579 #[cfg(feature = "transform-hash")]
4580 #[test]
4581 fn hash_missing_field_is_no_op() {
4582 let out = apply_all(
4583 json!({"id": 1}),
4584 &compiled(&hash_spec(&["email"], HashEncoding::Hex, None)),
4585 );
4586 assert_eq!(out, json!({"id": 1}));
4587 }
4588
4589 #[cfg(feature = "transform-hash")]
4594 #[test]
4595 fn hash_leaves_null_alone() {
4596 let out = apply_all(
4597 json!({"email": null, "other": "x"}),
4598 &compiled(&hash_spec(&["email"], HashEncoding::Hex, None)),
4599 );
4600 assert_eq!(out["email"], Value::Null, "null must not be hashed");
4601 assert_eq!(out["other"], json!("x"));
4602
4603 let a = apply_all(
4606 json!({"id": 1, "email": null}),
4607 &compiled(&hash_spec(&["email"], HashEncoding::Hex, None)),
4608 );
4609 let b = apply_all(
4610 json!({"id": 2, "email": null}),
4611 &compiled(&hash_spec(&["email"], HashEncoding::Hex, None)),
4612 );
4613 assert_ne!(a, b);
4614 assert!(a["email"].is_null() && b["email"].is_null());
4615 }
4616
4617 #[cfg(feature = "transform-hash")]
4618 #[test]
4619 fn hash_non_string_hashes_canonical_json() {
4620 let out = apply_all(
4622 json!({"n": 42}),
4623 &compiled(&hash_spec(&["n"], HashEncoding::Hex, None)),
4624 );
4625 let expected = hash_string("42", HashAlgorithm::Sha256, HashEncoding::Hex, None);
4626 assert_eq!(out["n"], Value::String(expected));
4627 }
4628
4629 #[cfg(feature = "transform-hash")]
4632 #[test]
4633 fn hash_object_ignores_key_insertion_order() {
4634 let mut rec = Map::new();
4635 rec.insert(
4636 "o".into(),
4637 reverse_inserted(&[("login", json!("octo-org")), ("id", json!(2))]),
4638 );
4639 let out = apply_all(
4640 Value::Object(rec),
4641 &compiled(&hash_spec(&["o"], HashEncoding::Hex, None)),
4642 );
4643 let expected = hash_string(
4644 r#"{"id":2,"login":"octo-org"}"#,
4645 HashAlgorithm::Sha256,
4646 HashEncoding::Hex,
4647 None,
4648 );
4649 assert_eq!(out["o"], Value::String(expected));
4650 }
4651
4652 #[cfg(feature = "transform-hash")]
4653 #[test]
4654 fn hash_blake3_differs_from_sha256() {
4655 let sha = apply_all(
4656 json!({"v": "x"}),
4657 &compiled(&[RecordTransform::Hash {
4658 fields: vec!["v".into()],
4659 algorithm: HashAlgorithm::Sha256,
4660 encoding: HashEncoding::Hex,
4661 salt: None,
4662 into: None,
4663 }]),
4664 );
4665 let b3 = apply_all(
4666 json!({"v": "x"}),
4667 &compiled(&[RecordTransform::Hash {
4668 fields: vec!["v".into()],
4669 algorithm: HashAlgorithm::Blake3,
4670 encoding: HashEncoding::Hex,
4671 salt: None,
4672 into: None,
4673 }]),
4674 );
4675 assert_ne!(sha["v"], b3["v"]);
4676 assert_eq!(b3["v"].as_str().unwrap().len(), 64);
4677 }
4678
4679 #[cfg(feature = "transform-hash")]
4680 #[test]
4681 fn hash_empty_fields_is_config_error() {
4682 let res = compile(&RecordTransform::Hash {
4683 fields: vec![],
4684 algorithm: HashAlgorithm::Sha256,
4685 encoding: HashEncoding::Hex,
4686 salt: None,
4687 into: None,
4688 });
4689 assert!(matches!(res, Err(FaucetError::Config(_))));
4690 }
4691
4692 #[cfg(feature = "transform-hash")]
4693 #[test]
4694 fn hash_into_with_multiple_fields_is_config_error() {
4695 let res = compile(&RecordTransform::Hash {
4696 fields: vec!["a".into(), "b".into()],
4697 algorithm: HashAlgorithm::Sha256,
4698 encoding: HashEncoding::Hex,
4699 salt: None,
4700 into: Some("x".into()),
4701 });
4702 assert!(matches!(res, Err(FaucetError::Config(_))));
4703 }
4704
4705 #[cfg(feature = "transform-hash")]
4706 #[test]
4707 fn hash_debug_redacts_salt() {
4708 let dbg = format!(
4709 "{:?}",
4710 RecordTransform::Hash {
4711 fields: vec!["a".into()],
4712 algorithm: HashAlgorithm::Sha256,
4713 encoding: HashEncoding::Hex,
4714 salt: Some("supersecret".into()),
4715 into: None,
4716 }
4717 );
4718 assert!(!dbg.contains("supersecret"), "{dbg}");
4719 assert!(dbg.contains("redacted"), "{dbg}");
4720 }
4721
4722 #[cfg(feature = "transform-json-parse")]
4725 fn json_parse_spec(field: &str, on_error: JsonParseOnError) -> Vec<RecordTransform> {
4726 vec![RecordTransform::JsonParse {
4727 fields: vec![field.to_owned()],
4728 on_error,
4729 into: None,
4730 }]
4731 }
4732
4733 #[cfg(feature = "transform-json-parse")]
4734 #[test]
4735 fn json_parse_object_string_becomes_object() {
4736 let out = apply_all(
4737 json!({"payload": "{\"a\":1,\"b\":[2,3]}"}),
4738 &compiled(&json_parse_spec("payload", JsonParseOnError::Keep)),
4739 );
4740 assert_eq!(out["payload"], json!({"a": 1, "b": [2, 3]}));
4741 }
4742
4743 #[cfg(feature = "transform-json-parse")]
4744 #[test]
4745 fn json_parse_keeps_big_integers_and_exact_decimals_exact() {
4746 let out = apply_all(
4747 json!({"payload": "{\"id\": 123456789012345678901234, \"total\": 12345678.123456789012345, \"n\": 7}"}),
4748 &compiled(&json_parse_spec("payload", JsonParseOnError::Error)),
4749 );
4750 assert_eq!(out["payload"]["id"], json!("123456789012345678901234"));
4751 assert_eq!(out["payload"]["total"], json!("12345678.123456789012345"));
4752 assert_eq!(out["payload"]["n"], json!(7));
4753 }
4754
4755 #[cfg(feature = "transform-json-parse")]
4756 #[test]
4757 fn json_parse_already_parsed_is_no_op() {
4758 let record = json!({"payload": {"a": 1}});
4759 let out = apply_all(
4760 record.clone(),
4761 &compiled(&json_parse_spec("payload", JsonParseOnError::Error)),
4762 );
4763 assert_eq!(out, record);
4764 }
4765
4766 #[cfg(feature = "transform-json-parse")]
4767 #[test]
4768 fn json_parse_missing_field_is_no_op() {
4769 let out = apply_all(
4770 json!({"id": 1}),
4771 &compiled(&json_parse_spec("payload", JsonParseOnError::Error)),
4772 );
4773 assert_eq!(out, json!({"id": 1}));
4774 }
4775
4776 #[cfg(feature = "transform-json-parse")]
4777 #[test]
4778 fn json_parse_invalid_keep_leaves_string() {
4779 let out = apply_all(
4780 json!({"payload": "not json"}),
4781 &compiled(&json_parse_spec("payload", JsonParseOnError::Keep)),
4782 );
4783 assert_eq!(out["payload"], "not json");
4784 }
4785
4786 #[cfg(feature = "transform-json-parse")]
4787 #[test]
4788 fn json_parse_invalid_null_replaces() {
4789 let out = apply_all(
4790 json!({"payload": "not json"}),
4791 &compiled(&json_parse_spec("payload", JsonParseOnError::Null)),
4792 );
4793 assert_eq!(out["payload"], Value::Null);
4794 }
4795
4796 #[cfg(feature = "transform-json-parse")]
4797 #[test]
4798 fn json_parse_invalid_error_propagates() {
4799 let err = super::apply_all(
4800 json!({"payload": "not json"}),
4801 &compiled(&json_parse_spec("payload", JsonParseOnError::Error)),
4802 )
4803 .expect_err("invalid JSON under on_error=error must fail");
4804 assert!(matches!(err, FaucetError::Transform(_)), "{err}");
4805 }
4806
4807 #[cfg(feature = "transform-json-parse")]
4808 #[test]
4809 fn json_parse_into_writes_target() {
4810 let out = apply_all(
4811 json!({"payload": "{\"a\":1}"}),
4812 &compiled(&[RecordTransform::JsonParse {
4813 fields: vec!["payload".into()],
4814 on_error: JsonParseOnError::Error,
4815 into: Some("parsed".into()),
4816 }]),
4817 );
4818 assert_eq!(out["payload"], "{\"a\":1}");
4819 assert_eq!(out["parsed"], json!({"a": 1}));
4820 }
4821
4822 #[cfg(feature = "transform-coalesce")]
4825 #[test]
4826 fn coalesce_default_fills_null_and_absent() {
4827 let spec = |field: &str| {
4828 vec![RecordTransform::Coalesce {
4829 field: field.to_owned(),
4830 default: Some(json!("unknown")),
4831 from: vec![],
4832 treat_empty_string_as_null: false,
4833 }]
4834 };
4835 let a = apply_all(json!({"status": null}), &compiled(&spec("status")));
4837 assert_eq!(a["status"], "unknown");
4838 let b = apply_all(json!({"id": 1}), &compiled(&spec("status")));
4840 assert_eq!(b["status"], "unknown");
4841 }
4842
4843 #[cfg(feature = "transform-coalesce")]
4844 #[test]
4845 fn coalesce_non_null_target_untouched() {
4846 let out = apply_all(
4847 json!({"status": "active"}),
4848 &compiled(&[RecordTransform::Coalesce {
4849 field: "status".into(),
4850 default: Some(json!("unknown")),
4851 from: vec![],
4852 treat_empty_string_as_null: false,
4853 }]),
4854 );
4855 assert_eq!(out["status"], "active");
4856 }
4857
4858 #[cfg(feature = "transform-coalesce")]
4859 #[test]
4860 fn coalesce_from_picks_first_non_null() {
4861 let out = apply_all(
4862 json!({"status": null, "state": null, "phase": "running"}),
4863 &compiled(&[RecordTransform::Coalesce {
4864 field: "status".into(),
4865 default: None,
4866 from: vec!["status".into(), "state".into(), "phase".into()],
4867 treat_empty_string_as_null: false,
4868 }]),
4869 );
4870 assert_eq!(out["status"], "running");
4871 }
4872
4873 #[cfg(feature = "transform-coalesce")]
4874 #[test]
4875 fn coalesce_empty_string_toggle() {
4876 let spec = |treat: bool| {
4877 vec![RecordTransform::Coalesce {
4878 field: "status".into(),
4879 default: Some(json!("unknown")),
4880 from: vec![],
4881 treat_empty_string_as_null: treat,
4882 }]
4883 };
4884 let off = apply_all(json!({"status": ""}), &compiled(&spec(false)));
4886 assert_eq!(off["status"], "");
4887 let on = apply_all(json!({"status": ""}), &compiled(&spec(true)));
4889 assert_eq!(on["status"], "unknown");
4890 }
4891
4892 #[cfg(feature = "transform-coalesce")]
4893 #[test]
4894 fn coalesce_from_all_null_leaves_target() {
4895 let out = apply_all(
4896 json!({"status": null, "state": null}),
4897 &compiled(&[RecordTransform::Coalesce {
4898 field: "status".into(),
4899 default: None,
4900 from: vec!["status".into(), "state".into()],
4901 treat_empty_string_as_null: false,
4902 }]),
4903 );
4904 assert_eq!(out["status"], Value::Null);
4905 }
4906
4907 #[cfg(feature = "transform-coalesce")]
4908 #[test]
4909 fn coalesce_both_default_and_from_is_config_error() {
4910 let res = compile(&RecordTransform::Coalesce {
4911 field: "status".into(),
4912 default: Some(json!("x")),
4913 from: vec!["state".into()],
4914 treat_empty_string_as_null: false,
4915 });
4916 assert!(matches!(res, Err(FaucetError::Config(_))));
4917 }
4918
4919 #[cfg(feature = "transform-coalesce")]
4920 #[test]
4921 fn coalesce_neither_default_nor_from_is_config_error() {
4922 let res = compile(&RecordTransform::Coalesce {
4923 field: "status".into(),
4924 default: None,
4925 from: vec![],
4926 treat_empty_string_as_null: false,
4927 });
4928 assert!(matches!(res, Err(FaucetError::Config(_))));
4929 }
4930
4931 #[cfg(feature = "transform-split-join")]
4934 #[test]
4935 fn split_basic_no_trim() {
4936 let out = apply_all(
4937 json!({"tags": "a, b ,c"}),
4938 &compiled(&[RecordTransform::Split {
4939 field: "tags".into(),
4940 delimiter: ",".into(),
4941 trim: false,
4942 into: None,
4943 }]),
4944 );
4945 assert_eq!(out["tags"], json!(["a", " b ", "c"]));
4946 }
4947
4948 #[cfg(feature = "transform-split-join")]
4949 #[test]
4950 fn split_with_trim_keeps_empty_segments() {
4951 let out = apply_all(
4952 json!({"tags": "a, ,c,"}),
4953 &compiled(&[RecordTransform::Split {
4954 field: "tags".into(),
4955 delimiter: ",".into(),
4956 trim: true,
4957 into: None,
4958 }]),
4959 );
4960 assert_eq!(out["tags"], json!(["a", "", "c", ""]));
4962 }
4963
4964 #[cfg(feature = "transform-split-join")]
4965 #[test]
4966 fn split_empty_input_yields_single_empty() {
4967 let out = apply_all(
4968 json!({"tags": ""}),
4969 &compiled(&[RecordTransform::Split {
4970 field: "tags".into(),
4971 delimiter: ",".into(),
4972 trim: false,
4973 into: None,
4974 }]),
4975 );
4976 assert_eq!(out["tags"], json!([""]));
4977 }
4978
4979 #[cfg(feature = "transform-split-join")]
4980 #[test]
4981 fn split_non_string_is_no_op() {
4982 let record = json!({"tags": [1, 2]});
4983 let out = apply_all(
4984 record.clone(),
4985 &compiled(&[RecordTransform::Split {
4986 field: "tags".into(),
4987 delimiter: ",".into(),
4988 trim: false,
4989 into: None,
4990 }]),
4991 );
4992 assert_eq!(out, record);
4993 }
4994
4995 #[cfg(feature = "transform-split-join")]
4996 #[test]
4997 fn split_into_writes_target() {
4998 let out = apply_all(
4999 json!({"csv": "a,b"}),
5000 &compiled(&[RecordTransform::Split {
5001 field: "csv".into(),
5002 delimiter: ",".into(),
5003 trim: false,
5004 into: Some("arr".into()),
5005 }]),
5006 );
5007 assert_eq!(out["csv"], "a,b");
5008 assert_eq!(out["arr"], json!(["a", "b"]));
5009 }
5010
5011 #[cfg(feature = "transform-split-join")]
5012 #[test]
5013 fn join_basic_and_non_string_elements() {
5014 let out = apply_all(
5015 json!({"parts": ["a", 2, true, null]}),
5016 &compiled(&[RecordTransform::Join {
5017 field: "parts".into(),
5018 delimiter: ",".into(),
5019 into: None,
5020 }]),
5021 );
5022 assert_eq!(out["parts"], "a,2,true,");
5024 }
5025
5026 #[cfg(feature = "transform-split-join")]
5027 #[test]
5028 fn join_non_array_is_no_op() {
5029 let record = json!({"parts": "already a string"});
5030 let out = apply_all(
5031 record.clone(),
5032 &compiled(&[RecordTransform::Join {
5033 field: "parts".into(),
5034 delimiter: ",".into(),
5035 into: None,
5036 }]),
5037 );
5038 assert_eq!(out, record);
5039 }
5040
5041 #[cfg(feature = "transform-split-join")]
5042 #[test]
5043 fn split_then_join_round_trips() {
5044 let out = apply_all(
5045 json!({"tags": "a,b,c"}),
5046 &compiled(&[
5047 RecordTransform::Split {
5048 field: "tags".into(),
5049 delimiter: ",".into(),
5050 trim: false,
5051 into: None,
5052 },
5053 RecordTransform::Join {
5054 field: "tags".into(),
5055 delimiter: ",".into(),
5056 into: None,
5057 },
5058 ]),
5059 );
5060 assert_eq!(out["tags"], "a,b,c");
5061 }
5062
5063 #[cfg(feature = "transform-value-case")]
5066 #[test]
5067 fn value_case_title() {
5068 let out = apply_all(
5069 json!({"city": "new york", "id": 1}),
5070 &compiled(&[RecordTransform::ValueCase {
5071 fields: vec!["city".into()],
5072 mode: ValueCaseMode::Title,
5073 }]),
5074 );
5075 assert_eq!(out["city"], "New York");
5076 assert_eq!(out["id"], 1);
5077 }
5078
5079 #[cfg(feature = "transform-value-case")]
5080 #[test]
5081 fn value_case_title_lowercases_rest_of_word() {
5082 let out = apply_all(
5083 json!({"s": "hELLO WORLD"}),
5084 &compiled(&[RecordTransform::ValueCase {
5085 fields: vec!["s".into()],
5086 mode: ValueCaseMode::Title,
5087 }]),
5088 );
5089 assert_eq!(out["s"], "Hello World");
5090 }
5091
5092 #[cfg(feature = "transform-value-case")]
5093 #[test]
5094 fn value_case_capitalize() {
5095 let out = apply_all(
5096 json!({"s": "hELLO wORLD"}),
5097 &compiled(&[RecordTransform::ValueCase {
5098 fields: vec!["s".into()],
5099 mode: ValueCaseMode::Capitalize,
5100 }]),
5101 );
5102 assert_eq!(out["s"], "Hello world");
5103 }
5104
5105 #[cfg(feature = "transform-value-case")]
5106 #[test]
5107 fn value_case_title_is_idempotent() {
5108 let once = apply_all(
5109 json!({"s": "new york"}),
5110 &compiled(&[RecordTransform::ValueCase {
5111 fields: vec!["s".into()],
5112 mode: ValueCaseMode::Title,
5113 }]),
5114 );
5115 let twice = apply_all(
5116 once.clone(),
5117 &compiled(&[RecordTransform::ValueCase {
5118 fields: vec!["s".into()],
5119 mode: ValueCaseMode::Title,
5120 }]),
5121 );
5122 assert_eq!(once, twice);
5123 }
5124
5125 #[cfg(feature = "transform-value-case")]
5126 #[test]
5127 fn value_case_title_non_string_no_op() {
5128 let out = apply_all(
5129 json!({"n": 42}),
5130 &compiled(&[RecordTransform::ValueCase {
5131 fields: vec!["n".into()],
5132 mode: ValueCaseMode::Title,
5133 }]),
5134 );
5135 assert_eq!(out["n"], 42);
5136 }
5137
5138 #[cfg(feature = "transform-keys-case")]
5141 #[test]
5142 fn keys_case_dot() {
5143 let out = apply_all(
5144 json!({"userId": 1, "First Name": 2, "kebab-case": 3}),
5145 &compiled(&keys_case_specs(KeyCaseMode::Dot)),
5146 );
5147 assert_eq!(out["user.id"], 1);
5148 assert_eq!(out["first.name"], 2);
5149 assert_eq!(out["kebab.case"], 3);
5150 }
5151
5152 #[cfg(feature = "transform-keys-case")]
5153 #[test]
5154 fn keys_case_dot_is_idempotent() {
5155 let once = apply_all(
5156 json!({"userId": 1}),
5157 &compiled(&keys_case_specs(KeyCaseMode::Dot)),
5158 );
5159 let twice = apply_all(once.clone(), &compiled(&keys_case_specs(KeyCaseMode::Dot)));
5160 assert_eq!(once, twice);
5161 assert_eq!(twice["user.id"], 1);
5162 }
5163
5164 #[cfg(feature = "transform-keys-case")]
5165 #[test]
5166 fn keys_case_dot_matches_snake_tokenization() {
5167 let record = json!({"XMLHttpRequest": 1, "second name": 2});
5169 let dot = apply_all(
5170 record.clone(),
5171 &compiled(&keys_case_specs(KeyCaseMode::Dot)),
5172 );
5173 let snake = apply_all(record, &compiled(&keys_case_specs(KeyCaseMode::Snake)));
5174 let dot_keys: Vec<String> = dot.as_object().unwrap().keys().cloned().collect();
5176 let snake_keys: Vec<String> = snake.as_object().unwrap().keys().cloned().collect();
5177 let converted: Vec<String> = snake_keys.iter().map(|k| k.replace('_', ".")).collect();
5178 assert_eq!(dot_keys, converted);
5179 }
5180
5181 #[cfg(feature = "transform-json-parse")]
5184 #[test]
5185 fn json_parse_empty_fields_is_config_error() {
5186 let res = compile(&RecordTransform::JsonParse {
5187 fields: vec![],
5188 on_error: JsonParseOnError::Keep,
5189 into: None,
5190 });
5191 assert!(matches!(res, Err(FaucetError::Config(_))));
5192 }
5193
5194 #[cfg(feature = "transform-json-parse")]
5195 #[test]
5196 fn json_parse_into_with_multiple_fields_is_config_error() {
5197 let res = compile(&RecordTransform::JsonParse {
5198 fields: vec!["a".into(), "b".into()],
5199 on_error: JsonParseOnError::Keep,
5200 into: Some("x".into()),
5201 });
5202 assert!(matches!(res, Err(FaucetError::Config(_))));
5203 }
5204
5205 #[cfg(feature = "transform-hash")]
5206 #[test]
5207 fn hash_passes_through_non_object() {
5208 let record = json!([1, 2, 3]);
5209 let result = apply_all(
5210 record.clone(),
5211 &compiled(&hash_spec(&["v"], HashEncoding::Hex, None)),
5212 );
5213 assert_eq!(result, record);
5214 }
5215
5216 #[cfg(feature = "transform-json-parse")]
5217 #[test]
5218 fn json_parse_passes_through_non_object() {
5219 let record = json!("scalar");
5220 let result = apply_all(
5221 record.clone(),
5222 &compiled(&json_parse_spec("v", JsonParseOnError::Error)),
5223 );
5224 assert_eq!(result, record);
5225 }
5226
5227 #[cfg(feature = "transform-json-encode")]
5228 #[test]
5229 fn json_encode_stringifies_nested_only() {
5230 let out = apply_all(
5231 json!({"id": 1, "addr": {"city": "NYC"}, "tags": [1, 2], "name": "a"}),
5232 &compiled(&[RecordTransform::JsonEncode {
5233 fields: vec![
5234 "addr".into(),
5235 "tags".into(),
5236 "name".into(),
5237 "missing".into(),
5238 ],
5239 }]),
5240 );
5241 assert_eq!(out["addr"], json!("{\"city\":\"NYC\"}"));
5242 assert_eq!(out["tags"], json!("[1,2]"));
5243 assert_eq!(out["name"], json!("a")); assert_eq!(out["id"], json!(1));
5245 }
5246
5247 #[cfg(any(feature = "transform-json-encode", feature = "transform-hash"))]
5250 fn reverse_inserted(pairs: &[(&str, Value)]) -> Value {
5251 let mut m = Map::new();
5252 for (k, v) in pairs {
5253 m.insert((*k).to_string(), v.clone());
5254 }
5255 Value::Object(m)
5256 }
5257
5258 #[cfg(feature = "transform-json-encode")]
5261 #[test]
5262 fn json_encode_sorts_keys_at_every_depth() {
5263 let owner = reverse_inserted(&[("login", json!("octo-org")), ("id", json!(2))]);
5264 let deep = reverse_inserted(&[
5265 (
5266 "z",
5267 json!([reverse_inserted(&[("b", json!(1)), ("a", json!(2))])]),
5268 ),
5269 (
5270 "a",
5271 reverse_inserted(&[("y", json!(null)), ("x", json!("s"))]),
5272 ),
5273 ]);
5274 let mut rec = Map::new();
5275 rec.insert("owner".into(), owner);
5276 rec.insert("deep".into(), deep);
5277 let out = apply_all(
5278 Value::Object(rec),
5279 &compiled(&[RecordTransform::JsonEncode {
5280 fields: vec!["owner".into(), "deep".into()],
5281 }]),
5282 );
5283 assert_eq!(out["owner"], json!(r#"{"id":2,"login":"octo-org"}"#));
5284 assert_eq!(
5285 out["deep"],
5286 json!(r#"{"a":{"x":"s","y":null},"z":[{"a":2,"b":1}]}"#)
5287 );
5288 }
5289
5290 #[cfg(feature = "transform-lookup")]
5291 fn ref_rows(v: Value) -> Vec<Map<String, Value>> {
5292 v.as_array()
5293 .unwrap()
5294 .iter()
5295 .map(|r| r.as_object().unwrap().clone())
5296 .collect()
5297 }
5298
5299 #[cfg(feature = "transform-lookup")]
5300 #[test]
5301 fn lookup_enriches_on_hit_and_nulls_on_miss() {
5302 let spec = RecordTransform::Lookup {
5303 reference: ref_rows(json!([{"id": 1, "name": "Alice"}, {"id": 2, "name": "Bob"}])),
5304 on_record: "user_id".into(),
5305 on_ref: "id".into(),
5306 add: vec![("user_name".into(), "name".into())],
5307 on_missing: LookupOnMissing::Null,
5308 };
5309 let hit = apply_all(
5310 json!({"user_id": 1}),
5311 &compiled(std::slice::from_ref(&spec)),
5312 );
5313 assert_eq!(hit["user_name"], json!("Alice"));
5314 let miss = apply_all(json!({"user_id": 9}), &compiled(&[spec]));
5315 assert_eq!(miss["user_name"], json!(null));
5316 }
5317
5318 #[cfg(feature = "transform-lookup")]
5319 #[test]
5320 fn lookup_matches_number_against_string_key() {
5321 let spec = RecordTransform::Lookup {
5322 reference: ref_rows(json!([{"code": "42", "label": "answer"}])),
5323 on_record: "code".into(),
5324 on_ref: "code".into(),
5325 add: vec![("label".into(), "label".into())],
5326 on_missing: LookupOnMissing::Keep,
5327 };
5328 let out = apply_all(json!({"code": 42}), &compiled(&[spec]));
5330 assert_eq!(out["label"], json!("answer"));
5331 }
5332
5333 #[cfg(feature = "transform-lookup")]
5334 #[test]
5335 fn lookup_null_key_is_missing_and_whole_floats_match() {
5336 let spec = RecordTransform::Lookup {
5337 reference: ref_rows(json!([
5338 {"code": "", "label": "blank"},
5339 {"code": "42", "label": "answer"},
5340 {"code": 7.5, "label": "frac"}
5341 ])),
5342 on_record: "code".into(),
5343 on_ref: "code".into(),
5344 add: vec![("label".into(), "label".into())],
5345 on_missing: LookupOnMissing::Null,
5346 };
5347 let c = compiled(&[spec]);
5348 assert_eq!(apply_all(json!({"code": null}), &c)["label"], json!(null));
5349 assert_eq!(apply_all(json!({}), &c)["label"], json!(null));
5350 assert_eq!(
5351 apply_all(json!({"code": 42.0}), &c)["label"],
5352 json!("answer")
5353 );
5354 assert_eq!(apply_all(json!({"code": 7.5}), &c)["label"], json!("frac"));
5355 }
5356
5357 #[cfg(feature = "transform-lookup")]
5358 #[test]
5359 fn lookup_on_missing_error_fails() {
5360 let c = compile(&RecordTransform::Lookup {
5361 reference: Vec::new(),
5362 on_record: "k".into(),
5363 on_ref: "k".into(),
5364 add: vec![("x".into(), "y".into())],
5365 on_missing: LookupOnMissing::Error,
5366 })
5367 .unwrap();
5368 let res = super::apply_all(json!({"k": "z"}), std::slice::from_ref(&c));
5369 assert!(res.is_err());
5370 }
5371
5372 #[cfg(feature = "transform-lookup")]
5373 #[test]
5374 fn lookup_empty_add_is_rejected_at_compile() {
5375 assert!(
5376 compile(&RecordTransform::Lookup {
5377 reference: Vec::new(),
5378 on_record: "k".into(),
5379 on_ref: "k".into(),
5380 add: Vec::new(),
5381 on_missing: LookupOnMissing::Null,
5382 })
5383 .is_err()
5384 );
5385 }
5386
5387 #[cfg(all(feature = "transform-json-encode", feature = "transform-lookup"))]
5388 #[test]
5389 fn json_encode_and_lookup_debug_and_clone() {
5390 let je = RecordTransform::JsonEncode {
5392 fields: vec!["meta".into()],
5393 };
5394 assert!(format!("{je:?}").contains("JsonEncode"));
5395 assert!(format!("{:?}", je.clone()).contains("JsonEncode"));
5396
5397 let mut row = serde_json::Map::new();
5398 row.insert("id".into(), Value::String("1".into()));
5399 let lk = RecordTransform::Lookup {
5400 reference: vec![row],
5401 on_record: "rid".into(),
5402 on_ref: "id".into(),
5403 add: vec![("name".into(), "name".into())],
5404 on_missing: LookupOnMissing::Null,
5405 };
5406 assert!(format!("{lk:?}").contains("Lookup"));
5407 assert!(format!("{:?}", lk.clone()).contains("Lookup"));
5408
5409 let _ = compile(&je).unwrap().clone();
5411 let _ = compile(&lk).unwrap().clone();
5412 }
5413
5414 #[cfg(feature = "transform-coalesce")]
5415 #[test]
5416 fn coalesce_non_null_non_string_target_untouched() {
5417 let out = apply_all(
5420 json!({"n": 0}),
5421 &compiled(&[RecordTransform::Coalesce {
5422 field: "n".into(),
5423 default: Some(json!(99)),
5424 from: vec![],
5425 treat_empty_string_as_null: true,
5426 }]),
5427 );
5428 assert_eq!(out["n"], 0);
5429 }
5430
5431 #[cfg(feature = "transform-coalesce")]
5432 #[test]
5433 fn coalesce_passes_through_non_object() {
5434 let record = json!([1, 2]);
5435 let result = apply_all(
5436 record.clone(),
5437 &compiled(&[RecordTransform::Coalesce {
5438 field: "a".into(),
5439 default: Some(json!("x")),
5440 from: vec![],
5441 treat_empty_string_as_null: false,
5442 }]),
5443 );
5444 assert_eq!(result, record);
5445 }
5446
5447 #[cfg(feature = "transform-split-join")]
5448 #[test]
5449 fn split_and_join_pass_through_non_object() {
5450 let record = json!(42);
5451 let split = apply_all(
5452 record.clone(),
5453 &compiled(&[RecordTransform::Split {
5454 field: "a".into(),
5455 delimiter: ",".into(),
5456 trim: false,
5457 into: None,
5458 }]),
5459 );
5460 assert_eq!(split, record);
5461 let join = apply_all(
5462 record.clone(),
5463 &compiled(&[RecordTransform::Join {
5464 field: "a".into(),
5465 delimiter: ",".into(),
5466 into: None,
5467 }]),
5468 );
5469 assert_eq!(join, record);
5470 }
5471
5472 #[cfg(feature = "transform-split-join")]
5473 #[test]
5474 fn split_empty_delimiter_yields_single_element() {
5475 let out = apply_all(
5476 json!({"s": " hi "}),
5477 &compiled(&[RecordTransform::Split {
5478 field: "s".into(),
5479 delimiter: String::new(),
5480 trim: true,
5481 into: None,
5482 }]),
5483 );
5484 assert_eq!(out["s"], json!(["hi"]));
5486 }
5487
5488 #[cfg(feature = "transform-value-case")]
5489 #[test]
5490 fn value_case_title_and_capitalize_handle_empty_string() {
5491 for mode in [ValueCaseMode::Title, ValueCaseMode::Capitalize] {
5492 let out = apply_all(
5493 json!({"s": ""}),
5494 &compiled(&[RecordTransform::ValueCase {
5495 fields: vec!["s".into()],
5496 mode,
5497 }]),
5498 );
5499 assert_eq!(out["s"], "");
5500 }
5501 }
5502}