nodedb_sql/parser/preprocess/object_literal_stmt.rs
1// SPDX-License-Identifier: Apache-2.0
2
3//! Rewrite `INSERT/UPSERT INTO coll { ... }` (and `[{ ... }, ...]`) into
4//! standard `INSERT INTO coll (cols) VALUES (row), ...`.
5
6use super::literal::value_to_sql_literal;
7use crate::error::SqlError;
8use crate::parser::object_literal::{
9 parse_object_literal_array_complete, parse_object_literal_complete,
10};
11
12/// Try to rewrite `INSERT INTO coll { ... }` or `INSERT INTO coll [{ ... }, { ... }]`
13/// into standard `INSERT INTO coll (cols) VALUES (row1), (row2)`.
14///
15/// `Ok(None)` means the statement does not use object-literal syntax and the
16/// caller should carry on with the original text. `Err` means it does and is
17/// malformed — including the case where the author wrote a clause after the
18/// literal.
19///
20/// The literal form carries no trailing clause, and that is a property of this
21/// rewrite rather than an oversight: [`fields_to_values_sql`] reconstructs the
22/// statement from the parsed fields alone, so anything the author wrote after
23/// the literal has nowhere to go. Carrying it is not a matter of appending the
24/// text either — the INSERT handler downstream rebuilds its own SQL from the
25/// parsed fields a second time, dropping the clause again, and the hand-rolled
26/// `(cols) VALUES (…)` scanner locates the value list with a reverse search for
27/// `)`, which an `ON CONFLICT (id)` would capture. Making the form carry clauses
28/// is a change to that whole pipeline; until then the honest answer is to refuse
29/// the statement and point at the `(cols) VALUES (…)` form, which does carry
30/// them.
31///
32/// `RETURNING` is the one clause this never sees: every caller splits it off the
33/// text before rewriting and re-attaches it to the rebuilt statement, so it
34/// survives the reconstruction and is not leftover input to reject.
35pub(super) fn try_rewrite_object_literal(sql: &str) -> Result<Option<String>, SqlError> {
36 let after_into = sql["INSERT INTO ".len()..].trim_start();
37 let Some(coll_end) = after_into.find(|c: char| c.is_whitespace()) else {
38 return Ok(None);
39 };
40 let coll_name = &after_into[..coll_end];
41 let rest = after_into[coll_end..].trim_start();
42
43 // Strip trailing semicolon before parsing.
44 let obj_str = rest.trim_end_matches(';').trim_end();
45
46 if obj_str.starts_with('[') {
47 return rewrite_array_form(coll_name, obj_str);
48 }
49
50 if !obj_str.starts_with('{') {
51 return Ok(None);
52 }
53
54 let Some(parsed) = parse_object_literal_complete(obj_str) else {
55 return Ok(None);
56 };
57 let fields = parsed?;
58 if fields.is_empty() {
59 return Ok(None);
60 }
61 Ok(Some(fields_to_values_sql(coll_name, &[fields])))
62}
63
64/// Rewrite `[{ ... }, { ... }]` → multi-row VALUES.
65fn rewrite_array_form(coll_name: &str, obj_str: &str) -> Result<Option<String>, SqlError> {
66 let Some(parsed) = parse_object_literal_array_complete(obj_str) else {
67 return Ok(None);
68 };
69 let objects = parsed?;
70 if objects.is_empty() {
71 return Ok(None);
72 }
73 Ok(Some(fields_to_values_sql(coll_name, &objects)))
74}
75
76/// Build `INSERT INTO coll (col_union) VALUES (row1), (row2), ...`
77///
78/// Collects the union of all keys across all rows. Missing keys get NULL.
79fn fields_to_values_sql(
80 coll_name: &str,
81 rows: &[std::collections::HashMap<String, nodedb_types::Value>],
82) -> String {
83 let mut all_keys: Vec<String> = rows
84 .iter()
85 .flat_map(|r| r.keys().cloned())
86 .collect::<std::collections::BTreeSet<_>>()
87 .into_iter()
88 .collect();
89 all_keys.sort();
90
91 let col_list = all_keys.join(", ");
92
93 let row_strs: Vec<String> = rows
94 .iter()
95 .map(|row| {
96 let vals: Vec<String> = all_keys
97 .iter()
98 .map(|k| match row.get(k) {
99 Some(v) => value_to_sql_literal(v),
100 None => "NULL".to_string(),
101 })
102 .collect();
103 format!("({})", vals.join(", "))
104 })
105 .collect();
106
107 format!(
108 "INSERT INTO {} ({}) VALUES {}",
109 coll_name,
110 col_list,
111 row_strs.join(", ")
112 )
113}