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}