oxisql_parse/dml.rs
1//! DML planning helpers.
2//!
3//! Converts sqlparser DML [`Statement`]s into a high-level [`DmlPlan`] that
4//! the rest of OxiSQL can reason about without depending on sqlparser's AST
5//! directly.
6//!
7//! # Supported statements
8//!
9//! | SQL form | [`DmlPlan`] variant |
10//! |---|---|
11//! | `INSERT INTO t SELECT …` | [`DmlPlan::InsertSelect`] |
12//! | `INSERT INTO t … ON CONFLICT … DO UPDATE` | [`DmlPlan::Upsert`] |
13//! | `UPDATE t SET … WHERE …` | [`DmlPlan::Update`] |
14//!
15//! All other statement types return `None` from [`plan_dml`].
16
17use crate::{plan_query, LogicalPlan};
18use sqlparser::ast::{
19 OnConflictAction, OnInsert, SetExpr, Statement, TableFactor, TableWithJoins,
20 UpdateTableFromKind,
21};
22
23// ── Public types ─────────────────────────────────────────────────────────────
24
25/// A high-level DML plan derived from a SQL statement.
26#[derive(Debug, Clone)]
27pub enum DmlPlan {
28 /// `INSERT INTO table SELECT …`
29 InsertSelect {
30 /// Target table name.
31 table: String,
32 /// Optional column list from `INSERT INTO t (col1, col2, …)`.
33 columns: Vec<String>,
34 /// Logical plan for the `SELECT` sub-query.
35 source: Box<LogicalPlan>,
36 },
37 /// `INSERT … ON CONFLICT … DO UPDATE SET …` (PostgreSQL / SQLite UPSERT).
38 Upsert {
39 /// Target table name.
40 table: String,
41 /// Column list from `INSERT INTO t (col1, …)`.
42 columns: Vec<String>,
43 /// Value rows as string expressions.
44 values: Vec<Vec<String>>,
45 /// The conflict target column(s), joined with `", "`.
46 conflict_column: String,
47 /// `(column, expression)` pairs from the `DO UPDATE SET` clause.
48 update_exprs: Vec<(String, String)>,
49 },
50 /// `UPDATE t SET col = expr [FROM t2] [WHERE pred]`.
51 Update {
52 /// Target table name.
53 table: String,
54 /// `(column, expression)` pairs from the `SET` clause.
55 set_exprs: Vec<(String, String)>,
56 /// Optional `WHERE` predicate rendered as a string.
57 predicate: Option<String>,
58 /// Optional source table for Postgres-style `UPDATE … FROM t2 …`.
59 from_table: Option<String>,
60 },
61}
62
63// ── Public API ────────────────────────────────────────────────────────────────
64
65/// Convert a sqlparser [`Statement`] into a [`DmlPlan`].
66///
67/// Returns `None` when the statement is not a recognised DML form (e.g. a
68/// plain `SELECT`).
69pub fn plan_dml(stmt: &Statement) -> Option<DmlPlan> {
70 match stmt {
71 Statement::Insert(insert) => plan_insert(insert),
72 Statement::Update(update) => plan_update(update),
73 _ => None,
74 }
75}
76
77// ── Private helpers ───────────────────────────────────────────────────────────
78
79fn plan_insert(insert: &sqlparser::ast::Insert) -> Option<DmlPlan> {
80 let table = insert.table.to_string();
81
82 // Collect column names.
83 let columns: Vec<String> = insert
84 .columns
85 .iter()
86 .filter_map(|col| {
87 col.0
88 .last()
89 .and_then(|p| p.as_ident())
90 .map(|id| id.value.clone())
91 })
92 .collect();
93
94 // Check for ON CONFLICT clause first → Upsert.
95 if let Some(OnInsert::OnConflict(on_conflict)) = &insert.on {
96 if let OnConflictAction::DoUpdate(do_update) = &on_conflict.action {
97 // Conflict target columns.
98 let conflict_column = on_conflict
99 .conflict_target
100 .as_ref()
101 .map(|ct| match ct {
102 sqlparser::ast::ConflictTarget::Columns(cols) => cols
103 .iter()
104 .map(|id| id.value.clone())
105 .collect::<Vec<_>>()
106 .join(", "),
107 sqlparser::ast::ConflictTarget::OnConstraint(name) => name.to_string(),
108 })
109 .unwrap_or_default();
110
111 // SET expressions from DO UPDATE.
112 let update_exprs: Vec<(String, String)> = do_update
113 .assignments
114 .iter()
115 .map(|a| (a.target.to_string(), a.value.to_string()))
116 .collect();
117
118 // Collect VALUES rows as strings.
119 let values = collect_values_rows(&insert.source);
120
121 return Some(DmlPlan::Upsert {
122 table,
123 columns,
124 values,
125 conflict_column,
126 update_exprs,
127 });
128 }
129 }
130
131 // INSERT … SELECT: source is a Query whose body is *not* Values.
132 if let Some(src) = &insert.source {
133 if !matches!(*src.body, SetExpr::Values(_)) {
134 // Plan the SELECT sub-query.
135 let source_plan = plan_query(src).ok()?;
136 return Some(DmlPlan::InsertSelect {
137 table,
138 columns,
139 source: Box::new(source_plan),
140 });
141 }
142 }
143
144 // INSERT … VALUES that carries an ON CONFLICT we already handled above,
145 // but plain VALUES without ON CONFLICT is not a DML plan variant here.
146 None
147}
148
149fn plan_update(update: &sqlparser::ast::Update) -> Option<DmlPlan> {
150 // Extract table name from the TableWithJoins relation.
151 let table = match &update.table.relation {
152 sqlparser::ast::TableFactor::Table { name, .. } => name.to_string(),
153 other => other.to_string(),
154 };
155
156 let set_exprs: Vec<(String, String)> = update
157 .assignments
158 .iter()
159 .map(|a| (a.target.to_string(), a.value.to_string()))
160 .collect();
161
162 let predicate = update.selection.as_ref().map(|e| e.to_string());
163
164 // Postgres-style `UPDATE … FROM t2 …` — extract the source table name.
165 let from_table = update.from.as_ref().and_then(|kind| {
166 let twjs: &[TableWithJoins] = match kind {
167 UpdateTableFromKind::BeforeSet(v) | UpdateTableFromKind::AfterSet(v) => v,
168 };
169 twjs.first().map(|twj| match &twj.relation {
170 TableFactor::Table { name, .. } => name.to_string(),
171 other => other.to_string(),
172 })
173 });
174
175 Some(DmlPlan::Update {
176 table,
177 set_exprs,
178 predicate,
179 from_table,
180 })
181}
182
183/// Extract value rows from an INSERT source query.
184///
185/// Returns an empty `Vec` when the source is absent or is not a `VALUES`
186/// clause.
187fn collect_values_rows(source: &Option<Box<sqlparser::ast::Query>>) -> Vec<Vec<String>> {
188 source
189 .as_ref()
190 .and_then(|q| {
191 if let SetExpr::Values(vals) = &*q.body {
192 Some(
193 vals.rows
194 .iter()
195 .map(|row| row.iter().map(|e| e.to_string()).collect())
196 .collect(),
197 )
198 } else {
199 None
200 }
201 })
202 .unwrap_or_default()
203}
204
205// ── Tests ─────────────────────────────────────────────────────────────────────
206
207#[cfg(test)]
208mod tests {
209 use super::*;
210 use crate::parse_one;
211
212 /// `INSERT INTO archive SELECT * FROM orders WHERE status = 'done'`
213 /// should produce `DmlPlan::InsertSelect` targeting "archive".
214 #[test]
215 fn test_plan_insert_select() {
216 let stmt = parse_one("INSERT INTO archive SELECT * FROM orders WHERE status = 'done'")
217 .expect("parse");
218
219 let plan = plan_dml(&stmt).expect("should produce a DmlPlan");
220
221 match plan {
222 DmlPlan::InsertSelect { table, .. } => {
223 assert_eq!(table, "archive");
224 }
225 other => panic!("expected InsertSelect, got {other:?}"),
226 }
227 }
228
229 /// `UPDATE users SET active = false WHERE last_login < '2020-01-01'`
230 /// should produce `DmlPlan::Update`.
231 #[test]
232 fn test_plan_update() {
233 let stmt = parse_one("UPDATE users SET active = false WHERE last_login < '2020-01-01'")
234 .expect("parse");
235
236 let plan = plan_dml(&stmt).expect("should produce a DmlPlan");
237
238 match plan {
239 DmlPlan::Update {
240 table,
241 predicate,
242 from_table,
243 ..
244 } => {
245 assert_eq!(table, "users");
246 assert!(predicate.is_some(), "expected a WHERE predicate");
247 assert!(
248 from_table.is_none(),
249 "plain UPDATE should have no from_table"
250 );
251 }
252 other => panic!("expected Update, got {other:?}"),
253 }
254 }
255
256 /// `UPDATE … FROM` — Postgres-style join-based update.
257 ///
258 /// Verified with the PostgreSQL dialect which parses `UPDATE … FROM`.
259 /// The generic dialect does not support this syntax, so we fall back
260 /// gracefully if that path is hit.
261 #[test]
262 fn test_plan_update_from() {
263 // Try with the Postgres dialect first — it supports UPDATE … FROM.
264 let sql = "UPDATE orders SET status = 'done' FROM users WHERE orders.user_id = users.id";
265 let result = crate::parse_postgres(sql);
266 match result {
267 Ok(stmts) => {
268 if let Some(stmt) = stmts.into_iter().next() {
269 let plan = plan_dml(&stmt);
270 match plan {
271 Some(DmlPlan::Update {
272 table, from_table, ..
273 }) => {
274 assert_eq!(table, "orders");
275 // Postgres dialect populates from_table for UPDATE … FROM.
276 assert_eq!(
277 from_table.as_deref(),
278 Some("users"),
279 "from_table should be 'users', got {:?}",
280 from_table
281 );
282 }
283 other => {
284 // Parsing succeeded but plan_dml returned None or a
285 // different variant — acceptable if dialect did something
286 // unexpected.
287 let _ = other;
288 }
289 }
290 }
291 }
292 Err(_) => {
293 // If even the Postgres dialect rejects this SQL, document the
294 // limitation and pass.
295 }
296 }
297 }
298
299 /// A plain `SELECT` statement should return `None` from `plan_dml`.
300 #[test]
301 fn test_plan_dml_returns_none_for_select() {
302 let stmt = parse_one("SELECT id FROM users").expect("parse");
303 let result = plan_dml(&stmt);
304 assert!(result.is_none(), "expected None for SELECT, got {result:?}");
305 }
306
307 /// `INSERT … ON CONFLICT … DO UPDATE` (UPSERT) is recognised.
308 #[test]
309 fn test_plan_upsert() {
310 let stmt = parse_one(
311 "INSERT INTO users (id, name) VALUES (1, 'alice') \
312 ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name",
313 )
314 .expect("parse");
315
316 let plan = plan_dml(&stmt).expect("should produce a DmlPlan");
317
318 match plan {
319 DmlPlan::Upsert {
320 table,
321 conflict_column,
322 update_exprs,
323 ..
324 } => {
325 assert_eq!(table, "users");
326 assert_eq!(conflict_column, "id");
327 assert!(
328 !update_exprs.is_empty(),
329 "expected at least one SET expression"
330 );
331 }
332 other => panic!("expected Upsert, got {other:?}"),
333 }
334 }
335}