Skip to main content

oxisql_parse/
analysis.rs

1//! SQL statement analysis utilities: table extraction, column extraction,
2//! parameter counting, and SQL normalization.
3
4use sqlparser::ast::Statement;
5
6use crate::columns;
7
8// ── Analysis functions ──────────────────────────────────────────────────────
9
10/// Returns `true` if the statement is read-only (does not modify data or
11/// schema).
12///
13/// Read-only statements include `SELECT`, `EXPLAIN`, `SHOW`, and similar.
14/// DML (`INSERT`, `UPDATE`, `DELETE`) and DDL (`CREATE`, `DROP`, `ALTER`)
15/// are considered non-read-only.
16pub fn is_read_only(stmt: &Statement) -> bool {
17    matches!(
18        stmt,
19        Statement::Query(_)
20            | Statement::Explain { .. }
21            | Statement::ExplainTable { .. }
22            | Statement::ShowTables { .. }
23            | Statement::ShowColumns { .. }
24            | Statement::ShowVariable { .. }
25            | Statement::ShowVariables { .. }
26            | Statement::ShowCreate { .. }
27            | Statement::ShowDatabases { .. }
28    )
29}
30
31// ── Table extraction ────────────────────────────────────────────────────────
32
33/// Extract table names referenced in a statement using the SQL text.
34///
35/// This is a practical extraction that re-formats the parsed AST to text and
36/// scans for table references.  For `SELECT` statements it inspects the `FROM`
37/// and `JOIN` clauses.  For `INSERT`/`UPDATE`/`DELETE` it inspects the target
38/// table.
39///
40/// Returns a `Vec<String>` of table names.  Names are deduplicated.
41///
42/// # Example
43///
44/// ```rust
45/// use oxisql_parse::{parse_one, extract_tables};
46///
47/// let stmt = parse_one("SELECT * FROM users JOIN orders ON users.id = orders.user_id").unwrap();
48/// let tables = extract_tables(&stmt);
49/// assert!(tables.contains(&"users".to_string()));
50/// assert!(tables.contains(&"orders".to_string()));
51/// ```
52pub fn extract_tables(stmt: &Statement) -> Vec<String> {
53    let mut tables = Vec::new();
54
55    match stmt {
56        Statement::Query(query) => {
57            extract_tables_from_query(query, &mut tables);
58        }
59        Statement::Insert(insert) => {
60            tables.push(insert.table.to_string());
61            if let Some(ref src) = insert.source {
62                if let sqlparser::ast::SetExpr::Select(ref sel) = *src.body {
63                    extract_tables_from_select(sel, &mut tables);
64                }
65            }
66        }
67        Statement::Update(update) => {
68            extract_tables_from_table_with_joins(&update.table, &mut tables);
69        }
70        Statement::Delete(delete) => match &delete.from {
71            sqlparser::ast::FromTable::WithFromKeyword(twjs) => {
72                for twj in twjs {
73                    extract_tables_from_table_with_joins(twj, &mut tables);
74                }
75            }
76            sqlparser::ast::FromTable::WithoutKeyword(twjs) => {
77                for twj in twjs {
78                    extract_tables_from_table_with_joins(twj, &mut tables);
79                }
80            }
81        },
82        Statement::CreateTable(ct) => {
83            tables.push(ct.name.to_string());
84        }
85        Statement::Drop { names, .. } => {
86            for name in names {
87                tables.push(name.to_string());
88            }
89        }
90        Statement::AlterTable(alter_table) => {
91            tables.push(alter_table.name.to_string());
92        }
93        _ => {}
94    }
95
96    // Deduplicate
97    tables.sort();
98    tables.dedup();
99    tables
100}
101
102fn extract_tables_from_query(query: &sqlparser::ast::Query, tables: &mut Vec<String>) {
103    if let sqlparser::ast::SetExpr::Select(ref sel) = *query.body {
104        extract_tables_from_select(sel, tables);
105    }
106}
107
108fn extract_tables_from_select(sel: &sqlparser::ast::Select, tables: &mut Vec<String>) {
109    for item in &sel.from {
110        extract_table_name_from_factor(&item.relation, tables);
111        for join in &item.joins {
112            extract_table_name_from_factor(&join.relation, tables);
113        }
114    }
115}
116
117fn extract_table_name_from_factor(factor: &sqlparser::ast::TableFactor, tables: &mut Vec<String>) {
118    match factor {
119        sqlparser::ast::TableFactor::Table { name, .. } => {
120            tables.push(name.to_string());
121        }
122        sqlparser::ast::TableFactor::Derived { subquery, .. } => {
123            extract_tables_from_query(subquery, tables);
124        }
125        sqlparser::ast::TableFactor::NestedJoin {
126            table_with_joins, ..
127        } => {
128            extract_table_name_from_factor(&table_with_joins.relation, tables);
129            for join in &table_with_joins.joins {
130                extract_table_name_from_factor(&join.relation, tables);
131            }
132        }
133        _ => {}
134    }
135}
136
137fn extract_tables_from_table_with_joins(
138    twj: &sqlparser::ast::TableWithJoins,
139    tables: &mut Vec<String>,
140) {
141    extract_table_name_from_factor(&twj.relation, tables);
142    for join in &twj.joins {
143        extract_table_name_from_factor(&join.relation, tables);
144    }
145}
146
147// ── Parameter counting ──────────────────────────────────────────────────────
148
149/// Count the number of positional parameter placeholders in a SQL string.
150///
151/// Recognises both `$N` (Postgres-style) and `?` (MySQL/SQLite-style)
152/// placeholders.  For `$N` parameters, returns the highest N found.
153/// For `?` parameters, returns the count.
154///
155/// Note: this is a simple lexical scan, not an AST analysis.  It does not
156/// distinguish placeholders inside string literals.
157pub fn count_params(sql: &str) -> usize {
158    let mut max_dollar = 0usize;
159    let mut question_count = 0usize;
160
161    let bytes = sql.as_bytes();
162    let mut i = 0;
163    while i < bytes.len() {
164        if bytes[i] == b'\'' {
165            // Skip string literal
166            i += 1;
167            while i < bytes.len() {
168                if bytes[i] == b'\'' {
169                    if i + 1 < bytes.len() && bytes[i + 1] == b'\'' {
170                        i += 2; // escaped quote
171                    } else {
172                        break;
173                    }
174                } else {
175                    i += 1;
176                }
177            }
178            i += 1; // skip closing quote
179        } else if bytes[i] == b'$' {
180            // Parse $N parameter
181            i += 1;
182            let start = i;
183            while i < bytes.len() && bytes[i].is_ascii_digit() {
184                i += 1;
185            }
186            if i > start {
187                if let Ok(n) = std::str::from_utf8(&bytes[start..i])
188                    .unwrap_or("0")
189                    .parse::<usize>()
190                {
191                    if n > max_dollar {
192                        max_dollar = n;
193                    }
194                }
195            }
196        } else if bytes[i] == b'?' {
197            question_count += 1;
198            i += 1;
199        } else {
200            i += 1;
201        }
202    }
203
204    if max_dollar > 0 {
205        max_dollar
206    } else {
207        question_count
208    }
209}
210
211// ── Normalization ────────────────────────────────────────────────────────────
212
213/// Normalize a SQL string for use as a cache key.
214///
215/// Applies:
216/// - Collapse any sequence of whitespace (spaces, tabs, newlines) to a
217///   single ASCII space.
218/// - Trim leading and trailing whitespace.
219/// - Uppercase all characters outside of string literals (so `select` and
220///   `SELECT` produce the same key).
221///
222/// This is a lightweight lexical normalization, not a full AST round-trip.
223/// It is suitable for prepared-statement cache key generation but does not
224/// guarantee two semantically identical queries will produce the same key
225/// (e.g. different identifier quoting styles will still differ).
226///
227/// # Example
228///
229/// ```rust
230/// use oxisql_parse::normalize;
231/// assert_eq!(normalize("select  id  from  users"), "SELECT ID FROM USERS");
232/// ```
233pub fn normalize(sql: &str) -> String {
234    let mut result = String::with_capacity(sql.len());
235    let mut in_string = false;
236    let mut prev_was_space = false;
237    let mut chars = sql.chars().peekable();
238
239    while let Some(ch) = chars.next() {
240        if in_string {
241            result.push(ch);
242            if ch == '\'' {
243                // Check for escaped quote ('')
244                if chars.peek() == Some(&'\'') {
245                    result.push('\'');
246                    chars.next();
247                } else {
248                    in_string = false;
249                }
250            }
251        } else if ch == '\'' {
252            in_string = true;
253            prev_was_space = false;
254            result.push(ch);
255        } else if ch.is_whitespace() {
256            if !prev_was_space && !result.is_empty() {
257                result.push(' ');
258                prev_was_space = true;
259            }
260        } else {
261            prev_was_space = false;
262            for uc in ch.to_uppercase() {
263                result.push(uc);
264            }
265        }
266    }
267
268    // Remove any trailing space (e.g. from trailing whitespace in input).
269    if result.ends_with(' ') {
270        result.pop();
271    }
272
273    result
274}
275
276// ── Column extraction ────────────────────────────────────────────────────────
277
278/// Extract column names referenced in a statement.
279///
280/// Returns a deduplicated, sorted list of column names (without table prefix).
281/// Only bare column references (`table.column` is normalized to `column`)
282/// from SELECT, WHERE, ORDER BY, GROUP BY, and JOIN ON clauses are included.
283/// Wildcard `*` selections are excluded.
284///
285/// # Example
286///
287/// ```rust
288/// use oxisql_parse::{parse_one, extract_columns};
289///
290/// let stmt = parse_one("SELECT id, name FROM users WHERE age > 18").unwrap();
291/// let cols = extract_columns(&stmt);
292/// assert!(cols.contains(&"id".to_string()));
293/// assert!(cols.contains(&"name".to_string()));
294/// assert!(cols.contains(&"age".to_string()));
295/// ```
296pub fn extract_columns(stmt: &Statement) -> Vec<String> {
297    let mut cols = Vec::new();
298    columns::collect_columns_from_statement(stmt, &mut cols);
299    cols.sort();
300    cols.dedup();
301    cols
302}