tokenfold_core/transforms/
json_fold.rs1use serde_json::{Map, Value};
22
23pub const TRANSFORM_ID: &str = "json_field_fold";
25
26pub const TRANSFORM_VERSION: &str = "1.0.0";
28
29const COLS: &str = "__tf_cols__";
32const ROWS: &str = "__tf_rows__";
33
34const MIN_ROWS: usize = 2;
38
39#[derive(Debug, thiserror::Error)]
40pub enum JsonFoldError {
41 #[error("invalid json: {0}")]
42 Invalid(#[from] serde_json::Error),
43}
44
45pub fn fold_json(input: &[u8]) -> Result<Vec<u8>, JsonFoldError> {
49 if input.is_empty() {
50 return Ok(Vec::new());
51 }
52 let value: Value = serde_json::from_slice(input)?;
53 let folded = fold_value(&value);
54 Ok(serde_json::to_vec(&folded)?)
55}
56
57pub fn unfold_json(input: &[u8]) -> Result<Vec<u8>, JsonFoldError> {
60 if input.is_empty() {
61 return Ok(Vec::new());
62 }
63 let value: Value = serde_json::from_slice(input)?;
64 let unfolded = unfold_value(&value);
65 Ok(serde_json::to_vec(&unfolded)?)
66}
67
68pub fn round_trips(before: &[u8], after: &[u8]) -> bool {
71 let (Ok(before_v), Ok(after_v)) = (
72 serde_json::from_slice::<Value>(before),
73 serde_json::from_slice::<Value>(after),
74 ) else {
75 return false;
76 };
77 unfold_value(&after_v) == before_v
78}
79
80fn fold_value(v: &Value) -> Value {
81 match v {
82 Value::Array(items) => {
83 let folded: Vec<Value> = items.iter().map(fold_value).collect();
84 try_fold_array(&folded).unwrap_or(Value::Array(folded))
85 }
86 Value::Object(map) => {
87 let mut out = Map::new();
88 for (k, val) in map {
89 out.insert(k.clone(), fold_value(val));
90 }
91 Value::Object(out)
92 }
93 _ => v.clone(),
94 }
95}
96
97fn try_fold_array(items: &[Value]) -> Option<Value> {
100 if items.len() < MIN_ROWS {
101 return None;
102 }
103 let first = items[0].as_object()?;
104 if first.is_empty() || first.contains_key(COLS) || first.contains_key(ROWS) {
105 return None;
106 }
107 let cols: Vec<String> = first.keys().cloned().collect();
111 for item in items {
112 let obj = item.as_object()?;
113 if obj.len() != cols.len() || !cols.iter().all(|k| obj.contains_key(k)) {
114 return None;
115 }
116 }
117 let rows: Vec<Value> = items
118 .iter()
119 .map(|item| {
120 let obj = item.as_object().expect("checked above");
121 Value::Array(cols.iter().map(|k| obj[k].clone()).collect())
122 })
123 .collect();
124 let mut folded = Map::new();
125 folded.insert(
126 COLS.to_string(),
127 Value::Array(cols.into_iter().map(Value::String).collect()),
128 );
129 folded.insert(ROWS.to_string(), Value::Array(rows));
130 Some(Value::Object(folded))
131}
132
133fn unfold_value(v: &Value) -> Value {
134 match v {
135 Value::Object(map) => {
136 if let Some(arr) = try_unfold_node(map) {
137 arr
138 } else {
139 let mut out = Map::new();
140 for (k, val) in map {
141 out.insert(k.clone(), unfold_value(val));
142 }
143 Value::Object(out)
144 }
145 }
146 Value::Array(items) => Value::Array(items.iter().map(unfold_value).collect()),
147 _ => v.clone(),
148 }
149}
150
151fn try_unfold_node(map: &Map<String, Value>) -> Option<Value> {
155 if map.len() != 2 {
156 return None;
157 }
158 let cols = map.get(COLS)?.as_array()?;
159 let rows = map.get(ROWS)?.as_array()?;
160 let col_names: Vec<&str> = cols.iter().map(|c| c.as_str()).collect::<Option<_>>()?;
161 let mut out = Vec::with_capacity(rows.len());
162 for row in rows {
163 let vals = row.as_array()?;
164 if vals.len() != col_names.len() {
165 return None;
166 }
167 let mut obj = Map::new();
168 for (name, val) in col_names.iter().zip(vals) {
169 obj.insert((*name).to_string(), unfold_value(val));
170 }
171 out.push(Value::Object(obj));
172 }
173 Some(Value::Array(out))
174}
175
176#[cfg(test)]
177mod tests {
178 use super::*;
179
180 fn parse(b: &[u8]) -> Value {
181 serde_json::from_slice(b).unwrap()
182 }
183
184 #[test]
185 fn folds_homogeneous_array_and_removes_repeated_keys() {
186 let input = br#"[{"id":1,"role":"member"},{"id":2,"role":"member"}]"#;
187 let out = fold_json(input).unwrap();
188 let s = String::from_utf8(out.clone()).unwrap();
189 assert!(s.contains("__tf_cols__"), "expected folded form, got {s}");
190 assert_eq!(s.matches("role").count(), 1);
192 assert!(round_trips(input, &out));
194 }
195
196 #[test]
197 fn fold_then_unfold_reproduces_original_value() {
198 let input = br#"{"count":2,"results":[{"a":1,"b":2},{"a":3,"b":4}]}"#;
199 let folded = fold_json(input).unwrap();
200 let unfolded = unfold_json(&folded).unwrap();
201 assert_eq!(parse(&unfolded), parse(input));
202 }
203
204 #[test]
205 fn nested_arrays_of_objects_fold_too() {
206 let input = br#"{"groups":[{"users":[{"id":1},{"id":2}]},{"users":[{"id":3},{"id":4}]}]}"#;
207 let out = fold_json(input).unwrap();
208 assert!(round_trips(input, &out));
209 let s = String::from_utf8(out).unwrap();
211 assert!(s.contains("__tf_cols__"));
212 }
213
214 #[test]
215 fn heterogeneous_array_is_left_alone() {
216 let input = br#"[{"a":1},{"b":2}]"#;
218 let out = fold_json(input).unwrap();
219 assert_eq!(parse(&out), parse(input));
220 assert!(!String::from_utf8(out).unwrap().contains("__tf_cols__"));
221 }
222
223 #[test]
224 fn array_of_non_objects_is_left_alone() {
225 let input = br#"[1,2,3,4]"#;
226 let out = fold_json(input).unwrap();
227 assert_eq!(parse(&out), parse(input));
228 }
229
230 #[test]
231 fn single_object_array_is_not_folded() {
232 let input = br#"[{"a":1,"b":2}]"#;
233 let out = fold_json(input).unwrap();
234 assert!(!String::from_utf8(out).unwrap().contains("__tf_cols__"));
235 }
236
237 #[test]
238 fn same_keys_different_order_still_folds_losslessly() {
239 let input = br#"[{"a":1,"b":2},{"b":4,"a":3}]"#;
240 let out = fold_json(input).unwrap();
241 assert!(round_trips(input, &out));
242 }
243
244 #[test]
245 fn source_data_using_reserved_markers_does_not_round_trip_falsely() {
246 let input = br#"{"__tf_cols__":["x"],"__tf_rows__":[[1]]}"#;
250 let folded = fold_json(input).unwrap();
254 assert!(!round_trips(input, &folded));
255 }
256
257 #[test]
258 fn empty_input_is_a_noop() {
259 assert!(fold_json(b"").unwrap().is_empty());
260 assert!(unfold_json(b"").unwrap().is_empty());
261 }
262
263 #[test]
264 fn invalid_json_is_rejected() {
265 assert!(fold_json(b"{not json").is_err());
266 }
267
268 #[test]
269 fn numbers_and_nulls_survive_the_round_trip() {
270 let input = br#"[{"n":1.5,"z":null,"b":true},{"n":2.5,"z":null,"b":false}]"#;
271 let out = fold_json(input).unwrap();
272 assert!(round_trips(input, &out));
273 }
274
275 use proptest::prelude::*;
276
277 fn arb_json() -> impl Strategy<Value = Value> {
280 let leaf = prop_oneof![
281 Just(Value::Null),
282 any::<bool>().prop_map(Value::Bool),
283 any::<i64>().prop_map(|n| Value::Number(n.into())),
284 "[^\"\\\\]{0,12}".prop_map(Value::String),
285 ];
286 leaf.prop_recursive(4, 48, 6, |inner| {
287 prop_oneof![
288 prop::collection::vec(inner.clone(), 0..6).prop_map(Value::Array),
289 prop::collection::hash_map("[a-z]{1,6}", inner, 0..6)
290 .prop_map(|m| Value::Object(m.into_iter().collect())),
291 ]
292 })
293 }
294
295 proptest! {
296 #[test]
299 fn fold_then_unfold_is_the_identity_on_arbitrary_json(v in arb_json()) {
300 let bytes = serde_json::to_vec(&v).unwrap();
301 let folded = fold_json(&bytes).unwrap();
302 prop_assert!(round_trips(&bytes, &folded), "round_trips() rejected a valid fold");
303 let unfolded = unfold_json(&folded).unwrap();
304 let back: Value = serde_json::from_slice(&unfolded).unwrap();
305 prop_assert_eq!(back, v);
306 }
307 }
308}