Skip to main content

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}