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 { mode, on_collision } => Self::KeysCase {
917 mode: *mode,
918 on_collision: *on_collision,
919 },
920 #[cfg(feature = "transform-select")]
921 Self::Select { fields } => Self::Select {
922 fields: fields.clone(),
923 },
924 #[cfg(feature = "transform-drop")]
925 Self::Drop { fields } => Self::Drop {
926 fields: fields.clone(),
927 },
928 #[cfg(feature = "transform-set")]
929 Self::Set { values } => Self::Set {
930 values: values.clone(),
931 },
932 #[cfg(feature = "transform-rename-field")]
933 Self::RenameField { fields } => Self::RenameField {
934 fields: fields.clone(),
935 },
936 #[cfg(feature = "transform-cast")]
937 Self::Cast { fields, on_error } => Self::Cast {
938 fields: fields.clone(),
939 on_error: *on_error,
940 },
941 #[cfg(feature = "transform-redact")]
942 Self::Redact { fields, mask } => Self::Redact {
943 fields: fields.clone(),
944 mask: mask.clone(),
945 },
946 #[cfg(feature = "transform-value-case")]
947 Self::ValueCase { fields, mode } => Self::ValueCase {
948 fields: fields.clone(),
949 mode: *mode,
950 },
951 #[cfg(feature = "transform-spell-symbols")]
952 Self::SpellSymbols {
953 replacements,
954 separator,
955 } => Self::SpellSymbols {
956 replacements: replacements.clone(),
957 separator: separator.clone(),
958 },
959 #[cfg(feature = "transform-hash")]
960 Self::Hash {
961 fields,
962 algorithm,
963 encoding,
964 salt,
965 into,
966 } => Self::Hash {
967 fields: fields.clone(),
968 algorithm: *algorithm,
969 encoding: *encoding,
970 salt: salt.clone(),
971 into: into.clone(),
972 },
973 #[cfg(feature = "transform-json-parse")]
974 Self::JsonParse {
975 fields,
976 on_error,
977 into,
978 } => Self::JsonParse {
979 fields: fields.clone(),
980 on_error: *on_error,
981 into: into.clone(),
982 },
983 #[cfg(feature = "transform-coalesce")]
984 Self::Coalesce {
985 field,
986 default,
987 from,
988 treat_empty_string_as_null,
989 } => Self::Coalesce {
990 field: field.clone(),
991 default: default.clone(),
992 from: from.clone(),
993 treat_empty_string_as_null: *treat_empty_string_as_null,
994 },
995 #[cfg(feature = "transform-split-join")]
996 Self::Split {
997 field,
998 delimiter,
999 trim,
1000 into,
1001 } => Self::Split {
1002 field: field.clone(),
1003 delimiter: delimiter.clone(),
1004 trim: *trim,
1005 into: into.clone(),
1006 },
1007 #[cfg(feature = "transform-split-join")]
1008 Self::Join {
1009 field,
1010 delimiter,
1011 into,
1012 } => Self::Join {
1013 field: field.clone(),
1014 delimiter: delimiter.clone(),
1015 into: into.clone(),
1016 },
1017 #[cfg(feature = "transform-json-encode")]
1018 Self::JsonEncode { fields } => Self::JsonEncode {
1019 fields: fields.clone(),
1020 },
1021 #[cfg(feature = "transform-lookup")]
1022 Self::Lookup {
1023 index,
1024 on_record,
1025 add,
1026 on_missing,
1027 } => Self::Lookup {
1028 index: index.clone(),
1029 on_record: on_record.clone(),
1030 add: add.clone(),
1031 on_missing: *on_missing,
1032 },
1033 Self::Custom(f) => Self::Custom(Arc::clone(f)),
1034 }
1035 }
1036}
1037
1038impl RecordTransform {
1039 pub fn custom<F>(f: F) -> Self
1063 where
1064 F: Fn(Value) -> Value + Send + Sync + 'static,
1065 {
1066 Self::Custom(Arc::new(f))
1067 }
1068}
1069
1070#[non_exhaustive]
1081pub enum CompiledTransform {
1082 #[cfg(feature = "transform-flatten")]
1083 Flatten {
1084 separator: String,
1085 },
1086 #[cfg(feature = "transform-rename-keys")]
1087 RenameKeys {
1088 re: Regex,
1089 replacement: String,
1090 },
1091 #[cfg(feature = "transform-keys-case")]
1092 KeysCase {
1093 mode: KeyCaseMode,
1094 on_collision: KeyCollision,
1095 },
1096 #[cfg(feature = "transform-select")]
1097 Select {
1098 fields: Vec<String>,
1099 },
1100 #[cfg(feature = "transform-drop")]
1101 Drop {
1102 fields: Vec<String>,
1103 },
1104 #[cfg(feature = "transform-set")]
1105 Set {
1106 values: Map<String, Value>,
1107 },
1108 #[cfg(feature = "transform-rename-field")]
1109 RenameField {
1110 fields: Vec<(String, String)>,
1113 },
1114 #[cfg(feature = "transform-cast")]
1115 Cast {
1116 fields: HashMap<String, CastType>,
1117 on_error: CastOnError,
1118 },
1119 #[cfg(feature = "transform-redact")]
1120 Redact {
1121 fields: Vec<String>,
1122 mask: Value,
1123 },
1124 #[cfg(feature = "transform-value-case")]
1125 ValueCase {
1126 fields: Vec<String>,
1127 mode: ValueCaseMode,
1128 },
1129 #[cfg(feature = "transform-spell-symbols")]
1130 SpellSymbols {
1131 replacements: Vec<(String, String)>,
1134 separator: String,
1135 },
1136 #[cfg(feature = "transform-hash")]
1137 Hash {
1138 fields: Vec<String>,
1139 algorithm: HashAlgorithm,
1140 encoding: HashEncoding,
1141 salt: Option<String>,
1142 into: Option<String>,
1143 },
1144 #[cfg(feature = "transform-json-parse")]
1145 JsonParse {
1146 fields: Vec<String>,
1147 on_error: JsonParseOnError,
1148 into: Option<String>,
1149 },
1150 #[cfg(feature = "transform-coalesce")]
1151 Coalesce {
1152 field: String,
1153 default: Option<Value>,
1154 from: Vec<String>,
1155 treat_empty_string_as_null: bool,
1156 },
1157 #[cfg(feature = "transform-split-join")]
1158 Split {
1159 field: String,
1160 delimiter: String,
1161 trim: bool,
1162 into: Option<String>,
1163 },
1164 #[cfg(feature = "transform-split-join")]
1165 Join {
1166 field: String,
1167 delimiter: String,
1168 into: Option<String>,
1169 },
1170 #[cfg(feature = "transform-json-encode")]
1171 JsonEncode {
1172 fields: Vec<String>,
1173 },
1174 #[cfg(feature = "transform-lookup")]
1175 Lookup {
1176 index: HashMap<String, Map<String, Value>>,
1179 on_record: String,
1180 add: Vec<(String, String)>,
1181 on_missing: LookupOnMissing,
1182 },
1183 Custom(Arc<dyn Fn(Value) -> Value + Send + Sync>),
1184}
1185
1186pub fn compile(t: &RecordTransform) -> Result<CompiledTransform, FaucetError> {
1190 match t {
1191 #[cfg(feature = "transform-flatten")]
1192 RecordTransform::Flatten { separator } => Ok(CompiledTransform::Flatten {
1193 separator: separator.clone(),
1194 }),
1195 #[cfg(feature = "transform-rename-keys")]
1196 RecordTransform::RenameKeys {
1197 pattern,
1198 replacement,
1199 } => {
1200 let re = Regex::new(pattern)
1201 .map_err(|e| FaucetError::Transform(format!("invalid regex '{pattern}': {e}")))?;
1202 Ok(CompiledTransform::RenameKeys {
1203 re,
1204 replacement: replacement.clone(),
1205 })
1206 }
1207 #[cfg(feature = "transform-keys-case")]
1208 RecordTransform::KeysCase { mode, on_collision } => Ok(CompiledTransform::KeysCase {
1209 mode: *mode,
1210 on_collision: *on_collision,
1211 }),
1212 #[cfg(feature = "transform-select")]
1213 RecordTransform::Select { fields } => Ok(CompiledTransform::Select {
1214 fields: fields.clone(),
1215 }),
1216 #[cfg(feature = "transform-drop")]
1217 RecordTransform::Drop { fields } => Ok(CompiledTransform::Drop {
1218 fields: fields.clone(),
1219 }),
1220 #[cfg(feature = "transform-set")]
1221 RecordTransform::Set { values } => Ok(CompiledTransform::Set {
1222 values: values.clone(),
1223 }),
1224 #[cfg(feature = "transform-rename-field")]
1225 RecordTransform::RenameField { fields } => {
1226 let mut fields: Vec<(String, String)> =
1231 fields.iter().map(|(f, t)| (f.clone(), t.clone())).collect();
1232 fields.sort();
1233 Ok(CompiledTransform::RenameField { fields })
1234 }
1235 #[cfg(feature = "transform-cast")]
1236 RecordTransform::Cast { fields, on_error } => Ok(CompiledTransform::Cast {
1237 fields: fields.clone(),
1238 on_error: *on_error,
1239 }),
1240 #[cfg(feature = "transform-redact")]
1241 RecordTransform::Redact { fields, mask } => Ok(CompiledTransform::Redact {
1242 fields: fields.clone(),
1243 mask: mask.clone(),
1244 }),
1245 #[cfg(feature = "transform-value-case")]
1246 RecordTransform::ValueCase { fields, mode } => Ok(CompiledTransform::ValueCase {
1247 fields: fields.clone(),
1248 mode: *mode,
1249 }),
1250 #[cfg(feature = "transform-spell-symbols")]
1251 RecordTransform::SpellSymbols { extra, separator } => {
1252 let mut merged = default_symbol_map();
1255 for (k, v) in extra {
1256 merged.insert(k.clone(), v.clone());
1257 }
1258 let mut replacements: Vec<(String, String)> = merged.into_iter().collect();
1259 replacements.sort_by_key(|b| std::cmp::Reverse(b.0.len()));
1260 Ok(CompiledTransform::SpellSymbols {
1261 replacements,
1262 separator: separator.clone(),
1263 })
1264 }
1265 #[cfg(feature = "transform-hash")]
1266 RecordTransform::Hash {
1267 fields,
1268 algorithm,
1269 encoding,
1270 salt,
1271 into,
1272 } => {
1273 if fields.is_empty() {
1274 return Err(FaucetError::Config(
1275 "hash: `fields` must not be empty".to_owned(),
1276 ));
1277 }
1278 if into.is_some() && fields.len() != 1 {
1279 return Err(FaucetError::Config(
1280 "hash: `into` is only valid with exactly one field".to_owned(),
1281 ));
1282 }
1283 Ok(CompiledTransform::Hash {
1284 fields: fields.clone(),
1285 algorithm: *algorithm,
1286 encoding: *encoding,
1287 salt: salt.clone(),
1288 into: into.clone(),
1289 })
1290 }
1291 #[cfg(feature = "transform-json-parse")]
1292 RecordTransform::JsonParse {
1293 fields,
1294 on_error,
1295 into,
1296 } => {
1297 if fields.is_empty() {
1298 return Err(FaucetError::Config(
1299 "json_parse: `fields` must not be empty".to_owned(),
1300 ));
1301 }
1302 if into.is_some() && fields.len() != 1 {
1303 return Err(FaucetError::Config(
1304 "json_parse: `into` is only valid with exactly one field".to_owned(),
1305 ));
1306 }
1307 Ok(CompiledTransform::JsonParse {
1308 fields: fields.clone(),
1309 on_error: *on_error,
1310 into: into.clone(),
1311 })
1312 }
1313 #[cfg(feature = "transform-coalesce")]
1314 RecordTransform::Coalesce {
1315 field,
1316 default,
1317 from,
1318 treat_empty_string_as_null,
1319 } => {
1320 match (default.is_some(), from.is_empty()) {
1321 (true, true) => {}
1323 (false, false) => {}
1325 (true, false) => {
1326 return Err(FaucetError::Config(
1327 "coalesce: set exactly one of `default` or `from`, not both".to_owned(),
1328 ));
1329 }
1330 (false, true) => {
1331 return Err(FaucetError::Config(
1332 "coalesce: set exactly one of `default` or `from`".to_owned(),
1333 ));
1334 }
1335 }
1336 Ok(CompiledTransform::Coalesce {
1337 field: field.clone(),
1338 default: default.clone(),
1339 from: from.clone(),
1340 treat_empty_string_as_null: *treat_empty_string_as_null,
1341 })
1342 }
1343 #[cfg(feature = "transform-split-join")]
1344 RecordTransform::Split {
1345 field,
1346 delimiter,
1347 trim,
1348 into,
1349 } => Ok(CompiledTransform::Split {
1350 field: field.clone(),
1351 delimiter: delimiter.clone(),
1352 trim: *trim,
1353 into: into.clone(),
1354 }),
1355 #[cfg(feature = "transform-split-join")]
1356 RecordTransform::Join {
1357 field,
1358 delimiter,
1359 into,
1360 } => Ok(CompiledTransform::Join {
1361 field: field.clone(),
1362 delimiter: delimiter.clone(),
1363 into: into.clone(),
1364 }),
1365 #[cfg(feature = "transform-json-encode")]
1366 RecordTransform::JsonEncode { fields } => Ok(CompiledTransform::JsonEncode {
1367 fields: fields.clone(),
1368 }),
1369 #[cfg(feature = "transform-lookup")]
1370 RecordTransform::Lookup {
1371 reference,
1372 on_record,
1373 on_ref,
1374 add,
1375 on_missing,
1376 } => {
1377 if on_record.trim().is_empty() || on_ref.trim().is_empty() {
1378 return Err(FaucetError::Transform(
1379 "lookup: `on_record` and `on_ref` must be non-empty".into(),
1380 ));
1381 }
1382 if add.is_empty() {
1383 return Err(FaucetError::Transform(
1384 "lookup: `add` must name at least one output column".into(),
1385 ));
1386 }
1387 let mut index = HashMap::with_capacity(reference.len());
1390 for row in reference {
1391 if let Some(k) = row.get(on_ref).and_then(value_to_key) {
1392 index.entry(k).or_insert_with(|| row.clone());
1393 }
1394 }
1395 Ok(CompiledTransform::Lookup {
1396 index,
1397 on_record: on_record.clone(),
1398 add: add.clone(),
1399 on_missing: *on_missing,
1400 })
1401 }
1402 RecordTransform::Custom(f) => Ok(CompiledTransform::Custom(Arc::clone(f))),
1403 }
1404}
1405
1406#[cfg(feature = "transform-lookup")]
1410fn value_to_key(v: &Value) -> Option<String> {
1411 match v {
1412 Value::String(s) => Some(s.clone()),
1413 Value::Null => None,
1414 Value::Number(n) if n.is_f64() => {
1415 let f = n.as_f64().unwrap_or(f64::NAN);
1416 if f.fract() == 0.0 && f.abs() < 9.007_199_254_740_992e15 {
1417 Some(format!("{}", f as i64))
1418 } else {
1419 Some(n.to_string())
1420 }
1421 }
1422 other => Some(crate::util::canonical_json(other)),
1423 }
1424}
1425
1426pub fn apply_all(record: Value, transforms: &[CompiledTransform]) -> Result<Value, FaucetError> {
1432 let mut acc = record;
1433 for t in transforms {
1434 acc = apply_one(acc, t)?;
1435 }
1436 Ok(acc)
1437}
1438
1439fn apply_one(value: Value, t: &CompiledTransform) -> Result<Value, FaucetError> {
1440 match t {
1441 #[cfg(feature = "transform-flatten")]
1442 CompiledTransform::Flatten { separator } => flatten(value, separator),
1443 #[cfg(feature = "transform-rename-keys")]
1444 CompiledTransform::RenameKeys { re, replacement } => rename_keys(value, re, replacement),
1445 #[cfg(feature = "transform-keys-case")]
1446 CompiledTransform::KeysCase { mode, on_collision } => {
1447 keys_case(value, *mode, *on_collision)
1448 }
1449 #[cfg(feature = "transform-select")]
1450 CompiledTransform::Select { fields } => Ok(select_fields(value, fields)),
1451 #[cfg(feature = "transform-drop")]
1452 CompiledTransform::Drop { fields } => Ok(drop_fields(value, fields)),
1453 #[cfg(feature = "transform-set")]
1454 CompiledTransform::Set { values } => Ok(set_fields(value, values)),
1455 #[cfg(feature = "transform-rename-field")]
1456 CompiledTransform::RenameField { fields } => rename_field(value, fields),
1457 #[cfg(feature = "transform-cast")]
1458 CompiledTransform::Cast { fields, on_error } => cast_fields(value, fields, *on_error),
1459 #[cfg(feature = "transform-redact")]
1460 CompiledTransform::Redact { fields, mask } => Ok(redact_fields(value, fields, mask)),
1461 #[cfg(feature = "transform-value-case")]
1462 CompiledTransform::ValueCase { fields, mode } => Ok(value_case(value, fields, *mode)),
1463 #[cfg(feature = "transform-spell-symbols")]
1464 CompiledTransform::SpellSymbols {
1465 replacements,
1466 separator,
1467 } => spell_symbols(value, replacements, separator),
1468 #[cfg(feature = "transform-hash")]
1469 CompiledTransform::Hash {
1470 fields,
1471 algorithm,
1472 encoding,
1473 salt,
1474 into,
1475 } => Ok(hash_fields(
1476 value,
1477 fields,
1478 *algorithm,
1479 *encoding,
1480 salt.as_deref(),
1481 into.as_deref(),
1482 )),
1483 #[cfg(feature = "transform-json-parse")]
1484 CompiledTransform::JsonParse {
1485 fields,
1486 on_error,
1487 into,
1488 } => json_parse_fields(value, fields, *on_error, into.as_deref()),
1489 #[cfg(feature = "transform-coalesce")]
1490 CompiledTransform::Coalesce {
1491 field,
1492 default,
1493 from,
1494 treat_empty_string_as_null,
1495 } => Ok(coalesce_field(
1496 value,
1497 field,
1498 default.as_ref(),
1499 from,
1500 *treat_empty_string_as_null,
1501 )),
1502 #[cfg(feature = "transform-split-join")]
1503 CompiledTransform::Split {
1504 field,
1505 delimiter,
1506 trim,
1507 into,
1508 } => Ok(split_field(value, field, delimiter, *trim, into.as_deref())),
1509 #[cfg(feature = "transform-split-join")]
1510 CompiledTransform::Join {
1511 field,
1512 delimiter,
1513 into,
1514 } => Ok(join_field(value, field, delimiter, into.as_deref())),
1515 #[cfg(feature = "transform-json-encode")]
1516 CompiledTransform::JsonEncode { fields } => Ok(json_encode_fields(value, fields)),
1517 #[cfg(feature = "transform-lookup")]
1518 CompiledTransform::Lookup {
1519 index,
1520 on_record,
1521 add,
1522 on_missing,
1523 } => lookup_field(value, index, on_record, add, *on_missing),
1524 CompiledTransform::Custom(f) => Ok(f(value)),
1525 }
1526}
1527
1528#[cfg(feature = "transform-flatten")]
1531fn flatten(value: Value, separator: &str) -> Result<Value, FaucetError> {
1532 match value {
1533 Value::Object(_) => {
1534 let mut out = Map::new();
1535 flatten_into(value, "", separator, &mut out)?;
1536 Ok(Value::Object(out))
1537 }
1538 other => Ok(other),
1539 }
1540}
1541
1542#[cfg(feature = "transform-flatten")]
1543fn flatten_into(
1544 value: Value,
1545 prefix: &str,
1546 separator: &str,
1547 out: &mut Map<String, Value>,
1548) -> Result<(), FaucetError> {
1549 match value {
1550 Value::Object(map) => {
1551 for (k, v) in map {
1552 let key = if prefix.is_empty() {
1553 k
1554 } else {
1555 format!("{prefix}{separator}{k}")
1556 };
1557 flatten_into(v, &key, separator, out)?;
1558 }
1559 }
1560 other => {
1561 if out.contains_key(prefix) {
1565 return Err(FaucetError::Transform(format!(
1566 "flatten produced a duplicate key '{prefix}'; two distinct fields collapse \
1567 to the same flattened key (separator '{separator}')"
1568 )));
1569 }
1570 out.insert(prefix.to_string(), other);
1571 }
1572 }
1573 Ok(())
1574}
1575
1576#[cfg(feature = "transform-rename-keys")]
1582fn rename_keys(value: Value, re: &Regex, replacement: &str) -> Result<Value, FaucetError> {
1583 match value {
1584 Value::Object(map) => {
1585 let mut new_map = Map::with_capacity(map.len());
1586 for (k, v) in map {
1587 let new_k = re.replace_all(&k, replacement).into_owned();
1588 let new_v = rename_keys(v, re, replacement)?;
1589 if new_map.contains_key(&new_k) {
1590 return Err(FaucetError::Transform(format!(
1591 "rename_keys produced a duplicate key '{new_k}' (from '{k}'); two \
1592 distinct keys rename to the same name"
1593 )));
1594 }
1595 new_map.insert(new_k, new_v);
1596 }
1597 Ok(Value::Object(new_map))
1598 }
1599 Value::Array(arr) => Ok(Value::Array(
1600 arr.into_iter()
1601 .map(|v| rename_keys(v, re, replacement))
1602 .collect::<Result<_, _>>()?,
1603 )),
1604 other => Ok(other),
1605 }
1606}
1607
1608#[cfg(feature = "transform-keys-case")]
1617fn keys_case(
1618 value: Value,
1619 mode: KeyCaseMode,
1620 on_collision: KeyCollision,
1621) -> Result<Value, FaucetError> {
1622 match value {
1623 Value::Object(map) => {
1624 let mut new_map = Map::with_capacity(map.len());
1625 for (k, v) in map {
1626 let tokens = tokenize_key(&k);
1627 let recased = if tokens.is_empty() {
1628 k
1631 } else {
1632 apply_key_case(tokens, mode)
1633 };
1634 let new_v = keys_case(v, mode, on_collision)?;
1635 let final_key = if new_map.contains_key(&recased) {
1636 match on_collision {
1637 KeyCollision::Error => {
1638 return Err(FaucetError::Transform(format!(
1639 "keys_case produced a duplicate key '{recased}'; two distinct \
1640 keys re-case to the same name under mode {mode:?} (set \
1641 on_collision: suffix to disambiguate)"
1642 )));
1643 }
1644 KeyCollision::Suffix => {
1645 let mut n = 2usize;
1648 let mut candidate = format!("{recased}_{n}");
1649 while new_map.contains_key(&candidate) {
1650 n += 1;
1651 candidate = format!("{recased}_{n}");
1652 }
1653 candidate
1654 }
1655 }
1656 } else {
1657 recased
1658 };
1659 new_map.insert(final_key, new_v);
1660 }
1661 Ok(Value::Object(new_map))
1662 }
1663 Value::Array(arr) => {
1664 let mut out = Vec::with_capacity(arr.len());
1665 for v in arr {
1666 out.push(keys_case(v, mode, on_collision)?);
1667 }
1668 Ok(Value::Array(out))
1669 }
1670 other => Ok(other),
1671 }
1672}
1673
1674#[cfg(feature = "transform-keys-case")]
1680fn tokenize_key(key: &str) -> Vec<String> {
1681 crate::util::tokenize_identifier(key)
1684}
1685
1686#[cfg(feature = "transform-keys-case")]
1687fn apply_key_case(tokens: Vec<String>, mode: KeyCaseMode) -> String {
1688 match mode {
1689 KeyCaseMode::Snake => tokens
1690 .iter()
1691 .map(|t| t.to_lowercase())
1692 .collect::<Vec<_>>()
1693 .join("_"),
1694 KeyCaseMode::ScreamingSnake => tokens
1695 .iter()
1696 .map(|t| t.to_uppercase())
1697 .collect::<Vec<_>>()
1698 .join("_"),
1699 KeyCaseMode::Kebab => tokens
1700 .iter()
1701 .map(|t| t.to_lowercase())
1702 .collect::<Vec<_>>()
1703 .join("-"),
1704 KeyCaseMode::Dot => tokens
1705 .iter()
1706 .map(|t| t.to_lowercase())
1707 .collect::<Vec<_>>()
1708 .join("."),
1709 KeyCaseMode::Camel => {
1710 let mut iter = tokens.into_iter();
1711 match iter.next() {
1712 None => String::new(),
1713 Some(first) => {
1714 let mut out = first.to_lowercase();
1715 for t in iter {
1716 out.push_str(&capitalize_token(&t));
1717 }
1718 out
1719 }
1720 }
1721 }
1722 KeyCaseMode::Pascal => tokens
1723 .into_iter()
1724 .map(|t| capitalize_token(&t))
1725 .collect::<String>(),
1726 }
1727}
1728
1729#[cfg(feature = "transform-keys-case")]
1731fn capitalize_token(s: &str) -> String {
1732 let lower = s.to_lowercase();
1733 let mut chars = lower.chars();
1734 match chars.next() {
1735 None => String::new(),
1736 Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
1737 }
1738}
1739
1740#[cfg(feature = "transform-select")]
1743fn select_fields(value: Value, fields: &[String]) -> Value {
1744 match value {
1745 Value::Object(map) => {
1746 let mut out = Map::with_capacity(fields.len().min(map.len()));
1747 for f in fields {
1749 if let Some(v) = map.get(f) {
1750 out.insert(f.clone(), v.clone());
1751 }
1752 }
1753 Value::Object(out)
1754 }
1755 other => other,
1756 }
1757}
1758
1759#[cfg(feature = "transform-drop")]
1762fn drop_fields(value: Value, fields: &[String]) -> Value {
1763 match value {
1764 Value::Object(mut map) => {
1765 for f in fields {
1766 map.remove(f);
1767 }
1768 Value::Object(map)
1769 }
1770 other => other,
1771 }
1772}
1773
1774#[cfg(feature = "transform-set")]
1777fn set_fields(value: Value, values: &Map<String, Value>) -> Value {
1778 match value {
1779 Value::Object(mut map) => {
1780 for (k, v) in values {
1781 map.insert(k.clone(), v.clone());
1782 }
1783 Value::Object(map)
1784 }
1785 other => other,
1786 }
1787}
1788
1789#[cfg(feature = "transform-rename-field")]
1792fn rename_field(value: Value, fields: &[(String, String)]) -> Result<Value, FaucetError> {
1793 match value {
1794 Value::Object(mut map) => {
1795 let renames: Vec<(&str, &str)> = fields
1801 .iter()
1802 .filter(|(from, to)| from != to && map.contains_key(from))
1803 .map(|(from, to)| (from.as_str(), to.as_str()))
1804 .collect();
1805 let sources: std::collections::HashSet<&str> =
1806 renames.iter().map(|(from, _)| *from).collect();
1807
1808 let mut seen_targets: std::collections::HashSet<&str> =
1810 std::collections::HashSet::new();
1811 for (from, to) in &renames {
1812 if !seen_targets.insert(to) {
1813 return Err(FaucetError::Transform(format!(
1814 "rename_field: two fields rename to the same target key '{to}'"
1815 )));
1816 }
1817 if map.contains_key(*to) && !sources.contains(to) {
1821 return Err(FaucetError::Transform(format!(
1822 "rename_field: target key '{to}' already exists on the record \
1823 (renaming from '{from}')"
1824 )));
1825 }
1826 }
1827
1828 let staged: Vec<(String, Value)> = renames
1829 .iter()
1830 .map(|(from, to)| {
1831 let v = map.remove(*from).expect("source presence checked above");
1832 (to.to_string(), v)
1833 })
1834 .collect();
1835 for (to, v) in staged {
1836 map.insert(to, v);
1837 }
1838 Ok(Value::Object(map))
1839 }
1840 other => Ok(other),
1841 }
1842}
1843
1844#[cfg(feature = "transform-cast")]
1847fn cast_fields(
1848 value: Value,
1849 fields: &HashMap<String, CastType>,
1850 on_error: CastOnError,
1851) -> Result<Value, FaucetError> {
1852 match value {
1853 Value::Object(mut map) => {
1854 for (field, target) in fields {
1855 let Some(current) = map.get(field) else {
1856 continue;
1857 };
1858 match cast_value(current, *target) {
1859 Ok(new_val) => {
1860 map.insert(field.clone(), new_val);
1861 }
1862 Err(msg) => match on_error {
1863 CastOnError::Error => {
1864 return Err(FaucetError::Transform(format!(
1865 "cast: field '{field}' to {target:?} failed: {msg}"
1866 )));
1867 }
1868 CastOnError::Null => {
1869 map.insert(field.clone(), Value::Null);
1870 }
1871 CastOnError::Skip => { }
1872 },
1873 }
1874 }
1875 Ok(Value::Object(map))
1876 }
1877 other => Ok(other),
1878 }
1879}
1880
1881#[cfg(feature = "transform-cast")]
1884fn cast_value(v: &Value, target: CastType) -> Result<Value, String> {
1885 match target {
1886 CastType::Int => match v {
1887 Value::Number(n) => {
1888 if let Some(i) = n.as_i64() {
1889 return Ok(Value::Number(i.into()));
1890 }
1891 match n.as_f64() {
1898 Some(f)
1899 if f.fract() == 0.0 && (-(2f64.powi(63))..2f64.powi(63)).contains(&f) =>
1900 {
1901 Ok(Value::Number((f as i64).into()))
1902 }
1903 Some(f) => Err(format!(
1904 "float '{f}' is not a whole number representable as i64"
1905 )),
1906 None => Err(format!("number '{n}' is not representable as i64")),
1907 }
1908 }
1909 Value::String(s) => s
1910 .trim()
1911 .parse::<i64>()
1912 .map(|i| Value::Number(i.into()))
1913 .map_err(|e| format!("'{s}' is not an integer: {e}")),
1914 Value::Bool(b) => Ok(Value::Number(i64::from(*b).into())),
1915 Value::Null => Err("null cannot be cast to int".to_owned()),
1916 Value::Array(_) | Value::Object(_) => {
1917 Err("composite values cannot be cast to int".to_owned())
1918 }
1919 },
1920 CastType::Float => match v {
1921 Value::Number(n) => n
1922 .as_f64()
1923 .and_then(|f| serde_json::Number::from_f64(f).map(Value::Number))
1924 .ok_or_else(|| format!("number '{n}' is not representable as f64")),
1925 Value::String(s) => s
1926 .trim()
1927 .parse::<f64>()
1928 .ok()
1929 .and_then(|f| serde_json::Number::from_f64(f).map(Value::Number))
1930 .ok_or_else(|| format!("'{s}' is not a float")),
1931 Value::Bool(b) => serde_json::Number::from_f64(if *b { 1.0 } else { 0.0 })
1932 .map(Value::Number)
1933 .ok_or_else(|| "could not encode bool as f64".to_owned()),
1934 Value::Null => Err("null cannot be cast to float".to_owned()),
1935 Value::Array(_) | Value::Object(_) => {
1936 Err("composite values cannot be cast to float".to_owned())
1937 }
1938 },
1939 CastType::Bool => match v {
1940 Value::Bool(b) => Ok(Value::Bool(*b)),
1941 Value::Number(n) => {
1942 if let Some(i) = n.as_i64() {
1943 match i {
1944 0 => Ok(Value::Bool(false)),
1945 1 => Ok(Value::Bool(true)),
1946 _ => Err(format!("integer {i} is not 0 or 1")),
1947 }
1948 } else {
1949 Err(format!("number '{n}' is not 0 or 1"))
1950 }
1951 }
1952 Value::String(s) => match s.trim().to_ascii_lowercase().as_str() {
1953 "true" | "1" | "yes" | "y" => Ok(Value::Bool(true)),
1954 "false" | "0" | "no" | "n" => Ok(Value::Bool(false)),
1955 other => Err(format!("'{other}' is not a recognised boolean")),
1956 },
1957 Value::Null => Err("null cannot be cast to bool".to_owned()),
1958 Value::Array(_) | Value::Object(_) => {
1959 Err("composite values cannot be cast to bool".to_owned())
1960 }
1961 },
1962 CastType::String => match v {
1963 Value::String(s) => Ok(Value::String(s.clone())),
1964 Value::Number(n) => Ok(Value::String(n.to_string())),
1965 Value::Bool(b) => Ok(Value::String(b.to_string())),
1966 Value::Null => Err("null cannot be cast to string".to_owned()),
1967 Value::Array(_) | Value::Object(_) => {
1968 Err("composite values cannot be cast to string".to_owned())
1969 }
1970 },
1971 CastType::Timestamp => match v {
1972 Value::String(s) => chrono::DateTime::parse_from_rfc3339(s)
1973 .map(|dt| Value::String(dt.to_rfc3339_opts(chrono::SecondsFormat::AutoSi, true)))
1974 .map_err(|e| format!("'{s}' is not a valid RFC 3339 timestamp: {e}")),
1975 other => Err(format!(
1976 "cannot cast {} to timestamp (expected RFC 3339 string)",
1977 value_type_name(other)
1978 )),
1979 },
1980 }
1981}
1982
1983#[cfg(feature = "transform-cast")]
1984fn value_type_name(v: &Value) -> &'static str {
1985 match v {
1986 Value::Null => "null",
1987 Value::Bool(_) => "bool",
1988 Value::Number(_) => "number",
1989 Value::String(_) => "string",
1990 Value::Array(_) => "array",
1991 Value::Object(_) => "object",
1992 }
1993}
1994
1995#[cfg(feature = "transform-redact")]
1998fn redact_fields(value: Value, fields: &[String], mask: &Value) -> Value {
1999 match value {
2000 Value::Object(mut map) => {
2001 for f in fields {
2002 if map.contains_key(f) {
2003 map.insert(f.clone(), mask.clone());
2004 }
2005 }
2006 Value::Object(map)
2007 }
2008 other => other,
2009 }
2010}
2011
2012#[cfg(feature = "transform-value-case")]
2015fn value_case(value: Value, fields: &[String], mode: ValueCaseMode) -> Value {
2016 match value {
2017 Value::Object(mut map) => {
2018 for f in fields {
2019 if let Some(Value::String(s)) = map.get(f) {
2020 let new_s = match mode {
2021 ValueCaseMode::Lower => s.to_lowercase(),
2022 ValueCaseMode::Upper => s.to_uppercase(),
2023 ValueCaseMode::Trim => s.trim().to_owned(),
2024 ValueCaseMode::Title => title_case(s),
2025 ValueCaseMode::Capitalize => capitalize_str(s),
2026 };
2027 map.insert(f.clone(), Value::String(new_s));
2028 }
2029 }
2030 Value::Object(map)
2031 }
2032 other => other,
2033 }
2034}
2035
2036#[cfg(feature = "transform-value-case")]
2041fn title_case(s: &str) -> String {
2042 let mut out = String::with_capacity(s.len());
2043 let mut at_word_start = true;
2044 for ch in s.chars() {
2045 if ch.is_whitespace() {
2046 at_word_start = true;
2047 out.push(ch);
2048 } else if at_word_start {
2049 out.extend(ch.to_uppercase());
2050 at_word_start = false;
2051 } else {
2052 out.extend(ch.to_lowercase());
2053 }
2054 }
2055 out
2056}
2057
2058#[cfg(feature = "transform-value-case")]
2061fn capitalize_str(s: &str) -> String {
2062 let mut chars = s.chars();
2063 match chars.next() {
2064 None => String::new(),
2065 Some(first) => {
2066 let mut out: String = first.to_uppercase().collect();
2067 out.push_str(&chars.as_str().to_lowercase());
2068 out
2069 }
2070 }
2071}
2072
2073#[cfg(feature = "transform-spell-symbols")]
2084pub fn default_symbol_map() -> HashMap<String, String> {
2085 let pairs: &[(&str, &str)] = &[
2086 ("%", "percent"),
2087 ("#", "number"),
2088 ("$", "dollar"),
2089 ("&", "and"),
2090 ("@", "at"),
2091 ("+", "plus"),
2092 ("*", "star"),
2093 ("=", "equals"),
2094 ("<", "lt"),
2095 (">", "gt"),
2096 ("/", "slash"),
2097 ("\\", "backslash"),
2098 ("|", "pipe"),
2099 ("^", "caret"),
2100 ("~", "tilde"),
2101 ];
2102 pairs
2103 .iter()
2104 .map(|(k, v)| ((*k).to_owned(), (*v).to_owned()))
2105 .collect()
2106}
2107
2108#[cfg(feature = "transform-spell-symbols")]
2109fn spell_symbols(
2110 value: Value,
2111 replacements: &[(String, String)],
2112 separator: &str,
2113) -> Result<Value, FaucetError> {
2114 match value {
2115 Value::Object(map) => {
2116 let mut new_map = Map::with_capacity(map.len());
2117 for (k, v) in map {
2118 let new_k = spell_symbols_in_key(&k, replacements, separator);
2119 let new_v = spell_symbols(v, replacements, separator)?;
2120 if new_map.contains_key(&new_k) {
2124 return Err(FaucetError::Transform(format!(
2125 "spell_symbols produced a duplicate key '{new_k}'; two distinct keys \
2126 expand to the same name"
2127 )));
2128 }
2129 new_map.insert(new_k, new_v);
2130 }
2131 Ok(Value::Object(new_map))
2132 }
2133 Value::Array(arr) => {
2134 let mut out = Vec::with_capacity(arr.len());
2135 for v in arr {
2136 out.push(spell_symbols(v, replacements, separator)?);
2137 }
2138 Ok(Value::Array(out))
2139 }
2140 other => Ok(other),
2141 }
2142}
2143
2144#[cfg(feature = "transform-spell-symbols")]
2148fn spell_symbols_in_key(key: &str, replacements: &[(String, String)], separator: &str) -> String {
2149 let bytes = key.as_bytes();
2153 let mut out = String::with_capacity(key.len());
2154 let mut i = 0;
2155 while i < bytes.len() {
2156 let mut matched = false;
2157 for (from, to) in replacements {
2158 let f = from.as_bytes();
2159 if !f.is_empty() && bytes[i..].starts_with(f) {
2160 out.push_str(separator);
2161 out.push_str(to);
2162 out.push_str(separator);
2163 i += f.len();
2164 matched = true;
2165 break;
2166 }
2167 }
2168 if !matched {
2169 let ch = key[i..]
2172 .chars()
2173 .next()
2174 .expect("non-empty slice yields at least one char");
2175 out.push(ch);
2176 i += ch.len_utf8();
2177 }
2178 }
2179 out
2180}
2181
2182#[cfg(feature = "transform-hash")]
2185fn hash_fields(
2186 value: Value,
2187 fields: &[String],
2188 algorithm: HashAlgorithm,
2189 encoding: HashEncoding,
2190 salt: Option<&str>,
2191 into: Option<&str>,
2192) -> Value {
2193 match value {
2194 Value::Object(mut map) => {
2195 for field in fields {
2196 let Some(current) = map.get(field) else {
2197 continue;
2198 };
2199 if current.is_null() {
2207 continue;
2208 }
2209 let input = match current {
2212 Value::String(s) => s.clone(),
2213 other => crate::util::canonical_json(other),
2214 };
2215 let digest = hash_string(&input, algorithm, encoding, salt);
2216 let target = into.unwrap_or(field.as_str());
2217 map.insert(target.to_owned(), Value::String(digest));
2218 }
2219 Value::Object(map)
2220 }
2221 other => other,
2222 }
2223}
2224
2225#[cfg(feature = "transform-hash")]
2226fn hash_string(
2227 input: &str,
2228 algorithm: HashAlgorithm,
2229 encoding: HashEncoding,
2230 salt: Option<&str>,
2231) -> String {
2232 let mut bytes: Vec<u8> = Vec::with_capacity(salt.map_or(0, str::len) + input.len());
2234 if let Some(s) = salt {
2235 bytes.extend_from_slice(s.as_bytes());
2236 }
2237 bytes.extend_from_slice(input.as_bytes());
2238 let digest: Vec<u8> = match algorithm {
2239 HashAlgorithm::Sha256 => {
2240 use sha2::{Digest, Sha256};
2241 let mut h = Sha256::new();
2242 h.update(&bytes);
2243 h.finalize().to_vec()
2244 }
2245 HashAlgorithm::Blake3 => blake3::hash(&bytes).as_bytes().to_vec(),
2246 };
2247 match encoding {
2248 HashEncoding::Hex => hex_encode(&digest),
2249 HashEncoding::Base64 => {
2250 use base64::Engine;
2251 base64::engine::general_purpose::STANDARD.encode(&digest)
2252 }
2253 }
2254}
2255
2256#[cfg(feature = "transform-hash")]
2257fn hex_encode(bytes: &[u8]) -> String {
2258 const HEX: &[u8; 16] = b"0123456789abcdef";
2259 let mut s = String::with_capacity(bytes.len() * 2);
2260 for &b in bytes {
2261 s.push(HEX[(b >> 4) as usize] as char);
2262 s.push(HEX[(b & 0x0f) as usize] as char);
2263 }
2264 s
2265}
2266
2267#[cfg(feature = "transform-json-encode")]
2272fn json_encode_fields(mut value: Value, fields: &[String]) -> Value {
2273 if let Value::Object(map) = &mut value {
2274 for f in fields {
2275 if let Some(v) = map.get_mut(f)
2276 && matches!(v, Value::Object(_) | Value::Array(_))
2277 {
2278 *v = Value::String(crate::util::canonical_json(v));
2279 }
2280 }
2281 }
2282 value
2283}
2284
2285#[cfg(feature = "transform-lookup")]
2287fn lookup_field(
2288 mut value: Value,
2289 index: &HashMap<String, Map<String, Value>>,
2290 on_record: &str,
2291 add: &[(String, String)],
2292 on_missing: LookupOnMissing,
2293) -> Result<Value, FaucetError> {
2294 let Value::Object(map) = &mut value else {
2295 return Ok(value);
2296 };
2297 let key = map.get(on_record).and_then(value_to_key);
2298 let matched = key.as_deref().and_then(|k| index.get(k));
2299 match matched {
2300 Some(row) => {
2301 for (out, src) in add {
2302 let v = row.get(src).cloned().unwrap_or(Value::Null);
2303 map.insert(out.clone(), v);
2304 }
2305 }
2306 None => match on_missing {
2307 LookupOnMissing::Null => {
2308 for (out, _) in add {
2309 map.insert(out.clone(), Value::Null);
2310 }
2311 }
2312 LookupOnMissing::Keep => {}
2313 LookupOnMissing::Error => {
2314 return Err(FaucetError::Transform(format!(
2315 "lookup: no reference row for {on_record}={:?}",
2316 key.unwrap_or_default()
2317 )));
2318 }
2319 },
2320 }
2321 Ok(value)
2322}
2323
2324#[cfg(feature = "transform-json-parse")]
2327fn json_parse_fields(
2328 value: Value,
2329 fields: &[String],
2330 on_error: JsonParseOnError,
2331 into: Option<&str>,
2332) -> Result<Value, FaucetError> {
2333 match value {
2334 Value::Object(mut map) => {
2335 for field in fields {
2336 let Some(Value::String(s)) = map.get(field) else {
2339 continue;
2340 };
2341 let s = s.clone();
2342 match serde_json::from_str::<Value>(&s) {
2343 Ok(parsed) => {
2344 let target = into.unwrap_or(field.as_str());
2345 map.insert(target.to_owned(), parsed);
2346 }
2347 Err(e) => match on_error {
2348 JsonParseOnError::Keep => { }
2349 JsonParseOnError::Null => {
2350 let target = into.unwrap_or(field.as_str());
2351 map.insert(target.to_owned(), Value::Null);
2352 }
2353 JsonParseOnError::Error => {
2354 return Err(FaucetError::Transform(format!(
2355 "json_parse: field '{field}' is not valid JSON: {e}"
2356 )));
2357 }
2358 },
2359 }
2360 }
2361 Ok(Value::Object(map))
2362 }
2363 other => Ok(other),
2364 }
2365}
2366
2367#[cfg(feature = "transform-coalesce")]
2370fn coalesce_field(
2371 value: Value,
2372 field: &str,
2373 default: Option<&Value>,
2374 from: &[String],
2375 treat_empty_string_as_null: bool,
2376) -> Value {
2377 match value {
2378 Value::Object(mut map) => {
2379 if is_nullish(map.get(field), treat_empty_string_as_null) {
2380 let replacement: Option<Value> = match default {
2381 Some(d) => Some(d.clone()),
2382 None => from.iter().find_map(|k| {
2383 let v = map.get(k);
2384 if is_nullish(v, treat_empty_string_as_null) {
2385 None
2386 } else {
2387 v.cloned()
2388 }
2389 }),
2390 };
2391 if let Some(v) = replacement {
2392 map.insert(field.to_owned(), v);
2393 }
2394 }
2395 Value::Object(map)
2396 }
2397 other => other,
2398 }
2399}
2400
2401#[cfg(feature = "transform-coalesce")]
2404fn is_nullish(v: Option<&Value>, treat_empty_string_as_null: bool) -> bool {
2405 match v {
2406 None | Some(Value::Null) => true,
2407 Some(Value::String(s)) => treat_empty_string_as_null && s.is_empty(),
2408 _ => false,
2409 }
2410}
2411
2412#[cfg(feature = "transform-split-join")]
2415fn split_field(
2416 value: Value,
2417 field: &str,
2418 delimiter: &str,
2419 trim: bool,
2420 into: Option<&str>,
2421) -> Value {
2422 match value {
2423 Value::Object(mut map) => {
2424 let Some(Value::String(s)) = map.get(field) else {
2425 return Value::Object(map);
2426 };
2427 let s = s.clone();
2428 let parts: Vec<Value> = if delimiter.is_empty() {
2432 vec![Value::String(if trim { s.trim().to_owned() } else { s })]
2433 } else {
2434 s.split(delimiter)
2435 .map(|part| {
2436 let p = if trim { part.trim() } else { part };
2437 Value::String(p.to_owned())
2438 })
2439 .collect()
2440 };
2441 let target = into.unwrap_or(field);
2442 map.insert(target.to_owned(), Value::Array(parts));
2443 Value::Object(map)
2444 }
2445 other => other,
2446 }
2447}
2448
2449#[cfg(feature = "transform-split-join")]
2450fn join_field(value: Value, field: &str, delimiter: &str, into: Option<&str>) -> Value {
2451 match value {
2452 Value::Object(mut map) => {
2453 let Some(Value::Array(arr)) = map.get(field) else {
2454 return Value::Object(map);
2455 };
2456 let joined = arr
2457 .iter()
2458 .map(scalar_to_string)
2459 .collect::<Vec<_>>()
2460 .join(delimiter);
2461 let target = into.unwrap_or(field);
2462 map.insert(target.to_owned(), Value::String(joined));
2463 Value::Object(map)
2464 }
2465 other => other,
2466 }
2467}
2468
2469#[cfg(feature = "transform-split-join")]
2472fn scalar_to_string(v: &Value) -> String {
2473 match v {
2474 Value::String(s) => s.clone(),
2475 Value::Null => String::new(),
2476 other => crate::util::canonical_json(other),
2477 }
2478}
2479
2480#[cfg(test)]
2483mod tests {
2484 use super::*;
2485 use serde_json::json;
2486
2487 fn apply_all(record: Value, transforms: &[CompiledTransform]) -> Value {
2492 super::apply_all(record, transforms).expect("transform should succeed in this test")
2493 }
2494
2495 fn compiled(transforms: &[RecordTransform]) -> Vec<CompiledTransform> {
2496 transforms.iter().map(|t| compile(t).unwrap()).collect()
2497 }
2498
2499 #[test]
2502 fn test_custom_adds_field() {
2503 let record = json!({"id": 1});
2504 let result = apply_all(
2505 record,
2506 &compiled(&[RecordTransform::custom(|mut v| {
2507 if let Value::Object(ref mut m) = v {
2508 m.insert("added".to_string(), json!(true));
2509 }
2510 v
2511 })]),
2512 );
2513 assert_eq!(result["id"], 1);
2514 assert_eq!(result["added"], true);
2515 }
2516
2517 #[test]
2518 fn test_custom_removes_field() {
2519 let record = json!({"id": 1, "secret": "drop_me"});
2520 let result = apply_all(
2521 record,
2522 &compiled(&[RecordTransform::custom(|mut v| {
2523 if let Value::Object(ref mut m) = v {
2524 m.remove("secret");
2525 }
2526 v
2527 })]),
2528 );
2529 assert_eq!(result["id"], 1);
2530 assert!(result.get("secret").is_none());
2531 }
2532
2533 #[test]
2534 fn test_no_transforms_is_identity() {
2535 let record = json!({"id": 1, "name": "Alice"});
2536 let result = apply_all(record.clone(), &[]);
2537 assert_eq!(result, record);
2538 }
2539
2540 #[cfg(feature = "transform-flatten")]
2543 #[test]
2544 fn test_flatten_nested_object() {
2545 let record = json!({"a": {"b": 1, "c": {"d": 2}}, "e": 3});
2546 let result = apply_all(
2547 record,
2548 &compiled(&[RecordTransform::Flatten {
2549 separator: "__".into(),
2550 }]),
2551 );
2552 assert_eq!(result["a__b"], 1);
2553 assert_eq!(result["a__c__d"], 2);
2554 assert_eq!(result["e"], 3);
2555 assert!(result.get("a").is_none(), "nested key should be removed");
2556 }
2557
2558 #[cfg(feature = "transform-flatten")]
2559 #[test]
2560 fn test_flatten_leaves_arrays_intact() {
2561 let record = json!({"tags": ["rust", "api"], "meta": {"count": 2}});
2562 let result = apply_all(
2563 record,
2564 &compiled(&[RecordTransform::Flatten {
2565 separator: ".".into(),
2566 }]),
2567 );
2568 assert_eq!(result["tags"], json!(["rust", "api"]));
2569 assert_eq!(result["meta.count"], 2);
2570 }
2571
2572 #[cfg(feature = "transform-flatten")]
2573 #[test]
2574 fn test_flatten_already_flat() {
2575 let record = json!({"id": 1, "name": "Alice"});
2576 let result = apply_all(
2577 record.clone(),
2578 &compiled(&[RecordTransform::Flatten {
2579 separator: "__".into(),
2580 }]),
2581 );
2582 assert_eq!(result, record);
2583 }
2584
2585 #[cfg(feature = "transform-flatten")]
2586 #[test]
2587 fn test_flatten_empty_separator() {
2588 let record = json!({"a": {"b": 1}});
2589 let result = apply_all(
2590 record,
2591 &compiled(&[RecordTransform::Flatten {
2592 separator: "".into(),
2593 }]),
2594 );
2595 assert_eq!(result["ab"], 1);
2596 }
2597
2598 #[cfg(feature = "transform-rename-keys")]
2601 #[test]
2602 fn test_rename_keys_strips_prefix() {
2603 let record = json!({"_prefix_id": 1, "_prefix_name": "Alice"});
2604 let result = apply_all(
2605 record,
2606 &compiled(&[RecordTransform::RenameKeys {
2607 pattern: r"^_prefix_".into(),
2608 replacement: "".into(),
2609 }]),
2610 );
2611 assert_eq!(result["id"], 1);
2612 assert_eq!(result["name"], "Alice");
2613 }
2614
2615 #[cfg(feature = "transform-rename-keys")]
2616 #[test]
2617 fn test_rename_keys_uppercase_to_placeholder() {
2618 let record = json!({"OUTER": {"INNER": 42}});
2619 let result = apply_all(
2620 record,
2621 &compiled(&[RecordTransform::RenameKeys {
2622 pattern: r"[A-Z]+".into(),
2623 replacement: "x".into(),
2624 }]),
2625 );
2626 assert_eq!(result["x"]["x"], 42);
2627 }
2628
2629 #[cfg(feature = "transform-rename-keys")]
2630 #[test]
2631 fn test_rename_keys_collision_is_an_error() {
2632 let t = compiled(&[RecordTransform::RenameKeys {
2633 pattern: r"[^a-z]".into(),
2634 replacement: "".into(),
2635 }]);
2636 let err = super::apply_all(json!({"a1": 1, "a2": 2}), &t).unwrap_err();
2637 assert!(err.to_string().contains("duplicate key 'a'"), "{err}");
2638 let err = super::apply_all(json!({"x": [{"b1": 1, "b2": 2}]}), &t).unwrap_err();
2639 assert!(err.to_string().contains("duplicate key 'b'"), "{err}");
2640 }
2641
2642 #[cfg(feature = "transform-rename-keys")]
2643 #[test]
2644 fn test_rename_keys_in_array_elements() {
2645 let record = json!({"items": [{"KEY": 1}, {"KEY": 2}]});
2646 let result = apply_all(
2647 record,
2648 &compiled(&[RecordTransform::RenameKeys {
2649 pattern: r"KEY".into(),
2650 replacement: "key".into(),
2651 }]),
2652 );
2653 assert_eq!(result["items"][0]["key"], 1);
2654 assert_eq!(result["items"][1]["key"], 2);
2655 }
2656
2657 #[cfg(feature = "transform-rename-keys")]
2658 #[test]
2659 fn test_rename_keys_invalid_regex_errors_at_compile() {
2660 let err = compile(&RecordTransform::RenameKeys {
2661 pattern: "[invalid".into(),
2662 replacement: "".into(),
2663 });
2664 assert!(err.is_err());
2665 assert!(matches!(err, Err(FaucetError::Transform(_))));
2666 }
2667
2668 #[cfg(feature = "transform-rename-keys")]
2669 #[test]
2670 fn test_rename_keys_chained() {
2671 let record = json!({"__camelCase__": 1});
2672 let result = apply_all(
2673 record,
2674 &compiled(&[
2675 RecordTransform::RenameKeys {
2676 pattern: r"^_+|_+$".into(),
2677 replacement: "".into(),
2678 },
2679 RecordTransform::RenameKeys {
2680 pattern: r"[A-Z]".into(),
2681 replacement: "_".into(),
2682 },
2683 ]),
2684 );
2685 let key = result.as_object().unwrap().keys().next().unwrap().clone();
2686 assert_eq!(key, "camel_ase");
2687 }
2688
2689 #[cfg(all(feature = "transform-keys-case", feature = "transform-flatten"))]
2692 #[test]
2693 fn test_keys_case_then_flatten() {
2694 let record = json!({"User Info": {"First Name": "Alice", "Last Name": "Smith"}});
2695 let result = apply_all(
2696 record,
2697 &compiled(&[
2698 RecordTransform::KeysCase {
2699 mode: KeyCaseMode::Snake,
2700 on_collision: KeyCollision::Error,
2701 },
2702 RecordTransform::Flatten {
2703 separator: "_".into(),
2704 },
2705 ]),
2706 );
2707 assert_eq!(result["user_info_first_name"], "Alice");
2708 assert_eq!(result["user_info_last_name"], "Smith");
2709 }
2710
2711 #[test]
2712 fn test_custom_chained_with_builtin() {
2713 let record = json!({"id": 1, "raw_value": 100});
2715 let result = apply_all(
2716 record,
2717 &compiled(&[
2718 RecordTransform::custom(|mut v| {
2720 if let Some(n) = v.get("raw_value").and_then(|n| n.as_i64())
2721 && let Value::Object(ref mut m) = v
2722 {
2723 m.insert("raw_value".to_string(), json!(n * 2));
2724 }
2725 v
2726 }),
2727 RecordTransform::custom(|mut v| {
2729 if let Value::Object(ref mut m) = v
2730 && let Some(val) = m.remove("raw_value")
2731 {
2732 m.insert("value".to_string(), val);
2733 }
2734 v
2735 }),
2736 ]),
2737 );
2738 assert_eq!(result["id"], 1);
2739 assert_eq!(result["value"], 200);
2740 assert!(result.get("raw_value").is_none());
2741 }
2742
2743 #[cfg(feature = "transform-flatten")]
2746 #[test]
2747 fn flatten_key_collision_errors() {
2748 let record = json!({"a__b": 1, "a": {"b": 2}});
2750 let err = super::apply_all(
2751 record,
2752 &compiled(&[RecordTransform::Flatten {
2753 separator: "__".into(),
2754 }]),
2755 )
2756 .expect_err("colliding flattened keys must error, not drop a value");
2757 assert!(matches!(err, FaucetError::Transform(_)));
2758 assert!(format!("{err}").contains("a__b"), "{err}");
2759 }
2760
2761 #[cfg(feature = "transform-keys-case")]
2762 #[test]
2763 fn keys_case_collision_errors_by_default() {
2764 let record = json!({"AccountId": 1, "account_id": 2});
2766 let err = super::apply_all(
2767 record,
2768 &compiled(&[RecordTransform::KeysCase {
2769 mode: KeyCaseMode::Snake,
2770 on_collision: KeyCollision::Error,
2771 }]),
2772 )
2773 .expect_err("colliding re-cased keys must error by default, not drop a value");
2774 assert!(matches!(err, FaucetError::Transform(_)));
2775 assert!(format!("{err}").contains("account_id"), "{err}");
2776 }
2777
2778 #[cfg(feature = "transform-keys-case")]
2779 #[test]
2780 fn keys_case_collision_suffix_disambiguates() {
2781 let record = json!({"AccountId": 1, "account_id": 2});
2782 let result = apply_all(
2783 record,
2784 &compiled(&[RecordTransform::KeysCase {
2785 mode: KeyCaseMode::Snake,
2786 on_collision: KeyCollision::Suffix,
2787 }]),
2788 );
2789 let obj = result.as_object().unwrap();
2790 let keys: std::collections::BTreeSet<&str> = obj.keys().map(String::as_str).collect();
2792 assert_eq!(
2793 keys,
2794 ["account_id", "account_id_2"].into_iter().collect(),
2795 "got {keys:?}"
2796 );
2797 let vals: std::collections::BTreeSet<i64> =
2798 obj.values().filter_map(Value::as_i64).collect();
2799 assert_eq!(vals, [1, 2].into_iter().collect(), "both values preserved");
2800 }
2801
2802 #[cfg(feature = "transform-keys-case")]
2803 #[test]
2804 fn keys_case_collision_suffix_three_way() {
2805 let record = json!({"Foo": 1, "foo": 2, "FOO": 3});
2807 let result = apply_all(
2808 record,
2809 &compiled(&[RecordTransform::KeysCase {
2810 mode: KeyCaseMode::Snake,
2811 on_collision: KeyCollision::Suffix,
2812 }]),
2813 );
2814 let obj = result.as_object().unwrap();
2815 let keys: std::collections::BTreeSet<&str> = obj.keys().map(String::as_str).collect();
2816 assert_eq!(
2817 keys,
2818 ["foo", "foo_2", "foo_3"].into_iter().collect(),
2819 "got {keys:?}"
2820 );
2821 let vals: std::collections::BTreeSet<i64> =
2822 obj.values().filter_map(Value::as_i64).collect();
2823 assert_eq!(vals, [1, 2, 3].into_iter().collect());
2824 }
2825
2826 #[cfg(feature = "transform-keys-case")]
2827 #[test]
2828 fn keys_case_collision_suffix_is_order_stable() {
2829 let record = json!({"AccountId": 1, "account_id": 2, "Account_Id": 3});
2830 let run = || {
2831 apply_all(
2832 record.clone(),
2833 &compiled(&[RecordTransform::KeysCase {
2834 mode: KeyCaseMode::Snake,
2835 on_collision: KeyCollision::Suffix,
2836 }]),
2837 )
2838 };
2839 assert_eq!(run(), run());
2841 }
2842
2843 #[cfg(feature = "transform-select")]
2846 #[test]
2847 fn select_keeps_only_listed_fields() {
2848 let record = json!({"id": 1, "name": "Alice", "secret": "drop"});
2849 let result = apply_all(
2850 record,
2851 &compiled(&[RecordTransform::Select {
2852 fields: vec!["id".into(), "name".into()],
2853 }]),
2854 );
2855 assert_eq!(result["id"], 1);
2856 assert_eq!(result["name"], "Alice");
2857 assert!(result.get("secret").is_none());
2858 }
2859
2860 #[cfg(feature = "transform-select")]
2861 #[test]
2862 fn select_missing_field_is_no_op() {
2863 let record = json!({"id": 1});
2865 let result = apply_all(
2866 record,
2867 &compiled(&[RecordTransform::Select {
2868 fields: vec!["id".into(), "missing".into()],
2869 }]),
2870 );
2871 assert_eq!(result["id"], 1);
2872 assert!(result.get("missing").is_none());
2873 }
2874
2875 #[cfg(feature = "transform-select")]
2876 #[test]
2877 fn select_passes_through_non_object() {
2878 let record = json!([1, 2, 3]);
2879 let result = apply_all(
2880 record.clone(),
2881 &compiled(&[RecordTransform::Select {
2882 fields: vec!["id".into()],
2883 }]),
2884 );
2885 assert_eq!(result, record);
2886 }
2887
2888 #[cfg(feature = "transform-drop")]
2891 #[test]
2892 fn drop_removes_listed_fields() {
2893 let record = json!({"id": 1, "ssn": "111-22-3333", "name": "Alice"});
2894 let result = apply_all(
2895 record,
2896 &compiled(&[RecordTransform::Drop {
2897 fields: vec!["ssn".into()],
2898 }]),
2899 );
2900 assert_eq!(result["id"], 1);
2901 assert_eq!(result["name"], "Alice");
2902 assert!(result.get("ssn").is_none());
2903 }
2904
2905 #[cfg(feature = "transform-drop")]
2906 #[test]
2907 fn drop_missing_field_is_no_op() {
2908 let record = json!({"id": 1});
2909 let result = apply_all(
2910 record,
2911 &compiled(&[RecordTransform::Drop {
2912 fields: vec!["missing".into()],
2913 }]),
2914 );
2915 assert_eq!(result["id"], 1);
2916 }
2917
2918 #[cfg(feature = "transform-set")]
2921 #[test]
2922 fn set_inserts_new_fields() {
2923 let record = json!({"id": 1});
2924 let mut values = Map::new();
2925 values.insert("_source".into(), json!("api"));
2926 values.insert("ingested_at".into(), json!("2026-01-01"));
2927 let result = apply_all(record, &compiled(&[RecordTransform::Set { values }]));
2928 assert_eq!(result["id"], 1);
2929 assert_eq!(result["_source"], "api");
2930 assert_eq!(result["ingested_at"], "2026-01-01");
2931 }
2932
2933 #[cfg(feature = "transform-set")]
2934 #[test]
2935 fn set_overwrites_existing_field() {
2936 let record = json!({"_source": "old", "id": 1});
2937 let mut values = Map::new();
2938 values.insert("_source".into(), json!("new"));
2939 let result = apply_all(record, &compiled(&[RecordTransform::Set { values }]));
2940 assert_eq!(result["_source"], "new");
2941 assert_eq!(result["id"], 1);
2942 }
2943
2944 #[cfg(feature = "transform-set")]
2945 #[test]
2946 fn set_supports_any_json_value() {
2947 let record = json!({});
2948 let mut values = Map::new();
2949 values.insert("n".into(), json!(42));
2950 values.insert("b".into(), json!(true));
2951 values.insert("arr".into(), json!([1, 2]));
2952 values.insert("obj".into(), json!({"k": "v"}));
2953 values.insert("null".into(), Value::Null);
2954 let result = apply_all(record, &compiled(&[RecordTransform::Set { values }]));
2955 assert_eq!(result["n"], 42);
2956 assert_eq!(result["b"], true);
2957 assert_eq!(result["arr"], json!([1, 2]));
2958 assert_eq!(result["obj"]["k"], "v");
2959 assert_eq!(result["null"], Value::Null);
2960 }
2961
2962 #[cfg(feature = "transform-rename-field")]
2965 #[test]
2966 fn rename_field_renames_exact_key() {
2967 let record = json!({"old_name": 1, "keep": 2});
2968 let mut fields = HashMap::new();
2969 fields.insert("old_name".to_owned(), "new_name".to_owned());
2970 let result = apply_all(
2971 record,
2972 &compiled(&[RecordTransform::RenameField { fields }]),
2973 );
2974 assert_eq!(result["new_name"], 1);
2975 assert_eq!(result["keep"], 2);
2976 assert!(result.get("old_name").is_none());
2977 }
2978
2979 #[cfg(feature = "transform-rename-field")]
2980 #[test]
2981 fn rename_field_missing_source_is_no_op() {
2982 let record = json!({"id": 1});
2983 let mut fields = HashMap::new();
2984 fields.insert("missing".to_owned(), "renamed".to_owned());
2985 let result = apply_all(
2986 record,
2987 &compiled(&[RecordTransform::RenameField { fields }]),
2988 );
2989 assert_eq!(result["id"], 1);
2990 assert!(result.get("renamed").is_none());
2991 }
2992
2993 #[cfg(feature = "transform-rename-field")]
2994 #[test]
2995 fn rename_field_target_collision_errors() {
2996 let record = json!({"a": 1, "b": 2});
2997 let mut fields = HashMap::new();
2998 fields.insert("a".to_owned(), "b".to_owned());
2999 let err = super::apply_all(
3000 record,
3001 &compiled(&[RecordTransform::RenameField { fields }]),
3002 )
3003 .expect_err("collision must error, not overwrite");
3004 assert!(matches!(err, FaucetError::Transform(_)));
3005 assert!(format!("{err}").contains("'b'"), "{err}");
3006 }
3007
3008 #[cfg(feature = "transform-rename-field")]
3009 #[test]
3010 fn rename_field_swap_is_deterministic() {
3011 for _ in 0..50 {
3014 let record = json!({"a": 1, "b": 2, "keep": 3});
3015 let mut fields = HashMap::new();
3016 fields.insert("a".to_owned(), "b".to_owned());
3017 fields.insert("b".to_owned(), "a".to_owned());
3018 let result = apply_all(
3019 record,
3020 &compiled(&[RecordTransform::RenameField { fields }]),
3021 );
3022 assert_eq!(result["a"], 2, "{result}");
3023 assert_eq!(result["b"], 1, "{result}");
3024 assert_eq!(result["keep"], 3);
3025 }
3026 }
3027
3028 #[cfg(feature = "transform-rename-field")]
3029 #[test]
3030 fn rename_field_chain_applies_against_original_snapshot() {
3031 for _ in 0..50 {
3034 let record = json!({"a": 1, "b": 2});
3035 let mut fields = HashMap::new();
3036 fields.insert("a".to_owned(), "b".to_owned());
3037 fields.insert("b".to_owned(), "c".to_owned());
3038 let result = apply_all(
3039 record,
3040 &compiled(&[RecordTransform::RenameField { fields }]),
3041 );
3042 assert_eq!(result["b"], 1, "{result}");
3043 assert_eq!(result["c"], 2, "{result}");
3044 assert!(result.get("a").is_none(), "{result}");
3045 }
3046 }
3047
3048 #[cfg(feature = "transform-rename-field")]
3049 #[test]
3050 fn rename_field_two_sources_one_target_errors() {
3051 let record = json!({"a": 1, "b": 2});
3052 let mut fields = HashMap::new();
3053 fields.insert("a".to_owned(), "c".to_owned());
3054 fields.insert("b".to_owned(), "c".to_owned());
3055 let err = super::apply_all(
3056 record,
3057 &compiled(&[RecordTransform::RenameField { fields }]),
3058 )
3059 .expect_err("two renames to the same target must error");
3060 assert!(format!("{err}").contains("same target"), "{err}");
3061 }
3062
3063 #[cfg(feature = "transform-cast")]
3066 fn cast_specs(field: &str, ty: CastType, on_error: CastOnError) -> Vec<RecordTransform> {
3067 let mut fields = HashMap::new();
3068 fields.insert(field.to_owned(), ty);
3069 vec![RecordTransform::Cast { fields, on_error }]
3070 }
3071
3072 #[cfg(feature = "transform-cast")]
3073 #[test]
3074 fn cast_string_to_int() {
3075 let record = json!({"age": "42"});
3076 let result = apply_all(
3077 record,
3078 &compiled(&cast_specs("age", CastType::Int, CastOnError::Error)),
3079 );
3080 assert_eq!(result["age"], 42);
3081 }
3082
3083 #[cfg(feature = "transform-cast")]
3084 #[test]
3085 fn cast_whole_number_float_to_int_succeeds() {
3086 let record = json!({"n": 5.0});
3088 let result = apply_all(
3089 record,
3090 &compiled(&cast_specs("n", CastType::Int, CastOnError::Error)),
3091 );
3092 assert_eq!(result["n"], 5);
3093 }
3094
3095 #[cfg(feature = "transform-cast")]
3096 #[test]
3097 fn cast_fractional_float_to_int_errors_under_on_error_error() {
3098 let record = json!({"n": 3.9});
3100 let err = super::apply_all(
3101 record,
3102 &compiled(&cast_specs("n", CastType::Int, CastOnError::Error)),
3103 )
3104 .expect_err("a fractional float must not silently truncate to int");
3105 assert!(matches!(err, FaucetError::Transform(_)), "{err}");
3106 }
3107
3108 #[cfg(feature = "transform-cast")]
3109 #[test]
3110 fn cast_out_of_range_float_to_int_errors_under_on_error_error() {
3111 let record = json!({"n": 1e30});
3113 let err = super::apply_all(
3114 record,
3115 &compiled(&cast_specs("n", CastType::Int, CastOnError::Error)),
3116 )
3117 .expect_err("an out-of-range float must not silently saturate to i64::MAX");
3118 assert!(matches!(err, FaucetError::Transform(_)), "{err}");
3119 }
3120
3121 #[cfg(feature = "transform-cast")]
3122 #[test]
3123 fn cast_fractional_float_to_int_nulls_under_on_error_null() {
3124 let record = json!({"n": 3.9});
3125 let result = apply_all(
3126 record,
3127 &compiled(&cast_specs("n", CastType::Int, CastOnError::Null)),
3128 );
3129 assert_eq!(result["n"], Value::Null);
3130 }
3131
3132 #[cfg(feature = "transform-cast")]
3133 #[test]
3134 fn cast_string_to_float() {
3135 let record = json!({"price": "9.99"});
3136 let result = apply_all(
3137 record,
3138 &compiled(&cast_specs("price", CastType::Float, CastOnError::Error)),
3139 );
3140 assert_eq!(result["price"], 9.99);
3141 }
3142
3143 #[cfg(feature = "transform-cast")]
3144 #[test]
3145 fn cast_string_to_bool() {
3146 for input in ["true", "TRUE", "1", "yes"] {
3147 let record = json!({"flag": input});
3148 let result = apply_all(
3149 record,
3150 &compiled(&cast_specs("flag", CastType::Bool, CastOnError::Error)),
3151 );
3152 assert_eq!(result["flag"], true, "input was {input:?}");
3153 }
3154 for input in ["false", "0", "no"] {
3155 let record = json!({"flag": input});
3156 let result = apply_all(
3157 record,
3158 &compiled(&cast_specs("flag", CastType::Bool, CastOnError::Error)),
3159 );
3160 assert_eq!(result["flag"], false, "input was {input:?}");
3161 }
3162 }
3163
3164 #[cfg(feature = "transform-cast")]
3165 #[test]
3166 fn cast_number_to_string() {
3167 let record = json!({"id": 42});
3168 let result = apply_all(
3169 record,
3170 &compiled(&cast_specs("id", CastType::String, CastOnError::Error)),
3171 );
3172 assert_eq!(result["id"], "42");
3173 }
3174
3175 #[cfg(feature = "transform-cast")]
3176 #[test]
3177 fn cast_string_to_timestamp_normalises() {
3178 let record = json!({"ts": "2026-05-28T12:34:56+00:00"});
3179 let result = apply_all(
3180 record,
3181 &compiled(&cast_specs("ts", CastType::Timestamp, CastOnError::Error)),
3182 );
3183 assert_eq!(result["ts"], "2026-05-28T12:34:56Z");
3185 }
3186
3187 #[cfg(feature = "transform-cast")]
3188 #[test]
3189 fn cast_on_error_error_propagates() {
3190 let record = json!({"age": "not a number"});
3191 let err = super::apply_all(
3192 record,
3193 &compiled(&cast_specs("age", CastType::Int, CastOnError::Error)),
3194 )
3195 .expect_err("uncastable value must error under on_error=error");
3196 assert!(matches!(err, FaucetError::Transform(_)));
3197 assert!(format!("{err}").contains("'age'"), "{err}");
3198 }
3199
3200 #[cfg(feature = "transform-cast")]
3201 #[test]
3202 fn cast_on_error_null_replaces() {
3203 let record = json!({"age": "not a number"});
3204 let result = apply_all(
3205 record,
3206 &compiled(&cast_specs("age", CastType::Int, CastOnError::Null)),
3207 );
3208 assert_eq!(result["age"], Value::Null);
3209 }
3210
3211 #[cfg(feature = "transform-cast")]
3212 #[test]
3213 fn cast_on_error_skip_leaves_value() {
3214 let record = json!({"age": "not a number"});
3215 let result = apply_all(
3216 record,
3217 &compiled(&cast_specs("age", CastType::Int, CastOnError::Skip)),
3218 );
3219 assert_eq!(result["age"], "not a number");
3220 }
3221
3222 #[cfg(feature = "transform-cast")]
3223 #[test]
3224 fn cast_missing_field_is_no_op() {
3225 let record = json!({"id": 1});
3226 let result = apply_all(
3227 record,
3228 &compiled(&cast_specs("missing", CastType::Int, CastOnError::Error)),
3229 );
3230 assert_eq!(result["id"], 1);
3231 assert!(result.get("missing").is_none());
3232 }
3233
3234 #[cfg(feature = "transform-redact")]
3237 #[test]
3238 fn redact_replaces_value_with_mask() {
3239 let record = json!({"id": 1, "ssn": "111-22-3333", "email": "x@y.z"});
3240 let result = apply_all(
3241 record,
3242 &compiled(&[RecordTransform::Redact {
3243 fields: vec!["ssn".into(), "email".into()],
3244 mask: json!("***"),
3245 }]),
3246 );
3247 assert_eq!(result["id"], 1);
3248 assert_eq!(result["ssn"], "***");
3249 assert_eq!(result["email"], "***");
3250 }
3251
3252 #[cfg(feature = "transform-redact")]
3253 #[test]
3254 fn redact_missing_field_does_not_insert_mask() {
3255 let record = json!({"id": 1});
3256 let result = apply_all(
3257 record,
3258 &compiled(&[RecordTransform::Redact {
3259 fields: vec!["ssn".into()],
3260 mask: json!("***"),
3261 }]),
3262 );
3263 assert_eq!(result["id"], 1);
3264 assert!(result.get("ssn").is_none());
3265 }
3266
3267 #[cfg(feature = "transform-value-case")]
3270 #[test]
3271 fn value_case_lower() {
3272 let record = json!({"email": "User@Example.COM", "id": 1});
3273 let result = apply_all(
3274 record,
3275 &compiled(&[RecordTransform::ValueCase {
3276 fields: vec!["email".into()],
3277 mode: ValueCaseMode::Lower,
3278 }]),
3279 );
3280 assert_eq!(result["email"], "user@example.com");
3281 assert_eq!(result["id"], 1);
3282 }
3283
3284 #[cfg(feature = "transform-value-case")]
3285 #[test]
3286 fn value_case_upper() {
3287 let record = json!({"code": "abc"});
3288 let result = apply_all(
3289 record,
3290 &compiled(&[RecordTransform::ValueCase {
3291 fields: vec!["code".into()],
3292 mode: ValueCaseMode::Upper,
3293 }]),
3294 );
3295 assert_eq!(result["code"], "ABC");
3296 }
3297
3298 #[cfg(feature = "transform-value-case")]
3299 #[test]
3300 fn value_case_trim() {
3301 let record = json!({"name": " Alice "});
3302 let result = apply_all(
3303 record,
3304 &compiled(&[RecordTransform::ValueCase {
3305 fields: vec!["name".into()],
3306 mode: ValueCaseMode::Trim,
3307 }]),
3308 );
3309 assert_eq!(result["name"], "Alice");
3310 }
3311
3312 #[cfg(feature = "transform-value-case")]
3313 #[test]
3314 fn value_case_passes_non_string_through() {
3315 let record = json!({"id": 42});
3316 let result = apply_all(
3317 record,
3318 &compiled(&[RecordTransform::ValueCase {
3319 fields: vec!["id".into()],
3320 mode: ValueCaseMode::Upper,
3321 }]),
3322 );
3323 assert_eq!(result["id"], 42);
3324 }
3325
3326 #[cfg(feature = "transform-spell-symbols")]
3329 fn spell_default() -> Vec<RecordTransform> {
3330 vec![RecordTransform::SpellSymbols {
3331 extra: HashMap::new(),
3332 separator: " ".into(),
3333 }]
3334 }
3335
3336 #[cfg(feature = "transform-spell-symbols")]
3337 #[test]
3338 fn spell_symbols_replaces_common_symbols() {
3339 let record = json!({"%sold": 1, "C#course": 2, "$amount": 3});
3340 let result = apply_all(record, &compiled(&spell_default()));
3341 assert!(result.get(" percent sold").is_some());
3344 assert!(result.get("C number course").is_some());
3345 assert!(result.get(" dollar amount").is_some());
3346 }
3347
3348 #[cfg(all(feature = "transform-spell-symbols", feature = "transform-keys-case"))]
3349 #[test]
3350 fn spell_symbols_then_keys_case_pipeline() {
3351 let record = json!({"% sold": 10, "C# courses": 20});
3352 let result = super::apply_all(
3353 record,
3354 &compiled(&[
3355 RecordTransform::SpellSymbols {
3356 extra: HashMap::new(),
3357 separator: " ".into(),
3358 },
3359 RecordTransform::KeysCase {
3360 mode: KeyCaseMode::Snake,
3361 on_collision: KeyCollision::Error,
3362 },
3363 ]),
3364 )
3365 .expect("pipeline must succeed");
3366 assert_eq!(result["percent_sold"], 10);
3367 assert_eq!(result["c_number_courses"], 20);
3368 }
3369
3370 #[cfg(feature = "transform-spell-symbols")]
3371 #[test]
3372 fn spell_symbols_extra_overrides_defaults() {
3373 let mut extra = HashMap::new();
3374 extra.insert("#".to_owned(), "hash".to_owned());
3375 extra.insert("©".to_owned(), "copyright".to_owned());
3376 let record = json!({"#tag": 1, "©2026": 2});
3377 let result = apply_all(
3378 record,
3379 &compiled(&[RecordTransform::SpellSymbols {
3380 extra,
3381 separator: " ".into(),
3382 }]),
3383 );
3384 assert!(result.get(" hash tag").is_some());
3386 assert!(result.get(" copyright 2026").is_some());
3388 }
3389
3390 #[cfg(feature = "transform-spell-symbols")]
3391 #[test]
3392 fn spell_symbols_longest_match_wins() {
3393 let mut extra = HashMap::new();
3395 extra.insert("<=".to_owned(), "lte".to_owned());
3396 let record = json!({"a<=b": 1});
3397 let result = apply_all(
3398 record,
3399 &compiled(&[RecordTransform::SpellSymbols {
3400 extra,
3401 separator: " ".into(),
3402 }]),
3403 );
3404 assert!(result.get("a lte b").is_some());
3405 assert!(result.get("a lt = b").is_none());
3407 }
3408
3409 #[cfg(feature = "transform-spell-symbols")]
3410 #[test]
3411 fn spell_symbols_recursive_into_objects_and_arrays() {
3412 let record = json!({"outer&": {"inner%": [{"deep#": 1}]}});
3413 let result = apply_all(record, &compiled(&spell_default()));
3414 let outer_key = result.as_object().unwrap().keys().next().unwrap().clone();
3415 assert!(outer_key.contains("and"), "outer key was {outer_key:?}");
3416 let inner = &result[&outer_key];
3417 let inner_key = inner.as_object().unwrap().keys().next().unwrap().clone();
3418 assert!(inner_key.contains("percent"), "inner key was {inner_key:?}");
3419 let deep = &inner[&inner_key][0];
3420 let deep_key = deep.as_object().unwrap().keys().next().unwrap().clone();
3421 assert!(deep_key.contains("number"), "deep key was {deep_key:?}");
3422 }
3423
3424 #[cfg(feature = "transform-spell-symbols")]
3425 #[test]
3426 fn spell_symbols_key_collision_errors() {
3427 let record = json!({"%": 1, "percent": 2});
3429 let err = super::apply_all(
3430 record,
3431 &compiled(&[RecordTransform::SpellSymbols {
3432 extra: HashMap::new(),
3433 separator: "".into(),
3434 }]),
3435 )
3436 .expect_err("colliding spelled keys must error, not drop a value");
3437 assert!(matches!(err, FaucetError::Transform(_)));
3438 assert!(format!("{err}").contains("percent"), "{err}");
3439 }
3440
3441 #[cfg(feature = "transform-keys-case")]
3444 fn keys_case_specs(mode: KeyCaseMode) -> Vec<RecordTransform> {
3445 vec![RecordTransform::KeysCase {
3446 mode,
3447 on_collision: KeyCollision::Error,
3448 }]
3449 }
3450
3451 #[cfg(feature = "transform-keys-case")]
3452 #[test]
3453 fn keys_case_snake() {
3454 let record = json!({"First Name": 1, "last-name": 2, "ID": 3});
3455 let result = apply_all(record, &compiled(&keys_case_specs(KeyCaseMode::Snake)));
3456 assert_eq!(result["first_name"], 1);
3457 assert_eq!(result["last_name"], 2);
3458 assert_eq!(result["id"], 3);
3459 }
3460
3461 #[cfg(feature = "transform-keys-case")]
3462 #[test]
3463 fn keys_case_camel_from_various_inputs() {
3464 let record = json!({"first_name": 1, "User ID": 2, "kebab-case": 3, "PascalCase": 4});
3466 let result = apply_all(record, &compiled(&keys_case_specs(KeyCaseMode::Camel)));
3467 assert_eq!(result["firstName"], 1);
3468 assert_eq!(result["userId"], 2);
3469 assert_eq!(result["kebabCase"], 3);
3470 assert_eq!(result["pascalCase"], 4);
3471 }
3472
3473 #[cfg(feature = "transform-keys-case")]
3474 #[test]
3475 fn keys_case_pascal() {
3476 let record = json!({"first_name": 1, "second name": 2});
3477 let result = apply_all(record, &compiled(&keys_case_specs(KeyCaseMode::Pascal)));
3478 assert_eq!(result["FirstName"], 1);
3479 assert_eq!(result["SecondName"], 2);
3480 }
3481
3482 #[cfg(feature = "transform-keys-case")]
3483 #[test]
3484 fn keys_case_kebab() {
3485 let record = json!({"firstName": 1, "second_name": 2});
3486 let result = apply_all(record, &compiled(&keys_case_specs(KeyCaseMode::Kebab)));
3487 assert_eq!(result["first-name"], 1);
3488 assert_eq!(result["second-name"], 2);
3489 }
3490
3491 #[cfg(feature = "transform-keys-case")]
3492 #[test]
3493 fn keys_case_screaming_snake() {
3494 let record = json!({"firstName": 1, "second name": 2});
3495 let result = apply_all(
3496 record,
3497 &compiled(&keys_case_specs(KeyCaseMode::ScreamingSnake)),
3498 );
3499 assert_eq!(result["FIRST_NAME"], 1);
3500 assert_eq!(result["SECOND_NAME"], 2);
3501 }
3502
3503 #[cfg(feature = "transform-keys-case")]
3504 #[test]
3505 fn keys_case_recursive_into_nested() {
3506 let record = json!({"User Info": {"First Name": "Alice", "items": [{"Tag Name": "x"}]}});
3507 let result = apply_all(record, &compiled(&keys_case_specs(KeyCaseMode::Snake)));
3508 assert_eq!(result["user_info"]["first_name"], "Alice");
3509 assert_eq!(result["user_info"]["items"][0]["tag_name"], "x");
3510 }
3511
3512 #[cfg(feature = "transform-keys-case")]
3513 #[test]
3514 fn keys_case_collision_errors() {
3515 let record = json!({"firstName": 1, "first_name": 2});
3517 let err = super::apply_all(record, &compiled(&keys_case_specs(KeyCaseMode::Snake)))
3518 .expect_err("colliding re-cased keys must error, not drop a value");
3519 assert!(matches!(err, FaucetError::Transform(_)));
3520 assert!(format!("{err}").contains("first_name"), "{err}");
3521 }
3522
3523 #[cfg(feature = "transform-keys-case")]
3524 #[test]
3525 fn keys_case_all_symbol_key_kept_as_is() {
3526 let record = json!({"!@#": 1, "id": 2});
3529 let result = apply_all(record, &compiled(&keys_case_specs(KeyCaseMode::Snake)));
3530 assert_eq!(result["!@#"], 1);
3531 assert_eq!(result["id"], 2);
3532 }
3533
3534 #[cfg(feature = "transform-keys-case")]
3535 #[test]
3536 fn keys_case_idempotent_in_target_mode() {
3537 let record = json!({"first_name": 1});
3540 let once = apply_all(record, &compiled(&keys_case_specs(KeyCaseMode::Snake)));
3541 let twice = apply_all(
3542 once.clone(),
3543 &compiled(&keys_case_specs(KeyCaseMode::Snake)),
3544 );
3545 assert_eq!(once, twice);
3546 }
3547
3548 #[cfg(feature = "transform-spell-symbols")]
3549 #[test]
3550 fn spell_symbols_handles_unicode_keys() {
3551 let record = json!({"café%": 1});
3553 let result = apply_all(record, &compiled(&spell_default()));
3554 let key = result.as_object().unwrap().keys().next().unwrap().clone();
3555 assert!(key.contains("café"), "key was {key:?}");
3556 assert!(key.contains("percent"), "key was {key:?}");
3557 }
3558
3559 #[test]
3562 fn debug_record_transform_all_variants() {
3563 let dbg = format!("{:?}", RecordTransform::custom(|v| v));
3565 assert_eq!(dbg, "Custom(<fn>)");
3566
3567 #[cfg(feature = "transform-flatten")]
3568 {
3569 let dbg = format!(
3570 "{:?}",
3571 RecordTransform::Flatten {
3572 separator: "__".into()
3573 }
3574 );
3575 assert!(dbg.starts_with("Flatten"), "{dbg}");
3576 assert!(dbg.contains("separator"), "{dbg}");
3577 assert!(dbg.contains("__"), "{dbg}");
3578 }
3579 #[cfg(feature = "transform-rename-keys")]
3580 {
3581 let dbg = format!(
3582 "{:?}",
3583 RecordTransform::RenameKeys {
3584 pattern: "p".into(),
3585 replacement: "r".into(),
3586 }
3587 );
3588 assert!(dbg.starts_with("RenameKeys"), "{dbg}");
3589 assert!(dbg.contains("pattern"), "{dbg}");
3590 assert!(dbg.contains("replacement"), "{dbg}");
3591 }
3592 #[cfg(feature = "transform-keys-case")]
3593 {
3594 let dbg = format!(
3595 "{:?}",
3596 RecordTransform::KeysCase {
3597 mode: KeyCaseMode::Snake,
3598 on_collision: Default::default(),
3599 }
3600 );
3601 assert!(dbg.starts_with("KeysCase"), "{dbg}");
3602 assert!(dbg.contains("Snake"), "{dbg}");
3603 }
3604 #[cfg(feature = "transform-select")]
3605 {
3606 let dbg = format!(
3607 "{:?}",
3608 RecordTransform::Select {
3609 fields: vec!["a".into()]
3610 }
3611 );
3612 assert!(dbg.starts_with("Select"), "{dbg}");
3613 assert!(dbg.contains("fields"), "{dbg}");
3614 }
3615 #[cfg(feature = "transform-drop")]
3616 {
3617 let dbg = format!(
3618 "{:?}",
3619 RecordTransform::Drop {
3620 fields: vec!["a".into()]
3621 }
3622 );
3623 assert!(dbg.starts_with("Drop"), "{dbg}");
3624 }
3625 #[cfg(feature = "transform-set")]
3626 {
3627 let mut values = Map::new();
3628 values.insert("k".into(), json!("v"));
3629 let dbg = format!("{:?}", RecordTransform::Set { values });
3630 assert!(dbg.starts_with("Set"), "{dbg}");
3631 assert!(dbg.contains("values"), "{dbg}");
3632 }
3633 #[cfg(feature = "transform-rename-field")]
3634 {
3635 let mut fields = HashMap::new();
3636 fields.insert("a".to_owned(), "b".to_owned());
3637 let dbg = format!("{:?}", RecordTransform::RenameField { fields });
3638 assert!(dbg.starts_with("RenameField"), "{dbg}");
3639 }
3640 #[cfg(feature = "transform-cast")]
3641 {
3642 let mut fields = HashMap::new();
3643 fields.insert("a".to_owned(), CastType::Int);
3644 let dbg = format!(
3645 "{:?}",
3646 RecordTransform::Cast {
3647 fields,
3648 on_error: CastOnError::Error,
3649 }
3650 );
3651 assert!(dbg.starts_with("Cast"), "{dbg}");
3652 assert!(dbg.contains("on_error"), "{dbg}");
3653 }
3654 #[cfg(feature = "transform-redact")]
3655 {
3656 let dbg = format!(
3657 "{:?}",
3658 RecordTransform::Redact {
3659 fields: vec!["a".into()],
3660 mask: json!("***"),
3661 }
3662 );
3663 assert!(dbg.starts_with("Redact"), "{dbg}");
3664 assert!(dbg.contains("mask"), "{dbg}");
3665 }
3666 #[cfg(feature = "transform-value-case")]
3667 {
3668 let dbg = format!(
3669 "{:?}",
3670 RecordTransform::ValueCase {
3671 fields: vec!["a".into()],
3672 mode: ValueCaseMode::Lower,
3673 }
3674 );
3675 assert!(dbg.starts_with("ValueCase"), "{dbg}");
3676 assert!(dbg.contains("mode"), "{dbg}");
3677 }
3678 #[cfg(feature = "transform-spell-symbols")]
3679 {
3680 let dbg = format!(
3681 "{:?}",
3682 RecordTransform::SpellSymbols {
3683 extra: HashMap::new(),
3684 separator: " ".into(),
3685 }
3686 );
3687 assert!(dbg.starts_with("SpellSymbols"), "{dbg}");
3688 assert!(dbg.contains("separator"), "{dbg}");
3689 }
3690 #[cfg(feature = "transform-hash")]
3691 {
3692 let dbg = format!(
3693 "{:?}",
3694 RecordTransform::Hash {
3695 fields: vec!["a".into()],
3696 algorithm: HashAlgorithm::Blake3,
3697 encoding: HashEncoding::Base64,
3698 salt: Some("s".into()),
3699 into: Some("a_hash".into()),
3700 }
3701 );
3702 assert!(dbg.starts_with("Hash"), "{dbg}");
3703 assert!(dbg.contains("algorithm"), "{dbg}");
3704 assert!(dbg.contains("encoding"), "{dbg}");
3705 }
3706 #[cfg(feature = "transform-json-parse")]
3707 {
3708 let dbg = format!(
3709 "{:?}",
3710 RecordTransform::JsonParse {
3711 fields: vec!["a".into()],
3712 on_error: JsonParseOnError::Null,
3713 into: None,
3714 }
3715 );
3716 assert!(dbg.starts_with("JsonParse"), "{dbg}");
3717 assert!(dbg.contains("on_error"), "{dbg}");
3718 }
3719 #[cfg(feature = "transform-coalesce")]
3720 {
3721 let dbg = format!(
3722 "{:?}",
3723 RecordTransform::Coalesce {
3724 field: "a".into(),
3725 default: Some(json!("x")),
3726 from: vec![],
3727 treat_empty_string_as_null: true,
3728 }
3729 );
3730 assert!(dbg.starts_with("Coalesce"), "{dbg}");
3731 assert!(dbg.contains("treat_empty_string_as_null"), "{dbg}");
3732 }
3733 #[cfg(feature = "transform-split-join")]
3734 {
3735 let dbg = format!(
3736 "{:?}",
3737 RecordTransform::Split {
3738 field: "a".into(),
3739 delimiter: ",".into(),
3740 trim: true,
3741 into: None,
3742 }
3743 );
3744 assert!(dbg.starts_with("Split"), "{dbg}");
3745 assert!(dbg.contains("delimiter"), "{dbg}");
3746 let dbg = format!(
3747 "{:?}",
3748 RecordTransform::Join {
3749 field: "a".into(),
3750 delimiter: ",".into(),
3751 into: None,
3752 }
3753 );
3754 assert!(dbg.starts_with("Join"), "{dbg}");
3755 }
3756 }
3757
3758 #[test]
3761 fn clone_record_transform_custom_preserves_behaviour() {
3762 let original = RecordTransform::custom(|mut v| {
3763 if let Value::Object(ref mut m) = v {
3764 m.insert("cloned".into(), json!(true));
3765 }
3766 v
3767 });
3768 let cloned = original.clone();
3769 assert_eq!(format!("{cloned:?}"), "Custom(<fn>)");
3770 let out = apply_all(json!({"id": 1}), &compiled(&[cloned]));
3771 assert_eq!(out["cloned"], true);
3772 assert_eq!(out["id"], 1);
3773 }
3774
3775 #[test]
3776 #[allow(clippy::vec_init_then_push)]
3779 fn clone_record_transform_all_builtin_variants() {
3780 let mut variants: Vec<RecordTransform> = Vec::new();
3781 #[cfg(feature = "transform-flatten")]
3782 variants.push(RecordTransform::Flatten {
3783 separator: "__".into(),
3784 });
3785 #[cfg(feature = "transform-rename-keys")]
3786 variants.push(RecordTransform::RenameKeys {
3787 pattern: "p".into(),
3788 replacement: "r".into(),
3789 });
3790 #[cfg(feature = "transform-keys-case")]
3791 variants.push(RecordTransform::KeysCase {
3792 mode: KeyCaseMode::Snake,
3793 on_collision: KeyCollision::Error,
3794 });
3795 #[cfg(feature = "transform-select")]
3796 variants.push(RecordTransform::Select {
3797 fields: vec!["a".into()],
3798 });
3799 #[cfg(feature = "transform-drop")]
3800 variants.push(RecordTransform::Drop {
3801 fields: vec!["a".into()],
3802 });
3803 #[cfg(feature = "transform-set")]
3804 {
3805 let mut values = Map::new();
3806 values.insert("k".into(), json!("v"));
3807 variants.push(RecordTransform::Set { values });
3808 }
3809 #[cfg(feature = "transform-rename-field")]
3810 {
3811 let mut fields = HashMap::new();
3812 fields.insert("a".to_owned(), "b".to_owned());
3813 variants.push(RecordTransform::RenameField { fields });
3814 }
3815 #[cfg(feature = "transform-cast")]
3816 {
3817 let mut fields = HashMap::new();
3818 fields.insert("a".to_owned(), CastType::Int);
3819 variants.push(RecordTransform::Cast {
3820 fields,
3821 on_error: CastOnError::Error,
3822 });
3823 }
3824 #[cfg(feature = "transform-redact")]
3825 variants.push(RecordTransform::Redact {
3826 fields: vec!["a".into()],
3827 mask: json!("***"),
3828 });
3829 #[cfg(feature = "transform-value-case")]
3830 variants.push(RecordTransform::ValueCase {
3831 fields: vec!["a".into()],
3832 mode: ValueCaseMode::Lower,
3833 });
3834 #[cfg(feature = "transform-spell-symbols")]
3835 variants.push(RecordTransform::SpellSymbols {
3836 extra: HashMap::new(),
3837 separator: " ".into(),
3838 });
3839 #[cfg(feature = "transform-hash")]
3840 variants.push(RecordTransform::Hash {
3841 fields: vec!["a".into()],
3842 algorithm: HashAlgorithm::Sha256,
3843 encoding: HashEncoding::Hex,
3844 salt: Some("s".into()),
3845 into: None,
3846 });
3847 #[cfg(feature = "transform-json-parse")]
3848 variants.push(RecordTransform::JsonParse {
3849 fields: vec!["a".into()],
3850 on_error: JsonParseOnError::Keep,
3851 into: Some("b".into()),
3852 });
3853 #[cfg(feature = "transform-coalesce")]
3854 variants.push(RecordTransform::Coalesce {
3855 field: "a".into(),
3856 default: None,
3857 from: vec!["b".into()],
3858 treat_empty_string_as_null: false,
3859 });
3860 #[cfg(feature = "transform-split-join")]
3861 {
3862 variants.push(RecordTransform::Split {
3863 field: "a".into(),
3864 delimiter: ",".into(),
3865 trim: true,
3866 into: None,
3867 });
3868 variants.push(RecordTransform::Join {
3869 field: "a".into(),
3870 delimiter: ",".into(),
3871 into: Some("b".into()),
3872 });
3873 }
3874
3875 for v in &variants {
3877 let cloned = v.clone();
3878 assert_eq!(format!("{v:?}"), format!("{cloned:?}"));
3879 }
3880 }
3881
3882 #[test]
3883 fn clone_compiled_transform_all_variants() {
3884 let mut specs: Vec<RecordTransform> = vec![RecordTransform::custom(|v| v)];
3885 #[cfg(feature = "transform-flatten")]
3886 specs.push(RecordTransform::Flatten {
3887 separator: "__".into(),
3888 });
3889 #[cfg(feature = "transform-rename-keys")]
3890 specs.push(RecordTransform::RenameKeys {
3891 pattern: "p".into(),
3892 replacement: "r".into(),
3893 });
3894 #[cfg(feature = "transform-keys-case")]
3895 specs.push(RecordTransform::KeysCase {
3896 mode: KeyCaseMode::Camel,
3897 on_collision: KeyCollision::Error,
3898 });
3899 #[cfg(feature = "transform-select")]
3900 specs.push(RecordTransform::Select {
3901 fields: vec!["a".into()],
3902 });
3903 #[cfg(feature = "transform-drop")]
3904 specs.push(RecordTransform::Drop {
3905 fields: vec!["a".into()],
3906 });
3907 #[cfg(feature = "transform-set")]
3908 {
3909 let mut values = Map::new();
3910 values.insert("k".into(), json!("v"));
3911 specs.push(RecordTransform::Set { values });
3912 }
3913 #[cfg(feature = "transform-rename-field")]
3914 {
3915 let mut fields = HashMap::new();
3916 fields.insert("a".to_owned(), "b".to_owned());
3917 specs.push(RecordTransform::RenameField { fields });
3918 }
3919 #[cfg(feature = "transform-cast")]
3920 {
3921 let mut fields = HashMap::new();
3922 fields.insert("a".to_owned(), CastType::Int);
3923 specs.push(RecordTransform::Cast {
3924 fields,
3925 on_error: CastOnError::Null,
3926 });
3927 }
3928 #[cfg(feature = "transform-redact")]
3929 specs.push(RecordTransform::Redact {
3930 fields: vec!["a".into()],
3931 mask: json!("***"),
3932 });
3933 #[cfg(feature = "transform-value-case")]
3934 specs.push(RecordTransform::ValueCase {
3935 fields: vec!["a".into()],
3936 mode: ValueCaseMode::Upper,
3937 });
3938 #[cfg(feature = "transform-spell-symbols")]
3939 specs.push(RecordTransform::SpellSymbols {
3940 extra: HashMap::new(),
3941 separator: " ".into(),
3942 });
3943 #[cfg(feature = "transform-hash")]
3944 specs.push(RecordTransform::Hash {
3945 fields: vec!["a".into()],
3946 algorithm: HashAlgorithm::Blake3,
3947 encoding: HashEncoding::Base64,
3948 salt: None,
3949 into: None,
3950 });
3951 #[cfg(feature = "transform-json-parse")]
3952 specs.push(RecordTransform::JsonParse {
3953 fields: vec!["a".into()],
3954 on_error: JsonParseOnError::Error,
3955 into: None,
3956 });
3957 #[cfg(feature = "transform-coalesce")]
3958 specs.push(RecordTransform::Coalesce {
3959 field: "a".into(),
3960 default: Some(json!("x")),
3961 from: vec![],
3962 treat_empty_string_as_null: true,
3963 });
3964 #[cfg(feature = "transform-split-join")]
3965 {
3966 specs.push(RecordTransform::Split {
3967 field: "a".into(),
3968 delimiter: ",".into(),
3969 trim: false,
3970 into: None,
3971 });
3972 specs.push(RecordTransform::Join {
3973 field: "a".into(),
3974 delimiter: ",".into(),
3975 into: None,
3976 });
3977 }
3978
3979 let original = compiled(&specs);
3982 let cloned: Vec<CompiledTransform> = original.to_vec();
3983 assert_eq!(original.len(), cloned.len());
3984 let record = json!({"a": "1", "k": "x"});
3985 let out_orig = super::apply_all(record.clone(), &original);
3986 let out_clone = super::apply_all(record, &cloned);
3987 assert_eq!(
3988 out_orig.is_ok(),
3989 out_clone.is_ok(),
3990 "clone must transform identically"
3991 );
3992 if let (Ok(a), Ok(b)) = (out_orig, out_clone) {
3993 assert_eq!(a, b);
3994 }
3995 }
3996
3997 #[cfg(feature = "transform-flatten")]
4000 #[test]
4001 fn flatten_passes_through_non_object() {
4002 let record = json!([1, 2, 3]);
4003 let result = apply_all(
4004 record.clone(),
4005 &compiled(&[RecordTransform::Flatten {
4006 separator: "__".into(),
4007 }]),
4008 );
4009 assert_eq!(result, record);
4010 let scalar = json!(42);
4012 let result = apply_all(
4013 scalar.clone(),
4014 &compiled(&[RecordTransform::Flatten {
4015 separator: "__".into(),
4016 }]),
4017 );
4018 assert_eq!(result, scalar);
4019 }
4020
4021 #[cfg(feature = "transform-drop")]
4022 #[test]
4023 fn drop_passes_through_non_object() {
4024 let record = json!([1, 2]);
4025 let result = apply_all(
4026 record.clone(),
4027 &compiled(&[RecordTransform::Drop {
4028 fields: vec!["a".into()],
4029 }]),
4030 );
4031 assert_eq!(result, record);
4032 }
4033
4034 #[cfg(feature = "transform-set")]
4035 #[test]
4036 fn set_passes_through_non_object() {
4037 let mut values = Map::new();
4038 values.insert("k".into(), json!("v"));
4039 let record = json!("scalar");
4040 let result = apply_all(
4041 record.clone(),
4042 &compiled(&[RecordTransform::Set { values }]),
4043 );
4044 assert_eq!(result, record);
4045 }
4046
4047 #[cfg(feature = "transform-rename-field")]
4048 #[test]
4049 fn rename_field_passes_through_non_object() {
4050 let mut fields = HashMap::new();
4051 fields.insert("a".to_owned(), "b".to_owned());
4052 let record = json!([1, 2]);
4053 let result = apply_all(
4054 record.clone(),
4055 &compiled(&[RecordTransform::RenameField { fields }]),
4056 );
4057 assert_eq!(result, record);
4058 }
4059
4060 #[cfg(feature = "transform-rename-field")]
4061 #[test]
4062 fn rename_field_same_name_is_skipped() {
4063 let mut fields = HashMap::new();
4065 fields.insert("a".to_owned(), "a".to_owned());
4066 let record = json!({"a": 1});
4067 let result = apply_all(
4068 record,
4069 &compiled(&[RecordTransform::RenameField { fields }]),
4070 );
4071 assert_eq!(result["a"], 1);
4072 }
4073
4074 #[cfg(feature = "transform-cast")]
4075 #[test]
4076 fn cast_passes_through_non_object() {
4077 let record = json!([1, 2]);
4078 let result = apply_all(
4079 record.clone(),
4080 &compiled(&cast_specs("a", CastType::Int, CastOnError::Error)),
4081 );
4082 assert_eq!(result, record);
4083 }
4084
4085 #[cfg(feature = "transform-redact")]
4086 #[test]
4087 fn redact_passes_through_non_object() {
4088 let record = json!("scalar");
4089 let result = apply_all(
4090 record.clone(),
4091 &compiled(&[RecordTransform::Redact {
4092 fields: vec!["a".into()],
4093 mask: json!("***"),
4094 }]),
4095 );
4096 assert_eq!(result, record);
4097 }
4098
4099 #[cfg(feature = "transform-value-case")]
4100 #[test]
4101 fn value_case_passes_through_non_object() {
4102 let record = json!([1, 2]);
4103 let result = apply_all(
4104 record.clone(),
4105 &compiled(&[RecordTransform::ValueCase {
4106 fields: vec!["a".into()],
4107 mode: ValueCaseMode::Lower,
4108 }]),
4109 );
4110 assert_eq!(result, record);
4111 }
4112
4113 #[cfg(feature = "transform-cast")]
4116 #[test]
4117 fn cast_integer_number_to_int_is_identity() {
4118 let record = json!({"n": 7});
4120 let result = apply_all(
4121 record,
4122 &compiled(&cast_specs("n", CastType::Int, CastOnError::Error)),
4123 );
4124 assert_eq!(result["n"], 7);
4125 }
4126
4127 #[cfg(feature = "transform-cast")]
4128 #[test]
4129 fn cast_bool_to_int() {
4130 let record = json!({"t": true, "f": false});
4131 let mut fields = HashMap::new();
4132 fields.insert("t".to_owned(), CastType::Int);
4133 fields.insert("f".to_owned(), CastType::Int);
4134 let result = apply_all(
4135 record,
4136 &compiled(&[RecordTransform::Cast {
4137 fields,
4138 on_error: CastOnError::Error,
4139 }]),
4140 );
4141 assert_eq!(result["t"], 1);
4142 assert_eq!(result["f"], 0);
4143 }
4144
4145 #[cfg(feature = "transform-cast")]
4146 #[test]
4147 fn cast_null_to_int_errors() {
4148 let record = json!({"n": null});
4149 let err = super::apply_all(
4150 record,
4151 &compiled(&cast_specs("n", CastType::Int, CastOnError::Error)),
4152 )
4153 .expect_err("null cannot become int");
4154 assert!(
4155 format!("{err}").contains("null cannot be cast to int"),
4156 "{err}"
4157 );
4158 }
4159
4160 #[cfg(feature = "transform-cast")]
4161 #[test]
4162 fn cast_composite_to_int_errors() {
4163 let record = json!({"n": [1, 2]});
4164 let err = super::apply_all(
4165 record,
4166 &compiled(&cast_specs("n", CastType::Int, CastOnError::Error)),
4167 )
4168 .expect_err("array cannot become int");
4169 assert!(format!("{err}").contains("composite"), "{err}");
4170 }
4171
4172 #[cfg(feature = "transform-cast")]
4173 #[test]
4174 fn cast_number_to_float() {
4175 let record = json!({"n": 5});
4176 let result = apply_all(
4177 record,
4178 &compiled(&cast_specs("n", CastType::Float, CastOnError::Error)),
4179 );
4180 assert_eq!(result["n"], 5.0);
4181 }
4182
4183 #[cfg(feature = "transform-cast")]
4184 #[test]
4185 fn cast_bool_to_float() {
4186 let record = json!({"t": true, "f": false});
4187 let mut fields = HashMap::new();
4188 fields.insert("t".to_owned(), CastType::Float);
4189 fields.insert("f".to_owned(), CastType::Float);
4190 let result = apply_all(
4191 record,
4192 &compiled(&[RecordTransform::Cast {
4193 fields,
4194 on_error: CastOnError::Error,
4195 }]),
4196 );
4197 assert_eq!(result["t"], 1.0);
4198 assert_eq!(result["f"], 0.0);
4199 }
4200
4201 #[cfg(feature = "transform-cast")]
4202 #[test]
4203 fn cast_null_to_float_errors() {
4204 let record = json!({"n": null});
4205 let err = super::apply_all(
4206 record,
4207 &compiled(&cast_specs("n", CastType::Float, CastOnError::Error)),
4208 )
4209 .expect_err("null cannot become float");
4210 assert!(
4211 format!("{err}").contains("null cannot be cast to float"),
4212 "{err}"
4213 );
4214 }
4215
4216 #[cfg(feature = "transform-cast")]
4217 #[test]
4218 fn cast_composite_to_float_errors() {
4219 let record = json!({"n": {"x": 1}});
4220 let err = super::apply_all(
4221 record,
4222 &compiled(&cast_specs("n", CastType::Float, CastOnError::Error)),
4223 )
4224 .expect_err("object cannot become float");
4225 assert!(format!("{err}").contains("composite"), "{err}");
4226 }
4227
4228 #[cfg(feature = "transform-cast")]
4229 #[test]
4230 fn cast_string_to_float_invalid_errors() {
4231 let record = json!({"n": "not a float"});
4232 let err = super::apply_all(
4233 record,
4234 &compiled(&cast_specs("n", CastType::Float, CastOnError::Error)),
4235 )
4236 .expect_err("non-numeric string cannot become float");
4237 assert!(format!("{err}").contains("is not a float"), "{err}");
4238 }
4239
4240 #[cfg(feature = "transform-cast")]
4241 #[test]
4242 fn cast_bool_to_bool_is_identity() {
4243 let record = json!({"b": true});
4244 let result = apply_all(
4245 record,
4246 &compiled(&cast_specs("b", CastType::Bool, CastOnError::Error)),
4247 );
4248 assert_eq!(result["b"], true);
4249 }
4250
4251 #[cfg(feature = "transform-cast")]
4252 #[test]
4253 fn cast_number_to_bool() {
4254 let record = json!({"on": 1, "off": 0});
4255 let mut fields = HashMap::new();
4256 fields.insert("on".to_owned(), CastType::Bool);
4257 fields.insert("off".to_owned(), CastType::Bool);
4258 let result = apply_all(
4259 record,
4260 &compiled(&[RecordTransform::Cast {
4261 fields,
4262 on_error: CastOnError::Error,
4263 }]),
4264 );
4265 assert_eq!(result["on"], true);
4266 assert_eq!(result["off"], false);
4267 }
4268
4269 #[cfg(feature = "transform-cast")]
4270 #[test]
4271 fn cast_integer_other_than_zero_one_to_bool_errors() {
4272 let record = json!({"n": 7});
4273 let err = super::apply_all(
4274 record,
4275 &compiled(&cast_specs("n", CastType::Bool, CastOnError::Error)),
4276 )
4277 .expect_err("only 0/1 convert to bool");
4278 assert!(format!("{err}").contains("not 0 or 1"), "{err}");
4279 }
4280
4281 #[cfg(feature = "transform-cast")]
4282 #[test]
4283 fn cast_float_number_to_bool_errors() {
4284 let record = json!({"n": 1.5});
4286 let err = super::apply_all(
4287 record,
4288 &compiled(&cast_specs("n", CastType::Bool, CastOnError::Error)),
4289 )
4290 .expect_err("fractional number cannot become bool");
4291 assert!(format!("{err}").contains("not 0 or 1"), "{err}");
4292 }
4293
4294 #[cfg(feature = "transform-cast")]
4295 #[test]
4296 fn cast_unrecognised_string_to_bool_errors() {
4297 let record = json!({"flag": "maybe"});
4298 let err = super::apply_all(
4299 record,
4300 &compiled(&cast_specs("flag", CastType::Bool, CastOnError::Error)),
4301 )
4302 .expect_err("'maybe' is not a boolean");
4303 assert!(
4304 format!("{err}").contains("not a recognised boolean"),
4305 "{err}"
4306 );
4307 }
4308
4309 #[cfg(feature = "transform-cast")]
4310 #[test]
4311 fn cast_null_to_bool_errors() {
4312 let record = json!({"b": null});
4313 let err = super::apply_all(
4314 record,
4315 &compiled(&cast_specs("b", CastType::Bool, CastOnError::Error)),
4316 )
4317 .expect_err("null cannot become bool");
4318 assert!(
4319 format!("{err}").contains("null cannot be cast to bool"),
4320 "{err}"
4321 );
4322 }
4323
4324 #[cfg(feature = "transform-cast")]
4325 #[test]
4326 fn cast_composite_to_bool_errors() {
4327 let record = json!({"b": [true]});
4328 let err = super::apply_all(
4329 record,
4330 &compiled(&cast_specs("b", CastType::Bool, CastOnError::Error)),
4331 )
4332 .expect_err("array cannot become bool");
4333 assert!(format!("{err}").contains("composite"), "{err}");
4334 }
4335
4336 #[cfg(feature = "transform-cast")]
4337 #[test]
4338 fn cast_string_to_string_is_identity() {
4339 let record = json!({"s": "hello"});
4340 let result = apply_all(
4341 record,
4342 &compiled(&cast_specs("s", CastType::String, CastOnError::Error)),
4343 );
4344 assert_eq!(result["s"], "hello");
4345 }
4346
4347 #[cfg(feature = "transform-cast")]
4348 #[test]
4349 fn cast_bool_to_string() {
4350 let record = json!({"b": true});
4351 let result = apply_all(
4352 record,
4353 &compiled(&cast_specs("b", CastType::String, CastOnError::Error)),
4354 );
4355 assert_eq!(result["b"], "true");
4356 }
4357
4358 #[cfg(feature = "transform-cast")]
4359 #[test]
4360 fn cast_null_to_string_errors() {
4361 let record = json!({"s": null});
4362 let err = super::apply_all(
4363 record,
4364 &compiled(&cast_specs("s", CastType::String, CastOnError::Error)),
4365 )
4366 .expect_err("null cannot become string");
4367 assert!(
4368 format!("{err}").contains("null cannot be cast to string"),
4369 "{err}"
4370 );
4371 }
4372
4373 #[cfg(feature = "transform-cast")]
4374 #[test]
4375 fn cast_composite_to_string_errors() {
4376 let record = json!({"s": {"a": 1}});
4377 let err = super::apply_all(
4378 record,
4379 &compiled(&cast_specs("s", CastType::String, CastOnError::Error)),
4380 )
4381 .expect_err("object cannot become string");
4382 assert!(format!("{err}").contains("composite"), "{err}");
4383 }
4384
4385 #[cfg(feature = "transform-cast")]
4386 #[test]
4387 fn cast_invalid_timestamp_string_errors() {
4388 let record = json!({"ts": "not a date"});
4389 let err = super::apply_all(
4390 record,
4391 &compiled(&cast_specs("ts", CastType::Timestamp, CastOnError::Error)),
4392 )
4393 .expect_err("invalid timestamp string");
4394 assert!(format!("{err}").contains("RFC 3339"), "{err}");
4395 }
4396
4397 #[cfg(feature = "transform-cast")]
4398 #[test]
4399 fn cast_non_string_to_timestamp_names_the_type() {
4400 for (val, ty_name) in [
4402 (json!(null), "null"),
4403 (json!(true), "bool"),
4404 (json!(42), "number"),
4405 (json!([1, 2]), "array"),
4406 (json!({"a": 1}), "object"),
4407 ] {
4408 let record = json!({ "ts": val });
4409 let err = super::apply_all(
4410 record,
4411 &compiled(&cast_specs("ts", CastType::Timestamp, CastOnError::Error)),
4412 )
4413 .expect_err("non-string cannot become timestamp");
4414 let msg = format!("{err}");
4415 assert!(msg.contains("timestamp"), "{msg}");
4416 assert!(
4417 msg.contains(ty_name),
4418 "expected type name {ty_name:?} in: {msg}"
4419 );
4420 }
4421 }
4422
4423 #[cfg(feature = "transform-hash")]
4426 fn hash_spec(fields: &[&str], enc: HashEncoding, salt: Option<&str>) -> Vec<RecordTransform> {
4427 vec![RecordTransform::Hash {
4428 fields: fields.iter().map(|s| (*s).to_owned()).collect(),
4429 algorithm: HashAlgorithm::Sha256,
4430 encoding: enc,
4431 salt: salt.map(str::to_owned),
4432 into: None,
4433 }]
4434 }
4435
4436 #[cfg(feature = "transform-hash")]
4437 #[test]
4438 fn hash_replaces_in_place_and_is_stable() {
4439 let a = apply_all(
4440 json!({"email": "a@b.com", "id": 1}),
4441 &compiled(&hash_spec(&["email"], HashEncoding::Hex, None)),
4442 );
4443 let b = apply_all(
4444 json!({"email": "a@b.com", "id": 1}),
4445 &compiled(&hash_spec(&["email"], HashEncoding::Hex, None)),
4446 );
4447 assert_eq!(a["email"], b["email"]);
4449 assert_eq!(a["id"], 1);
4450 assert_eq!(a["email"].as_str().unwrap().len(), 64);
4452 assert_ne!(a["email"], json!("a@b.com"));
4453 }
4454
4455 #[cfg(feature = "transform-hash")]
4456 #[test]
4457 fn hash_salt_changes_output() {
4458 let unsalted = apply_all(
4459 json!({"email": "a@b.com"}),
4460 &compiled(&hash_spec(&["email"], HashEncoding::Hex, None)),
4461 );
4462 let salted = apply_all(
4463 json!({"email": "a@b.com"}),
4464 &compiled(&hash_spec(&["email"], HashEncoding::Hex, Some("pepper"))),
4465 );
4466 assert_ne!(unsalted["email"], salted["email"]);
4467 }
4468
4469 #[cfg(feature = "transform-hash")]
4470 #[test]
4471 fn hash_hex_vs_base64_differ_and_both_decode() {
4472 let hex = apply_all(
4473 json!({"v": "x"}),
4474 &compiled(&hash_spec(&["v"], HashEncoding::Hex, None)),
4475 );
4476 let b64 = apply_all(
4477 json!({"v": "x"}),
4478 &compiled(&hash_spec(&["v"], HashEncoding::Base64, None)),
4479 );
4480 assert_ne!(hex["v"], b64["v"]);
4481 assert_eq!(hex["v"].as_str().unwrap().len(), 64);
4483 assert_eq!(b64["v"].as_str().unwrap().len(), 44);
4484 }
4485
4486 #[cfg(feature = "transform-hash")]
4487 #[test]
4488 fn hash_into_preserves_source() {
4489 let out = apply_all(
4490 json!({"email": "a@b.com"}),
4491 &compiled(&[RecordTransform::Hash {
4492 fields: vec!["email".into()],
4493 algorithm: HashAlgorithm::Sha256,
4494 encoding: HashEncoding::Hex,
4495 salt: None,
4496 into: Some("email_hash".into()),
4497 }]),
4498 );
4499 assert_eq!(out["email"], "a@b.com");
4500 assert_eq!(out["email_hash"].as_str().unwrap().len(), 64);
4501 }
4502
4503 #[cfg(feature = "transform-hash")]
4504 #[test]
4505 fn hash_missing_field_is_no_op() {
4506 let out = apply_all(
4507 json!({"id": 1}),
4508 &compiled(&hash_spec(&["email"], HashEncoding::Hex, None)),
4509 );
4510 assert_eq!(out, json!({"id": 1}));
4511 }
4512
4513 #[cfg(feature = "transform-hash")]
4518 #[test]
4519 fn hash_leaves_null_alone() {
4520 let out = apply_all(
4521 json!({"email": null, "other": "x"}),
4522 &compiled(&hash_spec(&["email"], HashEncoding::Hex, None)),
4523 );
4524 assert_eq!(out["email"], Value::Null, "null must not be hashed");
4525 assert_eq!(out["other"], json!("x"));
4526
4527 let a = apply_all(
4530 json!({"id": 1, "email": null}),
4531 &compiled(&hash_spec(&["email"], HashEncoding::Hex, None)),
4532 );
4533 let b = apply_all(
4534 json!({"id": 2, "email": null}),
4535 &compiled(&hash_spec(&["email"], HashEncoding::Hex, None)),
4536 );
4537 assert_ne!(a, b);
4538 assert!(a["email"].is_null() && b["email"].is_null());
4539 }
4540
4541 #[cfg(feature = "transform-hash")]
4542 #[test]
4543 fn hash_non_string_hashes_canonical_json() {
4544 let out = apply_all(
4546 json!({"n": 42}),
4547 &compiled(&hash_spec(&["n"], HashEncoding::Hex, None)),
4548 );
4549 let expected = hash_string("42", HashAlgorithm::Sha256, HashEncoding::Hex, None);
4550 assert_eq!(out["n"], Value::String(expected));
4551 }
4552
4553 #[cfg(feature = "transform-hash")]
4556 #[test]
4557 fn hash_object_ignores_key_insertion_order() {
4558 let mut rec = Map::new();
4559 rec.insert(
4560 "o".into(),
4561 reverse_inserted(&[("login", json!("octo-org")), ("id", json!(2))]),
4562 );
4563 let out = apply_all(
4564 Value::Object(rec),
4565 &compiled(&hash_spec(&["o"], HashEncoding::Hex, None)),
4566 );
4567 let expected = hash_string(
4568 r#"{"id":2,"login":"octo-org"}"#,
4569 HashAlgorithm::Sha256,
4570 HashEncoding::Hex,
4571 None,
4572 );
4573 assert_eq!(out["o"], Value::String(expected));
4574 }
4575
4576 #[cfg(feature = "transform-hash")]
4577 #[test]
4578 fn hash_blake3_differs_from_sha256() {
4579 let sha = apply_all(
4580 json!({"v": "x"}),
4581 &compiled(&[RecordTransform::Hash {
4582 fields: vec!["v".into()],
4583 algorithm: HashAlgorithm::Sha256,
4584 encoding: HashEncoding::Hex,
4585 salt: None,
4586 into: None,
4587 }]),
4588 );
4589 let b3 = apply_all(
4590 json!({"v": "x"}),
4591 &compiled(&[RecordTransform::Hash {
4592 fields: vec!["v".into()],
4593 algorithm: HashAlgorithm::Blake3,
4594 encoding: HashEncoding::Hex,
4595 salt: None,
4596 into: None,
4597 }]),
4598 );
4599 assert_ne!(sha["v"], b3["v"]);
4600 assert_eq!(b3["v"].as_str().unwrap().len(), 64);
4601 }
4602
4603 #[cfg(feature = "transform-hash")]
4604 #[test]
4605 fn hash_empty_fields_is_config_error() {
4606 let res = compile(&RecordTransform::Hash {
4607 fields: vec![],
4608 algorithm: HashAlgorithm::Sha256,
4609 encoding: HashEncoding::Hex,
4610 salt: None,
4611 into: None,
4612 });
4613 assert!(matches!(res, Err(FaucetError::Config(_))));
4614 }
4615
4616 #[cfg(feature = "transform-hash")]
4617 #[test]
4618 fn hash_into_with_multiple_fields_is_config_error() {
4619 let res = compile(&RecordTransform::Hash {
4620 fields: vec!["a".into(), "b".into()],
4621 algorithm: HashAlgorithm::Sha256,
4622 encoding: HashEncoding::Hex,
4623 salt: None,
4624 into: Some("x".into()),
4625 });
4626 assert!(matches!(res, Err(FaucetError::Config(_))));
4627 }
4628
4629 #[cfg(feature = "transform-hash")]
4630 #[test]
4631 fn hash_debug_redacts_salt() {
4632 let dbg = format!(
4633 "{:?}",
4634 RecordTransform::Hash {
4635 fields: vec!["a".into()],
4636 algorithm: HashAlgorithm::Sha256,
4637 encoding: HashEncoding::Hex,
4638 salt: Some("supersecret".into()),
4639 into: None,
4640 }
4641 );
4642 assert!(!dbg.contains("supersecret"), "{dbg}");
4643 assert!(dbg.contains("redacted"), "{dbg}");
4644 }
4645
4646 #[cfg(feature = "transform-json-parse")]
4649 fn json_parse_spec(field: &str, on_error: JsonParseOnError) -> Vec<RecordTransform> {
4650 vec![RecordTransform::JsonParse {
4651 fields: vec![field.to_owned()],
4652 on_error,
4653 into: None,
4654 }]
4655 }
4656
4657 #[cfg(feature = "transform-json-parse")]
4658 #[test]
4659 fn json_parse_object_string_becomes_object() {
4660 let out = apply_all(
4661 json!({"payload": "{\"a\":1,\"b\":[2,3]}"}),
4662 &compiled(&json_parse_spec("payload", JsonParseOnError::Keep)),
4663 );
4664 assert_eq!(out["payload"], json!({"a": 1, "b": [2, 3]}));
4665 }
4666
4667 #[cfg(feature = "transform-json-parse")]
4668 #[test]
4669 fn json_parse_already_parsed_is_no_op() {
4670 let record = json!({"payload": {"a": 1}});
4671 let out = apply_all(
4672 record.clone(),
4673 &compiled(&json_parse_spec("payload", JsonParseOnError::Error)),
4674 );
4675 assert_eq!(out, record);
4676 }
4677
4678 #[cfg(feature = "transform-json-parse")]
4679 #[test]
4680 fn json_parse_missing_field_is_no_op() {
4681 let out = apply_all(
4682 json!({"id": 1}),
4683 &compiled(&json_parse_spec("payload", JsonParseOnError::Error)),
4684 );
4685 assert_eq!(out, json!({"id": 1}));
4686 }
4687
4688 #[cfg(feature = "transform-json-parse")]
4689 #[test]
4690 fn json_parse_invalid_keep_leaves_string() {
4691 let out = apply_all(
4692 json!({"payload": "not json"}),
4693 &compiled(&json_parse_spec("payload", JsonParseOnError::Keep)),
4694 );
4695 assert_eq!(out["payload"], "not json");
4696 }
4697
4698 #[cfg(feature = "transform-json-parse")]
4699 #[test]
4700 fn json_parse_invalid_null_replaces() {
4701 let out = apply_all(
4702 json!({"payload": "not json"}),
4703 &compiled(&json_parse_spec("payload", JsonParseOnError::Null)),
4704 );
4705 assert_eq!(out["payload"], Value::Null);
4706 }
4707
4708 #[cfg(feature = "transform-json-parse")]
4709 #[test]
4710 fn json_parse_invalid_error_propagates() {
4711 let err = super::apply_all(
4712 json!({"payload": "not json"}),
4713 &compiled(&json_parse_spec("payload", JsonParseOnError::Error)),
4714 )
4715 .expect_err("invalid JSON under on_error=error must fail");
4716 assert!(matches!(err, FaucetError::Transform(_)), "{err}");
4717 }
4718
4719 #[cfg(feature = "transform-json-parse")]
4720 #[test]
4721 fn json_parse_into_writes_target() {
4722 let out = apply_all(
4723 json!({"payload": "{\"a\":1}"}),
4724 &compiled(&[RecordTransform::JsonParse {
4725 fields: vec!["payload".into()],
4726 on_error: JsonParseOnError::Error,
4727 into: Some("parsed".into()),
4728 }]),
4729 );
4730 assert_eq!(out["payload"], "{\"a\":1}");
4731 assert_eq!(out["parsed"], json!({"a": 1}));
4732 }
4733
4734 #[cfg(feature = "transform-coalesce")]
4737 #[test]
4738 fn coalesce_default_fills_null_and_absent() {
4739 let spec = |field: &str| {
4740 vec![RecordTransform::Coalesce {
4741 field: field.to_owned(),
4742 default: Some(json!("unknown")),
4743 from: vec![],
4744 treat_empty_string_as_null: false,
4745 }]
4746 };
4747 let a = apply_all(json!({"status": null}), &compiled(&spec("status")));
4749 assert_eq!(a["status"], "unknown");
4750 let b = apply_all(json!({"id": 1}), &compiled(&spec("status")));
4752 assert_eq!(b["status"], "unknown");
4753 }
4754
4755 #[cfg(feature = "transform-coalesce")]
4756 #[test]
4757 fn coalesce_non_null_target_untouched() {
4758 let out = apply_all(
4759 json!({"status": "active"}),
4760 &compiled(&[RecordTransform::Coalesce {
4761 field: "status".into(),
4762 default: Some(json!("unknown")),
4763 from: vec![],
4764 treat_empty_string_as_null: false,
4765 }]),
4766 );
4767 assert_eq!(out["status"], "active");
4768 }
4769
4770 #[cfg(feature = "transform-coalesce")]
4771 #[test]
4772 fn coalesce_from_picks_first_non_null() {
4773 let out = apply_all(
4774 json!({"status": null, "state": null, "phase": "running"}),
4775 &compiled(&[RecordTransform::Coalesce {
4776 field: "status".into(),
4777 default: None,
4778 from: vec!["status".into(), "state".into(), "phase".into()],
4779 treat_empty_string_as_null: false,
4780 }]),
4781 );
4782 assert_eq!(out["status"], "running");
4783 }
4784
4785 #[cfg(feature = "transform-coalesce")]
4786 #[test]
4787 fn coalesce_empty_string_toggle() {
4788 let spec = |treat: bool| {
4789 vec![RecordTransform::Coalesce {
4790 field: "status".into(),
4791 default: Some(json!("unknown")),
4792 from: vec![],
4793 treat_empty_string_as_null: treat,
4794 }]
4795 };
4796 let off = apply_all(json!({"status": ""}), &compiled(&spec(false)));
4798 assert_eq!(off["status"], "");
4799 let on = apply_all(json!({"status": ""}), &compiled(&spec(true)));
4801 assert_eq!(on["status"], "unknown");
4802 }
4803
4804 #[cfg(feature = "transform-coalesce")]
4805 #[test]
4806 fn coalesce_from_all_null_leaves_target() {
4807 let out = apply_all(
4808 json!({"status": null, "state": null}),
4809 &compiled(&[RecordTransform::Coalesce {
4810 field: "status".into(),
4811 default: None,
4812 from: vec!["status".into(), "state".into()],
4813 treat_empty_string_as_null: false,
4814 }]),
4815 );
4816 assert_eq!(out["status"], Value::Null);
4817 }
4818
4819 #[cfg(feature = "transform-coalesce")]
4820 #[test]
4821 fn coalesce_both_default_and_from_is_config_error() {
4822 let res = compile(&RecordTransform::Coalesce {
4823 field: "status".into(),
4824 default: Some(json!("x")),
4825 from: vec!["state".into()],
4826 treat_empty_string_as_null: false,
4827 });
4828 assert!(matches!(res, Err(FaucetError::Config(_))));
4829 }
4830
4831 #[cfg(feature = "transform-coalesce")]
4832 #[test]
4833 fn coalesce_neither_default_nor_from_is_config_error() {
4834 let res = compile(&RecordTransform::Coalesce {
4835 field: "status".into(),
4836 default: None,
4837 from: vec![],
4838 treat_empty_string_as_null: false,
4839 });
4840 assert!(matches!(res, Err(FaucetError::Config(_))));
4841 }
4842
4843 #[cfg(feature = "transform-split-join")]
4846 #[test]
4847 fn split_basic_no_trim() {
4848 let out = apply_all(
4849 json!({"tags": "a, b ,c"}),
4850 &compiled(&[RecordTransform::Split {
4851 field: "tags".into(),
4852 delimiter: ",".into(),
4853 trim: false,
4854 into: None,
4855 }]),
4856 );
4857 assert_eq!(out["tags"], json!(["a", " b ", "c"]));
4858 }
4859
4860 #[cfg(feature = "transform-split-join")]
4861 #[test]
4862 fn split_with_trim_keeps_empty_segments() {
4863 let out = apply_all(
4864 json!({"tags": "a, ,c,"}),
4865 &compiled(&[RecordTransform::Split {
4866 field: "tags".into(),
4867 delimiter: ",".into(),
4868 trim: true,
4869 into: None,
4870 }]),
4871 );
4872 assert_eq!(out["tags"], json!(["a", "", "c", ""]));
4874 }
4875
4876 #[cfg(feature = "transform-split-join")]
4877 #[test]
4878 fn split_empty_input_yields_single_empty() {
4879 let out = apply_all(
4880 json!({"tags": ""}),
4881 &compiled(&[RecordTransform::Split {
4882 field: "tags".into(),
4883 delimiter: ",".into(),
4884 trim: false,
4885 into: None,
4886 }]),
4887 );
4888 assert_eq!(out["tags"], json!([""]));
4889 }
4890
4891 #[cfg(feature = "transform-split-join")]
4892 #[test]
4893 fn split_non_string_is_no_op() {
4894 let record = json!({"tags": [1, 2]});
4895 let out = apply_all(
4896 record.clone(),
4897 &compiled(&[RecordTransform::Split {
4898 field: "tags".into(),
4899 delimiter: ",".into(),
4900 trim: false,
4901 into: None,
4902 }]),
4903 );
4904 assert_eq!(out, record);
4905 }
4906
4907 #[cfg(feature = "transform-split-join")]
4908 #[test]
4909 fn split_into_writes_target() {
4910 let out = apply_all(
4911 json!({"csv": "a,b"}),
4912 &compiled(&[RecordTransform::Split {
4913 field: "csv".into(),
4914 delimiter: ",".into(),
4915 trim: false,
4916 into: Some("arr".into()),
4917 }]),
4918 );
4919 assert_eq!(out["csv"], "a,b");
4920 assert_eq!(out["arr"], json!(["a", "b"]));
4921 }
4922
4923 #[cfg(feature = "transform-split-join")]
4924 #[test]
4925 fn join_basic_and_non_string_elements() {
4926 let out = apply_all(
4927 json!({"parts": ["a", 2, true, null]}),
4928 &compiled(&[RecordTransform::Join {
4929 field: "parts".into(),
4930 delimiter: ",".into(),
4931 into: None,
4932 }]),
4933 );
4934 assert_eq!(out["parts"], "a,2,true,");
4936 }
4937
4938 #[cfg(feature = "transform-split-join")]
4939 #[test]
4940 fn join_non_array_is_no_op() {
4941 let record = json!({"parts": "already a string"});
4942 let out = apply_all(
4943 record.clone(),
4944 &compiled(&[RecordTransform::Join {
4945 field: "parts".into(),
4946 delimiter: ",".into(),
4947 into: None,
4948 }]),
4949 );
4950 assert_eq!(out, record);
4951 }
4952
4953 #[cfg(feature = "transform-split-join")]
4954 #[test]
4955 fn split_then_join_round_trips() {
4956 let out = apply_all(
4957 json!({"tags": "a,b,c"}),
4958 &compiled(&[
4959 RecordTransform::Split {
4960 field: "tags".into(),
4961 delimiter: ",".into(),
4962 trim: false,
4963 into: None,
4964 },
4965 RecordTransform::Join {
4966 field: "tags".into(),
4967 delimiter: ",".into(),
4968 into: None,
4969 },
4970 ]),
4971 );
4972 assert_eq!(out["tags"], "a,b,c");
4973 }
4974
4975 #[cfg(feature = "transform-value-case")]
4978 #[test]
4979 fn value_case_title() {
4980 let out = apply_all(
4981 json!({"city": "new york", "id": 1}),
4982 &compiled(&[RecordTransform::ValueCase {
4983 fields: vec!["city".into()],
4984 mode: ValueCaseMode::Title,
4985 }]),
4986 );
4987 assert_eq!(out["city"], "New York");
4988 assert_eq!(out["id"], 1);
4989 }
4990
4991 #[cfg(feature = "transform-value-case")]
4992 #[test]
4993 fn value_case_title_lowercases_rest_of_word() {
4994 let out = apply_all(
4995 json!({"s": "hELLO WORLD"}),
4996 &compiled(&[RecordTransform::ValueCase {
4997 fields: vec!["s".into()],
4998 mode: ValueCaseMode::Title,
4999 }]),
5000 );
5001 assert_eq!(out["s"], "Hello World");
5002 }
5003
5004 #[cfg(feature = "transform-value-case")]
5005 #[test]
5006 fn value_case_capitalize() {
5007 let out = apply_all(
5008 json!({"s": "hELLO wORLD"}),
5009 &compiled(&[RecordTransform::ValueCase {
5010 fields: vec!["s".into()],
5011 mode: ValueCaseMode::Capitalize,
5012 }]),
5013 );
5014 assert_eq!(out["s"], "Hello world");
5015 }
5016
5017 #[cfg(feature = "transform-value-case")]
5018 #[test]
5019 fn value_case_title_is_idempotent() {
5020 let once = apply_all(
5021 json!({"s": "new york"}),
5022 &compiled(&[RecordTransform::ValueCase {
5023 fields: vec!["s".into()],
5024 mode: ValueCaseMode::Title,
5025 }]),
5026 );
5027 let twice = apply_all(
5028 once.clone(),
5029 &compiled(&[RecordTransform::ValueCase {
5030 fields: vec!["s".into()],
5031 mode: ValueCaseMode::Title,
5032 }]),
5033 );
5034 assert_eq!(once, twice);
5035 }
5036
5037 #[cfg(feature = "transform-value-case")]
5038 #[test]
5039 fn value_case_title_non_string_no_op() {
5040 let out = apply_all(
5041 json!({"n": 42}),
5042 &compiled(&[RecordTransform::ValueCase {
5043 fields: vec!["n".into()],
5044 mode: ValueCaseMode::Title,
5045 }]),
5046 );
5047 assert_eq!(out["n"], 42);
5048 }
5049
5050 #[cfg(feature = "transform-keys-case")]
5053 #[test]
5054 fn keys_case_dot() {
5055 let out = apply_all(
5056 json!({"userId": 1, "First Name": 2, "kebab-case": 3}),
5057 &compiled(&keys_case_specs(KeyCaseMode::Dot)),
5058 );
5059 assert_eq!(out["user.id"], 1);
5060 assert_eq!(out["first.name"], 2);
5061 assert_eq!(out["kebab.case"], 3);
5062 }
5063
5064 #[cfg(feature = "transform-keys-case")]
5065 #[test]
5066 fn keys_case_dot_is_idempotent() {
5067 let once = apply_all(
5068 json!({"userId": 1}),
5069 &compiled(&keys_case_specs(KeyCaseMode::Dot)),
5070 );
5071 let twice = apply_all(once.clone(), &compiled(&keys_case_specs(KeyCaseMode::Dot)));
5072 assert_eq!(once, twice);
5073 assert_eq!(twice["user.id"], 1);
5074 }
5075
5076 #[cfg(feature = "transform-keys-case")]
5077 #[test]
5078 fn keys_case_dot_matches_snake_tokenization() {
5079 let record = json!({"XMLHttpRequest": 1, "second name": 2});
5081 let dot = apply_all(
5082 record.clone(),
5083 &compiled(&keys_case_specs(KeyCaseMode::Dot)),
5084 );
5085 let snake = apply_all(record, &compiled(&keys_case_specs(KeyCaseMode::Snake)));
5086 let dot_keys: Vec<String> = dot.as_object().unwrap().keys().cloned().collect();
5088 let snake_keys: Vec<String> = snake.as_object().unwrap().keys().cloned().collect();
5089 let converted: Vec<String> = snake_keys.iter().map(|k| k.replace('_', ".")).collect();
5090 assert_eq!(dot_keys, converted);
5091 }
5092
5093 #[cfg(feature = "transform-json-parse")]
5096 #[test]
5097 fn json_parse_empty_fields_is_config_error() {
5098 let res = compile(&RecordTransform::JsonParse {
5099 fields: vec![],
5100 on_error: JsonParseOnError::Keep,
5101 into: None,
5102 });
5103 assert!(matches!(res, Err(FaucetError::Config(_))));
5104 }
5105
5106 #[cfg(feature = "transform-json-parse")]
5107 #[test]
5108 fn json_parse_into_with_multiple_fields_is_config_error() {
5109 let res = compile(&RecordTransform::JsonParse {
5110 fields: vec!["a".into(), "b".into()],
5111 on_error: JsonParseOnError::Keep,
5112 into: Some("x".into()),
5113 });
5114 assert!(matches!(res, Err(FaucetError::Config(_))));
5115 }
5116
5117 #[cfg(feature = "transform-hash")]
5118 #[test]
5119 fn hash_passes_through_non_object() {
5120 let record = json!([1, 2, 3]);
5121 let result = apply_all(
5122 record.clone(),
5123 &compiled(&hash_spec(&["v"], HashEncoding::Hex, None)),
5124 );
5125 assert_eq!(result, record);
5126 }
5127
5128 #[cfg(feature = "transform-json-parse")]
5129 #[test]
5130 fn json_parse_passes_through_non_object() {
5131 let record = json!("scalar");
5132 let result = apply_all(
5133 record.clone(),
5134 &compiled(&json_parse_spec("v", JsonParseOnError::Error)),
5135 );
5136 assert_eq!(result, record);
5137 }
5138
5139 #[cfg(feature = "transform-json-encode")]
5140 #[test]
5141 fn json_encode_stringifies_nested_only() {
5142 let out = apply_all(
5143 json!({"id": 1, "addr": {"city": "NYC"}, "tags": [1, 2], "name": "a"}),
5144 &compiled(&[RecordTransform::JsonEncode {
5145 fields: vec![
5146 "addr".into(),
5147 "tags".into(),
5148 "name".into(),
5149 "missing".into(),
5150 ],
5151 }]),
5152 );
5153 assert_eq!(out["addr"], json!("{\"city\":\"NYC\"}"));
5154 assert_eq!(out["tags"], json!("[1,2]"));
5155 assert_eq!(out["name"], json!("a")); assert_eq!(out["id"], json!(1));
5157 }
5158
5159 #[cfg(any(feature = "transform-json-encode", feature = "transform-hash"))]
5162 fn reverse_inserted(pairs: &[(&str, Value)]) -> Value {
5163 let mut m = Map::new();
5164 for (k, v) in pairs {
5165 m.insert((*k).to_string(), v.clone());
5166 }
5167 Value::Object(m)
5168 }
5169
5170 #[cfg(feature = "transform-json-encode")]
5173 #[test]
5174 fn json_encode_sorts_keys_at_every_depth() {
5175 let owner = reverse_inserted(&[("login", json!("octo-org")), ("id", json!(2))]);
5176 let deep = reverse_inserted(&[
5177 (
5178 "z",
5179 json!([reverse_inserted(&[("b", json!(1)), ("a", json!(2))])]),
5180 ),
5181 (
5182 "a",
5183 reverse_inserted(&[("y", json!(null)), ("x", json!("s"))]),
5184 ),
5185 ]);
5186 let mut rec = Map::new();
5187 rec.insert("owner".into(), owner);
5188 rec.insert("deep".into(), deep);
5189 let out = apply_all(
5190 Value::Object(rec),
5191 &compiled(&[RecordTransform::JsonEncode {
5192 fields: vec!["owner".into(), "deep".into()],
5193 }]),
5194 );
5195 assert_eq!(out["owner"], json!(r#"{"id":2,"login":"octo-org"}"#));
5196 assert_eq!(
5197 out["deep"],
5198 json!(r#"{"a":{"x":"s","y":null},"z":[{"a":2,"b":1}]}"#)
5199 );
5200 }
5201
5202 #[cfg(feature = "transform-lookup")]
5203 fn ref_rows(v: Value) -> Vec<Map<String, Value>> {
5204 v.as_array()
5205 .unwrap()
5206 .iter()
5207 .map(|r| r.as_object().unwrap().clone())
5208 .collect()
5209 }
5210
5211 #[cfg(feature = "transform-lookup")]
5212 #[test]
5213 fn lookup_enriches_on_hit_and_nulls_on_miss() {
5214 let spec = RecordTransform::Lookup {
5215 reference: ref_rows(json!([{"id": 1, "name": "Alice"}, {"id": 2, "name": "Bob"}])),
5216 on_record: "user_id".into(),
5217 on_ref: "id".into(),
5218 add: vec![("user_name".into(), "name".into())],
5219 on_missing: LookupOnMissing::Null,
5220 };
5221 let hit = apply_all(
5222 json!({"user_id": 1}),
5223 &compiled(std::slice::from_ref(&spec)),
5224 );
5225 assert_eq!(hit["user_name"], json!("Alice"));
5226 let miss = apply_all(json!({"user_id": 9}), &compiled(&[spec]));
5227 assert_eq!(miss["user_name"], json!(null));
5228 }
5229
5230 #[cfg(feature = "transform-lookup")]
5231 #[test]
5232 fn lookup_matches_number_against_string_key() {
5233 let spec = RecordTransform::Lookup {
5234 reference: ref_rows(json!([{"code": "42", "label": "answer"}])),
5235 on_record: "code".into(),
5236 on_ref: "code".into(),
5237 add: vec![("label".into(), "label".into())],
5238 on_missing: LookupOnMissing::Keep,
5239 };
5240 let out = apply_all(json!({"code": 42}), &compiled(&[spec]));
5242 assert_eq!(out["label"], json!("answer"));
5243 }
5244
5245 #[cfg(feature = "transform-lookup")]
5246 #[test]
5247 fn lookup_null_key_is_missing_and_whole_floats_match() {
5248 let spec = RecordTransform::Lookup {
5249 reference: ref_rows(json!([
5250 {"code": "", "label": "blank"},
5251 {"code": "42", "label": "answer"},
5252 {"code": 7.5, "label": "frac"}
5253 ])),
5254 on_record: "code".into(),
5255 on_ref: "code".into(),
5256 add: vec![("label".into(), "label".into())],
5257 on_missing: LookupOnMissing::Null,
5258 };
5259 let c = compiled(&[spec]);
5260 assert_eq!(apply_all(json!({"code": null}), &c)["label"], json!(null));
5261 assert_eq!(apply_all(json!({}), &c)["label"], json!(null));
5262 assert_eq!(
5263 apply_all(json!({"code": 42.0}), &c)["label"],
5264 json!("answer")
5265 );
5266 assert_eq!(apply_all(json!({"code": 7.5}), &c)["label"], json!("frac"));
5267 }
5268
5269 #[cfg(feature = "transform-lookup")]
5270 #[test]
5271 fn lookup_on_missing_error_fails() {
5272 let c = compile(&RecordTransform::Lookup {
5273 reference: Vec::new(),
5274 on_record: "k".into(),
5275 on_ref: "k".into(),
5276 add: vec![("x".into(), "y".into())],
5277 on_missing: LookupOnMissing::Error,
5278 })
5279 .unwrap();
5280 let res = super::apply_all(json!({"k": "z"}), std::slice::from_ref(&c));
5281 assert!(res.is_err());
5282 }
5283
5284 #[cfg(feature = "transform-lookup")]
5285 #[test]
5286 fn lookup_empty_add_is_rejected_at_compile() {
5287 assert!(
5288 compile(&RecordTransform::Lookup {
5289 reference: Vec::new(),
5290 on_record: "k".into(),
5291 on_ref: "k".into(),
5292 add: Vec::new(),
5293 on_missing: LookupOnMissing::Null,
5294 })
5295 .is_err()
5296 );
5297 }
5298
5299 #[cfg(all(feature = "transform-json-encode", feature = "transform-lookup"))]
5300 #[test]
5301 fn json_encode_and_lookup_debug_and_clone() {
5302 let je = RecordTransform::JsonEncode {
5304 fields: vec!["meta".into()],
5305 };
5306 assert!(format!("{je:?}").contains("JsonEncode"));
5307 assert!(format!("{:?}", je.clone()).contains("JsonEncode"));
5308
5309 let mut row = serde_json::Map::new();
5310 row.insert("id".into(), Value::String("1".into()));
5311 let lk = RecordTransform::Lookup {
5312 reference: vec![row],
5313 on_record: "rid".into(),
5314 on_ref: "id".into(),
5315 add: vec![("name".into(), "name".into())],
5316 on_missing: LookupOnMissing::Null,
5317 };
5318 assert!(format!("{lk:?}").contains("Lookup"));
5319 assert!(format!("{:?}", lk.clone()).contains("Lookup"));
5320
5321 let _ = compile(&je).unwrap().clone();
5323 let _ = compile(&lk).unwrap().clone();
5324 }
5325
5326 #[cfg(feature = "transform-coalesce")]
5327 #[test]
5328 fn coalesce_non_null_non_string_target_untouched() {
5329 let out = apply_all(
5332 json!({"n": 0}),
5333 &compiled(&[RecordTransform::Coalesce {
5334 field: "n".into(),
5335 default: Some(json!(99)),
5336 from: vec![],
5337 treat_empty_string_as_null: true,
5338 }]),
5339 );
5340 assert_eq!(out["n"], 0);
5341 }
5342
5343 #[cfg(feature = "transform-coalesce")]
5344 #[test]
5345 fn coalesce_passes_through_non_object() {
5346 let record = json!([1, 2]);
5347 let result = apply_all(
5348 record.clone(),
5349 &compiled(&[RecordTransform::Coalesce {
5350 field: "a".into(),
5351 default: Some(json!("x")),
5352 from: vec![],
5353 treat_empty_string_as_null: false,
5354 }]),
5355 );
5356 assert_eq!(result, record);
5357 }
5358
5359 #[cfg(feature = "transform-split-join")]
5360 #[test]
5361 fn split_and_join_pass_through_non_object() {
5362 let record = json!(42);
5363 let split = apply_all(
5364 record.clone(),
5365 &compiled(&[RecordTransform::Split {
5366 field: "a".into(),
5367 delimiter: ",".into(),
5368 trim: false,
5369 into: None,
5370 }]),
5371 );
5372 assert_eq!(split, record);
5373 let join = apply_all(
5374 record.clone(),
5375 &compiled(&[RecordTransform::Join {
5376 field: "a".into(),
5377 delimiter: ",".into(),
5378 into: None,
5379 }]),
5380 );
5381 assert_eq!(join, record);
5382 }
5383
5384 #[cfg(feature = "transform-split-join")]
5385 #[test]
5386 fn split_empty_delimiter_yields_single_element() {
5387 let out = apply_all(
5388 json!({"s": " hi "}),
5389 &compiled(&[RecordTransform::Split {
5390 field: "s".into(),
5391 delimiter: String::new(),
5392 trim: true,
5393 into: None,
5394 }]),
5395 );
5396 assert_eq!(out["s"], json!(["hi"]));
5398 }
5399
5400 #[cfg(feature = "transform-value-case")]
5401 #[test]
5402 fn value_case_title_and_capitalize_handle_empty_string() {
5403 for mode in [ValueCaseMode::Title, ValueCaseMode::Capitalize] {
5404 let out = apply_all(
5405 json!({"s": ""}),
5406 &compiled(&[RecordTransform::ValueCase {
5407 fields: vec!["s".into()],
5408 mode,
5409 }]),
5410 );
5411 assert_eq!(out["s"], "");
5412 }
5413 }
5414}