Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 72 additions & 4 deletions crates/oxide-sql-core/src/parser/mod.rs
Original file line number Diff line number Diff line change
@@ -1,11 +1,79 @@
//! SQL Parser
//!
//! A hand-written recursive descent parser with Pratt expression parsing.
//! A hand-written recursive descent parser with Pratt expression
//! parsing for a subset of SQL:2016 (ISO/IEC 9075) covering DML/DQL
//! operations.
//!
//! # Parsing approach
//!
//! Statements (`SELECT`, `INSERT`, `UPDATE`, `DELETE`) are parsed by
//! dedicated recursive-descent methods. Expressions use a Pratt
//! (top-down operator precedence) parser that handles prefix, infix,
//! and postfix operators with correct precedence and associativity.
//!
//! # Supported statements
//!
//! | Statement | Notes |
//! |-----------|-------|
//! | `SELECT` | Full DQL with all clauses listed below |
//! | `INSERT` | `VALUES`, `DEFAULT VALUES`, sub-`SELECT`, `ON CONFLICT` |
//! | `UPDATE` | `SET`, optional `FROM`, optional alias |
//! | `DELETE` | Optional alias, `WHERE` |
//!
//! # SELECT clauses
//!
//! `DISTINCT` / `ALL`, column list with aliases, `FROM` (table,
//! schema-qualified table, subquery, aliases), `WHERE`, `GROUP BY`,
//! `HAVING`, `ORDER BY` (with `ASC` / `DESC` and
//! `NULLS FIRST` / `NULLS LAST`), `LIMIT`, `OFFSET`.
//!
//! # JOINs
//!
//! `INNER`, `LEFT [OUTER]`, `RIGHT [OUTER]`, `FULL [OUTER]`,
//! `CROSS`, with `ON` or `USING` conditions. Chained (multi-table)
//! joins are left-associative.
//!
//! # Expressions
//!
//! - **Literals**: integers, floats, strings, blobs (`X'…'`),
//! booleans (`TRUE`/`FALSE`), `NULL`
//! - **Column references**: unqualified (`col`), qualified (`t.col`),
//! wildcards (`*`, `t.*`)
//! - **Binary operators**: `+`, `-`, `*`, `/`, `%`, `||`, `&`, `|`,
//! `<<`, `>>`, `=`, `!=`/`<>`, `<`, `<=`, `>`, `>=`, `AND`, `OR`,
//! `LIKE`
//! - **Unary operators**: `-` (negate), `NOT`, `~` (bitwise NOT)
//! - **Special forms**: `IS [NOT] NULL`, `BETWEEN … AND …`,
//! `IN (…)`, `CASE`/`WHEN`/`THEN`/`ELSE`/`END`,
//! `CAST(… AS <type>)`, `EXISTS(…)`
//! - **Function calls**: named functions with optional `DISTINCT`
//! (e.g. `COUNT(DISTINCT col)`)
//! - **Subqueries**: scalar `(SELECT …)` in expressions
//! - **Parameters**: positional (`?`) and named (`:name`)
//!
//! # Data types (via CAST)
//!
//! `SMALLINT`, `INTEGER`/`INT`, `BIGINT`, `REAL`, `DOUBLE`/`FLOAT`,
//! `DECIMAL(p, s)`, `NUMERIC(p, s)`, `CHAR(n)`, `VARCHAR(n)`,
//! `TEXT`, `BLOB`, `BINARY(n)`, `VARBINARY(n)`, `DATE`, `TIME`,
//! `TIMESTAMP`, `DATETIME`, `BOOLEAN`.
//!
//! # INSERT extensions
//!
//! `ON CONFLICT DO NOTHING` and `ON CONFLICT DO UPDATE SET …` for
//! upsert semantics.
//!
//! # Not supported
//!
//! DDL (`CREATE` / `ALTER` / `DROP`), transactions
//! (`BEGIN` / `COMMIT` / `ROLLBACK`), set operations
//! (`UNION` / `INTERSECT` / `EXCEPT`), window functions
//! (`OVER` / `PARTITION BY`), common table expressions (`WITH`),
//! `NATURAL JOIN`.

mod core;
mod error;
#[allow(clippy::module_inception)]
mod parser;
mod pratt;

pub use core::Parser;
pub use error::ParseError;
pub use parser::Parser;
60 changes: 60 additions & 0 deletions crates/oxide-sql-core/tests/common/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
#![allow(dead_code)]

use oxide_sql_core::ast::{
DeleteStatement, InsertStatement, SelectStatement, Statement, UpdateStatement,
};
use oxide_sql_core::{ParseError, Parser};

pub fn parse(sql: &str) -> Statement {
Parser::new(sql)
.parse_statement()
.unwrap_or_else(|e| panic!("Failed to parse: {sql}\nError: {e:?}"))
}

pub fn parse_err(sql: &str) -> ParseError {
Parser::new(sql)
.parse_statement()
.expect_err(&format!("Expected parse error for: {sql}"))
}

pub fn parse_select(sql: &str) -> SelectStatement {
match parse(sql) {
Statement::Select(s) => s,
other => panic!("Expected SELECT, got {other:?}"),
}
}

pub fn parse_insert(sql: &str) -> InsertStatement {
match parse(sql) {
Statement::Insert(i) => i,
other => panic!("Expected INSERT, got {other:?}"),
}
}

