Skip to main content

nodedb_lite/query/columnar_ops/writes/
ops.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Columnar write operations: insert (by intent), update, delete.
3
4use nodedb_physical::physical_plan::columnar::ColumnarInsertIntent;
5use nodedb_physical::physical_plan::document::types::UpdateValue;
6use nodedb_query::scan_filter::ScanFilter;
7use nodedb_types::columnar::ColumnarSchema;
8use nodedb_types::result::QueryResult;
9use nodedb_types::value::Value;
10
11use crate::error::LiteError;
12use crate::query::engine::LiteQueryEngine;
13use crate::query::text_index::{index_columnar_rows, remove_columnar_rows};
14use crate::storage::engine::StorageEngine;
15
16use super::super::reads::row_to_object;
17use super::payload::decode_payload;
18use super::rows::{apply_conflict_updates, find_row};
19
20/// Parameters for a columnar insert operation.
21pub struct InsertParams<'a> {
22    pub payload: &'a [u8],
23    pub format: &'a str,
24    pub intent: ColumnarInsertIntent,
25    pub on_conflict_updates: &'a [(
26        String,
27        nodedb_physical::physical_plan::document::types::UpdateValue,
28    )],
29    pub surrogates: &'a [nodedb_types::Surrogate],
30    pub schema_bytes: &'a [u8],
31}
32
33/// Insert rows into a columnar collection.
34///
35/// Decodes the payload per `format` ("json", "msgpack", "ilp"), respects
36/// `intent` (Insert / InsertIfAbsent / InsertUnique / Put), and assigns surrogates from the
37/// provided list falling back to 0 when the list is shorter than the row count.
38///
39/// Returns the `QueryResult` together with the column-ordered rows that were
40/// actually written to the memtable. The caller is responsible for calling
41/// `engine.columnar.enqueue_outbound` from an async context to durably queue
42/// those rows for replication to Origin.
43pub async fn insert<S: StorageEngine>(
44    engine: &LiteQueryEngine<S>,
45    collection: &str,
46    params: InsertParams<'_>,
47) -> Result<(QueryResult, Vec<Vec<Value>>), LiteError> {
48    let InsertParams {
49        payload,
50        format,
51        intent,
52        on_conflict_updates,
53        surrogates,
54        schema_bytes,
55    } = params;
56    let schema = engine
57        .columnar
58        .schema(collection)
59        .ok_or_else(|| LiteError::collection_not_found("columnar", collection))?;
60
61    // If caller supplied a schema override, decode it and use it for column ordering.
62    let effective_schema: ColumnarSchema = if !schema_bytes.is_empty() {
63        zerompk::from_msgpack(schema_bytes).unwrap_or(schema)
64    } else {
65        schema
66    };
67
68    let col_names: Vec<String> = effective_schema
69        .columns
70        .iter()
71        .map(|c| c.name.clone())
72        .collect();
73    // The declared primary-key column, or column 0 when none is declared.
74    let pk_idx = effective_schema
75        .columns
76        .iter()
77        .position(|c| c.primary_key)
78        .unwrap_or(0);
79
80    let rows = decode_payload(payload, format, &col_names)?;
81
82    let mut affected: u64 = 0;
83    let mut inserted_rows: Vec<Vec<Value>> = Vec::new();
84
85    for (row_idx, row_values) in rows.into_iter().enumerate() {
86        let _surrogate = surrogates
87            .get(row_idx)
88            .copied()
89            .unwrap_or(nodedb_types::Surrogate(0));
90
91        match intent {
92            ColumnarInsertIntent::Insert => {
93                engine.columnar.insert(collection, &row_values)?;
94                inserted_rows.push(row_values);
95                affected += 1;
96            }
97            ColumnarInsertIntent::InsertIfAbsent => {
98                if let Some(pk) = row_values.get(pk_idx)
99                    && find_row(engine, collection, pk).await?.is_some()
100                {
101                    continue;
102                }
103                engine.columnar.insert(collection, &row_values)?;
104                inserted_rows.push(row_values);
105                affected += 1;
106            }
107            ColumnarInsertIntent::InsertUnique => {
108                // A declared natural-key PRIMARY KEY: a duplicate refuses the
109                // row instead of tombstoning the prior one.
110                if let Some(pk) = row_values.get(pk_idx)
111                    && find_row(engine, collection, pk).await?.is_some()
112                {
113                    return Err(LiteError::UniqueViolation {
114                        collection: collection.to_string(),
115                        detail: format!(
116                            "{} = {pk:?}",
117                            col_names.get(pk_idx).map(String::as_str).unwrap_or("pk")
118                        ),
119                    });
120                }
121                engine.columnar.insert(collection, &row_values)?;
122                inserted_rows.push(row_values);
123                affected += 1;
124            }
125            ColumnarInsertIntent::Put => {
126                if on_conflict_updates.is_empty() {
127                    // Plain upsert: delete-then-insert (whole-row overwrite).
128                    if let Some(pk) = row_values.get(pk_idx) {
129                        let _ = engine.columnar.delete(collection, pk);
130                    }
131                    engine.columnar.insert(collection, &row_values)?;
132                    inserted_rows.push(row_values);
133                    affected += 1;
134                } else {
135                    // Merge: read existing row, apply conflict updates, write merged.
136                    let existing = match row_values.get(pk_idx) {
137                        Some(pk) => find_row(engine, collection, pk).await?,
138                        None => None,
139                    };
140                    let merged = match existing {
141                        Some(existing) => {
142                            let incoming_obj = row_to_object(&col_names, &row_values);
143                            apply_conflict_updates(
144                                existing,
145                                &incoming_obj,
146                                on_conflict_updates,
147                                &col_names,
148                            )?
149                        }
150                        None => row_values,
151                    };
152                    if let Some(pk) = merged.get(pk_idx) {
153                        let _ = engine.columnar.delete(collection, pk);
154                    }
155                    engine.columnar.insert(collection, &merged)?;
156                    inserted_rows.push(merged);
157                    affected += 1;
158                }
159            }
160        }
161    }
162
163    index_columnar_rows(engine, collection, inserted_rows.iter().map(Vec::as_slice))?;
164    Ok((
165        QueryResult {
166            columns: Vec::new(),
167            rows: Vec::new(),
168            rows_affected: affected,
169            command: Some("INSERT".into()),
170        },
171        inserted_rows,
172    ))
173}
174
175/// Update rows matching filter predicates.
176///
177/// A literal assignment decodes once. An expression assignment evaluates
178/// against each matched row's pre-image, so one assignment never observes
179/// another. Every post-image is built before the first row changes, so an
180/// assignment that fails leaves every row as it was.
181pub async fn update<S: StorageEngine>(
182    engine: &LiteQueryEngine<S>,
183    collection: &str,
184    filters_bytes: &[u8],
185    updates: &[(String, UpdateValue)],
186) -> Result<QueryResult, LiteError> {
187    let schema = engine
188        .columnar
189        .schema(collection)
190        .ok_or_else(|| LiteError::collection_not_found("columnar", collection))?;
191
192    let col_names: Vec<String> = schema.columns.iter().map(|c| c.name.clone()).collect();
193    let pk_idx = schema
194        .columns
195        .iter()
196        .position(|c| c.primary_key)
197        .unwrap_or(0);
198
199    let filters: Vec<ScanFilter> = if filters_bytes.is_empty() {
200        Vec::new()
201    } else {
202        zerompk::from_msgpack(filters_bytes).map_err(|e| LiteError::Serialization {
203            detail: format!("decode update filters: {e}"),
204        })?
205    };
206
207    // `(column index, assignment)` per SET entry. A literal decodes here.
208    let mut assignments: Vec<(usize, Assigned<'_>)> = Vec::with_capacity(updates.len());
209    for (field, update) in updates {
210        let Some(col_idx) = col_names.iter().position(|n| n == field) else {
211            continue;
212        };
213        let assigned = match update {
214            UpdateValue::Literal(bytes) => {
215                Assigned::Literal(nodedb_types::value_from_msgpack(bytes).map_err(|e| {
216                    LiteError::Serialization {
217                        detail: format!("decode update value for field '{field}': {e}"),
218                    }
219                })?)
220            }
221            UpdateValue::Expr(expr) => Assigned::Expr(expr),
222        };
223        assignments.push((col_idx, assigned));
224    }
225
226    let all_rows = engine.columnar.list_rows(collection).await?;
227
228    let mut pending: Vec<(Value, Vec<Value>)> = Vec::new();
229    for row in all_rows {
230        let doc = row_to_object(&col_names, &row);
231        if !ScanFilter::all_match_value(&filters, &doc)? {
232            continue;
233        }
234        let pk = row.get(pk_idx).cloned().unwrap_or(Value::Null);
235        let mut new_values = row;
236        for (col_idx, assigned) in &assignments {
237            let value = match assigned {
238                Assigned::Literal(value) => value.clone(),
239                Assigned::Expr(expr) => expr.eval(&doc)?,
240            };
241            if let Some(slot) = new_values.get_mut(*col_idx) {
242                *slot = value;
243            }
244        }
245        pending.push((pk, new_values));
246    }
247
248    let mut affected: u64 = 0;
249    for (pk, new_values) in pending {
250        engine.columnar.update(collection, &pk, &new_values)?;
251        index_columnar_rows(engine, collection, [new_values.as_slice()])?;
252        affected += 1;
253    }
254
255    Ok(QueryResult {
256        columns: Vec::new(),
257        rows: Vec::new(),
258        rows_affected: affected,
259        command: Some("UPDATE".into()),
260    })
261}
262
263/// The value one columnar UPDATE assignment writes.
264enum Assigned<'a> {
265    Literal(Value),
266    Expr(&'a nodedb_query::expr::SqlExpr),
267}
268
269/// Delete rows matching filter predicates.
270pub async fn delete<S: StorageEngine>(
271    engine: &LiteQueryEngine<S>,
272    collection: &str,
273    filters_bytes: &[u8],
274) -> Result<QueryResult, LiteError> {
275    let schema = engine
276        .columnar
277        .schema(collection)
278        .ok_or_else(|| LiteError::collection_not_found("columnar", collection))?;
279
280    let col_names: Vec<String> = schema.columns.iter().map(|c| c.name.clone()).collect();
281    let pk_idx = schema
282        .columns
283        .iter()
284        .position(|c| c.primary_key)
285        .unwrap_or(0);
286
287    let filters: Vec<ScanFilter> = if filters_bytes.is_empty() {
288        Vec::new()
289    } else {
290        zerompk::from_msgpack(filters_bytes).map_err(|e| LiteError::Serialization {
291            detail: format!("decode delete filters: {e}"),
292        })?
293    };
294
295    let all_rows = engine.columnar.list_rows(collection).await?;
296
297    let mut pks_to_delete: Vec<Value> = Vec::new();
298    for row in all_rows {
299        let doc = row_to_object(&col_names, &row);
300        let matches = filters.is_empty() || ScanFilter::all_match_value(&filters, &doc)?;
301        if matches {
302            pks_to_delete.push(row.get(pk_idx).cloned().unwrap_or(Value::Null));
303        }
304    }
305
306    let mut affected: u64 = 0;
307    for pk in &pks_to_delete {
308        if engine.columnar.delete(collection, pk)? {
309            affected += 1;
310        }
311    }
312    remove_columnar_rows(engine, collection, &pks_to_delete)?;
313
314    Ok(QueryResult {
315        columns: Vec::new(),
316        rows: Vec::new(),
317        rows_affected: affected,
318        command: Some("DELETE".into()),
319    })
320}
321
322#[cfg(test)]
323mod tests {
324    use std::collections::HashMap;
325
326    use nodedb_types::columnar::{ColumnDef, ColumnType, ColumnarProfile};
327
328    use super::*;
329    use crate::query::engine::test_engine;
330
331    async fn seed(engine: &LiteQueryEngine<crate::PagedbStorageMem>) {
332        let schema = ColumnarSchema::new(vec![
333            ColumnDef::required("sku", ColumnType::String).with_primary_key(),
334            ColumnDef::nullable("qty", ColumnType::Int64),
335        ])
336        .expect("schema");
337        engine
338            .columnar
339            .create_collection("items", schema, ColumnarProfile::Plain, false)
340            .await
341            .expect("create");
342    }
343
344    fn payload(sku: &str, qty: i64) -> Vec<u8> {
345        let mut row = HashMap::new();
346        row.insert("sku".to_string(), Value::String(sku.into()));
347        row.insert("qty".to_string(), Value::Integer(qty));
348        zerompk::to_msgpack_vec(&Value::Object(row)).expect("encode")
349    }
350
351    async fn run(
352        engine: &LiteQueryEngine<crate::PagedbStorageMem>,
353        intent: ColumnarInsertIntent,
354        bytes: &[u8],
355    ) -> Result<u64, LiteError> {
356        insert(
357            engine,
358            "items",
359            InsertParams {
360                payload: bytes,
361                format: "msgpack",
362                intent,
363                on_conflict_updates: &[],
364                surrogates: &[],
365                schema_bytes: &[],
366            },
367        )
368        .await
369        .map(|(r, _)| r.rows_affected)
370    }
371
372    #[tokio::test]
373    async fn insert_unique_duplicate_pk_is_a_unique_violation() {
374        let engine = test_engine().await;
375        seed(&engine).await;
376        let first = run(
377            &engine,
378            ColumnarInsertIntent::InsertUnique,
379            &payload("a", 1),
380        )
381        .await
382        .expect("first insert");
383        assert_eq!(first, 1);
384        let err = run(
385            &engine,
386            ColumnarInsertIntent::InsertUnique,
387            &payload("a", 2),
388        )
389        .await
390        .expect_err("duplicate");
391        assert!(matches!(err, LiteError::UniqueViolation { .. }), "{err}");
392        let rows = engine.columnar.list_rows("items").await.expect("rows");
393        assert_eq!(rows.len(), 1);
394        assert_eq!(rows[0][1], Value::Integer(1), "the first row is kept");
395    }
396
397    #[tokio::test]
398    async fn insert_if_absent_duplicate_pk_is_skipped() {
399        let engine = test_engine().await;
400        seed(&engine).await;
401        run(&engine, ColumnarInsertIntent::Insert, &payload("a", 1))
402            .await
403            .expect("first insert");
404        let n = run(
405            &engine,
406            ColumnarInsertIntent::InsertIfAbsent,
407            &payload("a", 2),
408        )
409        .await
410        .expect("if absent");
411        assert_eq!(n, 0);
412    }
413}