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).map(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")]
1409fn value_to_key(v: &Value) -> String {
1410 match v {
1411 Value::String(s) => s.clone(),
1412 Value::Null => String::new(),
1413 other => other.to_string(),
1414 }
1415}
1416
1417pub fn apply_all(record: Value, transforms: &[CompiledTransform]) -> Result<Value, FaucetError> {
1423 let mut acc = record;
1424 for t in transforms {
1425 acc = apply_one(acc, t)?;
1426 }
1427 Ok(acc)
1428}
1429
1430fn apply_one(value: Value, t: &CompiledTransform) -> Result<Value, FaucetError> {
1431 match t {
1432 #[cfg(feature = "transform-flatten")]
1433 CompiledTransform::Flatten { separator } => flatten(value, separator),
1434 #[cfg(feature = "transform-rename-keys")]
1435 CompiledTransform::RenameKeys { re, replacement } => {
1436 Ok(rename_keys(value, re, replacement))
1437 }
1438 #[cfg(feature = "transform-keys-case")]
1439 CompiledTransform::KeysCase { mode, on_collision } => {
1440 keys_case(value, *mode, *on_collision)
1441 }
1442 #[cfg(feature = "transform-select")]
1443 CompiledTransform::Select { fields } => Ok(select_fields(value, fields)),
1444 #[cfg(feature = "transform-drop")]
1445 CompiledTransform::Drop { fields } => Ok(drop_fields(value, fields)),
1446 #[cfg(feature = "transform-set")]
1447 CompiledTransform::Set { values } => Ok(set_fields(value, values)),
1448 #[cfg(feature = "transform-rename-field")]
1449 CompiledTransform::RenameField { fields } => rename_field(value, fields),
1450 #[cfg(feature = "transform-cast")]
1451 CompiledTransform::Cast { fields, on_error } => cast_fields(value, fields, *on_error),
1452 #[cfg(feature = "transform-redact")]
1453 CompiledTransform::Redact { fields, mask } => Ok(redact_fields(value, fields, mask)),
1454 #[cfg(feature = "transform-value-case")]
1455 CompiledTransform::ValueCase { fields, mode } => Ok(value_case(value, fields, *mode)),
1456 #[cfg(feature = "transform-spell-symbols")]
1457 CompiledTransform::SpellSymbols {
1458 replacements,
1459 separator,
1460 } => spell_symbols(value, replacements, separator),
1461 #[cfg(feature = "transform-hash")]
1462 CompiledTransform::Hash {
1463 fields,
1464 algorithm,
1465 encoding,
1466 salt,
1467 into,
1468 } => Ok(hash_fields(
1469 value,
1470 fields,
1471 *algorithm,
1472 *encoding,
1473 salt.as_deref(),
1474 into.as_deref(),
1475 )),
1476 #[cfg(feature = "transform-json-parse")]
1477 CompiledTransform::JsonParse {
1478 fields,
1479 on_error,
1480 into,
1481 } => json_parse_fields(value, fields, *on_error, into.as_deref()),
1482 #[cfg(feature = "transform-coalesce")]
1483 CompiledTransform::Coalesce {
1484 field,
1485 default,
1486 from,
1487 treat_empty_string_as_null,
1488 } => Ok(coalesce_field(
1489 value,
1490 field,
1491 default.as_ref(),
1492 from,
1493 *treat_empty_string_as_null,
1494 )),
1495 #[cfg(feature = "transform-split-join")]
1496 CompiledTransform::Split {
1497 field,
1498 delimiter,
1499 trim,
1500 into,
1501 } => Ok(split_field(value, field, delimiter, *trim, into.as_deref())),
1502 #[cfg(feature = "transform-split-join")]
1503 CompiledTransform::Join {
1504 field,
1505 delimiter,
1506 into,
1507 } => Ok(join_field(value, field, delimiter, into.as_deref())),
1508 #[cfg(feature = "transform-json-encode")]
1509 CompiledTransform::JsonEncode { fields } => Ok(json_encode_fields(value, fields)),
1510 #[cfg(feature = "transform-lookup")]
1511 CompiledTransform::Lookup {
1512 index,
1513 on_record,
1514 add,
1515 on_missing,
1516 } => lookup_field(value, index, on_record, add, *on_missing),
1517 CompiledTransform::Custom(f) => Ok(f(value)),
1518 }
1519}
1520
1521#[cfg(feature = "transform-flatten")]
1524fn flatten(value: Value, separator: &str) -> Result<Value, FaucetError> {
1525 match value {
1526 Value::Object(_) => {
1527 let mut out = Map::new();
1528 flatten_into(value, "", separator, &mut out)?;
1529 Ok(Value::Object(out))
1530 }
1531 other => Ok(other),
1532 }
1533}
1534
1535#[cfg(feature = "transform-flatten")]
1536fn flatten_into(
1537 value: Value,
1538 prefix: &str,
1539 separator: &str,
1540 out: &mut Map<String, Value>,
1541) -> Result<(), FaucetError> {
1542 match value {
1543 Value::Object(map) => {
1544 for (k, v) in map {
1545 let key = if prefix.is_empty() {
1546 k
1547 } else {
1548 format!("{prefix}{separator}{k}")
1549 };
1550 flatten_into(v, &key, separator, out)?;
1551 }
1552 }
1553 other => {
1554 if out.contains_key(prefix) {
1558 return Err(FaucetError::Transform(format!(
1559 "flatten produced a duplicate key '{prefix}'; two distinct fields collapse \
1560 to the same flattened key (separator '{separator}')"
1561 )));
1562 }
1563 out.insert(prefix.to_string(), other);
1564 }
1565 }
1566 Ok(())
1567}
1568
1569#[cfg(feature = "transform-rename-keys")]
1572fn rename_keys(value: Value, re: &Regex, replacement: &str) -> Value {
1573 match value {
1574 Value::Object(map) => {
1575 let new_map: Map<String, Value> = map
1576 .into_iter()
1577 .map(|(k, v)| {
1578 let new_k = re.replace_all(&k, replacement).into_owned();
1579 (new_k, rename_keys(v, re, replacement))
1580 })
1581 .collect();
1582 Value::Object(new_map)
1583 }
1584 Value::Array(arr) => Value::Array(
1585 arr.into_iter()
1586 .map(|v| rename_keys(v, re, replacement))
1587 .collect(),
1588 ),
1589 other => other,
1590 }
1591}
1592
1593#[cfg(feature = "transform-keys-case")]
1602fn keys_case(
1603 value: Value,
1604 mode: KeyCaseMode,
1605 on_collision: KeyCollision,
1606) -> Result<Value, FaucetError> {
1607 match value {
1608 Value::Object(map) => {
1609 let mut new_map = Map::with_capacity(map.len());
1610 for (k, v) in map {
1611 let tokens = tokenize_key(&k);
1612 let recased = if tokens.is_empty() {
1613 k
1616 } else {
1617 apply_key_case(tokens, mode)
1618 };
1619 let new_v = keys_case(v, mode, on_collision)?;
1620 let final_key = if new_map.contains_key(&recased) {
1621 match on_collision {
1622 KeyCollision::Error => {
1623 return Err(FaucetError::Transform(format!(
1624 "keys_case produced a duplicate key '{recased}'; two distinct \
1625 keys re-case to the same name under mode {mode:?} (set \
1626 on_collision: suffix to disambiguate)"
1627 )));
1628 }
1629 KeyCollision::Suffix => {
1630 let mut n = 2usize;
1633 let mut candidate = format!("{recased}_{n}");
1634 while new_map.contains_key(&candidate) {
1635 n += 1;
1636 candidate = format!("{recased}_{n}");
1637 }
1638 candidate
1639 }
1640 }
1641 } else {
1642 recased
1643 };
1644 new_map.insert(final_key, new_v);
1645 }
1646 Ok(Value::Object(new_map))
1647 }
1648 Value::Array(arr) => {
1649 let mut out = Vec::with_capacity(arr.len());
1650 for v in arr {
1651 out.push(keys_case(v, mode, on_collision)?);
1652 }
1653 Ok(Value::Array(out))
1654 }
1655 other => Ok(other),
1656 }
1657}
1658
1659#[cfg(feature = "transform-keys-case")]
1665fn tokenize_key(key: &str) -> Vec<String> {
1666 crate::util::tokenize_identifier(key)
1669}
1670
1671#[cfg(feature = "transform-keys-case")]
1672fn apply_key_case(tokens: Vec<String>, mode: KeyCaseMode) -> String {
1673 match mode {
1674 KeyCaseMode::Snake => tokens
1675 .iter()
1676 .map(|t| t.to_lowercase())
1677 .collect::<Vec<_>>()
1678 .join("_"),
1679 KeyCaseMode::ScreamingSnake => tokens
1680 .iter()
1681 .map(|t| t.to_uppercase())
1682 .collect::<Vec<_>>()
1683 .join("_"),
1684 KeyCaseMode::Kebab => tokens
1685 .iter()
1686 .map(|t| t.to_lowercase())
1687 .collect::<Vec<_>>()
1688 .join("-"),
1689 KeyCaseMode::Dot => tokens
1690 .iter()
1691 .map(|t| t.to_lowercase())
1692 .collect::<Vec<_>>()
1693 .join("."),
1694 KeyCaseMode::Camel => {
1695 let mut iter = tokens.into_iter();
1696 match iter.next() {
1697 None => String::new(),
1698 Some(first) => {
1699 let mut out = first.to_lowercase();
1700 for t in iter {
1701 out.push_str(&capitalize_token(&t));
1702 }
1703 out
1704 }
1705 }
1706 }
1707 KeyCaseMode::Pascal => tokens
1708 .into_iter()
1709 .map(|t| capitalize_token(&t))
1710 .collect::<String>(),
1711 }
1712}
1713
1714#[cfg(feature = "transform-keys-case")]
1716fn capitalize_token(s: &str) -> String {
1717 let lower = s.to_lowercase();
1718 let mut chars = lower.chars();
1719 match chars.next() {
1720 None => String::new(),
1721 Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
1722 }
1723}
1724
1725#[cfg(feature = "transform-select")]
1728fn select_fields(value: Value, fields: &[String]) -> Value {
1729 match value {
1730 Value::Object(map) => {
1731 let mut out = Map::with_capacity(fields.len().min(map.len()));
1732 for f in fields {
1734 if let Some(v) = map.get(f) {
1735 out.insert(f.clone(), v.clone());
1736 }
1737 }
1738 Value::Object(out)
1739 }
1740 other => other,
1741 }
1742}
1743
1744#[cfg(feature = "transform-drop")]
1747fn drop_fields(value: Value, fields: &[String]) -> Value {
1748 match value {
1749 Value::Object(mut map) => {
1750 for f in fields {
1751 map.remove(f);
1752 }
1753 Value::Object(map)
1754 }
1755 other => other,
1756 }
1757}
1758
1759#[cfg(feature = "transform-set")]
1762fn set_fields(value: Value, values: &Map<String, Value>) -> Value {
1763 match value {
1764 Value::Object(mut map) => {
1765 for (k, v) in values {
1766 map.insert(k.clone(), v.clone());
1767 }
1768 Value::Object(map)
1769 }
1770 other => other,
1771 }
1772}
1773
1774#[cfg(feature = "transform-rename-field")]
1777fn rename_field(value: Value, fields: &[(String, String)]) -> Result<Value, FaucetError> {
1778 match value {
1779 Value::Object(mut map) => {
1780 let renames: Vec<(&str, &str)> = fields
1786 .iter()
1787 .filter(|(from, to)| from != to && map.contains_key(from))
1788 .map(|(from, to)| (from.as_str(), to.as_str()))
1789 .collect();
1790 let sources: std::collections::HashSet<&str> =
1791 renames.iter().map(|(from, _)| *from).collect();
1792
1793 let mut seen_targets: std::collections::HashSet<&str> =
1795 std::collections::HashSet::new();
1796 for (from, to) in &renames {
1797 if !seen_targets.insert(to) {
1798 return Err(FaucetError::Transform(format!(
1799 "rename_field: two fields rename to the same target key '{to}'"
1800 )));
1801 }
1802 if map.contains_key(*to) && !sources.contains(to) {
1806 return Err(FaucetError::Transform(format!(
1807 "rename_field: target key '{to}' already exists on the record \
1808 (renaming from '{from}')"
1809 )));
1810 }
1811 }
1812
1813 let staged: Vec<(String, Value)> = renames
1814 .iter()
1815 .map(|(from, to)| {
1816 let v = map.remove(*from).expect("source presence checked above");
1817 (to.to_string(), v)
1818 })
1819 .collect();
1820 for (to, v) in staged {
1821 map.insert(to, v);
1822 }
1823 Ok(Value::Object(map))
1824 }
1825 other => Ok(other),
1826 }
1827}
1828
1829#[cfg(feature = "transform-cast")]
1832fn cast_fields(
1833 value: Value,
1834 fields: &HashMap<String, CastType>,
1835 on_error: CastOnError,
1836) -> Result<Value, FaucetError> {
1837 match value {
1838 Value::Object(mut map) => {
1839 for (field, target) in fields {
1840 let Some(current) = map.get(field) else {
1841 continue;
1842 };
1843 match cast_value(current, *target) {
1844 Ok(new_val) => {
1845 map.insert(field.clone(), new_val);
1846 }
1847 Err(msg) => match on_error {
1848 CastOnError::Error => {
1849 return Err(FaucetError::Transform(format!(
1850 "cast: field '{field}' to {target:?} failed: {msg}"
1851 )));
1852 }
1853 CastOnError::Null => {
1854 map.insert(field.clone(), Value::Null);
1855 }
1856 CastOnError::Skip => { }
1857 },
1858 }
1859 }
1860 Ok(Value::Object(map))
1861 }
1862 other => Ok(other),
1863 }
1864}
1865
1866#[cfg(feature = "transform-cast")]
1869fn cast_value(v: &Value, target: CastType) -> Result<Value, String> {
1870 match target {
1871 CastType::Int => match v {
1872 Value::Number(n) => {
1873 if let Some(i) = n.as_i64() {
1874 return Ok(Value::Number(i.into()));
1875 }
1876 match n.as_f64() {
1883 Some(f)
1884 if f.fract() == 0.0 && (-(2f64.powi(63))..2f64.powi(63)).contains(&f) =>
1885 {
1886 Ok(Value::Number((f as i64).into()))
1887 }
1888 Some(f) => Err(format!(
1889 "float '{f}' is not a whole number representable as i64"
1890 )),
1891 None => Err(format!("number '{n}' is not representable as i64")),
1892 }
1893 }
1894 Value::String(s) => s
1895 .trim()
1896 .parse::<i64>()
1897 .map(|i| Value::Number(i.into()))
1898 .map_err(|e| format!("'{s}' is not an integer: {e}")),
1899 Value::Bool(b) => Ok(Value::Number(i64::from(*b).into())),
1900 Value::Null => Err("null cannot be cast to int".to_owned()),
1901 Value::Array(_) | Value::Object(_) => {
1902 Err("composite values cannot be cast to int".to_owned())
1903 }
1904 },
1905 CastType::Float => match v {
1906 Value::Number(n) => n
1907 .as_f64()
1908 .and_then(|f| serde_json::Number::from_f64(f).map(Value::Number))
1909 .ok_or_else(|| format!("number '{n}' is not representable as f64")),
1910 Value::String(s) => s
1911 .trim()
1912 .parse::<f64>()
1913 .ok()
1914 .and_then(|f| serde_json::Number::from_f64(f).map(Value::Number))
1915 .ok_or_else(|| format!("'{s}' is not a float")),
1916 Value::Bool(b) => serde_json::Number::from_f64(if *b { 1.0 } else { 0.0 })
1917 .map(Value::Number)
1918 .ok_or_else(|| "could not encode bool as f64".to_owned()),
1919 Value::Null => Err("null cannot be cast to float".to_owned()),
1920 Value::Array(_) | Value::Object(_) => {
1921 Err("composite values cannot be cast to float".to_owned())
1922 }
1923 },
1924 CastType::Bool => match v {
1925 Value::Bool(b) => Ok(Value::Bool(*b)),
1926 Value::Number(n) => {
1927 if let Some(i) = n.as_i64() {
1928 match i {
1929 0 => Ok(Value::Bool(false)),
1930 1 => Ok(Value::Bool(true)),
1931 _ => Err(format!("integer {i} is not 0 or 1")),
1932 }
1933 } else {
1934 Err(format!("number '{n}' is not 0 or 1"))
1935 }
1936 }
1937 Value::String(s) => match s.trim().to_ascii_lowercase().as_str() {
1938 "true" | "1" | "yes" | "y" => Ok(Value::Bool(true)),
1939 "false" | "0" | "no" | "n" => Ok(Value::Bool(false)),
1940 other => Err(format!("'{other}' is not a recognised boolean")),
1941 },
1942 Value::Null => Err("null cannot be cast to bool".to_owned()),
1943 Value::Array(_) | Value::Object(_) => {
1944 Err("composite values cannot be cast to bool".to_owned())
1945 }
1946 },
1947 CastType::String => match v {
1948 Value::String(s) => Ok(Value::String(s.clone())),
1949 Value::Number(n) => Ok(Value::String(n.to_string())),
1950 Value::Bool(b) => Ok(Value::String(b.to_string())),
1951 Value::Null => Err("null cannot be cast to string".to_owned()),
1952 Value::Array(_) | Value::Object(_) => {
1953 Err("composite values cannot be cast to string".to_owned())
1954 }
1955 },
1956 CastType::Timestamp => match v {
1957 Value::String(s) => chrono::DateTime::parse_from_rfc3339(s)
1958 .map(|dt| Value::String(dt.to_rfc3339_opts(chrono::SecondsFormat::AutoSi, true)))
1959 .map_err(|e| format!("'{s}' is not a valid RFC 3339 timestamp: {e}")),
1960 other => Err(format!(
1961 "cannot cast {} to timestamp (expected RFC 3339 string)",
1962 value_type_name(other)
1963 )),
1964 },
1965 }
1966}
1967
1968#[cfg(feature = "transform-cast")]
1969fn value_type_name(v: &Value) -> &'static str {
1970 match v {
1971 Value::Null => "null",
1972 Value::Bool(_) => "bool",
1973 Value::Number(_) => "number",
1974 Value::String(_) => "string",
1975 Value::Array(_) => "array",
1976 Value::Object(_) => "object",
1977 }
1978}
1979
1980#[cfg(feature = "transform-redact")]
1983fn redact_fields(value: Value, fields: &[String], mask: &Value) -> Value {
1984 match value {
1985 Value::Object(mut map) => {
1986 for f in fields {
1987 if map.contains_key(f) {
1988 map.insert(f.clone(), mask.clone());
1989 }
1990 }
1991 Value::Object(map)
1992 }
1993 other => other,
1994 }
1995}
1996
1997#[cfg(feature = "transform-value-case")]
2000fn value_case(value: Value, fields: &[String], mode: ValueCaseMode) -> Value {
2001 match value {
2002 Value::Object(mut map) => {
2003 for f in fields {
2004 if let Some(Value::String(s)) = map.get(f) {
2005 let new_s = match mode {
2006 ValueCaseMode::Lower => s.to_lowercase(),
2007 ValueCaseMode::Upper => s.to_uppercase(),
2008 ValueCaseMode::Trim => s.trim().to_owned(),
2009 ValueCaseMode::Title => title_case(s),
2010 ValueCaseMode::Capitalize => capitalize_str(s),
2011 };
2012 map.insert(f.clone(), Value::String(new_s));
2013 }
2014 }
2015 Value::Object(map)
2016 }
2017 other => other,
2018 }
2019}
2020
2021#[cfg(feature = "transform-value-case")]
2026fn title_case(s: &str) -> String {
2027 let mut out = String::with_capacity(s.len());
2028 let mut at_word_start = true;
2029 for ch in s.chars() {
2030 if ch.is_whitespace() {
2031 at_word_start = true;
2032 out.push(ch);
2033 } else if at_word_start {
2034 out.extend(ch.to_uppercase());
2035 at_word_start = false;
2036 } else {
2037 out.extend(ch.to_lowercase());
2038 }
2039 }
2040 out
2041}
2042
2043#[cfg(feature = "transform-value-case")]
2046fn capitalize_str(s: &str) -> String {
2047 let mut chars = s.chars();
2048 match chars.next() {
2049 None => String::new(),
2050 Some(first) => {
2051 let mut out: String = first.to_uppercase().collect();
2052 out.push_str(&chars.as_str().to_lowercase());
2053 out
2054 }
2055 }
2056}
2057
2058#[cfg(feature = "transform-spell-symbols")]
2069pub fn default_symbol_map() -> HashMap<String, String> {
2070 let pairs: &[(&str, &str)] = &[
2071 ("%", "percent"),
2072 ("#", "number"),
2073 ("$", "dollar"),
2074 ("&", "and"),
2075 ("@", "at"),
2076 ("+", "plus"),
2077 ("*", "star"),
2078 ("=", "equals"),
2079 ("<", "lt"),
2080 (">", "gt"),
2081 ("/", "slash"),
2082 ("\\", "backslash"),
2083 ("|", "pipe"),
2084 ("^", "caret"),
2085 ("~", "tilde"),
2086 ];
2087 pairs
2088 .iter()
2089 .map(|(k, v)| ((*k).to_owned(), (*v).to_owned()))
2090 .collect()
2091}
2092
2093#[cfg(feature = "transform-spell-symbols")]
2094fn spell_symbols(
2095 value: Value,
2096 replacements: &[(String, String)],
2097 separator: &str,
2098) -> Result<Value, FaucetError> {
2099 match value {
2100 Value::Object(map) => {
2101 let mut new_map = Map::with_capacity(map.len());
2102 for (k, v) in map {
2103 let new_k = spell_symbols_in_key(&k, replacements, separator);
2104 let new_v = spell_symbols(v, replacements, separator)?;
2105 if new_map.contains_key(&new_k) {
2109 return Err(FaucetError::Transform(format!(
2110 "spell_symbols produced a duplicate key '{new_k}'; two distinct keys \
2111 expand to the same name"
2112 )));
2113 }
2114 new_map.insert(new_k, new_v);
2115 }
2116 Ok(Value::Object(new_map))
2117 }
2118 Value::Array(arr) => {
2119 let mut out = Vec::with_capacity(arr.len());
2120 for v in arr {
2121 out.push(spell_symbols(v, replacements, separator)?);
2122 }
2123 Ok(Value::Array(out))
2124 }
2125 other => Ok(other),
2126 }
2127}
2128
2129#[cfg(feature = "transform-spell-symbols")]
2133fn spell_symbols_in_key(key: &str, replacements: &[(String, String)], separator: &str) -> String {
2134 let bytes = key.as_bytes();
2138 let mut out = String::with_capacity(key.len());
2139 let mut i = 0;
2140 while i < bytes.len() {
2141 let mut matched = false;
2142 for (from, to) in replacements {
2143 let f = from.as_bytes();
2144 if !f.is_empty() && bytes[i..].starts_with(f) {
2145 out.push_str(separator);
2146 out.push_str(to);
2147 out.push_str(separator);
2148 i += f.len();
2149 matched = true;
2150 break;
2151 }
2152 }
2153 if !matched {
2154 let ch = key[i..]
2157 .chars()
2158 .next()
2159 .expect("non-empty slice yields at least one char");
2160 out.push(ch);
2161 i += ch.len_utf8();
2162 }
2163 }
2164 out
2165}
2166
2167#[cfg(feature = "transform-hash")]
2170fn hash_fields(
2171 value: Value,
2172 fields: &[String],
2173 algorithm: HashAlgorithm,
2174 encoding: HashEncoding,
2175 salt: Option<&str>,
2176 into: Option<&str>,
2177) -> Value {
2178 match value {
2179 Value::Object(mut map) => {
2180 for field in fields {
2181 let Some(current) = map.get(field) else {
2182 continue;
2183 };
2184 if current.is_null() {
2192 continue;
2193 }
2194 let input = match current {
2197 Value::String(s) => s.clone(),
2198 other => other.to_string(),
2199 };
2200 let digest = hash_string(&input, algorithm, encoding, salt);
2201 let target = into.unwrap_or(field.as_str());
2202 map.insert(target.to_owned(), Value::String(digest));
2203 }
2204 Value::Object(map)
2205 }
2206 other => other,
2207 }
2208}
2209
2210#[cfg(feature = "transform-hash")]
2211fn hash_string(
2212 input: &str,
2213 algorithm: HashAlgorithm,
2214 encoding: HashEncoding,
2215 salt: Option<&str>,
2216) -> String {
2217 let mut bytes: Vec<u8> = Vec::with_capacity(salt.map_or(0, str::len) + input.len());
2219 if let Some(s) = salt {
2220 bytes.extend_from_slice(s.as_bytes());
2221 }
2222 bytes.extend_from_slice(input.as_bytes());
2223 let digest: Vec<u8> = match algorithm {
2224 HashAlgorithm::Sha256 => {
2225 use sha2::{Digest, Sha256};
2226 let mut h = Sha256::new();
2227 h.update(&bytes);
2228 h.finalize().to_vec()
2229 }
2230 HashAlgorithm::Blake3 => blake3::hash(&bytes).as_bytes().to_vec(),
2231 };
2232 match encoding {
2233 HashEncoding::Hex => hex_encode(&digest),
2234 HashEncoding::Base64 => {
2235 use base64::Engine;
2236 base64::engine::general_purpose::STANDARD.encode(&digest)
2237 }
2238 }
2239}
2240
2241#[cfg(feature = "transform-hash")]
2242fn hex_encode(bytes: &[u8]) -> String {
2243 const HEX: &[u8; 16] = b"0123456789abcdef";
2244 let mut s = String::with_capacity(bytes.len() * 2);
2245 for &b in bytes {
2246 s.push(HEX[(b >> 4) as usize] as char);
2247 s.push(HEX[(b & 0x0f) as usize] as char);
2248 }
2249 s
2250}
2251
2252#[cfg(feature = "transform-json-encode")]
2257fn json_encode_fields(mut value: Value, fields: &[String]) -> Value {
2258 if let Value::Object(map) = &mut value {
2259 for f in fields {
2260 if let Some(v) = map.get_mut(f)
2261 && matches!(v, Value::Object(_) | Value::Array(_))
2262 {
2263 let s = serde_json::to_string(v).unwrap_or_else(|_| "null".to_string());
2264 *v = Value::String(s);
2265 }
2266 }
2267 }
2268 value
2269}
2270
2271#[cfg(feature = "transform-lookup")]
2273fn lookup_field(
2274 mut value: Value,
2275 index: &HashMap<String, Map<String, Value>>,
2276 on_record: &str,
2277 add: &[(String, String)],
2278 on_missing: LookupOnMissing,
2279) -> Result<Value, FaucetError> {
2280 let Value::Object(map) = &mut value else {
2281 return Ok(value);
2282 };
2283 let key = map.get(on_record).map(value_to_key);
2284 let matched = key.as_deref().and_then(|k| index.get(k));
2285 match matched {
2286 Some(row) => {
2287 for (out, src) in add {
2288 let v = row.get(src).cloned().unwrap_or(Value::Null);
2289 map.insert(out.clone(), v);
2290 }
2291 }
2292 None => match on_missing {
2293 LookupOnMissing::Null => {
2294 for (out, _) in add {
2295 map.insert(out.clone(), Value::Null);
2296 }
2297 }
2298 LookupOnMissing::Keep => {}
2299 LookupOnMissing::Error => {
2300 return Err(FaucetError::Transform(format!(
2301 "lookup: no reference row for {on_record}={:?}",
2302 key.unwrap_or_default()
2303 )));
2304 }
2305 },
2306 }
2307 Ok(value)
2308}
2309
2310#[cfg(feature = "transform-json-parse")]
2313fn json_parse_fields(
2314 value: Value,
2315 fields: &[String],
2316 on_error: JsonParseOnError,
2317 into: Option<&str>,
2318) -> Result<Value, FaucetError> {
2319 match value {
2320 Value::Object(mut map) => {
2321 for field in fields {
2322 let Some(Value::String(s)) = map.get(field) else {
2325 continue;
2326 };
2327 let s = s.clone();
2328 match serde_json::from_str::<Value>(&s) {
2329 Ok(parsed) => {
2330 let target = into.unwrap_or(field.as_str());
2331 map.insert(target.to_owned(), parsed);
2332 }
2333 Err(e) => match on_error {
2334 JsonParseOnError::Keep => { }
2335 JsonParseOnError::Null => {
2336 let target = into.unwrap_or(field.as_str());
2337 map.insert(target.to_owned(), Value::Null);
2338 }
2339 JsonParseOnError::Error => {
2340 return Err(FaucetError::Transform(format!(
2341 "json_parse: field '{field}' is not valid JSON: {e}"
2342 )));
2343 }
2344 },
2345 }
2346 }
2347 Ok(Value::Object(map))
2348 }
2349 other => Ok(other),
2350 }
2351}
2352
2353#[cfg(feature = "transform-coalesce")]
2356fn coalesce_field(
2357 value: Value,
2358 field: &str,
2359 default: Option<&Value>,
2360 from: &[String],
2361 treat_empty_string_as_null: bool,
2362) -> Value {
2363 match value {
2364 Value::Object(mut map) => {
2365 if is_nullish(map.get(field), treat_empty_string_as_null) {
2366 let replacement: Option<Value> = match default {
2367 Some(d) => Some(d.clone()),
2368 None => from.iter().find_map(|k| {
2369 let v = map.get(k);
2370 if is_nullish(v, treat_empty_string_as_null) {
2371 None
2372 } else {
2373 v.cloned()
2374 }
2375 }),
2376 };
2377 if let Some(v) = replacement {
2378 map.insert(field.to_owned(), v);
2379 }
2380 }
2381 Value::Object(map)
2382 }
2383 other => other,
2384 }
2385}
2386
2387#[cfg(feature = "transform-coalesce")]
2390fn is_nullish(v: Option<&Value>, treat_empty_string_as_null: bool) -> bool {
2391 match v {
2392 None | Some(Value::Null) => true,
2393 Some(Value::String(s)) => treat_empty_string_as_null && s.is_empty(),
2394 _ => false,
2395 }
2396}
2397
2398#[cfg(feature = "transform-split-join")]
2401fn split_field(
2402 value: Value,
2403 field: &str,
2404 delimiter: &str,
2405 trim: bool,
2406 into: Option<&str>,
2407) -> Value {
2408 match value {
2409 Value::Object(mut map) => {
2410 let Some(Value::String(s)) = map.get(field) else {
2411 return Value::Object(map);
2412 };
2413 let s = s.clone();
2414 let parts: Vec<Value> = if delimiter.is_empty() {
2418 vec![Value::String(if trim { s.trim().to_owned() } else { s })]
2419 } else {
2420 s.split(delimiter)
2421 .map(|part| {
2422 let p = if trim { part.trim() } else { part };
2423 Value::String(p.to_owned())
2424 })
2425 .collect()
2426 };
2427 let target = into.unwrap_or(field);
2428 map.insert(target.to_owned(), Value::Array(parts));
2429 Value::Object(map)
2430 }
2431 other => other,
2432 }
2433}
2434
2435#[cfg(feature = "transform-split-join")]
2436fn join_field(value: Value, field: &str, delimiter: &str, into: Option<&str>) -> Value {
2437 match value {
2438 Value::Object(mut map) => {
2439 let Some(Value::Array(arr)) = map.get(field) else {
2440 return Value::Object(map);
2441 };
2442 let joined = arr
2443 .iter()
2444 .map(scalar_to_string)
2445 .collect::<Vec<_>>()
2446 .join(delimiter);
2447 let target = into.unwrap_or(field);
2448 map.insert(target.to_owned(), Value::String(joined));
2449 Value::Object(map)
2450 }
2451 other => other,
2452 }
2453}
2454
2455#[cfg(feature = "transform-split-join")]
2458fn scalar_to_string(v: &Value) -> String {
2459 match v {
2460 Value::String(s) => s.clone(),
2461 Value::Null => String::new(),
2462 other => other.to_string(),
2463 }
2464}
2465
2466#[cfg(test)]
2469mod tests {
2470 use super::*;
2471 use serde_json::json;
2472
2473 fn apply_all(record: Value, transforms: &[CompiledTransform]) -> Value {
2478 super::apply_all(record, transforms).expect("transform should succeed in this test")
2479 }
2480
2481 fn compiled(transforms: &[RecordTransform]) -> Vec<CompiledTransform> {
2482 transforms.iter().map(|t| compile(t).unwrap()).collect()
2483 }
2484
2485 #[test]
2488 fn test_custom_adds_field() {
2489 let record = json!({"id": 1});
2490 let result = apply_all(
2491 record,
2492 &compiled(&[RecordTransform::custom(|mut v| {
2493 if let Value::Object(ref mut m) = v {
2494 m.insert("added".to_string(), json!(true));
2495 }
2496 v
2497 })]),
2498 );
2499 assert_eq!(result["id"], 1);
2500 assert_eq!(result["added"], true);
2501 }
2502
2503 #[test]
2504 fn test_custom_removes_field() {
2505 let record = json!({"id": 1, "secret": "drop_me"});
2506 let result = apply_all(
2507 record,
2508 &compiled(&[RecordTransform::custom(|mut v| {
2509 if let Value::Object(ref mut m) = v {
2510 m.remove("secret");
2511 }
2512 v
2513 })]),
2514 );
2515 assert_eq!(result["id"], 1);
2516 assert!(result.get("secret").is_none());
2517 }
2518
2519 #[test]
2520 fn test_no_transforms_is_identity() {
2521 let record = json!({"id": 1, "name": "Alice"});
2522 let result = apply_all(record.clone(), &[]);
2523 assert_eq!(result, record);
2524 }
2525
2526 #[cfg(feature = "transform-flatten")]
2529 #[test]
2530 fn test_flatten_nested_object() {
2531 let record = json!({"a": {"b": 1, "c": {"d": 2}}, "e": 3});
2532 let result = apply_all(
2533 record,
2534 &compiled(&[RecordTransform::Flatten {
2535 separator: "__".into(),
2536 }]),
2537 );
2538 assert_eq!(result["a__b"], 1);
2539 assert_eq!(result["a__c__d"], 2);
2540 assert_eq!(result["e"], 3);
2541 assert!(result.get("a").is_none(), "nested key should be removed");
2542 }
2543
2544 #[cfg(feature = "transform-flatten")]
2545 #[test]
2546 fn test_flatten_leaves_arrays_intact() {
2547 let record = json!({"tags": ["rust", "api"], "meta": {"count": 2}});
2548 let result = apply_all(
2549 record,
2550 &compiled(&[RecordTransform::Flatten {
2551 separator: ".".into(),
2552 }]),
2553 );
2554 assert_eq!(result["tags"], json!(["rust", "api"]));
2555 assert_eq!(result["meta.count"], 2);
2556 }
2557
2558 #[cfg(feature = "transform-flatten")]
2559 #[test]
2560 fn test_flatten_already_flat() {
2561 let record = json!({"id": 1, "name": "Alice"});
2562 let result = apply_all(
2563 record.clone(),
2564 &compiled(&[RecordTransform::Flatten {
2565 separator: "__".into(),
2566 }]),
2567 );
2568 assert_eq!(result, record);
2569 }
2570
2571 #[cfg(feature = "transform-flatten")]
2572 #[test]
2573 fn test_flatten_empty_separator() {
2574 let record = json!({"a": {"b": 1}});
2575 let result = apply_all(
2576 record,
2577 &compiled(&[RecordTransform::Flatten {
2578 separator: "".into(),
2579 }]),
2580 );
2581 assert_eq!(result["ab"], 1);
2582 }
2583
2584 #[cfg(feature = "transform-rename-keys")]
2587 #[test]
2588 fn test_rename_keys_strips_prefix() {
2589 let record = json!({"_prefix_id": 1, "_prefix_name": "Alice"});
2590 let result = apply_all(
2591 record,
2592 &compiled(&[RecordTransform::RenameKeys {
2593 pattern: r"^_prefix_".into(),
2594 replacement: "".into(),
2595 }]),
2596 );
2597 assert_eq!(result["id"], 1);
2598 assert_eq!(result["name"], "Alice");
2599 }
2600
2601 #[cfg(feature = "transform-rename-keys")]
2602 #[test]
2603 fn test_rename_keys_uppercase_to_placeholder() {
2604 let record = json!({"OUTER": {"INNER": 42}});
2605 let result = apply_all(
2606 record,
2607 &compiled(&[RecordTransform::RenameKeys {
2608 pattern: r"[A-Z]+".into(),
2609 replacement: "x".into(),
2610 }]),
2611 );
2612 assert_eq!(result["x"]["x"], 42);
2613 }
2614
2615 #[cfg(feature = "transform-rename-keys")]
2616 #[test]
2617 fn test_rename_keys_in_array_elements() {
2618 let record = json!({"items": [{"KEY": 1}, {"KEY": 2}]});
2619 let result = apply_all(
2620 record,
2621 &compiled(&[RecordTransform::RenameKeys {
2622 pattern: r"KEY".into(),
2623 replacement: "key".into(),
2624 }]),
2625 );
2626 assert_eq!(result["items"][0]["key"], 1);
2627 assert_eq!(result["items"][1]["key"], 2);
2628 }
2629
2630 #[cfg(feature = "transform-rename-keys")]
2631 #[test]
2632 fn test_rename_keys_invalid_regex_errors_at_compile() {
2633 let err = compile(&RecordTransform::RenameKeys {
2634 pattern: "[invalid".into(),
2635 replacement: "".into(),
2636 });
2637 assert!(err.is_err());
2638 assert!(matches!(err, Err(FaucetError::Transform(_))));
2639 }
2640
2641 #[cfg(feature = "transform-rename-keys")]
2642 #[test]
2643 fn test_rename_keys_chained() {
2644 let record = json!({"__camelCase__": 1});
2645 let result = apply_all(
2646 record,
2647 &compiled(&[
2648 RecordTransform::RenameKeys {
2649 pattern: r"^_+|_+$".into(),
2650 replacement: "".into(),
2651 },
2652 RecordTransform::RenameKeys {
2653 pattern: r"[A-Z]".into(),
2654 replacement: "_".into(),
2655 },
2656 ]),
2657 );
2658 let key = result.as_object().unwrap().keys().next().unwrap().clone();
2659 assert_eq!(key, "camel_ase");
2660 }
2661
2662 #[cfg(all(feature = "transform-keys-case", feature = "transform-flatten"))]
2665 #[test]
2666 fn test_keys_case_then_flatten() {
2667 let record = json!({"User Info": {"First Name": "Alice", "Last Name": "Smith"}});
2668 let result = apply_all(
2669 record,
2670 &compiled(&[
2671 RecordTransform::KeysCase {
2672 mode: KeyCaseMode::Snake,
2673 on_collision: KeyCollision::Error,
2674 },
2675 RecordTransform::Flatten {
2676 separator: "_".into(),
2677 },
2678 ]),
2679 );
2680 assert_eq!(result["user_info_first_name"], "Alice");
2681 assert_eq!(result["user_info_last_name"], "Smith");
2682 }
2683
2684 #[test]
2685 fn test_custom_chained_with_builtin() {
2686 let record = json!({"id": 1, "raw_value": 100});
2688 let result = apply_all(
2689 record,
2690 &compiled(&[
2691 RecordTransform::custom(|mut v| {
2693 if let Some(n) = v.get("raw_value").and_then(|n| n.as_i64())
2694 && let Value::Object(ref mut m) = v
2695 {
2696 m.insert("raw_value".to_string(), json!(n * 2));
2697 }
2698 v
2699 }),
2700 RecordTransform::custom(|mut v| {
2702 if let Value::Object(ref mut m) = v
2703 && let Some(val) = m.remove("raw_value")
2704 {
2705 m.insert("value".to_string(), val);
2706 }
2707 v
2708 }),
2709 ]),
2710 );
2711 assert_eq!(result["id"], 1);
2712 assert_eq!(result["value"], 200);
2713 assert!(result.get("raw_value").is_none());
2714 }
2715
2716 #[cfg(feature = "transform-flatten")]
2719 #[test]
2720 fn flatten_key_collision_errors() {
2721 let record = json!({"a__b": 1, "a": {"b": 2}});
2723 let err = super::apply_all(
2724 record,
2725 &compiled(&[RecordTransform::Flatten {
2726 separator: "__".into(),
2727 }]),
2728 )
2729 .expect_err("colliding flattened keys must error, not drop a value");
2730 assert!(matches!(err, FaucetError::Transform(_)));
2731 assert!(format!("{err}").contains("a__b"), "{err}");
2732 }
2733
2734 #[cfg(feature = "transform-keys-case")]
2735 #[test]
2736 fn keys_case_collision_errors_by_default() {
2737 let record = json!({"AccountId": 1, "account_id": 2});
2739 let err = super::apply_all(
2740 record,
2741 &compiled(&[RecordTransform::KeysCase {
2742 mode: KeyCaseMode::Snake,
2743 on_collision: KeyCollision::Error,
2744 }]),
2745 )
2746 .expect_err("colliding re-cased keys must error by default, not drop a value");
2747 assert!(matches!(err, FaucetError::Transform(_)));
2748 assert!(format!("{err}").contains("account_id"), "{err}");
2749 }
2750
2751 #[cfg(feature = "transform-keys-case")]
2752 #[test]
2753 fn keys_case_collision_suffix_disambiguates() {
2754 let record = json!({"AccountId": 1, "account_id": 2});
2755 let result = apply_all(
2756 record,
2757 &compiled(&[RecordTransform::KeysCase {
2758 mode: KeyCaseMode::Snake,
2759 on_collision: KeyCollision::Suffix,
2760 }]),
2761 );
2762 let obj = result.as_object().unwrap();
2763 let keys: std::collections::BTreeSet<&str> = obj.keys().map(String::as_str).collect();
2765 assert_eq!(
2766 keys,
2767 ["account_id", "account_id_2"].into_iter().collect(),
2768 "got {keys:?}"
2769 );
2770 let vals: std::collections::BTreeSet<i64> =
2771 obj.values().filter_map(Value::as_i64).collect();
2772 assert_eq!(vals, [1, 2].into_iter().collect(), "both values preserved");
2773 }
2774
2775 #[cfg(feature = "transform-keys-case")]
2776 #[test]
2777 fn keys_case_collision_suffix_three_way() {
2778 let record = json!({"Foo": 1, "foo": 2, "FOO": 3});
2780 let result = apply_all(
2781 record,
2782 &compiled(&[RecordTransform::KeysCase {
2783 mode: KeyCaseMode::Snake,
2784 on_collision: KeyCollision::Suffix,
2785 }]),
2786 );
2787 let obj = result.as_object().unwrap();
2788 let keys: std::collections::BTreeSet<&str> = obj.keys().map(String::as_str).collect();
2789 assert_eq!(
2790 keys,
2791 ["foo", "foo_2", "foo_3"].into_iter().collect(),
2792 "got {keys:?}"
2793 );
2794 let vals: std::collections::BTreeSet<i64> =
2795 obj.values().filter_map(Value::as_i64).collect();
2796 assert_eq!(vals, [1, 2, 3].into_iter().collect());
2797 }
2798
2799 #[cfg(feature = "transform-keys-case")]
2800 #[test]
2801 fn keys_case_collision_suffix_is_order_stable() {
2802 let record = json!({"AccountId": 1, "account_id": 2, "Account_Id": 3});
2803 let run = || {
2804 apply_all(
2805 record.clone(),
2806 &compiled(&[RecordTransform::KeysCase {
2807 mode: KeyCaseMode::Snake,
2808 on_collision: KeyCollision::Suffix,
2809 }]),
2810 )
2811 };
2812 assert_eq!(run(), run());
2814 }
2815
2816 #[cfg(feature = "transform-select")]
2819 #[test]
2820 fn select_keeps_only_listed_fields() {
2821 let record = json!({"id": 1, "name": "Alice", "secret": "drop"});
2822 let result = apply_all(
2823 record,
2824 &compiled(&[RecordTransform::Select {
2825 fields: vec!["id".into(), "name".into()],
2826 }]),
2827 );
2828 assert_eq!(result["id"], 1);
2829 assert_eq!(result["name"], "Alice");
2830 assert!(result.get("secret").is_none());
2831 }
2832
2833 #[cfg(feature = "transform-select")]
2834 #[test]
2835 fn select_missing_field_is_no_op() {
2836 let record = json!({"id": 1});
2838 let result = apply_all(
2839 record,
2840 &compiled(&[RecordTransform::Select {
2841 fields: vec!["id".into(), "missing".into()],
2842 }]),
2843 );
2844 assert_eq!(result["id"], 1);
2845 assert!(result.get("missing").is_none());
2846 }
2847
2848 #[cfg(feature = "transform-select")]
2849 #[test]
2850 fn select_passes_through_non_object() {
2851 let record = json!([1, 2, 3]);
2852 let result = apply_all(
2853 record.clone(),
2854 &compiled(&[RecordTransform::Select {
2855 fields: vec!["id".into()],
2856 }]),
2857 );
2858 assert_eq!(result, record);
2859 }
2860
2861 #[cfg(feature = "transform-drop")]
2864 #[test]
2865 fn drop_removes_listed_fields() {
2866 let record = json!({"id": 1, "ssn": "111-22-3333", "name": "Alice"});
2867 let result = apply_all(
2868 record,
2869 &compiled(&[RecordTransform::Drop {
2870 fields: vec!["ssn".into()],
2871 }]),
2872 );
2873 assert_eq!(result["id"], 1);
2874 assert_eq!(result["name"], "Alice");
2875 assert!(result.get("ssn").is_none());
2876 }
2877
2878 #[cfg(feature = "transform-drop")]
2879 #[test]
2880 fn drop_missing_field_is_no_op() {
2881 let record = json!({"id": 1});
2882 let result = apply_all(
2883 record,
2884 &compiled(&[RecordTransform::Drop {
2885 fields: vec!["missing".into()],
2886 }]),
2887 );
2888 assert_eq!(result["id"], 1);
2889 }
2890
2891 #[cfg(feature = "transform-set")]
2894 #[test]
2895 fn set_inserts_new_fields() {
2896 let record = json!({"id": 1});
2897 let mut values = Map::new();
2898 values.insert("_source".into(), json!("api"));
2899 values.insert("ingested_at".into(), json!("2026-01-01"));
2900 let result = apply_all(record, &compiled(&[RecordTransform::Set { values }]));
2901 assert_eq!(result["id"], 1);
2902 assert_eq!(result["_source"], "api");
2903 assert_eq!(result["ingested_at"], "2026-01-01");
2904 }
2905
2906 #[cfg(feature = "transform-set")]
2907 #[test]
2908 fn set_overwrites_existing_field() {
2909 let record = json!({"_source": "old", "id": 1});
2910 let mut values = Map::new();
2911 values.insert("_source".into(), json!("new"));
2912 let result = apply_all(record, &compiled(&[RecordTransform::Set { values }]));
2913 assert_eq!(result["_source"], "new");
2914 assert_eq!(result["id"], 1);
2915 }
2916
2917 #[cfg(feature = "transform-set")]
2918 #[test]
2919 fn set_supports_any_json_value() {
2920 let record = json!({});
2921 let mut values = Map::new();
2922 values.insert("n".into(), json!(42));
2923 values.insert("b".into(), json!(true));
2924 values.insert("arr".into(), json!([1, 2]));
2925 values.insert("obj".into(), json!({"k": "v"}));
2926 values.insert("null".into(), Value::Null);
2927 let result = apply_all(record, &compiled(&[RecordTransform::Set { values }]));
2928 assert_eq!(result["n"], 42);
2929 assert_eq!(result["b"], true);
2930 assert_eq!(result["arr"], json!([1, 2]));
2931 assert_eq!(result["obj"]["k"], "v");
2932 assert_eq!(result["null"], Value::Null);
2933 }
2934
2935 #[cfg(feature = "transform-rename-field")]
2938 #[test]
2939 fn rename_field_renames_exact_key() {
2940 let record = json!({"old_name": 1, "keep": 2});
2941 let mut fields = HashMap::new();
2942 fields.insert("old_name".to_owned(), "new_name".to_owned());
2943 let result = apply_all(
2944 record,
2945 &compiled(&[RecordTransform::RenameField { fields }]),
2946 );
2947 assert_eq!(result["new_name"], 1);
2948 assert_eq!(result["keep"], 2);
2949 assert!(result.get("old_name").is_none());
2950 }
2951
2952 #[cfg(feature = "transform-rename-field")]
2953 #[test]
2954 fn rename_field_missing_source_is_no_op() {
2955 let record = json!({"id": 1});
2956 let mut fields = HashMap::new();
2957 fields.insert("missing".to_owned(), "renamed".to_owned());
2958 let result = apply_all(
2959 record,
2960 &compiled(&[RecordTransform::RenameField { fields }]),
2961 );
2962 assert_eq!(result["id"], 1);
2963 assert!(result.get("renamed").is_none());
2964 }
2965
2966 #[cfg(feature = "transform-rename-field")]
2967 #[test]
2968 fn rename_field_target_collision_errors() {
2969 let record = json!({"a": 1, "b": 2});
2970 let mut fields = HashMap::new();
2971 fields.insert("a".to_owned(), "b".to_owned());
2972 let err = super::apply_all(
2973 record,
2974 &compiled(&[RecordTransform::RenameField { fields }]),
2975 )
2976 .expect_err("collision must error, not overwrite");
2977 assert!(matches!(err, FaucetError::Transform(_)));
2978 assert!(format!("{err}").contains("'b'"), "{err}");
2979 }
2980
2981 #[cfg(feature = "transform-rename-field")]
2982 #[test]
2983 fn rename_field_swap_is_deterministic() {
2984 for _ in 0..50 {
2987 let record = json!({"a": 1, "b": 2, "keep": 3});
2988 let mut fields = HashMap::new();
2989 fields.insert("a".to_owned(), "b".to_owned());
2990 fields.insert("b".to_owned(), "a".to_owned());
2991 let result = apply_all(
2992 record,
2993 &compiled(&[RecordTransform::RenameField { fields }]),
2994 );
2995 assert_eq!(result["a"], 2, "{result}");
2996 assert_eq!(result["b"], 1, "{result}");
2997 assert_eq!(result["keep"], 3);
2998 }
2999 }
3000
3001 #[cfg(feature = "transform-rename-field")]
3002 #[test]
3003 fn rename_field_chain_applies_against_original_snapshot() {
3004 for _ in 0..50 {
3007 let record = json!({"a": 1, "b": 2});
3008 let mut fields = HashMap::new();
3009 fields.insert("a".to_owned(), "b".to_owned());
3010 fields.insert("b".to_owned(), "c".to_owned());
3011 let result = apply_all(
3012 record,
3013 &compiled(&[RecordTransform::RenameField { fields }]),
3014 );
3015 assert_eq!(result["b"], 1, "{result}");
3016 assert_eq!(result["c"], 2, "{result}");
3017 assert!(result.get("a").is_none(), "{result}");
3018 }
3019 }
3020
3021 #[cfg(feature = "transform-rename-field")]
3022 #[test]
3023 fn rename_field_two_sources_one_target_errors() {
3024 let record = json!({"a": 1, "b": 2});
3025 let mut fields = HashMap::new();
3026 fields.insert("a".to_owned(), "c".to_owned());
3027 fields.insert("b".to_owned(), "c".to_owned());
3028 let err = super::apply_all(
3029 record,
3030 &compiled(&[RecordTransform::RenameField { fields }]),
3031 )
3032 .expect_err("two renames to the same target must error");
3033 assert!(format!("{err}").contains("same target"), "{err}");
3034 }
3035
3036 #[cfg(feature = "transform-cast")]
3039 fn cast_specs(field: &str, ty: CastType, on_error: CastOnError) -> Vec<RecordTransform> {
3040 let mut fields = HashMap::new();
3041 fields.insert(field.to_owned(), ty);
3042 vec![RecordTransform::Cast { fields, on_error }]
3043 }
3044
3045 #[cfg(feature = "transform-cast")]
3046 #[test]
3047 fn cast_string_to_int() {
3048 let record = json!({"age": "42"});
3049 let result = apply_all(
3050 record,
3051 &compiled(&cast_specs("age", CastType::Int, CastOnError::Error)),
3052 );
3053 assert_eq!(result["age"], 42);
3054 }
3055
3056 #[cfg(feature = "transform-cast")]
3057 #[test]
3058 fn cast_whole_number_float_to_int_succeeds() {
3059 let record = json!({"n": 5.0});
3061 let result = apply_all(
3062 record,
3063 &compiled(&cast_specs("n", CastType::Int, CastOnError::Error)),
3064 );
3065 assert_eq!(result["n"], 5);
3066 }
3067
3068 #[cfg(feature = "transform-cast")]
3069 #[test]
3070 fn cast_fractional_float_to_int_errors_under_on_error_error() {
3071 let record = json!({"n": 3.9});
3073 let err = super::apply_all(
3074 record,
3075 &compiled(&cast_specs("n", CastType::Int, CastOnError::Error)),
3076 )
3077 .expect_err("a fractional float must not silently truncate to int");
3078 assert!(matches!(err, FaucetError::Transform(_)), "{err}");
3079 }
3080
3081 #[cfg(feature = "transform-cast")]
3082 #[test]
3083 fn cast_out_of_range_float_to_int_errors_under_on_error_error() {
3084 let record = json!({"n": 1e30});
3086 let err = super::apply_all(
3087 record,
3088 &compiled(&cast_specs("n", CastType::Int, CastOnError::Error)),
3089 )
3090 .expect_err("an out-of-range float must not silently saturate to i64::MAX");
3091 assert!(matches!(err, FaucetError::Transform(_)), "{err}");
3092 }
3093
3094 #[cfg(feature = "transform-cast")]
3095 #[test]
3096 fn cast_fractional_float_to_int_nulls_under_on_error_null() {
3097 let record = json!({"n": 3.9});
3098 let result = apply_all(
3099 record,
3100 &compiled(&cast_specs("n", CastType::Int, CastOnError::Null)),
3101 );
3102 assert_eq!(result["n"], Value::Null);
3103 }
3104
3105 #[cfg(feature = "transform-cast")]
3106 #[test]
3107 fn cast_string_to_float() {
3108 let record = json!({"price": "9.99"});
3109 let result = apply_all(
3110 record,
3111 &compiled(&cast_specs("price", CastType::Float, CastOnError::Error)),
3112 );
3113 assert_eq!(result["price"], 9.99);
3114 }
3115
3116 #[cfg(feature = "transform-cast")]
3117 #[test]
3118 fn cast_string_to_bool() {
3119 for input in ["true", "TRUE", "1", "yes"] {
3120 let record = json!({"flag": input});
3121 let result = apply_all(
3122 record,
3123 &compiled(&cast_specs("flag", CastType::Bool, CastOnError::Error)),
3124 );
3125 assert_eq!(result["flag"], true, "input was {input:?}");
3126 }
3127 for input in ["false", "0", "no"] {
3128 let record = json!({"flag": input});
3129 let result = apply_all(
3130 record,
3131 &compiled(&cast_specs("flag", CastType::Bool, CastOnError::Error)),
3132 );
3133 assert_eq!(result["flag"], false, "input was {input:?}");
3134 }
3135 }
3136
3137 #[cfg(feature = "transform-cast")]
3138 #[test]
3139 fn cast_number_to_string() {
3140 let record = json!({"id": 42});
3141 let result = apply_all(
3142 record,
3143 &compiled(&cast_specs("id", CastType::String, CastOnError::Error)),
3144 );
3145 assert_eq!(result["id"], "42");
3146 }
3147
3148 #[cfg(feature = "transform-cast")]
3149 #[test]
3150 fn cast_string_to_timestamp_normalises() {
3151 let record = json!({"ts": "2026-05-28T12:34:56+00:00"});
3152 let result = apply_all(
3153 record,
3154 &compiled(&cast_specs("ts", CastType::Timestamp, CastOnError::Error)),
3155 );
3156 assert_eq!(result["ts"], "2026-05-28T12:34:56Z");
3158 }
3159
3160 #[cfg(feature = "transform-cast")]
3161 #[test]
3162 fn cast_on_error_error_propagates() {
3163 let record = json!({"age": "not a number"});
3164 let err = super::apply_all(
3165 record,
3166 &compiled(&cast_specs("age", CastType::Int, CastOnError::Error)),
3167 )
3168 .expect_err("uncastable value must error under on_error=error");
3169 assert!(matches!(err, FaucetError::Transform(_)));
3170 assert!(format!("{err}").contains("'age'"), "{err}");
3171 }
3172
3173 #[cfg(feature = "transform-cast")]
3174 #[test]
3175 fn cast_on_error_null_replaces() {
3176 let record = json!({"age": "not a number"});
3177 let result = apply_all(
3178 record,
3179 &compiled(&cast_specs("age", CastType::Int, CastOnError::Null)),
3180 );
3181 assert_eq!(result["age"], Value::Null);
3182 }
3183
3184 #[cfg(feature = "transform-cast")]
3185 #[test]
3186 fn cast_on_error_skip_leaves_value() {
3187 let record = json!({"age": "not a number"});
3188 let result = apply_all(
3189 record,
3190 &compiled(&cast_specs("age", CastType::Int, CastOnError::Skip)),
3191 );
3192 assert_eq!(result["age"], "not a number");
3193 }
3194
3195 #[cfg(feature = "transform-cast")]
3196 #[test]
3197 fn cast_missing_field_is_no_op() {
3198 let record = json!({"id": 1});
3199 let result = apply_all(
3200 record,
3201 &compiled(&cast_specs("missing", CastType::Int, CastOnError::Error)),
3202 );
3203 assert_eq!(result["id"], 1);
3204 assert!(result.get("missing").is_none());
3205 }
3206
3207 #[cfg(feature = "transform-redact")]
3210 #[test]
3211 fn redact_replaces_value_with_mask() {
3212 let record = json!({"id": 1, "ssn": "111-22-3333", "email": "x@y.z"});
3213 let result = apply_all(
3214 record,
3215 &compiled(&[RecordTransform::Redact {
3216 fields: vec!["ssn".into(), "email".into()],
3217 mask: json!("***"),
3218 }]),
3219 );
3220 assert_eq!(result["id"], 1);
3221 assert_eq!(result["ssn"], "***");
3222 assert_eq!(result["email"], "***");
3223 }
3224
3225 #[cfg(feature = "transform-redact")]
3226 #[test]
3227 fn redact_missing_field_does_not_insert_mask() {
3228 let record = json!({"id": 1});
3229 let result = apply_all(
3230 record,
3231 &compiled(&[RecordTransform::Redact {
3232 fields: vec!["ssn".into()],
3233 mask: json!("***"),
3234 }]),
3235 );
3236 assert_eq!(result["id"], 1);
3237 assert!(result.get("ssn").is_none());
3238 }
3239
3240 #[cfg(feature = "transform-value-case")]
3243 #[test]
3244 fn value_case_lower() {
3245 let record = json!({"email": "User@Example.COM", "id": 1});
3246 let result = apply_all(
3247 record,
3248 &compiled(&[RecordTransform::ValueCase {
3249 fields: vec!["email".into()],
3250 mode: ValueCaseMode::Lower,
3251 }]),
3252 );
3253 assert_eq!(result["email"], "user@example.com");
3254 assert_eq!(result["id"], 1);
3255 }
3256
3257 #[cfg(feature = "transform-value-case")]
3258 #[test]
3259 fn value_case_upper() {
3260 let record = json!({"code": "abc"});
3261 let result = apply_all(
3262 record,
3263 &compiled(&[RecordTransform::ValueCase {
3264 fields: vec!["code".into()],
3265 mode: ValueCaseMode::Upper,
3266 }]),
3267 );
3268 assert_eq!(result["code"], "ABC");
3269 }
3270
3271 #[cfg(feature = "transform-value-case")]
3272 #[test]
3273 fn value_case_trim() {
3274 let record = json!({"name": " Alice "});
3275 let result = apply_all(
3276 record,
3277 &compiled(&[RecordTransform::ValueCase {
3278 fields: vec!["name".into()],
3279 mode: ValueCaseMode::Trim,
3280 }]),
3281 );
3282 assert_eq!(result["name"], "Alice");
3283 }
3284
3285 #[cfg(feature = "transform-value-case")]
3286 #[test]
3287 fn value_case_passes_non_string_through() {
3288 let record = json!({"id": 42});
3289 let result = apply_all(
3290 record,
3291 &compiled(&[RecordTransform::ValueCase {
3292 fields: vec!["id".into()],
3293 mode: ValueCaseMode::Upper,
3294 }]),
3295 );
3296 assert_eq!(result["id"], 42);
3297 }
3298
3299 #[cfg(feature = "transform-spell-symbols")]
3302 fn spell_default() -> Vec<RecordTransform> {
3303 vec![RecordTransform::SpellSymbols {
3304 extra: HashMap::new(),
3305 separator: " ".into(),
3306 }]
3307 }
3308
3309 #[cfg(feature = "transform-spell-symbols")]
3310 #[test]
3311 fn spell_symbols_replaces_common_symbols() {
3312 let record = json!({"%sold": 1, "C#course": 2, "$amount": 3});
3313 let result = apply_all(record, &compiled(&spell_default()));
3314 assert!(result.get(" percent sold").is_some());
3317 assert!(result.get("C number course").is_some());
3318 assert!(result.get(" dollar amount").is_some());
3319 }
3320
3321 #[cfg(all(feature = "transform-spell-symbols", feature = "transform-keys-case"))]
3322 #[test]
3323 fn spell_symbols_then_keys_case_pipeline() {
3324 let record = json!({"% sold": 10, "C# courses": 20});
3325 let result = super::apply_all(
3326 record,
3327 &compiled(&[
3328 RecordTransform::SpellSymbols {
3329 extra: HashMap::new(),
3330 separator: " ".into(),
3331 },
3332 RecordTransform::KeysCase {
3333 mode: KeyCaseMode::Snake,
3334 on_collision: KeyCollision::Error,
3335 },
3336 ]),
3337 )
3338 .expect("pipeline must succeed");
3339 assert_eq!(result["percent_sold"], 10);
3340 assert_eq!(result["c_number_courses"], 20);
3341 }
3342
3343 #[cfg(feature = "transform-spell-symbols")]
3344 #[test]
3345 fn spell_symbols_extra_overrides_defaults() {
3346 let mut extra = HashMap::new();
3347 extra.insert("#".to_owned(), "hash".to_owned());
3348 extra.insert("©".to_owned(), "copyright".to_owned());
3349 let record = json!({"#tag": 1, "©2026": 2});
3350 let result = apply_all(
3351 record,
3352 &compiled(&[RecordTransform::SpellSymbols {
3353 extra,
3354 separator: " ".into(),
3355 }]),
3356 );
3357 assert!(result.get(" hash tag").is_some());
3359 assert!(result.get(" copyright 2026").is_some());
3361 }
3362
3363 #[cfg(feature = "transform-spell-symbols")]
3364 #[test]
3365 fn spell_symbols_longest_match_wins() {
3366 let mut extra = HashMap::new();
3368 extra.insert("<=".to_owned(), "lte".to_owned());
3369 let record = json!({"a<=b": 1});
3370 let result = apply_all(
3371 record,
3372 &compiled(&[RecordTransform::SpellSymbols {
3373 extra,
3374 separator: " ".into(),
3375 }]),
3376 );
3377 assert!(result.get("a lte b").is_some());
3378 assert!(result.get("a lt = b").is_none());
3380 }
3381
3382 #[cfg(feature = "transform-spell-symbols")]
3383 #[test]
3384 fn spell_symbols_recursive_into_objects_and_arrays() {
3385 let record = json!({"outer&": {"inner%": [{"deep#": 1}]}});
3386 let result = apply_all(record, &compiled(&spell_default()));
3387 let outer_key = result.as_object().unwrap().keys().next().unwrap().clone();
3388 assert!(outer_key.contains("and"), "outer key was {outer_key:?}");
3389 let inner = &result[&outer_key];
3390 let inner_key = inner.as_object().unwrap().keys().next().unwrap().clone();
3391 assert!(inner_key.contains("percent"), "inner key was {inner_key:?}");
3392 let deep = &inner[&inner_key][0];
3393 let deep_key = deep.as_object().unwrap().keys().next().unwrap().clone();
3394 assert!(deep_key.contains("number"), "deep key was {deep_key:?}");
3395 }
3396
3397 #[cfg(feature = "transform-spell-symbols")]
3398 #[test]
3399 fn spell_symbols_key_collision_errors() {
3400 let record = json!({"%": 1, "percent": 2});
3402 let err = super::apply_all(
3403 record,
3404 &compiled(&[RecordTransform::SpellSymbols {
3405 extra: HashMap::new(),
3406 separator: "".into(),
3407 }]),
3408 )
3409 .expect_err("colliding spelled keys must error, not drop a value");
3410 assert!(matches!(err, FaucetError::Transform(_)));
3411 assert!(format!("{err}").contains("percent"), "{err}");
3412 }
3413
3414 #[cfg(feature = "transform-keys-case")]
3417 fn keys_case_specs(mode: KeyCaseMode) -> Vec<RecordTransform> {
3418 vec![RecordTransform::KeysCase {
3419 mode,
3420 on_collision: KeyCollision::Error,
3421 }]
3422 }
3423
3424 #[cfg(feature = "transform-keys-case")]
3425 #[test]
3426 fn keys_case_snake() {
3427 let record = json!({"First Name": 1, "last-name": 2, "ID": 3});
3428 let result = apply_all(record, &compiled(&keys_case_specs(KeyCaseMode::Snake)));
3429 assert_eq!(result["first_name"], 1);
3430 assert_eq!(result["last_name"], 2);
3431 assert_eq!(result["id"], 3);
3432 }
3433
3434 #[cfg(feature = "transform-keys-case")]
3435 #[test]
3436 fn keys_case_camel_from_various_inputs() {
3437 let record = json!({"first_name": 1, "User ID": 2, "kebab-case": 3, "PascalCase": 4});
3439 let result = apply_all(record, &compiled(&keys_case_specs(KeyCaseMode::Camel)));
3440 assert_eq!(result["firstName"], 1);
3441 assert_eq!(result["userId"], 2);
3442 assert_eq!(result["kebabCase"], 3);
3443 assert_eq!(result["pascalCase"], 4);
3444 }
3445
3446 #[cfg(feature = "transform-keys-case")]
3447 #[test]
3448 fn keys_case_pascal() {
3449 let record = json!({"first_name": 1, "second name": 2});
3450 let result = apply_all(record, &compiled(&keys_case_specs(KeyCaseMode::Pascal)));
3451 assert_eq!(result["FirstName"], 1);
3452 assert_eq!(result["SecondName"], 2);
3453 }
3454
3455 #[cfg(feature = "transform-keys-case")]
3456 #[test]
3457 fn keys_case_kebab() {
3458 let record = json!({"firstName": 1, "second_name": 2});
3459 let result = apply_all(record, &compiled(&keys_case_specs(KeyCaseMode::Kebab)));
3460 assert_eq!(result["first-name"], 1);
3461 assert_eq!(result["second-name"], 2);
3462 }
3463
3464 #[cfg(feature = "transform-keys-case")]
3465 #[test]
3466 fn keys_case_screaming_snake() {
3467 let record = json!({"firstName": 1, "second name": 2});
3468 let result = apply_all(
3469 record,
3470 &compiled(&keys_case_specs(KeyCaseMode::ScreamingSnake)),
3471 );
3472 assert_eq!(result["FIRST_NAME"], 1);
3473 assert_eq!(result["SECOND_NAME"], 2);
3474 }
3475
3476 #[cfg(feature = "transform-keys-case")]
3477 #[test]
3478 fn keys_case_recursive_into_nested() {
3479 let record = json!({"User Info": {"First Name": "Alice", "items": [{"Tag Name": "x"}]}});
3480 let result = apply_all(record, &compiled(&keys_case_specs(KeyCaseMode::Snake)));
3481 assert_eq!(result["user_info"]["first_name"], "Alice");
3482 assert_eq!(result["user_info"]["items"][0]["tag_name"], "x");
3483 }
3484
3485 #[cfg(feature = "transform-keys-case")]
3486 #[test]
3487 fn keys_case_collision_errors() {
3488 let record = json!({"firstName": 1, "first_name": 2});
3490 let err = super::apply_all(record, &compiled(&keys_case_specs(KeyCaseMode::Snake)))
3491 .expect_err("colliding re-cased keys must error, not drop a value");
3492 assert!(matches!(err, FaucetError::Transform(_)));
3493 assert!(format!("{err}").contains("first_name"), "{err}");
3494 }
3495
3496 #[cfg(feature = "transform-keys-case")]
3497 #[test]
3498 fn keys_case_all_symbol_key_kept_as_is() {
3499 let record = json!({"!@#": 1, "id": 2});
3502 let result = apply_all(record, &compiled(&keys_case_specs(KeyCaseMode::Snake)));
3503 assert_eq!(result["!@#"], 1);
3504 assert_eq!(result["id"], 2);
3505 }
3506
3507 #[cfg(feature = "transform-keys-case")]
3508 #[test]
3509 fn keys_case_idempotent_in_target_mode() {
3510 let record = json!({"first_name": 1});
3513 let once = apply_all(record, &compiled(&keys_case_specs(KeyCaseMode::Snake)));
3514 let twice = apply_all(
3515 once.clone(),
3516 &compiled(&keys_case_specs(KeyCaseMode::Snake)),
3517 );
3518 assert_eq!(once, twice);
3519 }
3520
3521 #[cfg(feature = "transform-spell-symbols")]
3522 #[test]
3523 fn spell_symbols_handles_unicode_keys() {
3524 let record = json!({"café%": 1});
3526 let result = apply_all(record, &compiled(&spell_default()));
3527 let key = result.as_object().unwrap().keys().next().unwrap().clone();
3528 assert!(key.contains("café"), "key was {key:?}");
3529 assert!(key.contains("percent"), "key was {key:?}");
3530 }
3531
3532 #[test]
3535 fn debug_record_transform_all_variants() {
3536 let dbg = format!("{:?}", RecordTransform::custom(|v| v));
3538 assert_eq!(dbg, "Custom(<fn>)");
3539
3540 #[cfg(feature = "transform-flatten")]
3541 {
3542 let dbg = format!(
3543 "{:?}",
3544 RecordTransform::Flatten {
3545 separator: "__".into()
3546 }
3547 );
3548 assert!(dbg.starts_with("Flatten"), "{dbg}");
3549 assert!(dbg.contains("separator"), "{dbg}");
3550 assert!(dbg.contains("__"), "{dbg}");
3551 }
3552 #[cfg(feature = "transform-rename-keys")]
3553 {
3554 let dbg = format!(
3555 "{:?}",
3556 RecordTransform::RenameKeys {
3557 pattern: "p".into(),
3558 replacement: "r".into(),
3559 }
3560 );
3561 assert!(dbg.starts_with("RenameKeys"), "{dbg}");
3562 assert!(dbg.contains("pattern"), "{dbg}");
3563 assert!(dbg.contains("replacement"), "{dbg}");
3564 }
3565 #[cfg(feature = "transform-keys-case")]
3566 {
3567 let dbg = format!(
3568 "{:?}",
3569 RecordTransform::KeysCase {
3570 mode: KeyCaseMode::Snake,
3571 on_collision: Default::default(),
3572 }
3573 );
3574 assert!(dbg.starts_with("KeysCase"), "{dbg}");
3575 assert!(dbg.contains("Snake"), "{dbg}");
3576 }
3577 #[cfg(feature = "transform-select")]
3578 {
3579 let dbg = format!(
3580 "{:?}",
3581 RecordTransform::Select {
3582 fields: vec!["a".into()]
3583 }
3584 );
3585 assert!(dbg.starts_with("Select"), "{dbg}");
3586 assert!(dbg.contains("fields"), "{dbg}");
3587 }
3588 #[cfg(feature = "transform-drop")]
3589 {
3590 let dbg = format!(
3591 "{:?}",
3592 RecordTransform::Drop {
3593 fields: vec!["a".into()]
3594 }
3595 );
3596 assert!(dbg.starts_with("Drop"), "{dbg}");
3597 }
3598 #[cfg(feature = "transform-set")]
3599 {
3600 let mut values = Map::new();
3601 values.insert("k".into(), json!("v"));
3602 let dbg = format!("{:?}", RecordTransform::Set { values });
3603 assert!(dbg.starts_with("Set"), "{dbg}");
3604 assert!(dbg.contains("values"), "{dbg}");
3605 }
3606 #[cfg(feature = "transform-rename-field")]
3607 {
3608 let mut fields = HashMap::new();
3609 fields.insert("a".to_owned(), "b".to_owned());
3610 let dbg = format!("{:?}", RecordTransform::RenameField { fields });
3611 assert!(dbg.starts_with("RenameField"), "{dbg}");
3612 }
3613 #[cfg(feature = "transform-cast")]
3614 {
3615 let mut fields = HashMap::new();
3616 fields.insert("a".to_owned(), CastType::Int);
3617 let dbg = format!(
3618 "{:?}",
3619 RecordTransform::Cast {
3620 fields,
3621 on_error: CastOnError::Error,
3622 }
3623 );
3624 assert!(dbg.starts_with("Cast"), "{dbg}");
3625 assert!(dbg.contains("on_error"), "{dbg}");
3626 }
3627 #[cfg(feature = "transform-redact")]
3628 {
3629 let dbg = format!(
3630 "{:?}",
3631 RecordTransform::Redact {
3632 fields: vec!["a".into()],
3633 mask: json!("***"),
3634 }
3635 );
3636 assert!(dbg.starts_with("Redact"), "{dbg}");
3637 assert!(dbg.contains("mask"), "{dbg}");
3638 }
3639 #[cfg(feature = "transform-value-case")]
3640 {
3641 let dbg = format!(
3642 "{:?}",
3643 RecordTransform::ValueCase {
3644 fields: vec!["a".into()],
3645 mode: ValueCaseMode::Lower,
3646 }
3647 );
3648 assert!(dbg.starts_with("ValueCase"), "{dbg}");
3649 assert!(dbg.contains("mode"), "{dbg}");
3650 }
3651 #[cfg(feature = "transform-spell-symbols")]
3652 {
3653 let dbg = format!(
3654 "{:?}",
3655 RecordTransform::SpellSymbols {
3656 extra: HashMap::new(),
3657 separator: " ".into(),
3658 }
3659 );
3660 assert!(dbg.starts_with("SpellSymbols"), "{dbg}");
3661 assert!(dbg.contains("separator"), "{dbg}");
3662 }
3663 #[cfg(feature = "transform-hash")]
3664 {
3665 let dbg = format!(
3666 "{:?}",
3667 RecordTransform::Hash {
3668 fields: vec!["a".into()],
3669 algorithm: HashAlgorithm::Blake3,
3670 encoding: HashEncoding::Base64,
3671 salt: Some("s".into()),
3672 into: Some("a_hash".into()),
3673 }
3674 );
3675 assert!(dbg.starts_with("Hash"), "{dbg}");
3676 assert!(dbg.contains("algorithm"), "{dbg}");
3677 assert!(dbg.contains("encoding"), "{dbg}");
3678 }
3679 #[cfg(feature = "transform-json-parse")]
3680 {
3681 let dbg = format!(
3682 "{:?}",
3683 RecordTransform::JsonParse {
3684 fields: vec!["a".into()],
3685 on_error: JsonParseOnError::Null,
3686 into: None,
3687 }
3688 );
3689 assert!(dbg.starts_with("JsonParse"), "{dbg}");
3690 assert!(dbg.contains("on_error"), "{dbg}");
3691 }
3692 #[cfg(feature = "transform-coalesce")]
3693 {
3694 let dbg = format!(
3695 "{:?}",
3696 RecordTransform::Coalesce {
3697 field: "a".into(),
3698 default: Some(json!("x")),
3699 from: vec![],
3700 treat_empty_string_as_null: true,
3701 }
3702 );
3703 assert!(dbg.starts_with("Coalesce"), "{dbg}");
3704 assert!(dbg.contains("treat_empty_string_as_null"), "{dbg}");
3705 }
3706 #[cfg(feature = "transform-split-join")]
3707 {
3708 let dbg = format!(
3709 "{:?}",
3710 RecordTransform::Split {
3711 field: "a".into(),
3712 delimiter: ",".into(),
3713 trim: true,
3714 into: None,
3715 }
3716 );
3717 assert!(dbg.starts_with("Split"), "{dbg}");
3718 assert!(dbg.contains("delimiter"), "{dbg}");
3719 let dbg = format!(
3720 "{:?}",
3721 RecordTransform::Join {
3722 field: "a".into(),
3723 delimiter: ",".into(),
3724 into: None,
3725 }
3726 );
3727 assert!(dbg.starts_with("Join"), "{dbg}");
3728 }
3729 }
3730
3731 #[test]
3734 fn clone_record_transform_custom_preserves_behaviour() {
3735 let original = RecordTransform::custom(|mut v| {
3736 if let Value::Object(ref mut m) = v {
3737 m.insert("cloned".into(), json!(true));
3738 }
3739 v
3740 });
3741 let cloned = original.clone();
3742 assert_eq!(format!("{cloned:?}"), "Custom(<fn>)");
3743 let out = apply_all(json!({"id": 1}), &compiled(&[cloned]));
3744 assert_eq!(out["cloned"], true);
3745 assert_eq!(out["id"], 1);
3746 }
3747
3748 #[test]
3749 #[allow(clippy::vec_init_then_push)]
3752 fn clone_record_transform_all_builtin_variants() {
3753 let mut variants: Vec<RecordTransform> = Vec::new();
3754 #[cfg(feature = "transform-flatten")]
3755 variants.push(RecordTransform::Flatten {
3756 separator: "__".into(),
3757 });
3758 #[cfg(feature = "transform-rename-keys")]
3759 variants.push(RecordTransform::RenameKeys {
3760 pattern: "p".into(),
3761 replacement: "r".into(),
3762 });
3763 #[cfg(feature = "transform-keys-case")]
3764 variants.push(RecordTransform::KeysCase {
3765 mode: KeyCaseMode::Snake,
3766 on_collision: KeyCollision::Error,
3767 });
3768 #[cfg(feature = "transform-select")]
3769 variants.push(RecordTransform::Select {
3770 fields: vec!["a".into()],
3771 });
3772 #[cfg(feature = "transform-drop")]
3773 variants.push(RecordTransform::Drop {
3774 fields: vec!["a".into()],
3775 });
3776 #[cfg(feature = "transform-set")]
3777 {
3778 let mut values = Map::new();
3779 values.insert("k".into(), json!("v"));
3780 variants.push(RecordTransform::Set { values });
3781 }
3782 #[cfg(feature = "transform-rename-field")]
3783 {
3784 let mut fields = HashMap::new();
3785 fields.insert("a".to_owned(), "b".to_owned());
3786 variants.push(RecordTransform::RenameField { fields });
3787 }
3788 #[cfg(feature = "transform-cast")]
3789 {
3790 let mut fields = HashMap::new();
3791 fields.insert("a".to_owned(), CastType::Int);
3792 variants.push(RecordTransform::Cast {
3793 fields,
3794 on_error: CastOnError::Error,
3795 });
3796 }
3797 #[cfg(feature = "transform-redact")]
3798 variants.push(RecordTransform::Redact {
3799 fields: vec!["a".into()],
3800 mask: json!("***"),
3801 });
3802 #[cfg(feature = "transform-value-case")]
3803 variants.push(RecordTransform::ValueCase {
3804 fields: vec!["a".into()],
3805 mode: ValueCaseMode::Lower,
3806 });
3807 #[cfg(feature = "transform-spell-symbols")]
3808 variants.push(RecordTransform::SpellSymbols {
3809 extra: HashMap::new(),
3810 separator: " ".into(),
3811 });
3812 #[cfg(feature = "transform-hash")]
3813 variants.push(RecordTransform::Hash {
3814 fields: vec!["a".into()],
3815 algorithm: HashAlgorithm::Sha256,
3816 encoding: HashEncoding::Hex,
3817 salt: Some("s".into()),
3818 into: None,
3819 });
3820 #[cfg(feature = "transform-json-parse")]
3821 variants.push(RecordTransform::JsonParse {
3822 fields: vec!["a".into()],
3823 on_error: JsonParseOnError::Keep,
3824 into: Some("b".into()),
3825 });
3826 #[cfg(feature = "transform-coalesce")]
3827 variants.push(RecordTransform::Coalesce {
3828 field: "a".into(),
3829 default: None,
3830 from: vec!["b".into()],
3831 treat_empty_string_as_null: false,
3832 });
3833 #[cfg(feature = "transform-split-join")]
3834 {
3835 variants.push(RecordTransform::Split {
3836 field: "a".into(),
3837 delimiter: ",".into(),
3838 trim: true,
3839 into: None,
3840 });
3841 variants.push(RecordTransform::Join {
3842 field: "a".into(),
3843 delimiter: ",".into(),
3844 into: Some("b".into()),
3845 });
3846 }
3847
3848 for v in &variants {
3850 let cloned = v.clone();
3851 assert_eq!(format!("{v:?}"), format!("{cloned:?}"));
3852 }
3853 }
3854
3855 #[test]
3856 fn clone_compiled_transform_all_variants() {
3857 let mut specs: Vec<RecordTransform> = vec![RecordTransform::custom(|v| v)];
3858 #[cfg(feature = "transform-flatten")]
3859 specs.push(RecordTransform::Flatten {
3860 separator: "__".into(),
3861 });
3862 #[cfg(feature = "transform-rename-keys")]
3863 specs.push(RecordTransform::RenameKeys {
3864 pattern: "p".into(),
3865 replacement: "r".into(),
3866 });
3867 #[cfg(feature = "transform-keys-case")]
3868 specs.push(RecordTransform::KeysCase {
3869 mode: KeyCaseMode::Camel,
3870 on_collision: KeyCollision::Error,
3871 });
3872 #[cfg(feature = "transform-select")]
3873 specs.push(RecordTransform::Select {
3874 fields: vec!["a".into()],
3875 });
3876 #[cfg(feature = "transform-drop")]
3877 specs.push(RecordTransform::Drop {
3878 fields: vec!["a".into()],
3879 });
3880 #[cfg(feature = "transform-set")]
3881 {
3882 let mut values = Map::new();
3883 values.insert("k".into(), json!("v"));
3884 specs.push(RecordTransform::Set { values });
3885 }
3886 #[cfg(feature = "transform-rename-field")]
3887 {
3888 let mut fields = HashMap::new();
3889 fields.insert("a".to_owned(), "b".to_owned());
3890 specs.push(RecordTransform::RenameField { fields });
3891 }
3892 #[cfg(feature = "transform-cast")]
3893 {
3894 let mut fields = HashMap::new();
3895 fields.insert("a".to_owned(), CastType::Int);
3896 specs.push(RecordTransform::Cast {
3897 fields,
3898 on_error: CastOnError::Null,
3899 });
3900 }
3901 #[cfg(feature = "transform-redact")]
3902 specs.push(RecordTransform::Redact {
3903 fields: vec!["a".into()],
3904 mask: json!("***"),
3905 });
3906 #[cfg(feature = "transform-value-case")]
3907 specs.push(RecordTransform::ValueCase {
3908 fields: vec!["a".into()],
3909 mode: ValueCaseMode::Upper,
3910 });
3911 #[cfg(feature = "transform-spell-symbols")]
3912 specs.push(RecordTransform::SpellSymbols {
3913 extra: HashMap::new(),
3914 separator: " ".into(),
3915 });
3916 #[cfg(feature = "transform-hash")]
3917 specs.push(RecordTransform::Hash {
3918 fields: vec!["a".into()],
3919 algorithm: HashAlgorithm::Blake3,
3920 encoding: HashEncoding::Base64,
3921 salt: None,
3922 into: None,
3923 });
3924 #[cfg(feature = "transform-json-parse")]
3925 specs.push(RecordTransform::JsonParse {
3926 fields: vec!["a".into()],
3927 on_error: JsonParseOnError::Error,
3928 into: None,
3929 });
3930 #[cfg(feature = "transform-coalesce")]
3931 specs.push(RecordTransform::Coalesce {
3932 field: "a".into(),
3933 default: Some(json!("x")),
3934 from: vec![],
3935 treat_empty_string_as_null: true,
3936 });
3937 #[cfg(feature = "transform-split-join")]
3938 {
3939 specs.push(RecordTransform::Split {
3940 field: "a".into(),
3941 delimiter: ",".into(),
3942 trim: false,
3943 into: None,
3944 });
3945 specs.push(RecordTransform::Join {
3946 field: "a".into(),
3947 delimiter: ",".into(),
3948 into: None,
3949 });
3950 }
3951
3952 let original = compiled(&specs);
3955 let cloned: Vec<CompiledTransform> = original.to_vec();
3956 assert_eq!(original.len(), cloned.len());
3957 let record = json!({"a": "1", "k": "x"});
3958 let out_orig = super::apply_all(record.clone(), &original);
3959 let out_clone = super::apply_all(record, &cloned);
3960 assert_eq!(
3961 out_orig.is_ok(),
3962 out_clone.is_ok(),
3963 "clone must transform identically"
3964 );
3965 if let (Ok(a), Ok(b)) = (out_orig, out_clone) {
3966 assert_eq!(a, b);
3967 }
3968 }
3969
3970 #[cfg(feature = "transform-flatten")]
3973 #[test]
3974 fn flatten_passes_through_non_object() {
3975 let record = json!([1, 2, 3]);
3976 let result = apply_all(
3977 record.clone(),
3978 &compiled(&[RecordTransform::Flatten {
3979 separator: "__".into(),
3980 }]),
3981 );
3982 assert_eq!(result, record);
3983 let scalar = json!(42);
3985 let result = apply_all(
3986 scalar.clone(),
3987 &compiled(&[RecordTransform::Flatten {
3988 separator: "__".into(),
3989 }]),
3990 );
3991 assert_eq!(result, scalar);
3992 }
3993
3994 #[cfg(feature = "transform-drop")]
3995 #[test]
3996 fn drop_passes_through_non_object() {
3997 let record = json!([1, 2]);
3998 let result = apply_all(
3999 record.clone(),
4000 &compiled(&[RecordTransform::Drop {
4001 fields: vec!["a".into()],
4002 }]),
4003 );
4004 assert_eq!(result, record);
4005 }
4006
4007 #[cfg(feature = "transform-set")]
4008 #[test]
4009 fn set_passes_through_non_object() {
4010 let mut values = Map::new();
4011 values.insert("k".into(), json!("v"));
4012 let record = json!("scalar");
4013 let result = apply_all(
4014 record.clone(),
4015 &compiled(&[RecordTransform::Set { values }]),
4016 );
4017 assert_eq!(result, record);
4018 }
4019
4020 #[cfg(feature = "transform-rename-field")]
4021 #[test]
4022 fn rename_field_passes_through_non_object() {
4023 let mut fields = HashMap::new();
4024 fields.insert("a".to_owned(), "b".to_owned());
4025 let record = json!([1, 2]);
4026 let result = apply_all(
4027 record.clone(),
4028 &compiled(&[RecordTransform::RenameField { fields }]),
4029 );
4030 assert_eq!(result, record);
4031 }
4032
4033 #[cfg(feature = "transform-rename-field")]
4034 #[test]
4035 fn rename_field_same_name_is_skipped() {
4036 let mut fields = HashMap::new();
4038 fields.insert("a".to_owned(), "a".to_owned());
4039 let record = json!({"a": 1});
4040 let result = apply_all(
4041 record,
4042 &compiled(&[RecordTransform::RenameField { fields }]),
4043 );
4044 assert_eq!(result["a"], 1);
4045 }
4046
4047 #[cfg(feature = "transform-cast")]
4048 #[test]
4049 fn cast_passes_through_non_object() {
4050 let record = json!([1, 2]);
4051 let result = apply_all(
4052 record.clone(),
4053 &compiled(&cast_specs("a", CastType::Int, CastOnError::Error)),
4054 );
4055 assert_eq!(result, record);
4056 }
4057
4058 #[cfg(feature = "transform-redact")]
4059 #[test]
4060 fn redact_passes_through_non_object() {
4061 let record = json!("scalar");
4062 let result = apply_all(
4063 record.clone(),
4064 &compiled(&[RecordTransform::Redact {
4065 fields: vec!["a".into()],
4066 mask: json!("***"),
4067 }]),
4068 );
4069 assert_eq!(result, record);
4070 }
4071
4072 #[cfg(feature = "transform-value-case")]
4073 #[test]
4074 fn value_case_passes_through_non_object() {
4075 let record = json!([1, 2]);
4076 let result = apply_all(
4077 record.clone(),
4078 &compiled(&[RecordTransform::ValueCase {
4079 fields: vec!["a".into()],
4080 mode: ValueCaseMode::Lower,
4081 }]),
4082 );
4083 assert_eq!(result, record);
4084 }
4085
4086 #[cfg(feature = "transform-cast")]
4089 #[test]
4090 fn cast_integer_number_to_int_is_identity() {
4091 let record = json!({"n": 7});
4093 let result = apply_all(
4094 record,
4095 &compiled(&cast_specs("n", CastType::Int, CastOnError::Error)),
4096 );
4097 assert_eq!(result["n"], 7);
4098 }
4099
4100 #[cfg(feature = "transform-cast")]
4101 #[test]
4102 fn cast_bool_to_int() {
4103 let record = json!({"t": true, "f": false});
4104 let mut fields = HashMap::new();
4105 fields.insert("t".to_owned(), CastType::Int);
4106 fields.insert("f".to_owned(), CastType::Int);
4107 let result = apply_all(
4108 record,
4109 &compiled(&[RecordTransform::Cast {
4110 fields,
4111 on_error: CastOnError::Error,
4112 }]),
4113 );
4114 assert_eq!(result["t"], 1);
4115 assert_eq!(result["f"], 0);
4116 }
4117
4118 #[cfg(feature = "transform-cast")]
4119 #[test]
4120 fn cast_null_to_int_errors() {
4121 let record = json!({"n": null});
4122 let err = super::apply_all(
4123 record,
4124 &compiled(&cast_specs("n", CastType::Int, CastOnError::Error)),
4125 )
4126 .expect_err("null cannot become int");
4127 assert!(
4128 format!("{err}").contains("null cannot be cast to int"),
4129 "{err}"
4130 );
4131 }
4132
4133 #[cfg(feature = "transform-cast")]
4134 #[test]
4135 fn cast_composite_to_int_errors() {
4136 let record = json!({"n": [1, 2]});
4137 let err = super::apply_all(
4138 record,
4139 &compiled(&cast_specs("n", CastType::Int, CastOnError::Error)),
4140 )
4141 .expect_err("array cannot become int");
4142 assert!(format!("{err}").contains("composite"), "{err}");
4143 }
4144
4145 #[cfg(feature = "transform-cast")]
4146 #[test]
4147 fn cast_number_to_float() {
4148 let record = json!({"n": 5});
4149 let result = apply_all(
4150 record,
4151 &compiled(&cast_specs("n", CastType::Float, CastOnError::Error)),
4152 );
4153 assert_eq!(result["n"], 5.0);
4154 }
4155
4156 #[cfg(feature = "transform-cast")]
4157 #[test]
4158 fn cast_bool_to_float() {
4159 let record = json!({"t": true, "f": false});
4160 let mut fields = HashMap::new();
4161 fields.insert("t".to_owned(), CastType::Float);
4162 fields.insert("f".to_owned(), CastType::Float);
4163 let result = apply_all(
4164 record,
4165 &compiled(&[RecordTransform::Cast {
4166 fields,
4167 on_error: CastOnError::Error,
4168 }]),
4169 );
4170 assert_eq!(result["t"], 1.0);
4171 assert_eq!(result["f"], 0.0);
4172 }
4173
4174 #[cfg(feature = "transform-cast")]
4175 #[test]
4176 fn cast_null_to_float_errors() {
4177 let record = json!({"n": null});
4178 let err = super::apply_all(
4179 record,
4180 &compiled(&cast_specs("n", CastType::Float, CastOnError::Error)),
4181 )
4182 .expect_err("null cannot become float");
4183 assert!(
4184 format!("{err}").contains("null cannot be cast to float"),
4185 "{err}"
4186 );
4187 }
4188
4189 #[cfg(feature = "transform-cast")]
4190 #[test]
4191 fn cast_composite_to_float_errors() {
4192 let record = json!({"n": {"x": 1}});
4193 let err = super::apply_all(
4194 record,
4195 &compiled(&cast_specs("n", CastType::Float, CastOnError::Error)),
4196 )
4197 .expect_err("object cannot become float");
4198 assert!(format!("{err}").contains("composite"), "{err}");
4199 }
4200
4201 #[cfg(feature = "transform-cast")]
4202 #[test]
4203 fn cast_string_to_float_invalid_errors() {
4204 let record = json!({"n": "not a float"});
4205 let err = super::apply_all(
4206 record,
4207 &compiled(&cast_specs("n", CastType::Float, CastOnError::Error)),
4208 )
4209 .expect_err("non-numeric string cannot become float");
4210 assert!(format!("{err}").contains("is not a float"), "{err}");
4211 }
4212
4213 #[cfg(feature = "transform-cast")]
4214 #[test]
4215 fn cast_bool_to_bool_is_identity() {
4216 let record = json!({"b": true});
4217 let result = apply_all(
4218 record,
4219 &compiled(&cast_specs("b", CastType::Bool, CastOnError::Error)),
4220 );
4221 assert_eq!(result["b"], true);
4222 }
4223
4224 #[cfg(feature = "transform-cast")]
4225 #[test]
4226 fn cast_number_to_bool() {
4227 let record = json!({"on": 1, "off": 0});
4228 let mut fields = HashMap::new();
4229 fields.insert("on".to_owned(), CastType::Bool);
4230 fields.insert("off".to_owned(), CastType::Bool);
4231 let result = apply_all(
4232 record,
4233 &compiled(&[RecordTransform::Cast {
4234 fields,
4235 on_error: CastOnError::Error,
4236 }]),
4237 );
4238 assert_eq!(result["on"], true);
4239 assert_eq!(result["off"], false);
4240 }
4241
4242 #[cfg(feature = "transform-cast")]
4243 #[test]
4244 fn cast_integer_other_than_zero_one_to_bool_errors() {
4245 let record = json!({"n": 7});
4246 let err = super::apply_all(
4247 record,
4248 &compiled(&cast_specs("n", CastType::Bool, CastOnError::Error)),
4249 )
4250 .expect_err("only 0/1 convert to bool");
4251 assert!(format!("{err}").contains("not 0 or 1"), "{err}");
4252 }
4253
4254 #[cfg(feature = "transform-cast")]
4255 #[test]
4256 fn cast_float_number_to_bool_errors() {
4257 let record = json!({"n": 1.5});
4259 let err = super::apply_all(
4260 record,
4261 &compiled(&cast_specs("n", CastType::Bool, CastOnError::Error)),
4262 )
4263 .expect_err("fractional number cannot become bool");
4264 assert!(format!("{err}").contains("not 0 or 1"), "{err}");
4265 }
4266
4267 #[cfg(feature = "transform-cast")]
4268 #[test]
4269 fn cast_unrecognised_string_to_bool_errors() {
4270 let record = json!({"flag": "maybe"});
4271 let err = super::apply_all(
4272 record,
4273 &compiled(&cast_specs("flag", CastType::Bool, CastOnError::Error)),
4274 )
4275 .expect_err("'maybe' is not a boolean");
4276 assert!(
4277 format!("{err}").contains("not a recognised boolean"),
4278 "{err}"
4279 );
4280 }
4281
4282 #[cfg(feature = "transform-cast")]
4283 #[test]
4284 fn cast_null_to_bool_errors() {
4285 let record = json!({"b": null});
4286 let err = super::apply_all(
4287 record,
4288 &compiled(&cast_specs("b", CastType::Bool, CastOnError::Error)),
4289 )
4290 .expect_err("null cannot become bool");
4291 assert!(
4292 format!("{err}").contains("null cannot be cast to bool"),
4293 "{err}"
4294 );
4295 }
4296
4297 #[cfg(feature = "transform-cast")]
4298 #[test]
4299 fn cast_composite_to_bool_errors() {
4300 let record = json!({"b": [true]});
4301 let err = super::apply_all(
4302 record,
4303 &compiled(&cast_specs("b", CastType::Bool, CastOnError::Error)),
4304 )
4305 .expect_err("array cannot become bool");
4306 assert!(format!("{err}").contains("composite"), "{err}");
4307 }
4308
4309 #[cfg(feature = "transform-cast")]
4310 #[test]
4311 fn cast_string_to_string_is_identity() {
4312 let record = json!({"s": "hello"});
4313 let result = apply_all(
4314 record,
4315 &compiled(&cast_specs("s", CastType::String, CastOnError::Error)),
4316 );
4317 assert_eq!(result["s"], "hello");
4318 }
4319
4320 #[cfg(feature = "transform-cast")]
4321 #[test]
4322 fn cast_bool_to_string() {
4323 let record = json!({"b": true});
4324 let result = apply_all(
4325 record,
4326 &compiled(&cast_specs("b", CastType::String, CastOnError::Error)),
4327 );
4328 assert_eq!(result["b"], "true");
4329 }
4330
4331 #[cfg(feature = "transform-cast")]
4332 #[test]
4333 fn cast_null_to_string_errors() {
4334 let record = json!({"s": null});
4335 let err = super::apply_all(
4336 record,
4337 &compiled(&cast_specs("s", CastType::String, CastOnError::Error)),
4338 )
4339 .expect_err("null cannot become string");
4340 assert!(
4341 format!("{err}").contains("null cannot be cast to string"),
4342 "{err}"
4343 );
4344 }
4345
4346 #[cfg(feature = "transform-cast")]
4347 #[test]
4348 fn cast_composite_to_string_errors() {
4349 let record = json!({"s": {"a": 1}});
4350 let err = super::apply_all(
4351 record,
4352 &compiled(&cast_specs("s", CastType::String, CastOnError::Error)),
4353 )
4354 .expect_err("object cannot become string");
4355 assert!(format!("{err}").contains("composite"), "{err}");
4356 }
4357
4358 #[cfg(feature = "transform-cast")]
4359 #[test]
4360 fn cast_invalid_timestamp_string_errors() {
4361 let record = json!({"ts": "not a date"});
4362 let err = super::apply_all(
4363 record,
4364 &compiled(&cast_specs("ts", CastType::Timestamp, CastOnError::Error)),
4365 )
4366 .expect_err("invalid timestamp string");
4367 assert!(format!("{err}").contains("RFC 3339"), "{err}");
4368 }
4369
4370 #[cfg(feature = "transform-cast")]
4371 #[test]
4372 fn cast_non_string_to_timestamp_names_the_type() {
4373 for (val, ty_name) in [
4375 (json!(null), "null"),
4376 (json!(true), "bool"),
4377 (json!(42), "number"),
4378 (json!([1, 2]), "array"),
4379 (json!({"a": 1}), "object"),
4380 ] {
4381 let record = json!({ "ts": val });
4382 let err = super::apply_all(
4383 record,
4384 &compiled(&cast_specs("ts", CastType::Timestamp, CastOnError::Error)),
4385 )
4386 .expect_err("non-string cannot become timestamp");
4387 let msg = format!("{err}");
4388 assert!(msg.contains("timestamp"), "{msg}");
4389 assert!(
4390 msg.contains(ty_name),
4391 "expected type name {ty_name:?} in: {msg}"
4392 );
4393 }
4394 }
4395
4396 #[cfg(feature = "transform-hash")]
4399 fn hash_spec(fields: &[&str], enc: HashEncoding, salt: Option<&str>) -> Vec<RecordTransform> {
4400 vec![RecordTransform::Hash {
4401 fields: fields.iter().map(|s| (*s).to_owned()).collect(),
4402 algorithm: HashAlgorithm::Sha256,
4403 encoding: enc,
4404 salt: salt.map(str::to_owned),
4405 into: None,
4406 }]
4407 }
4408
4409 #[cfg(feature = "transform-hash")]
4410 #[test]
4411 fn hash_replaces_in_place_and_is_stable() {
4412 let a = apply_all(
4413 json!({"email": "a@b.com", "id": 1}),
4414 &compiled(&hash_spec(&["email"], HashEncoding::Hex, None)),
4415 );
4416 let b = apply_all(
4417 json!({"email": "a@b.com", "id": 1}),
4418 &compiled(&hash_spec(&["email"], HashEncoding::Hex, None)),
4419 );
4420 assert_eq!(a["email"], b["email"]);
4422 assert_eq!(a["id"], 1);
4423 assert_eq!(a["email"].as_str().unwrap().len(), 64);
4425 assert_ne!(a["email"], json!("a@b.com"));
4426 }
4427
4428 #[cfg(feature = "transform-hash")]
4429 #[test]
4430 fn hash_salt_changes_output() {
4431 let unsalted = apply_all(
4432 json!({"email": "a@b.com"}),
4433 &compiled(&hash_spec(&["email"], HashEncoding::Hex, None)),
4434 );
4435 let salted = apply_all(
4436 json!({"email": "a@b.com"}),
4437 &compiled(&hash_spec(&["email"], HashEncoding::Hex, Some("pepper"))),
4438 );
4439 assert_ne!(unsalted["email"], salted["email"]);
4440 }
4441
4442 #[cfg(feature = "transform-hash")]
4443 #[test]
4444 fn hash_hex_vs_base64_differ_and_both_decode() {
4445 let hex = apply_all(
4446 json!({"v": "x"}),
4447 &compiled(&hash_spec(&["v"], HashEncoding::Hex, None)),
4448 );
4449 let b64 = apply_all(
4450 json!({"v": "x"}),
4451 &compiled(&hash_spec(&["v"], HashEncoding::Base64, None)),
4452 );
4453 assert_ne!(hex["v"], b64["v"]);
4454 assert_eq!(hex["v"].as_str().unwrap().len(), 64);
4456 assert_eq!(b64["v"].as_str().unwrap().len(), 44);
4457 }
4458
4459 #[cfg(feature = "transform-hash")]
4460 #[test]
4461 fn hash_into_preserves_source() {
4462 let out = apply_all(
4463 json!({"email": "a@b.com"}),
4464 &compiled(&[RecordTransform::Hash {
4465 fields: vec!["email".into()],
4466 algorithm: HashAlgorithm::Sha256,
4467 encoding: HashEncoding::Hex,
4468 salt: None,
4469 into: Some("email_hash".into()),
4470 }]),
4471 );
4472 assert_eq!(out["email"], "a@b.com");
4473 assert_eq!(out["email_hash"].as_str().unwrap().len(), 64);
4474 }
4475
4476 #[cfg(feature = "transform-hash")]
4477 #[test]
4478 fn hash_missing_field_is_no_op() {
4479 let out = apply_all(
4480 json!({"id": 1}),
4481 &compiled(&hash_spec(&["email"], HashEncoding::Hex, None)),
4482 );
4483 assert_eq!(out, json!({"id": 1}));
4484 }
4485
4486 #[cfg(feature = "transform-hash")]
4491 #[test]
4492 fn hash_leaves_null_alone() {
4493 let out = apply_all(
4494 json!({"email": null, "other": "x"}),
4495 &compiled(&hash_spec(&["email"], HashEncoding::Hex, None)),
4496 );
4497 assert_eq!(out["email"], Value::Null, "null must not be hashed");
4498 assert_eq!(out["other"], json!("x"));
4499
4500 let a = apply_all(
4503 json!({"id": 1, "email": null}),
4504 &compiled(&hash_spec(&["email"], HashEncoding::Hex, None)),
4505 );
4506 let b = apply_all(
4507 json!({"id": 2, "email": null}),
4508 &compiled(&hash_spec(&["email"], HashEncoding::Hex, None)),
4509 );
4510 assert_ne!(a, b);
4511 assert!(a["email"].is_null() && b["email"].is_null());
4512 }
4513
4514 #[cfg(feature = "transform-hash")]
4515 #[test]
4516 fn hash_non_string_hashes_canonical_json() {
4517 let out = apply_all(
4519 json!({"n": 42}),
4520 &compiled(&hash_spec(&["n"], HashEncoding::Hex, None)),
4521 );
4522 let expected = hash_string("42", HashAlgorithm::Sha256, HashEncoding::Hex, None);
4523 assert_eq!(out["n"], Value::String(expected));
4524 }
4525
4526 #[cfg(feature = "transform-hash")]
4527 #[test]
4528 fn hash_blake3_differs_from_sha256() {
4529 let sha = apply_all(
4530 json!({"v": "x"}),
4531 &compiled(&[RecordTransform::Hash {
4532 fields: vec!["v".into()],
4533 algorithm: HashAlgorithm::Sha256,
4534 encoding: HashEncoding::Hex,
4535 salt: None,
4536 into: None,
4537 }]),
4538 );
4539 let b3 = apply_all(
4540 json!({"v": "x"}),
4541 &compiled(&[RecordTransform::Hash {
4542 fields: vec!["v".into()],
4543 algorithm: HashAlgorithm::Blake3,
4544 encoding: HashEncoding::Hex,
4545 salt: None,
4546 into: None,
4547 }]),
4548 );
4549 assert_ne!(sha["v"], b3["v"]);
4550 assert_eq!(b3["v"].as_str().unwrap().len(), 64);
4551 }
4552
4553 #[cfg(feature = "transform-hash")]
4554 #[test]
4555 fn hash_empty_fields_is_config_error() {
4556 let res = compile(&RecordTransform::Hash {
4557 fields: vec![],
4558 algorithm: HashAlgorithm::Sha256,
4559 encoding: HashEncoding::Hex,
4560 salt: None,
4561 into: None,
4562 });
4563 assert!(matches!(res, Err(FaucetError::Config(_))));
4564 }
4565
4566 #[cfg(feature = "transform-hash")]
4567 #[test]
4568 fn hash_into_with_multiple_fields_is_config_error() {
4569 let res = compile(&RecordTransform::Hash {
4570 fields: vec!["a".into(), "b".into()],
4571 algorithm: HashAlgorithm::Sha256,
4572 encoding: HashEncoding::Hex,
4573 salt: None,
4574 into: Some("x".into()),
4575 });
4576 assert!(matches!(res, Err(FaucetError::Config(_))));
4577 }
4578
4579 #[cfg(feature = "transform-hash")]
4580 #[test]
4581 fn hash_debug_redacts_salt() {
4582 let dbg = format!(
4583 "{:?}",
4584 RecordTransform::Hash {
4585 fields: vec!["a".into()],
4586 algorithm: HashAlgorithm::Sha256,
4587 encoding: HashEncoding::Hex,
4588 salt: Some("supersecret".into()),
4589 into: None,
4590 }
4591 );
4592 assert!(!dbg.contains("supersecret"), "{dbg}");
4593 assert!(dbg.contains("redacted"), "{dbg}");
4594 }
4595
4596 #[cfg(feature = "transform-json-parse")]
4599 fn json_parse_spec(field: &str, on_error: JsonParseOnError) -> Vec<RecordTransform> {
4600 vec![RecordTransform::JsonParse {
4601 fields: vec![field.to_owned()],
4602 on_error,
4603 into: None,
4604 }]
4605 }
4606
4607 #[cfg(feature = "transform-json-parse")]
4608 #[test]
4609 fn json_parse_object_string_becomes_object() {
4610 let out = apply_all(
4611 json!({"payload": "{\"a\":1,\"b\":[2,3]}"}),
4612 &compiled(&json_parse_spec("payload", JsonParseOnError::Keep)),
4613 );
4614 assert_eq!(out["payload"], json!({"a": 1, "b": [2, 3]}));
4615 }
4616
4617 #[cfg(feature = "transform-json-parse")]
4618 #[test]
4619 fn json_parse_already_parsed_is_no_op() {
4620 let record = json!({"payload": {"a": 1}});
4621 let out = apply_all(
4622 record.clone(),
4623 &compiled(&json_parse_spec("payload", JsonParseOnError::Error)),
4624 );
4625 assert_eq!(out, record);
4626 }
4627
4628 #[cfg(feature = "transform-json-parse")]
4629 #[test]
4630 fn json_parse_missing_field_is_no_op() {
4631 let out = apply_all(
4632 json!({"id": 1}),
4633 &compiled(&json_parse_spec("payload", JsonParseOnError::Error)),
4634 );
4635 assert_eq!(out, json!({"id": 1}));
4636 }
4637
4638 #[cfg(feature = "transform-json-parse")]
4639 #[test]
4640 fn json_parse_invalid_keep_leaves_string() {
4641 let out = apply_all(
4642 json!({"payload": "not json"}),
4643 &compiled(&json_parse_spec("payload", JsonParseOnError::Keep)),
4644 );
4645 assert_eq!(out["payload"], "not json");
4646 }
4647
4648 #[cfg(feature = "transform-json-parse")]
4649 #[test]
4650 fn json_parse_invalid_null_replaces() {
4651 let out = apply_all(
4652 json!({"payload": "not json"}),
4653 &compiled(&json_parse_spec("payload", JsonParseOnError::Null)),
4654 );
4655 assert_eq!(out["payload"], Value::Null);
4656 }
4657
4658 #[cfg(feature = "transform-json-parse")]
4659 #[test]
4660 fn json_parse_invalid_error_propagates() {
4661 let err = super::apply_all(
4662 json!({"payload": "not json"}),
4663 &compiled(&json_parse_spec("payload", JsonParseOnError::Error)),
4664 )
4665 .expect_err("invalid JSON under on_error=error must fail");
4666 assert!(matches!(err, FaucetError::Transform(_)), "{err}");
4667 }
4668
4669 #[cfg(feature = "transform-json-parse")]
4670 #[test]
4671 fn json_parse_into_writes_target() {
4672 let out = apply_all(
4673 json!({"payload": "{\"a\":1}"}),
4674 &compiled(&[RecordTransform::JsonParse {
4675 fields: vec!["payload".into()],
4676 on_error: JsonParseOnError::Error,
4677 into: Some("parsed".into()),
4678 }]),
4679 );
4680 assert_eq!(out["payload"], "{\"a\":1}");
4681 assert_eq!(out["parsed"], json!({"a": 1}));
4682 }
4683
4684 #[cfg(feature = "transform-coalesce")]
4687 #[test]
4688 fn coalesce_default_fills_null_and_absent() {
4689 let spec = |field: &str| {
4690 vec![RecordTransform::Coalesce {
4691 field: field.to_owned(),
4692 default: Some(json!("unknown")),
4693 from: vec![],
4694 treat_empty_string_as_null: false,
4695 }]
4696 };
4697 let a = apply_all(json!({"status": null}), &compiled(&spec("status")));
4699 assert_eq!(a["status"], "unknown");
4700 let b = apply_all(json!({"id": 1}), &compiled(&spec("status")));
4702 assert_eq!(b["status"], "unknown");
4703 }
4704
4705 #[cfg(feature = "transform-coalesce")]
4706 #[test]
4707 fn coalesce_non_null_target_untouched() {
4708 let out = apply_all(
4709 json!({"status": "active"}),
4710 &compiled(&[RecordTransform::Coalesce {
4711 field: "status".into(),
4712 default: Some(json!("unknown")),
4713 from: vec![],
4714 treat_empty_string_as_null: false,
4715 }]),
4716 );
4717 assert_eq!(out["status"], "active");
4718 }
4719
4720 #[cfg(feature = "transform-coalesce")]
4721 #[test]
4722 fn coalesce_from_picks_first_non_null() {
4723 let out = apply_all(
4724 json!({"status": null, "state": null, "phase": "running"}),
4725 &compiled(&[RecordTransform::Coalesce {
4726 field: "status".into(),
4727 default: None,
4728 from: vec!["status".into(), "state".into(), "phase".into()],
4729 treat_empty_string_as_null: false,
4730 }]),
4731 );
4732 assert_eq!(out["status"], "running");
4733 }
4734
4735 #[cfg(feature = "transform-coalesce")]
4736 #[test]
4737 fn coalesce_empty_string_toggle() {
4738 let spec = |treat: bool| {
4739 vec![RecordTransform::Coalesce {
4740 field: "status".into(),
4741 default: Some(json!("unknown")),
4742 from: vec![],
4743 treat_empty_string_as_null: treat,
4744 }]
4745 };
4746 let off = apply_all(json!({"status": ""}), &compiled(&spec(false)));
4748 assert_eq!(off["status"], "");
4749 let on = apply_all(json!({"status": ""}), &compiled(&spec(true)));
4751 assert_eq!(on["status"], "unknown");
4752 }
4753
4754 #[cfg(feature = "transform-coalesce")]
4755 #[test]
4756 fn coalesce_from_all_null_leaves_target() {
4757 let out = apply_all(
4758 json!({"status": null, "state": null}),
4759 &compiled(&[RecordTransform::Coalesce {
4760 field: "status".into(),
4761 default: None,
4762 from: vec!["status".into(), "state".into()],
4763 treat_empty_string_as_null: false,
4764 }]),
4765 );
4766 assert_eq!(out["status"], Value::Null);
4767 }
4768
4769 #[cfg(feature = "transform-coalesce")]
4770 #[test]
4771 fn coalesce_both_default_and_from_is_config_error() {
4772 let res = compile(&RecordTransform::Coalesce {
4773 field: "status".into(),
4774 default: Some(json!("x")),
4775 from: vec!["state".into()],
4776 treat_empty_string_as_null: false,
4777 });
4778 assert!(matches!(res, Err(FaucetError::Config(_))));
4779 }
4780
4781 #[cfg(feature = "transform-coalesce")]
4782 #[test]
4783 fn coalesce_neither_default_nor_from_is_config_error() {
4784 let res = compile(&RecordTransform::Coalesce {
4785 field: "status".into(),
4786 default: None,
4787 from: vec![],
4788 treat_empty_string_as_null: false,
4789 });
4790 assert!(matches!(res, Err(FaucetError::Config(_))));
4791 }
4792
4793 #[cfg(feature = "transform-split-join")]
4796 #[test]
4797 fn split_basic_no_trim() {
4798 let out = apply_all(
4799 json!({"tags": "a, b ,c"}),
4800 &compiled(&[RecordTransform::Split {
4801 field: "tags".into(),
4802 delimiter: ",".into(),
4803 trim: false,
4804 into: None,
4805 }]),
4806 );
4807 assert_eq!(out["tags"], json!(["a", " b ", "c"]));
4808 }
4809
4810 #[cfg(feature = "transform-split-join")]
4811 #[test]
4812 fn split_with_trim_keeps_empty_segments() {
4813 let out = apply_all(
4814 json!({"tags": "a, ,c,"}),
4815 &compiled(&[RecordTransform::Split {
4816 field: "tags".into(),
4817 delimiter: ",".into(),
4818 trim: true,
4819 into: None,
4820 }]),
4821 );
4822 assert_eq!(out["tags"], json!(["a", "", "c", ""]));
4824 }
4825
4826 #[cfg(feature = "transform-split-join")]
4827 #[test]
4828 fn split_empty_input_yields_single_empty() {
4829 let out = apply_all(
4830 json!({"tags": ""}),
4831 &compiled(&[RecordTransform::Split {
4832 field: "tags".into(),
4833 delimiter: ",".into(),
4834 trim: false,
4835 into: None,
4836 }]),
4837 );
4838 assert_eq!(out["tags"], json!([""]));
4839 }
4840
4841 #[cfg(feature = "transform-split-join")]
4842 #[test]
4843 fn split_non_string_is_no_op() {
4844 let record = json!({"tags": [1, 2]});
4845 let out = apply_all(
4846 record.clone(),
4847 &compiled(&[RecordTransform::Split {
4848 field: "tags".into(),
4849 delimiter: ",".into(),
4850 trim: false,
4851 into: None,
4852 }]),
4853 );
4854 assert_eq!(out, record);
4855 }
4856
4857 #[cfg(feature = "transform-split-join")]
4858 #[test]
4859 fn split_into_writes_target() {
4860 let out = apply_all(
4861 json!({"csv": "a,b"}),
4862 &compiled(&[RecordTransform::Split {
4863 field: "csv".into(),
4864 delimiter: ",".into(),
4865 trim: false,
4866 into: Some("arr".into()),
4867 }]),
4868 );
4869 assert_eq!(out["csv"], "a,b");
4870 assert_eq!(out["arr"], json!(["a", "b"]));
4871 }
4872
4873 #[cfg(feature = "transform-split-join")]
4874 #[test]
4875 fn join_basic_and_non_string_elements() {
4876 let out = apply_all(
4877 json!({"parts": ["a", 2, true, null]}),
4878 &compiled(&[RecordTransform::Join {
4879 field: "parts".into(),
4880 delimiter: ",".into(),
4881 into: None,
4882 }]),
4883 );
4884 assert_eq!(out["parts"], "a,2,true,");
4886 }
4887
4888 #[cfg(feature = "transform-split-join")]
4889 #[test]
4890 fn join_non_array_is_no_op() {
4891 let record = json!({"parts": "already a string"});
4892 let out = apply_all(
4893 record.clone(),
4894 &compiled(&[RecordTransform::Join {
4895 field: "parts".into(),
4896 delimiter: ",".into(),
4897 into: None,
4898 }]),
4899 );
4900 assert_eq!(out, record);
4901 }
4902
4903 #[cfg(feature = "transform-split-join")]
4904 #[test]
4905 fn split_then_join_round_trips() {
4906 let out = apply_all(
4907 json!({"tags": "a,b,c"}),
4908 &compiled(&[
4909 RecordTransform::Split {
4910 field: "tags".into(),
4911 delimiter: ",".into(),
4912 trim: false,
4913 into: None,
4914 },
4915 RecordTransform::Join {
4916 field: "tags".into(),
4917 delimiter: ",".into(),
4918 into: None,
4919 },
4920 ]),
4921 );
4922 assert_eq!(out["tags"], "a,b,c");
4923 }
4924
4925 #[cfg(feature = "transform-value-case")]
4928 #[test]
4929 fn value_case_title() {
4930 let out = apply_all(
4931 json!({"city": "new york", "id": 1}),
4932 &compiled(&[RecordTransform::ValueCase {
4933 fields: vec!["city".into()],
4934 mode: ValueCaseMode::Title,
4935 }]),
4936 );
4937 assert_eq!(out["city"], "New York");
4938 assert_eq!(out["id"], 1);
4939 }
4940
4941 #[cfg(feature = "transform-value-case")]
4942 #[test]
4943 fn value_case_title_lowercases_rest_of_word() {
4944 let out = apply_all(
4945 json!({"s": "hELLO WORLD"}),
4946 &compiled(&[RecordTransform::ValueCase {
4947 fields: vec!["s".into()],
4948 mode: ValueCaseMode::Title,
4949 }]),
4950 );
4951 assert_eq!(out["s"], "Hello World");
4952 }
4953
4954 #[cfg(feature = "transform-value-case")]
4955 #[test]
4956 fn value_case_capitalize() {
4957 let out = apply_all(
4958 json!({"s": "hELLO wORLD"}),
4959 &compiled(&[RecordTransform::ValueCase {
4960 fields: vec!["s".into()],
4961 mode: ValueCaseMode::Capitalize,
4962 }]),
4963 );
4964 assert_eq!(out["s"], "Hello world");
4965 }
4966
4967 #[cfg(feature = "transform-value-case")]
4968 #[test]
4969 fn value_case_title_is_idempotent() {
4970 let once = apply_all(
4971 json!({"s": "new york"}),
4972 &compiled(&[RecordTransform::ValueCase {
4973 fields: vec!["s".into()],
4974 mode: ValueCaseMode::Title,
4975 }]),
4976 );
4977 let twice = apply_all(
4978 once.clone(),
4979 &compiled(&[RecordTransform::ValueCase {
4980 fields: vec!["s".into()],
4981 mode: ValueCaseMode::Title,
4982 }]),
4983 );
4984 assert_eq!(once, twice);
4985 }
4986
4987 #[cfg(feature = "transform-value-case")]
4988 #[test]
4989 fn value_case_title_non_string_no_op() {
4990 let out = apply_all(
4991 json!({"n": 42}),
4992 &compiled(&[RecordTransform::ValueCase {
4993 fields: vec!["n".into()],
4994 mode: ValueCaseMode::Title,
4995 }]),
4996 );
4997 assert_eq!(out["n"], 42);
4998 }
4999
5000 #[cfg(feature = "transform-keys-case")]
5003 #[test]
5004 fn keys_case_dot() {
5005 let out = apply_all(
5006 json!({"userId": 1, "First Name": 2, "kebab-case": 3}),
5007 &compiled(&keys_case_specs(KeyCaseMode::Dot)),
5008 );
5009 assert_eq!(out["user.id"], 1);
5010 assert_eq!(out["first.name"], 2);
5011 assert_eq!(out["kebab.case"], 3);
5012 }
5013
5014 #[cfg(feature = "transform-keys-case")]
5015 #[test]
5016 fn keys_case_dot_is_idempotent() {
5017 let once = apply_all(
5018 json!({"userId": 1}),
5019 &compiled(&keys_case_specs(KeyCaseMode::Dot)),
5020 );
5021 let twice = apply_all(once.clone(), &compiled(&keys_case_specs(KeyCaseMode::Dot)));
5022 assert_eq!(once, twice);
5023 assert_eq!(twice["user.id"], 1);
5024 }
5025
5026 #[cfg(feature = "transform-keys-case")]
5027 #[test]
5028 fn keys_case_dot_matches_snake_tokenization() {
5029 let record = json!({"XMLHttpRequest": 1, "second name": 2});
5031 let dot = apply_all(
5032 record.clone(),
5033 &compiled(&keys_case_specs(KeyCaseMode::Dot)),
5034 );
5035 let snake = apply_all(record, &compiled(&keys_case_specs(KeyCaseMode::Snake)));
5036 let dot_keys: Vec<String> = dot.as_object().unwrap().keys().cloned().collect();
5038 let snake_keys: Vec<String> = snake.as_object().unwrap().keys().cloned().collect();
5039 let converted: Vec<String> = snake_keys.iter().map(|k| k.replace('_', ".")).collect();
5040 assert_eq!(dot_keys, converted);
5041 }
5042
5043 #[cfg(feature = "transform-json-parse")]
5046 #[test]
5047 fn json_parse_empty_fields_is_config_error() {
5048 let res = compile(&RecordTransform::JsonParse {
5049 fields: vec![],
5050 on_error: JsonParseOnError::Keep,
5051 into: None,
5052 });
5053 assert!(matches!(res, Err(FaucetError::Config(_))));
5054 }
5055
5056 #[cfg(feature = "transform-json-parse")]
5057 #[test]
5058 fn json_parse_into_with_multiple_fields_is_config_error() {
5059 let res = compile(&RecordTransform::JsonParse {
5060 fields: vec!["a".into(), "b".into()],
5061 on_error: JsonParseOnError::Keep,
5062 into: Some("x".into()),
5063 });
5064 assert!(matches!(res, Err(FaucetError::Config(_))));
5065 }
5066
5067 #[cfg(feature = "transform-hash")]
5068 #[test]
5069 fn hash_passes_through_non_object() {
5070 let record = json!([1, 2, 3]);
5071 let result = apply_all(
5072 record.clone(),
5073 &compiled(&hash_spec(&["v"], HashEncoding::Hex, None)),
5074 );
5075 assert_eq!(result, record);
5076 }
5077
5078 #[cfg(feature = "transform-json-parse")]
5079 #[test]
5080 fn json_parse_passes_through_non_object() {
5081 let record = json!("scalar");
5082 let result = apply_all(
5083 record.clone(),
5084 &compiled(&json_parse_spec("v", JsonParseOnError::Error)),
5085 );
5086 assert_eq!(result, record);
5087 }
5088
5089 #[cfg(feature = "transform-json-encode")]
5090 #[test]
5091 fn json_encode_stringifies_nested_only() {
5092 let out = apply_all(
5093 json!({"id": 1, "addr": {"city": "NYC"}, "tags": [1, 2], "name": "a"}),
5094 &compiled(&[RecordTransform::JsonEncode {
5095 fields: vec![
5096 "addr".into(),
5097 "tags".into(),
5098 "name".into(),
5099 "missing".into(),
5100 ],
5101 }]),
5102 );
5103 assert_eq!(out["addr"], json!("{\"city\":\"NYC\"}"));
5104 assert_eq!(out["tags"], json!("[1,2]"));
5105 assert_eq!(out["name"], json!("a")); assert_eq!(out["id"], json!(1));
5107 }
5108
5109 #[cfg(feature = "transform-lookup")]
5110 fn ref_rows(v: Value) -> Vec<Map<String, Value>> {
5111 v.as_array()
5112 .unwrap()
5113 .iter()
5114 .map(|r| r.as_object().unwrap().clone())
5115 .collect()
5116 }
5117
5118 #[cfg(feature = "transform-lookup")]
5119 #[test]
5120 fn lookup_enriches_on_hit_and_nulls_on_miss() {
5121 let spec = RecordTransform::Lookup {
5122 reference: ref_rows(json!([{"id": 1, "name": "Alice"}, {"id": 2, "name": "Bob"}])),
5123 on_record: "user_id".into(),
5124 on_ref: "id".into(),
5125 add: vec![("user_name".into(), "name".into())],
5126 on_missing: LookupOnMissing::Null,
5127 };
5128 let hit = apply_all(
5129 json!({"user_id": 1}),
5130 &compiled(std::slice::from_ref(&spec)),
5131 );
5132 assert_eq!(hit["user_name"], json!("Alice"));
5133 let miss = apply_all(json!({"user_id": 9}), &compiled(&[spec]));
5134 assert_eq!(miss["user_name"], json!(null));
5135 }
5136
5137 #[cfg(feature = "transform-lookup")]
5138 #[test]
5139 fn lookup_matches_number_against_string_key() {
5140 let spec = RecordTransform::Lookup {
5141 reference: ref_rows(json!([{"code": "42", "label": "answer"}])),
5142 on_record: "code".into(),
5143 on_ref: "code".into(),
5144 add: vec![("label".into(), "label".into())],
5145 on_missing: LookupOnMissing::Keep,
5146 };
5147 let out = apply_all(json!({"code": 42}), &compiled(&[spec]));
5149 assert_eq!(out["label"], json!("answer"));
5150 }
5151
5152 #[cfg(feature = "transform-lookup")]
5153 #[test]
5154 fn lookup_on_missing_error_fails() {
5155 let c = compile(&RecordTransform::Lookup {
5156 reference: Vec::new(),
5157 on_record: "k".into(),
5158 on_ref: "k".into(),
5159 add: vec![("x".into(), "y".into())],
5160 on_missing: LookupOnMissing::Error,
5161 })
5162 .unwrap();
5163 let res = super::apply_all(json!({"k": "z"}), std::slice::from_ref(&c));
5164 assert!(res.is_err());
5165 }
5166
5167 #[cfg(feature = "transform-lookup")]
5168 #[test]
5169 fn lookup_empty_add_is_rejected_at_compile() {
5170 assert!(
5171 compile(&RecordTransform::Lookup {
5172 reference: Vec::new(),
5173 on_record: "k".into(),
5174 on_ref: "k".into(),
5175 add: Vec::new(),
5176 on_missing: LookupOnMissing::Null,
5177 })
5178 .is_err()
5179 );
5180 }
5181
5182 #[cfg(all(feature = "transform-json-encode", feature = "transform-lookup"))]
5183 #[test]
5184 fn json_encode_and_lookup_debug_and_clone() {
5185 let je = RecordTransform::JsonEncode {
5187 fields: vec!["meta".into()],
5188 };
5189 assert!(format!("{je:?}").contains("JsonEncode"));
5190 assert!(format!("{:?}", je.clone()).contains("JsonEncode"));
5191
5192 let mut row = serde_json::Map::new();
5193 row.insert("id".into(), Value::String("1".into()));
5194 let lk = RecordTransform::Lookup {
5195 reference: vec![row],
5196 on_record: "rid".into(),
5197 on_ref: "id".into(),
5198 add: vec![("name".into(), "name".into())],
5199 on_missing: LookupOnMissing::Null,
5200 };
5201 assert!(format!("{lk:?}").contains("Lookup"));
5202 assert!(format!("{:?}", lk.clone()).contains("Lookup"));
5203
5204 let _ = compile(&je).unwrap().clone();
5206 let _ = compile(&lk).unwrap().clone();
5207 }
5208
5209 #[cfg(feature = "transform-coalesce")]
5210 #[test]
5211 fn coalesce_non_null_non_string_target_untouched() {
5212 let out = apply_all(
5215 json!({"n": 0}),
5216 &compiled(&[RecordTransform::Coalesce {
5217 field: "n".into(),
5218 default: Some(json!(99)),
5219 from: vec![],
5220 treat_empty_string_as_null: true,
5221 }]),
5222 );
5223 assert_eq!(out["n"], 0);
5224 }
5225
5226 #[cfg(feature = "transform-coalesce")]
5227 #[test]
5228 fn coalesce_passes_through_non_object() {
5229 let record = json!([1, 2]);
5230 let result = apply_all(
5231 record.clone(),
5232 &compiled(&[RecordTransform::Coalesce {
5233 field: "a".into(),
5234 default: Some(json!("x")),
5235 from: vec![],
5236 treat_empty_string_as_null: false,
5237 }]),
5238 );
5239 assert_eq!(result, record);
5240 }
5241
5242 #[cfg(feature = "transform-split-join")]
5243 #[test]
5244 fn split_and_join_pass_through_non_object() {
5245 let record = json!(42);
5246 let split = apply_all(
5247 record.clone(),
5248 &compiled(&[RecordTransform::Split {
5249 field: "a".into(),
5250 delimiter: ",".into(),
5251 trim: false,
5252 into: None,
5253 }]),
5254 );
5255 assert_eq!(split, record);
5256 let join = apply_all(
5257 record.clone(),
5258 &compiled(&[RecordTransform::Join {
5259 field: "a".into(),
5260 delimiter: ",".into(),
5261 into: None,
5262 }]),
5263 );
5264 assert_eq!(join, record);
5265 }
5266
5267 #[cfg(feature = "transform-split-join")]
5268 #[test]
5269 fn split_empty_delimiter_yields_single_element() {
5270 let out = apply_all(
5271 json!({"s": " hi "}),
5272 &compiled(&[RecordTransform::Split {
5273 field: "s".into(),
5274 delimiter: String::new(),
5275 trim: true,
5276 into: None,
5277 }]),
5278 );
5279 assert_eq!(out["s"], json!(["hi"]));
5281 }
5282
5283 #[cfg(feature = "transform-value-case")]
5284 #[test]
5285 fn value_case_title_and_capitalize_handle_empty_string() {
5286 for mode in [ValueCaseMode::Title, ValueCaseMode::Capitalize] {
5287 let out = apply_all(
5288 json!({"s": ""}),
5289 &compiled(&[RecordTransform::ValueCase {
5290 fields: vec!["s".into()],
5291 mode,
5292 }]),
5293 );
5294 assert_eq!(out["s"], "");
5295 }
5296 }
5297}