Skip to main content

faucet_core/
write_mode.rs

1//! Unified write-mode types + planner shared by every upsert-capable sink.
2
3use crate::error::FaucetError;
4use serde::{Deserialize, Serialize};
5use serde_json::{Map, Value};
6use std::collections::HashMap;
7
8/// Write semantics for a sink. Serialized snake_case. Default `Append`.
9// `#[non_exhaustive]`: this is a deliberate extension point — adding a write
10// mode (as `Overwrite` was, #492) is an additive change that ships as a minor
11// release. Downstream connectors that `match` on it must carry a wildcard arm;
12// the built-in sinks already gate on the specific modes they implement. Kept a
13// plain comment (not rustdoc) so the schema/rustdoc description is unchanged.
14#[derive(
15    Debug, Clone, Copy, Default, Serialize, Deserialize, schemars::JsonSchema, PartialEq, Eq,
16)]
17#[serde(rename_all = "snake_case")]
18#[non_exhaustive]
19pub enum WriteMode {
20    /// Insert every record (today's behaviour).
21    #[default]
22    Append,
23    /// Insert-or-update by `key`; optionally route delete-marked rows to deletes.
24    Upsert,
25    /// Delete by `key` for every record.
26    Delete,
27    /// Replace the entire destination with this run's records (truncate-load /
28    /// full refresh). The old contents are swapped out atomically only after the
29    /// run completes successfully, so a mid-run failure leaves them intact. No
30    /// `key` is required — it is a whole-dataset operation, not a keyed one.
31    Overwrite,
32}
33
34impl WriteMode {
35    /// Lowercase wire name, for error messages.
36    pub fn as_str(&self) -> &'static str {
37        match self {
38            WriteMode::Append => "append",
39            WriteMode::Upsert => "upsert",
40            WriteMode::Delete => "delete",
41            WriteMode::Overwrite => "overwrite",
42        }
43    }
44}
45
46/// Identifies a record as a delete (vs. an upsert) by a marker field's value.
47/// e.g. `{ field: "__op", values: ["d", "delete"] }`.
48#[derive(Debug, Clone, Default, Serialize, Deserialize, schemars::JsonSchema, PartialEq, Eq)]
49pub struct DeleteMarker {
50    /// Field name whose value flags a delete.
51    pub field: String,
52    /// Values of `field` that mean "this row is a delete".
53    pub values: Vec<String>,
54}
55
56/// Scope for a **scoped/windowed overwrite** (#518): with `write_mode:
57/// overwrite`, replace only the destination rows matching this scope instead of
58/// truncating the whole table. The sink-side sibling of scoped cleanup (#478).
59///
60/// v1 supports a half-open date/number **window** on a single column.
61#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
62#[serde(rename_all = "snake_case")]
63#[non_exhaustive]
64pub enum OverwriteScope {
65    /// Replace rows where `column` is in the half-open range `[from, to)`.
66    Window {
67        /// The destination column to window on.
68        column: String,
69        /// Inclusive lower bound.
70        from: Value,
71        /// Exclusive upper bound.
72        to: Value,
73    },
74}
75
76impl OverwriteScope {
77    /// The scoped column name.
78    pub fn column(&self) -> &str {
79        match self {
80            OverwriteScope::Window { column, .. } => column,
81        }
82    }
83
84    /// Validate the scope at config-load time.
85    pub fn validate(&self) -> Result<(), FaucetError> {
86        match self {
87            OverwriteScope::Window { column, from, to } => {
88                if column.trim().is_empty() {
89                    return Err(FaucetError::Config(
90                        "overwrite scope: window `column` must not be empty".into(),
91                    ));
92                }
93                if from.is_null() || to.is_null() {
94                    return Err(FaucetError::Config(
95                        "overwrite scope: window `from`/`to` must not be null".into(),
96                    ));
97                }
98                Ok(())
99            }
100        }
101    }
102
103    /// Render a SQL `WHERE` predicate over the (already-quoted) column using
104    /// escaped SQL **literals** for the bounds. Literals (not bind params) are
105    /// used so the engine coerces a string bound to the column's real type
106    /// (`date >= '2024-06-01'` works; a text-typed bind param would not). String
107    /// literals have their single quotes doubled, so a crafted bound cannot
108    /// break out of the quotes.
109    pub fn render_where_literal(&self, quoted_col: &str) -> String {
110        match self {
111            OverwriteScope::Window { from, to, .. } => format!(
112                "{quoted_col} >= {} AND {quoted_col} < {}",
113                sql_literal(from),
114                sql_literal(to)
115            ),
116        }
117    }
118}
119
120/// Render a JSON scalar as a SQL literal (strings single-quoted + escaped).
121fn sql_literal(v: &Value) -> String {
122    match v {
123        Value::String(s) => format!("'{}'", s.replace('\'', "''")),
124        Value::Number(n) => n.to_string(),
125        Value::Bool(b) => b.to_string(),
126        // Validated non-null upstream; any residual maps to NULL (never matches
127        // a range comparison, so the delete is a safe no-op rather than wrong).
128        _ => "NULL".to_owned(),
129    }
130}
131
132/// Shared write-mode config, embedded in each upsert-capable sink config via
133/// `#[serde(flatten)]` so `write_mode` / `key` / `delete_marker` appear at the
134/// sink-config top level.
135#[derive(Debug, Clone, Default, Serialize, Deserialize, schemars::JsonSchema)]
136pub struct WriteSpec {
137    /// Append (default), upsert, or delete.
138    #[serde(default)]
139    pub write_mode: WriteMode,
140    /// Key columns. Required and non-empty for upsert/delete; ignored for append.
141    #[serde(default)]
142    pub key: Vec<String>,
143    /// Optional. Upsert only: rows whose `field` matches one of `values` are
144    /// deletes; all others are upserts. The marker field is stripped from
145    /// upsert rows before writing.
146    #[serde(default, skip_serializing_if = "Option::is_none")]
147    pub delete_marker: Option<DeleteMarker>,
148}
149
150impl WriteSpec {
151    /// Validate internal consistency at config-load time.
152    pub fn validate(&self) -> Result<(), FaucetError> {
153        if matches!(self.write_mode, WriteMode::Upsert | WriteMode::Delete) && self.key.is_empty() {
154            return Err(FaucetError::Config(format!(
155                "write_mode: {} requires a non-empty `key`",
156                self.write_mode.as_str()
157            )));
158        }
159        Ok(())
160    }
161
162    /// Whether this spec makes writes converge by key — `write_mode: upsert`
163    /// or `delete` with a non-empty `key`. The canonical implementation of
164    /// [`Sink::dedups_by_key`](crate::Sink::dedups_by_key) for sinks that
165    /// flatten a `WriteSpec` into their config.
166    pub fn dedups_by_key(&self) -> bool {
167        matches!(self.write_mode, WriteMode::Upsert | WriteMode::Delete) && !self.key.is_empty()
168    }
169
170    /// Whether this spec requests full-destination replacement
171    /// ([`WriteMode::Overwrite`]). The canonical implementation of
172    /// [`Sink::is_overwrite`](crate::Sink::is_overwrite) for sinks that flatten
173    /// a `WriteSpec` into their config.
174    pub fn is_overwrite(&self) -> bool {
175        matches!(self.write_mode, WriteMode::Overwrite)
176    }
177}
178
179/// Ordered key column → value pairs, in `key` declaration order.
180#[derive(Debug, Clone, PartialEq)]
181pub struct KeyTuple(pub Vec<(String, Value)>);
182
183/// The partition of a page by write mode. Infallible to build — per-row
184/// failures (missing/null key) land in `failed` with their original page index
185/// so the caller can route them to a DLQ or abort.
186#[derive(Debug, Default)]
187pub struct WritePlan {
188    /// Rows to insert-or-update, deduped (last-write-wins), marker stripped.
189    pub upserts: Vec<Value>,
190    /// Key tuples to delete, deduped.
191    pub deletes: Vec<KeyTuple>,
192    /// `(page_index, message)` for rows whose key could not be extracted.
193    pub failed: Vec<(usize, String)>,
194}
195
196#[derive(Clone)]
197enum Action {
198    Upsert(Value),
199    Delete(KeyTuple),
200}
201
202/// Partition `page` into upserts + deletes per `spec`. The single place all six
203/// sinks share. `WriteMode::Append` should never reach here (callers route
204/// append separately); if it does, every row is treated as an upsert.
205pub fn plan_writes(page: &[Value], spec: &WriteSpec) -> WritePlan {
206    debug_assert!(
207        matches!(spec.write_mode, WriteMode::Upsert | WriteMode::Delete),
208        "plan_writes is only for Upsert/Delete — Append and Overwrite are routed separately"
209    );
210    let mut plan = WritePlan::default();
211    let mut index: HashMap<String, usize> = HashMap::new();
212    let mut order: Vec<Action> = Vec::new();
213
214    for (i, rec) in page.iter().enumerate() {
215        let key_tuple = match extract_key(rec, &spec.key) {
216            Ok(k) => k,
217            Err(msg) => {
218                plan.failed.push((i, msg));
219                continue;
220            }
221        };
222        let canon = canonical(&key_tuple);
223
224        let is_delete = match spec.write_mode {
225            WriteMode::Delete => true,
226            WriteMode::Upsert => is_delete_marked(rec, spec.delete_marker.as_ref()),
227            WriteMode::Append | WriteMode::Overwrite => false,
228        };
229
230        let action = if is_delete {
231            Action::Delete(key_tuple)
232        } else {
233            Action::Upsert(strip_marker(rec.clone(), spec.delete_marker.as_ref()))
234        };
235
236        match index.get(&canon) {
237            Some(&slot) => order[slot] = action,
238            None => {
239                index.insert(canon, order.len());
240                order.push(action);
241            }
242        }
243    }
244
245    for action in order {
246        match action {
247            Action::Upsert(v) => plan.upserts.push(v),
248            Action::Delete(k) => plan.deletes.push(k),
249        }
250    }
251    plan
252}
253
254/// Pull the key columns out of a record in `key` order. Missing key or null
255/// key value is an error.
256fn extract_key(rec: &Value, key: &[String]) -> Result<KeyTuple, String> {
257    let obj = rec
258        .as_object()
259        .ok_or_else(|| "record is not a JSON object".to_string())?;
260    let mut out = Vec::with_capacity(key.len());
261    for col in key {
262        match obj.get(col) {
263            None => return Err(format!("missing key column '{col}'")),
264            Some(Value::Null) => return Err(format!("null value for key column '{col}'")),
265            Some(v) => out.push((col.clone(), v.clone())),
266        }
267    }
268    Ok(KeyTuple(out))
269}
270
271fn is_delete_marked(rec: &Value, marker: Option<&DeleteMarker>) -> bool {
272    let Some(dm) = marker else { return false };
273    let Some(v) = rec.get(&dm.field) else {
274        return false;
275    };
276    let Some(s) = v.as_str() else { return false };
277    dm.values.iter().any(|m| m == s)
278}
279
280fn strip_marker(mut rec: Value, marker: Option<&DeleteMarker>) -> Value {
281    if let (Some(dm), Value::Object(map)) = (marker, &mut rec) {
282        map.remove(&dm.field);
283    }
284    rec
285}
286
287/// Stable canonical string for a key tuple, for dedup.
288fn canonical(k: &KeyTuple) -> String {
289    let arr: Vec<&Value> = k.0.iter().map(|(_, v)| v).collect();
290    serde_json::to_string(&arr).expect("a Vec<&serde_json::Value> always serializes")
291}
292
293/// Render a key tuple into a single document id (Elasticsearch `_id`).
294///
295/// A single-column key is rendered as its plain string / JSON form (no
296/// separator can collide). A **composite** key is rendered as a canonical JSON
297/// array of its values rather than a separator-join: a plain join is not
298/// injective — e.g. `["a_", "b"]` and `["a", "_b"]` both collapse to `"a__b"`
299/// under separator `"_"`, silently overwriting two distinct rows with one. JSON
300/// encoding escapes any separator-like characters in the values, so distinct key
301/// tuples always map to distinct ids.
302///
303/// Assumes each key column has a consistent JSON type across records (the
304/// normal case for SQL and CDC sources); it does not disambiguate, e.g., the
305/// integer `7` from the string `"7"` in the same column.
306pub fn key_to_doc_id(k: &KeyTuple, separator: &str) -> String {
307    let _ = separator; // retained for API stability; no separator can collide now
308    if k.0.len() == 1 {
309        return match &k.0[0].1 {
310            Value::String(s) => s.clone(),
311            other => other.to_string(),
312        };
313    }
314    let values: Vec<&Value> = k.0.iter().map(|(_, v)| v).collect();
315    serde_json::to_string(&values).expect("a Vec<&serde_json::Value> always serializes")
316}
317
318/// Build a Mongo/ES filter document `{ col: value, … }` from a key tuple.
319pub fn key_to_filter(k: &KeyTuple) -> Map<String, Value> {
320    k.0.iter().map(|(c, v)| (c.clone(), v.clone())).collect()
321}
322
323#[cfg(test)]
324mod tests {
325    use super::*;
326    use serde_json::json;
327
328    fn upsert_spec(keys: &[&str]) -> WriteSpec {
329        WriteSpec {
330            write_mode: WriteMode::Upsert,
331            key: keys.iter().map(|s| s.to_string()).collect(),
332            delete_marker: None,
333        }
334    }
335
336    #[test]
337    fn upsert_extracts_key_and_keeps_row() {
338        let plan = plan_writes(&[json!({"id": 1, "name": "a"})], &upsert_spec(&["id"]));
339        assert_eq!(plan.upserts, vec![json!({"id": 1, "name": "a"})]);
340        assert!(plan.deletes.is_empty());
341        assert!(plan.failed.is_empty());
342    }
343
344    #[test]
345    fn key_to_doc_id_single_key_is_plain() {
346        let k = KeyTuple(vec![("id".into(), json!(7))]);
347        assert_eq!(key_to_doc_id(&k, "_"), "7");
348        let k = KeyTuple(vec![("name".into(), json!("alice"))]);
349        assert_eq!(key_to_doc_id(&k, "_"), "alice");
350    }
351
352    #[test]
353    fn key_to_doc_id_composite_is_injective() {
354        // ["a_", "b"] and ["a", "_b"] must NOT collide (the F13 separator bug).
355        let k1 = KeyTuple(vec![("x".into(), json!("a_")), ("y".into(), json!("b"))]);
356        let k2 = KeyTuple(vec![("x".into(), json!("a")), ("y".into(), json!("_b"))]);
357        let id1 = key_to_doc_id(&k1, "_");
358        let id2 = key_to_doc_id(&k2, "_");
359        assert_ne!(id1, id2, "distinct composite keys must map to distinct ids");
360        // Mixed types also stay distinct.
361        let k3 = KeyTuple(vec![("x".into(), json!(1)), ("y".into(), json!("2"))]);
362        let k4 = KeyTuple(vec![("x".into(), json!("1")), ("y".into(), json!(2))]);
363        assert_ne!(key_to_doc_id(&k3, "_"), key_to_doc_id(&k4, "_"));
364    }
365
366    #[test]
367    fn missing_key_goes_to_failed_with_original_index() {
368        let plan = plan_writes(
369            &[json!({"id": 1}), json!({"name": "no-key"})],
370            &upsert_spec(&["id"]),
371        );
372        assert_eq!(plan.upserts.len(), 1);
373        assert_eq!(plan.failed.len(), 1);
374        assert_eq!(plan.failed[0].0, 1, "failed row keeps its page index");
375    }
376
377    #[test]
378    fn null_key_value_is_a_failure() {
379        let plan = plan_writes(&[json!({"id": null})], &upsert_spec(&["id"]));
380        assert!(plan.upserts.is_empty());
381        assert_eq!(plan.failed.len(), 1);
382    }
383
384    #[test]
385    fn delete_marker_routes_to_deletes_and_strips_marker() {
386        let spec = WriteSpec {
387            write_mode: WriteMode::Upsert,
388            key: vec!["id".into()],
389            delete_marker: Some(DeleteMarker {
390                field: "__op".into(),
391                values: vec!["d".into()],
392            }),
393        };
394        let plan = plan_writes(
395            &[
396                json!({"id": 1, "name": "a", "__op": "u"}),
397                json!({"id": 2, "__op": "d"}),
398            ],
399            &spec,
400        );
401        assert_eq!(plan.upserts, vec![json!({"id": 1, "name": "a"})]);
402        assert_eq!(plan.deletes.len(), 1);
403        assert_eq!(plan.deletes[0].0, vec![("id".to_string(), json!(2))]);
404    }
405
406    #[test]
407    fn last_write_wins_dedup_keeps_final_upsert() {
408        let plan = plan_writes(
409            &[json!({"id": 1, "v": "old"}), json!({"id": 1, "v": "new"})],
410            &upsert_spec(&["id"]),
411        );
412        assert_eq!(plan.upserts, vec![json!({"id": 1, "v": "new"})]);
413    }
414
415    #[test]
416    fn last_write_wins_delete_after_upsert_is_a_delete() {
417        let spec = WriteSpec {
418            write_mode: WriteMode::Upsert,
419            key: vec!["id".into()],
420            delete_marker: Some(DeleteMarker {
421                field: "__op".into(),
422                values: vec!["d".into()],
423            }),
424        };
425        let plan = plan_writes(
426            &[json!({"id": 1, "__op": "u"}), json!({"id": 1, "__op": "d"})],
427            &spec,
428        );
429        assert!(plan.upserts.is_empty());
430        assert_eq!(plan.deletes.len(), 1);
431    }
432
433    #[test]
434    fn delete_mode_routes_every_row_to_deletes() {
435        let spec = WriteSpec {
436            write_mode: WriteMode::Delete,
437            key: vec!["id".into()],
438            delete_marker: None,
439        };
440        let plan = plan_writes(&[json!({"id": 1}), json!({"id": 2})], &spec);
441        assert!(plan.upserts.is_empty());
442        assert_eq!(plan.deletes.len(), 2);
443    }
444
445    #[test]
446    fn composite_key_tuple_is_ordered() {
447        let plan = plan_writes(
448            &[json!({"a": 1, "b": 2, "v": 9})],
449            &upsert_spec(&["a", "b"]),
450        );
451        assert_eq!(plan.upserts.len(), 1);
452        let plan2 = plan_writes(
453            &[
454                json!({"a": 1, "b": 2, "v": "x"}),
455                json!({"a": 1, "b": 3, "v": "y"}),
456            ],
457            &upsert_spec(&["a", "b"]),
458        );
459        assert_eq!(plan2.upserts.len(), 2, "(1,2) and (1,3) are distinct keys");
460    }
461
462    #[test]
463    fn validate_rejects_upsert_without_key() {
464        let spec = WriteSpec {
465            write_mode: WriteMode::Upsert,
466            key: vec![],
467            delete_marker: None,
468        };
469        assert!(spec.validate().is_err());
470    }
471
472    #[test]
473    fn validate_allows_append_without_key() {
474        assert!(WriteSpec::default().validate().is_ok());
475    }
476
477    #[test]
478    fn dedups_by_key_requires_keyed_upsert_or_delete() {
479        assert!(!WriteSpec::default().dedups_by_key());
480        let upsert = WriteSpec {
481            write_mode: WriteMode::Upsert,
482            key: vec!["id".into()],
483            delete_marker: None,
484        };
485        assert!(upsert.dedups_by_key());
486        let delete = WriteSpec {
487            write_mode: WriteMode::Delete,
488            key: vec!["id".into()],
489            delete_marker: None,
490        };
491        assert!(delete.dedups_by_key());
492        // An (invalid) keyless upsert never claims keyed dedup.
493        let keyless = WriteSpec {
494            write_mode: WriteMode::Upsert,
495            key: vec![],
496            delete_marker: None,
497        };
498        assert!(!keyless.dedups_by_key());
499    }
500
501    #[test]
502    fn last_write_wins_upsert_after_delete_is_an_upsert() {
503        // Inverse of the delete-after-upsert case: [delete, upsert] → upsert wins.
504        let spec = WriteSpec {
505            write_mode: WriteMode::Upsert,
506            key: vec!["id".into()],
507            delete_marker: Some(DeleteMarker {
508                field: "__op".into(),
509                values: vec!["d".into()],
510            }),
511        };
512        let plan = plan_writes(
513            &[
514                json!({"id": 1, "__op": "d"}),
515                json!({"id": 1, "v": 9, "__op": "u"}),
516            ],
517            &spec,
518        );
519        assert!(plan.deletes.is_empty());
520        assert_eq!(plan.upserts, vec![json!({"id": 1, "v": 9})]);
521    }
522
523    #[test]
524    fn overwrite_mode_flags_and_needs_no_key() {
525        let spec = WriteSpec {
526            write_mode: WriteMode::Overwrite,
527            ..Default::default()
528        };
529        assert!(spec.is_overwrite());
530        assert!(!spec.dedups_by_key());
531        // Overwrite is a whole-dataset op — no key required, so validate passes.
532        assert!(spec.validate().is_ok());
533        assert_eq!(WriteMode::Overwrite.as_str(), "overwrite");
534        // Non-overwrite specs report false.
535        assert!(!WriteSpec::default().is_overwrite());
536        assert!(!upsert_spec(&["id"]).is_overwrite());
537    }
538
539    #[test]
540    fn overwrite_scope_window_validates_and_renders() {
541        let scope = OverwriteScope::Window {
542            column: "posting_date".into(),
543            from: json!("2024-06-01"),
544            to: json!("2024-07-01"),
545        };
546        assert_eq!(scope.column(), "posting_date");
547        assert!(scope.validate().is_ok());
548        let whr = scope.render_where_literal("\"posting_date\"");
549        assert_eq!(
550            whr,
551            "\"posting_date\" >= '2024-06-01' AND \"posting_date\" < '2024-07-01'"
552        );
553
554        // Empty column / null bounds are rejected.
555        assert!(
556            OverwriteScope::Window {
557                column: " ".into(),
558                from: json!(1),
559                to: json!(2)
560            }
561            .validate()
562            .is_err()
563        );
564        assert!(
565            OverwriteScope::Window {
566                column: "c".into(),
567                from: json!(null),
568                to: json!(2)
569            }
570            .validate()
571            .is_err()
572        );
573    }
574
575    #[test]
576    fn scope_window_number_bounds() {
577        let scope = OverwriteScope::Window {
578            column: "seq".into(),
579            from: json!(100),
580            to: json!(200),
581        };
582        assert!(scope.validate().is_ok());
583        assert_eq!(
584            scope.render_where_literal("`seq`"),
585            "`seq` >= 100 AND `seq` < 200"
586        );
587    }
588
589    #[test]
590    fn scope_literal_escapes_quotes() {
591        // A crafted bound cannot break out of the string literal.
592        let scope = OverwriteScope::Window {
593            column: "c".into(),
594            from: json!("x' OR '1'='1"),
595            to: json!("z"),
596        };
597        let whr = scope.render_where_literal("\"c\"");
598        assert!(whr.contains("'x'' OR ''1''=''1'"), "{whr}");
599    }
600
601    #[test]
602    fn overwrite_deserializes_from_wire() {
603        let spec: WriteSpec = serde_json::from_value(json!({"write_mode": "overwrite"})).unwrap();
604        assert_eq!(spec.write_mode, WriteMode::Overwrite);
605        assert!(spec.is_overwrite());
606    }
607
608    #[test]
609    fn empty_page_produces_empty_plan() {
610        let plan = plan_writes(&[], &upsert_spec(&["id"]));
611        assert!(plan.upserts.is_empty());
612        assert!(plan.deletes.is_empty());
613        assert!(plan.failed.is_empty());
614    }
615
616    #[test]
617    fn delete_mode_dedups_repeated_key() {
618        // Same key deleted twice in one page collapses to a single delete.
619        let spec = WriteSpec {
620            write_mode: WriteMode::Delete,
621            key: vec!["id".into()],
622            delete_marker: None,
623        };
624        let plan = plan_writes(&[json!({"id": 1}), json!({"id": 1})], &spec);
625        assert_eq!(plan.deletes.len(), 1);
626    }
627}