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 pub fn dedups_by_key(&self) -> bool {
79 matches!(self.write_mode, WriteMode::Upsert | WriteMode::Delete) && !self.key.is_empty()
80 }
81}
82
83#[derive(Debug, Clone, PartialEq)]
85pub struct KeyTuple(pub Vec<(String, Value)>);
86
87#[derive(Debug, Default)]
91pub struct WritePlan {
92 pub upserts: Vec<Value>,
94 pub deletes: Vec<KeyTuple>,
96 pub failed: Vec<(usize, String)>,
98}
99
100#[derive(Clone)]
101enum Action {
102 Upsert(Value),
103 Delete(KeyTuple),
104}
105
106pub fn plan_writes(page: &[Value], spec: &WriteSpec) -> WritePlan {
110 debug_assert!(
111 spec.write_mode != WriteMode::Append,
112 "plan_writes called with WriteMode::Append — callers must route append separately"
113 );
114 let mut plan = WritePlan::default();
115 let mut index: HashMap<String, usize> = HashMap::new();
116 let mut order: Vec<Action> = Vec::new();
117
118 for (i, rec) in page.iter().enumerate() {
119 let key_tuple = match extract_key(rec, &spec.key) {
120 Ok(k) => k,
121 Err(msg) => {
122 plan.failed.push((i, msg));
123 continue;
124 }
125 };
126 let canon = canonical(&key_tuple);
127
128 let is_delete = match spec.write_mode {
129 WriteMode::Delete => true,
130 WriteMode::Upsert => is_delete_marked(rec, spec.delete_marker.as_ref()),
131 WriteMode::Append => false,
132 };
133
134 let action = if is_delete {
135 Action::Delete(key_tuple)
136 } else {
137 Action::Upsert(strip_marker(rec.clone(), spec.delete_marker.as_ref()))
138 };
139
140 match index.get(&canon) {
141 Some(&slot) => order[slot] = action,
142 None => {
143 index.insert(canon, order.len());
144 order.push(action);
145 }
146 }
147 }
148
149 for action in order {
150 match action {
151 Action::Upsert(v) => plan.upserts.push(v),
152 Action::Delete(k) => plan.deletes.push(k),
153 }
154 }
155 plan
156}
157
158fn extract_key(rec: &Value, key: &[String]) -> Result<KeyTuple, String> {
161 let obj = rec
162 .as_object()
163 .ok_or_else(|| "record is not a JSON object".to_string())?;
164 let mut out = Vec::with_capacity(key.len());
165 for col in key {
166 match obj.get(col) {
167 None => return Err(format!("missing key column '{col}'")),
168 Some(Value::Null) => return Err(format!("null value for key column '{col}'")),
169 Some(v) => out.push((col.clone(), v.clone())),
170 }
171 }
172 Ok(KeyTuple(out))
173}
174
175fn is_delete_marked(rec: &Value, marker: Option<&DeleteMarker>) -> bool {
176 let Some(dm) = marker else { return false };
177 let Some(v) = rec.get(&dm.field) else {
178 return false;
179 };
180 let Some(s) = v.as_str() else { return false };
181 dm.values.iter().any(|m| m == s)
182}
183
184fn strip_marker(mut rec: Value, marker: Option<&DeleteMarker>) -> Value {
185 if let (Some(dm), Value::Object(map)) = (marker, &mut rec) {
186 map.remove(&dm.field);
187 }
188 rec
189}
190
191fn canonical(k: &KeyTuple) -> String {
193 let arr: Vec<&Value> = k.0.iter().map(|(_, v)| v).collect();
194 serde_json::to_string(&arr).expect("a Vec<&serde_json::Value> always serializes")
195}
196
197pub fn key_to_doc_id(k: &KeyTuple, separator: &str) -> String {
211 let _ = separator; if k.0.len() == 1 {
213 return match &k.0[0].1 {
214 Value::String(s) => s.clone(),
215 other => other.to_string(),
216 };
217 }
218 let values: Vec<&Value> = k.0.iter().map(|(_, v)| v).collect();
219 serde_json::to_string(&values).expect("a Vec<&serde_json::Value> always serializes")
220}
221
222pub fn key_to_filter(k: &KeyTuple) -> Map<String, Value> {
224 k.0.iter().map(|(c, v)| (c.clone(), v.clone())).collect()
225}
226
227#[cfg(test)]
228mod tests {
229 use super::*;
230 use serde_json::json;
231
232 fn upsert_spec(keys: &[&str]) -> WriteSpec {
233 WriteSpec {
234 write_mode: WriteMode::Upsert,
235 key: keys.iter().map(|s| s.to_string()).collect(),
236 delete_marker: None,
237 }
238 }
239
240 #[test]
241 fn upsert_extracts_key_and_keeps_row() {
242 let plan = plan_writes(&[json!({"id": 1, "name": "a"})], &upsert_spec(&["id"]));
243 assert_eq!(plan.upserts, vec![json!({"id": 1, "name": "a"})]);
244 assert!(plan.deletes.is_empty());
245 assert!(plan.failed.is_empty());
246 }
247
248 #[test]
249 fn key_to_doc_id_single_key_is_plain() {
250 let k = KeyTuple(vec![("id".into(), json!(7))]);
251 assert_eq!(key_to_doc_id(&k, "_"), "7");
252 let k = KeyTuple(vec![("name".into(), json!("alice"))]);
253 assert_eq!(key_to_doc_id(&k, "_"), "alice");
254 }
255
256 #[test]
257 fn key_to_doc_id_composite_is_injective() {
258 let k1 = KeyTuple(vec![("x".into(), json!("a_")), ("y".into(), json!("b"))]);
260 let k2 = KeyTuple(vec![("x".into(), json!("a")), ("y".into(), json!("_b"))]);
261 let id1 = key_to_doc_id(&k1, "_");
262 let id2 = key_to_doc_id(&k2, "_");
263 assert_ne!(id1, id2, "distinct composite keys must map to distinct ids");
264 let k3 = KeyTuple(vec![("x".into(), json!(1)), ("y".into(), json!("2"))]);
266 let k4 = KeyTuple(vec![("x".into(), json!("1")), ("y".into(), json!(2))]);
267 assert_ne!(key_to_doc_id(&k3, "_"), key_to_doc_id(&k4, "_"));
268 }
269
270 #[test]
271 fn missing_key_goes_to_failed_with_original_index() {
272 let plan = plan_writes(
273 &[json!({"id": 1}), json!({"name": "no-key"})],
274 &upsert_spec(&["id"]),
275 );
276 assert_eq!(plan.upserts.len(), 1);
277 assert_eq!(plan.failed.len(), 1);
278 assert_eq!(plan.failed[0].0, 1, "failed row keeps its page index");
279 }
280
281 #[test]
282 fn null_key_value_is_a_failure() {
283 let plan = plan_writes(&[json!({"id": null})], &upsert_spec(&["id"]));
284 assert!(plan.upserts.is_empty());
285 assert_eq!(plan.failed.len(), 1);
286 }
287
288 #[test]
289 fn delete_marker_routes_to_deletes_and_strips_marker() {
290 let spec = WriteSpec {
291 write_mode: WriteMode::Upsert,
292 key: vec!["id".into()],
293 delete_marker: Some(DeleteMarker {
294 field: "__op".into(),
295 values: vec!["d".into()],
296 }),
297 };
298 let plan = plan_writes(
299 &[
300 json!({"id": 1, "name": "a", "__op": "u"}),
301 json!({"id": 2, "__op": "d"}),
302 ],
303 &spec,
304 );
305 assert_eq!(plan.upserts, vec![json!({"id": 1, "name": "a"})]);
306 assert_eq!(plan.deletes.len(), 1);
307 assert_eq!(plan.deletes[0].0, vec![("id".to_string(), json!(2))]);
308 }
309
310 #[test]
311 fn last_write_wins_dedup_keeps_final_upsert() {
312 let plan = plan_writes(
313 &[json!({"id": 1, "v": "old"}), json!({"id": 1, "v": "new"})],
314 &upsert_spec(&["id"]),
315 );
316 assert_eq!(plan.upserts, vec![json!({"id": 1, "v": "new"})]);
317 }
318
319 #[test]
320 fn last_write_wins_delete_after_upsert_is_a_delete() {
321 let spec = WriteSpec {
322 write_mode: WriteMode::Upsert,
323 key: vec!["id".into()],
324 delete_marker: Some(DeleteMarker {
325 field: "__op".into(),
326 values: vec!["d".into()],
327 }),
328 };
329 let plan = plan_writes(
330 &[json!({"id": 1, "__op": "u"}), json!({"id": 1, "__op": "d"})],
331 &spec,
332 );
333 assert!(plan.upserts.is_empty());
334 assert_eq!(plan.deletes.len(), 1);
335 }
336
337 #[test]
338 fn delete_mode_routes_every_row_to_deletes() {
339 let spec = WriteSpec {
340 write_mode: WriteMode::Delete,
341 key: vec!["id".into()],
342 delete_marker: None,
343 };
344 let plan = plan_writes(&[json!({"id": 1}), json!({"id": 2})], &spec);
345 assert!(plan.upserts.is_empty());
346 assert_eq!(plan.deletes.len(), 2);
347 }
348
349 #[test]
350 fn composite_key_tuple_is_ordered() {
351 let plan = plan_writes(
352 &[json!({"a": 1, "b": 2, "v": 9})],
353 &upsert_spec(&["a", "b"]),
354 );
355 assert_eq!(plan.upserts.len(), 1);
356 let plan2 = plan_writes(
357 &[
358 json!({"a": 1, "b": 2, "v": "x"}),
359 json!({"a": 1, "b": 3, "v": "y"}),
360 ],
361 &upsert_spec(&["a", "b"]),
362 );
363 assert_eq!(plan2.upserts.len(), 2, "(1,2) and (1,3) are distinct keys");
364 }
365
366 #[test]
367 fn validate_rejects_upsert_without_key() {
368 let spec = WriteSpec {
369 write_mode: WriteMode::Upsert,
370 key: vec![],
371 delete_marker: None,
372 };
373 assert!(spec.validate().is_err());
374 }
375
376 #[test]
377 fn validate_allows_append_without_key() {
378 assert!(WriteSpec::default().validate().is_ok());
379 }
380
381 #[test]
382 fn dedups_by_key_requires_keyed_upsert_or_delete() {
383 assert!(!WriteSpec::default().dedups_by_key());
384 let upsert = WriteSpec {
385 write_mode: WriteMode::Upsert,
386 key: vec!["id".into()],
387 delete_marker: None,
388 };
389 assert!(upsert.dedups_by_key());
390 let delete = WriteSpec {
391 write_mode: WriteMode::Delete,
392 key: vec!["id".into()],
393 delete_marker: None,
394 };
395 assert!(delete.dedups_by_key());
396 let keyless = WriteSpec {
398 write_mode: WriteMode::Upsert,
399 key: vec![],
400 delete_marker: None,
401 };
402 assert!(!keyless.dedups_by_key());
403 }
404
405 #[test]
406 fn last_write_wins_upsert_after_delete_is_an_upsert() {
407 let spec = WriteSpec {
409 write_mode: WriteMode::Upsert,
410 key: vec!["id".into()],
411 delete_marker: Some(DeleteMarker {
412 field: "__op".into(),
413 values: vec!["d".into()],
414 }),
415 };
416 let plan = plan_writes(
417 &[
418 json!({"id": 1, "__op": "d"}),
419 json!({"id": 1, "v": 9, "__op": "u"}),
420 ],
421 &spec,
422 );
423 assert!(plan.deletes.is_empty());
424 assert_eq!(plan.upserts, vec![json!({"id": 1, "v": 9})]);
425 }
426
427 #[test]
428 fn empty_page_produces_empty_plan() {
429 let plan = plan_writes(&[], &upsert_spec(&["id"]));
430 assert!(plan.upserts.is_empty());
431 assert!(plan.deletes.is_empty());
432 assert!(plan.failed.is_empty());
433 }
434
435 #[test]
436 fn delete_mode_dedups_repeated_key() {
437 let spec = WriteSpec {
439 write_mode: WriteMode::Delete,
440 key: vec!["id".into()],
441 delete_marker: None,
442 };
443 let plan = plan_writes(&[json!({"id": 1}), json!({"id": 1})], &spec);
444 assert_eq!(plan.deletes.len(), 1);
445 }
446}