pub fn parse_update(sql: &str) -> UpdateStatement {
match parse(sql) {
Statement::Update(u) => u,
other => panic!("Expected UPDATE, got {other:?}"),
}
}

pub fn parse_delete(sql: &str) -> DeleteStatement {
match parse(sql) {
Statement::Delete(d) => d,
other => panic!("Expected DELETE, got {other:?}"),
}
}

/// Verifies that `to_string()` produces a fixed point:
/// parse(sql).to_string() can be re-parsed and yields the same
/// string again.
pub fn round_trip(sql: &str) {
let ast1 = parse(sql);
let rendered1 = ast1.to_string();
let ast2 = parse(&rendered1);
let rendered2 = ast2.to_string();
assert_eq!(
rendered1, rendered2,
"Round-trip failed.\n Input: {sql}\n First: {rendered1}\n Second: {rendered2}"
);
}
155 changes: 155 additions & 0 deletions crates/oxide-sql-core/tests/parser_complex.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
//! Tests for complex realistic queries combining multiple features.

mod common;
use common::*;

use oxide_sql_core::ast::{BinaryOp, Expr, InsertSource, JoinType, OrderDirection, TableRef};

#[test]
fn complex_report_query() {
let s = parse_select(
"SELECT c.name, COUNT(o.id) AS order_count, SUM(o.total) AS revenue \
FROM customers c \
LEFT JOIN orders 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",
);
assert_eq!(s.columns.len(), 3);
assert!(s.where_clause.is_some());
assert_eq!(s.group_by.len(), 1);
assert!(s.having.is_some());
assert_eq!(s.order_by.len(), 1);
assert_eq!(s.order_by[0].direction, OrderDirection::Desc);
assert!(s.limit.is_some());
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");
}

#[test]
fn complex_self_join() {
let s = parse_select(
"SELECT e.name, m.name AS manager_name \
FROM employees e \
LEFT JOIN employees m ON e.manager_id = m.id",
);
if let Some(TableRef::Join { left, join }) = &s.from {
assert_eq!(join.join_type, JoinType::Left);
assert!(matches!(
left.as_ref(),
TableRef::Table { name, alias: Some(a), .. }
if name == "employees" && a == "e"
));
assert!(matches!(
&join.table,
TableRef::Table { name, alias: Some(a), .. }
if name == "employees" && a == "m"
));
} else {
panic!("Expected self-join");
}
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");
}

#[test]
fn complex_three_table_join() {
let s = parse_select(
"SELECT u.name, o.id, p.title \
FROM users u \
JOIN orders o ON u.id = o.user_id \
JOIN products p ON o.product_id = p.id",
);
if let Some(TableRef::Join { left, join: outer }) = &s.from {
assert!(matches!(
&outer.table,
TableRef::Table { name, .. } if name == "products"
));
assert!(matches!(left.as_ref(), TableRef::Join { .. }));
} else {
panic!("Expected 3-table join");
}
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");
}

#[test]
fn complex_insert_from_select_with_join() {
let i = parse_insert(
"INSERT INTO order_summary (user_name, total) \
SELECT u.name, SUM(o.amount) \
FROM users u \
JOIN orders o ON u.id = o.user_id \
GROUP BY u.name",
);
assert_eq!(i.columns, vec!["user_name", "total"]);
if let InsertSource::Query(q) = &i.values {
assert!(q.from.is_some());
assert_eq!(q.group_by.len(), 1);
} else {
panic!("Expected INSERT ... SELECT");
}
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");
}

#[test]
fn complex_deeply_nested_arithmetic() {
let s = parse_select("SELECT ((1 + 2) * (3 - 4)) / 5");
if let Expr::Binary { op, .. } = &s.columns[0].expr {
assert_eq!(*op, BinaryOp::Div);
} else {
panic!("Expected division");
}
round_trip("SELECT ((1 + 2) * (3 - 4)) / 5");
}

#[test]
fn complex_case_with_alias_and_order_by() {
let s = parse_select(
"SELECT id, \
CASE \
WHEN score >= 90 THEN 'A' \
WHEN score >= 80 THEN 'B' \
ELSE 'C' \
END AS grade \
FROM students \
ORDER BY grade ASC",
);
assert_eq!(s.columns.len(), 2);
assert_eq!(s.columns[1].alias.as_deref(), Some("grade"));
assert!(matches!(&s.columns[1].expr, Expr::Case { .. }));
assert_eq!(s.order_by.len(), 1);
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");
}

#[test]
fn complex_where_mixing_operators() {
let s = parse_select(
"SELECT * FROM products \
WHERE (price > 10 AND price < 100) \
OR (name LIKE '%sale%' AND active = 1)",
);
assert!(matches!(
&s.where_clause,
Some(Expr::Binary {
op: BinaryOp::Or,
..
})
));
round_trip(
"SELECT * FROM products WHERE (price > 10 AND price < 100) OR (name LIKE '%sale%' AND active = 1)",
);
}

#[test]
fn complex_update_with_subquery_in_set() {
let u = parse_update(
"UPDATE users SET rank = (SELECT COUNT(*) FROM scores WHERE scores.user_id = users.id) \
WHERE active = 1",
);
assert_eq!(u.assignments.len(), 1);
assert!(matches!(&u.assignments[0].value, Expr::Subquery(_)));
assert!(u.where_clause.is_some());
round_trip(
"UPDATE users SET rank = (SELECT COUNT(*) FROM scores WHERE scores.user_id = users.id) WHERE active = 1",
);
}
Loading