1use crate::error::FaucetError;
4use serde::{Deserialize, Serialize};
5use serde_json::{Map, Value};
6use std::collections::HashMap;
7
8#[derive(
10 Debug, Clone, Copy, Default, Serialize, Deserialize, schemars::JsonSchema, PartialEq, Eq,
11)]
12#[serde(rename_all = "snake_case")]
13pub enum WriteMode {
14 #[default]
16 Append,
17 Upsert,
19 Delete,
21}
22
23impl WriteMode {
24 pub fn as_str(&self) -> &'static str {
26 match self {
27 WriteMode::Append => "append",
28 WriteMode::Upsert => "upsert",
29 WriteMode::Delete => "delete",
30 }
31 }
32}
33
34#[derive(Debug, Clone, Default, Serialize, Deserialize, schemars::JsonSchema, PartialEq, Eq)]
37pub struct DeleteMarker {
38 pub field: String,
40 pub values: Vec<String>,
42}
43
44#[derive(Debug, Clone, Default, Serialize, Deserialize, schemars::JsonSchema)]
48pub struct WriteSpec {
49 #[serde(default)]
51 pub write_mode: WriteMode,
52 #[serde(default)]
54 pub key: Vec<String>,
55 #[serde(default, skip_serializing_if = "Option::is_none")]
59 pub delete_marker: Option<DeleteMarker>,
60}
61
62impl WriteSpec {
63 pub fn validate(&self) -> Result<(), FaucetError> {
65 if matches!(self.write_mode, WriteMode::Upsert | WriteMode::Delete) && self.key.is_empty() {
66 return Err(FaucetError::Config(format!(
67 "write_mode: {} requires a non-empty `key`",
68 self.write_mode.as_str()
69 )));
70 }
71 Ok(())
72 }
73}
74
75#[derive(Debug, Clone, PartialEq)]
77pub struct KeyTuple(pub Vec<(String, Value)>);
78
79#[derive(Debug, Default)]
83pub struct WritePlan {
84 pub upserts: Vec<Value>,
86 pub deletes: Vec<KeyTuple>,
88 pub failed: Vec<(usize, String)>,
90}
91
92#[derive(Clone)]
93enum Action {
94 Upsert(Value),
95 Delete(KeyTuple),
96}
97
98pub fn plan_writes(page: &[Value], spec: &WriteSpec) -> WritePlan {
102 debug_assert!(
103 spec.write_mode != WriteMode::Append,
104 "plan_writes called with WriteMode::Append — callers must route append separately"
105 );
106 let mut plan = WritePlan::default();
107 let mut index: HashMap<String, usize> = HashMap::new();
108 let mut order: Vec<Action> = Vec::new();
109
110 for (i, rec) in page.iter().enumerate() {
111 let key_tuple = match extract_key(rec, &spec.key) {
112 Ok(k) => k,
113 Err(msg) => {
114 plan.failed.push((i, msg));
115 continue;
116 }
117 };
118 let canon = canonical(&key_tuple);
119
120 let is_delete = match spec.write_mode {
121 WriteMode::Delete => true,
122 WriteMode::Upsert => is_delete_marked(rec, spec.delete_marker.as_ref()),
123 WriteMode::Append => false,
124 };
125
126 let action = if is_delete {
127 Action::Delete(key_tuple)
128 } else {
129 Action::Upsert(strip_marker(rec.clone(), spec.delete_marker.as_ref()))
130 };
131
132 match index.get(&canon) {
133 Some(&slot) => order[slot] = action,
134 None => {
135 index.insert(canon, order.len());
136 order.push(action);
137 }
138 }
139 }
140
141 for action in order {
142 match action {
143 Action::Upsert(v) => plan.upserts.push(v),
144 Action::Delete(k) => plan.deletes.push(k),
145 }
146 }
147 plan
148}
149
150fn extract_key(rec: &Value, key: &[String]) -> Result<KeyTuple, String> {
153 let obj = rec
154 .as_object()
155 .ok_or_else(|| "record is not a JSON object".to_string())?;
156 let mut out = Vec::with_capacity(key.len());
157 for col in key {
158 match obj.get(col) {
159 None => return Err(format!("missing key column '{col}'")),
160 Some(Value::Null) => return Err(format!("null value for key column '{col}'")),
161 Some(v) => out.push((col.clone(), v.clone())),
162 }
163 }
164 Ok(KeyTuple(out))
165}
166
167fn is_delete_marked(rec: &Value, marker: Option<&DeleteMarker>) -> bool {
168 let Some(dm) = marker else { return false };
169 let Some(v) = rec.get(&dm.field) else {
170 return false;
171 };
172 let Some(s) = v.as_str() else { return false };
173 dm.values.iter().any(|m| m == s)
174}
175
176fn strip_marker(mut rec: Value, marker: Option<&DeleteMarker>) -> Value {
177 if let (Some(dm), Value::Object(map)) = (marker, &mut rec) {
178 map.remove(&dm.field);
179 }
180 rec
181}
182
183fn canonical(k: &KeyTuple) -> String {
185 let arr: Vec<&Value> = k.0.iter().map(|(_, v)| v).collect();
186 serde_json::to_string(&arr).expect("a Vec<&serde_json::Value> always serializes")
187}
188
189pub fn key_to_doc_id(k: &KeyTuple, separator: &str) -> String {
203 let _ = separator; if k.0.len() == 1 {
205 return match &k.0[0].1 {
206 Value::String(s) => s.clone(),
207 other => other.to_string(),
208 };
209 }
210 let values: Vec<&Value> = k.0.iter().map(|(_, v)| v).collect();
211 serde_json::to_string(&values).expect("a Vec<&serde_json::Value> always serializes")
212}
213
214pub fn key_to_filter(k: &KeyTuple) -> Map<String, Value> {
216 k.0.iter().map(|(c, v)| (c.clone(), v.clone())).collect()
217}
218
219#[cfg(test)]
220mod tests {
221 use super::*;
222 use serde_json::json;
223
224 fn upsert_spec(keys: &[&str]) -> WriteSpec {
225 WriteSpec {
226 write_mode: WriteMode::Upsert,
227 key: keys.iter().map(|s| s.to_string()).collect(),
228 delete_marker: None,
229 }
230 }
231
232 #[test]
233 fn upsert_extracts_key_and_keeps_row() {
234 let plan = plan_writes(&[json!({"id": 1, "name": "a"})], &upsert_spec(&["id"]));
235 assert_eq!(plan.upserts, vec![json!({"id": 1, "name": "a"})]);
236 assert!(plan.deletes.is_empty());
237 assert!(plan.failed.is_empty());
238 }
239
240 #[test]
241 fn key_to_doc_id_single_key_is_plain() {
242 let k = KeyTuple(vec![("id".into(), json!(7))]);
243 assert_eq!(key_to_doc_id(&k, "_"), "7");
244 let k = KeyTuple(vec![("name".into(), json!("alice"))]);
245 assert_eq!(key_to_doc_id(&k, "_"), "alice");
246 }
247
248 #[test]
249 fn key_to_doc_id_composite_is_injective() {
250 let k1 = KeyTuple(vec![("x".into(), json!("a_")), ("y".into(), json!("b"))]);
252 let k2 = KeyTuple(vec![("x".into(), json!("a")), ("y".into(), json!("_b"))]);
253 let id1 = key_to_doc_id(&k1, "_");
254 let id2 = key_to_doc_id(&k2, "_");
255 assert_ne!(id1, id2, "distinct composite keys must map to distinct ids");
256 let k3 = KeyTuple(vec![("x".into(), json!(1)), ("y".into(), json!("2"))]);
258 let k4 = KeyTuple(vec![("x".into(), json!("1")), ("y".into(), json!(2))]);
259 assert_ne!(key_to_doc_id(&k3, "_"), key_to_doc_id(&k4, "_"));
260 }
261
262 #[test]
263 fn missing_key_goes_to_failed_with_original_index() {
264 let plan = plan_writes(
265 &[json!({"id": 1}), json!({"name": "no-key"})],
266 &upsert_spec(&["id"]),
267 );
268 assert_eq!(plan.upserts.len(), 1);
269 assert_eq!(plan.failed.len(), 1);
270 assert_eq!(plan.failed[0].0, 1, "failed row keeps its page index");
271 }
272
273 #[test]
274 fn null_key_value_is_a_failure() {
275 let plan = plan_writes(&[json!({"id": null})], &upsert_spec(&["id"]));
276 assert!(plan.upserts.is_empty());
277 assert_eq!(plan.failed.len(), 1);
278 }
279
280 #[test]
281 fn delete_marker_routes_to_deletes_and_strips_marker() {
282 let spec = WriteSpec {
283 write_mode: WriteMode::Upsert,
284 key: vec!["id".into()],
285 delete_marker: Some(DeleteMarker {
286 field: "__op".into(),
287 values: vec!["d".into()],
288 }),
289 };
290 let plan = plan_writes(
291 &[
292 json!({"id": 1, "name": "a", "__op": "u"}),
293 json!({"id": 2, "__op": "d"}),
294 ],
295 &spec,
296 );
297 assert_eq!(plan.upserts, vec![json!({"id": 1, "name": "a"})]);
298 assert_eq!(plan.deletes.len(), 1);
299 assert_eq!(plan.deletes[0].0, vec![("id".to_string(), json!(2))]);
300 }
301
302 #[test]
303 fn last_write_wins_dedup_keeps_final_upsert() {
304 let plan = plan_writes(
305 &[json!({"id": 1, "v": "old"}), json!({"id": 1, "v": "new"})],
306 &upsert_spec(&["id"]),
307 );
308 assert_eq!(plan.upserts, vec![json!({"id": 1, "v": "new"})]);
309 }
310
311 #[test]
312 fn last_write_wins_delete_after_upsert_is_a_delete() {
313 let spec = WriteSpec {
314 write_mode: WriteMode::Upsert,
315 key: vec!["id".into()],
316 delete_marker: Some(DeleteMarker {
317 field: "__op".into(),
318 values: vec!["d".into()],
319 }),
320 };
321 let plan = plan_writes(
322 &[json!({"id": 1, "__op": "u"}), json!({"id": 1, "__op": "d"})],
323 &spec,
324 );
325 assert!(plan.upserts.is_empty());
326 assert_eq!(plan.deletes.len(), 1);
327 }
328
329 #[test]
330 fn delete_mode_routes_every_row_to_deletes() {
331 let spec = WriteSpec {
332 write_mode: WriteMode::Delete,
333 key: vec!["id".into()],
334 delete_marker: None,
335 };
336 let plan = plan_writes(&[json!({"id": 1}), json!({"id": 2})], &spec);
337 assert!(plan.upserts.is_empty());
338 assert_eq!(plan.deletes.len(), 2);
339 }
340
341 #[test]
342 fn composite_key_tuple_is_ordered() {
343 let plan = plan_writes(
344 &[json!({"a": 1, "b": 2, "v": 9})],
345 &upsert_spec(&["a", "b"]),
346 );
347 assert_eq!(plan.upserts.len(), 1);
348 let plan2 = plan_writes(
349 &[
350 json!({"a": 1, "b": 2, "v": "x"}),
351 json!({"a": 1, "b": 3, "v": "y"}),
352 ],
353 &upsert_spec(&["a", "b"]),
354 );
355 assert_eq!(plan2.upserts.len(), 2, "(1,2) and (1,3) are distinct keys");
356 }
357
358 #[test]
359 fn validate_rejects_upsert_without_key() {
360 let spec = WriteSpec {
361 write_mode: WriteMode::Upsert,
362 key: vec![],
363 delete_marker: None,
364 };
365 assert!(spec.validate().is_err());
366 }
367
368 #[test]
369 fn validate_allows_append_without_key() {
370 assert!(WriteSpec::default().validate().is_ok());
371 }
372
373 #[test]
374 fn last_write_wins_upsert_after_delete_is_an_upsert() {
375 let spec = WriteSpec {
377 write_mode: WriteMode::Upsert,
378 key: vec!["id".into()],
379 delete_marker: Some(DeleteMarker {
380 field: "__op".into(),
381 values: vec!["d".into()],
382 }),
383 };
384 let plan = plan_writes(
385 &[
386 json!({"id": 1, "__op": "d"}),
387 json!({"id": 1, "v": 9, "__op": "u"}),
388 ],
389 &spec,
390 );
391 assert!(plan.deletes.is_empty());
392 assert_eq!(plan.upserts, vec![json!({"id": 1, "v": 9})]);
393 }
394
395 #[test]
396 fn empty_page_produces_empty_plan() {
397 let plan = plan_writes(&[], &upsert_spec(&["id"]));
398 assert!(plan.upserts.is_empty());
399 assert!(plan.deletes.is_empty());
400 assert!(plan.failed.is_empty());
401 }
402
403 #[test]
404 fn delete_mode_dedups_repeated_key() {
405 let spec = WriteSpec {
407 write_mode: WriteMode::Delete,
408 key: vec!["id".into()],
409 delete_marker: None,
410 };
411 let plan = plan_writes(&[json!({"id": 1}), json!({"id": 1})], &spec);
412 assert_eq!(plan.deletes.len(), 1);
413 }
414}