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        self.render_where_with(quoted_col, sql_literal)
111    }
112
113    /// [`render_where_literal`](Self::render_where_literal) with a
114    /// dialect-specific literal renderer, for dialects whose string literals
115    /// give some other character (BigQuery's backslash) escape meaning.
116    pub fn render_where_with(
117        &self,
118        quoted_col: &str,
119        literal: impl Fn(&Value) -> String,
120    ) -> String {
121        match self {
122            OverwriteScope::Window { from, to, .. } => format!(
123                "{quoted_col} >= {} AND {quoted_col} < {}",
124                literal(from),
125                literal(to)
126            ),
127        }
128    }
129}
130
131/// Render a JSON scalar as an **ANSI-standard** SQL literal: strings are
132/// single-quoted with embedded quotes doubled (`''`), numbers and booleans pass
133/// through in their canonical form, and anything else becomes `NULL`.
134///
135/// This is the house escaper for every dialect that follows the ANSI rule —
136/// Postgres (with `standard_conforming_strings`, the default since 9.1), MySQL
137/// under `NO_BACKSLASH_ESCAPES`, SQL Server, and SQLite. **Not BigQuery**:
138/// GoogleSQL treats `\'` as an escaped quote, so a trailing backslash would
139/// close the literal early. **Public so connectors escape
140/// through one audited implementation instead of hand-rolling a third
141/// convention** (#654 M14): the copies that existed before it was exported each
142/// invented their own rules, so a fix to one reached none of the others.
143///
144/// A dialect whose string literals treat some *other* character as a
145/// meta-character cannot use this function — doubling quotes alone does not
146/// neutralise a trailing escape character. Such a dialect must keep its own
147/// escaper, and that escaper's doc comment must state which extra characters it
148/// escapes and why (see `faucet_common_clickhouse::sql_literal`, where a
149/// backslash is a literal meta-character).
150pub fn sql_literal(v: &Value) -> String {
151    match v {
152        Value::String(s) => format!("'{}'", s.replace('\'', "''")),
153        Value::Number(n) => n.to_string(),
154        Value::Bool(b) => b.to_string(),
155        // Validated non-null upstream; any residual maps to NULL (never matches
156        // a range comparison, so the delete is a safe no-op rather than wrong).
157        _ => "NULL".to_owned(),
158    }
159}
160
161/// Shared write-mode config, embedded in each upsert-capable sink config via
162/// `#[serde(flatten)]` so `write_mode` / `key` / `delete_marker` appear at the
163/// sink-config top level.
164#[derive(Debug, Clone, Default, Serialize, Deserialize, schemars::JsonSchema)]
165pub struct WriteSpec {
166    /// Append (default), upsert, or delete.
167    #[serde(default)]
168    pub write_mode: WriteMode,
169    /// Key columns. Required and non-empty for upsert/delete; ignored for append.
170    #[serde(default)]
171    pub key: Vec<String>,
172    /// Optional. Upsert only: rows whose `field` matches one of `values` are
173    /// deletes; all others are upserts. The marker field is stripped from
174    /// upsert rows before writing.
175    #[serde(default, skip_serializing_if = "Option::is_none")]
176    pub delete_marker: Option<DeleteMarker>,
177    /// Runtime rollback settings (#706): the run id to journal under, the
178    /// run-id column, and whether to journal before-images / keep a replaced
179    /// table. Injected per invocation by the CLI when a config has a
180    /// top-level `rollback:` block — not meant to be written by hand.
181    #[serde(default, skip_serializing_if = "Option::is_none")]
182    pub rollback: Option<crate::rollback::RollbackWriteSpec>,
183}
184
185impl WriteSpec {
186    /// The run id this invocation writes under, when rollback is on.
187    pub fn rollback_run_id(&self) -> Option<&str> {
188        self.rollback.as_ref().map(|r| r.run_id.as_str())
189    }
190
191    /// Whether upserts/deletes must journal before-images (#706).
192    pub fn journals(&self) -> bool {
193        self.rollback.as_ref().is_some_and(|r| r.journal)
194    }
195
196    /// Whether an overwrite keeps the replaced table (#706).
197    pub fn keeps_previous(&self) -> bool {
198        self.rollback.as_ref().is_some_and(|r| r.keep_previous)
199    }
200
201    /// Validate internal consistency at config-load time.
202    pub fn validate(&self) -> Result<(), FaucetError> {
203        if matches!(self.write_mode, WriteMode::Upsert | WriteMode::Delete) && self.key.is_empty() {
204            return Err(FaucetError::Config(format!(
205                "write_mode: {} requires a non-empty `key`",
206                self.write_mode.as_str()
207            )));
208        }
209        Ok(())
210    }
211
212    /// Whether this spec makes writes converge by key — `write_mode: upsert`
213    /// or `delete` with a non-empty `key`. The canonical implementation of
214    /// [`Sink::dedups_by_key`](crate::Sink::dedups_by_key) for sinks that
215    /// flatten a `WriteSpec` into their config.
216    pub fn dedups_by_key(&self) -> bool {
217        matches!(self.write_mode, WriteMode::Upsert | WriteMode::Delete) && !self.key.is_empty()
218    }
219
220    /// Whether this spec requests full-destination replacement
221    /// ([`WriteMode::Overwrite`]). The canonical implementation of
222    /// [`Sink::is_overwrite`](crate::Sink::is_overwrite) for sinks that flatten
223    /// a `WriteSpec` into their config.
224    pub fn is_overwrite(&self) -> bool {
225        matches!(self.write_mode, WriteMode::Overwrite)
226    }
227}
228
229/// Ordered key column → value pairs, in `key` declaration order.
230#[derive(Debug, Clone, PartialEq)]
231pub struct KeyTuple(pub Vec<(String, Value)>);
232
233/// The partition of a page by write mode. Infallible to build — per-row
234/// failures (missing/null key) land in `failed` with their original page index
235/// so the caller can route them to a DLQ or abort.
236#[derive(Debug, Default)]
237pub struct WritePlan {
238    /// Rows to insert-or-update, deduped (last-write-wins), marker stripped.
239    pub upserts: Vec<Value>,
240    /// Key tuples to delete, deduped.
241    pub deletes: Vec<KeyTuple>,
242    /// `(page_index, message)` for rows whose key could not be extracted.
243    pub failed: Vec<(usize, String)>,
244}
245
246#[derive(Clone)]
247enum Action {
248    Upsert(Value),
249    Delete(KeyTuple),
250}
251
252/// Partition `page` into upserts + deletes per `spec`. The single place all six
253/// sinks share. `WriteMode::Append` should never reach here (callers route
254/// append separately); if it does, every row is treated as an upsert.
255///
256/// Keys deduplicate the way a typed SQL key column compares them: `7`, `7.0`
257/// and `"7"` are one key. A sink that keeps those apart (a document store
258/// matching on the JSON value) uses [`plan_writes_exact`].
259pub fn plan_writes(page: &[Value], spec: &WriteSpec) -> WritePlan {
260    plan_writes_by(page, spec, canonical)
261}
262
263/// [`plan_writes`] deduplicating only keys a document store treats as equal:
264/// `7` and `"7"` stay two rows, while numbers compare by value, so `7` and
265/// `7.0` are one — as a document store's `_id` match does (#862 M-018).
266pub fn plan_writes_exact(page: &[Value], spec: &WriteSpec) -> WritePlan {
267    plan_writes_by(page, spec, |k| {
268        let values: Vec<Value> = k.0.iter().map(|(_, v)| numeric_value(v)).collect();
269        crate::util::canonical_json(&Value::Array(values))
270    })
271}
272
273/// `v` with every whole float written as the integer it equals, so numbers
274/// compare by value; other values are unchanged.
275fn numeric_value(v: &Value) -> Value {
276    match v {
277        Value::Number(n) if n.is_f64() => {
278            let f = n.as_f64().unwrap_or(f64::NAN);
279            let whole = f.is_finite() && f.fract() == 0.0;
280            if whole && f >= i64::MIN as f64 && f < i64::MAX as f64 {
281                Value::from(f as i64)
282            } else if whole && f >= 0.0 && f < u64::MAX as f64 {
283                Value::from(f as u64)
284            } else {
285                v.clone()
286            }
287        }
288        Value::Array(items) => Value::Array(items.iter().map(numeric_value).collect()),
289        Value::Object(map) => Value::Object(
290            map.iter()
291                .map(|(k, v)| (k.clone(), numeric_value(v)))
292                .collect(),
293        ),
294        _ => v.clone(),
295    }
296}
297
298fn plan_writes_by(
299    page: &[Value],
300    spec: &WriteSpec,
301    canonical: impl Fn(&KeyTuple) -> String,
302) -> WritePlan {
303    debug_assert!(
304        matches!(spec.write_mode, WriteMode::Upsert | WriteMode::Delete),
305        "plan_writes is only for Upsert/Delete — Append and Overwrite are routed separately"
306    );
307    let mut plan = WritePlan::default();
308    let mut index: HashMap<String, usize> = HashMap::new();
309    let mut order: Vec<Action> = Vec::new();
310
311    for (i, rec) in page.iter().enumerate() {
312        let key_tuple = match extract_key(rec, &spec.key) {
313            Ok(k) => k,
314            Err(msg) => {
315                plan.failed.push((i, msg));
316                continue;
317            }
318        };
319        let canon = canonical(&key_tuple);
320
321        let is_delete = match spec.write_mode {
322            WriteMode::Delete => true,
323            WriteMode::Upsert => is_delete_marked(rec, spec.delete_marker.as_ref()),
324            WriteMode::Append | WriteMode::Overwrite => false,
325        };
326
327        let action = if is_delete {
328            Action::Delete(key_tuple)
329        } else {
330            Action::Upsert(strip_marker(rec.clone(), spec.delete_marker.as_ref()))
331        };
332
333        match index.get(&canon) {
334            Some(&slot) => order[slot] = action,
335            None => {
336                index.insert(canon, order.len());
337                order.push(action);
338            }
339        }
340    }
341
342    for action in order {
343        match action {
344            Action::Upsert(v) => plan.upserts.push(v),
345            Action::Delete(k) => plan.deletes.push(k),
346        }
347    }
348    plan
349}
350
351/// Split a keyed page into runs of consecutive upserts or deletes and plan
352/// each run, so the runs apply in page order. One [`plan_writes`] over the
353/// whole page applies every upsert before every delete; when a delete and a
354/// later upsert address keys that differ in JSON but are equal under the
355/// destination's collation (`"Bob"` / `"bob"`), that order deletes the row the
356/// page re-inserted. Rows with no usable key are left out — the caller reports
357/// them from its own [`plan_writes`] over the page.
358pub fn plan_writes_ordered(page: &[Value], spec: &WriteSpec) -> Vec<WritePlan> {
359    let mut plans = Vec::new();
360    let mut run: Vec<Value> = Vec::new();
361    let mut run_is_delete = None;
362    for rec in page {
363        let single = plan_writes(std::slice::from_ref(rec), spec);
364        if !single.failed.is_empty() {
365            continue;
366        }
367        let is_delete = !single.deletes.is_empty();
368        if run_is_delete.is_some_and(|d| d != is_delete) {
369            plans.push(plan_writes(&std::mem::take(&mut run), spec));
370        }
371        run_is_delete = Some(is_delete);
372        run.push(rec.clone());
373    }
374    if !run.is_empty() {
375        plans.push(plan_writes(&run, spec));
376    }
377    plans
378}
379
380/// The key tuple of a record in `key` order, or `None` when a key column is
381/// missing or null — the same rule [`plan_writes`] applies.
382pub fn record_key(rec: &Value, key: &[String]) -> Option<KeyTuple> {
383    extract_key(rec, key).ok()
384}
385
386/// Pull the key columns out of a record in `key` order. Missing key or null
387/// key value is an error.
388fn extract_key(rec: &Value, key: &[String]) -> Result<KeyTuple, String> {
389    let obj = rec
390        .as_object()
391        .ok_or_else(|| "record is not a JSON object".to_string())?;
392    let mut out = Vec::with_capacity(key.len());
393    for col in key {
394        match obj.get(col) {
395            None => return Err(format!("missing key column '{col}'")),
396            Some(Value::Null) => return Err(format!("null value for key column '{col}'")),
397            Some(v) => out.push((col.clone(), v.clone())),
398        }
399    }
400    Ok(KeyTuple(out))
401}
402
403fn is_delete_marked(rec: &Value, marker: Option<&DeleteMarker>) -> bool {
404    let Some(dm) = marker else { return false };
405    let Some(v) = rec.get(&dm.field) else {
406        return false;
407    };
408    let Some(s) = v.as_str() else { return false };
409    dm.values.iter().any(|m| m == s)
410}
411
412fn strip_marker(mut rec: Value, marker: Option<&DeleteMarker>) -> Value {
413    if let (Some(dm), Value::Object(map)) = (marker, &mut rec) {
414        map.remove(&dm.field);
415    }
416    rec
417}
418
419/// Stable canonical string for a key tuple, for dedup. Scalars compare by
420/// their text, as a database compares a key column, so `7`, `7.0` and `"7"`
421/// are one key — the rule `rollback::canonical_key` applies too.
422fn canonical(k: &KeyTuple) -> String {
423    let arr: Vec<String> = k.0.iter().map(|(_, v)| key_scalar_text(v)).collect();
424    serde_json::to_string(&arr).expect("a Vec<String> always serializes")
425}
426
427pub(crate) fn key_scalar_text(v: &Value) -> String {
428    match v {
429        Value::String(s) => s.clone(),
430        Value::Number(n) => match n.as_f64() {
431            Some(f)
432                if n.as_i64().is_none()
433                    && n.as_u64().is_none()
434                    && f.fract() == 0.0
435                    && f.abs() < 9_007_199_254_740_992.0 =>
436            {
437                (f as i64).to_string()
438            }
439            _ => n.to_string(),
440        },
441        Value::Bool(b) => b.to_string(),
442        other => crate::util::canonical_json(other),
443    }
444}
445
446/// Render a key tuple into a single document id (Elasticsearch `_id`).
447///
448/// A single-column key is rendered as its plain string / JSON form (no
449/// separator can collide). A **composite** key is rendered as a canonical JSON
450/// array of its values rather than a separator-join: a plain join is not
451/// injective — e.g. `["a_", "b"]` and `["a", "_b"]` both collapse to `"a__b"`
452/// under separator `"_"`, silently overwriting two distinct rows with one. JSON
453/// encoding escapes any separator-like characters in the values, so distinct key
454/// tuples always map to distinct ids.
455///
456/// Assumes each key column has a consistent JSON type across records (the
457/// normal case for SQL and CDC sources); it does not disambiguate, e.g., the
458/// integer `7` from the string `"7"` in the same column.
459pub fn key_to_doc_id(k: &KeyTuple, separator: &str) -> String {
460    let _ = separator; // retained for API stability; no separator can collide now
461    if k.0.len() == 1 {
462        return match &k.0[0].1 {
463            Value::String(s) => s.clone(),
464            other => crate::util::canonical_json(other),
465        };
466    }
467    let values: Vec<Value> = k.0.iter().map(|(_, v)| v.clone()).collect();
468    crate::util::canonical_json(&Value::Array(values))
469}
470
471/// Build a Mongo/ES filter document `{ col: value, … }` from a key tuple.
472pub fn key_to_filter(k: &KeyTuple) -> Map<String, Value> {
473    k.0.iter().map(|(c, v)| (c.clone(), v.clone())).collect()
474}
475
476#[cfg(test)]
477mod tests {
478    use super::*;
479    use serde_json::json;
480
481    #[test]
482    fn exact_planning_keeps_keys_of_different_json_types_apart() {
483        let spec = WriteSpec {
484            write_mode: WriteMode::Upsert,
485            key: vec!["id".into()],
486            ..Default::default()
487        };
488        let page = vec![
489            json!({"id": 7, "v": "a"}),
490            json!({"id": "7", "v": "b"}),
491            json!({"id": 7, "v": "c"}),
492        ];
493        let exact = plan_writes_exact(&page, &spec);
494        assert_eq!(
495            exact.upserts,
496            vec![json!({"id": 7, "v": "c"}), json!({"id": "7", "v": "b"})]
497        );
498        assert_eq!(
499            plan_writes(&page, &spec).upserts,
500            vec![json!({"id": 7, "v": "c"})]
501        );
502    }
503
504    #[test]
505    fn exact_planning_compares_numbers_by_value() {
506        let spec = WriteSpec {
507            write_mode: WriteMode::Upsert,
508            key: vec!["id".into()],
509            ..Default::default()
510        };
511        let page = vec![
512            json!({"id": 7, "v": 1}),
513            json!({"id": 7.0, "v": 2}),
514            json!({"id": 7.5, "v": 3}),
515            json!({"id": {"a": [1.0]}, "v": 4}),
516            json!({"id": {"a": [1]}, "v": 5}),
517            json!({"id": 9_007_199_254_740_993_u64, "v": 6}),
518            json!({"id": 9_007_199_254_740_992.0, "v": 7}),
519            json!({"id": 1.8e19, "v": 8}),
520            json!({"id": 1e300, "v": 9}),
521        ];
522        let v: Vec<Value> = plan_writes_exact(&page, &spec)
523            .upserts
524            .iter()
525            .map(|r| r["v"].clone())
526            .collect();
527        assert_eq!(
528            v,
529            vec![
530                json!(2),
531                json!(3),
532                json!(5),
533                json!(6),
534                json!(7),
535                json!(8),
536                json!(9)
537            ]
538        );
539    }
540
541    /// An object inserted in reverse-sorted key order (#817).
542    fn login_then_id() -> Value {
543        let mut m = serde_json::Map::new();
544        m.insert("login".into(), json!("o"));
545        m.insert("id".into(), json!(2));
546        Value::Object(m)
547    }
548
549    #[test]
550    fn object_key_values_render_with_sorted_keys() {
551        assert_eq!(key_scalar_text(&login_then_id()), r#"{"id":2,"login":"o"}"#);
552        let k = KeyTuple(vec![("k".into(), login_then_id())]);
553        assert_eq!(key_to_doc_id(&k, "_"), r#"{"id":2,"login":"o"}"#);
554    }
555
556    fn upsert_spec(keys: &[&str]) -> WriteSpec {
557        WriteSpec {
558            write_mode: WriteMode::Upsert,
559            key: keys.iter().map(|s| s.to_string()).collect(),
560            delete_marker: None,
561            rollback: None,
562        }
563    }
564
565    #[test]
566    fn ordered_plans_keep_page_order_across_upsert_delete_runs() {
567        let spec = WriteSpec {
568            write_mode: WriteMode::Upsert,
569            key: vec!["name".into()],
570            delete_marker: Some(DeleteMarker {
571                field: "op".into(),
572                values: vec!["d".into()],
573            }),
574            rollback: None,
575        };
576        let page = vec![
577            json!({"name": "Bob", "op": "d"}),
578            json!({"name": "bob", "v": 1}),
579            json!({"name": "carl", "v": 2}),
580            json!({"v": 3}),
581            json!({"name": "dee", "op": "d"}),
582        ];
583        let plans = plan_writes_ordered(&page, &spec);
584        let shape: Vec<(usize, usize)> = plans
585            .iter()
586            .map(|p| (p.upserts.len(), p.deletes.len()))
587            .collect();
588        assert_eq!(shape, vec![(0, 1), (2, 0), (0, 1)]);
589        assert!(plans.iter().all(|p| p.failed.is_empty()));
590        assert!(plan_writes_ordered(&[], &spec).is_empty());
591    }
592
593    #[test]
594    fn upsert_extracts_key_and_keeps_row() {
595        let plan = plan_writes(&[json!({"id": 1, "name": "a"})], &upsert_spec(&["id"]));
596        assert_eq!(plan.upserts, vec![json!({"id": 1, "name": "a"})]);
597        assert!(plan.deletes.is_empty());
598        assert!(plan.failed.is_empty());
599    }
600
601    #[test]
602    fn key_to_doc_id_single_key_is_plain() {
603        let k = KeyTuple(vec![("id".into(), json!(7))]);
604        assert_eq!(key_to_doc_id(&k, "_"), "7");
605        let k = KeyTuple(vec![("name".into(), json!("alice"))]);
606        assert_eq!(key_to_doc_id(&k, "_"), "alice");
607    }
608
609    #[test]
610    fn key_to_doc_id_composite_is_injective() {
611        // ["a_", "b"] and ["a", "_b"] must NOT collide (the F13 separator bug).
612        let k1 = KeyTuple(vec![("x".into(), json!("a_")), ("y".into(), json!("b"))]);
613        let k2 = KeyTuple(vec![("x".into(), json!("a")), ("y".into(), json!("_b"))]);
614        let id1 = key_to_doc_id(&k1, "_");
615        let id2 = key_to_doc_id(&k2, "_");
616        assert_ne!(id1, id2, "distinct composite keys must map to distinct ids");
617        // Mixed types also stay distinct.
618        let k3 = KeyTuple(vec![("x".into(), json!(1)), ("y".into(), json!("2"))]);
619        let k4 = KeyTuple(vec![("x".into(), json!("1")), ("y".into(), json!(2))]);
620        assert_ne!(key_to_doc_id(&k3, "_"), key_to_doc_id(&k4, "_"));
621    }
622
623    #[test]
624    fn missing_key_goes_to_failed_with_original_index() {
625        let plan = plan_writes(
626            &[json!({"id": 1}), json!({"name": "no-key"})],
627            &upsert_spec(&["id"]),
628        );
629        assert_eq!(plan.upserts.len(), 1);
630        assert_eq!(plan.failed.len(), 1);
631        assert_eq!(plan.failed[0].0, 1, "failed row keeps its page index");
632    }
633
634    #[test]
635    fn null_key_value_is_a_failure() {
636        let plan = plan_writes(&[json!({"id": null})], &upsert_spec(&["id"]));
637        assert!(plan.upserts.is_empty());
638        assert_eq!(plan.failed.len(), 1);
639    }
640
641    #[test]
642    fn delete_marker_routes_to_deletes_and_strips_marker() {
643        let spec = WriteSpec {
644            write_mode: WriteMode::Upsert,
645            key: vec!["id".into()],
646            delete_marker: Some(DeleteMarker {
647                field: "__op".into(),
648                values: vec!["d".into()],
649            }),
650            rollback: None,
651        };
652        let plan = plan_writes(
653            &[
654                json!({"id": 1, "name": "a", "__op": "u"}),
655                json!({"id": 2, "__op": "d"}),
656            ],
657            &spec,
658        );
659        assert_eq!(plan.upserts, vec![json!({"id": 1, "name": "a"})]);
660        assert_eq!(plan.deletes.len(), 1);
661        assert_eq!(plan.deletes[0].0, vec![("id".to_string(), json!(2))]);
662    }
663
664    #[test]
665    fn last_write_wins_dedup_keeps_final_upsert() {
666        let plan = plan_writes(
667            &[json!({"id": 1, "v": "old"}), json!({"id": 1, "v": "new"})],
668            &upsert_spec(&["id"]),
669        );
670        assert_eq!(plan.upserts, vec![json!({"id": 1, "v": "new"})]);
671    }
672
673    #[test]
674    fn mixed_json_types_of_one_key_dedup_as_one_key() {
675        let plan = plan_writes(
676            &[
677                json!({"id": 7, "v": "a"}),
678                json!({"id": 7.0, "v": "b"}),
679                json!({"id": "7", "v": "c"}),
680                json!({"id": 7.5, "v": "d"}),
681            ],
682            &upsert_spec(&["id"]),
683        );
684        assert_eq!(
685            plan.upserts,
686            vec![json!({"id": "7", "v": "c"}), json!({"id": 7.5, "v": "d"})]
687        );
688    }
689
690    #[test]
691    fn last_write_wins_delete_after_upsert_is_a_delete() {
692        let spec = WriteSpec {
693            write_mode: WriteMode::Upsert,
694            key: vec!["id".into()],
695            delete_marker: Some(DeleteMarker {
696                field: "__op".into(),
697                values: vec!["d".into()],
698            }),
699            rollback: None,
700        };
701        let plan = plan_writes(
702            &[json!({"id": 1, "__op": "u"}), json!({"id": 1, "__op": "d"})],
703            &spec,
704        );
705        assert!(plan.upserts.is_empty());
706        assert_eq!(plan.deletes.len(), 1);
707    }
708
709    #[test]
710    fn delete_mode_routes_every_row_to_deletes() {
711        let spec = WriteSpec {
712            write_mode: WriteMode::Delete,
713            key: vec!["id".into()],
714            delete_marker: None,
715            rollback: None,
716        };
717        let plan = plan_writes(&[json!({"id": 1}), json!({"id": 2})], &spec);
718        assert!(plan.upserts.is_empty());
719        assert_eq!(plan.deletes.len(), 2);
720    }
721
722    #[test]
723    fn composite_key_tuple_is_ordered() {
724        let plan = plan_writes(
725            &[json!({"a": 1, "b": 2, "v": 9})],
726            &upsert_spec(&["a", "b"]),
727        );
728        assert_eq!(plan.upserts.len(), 1);
729        let plan2 = plan_writes(
730            &[
731                json!({"a": 1, "b": 2, "v": "x"}),
732                json!({"a": 1, "b": 3, "v": "y"}),
733            ],
734            &upsert_spec(&["a", "b"]),
735        );
736        assert_eq!(plan2.upserts.len(), 2, "(1,2) and (1,3) are distinct keys");
737    }
738
739    #[test]
740    fn validate_rejects_upsert_without_key() {
741        let spec = WriteSpec {
742            write_mode: WriteMode::Upsert,
743            key: vec![],
744            delete_marker: None,
745            rollback: None,
746        };
747        assert!(spec.validate().is_err());
748    }
749
750    #[test]
751    fn validate_allows_append_without_key() {
752        assert!(WriteSpec::default().validate().is_ok());
753    }
754
755    #[test]
756    fn dedups_by_key_requires_keyed_upsert_or_delete() {
757        assert!(!WriteSpec::default().dedups_by_key());
758        let upsert = WriteSpec {
759            write_mode: WriteMode::Upsert,
760            key: vec!["id".into()],
761            delete_marker: None,
762            rollback: None,
763        };
764        assert!(upsert.dedups_by_key());
765        let delete = WriteSpec {
766            write_mode: WriteMode::Delete,
767            key: vec!["id".into()],
768            delete_marker: None,
769            rollback: None,
770        };
771        assert!(delete.dedups_by_key());
772        // An (invalid) keyless upsert never claims keyed dedup.
773        let keyless = WriteSpec {
774            write_mode: WriteMode::Upsert,
775            key: vec![],
776            delete_marker: None,
777            rollback: None,
778        };
779        assert!(!keyless.dedups_by_key());
780    }
781
782    #[test]
783    fn last_write_wins_upsert_after_delete_is_an_upsert() {
784        // Inverse of the delete-after-upsert case: [delete, upsert] → upsert wins.
785        let spec = WriteSpec {
786            write_mode: WriteMode::Upsert,
787            key: vec!["id".into()],
788            delete_marker: Some(DeleteMarker {
789                field: "__op".into(),
790                values: vec!["d".into()],
791            }),
792            rollback: None,
793        };
794        let plan = plan_writes(
795            &[
796                json!({"id": 1, "__op": "d"}),
797                json!({"id": 1, "v": 9, "__op": "u"}),
798            ],
799            &spec,
800        );
801        assert!(plan.deletes.is_empty());
802        assert_eq!(plan.upserts, vec![json!({"id": 1, "v": 9})]);
803    }
804
805    #[test]
806    fn overwrite_mode_flags_and_needs_no_key() {
807        let spec = WriteSpec {
808            write_mode: WriteMode::Overwrite,
809            ..Default::default()
810        };
811        assert!(spec.is_overwrite());
812        assert!(!spec.dedups_by_key());
813        // Overwrite is a whole-dataset op — no key required, so validate passes.
814        assert!(spec.validate().is_ok());
815        assert_eq!(WriteMode::Overwrite.as_str(), "overwrite");
816        // Non-overwrite specs report false.
817        assert!(!WriteSpec::default().is_overwrite());
818        assert!(!upsert_spec(&["id"]).is_overwrite());
819    }
820
821    #[test]
822    fn overwrite_scope_window_validates_and_renders() {
823        let scope = OverwriteScope::Window {
824            column: "posting_date".into(),
825            from: json!("2024-06-01"),
826            to: json!("2024-07-01"),
827        };
828        assert_eq!(scope.column(), "posting_date");
829        assert!(scope.validate().is_ok());
830        let whr = scope.render_where_literal("\"posting_date\"");
831        assert_eq!(
832            whr,
833            "\"posting_date\" >= '2024-06-01' AND \"posting_date\" < '2024-07-01'"
834        );
835
836        // Empty column / null bounds are rejected.
837        assert!(
838            OverwriteScope::Window {
839                column: " ".into(),
840                from: json!(1),
841                to: json!(2)
842            }
843            .validate()
844            .is_err()
845        );
846        assert!(
847            OverwriteScope::Window {
848                column: "c".into(),
849                from: json!(null),
850                to: json!(2)
851            }
852            .validate()
853            .is_err()
854        );
855    }
856
857    #[test]
858    fn scope_window_number_bounds() {
859        let scope = OverwriteScope::Window {
860            column: "seq".into(),
861            from: json!(100),
862            to: json!(200),
863        };
864        assert!(scope.validate().is_ok());
865        assert_eq!(
866            scope.render_where_literal("`seq`"),
867            "`seq` >= 100 AND `seq` < 200"
868        );
869    }
870
871    #[test]
872    fn sql_literal_renders_each_scalar_kind_by_the_ansi_rule() {
873        // Strings: single-quoted, embedded quotes doubled — and *only* quotes,
874        // since a backslash is an ordinary character under the ANSI rule.
875        assert_eq!(sql_literal(&json!("plain")), "'plain'");
876        assert_eq!(sql_literal(&json!("O'Brien")), "'O''Brien'");
877        assert_eq!(sql_literal(&json!("a\\b")), "'a\\b'");
878        // Every quote is doubled, not just the first: 5 interior quotes → 10.
879        assert_eq!(sql_literal(&json!("'' ' ''")), "''''' '' '''''");
880        // An empty string stays a valid (empty) literal, not bare quotes-less text.
881        assert_eq!(sql_literal(&json!("")), "''");
882        // Numbers and booleans pass through in canonical form.
883        assert_eq!(sql_literal(&json!(42)), "42");
884        assert_eq!(sql_literal(&json!(-1.5)), "-1.5");
885        assert_eq!(sql_literal(&json!(true)), "true");
886        assert_eq!(sql_literal(&json!(false)), "false");
887        // Null and non-scalars degrade to NULL rather than injecting structure.
888        assert_eq!(sql_literal(&Value::Null), "NULL");
889        assert_eq!(sql_literal(&json!({"a": 1})), "NULL");
890        assert_eq!(sql_literal(&json!([1, 2])), "NULL");
891    }
892
893    #[test]
894    fn sql_literal_neutralises_a_quote_breakout_attempt() {
895        // The whole point of the escaper: a crafted value must stay one literal.
896        let out = sql_literal(&json!("x' OR 1=1 --"));
897        assert_eq!(out, "'x'' OR 1=1 --'");
898        // Exactly one opening and one closing quote delimit the literal: every
899        // interior quote is part of a doubled pair.
900        let interior = &out[1..out.len() - 1];
901        assert!(
902            interior.matches('\'').count().is_multiple_of(2),
903            "unpaired quote inside literal: {out}"
904        );
905    }
906
907    #[test]
908    fn scope_literal_escapes_quotes() {
909        // A crafted bound cannot break out of the string literal.
910        let scope = OverwriteScope::Window {
911            column: "c".into(),
912            from: json!("x' OR '1'='1"),
913            to: json!("z"),
914        };
915        let whr = scope.render_where_literal("\"c\"");
916        assert!(whr.contains("'x'' OR ''1''=''1'"), "{whr}");
917    }
918
919    #[test]
920    fn render_where_with_uses_the_dialect_renderer() {
921        let scope = OverwriteScope::Window {
922            column: "c".into(),
923            from: json!("a"),
924            to: json!(5),
925        };
926        let whr = scope.render_where_with("`c`", |v| format!("<{v}>"));
927        assert_eq!(whr, "`c` >= <\"a\"> AND `c` < <5>");
928    }
929
930    #[test]
931    fn overwrite_deserializes_from_wire() {
932        let spec: WriteSpec = serde_json::from_value(json!({"write_mode": "overwrite"})).unwrap();
933        assert_eq!(spec.write_mode, WriteMode::Overwrite);
934        assert!(spec.is_overwrite());
935    }
936
937    #[test]
938    fn empty_page_produces_empty_plan() {
939        let plan = plan_writes(&[], &upsert_spec(&["id"]));
940        assert!(plan.upserts.is_empty());
941        assert!(plan.deletes.is_empty());
942        assert!(plan.failed.is_empty());
943    }
944
945    #[test]
946    fn delete_mode_dedups_repeated_key() {
947        // Same key deleted twice in one page collapses to a single delete.
948        let spec = WriteSpec {
949            write_mode: WriteMode::Delete,
950            key: vec!["id".into()],
951            delete_marker: None,
952            rollback: None,
953        };
954        let plan = plan_writes(&[json!({"id": 1}), json!({"id": 1})], &spec);
955        assert_eq!(plan.deletes.len(), 1);
956    }
957}