Skip to content

Commit bfaa2a3

Browse files
authored
Merge pull request #16 from LeakIX/danny/split-parser-tests
Split parser tests into focused files, add module docs
2 parents 2b32cd0 + a60c960 commit bfaa2a3

16 files changed

Lines changed: 2197 additions & 2120 deletions
File renamed without changes.

crates/oxide-sql-core/src/parser/mod.rs

Lines changed: 72 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,79 @@
11
//! SQL Parser
22
//!
3-
//! A hand-written recursive descent parser with Pratt expression parsing.
3+
//! A hand-written recursive descent parser with Pratt expression
4+
//! parsing for a subset of SQL:2016 (ISO/IEC 9075) covering DML/DQL
5+
//! operations.
6+
//!
7+
//! # Parsing approach
8+
//!
9+
//! Statements (`SELECT`, `INSERT`, `UPDATE`, `DELETE`) are parsed by
10+
//! dedicated recursive-descent methods. Expressions use a Pratt
11+
//! (top-down operator precedence) parser that handles prefix, infix,
12+
//! and postfix operators with correct precedence and associativity.
13+
//!
14+
//! # Supported statements
15+
//!
16+
//! | Statement | Notes |
17+
//! |-----------|-------|
18+
//! | `SELECT` | Full DQL with all clauses listed below |
19+
//! | `INSERT` | `VALUES`, `DEFAULT VALUES`, sub-`SELECT`, `ON CONFLICT` |
20+
//! | `UPDATE` | `SET`, optional `FROM`, optional alias |
21+
//! | `DELETE` | Optional alias, `WHERE` |
22+
//!
23+
//! # SELECT clauses
24+
//!
25+
//! `DISTINCT` / `ALL`, column list with aliases, `FROM` (table,
26+
//! schema-qualified table, subquery, aliases), `WHERE`, `GROUP BY`,
27+
//! `HAVING`, `ORDER BY` (with `ASC` / `DESC` and
28+
//! `NULLS FIRST` / `NULLS LAST`), `LIMIT`, `OFFSET`.
29+
//!
30+
//! # JOINs
31+
//!
32+
//! `INNER`, `LEFT [OUTER]`, `RIGHT [OUTER]`, `FULL [OUTER]`,
33+
//! `CROSS`, with `ON` or `USING` conditions. Chained (multi-table)
34+
//! joins are left-associative.
35+
//!
36+
//! # Expressions
37+
//!
38+
//! - **Literals**: integers, floats, strings, blobs (`X'…'`),
39+
//! booleans (`TRUE`/`FALSE`), `NULL`
40+
//! - **Column references**: unqualified (`col`), qualified (`t.col`),
41+
//! wildcards (`*`, `t.*`)
42+
//! - **Binary operators**: `+`, `-`, `*`, `/`, `%`, `||`, `&`, `|`,
43+
//! `<<`, `>>`, `=`, `!=`/`<>`, `<`, `<=`, `>`, `>=`, `AND`, `OR`,
44+
//! `LIKE`
45+
//! - **Unary operators**: `-` (negate), `NOT`, `~` (bitwise NOT)
46+
//! - **Special forms**: `IS [NOT] NULL`, `BETWEEN … AND …`,
47+
//! `IN (…)`, `CASE`/`WHEN`/`THEN`/`ELSE`/`END`,
48+
//! `CAST(… AS <type>)`, `EXISTS(…)`
49+
//! - **Function calls**: named functions with optional `DISTINCT`
50+
//! (e.g. `COUNT(DISTINCT col)`)
51+
//! - **Subqueries**: scalar `(SELECT …)` in expressions
52+
//! - **Parameters**: positional (`?`) and named (`:name`)
53+
//!
54+
//! # Data types (via CAST)
55+
//!
56+
//! `SMALLINT`, `INTEGER`/`INT`, `BIGINT`, `REAL`, `DOUBLE`/`FLOAT`,
57+
//! `DECIMAL(p, s)`, `NUMERIC(p, s)`, `CHAR(n)`, `VARCHAR(n)`,
58+
//! `TEXT`, `BLOB`, `BINARY(n)`, `VARBINARY(n)`, `DATE`, `TIME`,
59+
//! `TIMESTAMP`, `DATETIME`, `BOOLEAN`.
60+
//!
61+
//! # INSERT extensions
62+
//!
63+
//! `ON CONFLICT DO NOTHING` and `ON CONFLICT DO UPDATE SET …` for
64+
//! upsert semantics.
65+
//!
66+
//! # Not supported
67+
//!
68+
//! DDL (`CREATE` / `ALTER` / `DROP`), transactions
69+
//! (`BEGIN` / `COMMIT` / `ROLLBACK`), set operations
70+
//! (`UNION` / `INTERSECT` / `EXCEPT`), window functions
71+
//! (`OVER` / `PARTITION BY`), common table expressions (`WITH`),
72+
//! `NATURAL JOIN`.
473
74+
mod core;
575
mod error;
6-
#[allow(clippy::module_inception)]
7-
mod parser;
876
mod pratt;
977

