Skip to main content

oversync_transforms/
steps.rs

1use oversync_core::error::OversyncError;
2
3use crate::TransformStep;
4
5/// Rename a field: moves value from `from` to `to`.
6pub struct Rename {
7	pub from: String,
8	pub to: String,
9}
10
11impl TransformStep for Rename {
12	fn apply(&self, data: &mut serde_json::Value) -> Result<bool, OversyncError> {
13		if let Some(obj) = data.as_object_mut()
14			&& let Some(val) = obj.remove(&self.from)
15		{
16			obj.insert(self.to.clone(), val);
17		}
18		Ok(true)
19	}
20
21	fn step_name(&self) -> &str {
22		"rename"
23	}
24}
25
26/// Set a field to a constant value (overwrites if exists).
27pub struct Set {
28	pub field: String,
29	pub value: serde_json::Value,
30}
31
32impl TransformStep for Set {
33	fn apply(&self, data: &mut serde_json::Value) -> Result<bool, OversyncError> {
34		if let Some(obj) = data.as_object_mut() {
35			obj.insert(self.field.clone(), self.value.clone());
36		}
37		Ok(true)
38	}
39
40	fn step_name(&self) -> &str {
41		"set"
42	}
43}
44
45/// Convert a string field to uppercase.
46pub struct Upper {
47	pub field: String,
48}
49
50impl TransformStep for Upper {
51	fn apply(&self, data: &mut serde_json::Value) -> Result<bool, OversyncError> {
52		if let Some(obj) = data.as_object_mut()
53			&& let Some(val) = obj.get_mut(&self.field)
54			&& let Some(s) = val.as_str()
55		{
56			*val = serde_json::Value::String(s.to_uppercase());
57		}
58		Ok(true)
59	}
60
61	fn step_name(&self) -> &str {
62		"upper"
63	}
64}
65
66/// Convert a string field to lowercase.
67pub struct Lower {
68	pub field: String,
69}
70
71impl TransformStep for Lower {
72	fn apply(&self, data: &mut serde_json::Value) -> Result<bool, OversyncError> {
73		if let Some(obj) = data.as_object_mut()
74			&& let Some(val) = obj.get_mut(&self.field)
75			&& let Some(s) = val.as_str()
76		{
77			*val = serde_json::Value::String(s.to_lowercase());
78		}
79		Ok(true)
80	}
81
82	fn step_name(&self) -> &str {
83		"lower"
84	}
85}
86
87/// Remove a field from the record.
88pub struct Remove {
89	pub field: String,
90}
91
92impl TransformStep for Remove {
93	fn apply(&self, data: &mut serde_json::Value) -> Result<bool, OversyncError> {
94		if let Some(obj) = data.as_object_mut() {
95			obj.remove(&self.field);
96		}
97		Ok(true)
98	}
99
100	fn step_name(&self) -> &str {
101		"remove"
102	}
103}
104
105/// Copy value from one field to another (keeps original).
106pub struct Copy {
107	pub from: String,
108	pub to: String,
109}
110
111impl TransformStep for Copy {
112	fn apply(&self, data: &mut serde_json::Value) -> Result<bool, OversyncError> {
113		if let Some(obj) = data.as_object_mut()
114			&& let Some(val) = obj.get(&self.from).cloned()
115		{
116			obj.insert(self.to.clone(), val);
117		}
118		Ok(true)
119	}
120
121	fn step_name(&self) -> &str {
122		"copy"
123	}
124}
125
126/// Set a field only if it doesn't exist or is null.
127pub struct Default {
128	pub field: String,
129	pub value: serde_json::Value,
130}
131
132impl TransformStep for Default {
133	fn apply(&self, data: &mut serde_json::Value) -> Result<bool, OversyncError> {
134		if let Some(obj) = data.as_object_mut() {
135			let needs_default = match obj.get(&self.field) {
136				None => true,
137				Some(v) => v.is_null(),
138			};
139			if needs_default {
140				obj.insert(self.field.clone(), self.value.clone());
141			}
142		}
143		Ok(true)
144	}
145
146	fn step_name(&self) -> &str {
147		"default"
148	}
149}
150
151/// Filter records by comparing a field value. Returns false to drop.
152pub struct Filter {
153	pub field: String,
154	pub op: FilterOp,
155	pub value: serde_json::Value,
156}
157
158#[derive(Debug, Clone)]
159pub enum FilterOp {
160	Eq,
161	Ne,
162	Gt,
163	Gte,
164	Lt,
165	Lte,
166	Contains,
167	Exists,
168}
169
170impl TransformStep for Filter {
171	fn apply(&self, data: &mut serde_json::Value) -> Result<bool, OversyncError> {
172		let obj = match data.as_object() {
173			Some(o) => o,
174			None => return Ok(true),
175		};
176
177		if matches!(self.op, FilterOp::Exists) {
178			return Ok(obj.contains_key(&self.field));
179		}
180
181		let field_val = match obj.get(&self.field) {
182			Some(v) => v,
183			None => return Ok(false),
184		};
185
186		let keep = match self.op {
187			FilterOp::Eq => field_val == &self.value,
188			FilterOp::Ne => field_val != &self.value,
189			FilterOp::Gt => json_cmp(field_val, &self.value).is_some_and(|o| o.is_gt()),
190			FilterOp::Gte => json_cmp(field_val, &self.value).is_some_and(|o| o.is_ge()),
191			FilterOp::Lt => json_cmp(field_val, &self.value).is_some_and(|o| o.is_lt()),
192			FilterOp::Lte => json_cmp(field_val, &self.value).is_some_and(|o| o.is_le()),
193			FilterOp::Contains => {
194				if let (Some(haystack), Some(needle)) = (field_val.as_str(), self.value.as_str()) {
195					haystack.contains(needle)
196				} else {
197					false
198				}
199			}
200			FilterOp::Exists => unreachable!(),
201		};
202		Ok(keep)
203	}
204
205	fn step_name(&self) -> &str {
206		"filter"
207	}
208}
209
210/// Replace field value using a lookup mapping. Unmapped values stay unchanged.
211pub struct MapValue {
212	pub field: String,
213	pub mapping: serde_json::Map<String, serde_json::Value>,
214}
215
216impl TransformStep for MapValue {
217	fn apply(&self, data: &mut serde_json::Value) -> Result<bool, OversyncError> {
218		if let Some(obj) = data.as_object_mut()
219			&& let Some(val) = obj.get(&self.field)
220		{
221			let key = match val {
222				serde_json::Value::String(s) => s.clone(),
223				other => other.to_string(),
224			};
225			if let Some(replacement) = self.mapping.get(&key) {
226				obj.insert(self.field.clone(), replacement.clone());
227			}
228		}
229		Ok(true)
230	}
231
232	fn step_name(&self) -> &str {
233		"map_value"
234	}
235}
236
237/// Truncate a string field to max_len characters.
238pub struct Truncate {
239	pub field: String,
240	pub max_len: usize,
241}
242
243impl TransformStep for Truncate {
244	fn apply(&self, data: &mut serde_json::Value) -> Result<bool, OversyncError> {
245		if let Some(obj) = data.as_object_mut()
246			&& let Some(val) = obj.get_mut(&self.field)
247			&& let Some(s) = val.as_str()
248			&& s.chars().count() > self.max_len
249		{
250			let truncated: String = s.chars().take(self.max_len).collect();
251			*val = serde_json::Value::String(truncated);
252		}
253		Ok(true)
254	}
255
256	fn step_name(&self) -> &str {
257		"truncate"
258	}
259}
260
261/// Nest multiple fields into a sub-object.
262pub struct Nest {
263	pub fields: Vec<String>,
264	pub into: String,
265}
266
267impl TransformStep for Nest {
268	fn apply(&self, data: &mut serde_json::Value) -> Result<bool, OversyncError> {
269		if let Some(obj) = data.as_object_mut() {
270			let mut nested = serde_json::Map::new();
271			for field in &self.fields {
272				if let Some(val) = obj.remove(field) {
273					nested.insert(field.clone(), val);
274				}
275			}
276			if !nested.is_empty() {
277				obj.insert(self.into.clone(), serde_json::Value::Object(nested));
278			}
279		}
280		Ok(true)
281	}
282
283	fn step_name(&self) -> &str {
284		"nest"
285	}
286}
287
288/// Flatten a sub-object's fields into the parent.
289pub struct Flatten {
290	pub field: String,
291}
292
293impl TransformStep for Flatten {
294	fn apply(&self, data: &mut serde_json::Value) -> Result<bool, OversyncError> {
295		if let Some(obj) = data.as_object_mut()
296			&& let Some(serde_json::Value::Object(nested)) = obj.remove(&self.field)
297		{
298			for (k, v) in nested {
299				obj.insert(k, v);
300			}
301		}
302		Ok(true)
303	}
304
305	fn step_name(&self) -> &str {
306		"flatten"
307	}
308}
309
310/// Hash a field value with SHA-256, replacing it with the hex digest.
311pub struct Hash {
312	pub field: String,
313}
314
315impl TransformStep for Hash {
316	fn apply(&self, data: &mut serde_json::Value) -> Result<bool, OversyncError> {
317		if let Some(obj) = data.as_object_mut()
318			&& let Some(val) = obj.get(&self.field)
319		{
320			let input = match val {
321				serde_json::Value::String(s) => s.clone(),
322				other => other.to_string(),
323			};
324			use sha2::{Digest, Sha256};
325			let hash = hex::encode(Sha256::digest(input.as_bytes()));
326			obj.insert(self.field.clone(), serde_json::Value::String(hash));
327		}
328		Ok(true)
329	}
330
331	fn step_name(&self) -> &str {
332		"hash"
333	}
334}
335
336/// Take the first non-null value from a list of fields and write to target.
337pub struct Coalesce {
338	pub fields: Vec<String>,
339	pub into: String,
340}
341
342impl TransformStep for Coalesce {
343	fn apply(&self, data: &mut serde_json::Value) -> Result<bool, OversyncError> {
344		if let Some(obj) = data.as_object_mut() {
345			for field in &self.fields {
346				if let Some(val) = obj.get(field)
347					&& !val.is_null()
348				{
349					let v = val.clone();
350					obj.insert(self.into.clone(), v);
351					return Ok(true);
352				}
353			}
354		}
355		Ok(true)
356	}
357
358	fn step_name(&self) -> &str {
359		"coalesce"
360	}
361}
362
363/// Filter records by matching a field value against allow/deny regex patterns.
364///
365/// Evaluation order:
366/// 1. If `deny` patterns are set and any matches → drop
367/// 2. If `allow` patterns are set and none matches → drop
368/// 3. Otherwise → keep
369pub struct SchemaFilter {
370	pub field: String,
371	pub allow: Vec<regex::Regex>,
372	pub deny: Vec<regex::Regex>,
373}
374
375impl TransformStep for SchemaFilter {
376	fn apply(&self, data: &mut serde_json::Value) -> Result<bool, OversyncError> {
377		let val_str = match data.as_object().and_then(|o| o.get(&self.field)) {
378			Some(serde_json::Value::String(s)) => s.clone(),
379			Some(v) => v.to_string(),
380			None => return Ok(self.allow.is_empty()),
381		};
382
383		for deny in &self.deny {
384			if deny.is_match(&val_str) {
385				return Ok(false);
386			}
387		}
388
389		if !self.allow.is_empty() {
390			let allowed = self.allow.iter().any(|r| r.is_match(&val_str));
391			if !allowed {
392				return Ok(false);
393			}
394		}
395
396		Ok(true)
397	}
398
399	fn step_name(&self) -> &str {
400		"schema_filter"
401	}
402}
403
404fn json_cmp(a: &serde_json::Value, b: &serde_json::Value) -> Option<std::cmp::Ordering> {
405	match (a, b) {
406		(serde_json::Value::Number(a), serde_json::Value::Number(b)) => {
407			a.as_f64()?.partial_cmp(&b.as_f64()?)
408		}
409		(serde_json::Value::String(a), serde_json::Value::String(b)) => Some(a.cmp(b)),
410		_ => None,
411	}
412}
413
414#[cfg(test)]
415mod tests {
416	use super::*;
417
418	#[test]
419	fn rename_moves_field() {
420		let mut data = serde_json::json!({"old_name": "alice"});
421		let step = Rename {
422			from: "old_name".into(),
423			to: "new_name".into(),
424		};
425		assert!(step.apply(&mut data).unwrap());
426		assert_eq!(data, serde_json::json!({"new_name": "alice"}));
427	}
428
429	#[test]
430	fn rename_missing_field_is_noop() {
431		let mut data = serde_json::json!({"x": 1});
432		let step = Rename {
433			from: "missing".into(),
434			to: "y".into(),
435		};
436		assert!(step.apply(&mut data).unwrap());
437		assert_eq!(data, serde_json::json!({"x": 1}));
438	}
439
440	#[test]
441	fn set_adds_field() {
442		let mut data = serde_json::json!({"x": 1});
443		let step = Set {
444			field: "version".into(),
445			value: serde_json::json!(2),
446		};
447		step.apply(&mut data).unwrap();
448		assert_eq!(data["version"], 2);
449	}
450
451	#[test]
452	fn set_overwrites_existing() {
453		let mut data = serde_json::json!({"x": 1});
454		let step = Set {
455			field: "x".into(),
456			value: serde_json::json!(99),
457		};
458		step.apply(&mut data).unwrap();
459		assert_eq!(data["x"], 99);
460	}
461
462	#[test]
463	fn upper_converts_string() {
464		let mut data = serde_json::json!({"name": "alice"});
465		Upper {
466			field: "name".into(),
467		}
468		.apply(&mut data)
469		.unwrap();
470		assert_eq!(data["name"], "ALICE");
471	}
472
473	#[test]
474	fn upper_ignores_non_string() {
475		let mut data = serde_json::json!({"count": 42});
476		Upper {
477			field: "count".into(),
478		}
479		.apply(&mut data)
480		.unwrap();
481		assert_eq!(data["count"], 42);
482	}
483
484	#[test]
485	fn lower_converts_string() {
486		let mut data = serde_json::json!({"name": "ALICE"});
487		Lower {
488			field: "name".into(),
489		}
490		.apply(&mut data)
491		.unwrap();
492		assert_eq!(data["name"], "alice");
493	}
494
495	#[test]
496	fn remove_deletes_field() {
497		let mut data = serde_json::json!({"x": 1, "secret": "password"});
498		Remove {
499			field: "secret".into(),
500		}
501		.apply(&mut data)
502		.unwrap();
503		assert_eq!(data, serde_json::json!({"x": 1}));
504	}
505
506	#[test]
507	fn remove_missing_is_noop() {
508		let mut data = serde_json::json!({"x": 1});
509		Remove {
510			field: "missing".into(),
511		}
512		.apply(&mut data)
513		.unwrap();
514		assert_eq!(data, serde_json::json!({"x": 1}));
515	}
516
517	#[test]
518	fn copy_duplicates_value() {
519		let mut data = serde_json::json!({"src": "hello"});
520		Copy {
521			from: "src".into(),
522			to: "dst".into(),
523		}
524		.apply(&mut data)
525		.unwrap();
526		assert_eq!(data["src"], "hello");
527		assert_eq!(data["dst"], "hello");
528	}
529
530	#[test]
531	fn copy_missing_source_is_noop() {
532		let mut data = serde_json::json!({"x": 1});
533		Copy {
534			from: "missing".into(),
535			to: "dst".into(),
536		}
537		.apply(&mut data)
538		.unwrap();
539		assert!(!data.as_object().unwrap().contains_key("dst"));
540	}
541
542	#[test]
543	fn default_sets_when_absent() {
544		let mut data = serde_json::json!({"x": 1});
545		Default {
546			field: "y".into(),
547			value: serde_json::json!(42),
548		}
549		.apply(&mut data)
550		.unwrap();
551		assert_eq!(data["y"], 42);
552	}
553
554	#[test]
555	fn default_sets_when_null() {
556		let mut data = serde_json::json!({"x": null});
557		Default {
558			field: "x".into(),
559			value: serde_json::json!(0),
560		}
561		.apply(&mut data)
562		.unwrap();
563		assert_eq!(data["x"], 0);
564	}
565
566	#[test]
567	fn default_skips_when_present() {
568		let mut data = serde_json::json!({"x": 99});
569		Default {
570			field: "x".into(),
571			value: serde_json::json!(0),
572		}
573		.apply(&mut data)
574		.unwrap();
575		assert_eq!(data["x"], 99);
576	}
577
578	#[test]
579	fn filter_eq_keeps() {
580		let mut data = serde_json::json!({"status": "active"});
581		let step = Filter {
582			field: "status".into(),
583			op: FilterOp::Eq,
584			value: serde_json::json!("active"),
585		};
586		assert!(step.apply(&mut data).unwrap());
587	}
588
589	#[test]
590	fn filter_eq_drops() {
591		let mut data = serde_json::json!({"status": "inactive"});
592		let step = Filter {
593			field: "status".into(),
594			op: FilterOp::Eq,
595			value: serde_json::json!("active"),
596		};
597		assert!(!step.apply(&mut data).unwrap());
598	}
599
600	#[test]
601	fn filter_ne() {
602		let mut data = serde_json::json!({"status": "active"});
603		let step = Filter {
604			field: "status".into(),
605			op: FilterOp::Ne,
606			value: serde_json::json!("deleted"),
607		};
608		assert!(step.apply(&mut data).unwrap());
609	}
610
611	#[test]
612	fn filter_gt_numeric() {
613		let mut data = serde_json::json!({"score": 80});
614		let step = Filter {
615			field: "score".into(),
616			op: FilterOp::Gt,
617			value: serde_json::json!(50),
618		};
619		assert!(step.apply(&mut data).unwrap());
620
621		let mut data2 = serde_json::json!({"score": 30});
622		assert!(!step.apply(&mut data2).unwrap());
623	}
624
625	#[test]
626	fn filter_contains_string() {
627		let mut data = serde_json::json!({"email": "alice@example.com"});
628		let step = Filter {
629			field: "email".into(),
630			op: FilterOp::Contains,
631			value: serde_json::json!("@example"),
632		};
633		assert!(step.apply(&mut data).unwrap());
634	}
635
636	#[test]
637	fn filter_exists() {
638		let mut data = serde_json::json!({"name": "alice"});
639		let step = Filter {
640			field: "name".into(),
641			op: FilterOp::Exists,
642			value: serde_json::json!(null),
643		};
644		assert!(step.apply(&mut data).unwrap());
645
646		let mut data2 = serde_json::json!({"other": 1});
647		assert!(!step.apply(&mut data2).unwrap());
648	}
649
650	#[test]
651	fn filter_missing_field_drops() {
652		let mut data = serde_json::json!({"x": 1});
653		let step = Filter {
654			field: "y".into(),
655			op: FilterOp::Eq,
656			value: serde_json::json!(1),
657		};
658		assert!(!step.apply(&mut data).unwrap());
659	}
660
661	#[test]
662	fn map_value_replaces() {
663		let mut data = serde_json::json!({"op_type": "D"});
664		let mut mapping = serde_json::Map::new();
665		mapping.insert("D".into(), serde_json::json!("deleted"));
666		mapping.insert("U".into(), serde_json::json!("updated"));
667		mapping.insert("I".into(), serde_json::json!("inserted"));
668		let step = MapValue {
669			field: "op_type".into(),
670			mapping,
671		};
672		step.apply(&mut data).unwrap();
673		assert_eq!(data["op_type"], "deleted");
674	}
675
676	#[test]
677	fn map_value_unmapped_unchanged() {
678		let mut data = serde_json::json!({"op_type": "X"});
679		let mut mapping = serde_json::Map::new();
680		mapping.insert("D".into(), serde_json::json!("deleted"));
681		let step = MapValue {
682			field: "op_type".into(),
683			mapping,
684		};
685		step.apply(&mut data).unwrap();
686		assert_eq!(data["op_type"], "X");
687	}
688
689	#[test]
690	fn truncate_shortens() {
691		let mut data = serde_json::json!({"desc": "a very long description text here"});
692		Truncate {
693			field: "desc".into(),
694			max_len: 10,
695		}
696		.apply(&mut data)
697		.unwrap();
698		assert_eq!(data["desc"], "a very lon");
699	}
700
701	#[test]
702	fn truncate_short_string_unchanged() {
703		let mut data = serde_json::json!({"desc": "short"});
704		Truncate {
705			field: "desc".into(),
706			max_len: 100,
707		}
708		.apply(&mut data)
709		.unwrap();
710		assert_eq!(data["desc"], "short");
711	}
712
713	#[test]
714	fn nest_groups_fields() {
715		let mut data = serde_json::json!({"city": "NYC", "zip": "10001", "name": "alice"});
716		Nest {
717			fields: vec!["city".into(), "zip".into()],
718			into: "address".into(),
719		}
720		.apply(&mut data)
721		.unwrap();
722		assert_eq!(data["address"]["city"], "NYC");
723		assert_eq!(data["address"]["zip"], "10001");
724		assert_eq!(data["name"], "alice");
725		assert!(!data.as_object().unwrap().contains_key("city"));
726	}
727
728	#[test]
729	fn nest_partial_fields() {
730		let mut data = serde_json::json!({"city": "NYC"});
731		Nest {
732			fields: vec!["city".into(), "missing".into()],
733			into: "addr".into(),
734		}
735		.apply(&mut data)
736		.unwrap();
737		assert_eq!(data["addr"]["city"], "NYC");
738		assert!(!data["addr"].as_object().unwrap().contains_key("missing"));
739	}
740
741	#[test]
742	fn flatten_inlines_nested() {
743		let mut data = serde_json::json!({"meta": {"source": "pg", "version": 2}, "id": 1});
744		Flatten {
745			field: "meta".into(),
746		}
747		.apply(&mut data)
748		.unwrap();
749		assert_eq!(data["source"], "pg");
750		assert_eq!(data["version"], 2);
751		assert_eq!(data["id"], 1);
752		assert!(!data.as_object().unwrap().contains_key("meta"));
753	}
754
755	#[test]
756	fn flatten_missing_is_noop() {
757		let mut data = serde_json::json!({"id": 1});
758		Flatten {
759			field: "meta".into(),
760		}
761		.apply(&mut data)
762		.unwrap();
763		assert_eq!(data, serde_json::json!({"id": 1}));
764	}
765
766	#[test]
767	fn hash_sha256() {
768		let mut data = serde_json::json!({"email": "alice@example.com"});
769		Hash {
770			field: "email".into(),
771		}
772		.apply(&mut data)
773		.unwrap();
774		let hashed = data["email"].as_str().unwrap();
775		assert_eq!(hashed.len(), 64);
776		assert!(hashed.chars().all(|c| c.is_ascii_hexdigit()));
777		assert_ne!(hashed, "alice@example.com");
778	}
779
780	#[test]
781	fn hash_missing_is_noop() {
782		let mut data = serde_json::json!({"x": 1});
783		Hash {
784			field: "missing".into(),
785		}
786		.apply(&mut data)
787		.unwrap();
788		assert_eq!(data, serde_json::json!({"x": 1}));
789	}
790
791	#[test]
792	fn coalesce_takes_first_non_null() {
793		let mut data = serde_json::json!({"a": null, "b": "found", "c": "also"});
794		Coalesce {
795			fields: vec!["a".into(), "b".into(), "c".into()],
796			into: "result".into(),
797		}
798		.apply(&mut data)
799		.unwrap();
800		assert_eq!(data["result"], "found");
801	}
802
803	#[test]
804	fn coalesce_all_null_no_write() {
805		let mut data = serde_json::json!({"a": null, "b": null});
806		Coalesce {
807			fields: vec!["a".into(), "b".into()],
808			into: "result".into(),
809		}
810		.apply(&mut data)
811		.unwrap();
812		assert!(!data.as_object().unwrap().contains_key("result"));
813	}
814
815	#[test]
816	fn coalesce_missing_field_skipped() {
817		let mut data = serde_json::json!({"b": 42});
818		Coalesce {
819			fields: vec!["missing".into(), "b".into()],
820			into: "out".into(),
821		}
822		.apply(&mut data)
823		.unwrap();
824		assert_eq!(data["out"], 42);
825	}
826
827	// ── SchemaFilter tests ──────────────────────────────────────
828
829	fn re(pattern: &str) -> regex::Regex {
830		regex::Regex::new(pattern).unwrap()
831	}
832
833	#[test]
834	fn schema_filter_allow_keeps_matching() {
835		let mut data = serde_json::json!({"table": "public.users"});
836		let step = SchemaFilter {
837			field: "table".into(),
838			allow: vec![re("^public\\.")],
839			deny: vec![],
840		};
841		assert!(step.apply(&mut data).unwrap());
842	}
843
844	#[test]
845	fn schema_filter_allow_drops_non_matching() {
846		let mut data = serde_json::json!({"table": "internal.secrets"});
847		let step = SchemaFilter {
848			field: "table".into(),
849			allow: vec![re("^public\\.")],
850			deny: vec![],
851		};
852		assert!(!step.apply(&mut data).unwrap());
853	}
854
855	#[test]
856	fn schema_filter_deny_drops_matching() {
857		let mut data = serde_json::json!({"table": "pg_catalog.pg_class"});
858		let step = SchemaFilter {
859			field: "table".into(),
860			allow: vec![],
861			deny: vec![re("^pg_catalog"), re("^information_schema")],
862		};
863		assert!(!step.apply(&mut data).unwrap());
864	}
865
866	#[test]
867	fn schema_filter_deny_keeps_non_matching() {
868		let mut data = serde_json::json!({"table": "public.users"});
869		let step = SchemaFilter {
870			field: "table".into(),
871			allow: vec![],
872			deny: vec![re("^pg_catalog")],
873		};
874		assert!(step.apply(&mut data).unwrap());
875	}
876
877	#[test]
878	fn schema_filter_deny_takes_precedence_over_allow() {
879		let mut data = serde_json::json!({"table": "public.pg_temp"});
880		let step = SchemaFilter {
881			field: "table".into(),
882			allow: vec![re("^public\\.")],
883			deny: vec![re("pg_temp$")],
884		};
885		assert!(!step.apply(&mut data).unwrap());
886	}
887
888	#[test]
889	fn schema_filter_multiple_allow_any_matches() {
890		let step = SchemaFilter {
891			field: "schema".into(),
892			allow: vec![re("^public$"), re("^analytics$")],
893			deny: vec![],
894		};
895		let mut d1 = serde_json::json!({"schema": "public"});
896		assert!(step.apply(&mut d1).unwrap());
897		let mut d2 = serde_json::json!({"schema": "analytics"});
898		assert!(step.apply(&mut d2).unwrap());
899		let mut d3 = serde_json::json!({"schema": "internal"});
900		assert!(!step.apply(&mut d3).unwrap());
901	}
902
903	#[test]
904	fn schema_filter_missing_field_dropped_when_allow_set() {
905		let mut data = serde_json::json!({"other": "value"});
906		let step = SchemaFilter {
907			field: "table".into(),
908			allow: vec![re(".*")],
909			deny: vec![],
910		};
911		assert!(!step.apply(&mut data).unwrap());
912	}
913
914	#[test]
915	fn schema_filter_missing_field_kept_when_deny_only() {
916		let mut data = serde_json::json!({"other": "value"});
917		let step = SchemaFilter {
918			field: "table".into(),
919			allow: vec![],
920			deny: vec![re("secret")],
921		};
922		assert!(step.apply(&mut data).unwrap());
923	}
924
925	// ── Overwrite edge case tests ───────────────────────────────
926
927	#[test]
928	fn rename_overwrites_existing_target() {
929		let mut data = serde_json::json!({"old": "moved", "new": "existing"});
930		Rename {
931			from: "old".into(),
932			to: "new".into(),
933		}
934		.apply(&mut data)
935		.unwrap();
936		assert_eq!(data["new"], "moved");
937		assert!(!data.as_object().unwrap().contains_key("old"));
938	}
939
940	#[test]
941	fn flatten_overwrites_parent_on_collision() {
942		let mut data = serde_json::json!({"id": 1, "meta": {"id": "nested", "extra": "val"}});
943		Flatten {
944			field: "meta".into(),
945		}
946		.apply(&mut data)
947		.unwrap();
948		assert_eq!(data["id"], "nested"); // parent overwritten
949		assert_eq!(data["extra"], "val");
950	}
951
952	#[test]
953	fn nest_overwrites_existing_target() {
954		let mut data = serde_json::json!({"a": 1, "b": 2, "target": "old"});
955		Nest {
956			fields: vec!["a".into(), "b".into()],
957			into: "target".into(),
958		}
959		.apply(&mut data)
960		.unwrap();
961		assert!(data["target"].is_object()); // overwritten with nested object
962		assert_eq!(data["target"]["a"], 1);
963	}
964}
965
966#[cfg(test)]
967mod prop_tests {
968	use super::*;
969	use crate::{StepChain, TransformStep};
970	use proptest::prelude::*;
971
972	fn arb_json_leaf() -> impl Strategy<Value = serde_json::Value> {
973		prop_oneof![
974			Just(serde_json::Value::Null),
975			any::<bool>().prop_map(serde_json::Value::Bool),
976			any::<i64>().prop_map(|n| serde_json::json!(n)),
977			"[a-zA-Z0-9_ ]{0,30}".prop_map(serde_json::Value::String),
978		]
979	}
980
981	fn arb_json_object() -> impl Strategy<Value = serde_json::Value> {
982		prop::collection::vec(("[a-z]{1,8}", arb_json_leaf()), 0..10).prop_map(|pairs| {
983			let map: serde_json::Map<String, serde_json::Value> = pairs.into_iter().collect();
984			serde_json::Value::Object(map)
985		})
986	}
987
988	fn arb_field_name() -> impl Strategy<Value = String> {
989		"[a-z]{1,8}"
990	}
991
992	proptest! {
993		#[test]
994		fn rename_roundtrip_is_identity(
995			base in arb_json_object(),
996			value in arb_json_leaf(),
997		) {
998			// Construct object with known field "src" and without "dst"
999			let mut obj = base;
1000			obj.as_object_mut().unwrap().insert("src".into(), value);
1001			obj.as_object_mut().unwrap().remove("dst");
1002			let original = obj.clone();
1003
1004			Rename { from: "src".into(), to: "dst".into() }.apply(&mut obj).unwrap();
1005			Rename { from: "dst".into(), to: "src".into() }.apply(&mut obj).unwrap();
1006			prop_assert_eq!(obj, original);
1007		}
1008
1009		#[test]
1010		fn set_then_remove_restores_keys(
1011			mut obj in arb_json_object(),
1012			field in arb_field_name(),
1013			value in arb_json_leaf(),
1014		) {
1015			prop_assume!(!obj.as_object().unwrap().contains_key(&field));
1016			let original = obj.clone();
1017			Set { field: field.clone(), value }.apply(&mut obj).unwrap();
1018			Remove { field }.apply(&mut obj).unwrap();
1019			prop_assert_eq!(obj, original);
1020		}
1021
1022		#[test]
1023		fn upper_then_lower_idempotent(
1024			mut obj in arb_json_object(),
1025			field in arb_field_name(),
1026		) {
1027			// upper(lower(x)) = upper(x) for any string
1028			let mut obj2 = obj.clone();
1029			Upper { field: field.clone() }.apply(&mut obj).unwrap();
1030			let after_upper = obj.clone();
1031
1032			Lower { field: field.clone() }.apply(&mut obj).unwrap();
1033			Upper { field: field.clone() }.apply(&mut obj).unwrap();
1034			prop_assert_eq!(obj, after_upper);
1035
1036			// lower(upper(x)) = lower(x)
1037			Lower { field: field.clone() }.apply(&mut obj2).unwrap();
1038			let after_lower = obj2.clone();
1039			Upper { field: field.clone() }.apply(&mut obj2).unwrap();
1040			Lower { field }.apply(&mut obj2).unwrap();
1041			prop_assert_eq!(obj2, after_lower);
1042		}
1043
1044		#[test]
1045		fn copy_preserves_source(
1046			mut obj in arb_json_object(),
1047			src in arb_field_name(),
1048			dst in arb_field_name(),
1049		) {
1050			prop_assume!(src != dst);
1051			let original_src = obj.as_object().and_then(|o| o.get(&src)).cloned();
1052			Copy { from: src.clone(), to: dst }.apply(&mut obj).unwrap();
1053			let after_src = obj.as_object().and_then(|o| o.get(&src)).cloned();
1054			prop_assert_eq!(original_src, after_src);
1055		}
1056
1057		#[test]
1058		fn default_is_idempotent(
1059			mut obj in arb_json_object(),
1060			field in arb_field_name(),
1061			value in arb_json_leaf(),
1062		) {
1063			Default { field: field.clone(), value: value.clone() }.apply(&mut obj).unwrap();
1064			let after_first = obj.clone();
1065			Default { field, value }.apply(&mut obj).unwrap();
1066			prop_assert_eq!(obj, after_first);
1067		}
1068
1069		#[test]
1070		fn filter_eq_ne_are_complementary(
1071			obj in arb_json_object(),
1072			field in arb_field_name(),
1073			value in arb_json_leaf(),
1074		) {
1075			let mut obj_eq = obj.clone();
1076			let mut obj_ne = obj;
1077			let eq_result = Filter { field: field.clone(), op: FilterOp::Eq, value: value.clone() }
1078				.apply(&mut obj_eq).unwrap();
1079			let ne_result = Filter { field: field.clone(), op: FilterOp::Ne, value }
1080				.apply(&mut obj_ne).unwrap();
1081
1082			// If field exists, eq and ne are complementary
1083			if obj_eq.as_object().unwrap().contains_key(&field) {
1084				prop_assert_ne!(eq_result, ne_result);
1085			}
1086		}
1087
1088		#[test]
1089		fn chain_never_panics_on_valid_json(
1090			mut obj in arb_json_object(),
1091			field_a in arb_field_name(),
1092			field_b in arb_field_name(),
1093			value in arb_json_leaf(),
1094		) {
1095			let steps: Vec<Box<dyn TransformStep>> = vec![
1096				Box::new(Set { field: field_a.clone(), value: value.clone() }),
1097				Box::new(Rename { from: field_a.clone(), to: field_b.clone() }),
1098				Box::new(Upper { field: field_b.clone() }),
1099				Box::new(Lower { field: field_b.clone() }),
1100				Box::new(Copy { from: field_b.clone(), to: field_a.clone() }),
1101				Box::new(Default { field: "missing".into(), value }),
1102				Box::new(Remove { field: field_a }),
1103			];
1104			let chain = StepChain::new(steps);
1105			let result = chain.apply_one(&mut obj);
1106			prop_assert!(result.is_ok());
1107		}
1108
1109		#[test]
1110		fn truncate_respects_max_len(
1111			mut obj in arb_json_object(),
1112			field in arb_field_name(),
1113			max_len in 0usize..100,
1114		) {
1115			Truncate { field: field.clone(), max_len }.apply(&mut obj).unwrap();
1116			if let Some(s) = obj.as_object().and_then(|o| o.get(&field)).and_then(|v| v.as_str()) {
1117				prop_assert!(s.chars().count() <= max_len);
1118			}
1119		}
1120
1121		#[test]
1122		fn remove_then_default_sets_value(
1123			mut obj in arb_json_object(),
1124			field in arb_field_name(),
1125			value in arb_json_leaf(),
1126		) {
1127			Remove { field: field.clone() }.apply(&mut obj).unwrap();
1128			Default { field: field.clone(), value: value.clone() }.apply(&mut obj).unwrap();
1129			prop_assert_eq!(obj.as_object().unwrap().get(&field).unwrap(), &value);
1130		}
1131
1132		#[test]
1133		fn nest_then_flatten_preserves_fields(
1134			field_a in arb_field_name(),
1135			field_b in arb_field_name(),
1136			val_a in arb_json_leaf(),
1137			val_b in arb_json_leaf(),
1138		) {
1139			let nest_target = "nested".to_string();
1140			prop_assume!(field_a != field_b);
1141			prop_assume!(field_a != nest_target && field_b != nest_target);
1142
1143			let mut obj = serde_json::json!({ &field_a: val_a.clone(), &field_b: val_b.clone() });
1144			Nest { fields: vec![field_a.clone(), field_b.clone()], into: nest_target.clone() }
1145				.apply(&mut obj).unwrap();
1146			Flatten { field: nest_target }.apply(&mut obj).unwrap();
1147
1148			prop_assert_eq!(obj.as_object().unwrap().get(&field_a).unwrap(), &val_a);
1149			prop_assert_eq!(obj.as_object().unwrap().get(&field_b).unwrap(), &val_b);
1150		}
1151
1152		#[test]
1153		fn hash_is_deterministic(
1154			obj in arb_json_object(),
1155			field in arb_field_name(),
1156		) {
1157			let mut obj1 = obj.clone();
1158			let mut obj2 = obj;
1159			Hash { field: field.clone() }.apply(&mut obj1).unwrap();
1160			Hash { field }.apply(&mut obj2).unwrap();
1161			prop_assert_eq!(obj1, obj2);
1162		}
1163	}
1164}