78+
pub use core::Parser;
1079
pub use error::ParseError;
11-
pub use parser::Parser;
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
#![allow(dead_code)]
2+
3+
use oxide_sql_core::ast::{
4+
DeleteStatement, InsertStatement, SelectStatement, Statement, UpdateStatement,
5+
};
6+
use oxide_sql_core::{ParseError, Parser};
7+
8+
pub fn parse(sql: &str) -> Statement {
9+
Parser::new(sql)
10+
.parse_statement()
11+
.unwrap_or_else(|e| panic!("Failed to parse: {sql}\nError: {e:?}"))
12+
}
13+
14+
pub fn parse_err(sql: &str) -> ParseError {
15+
Parser::new(sql)
16+
.parse_statement()
17+
.expect_err(&format!("Expected parse error for: {sql}"))
18+
}
19+
20+
pub fn parse_select(sql: &str) -> SelectStatement {
21+
match parse(sql) {
22+
Statement::Select(s) => s,
23+
other => panic!("Expected SELECT, got {other:?}"),
24+
}
25+
}
26+
27+
pub fn parse_insert(sql: &str) -> InsertStatement {
28+
match parse(sql) {
29+
Statement::Insert(i) => i,
30+
other => panic!("Expected INSERT, got {other:?}"),
31+
}
32+
}
33+
34+
pub fn parse_update(sql: &str) -> UpdateStatement {
35+
match parse(sql) {
36+
Statement::Update(u) => u,
37+
other => panic!("Expected UPDATE, got {other:?}"),
38+
}
39+
}
40+
41+
pub fn parse_delete(sql: &str) -> DeleteStatement {
42+
match parse(sql) {
43+
Statement::Delete(d) => d,
44+
other => panic!("Expected DELETE, got {other:?}"),
45+
}
46+
}
47+
48+
/// Verifies that `to_string()` produces a fixed point:
49+
/// parse(sql).to_string() can be re-parsed and yields the same
50+
/// string again.
51+
pub fn round_trip(sql: &str) {
52+
let ast1 = parse(sql);
53+
let rendered1 = ast1.to_string();
54+
let ast2 = parse(&rendered1);
55+
let rendered2 = ast2.to_string();
56+
assert_eq!(
57+
rendered1, rendered2,
58+
"Round-trip failed.\n Input: {sql}\n First: {rendered1}\n Second: {rendered2}"
59+
);
60+
}
Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
//! Tests for complex realistic queries combining multiple features.
2+
3+
mod common;
4+
use common::*;
5+
6+
use oxide_sql_core::ast::{BinaryOp, Expr, InsertSource, JoinType, OrderDirection, TableRef};
7+
8+
#[test]
9+
fn complex_report_query() {
10+
let s = parse_select(
11+
"SELECT c.name, COUNT(o.id) AS order_count, SUM(o.total) AS revenue \
12+
FROM customers c \
13+
LEFT JOIN orders o ON c.id = o.customer_id \
14+
WHERE c.active = 1 \
15+
GROUP BY c.name \
16+
HAVING COUNT(o.id) > 0 \
17+
ORDER BY revenue DESC \
18+
LIMIT 100",
19+
);
20+
assert_eq!(s.columns.len(), 3);
21+
assert!(s.where_clause.is_some());
22+
assert_eq!(s.group_by.len(), 1);
23+
assert!(s.having.is_some());
24+
assert_eq!(s.order_by.len(), 1);
25+
assert_eq!(s.order_by[0].direction, OrderDirection::Desc);
26+
assert!(s.limit.is_some());
27+
round_trip("SELECT c.name, COUNT(o.id) AS order_count, SUM(o.total) AS revenue FROM customers AS c LEFT JOIN orders AS o ON c.id = o.customer_id WHERE c.active = 1 GROUP BY c.name HAVING COUNT(o.id) > 0 ORDER BY revenue DESC LIMIT 100");
28+
}
29+
30+
#[test]
31+
fn complex_self_join() {
32+
let s = parse_select(
33+
"SELECT e.name, m.name AS manager_name \
34+
FROM employees e \
35+
LEFT JOIN employees m ON e.manager_id = m.id",
36+
);
37+
if let Some(TableRef::Join { left, join }) = &s.from {
38+
assert_eq!(join.join_type, JoinType::Left);
39+
assert!(matches!(
40+
left.as_ref(),
41+
TableRef::Table { name, alias: Some(a), .. }
42+
if name == "employees" && a == "e"
43+
));
44+
assert!(matches!(
45+
&join.table,
46+
TableRef::Table { name, alias: Some(a), .. }
47+
if name == "employees" && a == "m"
48+
));
49+
} else {
50+
panic!("Expected self-join");
51+
}
52+
round_trip("SELECT e.name, m.name AS manager_name FROM employees AS e LEFT JOIN employees AS m ON e.manager_id = m.id");
53+
}
54+
55+
#[test]
56+
fn complex_three_table_join() {
57+
let s = parse_select(
58+
"SELECT u.name, o.id, p.title \
59+
FROM users u \
60+
JOIN orders o ON u.id = o.user_id \
61+
JOIN products p ON o.product_id = p.id",
62+
);
63+
if let Some(TableRef::Join { left, join: outer }) = &s.from {
64+
assert!(matches!(
65+
&outer.table,
66+
TableRef::Table { name, .. } if name == "products"
67+
));
68+
assert!(matches!(left.as_ref(), TableRef::Join { .. }));
69+
} else {
70+
panic!("Expected 3-table join");
71+
}
72+
round_trip("SELECT u.name, o.id, p.title FROM users AS u INNER JOIN orders AS o ON u.id = o.user_id INNER JOIN products AS p ON o.product_id = p.id");
73+
}
74+
75+
#[test]
76+
fn complex_insert_from_select_with_join() {
77+
let i = parse_insert(
78+
"INSERT INTO order_summary (user_name, total) \
79+
SELECT u.name, SUM(o.amount) \
80+
FROM users u \
81+
JOIN orders o ON u.id = o.user_id \
82+
GROUP BY u.name",
83+
);
84+
assert_eq!(i.columns, vec!["user_name", "total"]);
85+
if let InsertSource::Query(q) = &i.values {
86+
assert!(q.from.is_some());
87+
assert_eq!(q.group_by.len(), 1);
88+
} else {
89+
panic!("Expected INSERT ... SELECT");
90+
}
91+
round_trip("INSERT INTO order_summary (user_name, total) SELECT u.name, SUM(o.amount) FROM users AS u INNER JOIN orders AS o ON u.id = o.user_id GROUP BY u.name");
92+
}
93+
94+
#[test]
95+
fn complex_deeply_nested_arithmetic() {
96+
let s = parse_select("SELECT ((1 + 2) * (3 - 4)) / 5");
97+
if let Expr::Binary { op, .. } = &s.columns[0].expr {
98+
assert_eq!(*op, BinaryOp::Div);
99+
} else {
100+
panic!("Expected division");
101+
}
102+
round_trip("SELECT ((1 + 2) * (3 - 4)) / 5");
103+
}
104+
105+
#[test]
106+
fn complex_case_with_alias_and_order_by() {
107+
let s = parse_select(
108+
"SELECT id, \
109+
CASE \
110+
WHEN score >= 90 THEN 'A' \
111+
WHEN score >= 80 THEN 'B' \
112+
ELSE 'C' \
113+
END AS grade \
114+
FROM students \
115+
ORDER BY grade ASC",
116+
);
117+
assert_eq!(s.columns.len(), 2);
118+
assert_eq!(s.columns[1].alias.as_deref(), Some("grade"));
119+
assert!(matches!(&s.columns[1].expr, Expr::Case { .. }));
120+
assert_eq!(s.order_by.len(), 1);
121+
round_trip("SELECT id, CASE WHEN score >= 90 THEN 'A' WHEN score >= 80 THEN 'B' ELSE 'C' END AS grade FROM students ORDER BY grade ASC");
122+
}
123+
124+
#[test]
125+
fn complex_where_mixing_operators() {
126+
let s = parse_select(
127+
"SELECT * FROM products \
128+
WHERE (price > 10 AND price < 100) \
129+
OR (name LIKE '%sale%' AND active = 1)",
130+
);
131+
assert!(matches!(
132+
&s.where_clause,
133+
Some(Expr::Binary {
134+
op: BinaryOp::Or,
135+
..
136+
})
137+
));
138+
round_trip(
139+
"SELECT * FROM products WHERE (price > 10 AND price < 100) OR (name LIKE '%sale%' AND active = 1)",
140+
);
141+
}
142+
143+
#[test]
144+
fn complex_update_with_subquery_in_set() {
145+
let u = parse_update(
146+
"UPDATE users SET rank = (SELECT COUNT(*) FROM scores WHERE scores.user_id = users.id) \
147+
WHERE active = 1",
148+
);
149+
assert_eq!(u.assignments.len(), 1);
150+
assert!(matches!(&u.assignments[0].value, Expr::Subquery(_)));
151+
assert!(u.where_clause.is_some());
152+
round_trip(
153+
"UPDATE users SET rank = (SELECT COUNT(*) FROM scores WHERE scores.user_id = users.id) WHERE active = 1",
154+
);
155+
}

0 commit comments

Comments
 (0)