diff --git a/Cargo.lock b/Cargo.lock index 734fa61..5425989 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8,6 +8,15 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + [[package]] name = "allocator-api2" version = "0.2.21" @@ -23,6 +32,12 @@ dependencies = [ "libc", ] +[[package]] +name = "anes" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299" + [[package]] name = "anstream" version = "0.6.21" @@ -182,6 +197,12 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" +[[package]] +name = "cast" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" + [[package]] name = "castaway" version = "0.2.4" @@ -221,6 +242,33 @@ dependencies = [ "windows-link", ] +[[package]] +name = "ciborium" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e" +dependencies = [ + "ciborium-io", + "ciborium-ll", + "serde", +] + +[[package]] +name = "ciborium-io" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757" + +[[package]] +name = "ciborium-ll" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9" +dependencies = [ + "ciborium-io", + "half", +] + [[package]] name = "clap" version = "4.5.59" @@ -324,6 +372,67 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "criterion" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2b12d017a929603d80db1831cd3a24082f8137ce19c69e6447f54f5fc8d692f" +dependencies = [ + "anes", + "cast", + "ciborium", + "clap", + "criterion-plot", + "is-terminal", + "itertools 0.10.5", + "num-traits", + "once_cell", + "oorandom", + "plotters", + "rayon", + "regex", + "serde", + "serde_derive", + "serde_json", + "tinytemplate", + "walkdir", +] + +[[package]] +name = "criterion-plot" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b50826342786a51a89e2da3a28f1c32b06e387201bc2d19791f622c673706b1" +dependencies = [ + "cast", + "itertools 0.10.5", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + [[package]] name = "crossterm" version = "0.28.1" @@ -828,12 +937,32 @@ dependencies = [ "syn", ] +[[package]] +name = "is-terminal" +version = "0.4.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" +dependencies = [ + "hermit-abi", + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "is_terminal_polyfill" version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +[[package]] +name = "itertools" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" +dependencies = [ + "either", +] + [[package]] name = "itertools" version = "0.13.0" @@ -1144,6 +1273,12 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "oorandom" +version = "11.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" + [[package]] name = "openssl" version = "0.10.75" @@ -1237,6 +1372,7 @@ dependencies = [ "arboard", "chrono", "clap", + "criterion", "crossterm", "deadpool-postgres", "dirs", @@ -1301,6 +1437,34 @@ dependencies = [ "time", ] +[[package]] +name = "plotters" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5aeb6f403d7a4911efb1e33402027fc44f29b5bf6def3effcc22d7bb75f2b747" +dependencies = [ + "num-traits", + "plotters-backend", + "plotters-svg", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "plotters-backend" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df42e13c12958a16b3f7f4386b9ab1f3e7933914ecea48da7139435263a4172a" + +[[package]] +name = "plotters-svg" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51bae2ac328883f7acdfea3d66a7c35751187f870bc81f94563733a154d7a670" +dependencies = [ + "plotters-backend", +] + [[package]] name = "png" version = "0.18.1" @@ -1471,7 +1635,7 @@ dependencies = [ "compact_str", "crossterm", "instability", - "itertools", + "itertools 0.13.0", "lru", "paste", "strum", @@ -1481,6 +1645,26 @@ dependencies = [ "unicode-width", ] +[[package]] +name = "rayon" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -1501,6 +1685,29 @@ dependencies = [ "thiserror 1.0.69", ] +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + [[package]] name = "regex-syntax" version = "0.8.9" @@ -1944,6 +2151,16 @@ dependencies = [ "time-core", ] +[[package]] +name = "tinytemplate" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be4d6b5f19ff7664e8c98d03e2139cb510db9b0a60b55f8e8709b689d939b6bc" +dependencies = [ + "serde", + "serde_json", +] + [[package]] name = "tinyvec" version = "1.10.0" @@ -2179,7 +2396,7 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf" dependencies = [ - "itertools", + "itertools 0.13.0", "unicode-segmentation", "unicode-width", ] diff --git a/Cargo.toml b/Cargo.toml index 6196121..2ca9735 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -62,10 +62,21 @@ sqlparser = "0.53" tracing = "0.1" tracing-subscriber = "0.3" +[lib] +name = "pgrsql" +path = "src/lib.rs" + [[bin]] name = "pgrsql" path = "src/main.rs" +[dev-dependencies] +criterion = { version = "0.5", features = ["html_reports"] } + +[[bench]] +name = "sql_benchmarks" +harness = false + [profile.release] lto = true codegen-units = 1 diff --git a/benches/sql_benchmarks.rs b/benches/sql_benchmarks.rs new file mode 100644 index 0000000..f7ef166 --- /dev/null +++ b/benches/sql_benchmarks.rs @@ -0,0 +1,286 @@ +//! Comprehensive benchmark suite for pgrsql's SQL processing pipeline. +//! +//! Benchmarks cover: +//! - SQL parsing (text → AST) +//! - AST optimization passes +//! - SQL compilation (AST → text) +//! - Full round-trip (parse → optimize → compile) +//! - EXPLAIN plan parsing +//! +//! Run with: `cargo bench` + +use criterion::{black_box, criterion_group, criterion_main, BenchmarkId, Criterion}; +use pgrsql::ast::{compile, parse_single, parse_sql, Optimizer}; +use pgrsql::explain::parse_explain_output; + +// --------------------------------------------------------------------------- +// SQL test inputs organized by complexity +// --------------------------------------------------------------------------- + +const SIMPLE_SELECT: &str = "SELECT * FROM users"; + +const SELECT_WITH_WHERE: &str = + "SELECT id, name, email FROM users WHERE age > 18 AND status = 'active'"; + +const SELECT_WITH_JOIN: &str = "SELECT u.name, o.total, o.created_at \ + FROM users u \ + JOIN orders o ON u.id = o.user_id \ + WHERE o.total > 100.00 \ + ORDER BY o.created_at DESC \ + LIMIT 50"; + +const MULTI_JOIN: &str = "SELECT u.name, o.id, p.name AS product, oi.quantity \ + FROM users u \ + JOIN orders o ON u.id = o.user_id \ + JOIN order_items oi ON o.id = oi.order_id \ + JOIN products p ON oi.product_id = p.id \ + WHERE o.status = 'completed' AND u.active = true \ + ORDER BY o.created_at DESC"; + +const AGGREGATION: &str = "SELECT department, COUNT(*) AS emp_count, \ + AVG(salary) AS avg_salary, MAX(salary) AS max_salary, MIN(salary) AS min_salary \ + FROM employees \ + WHERE hire_date > '2020-01-01' \ + GROUP BY department \ + HAVING COUNT(*) > 5 \ + ORDER BY avg_salary DESC"; + +const CTE_QUERY: &str = "WITH active_users AS (\ + SELECT id, name, email FROM users WHERE status = 'active'\ + ), user_orders AS (\ + SELECT u.id, u.name, COUNT(o.id) AS order_count, SUM(o.total) AS total_spent \ + FROM active_users u \ + JOIN orders o ON u.id = o.user_id \ + GROUP BY u.id, u.name\ + ) \ + SELECT name, order_count, total_spent \ + FROM user_orders \ + WHERE total_spent > 1000 \ + ORDER BY total_spent DESC"; + +const WINDOW_FUNCTION: &str = "SELECT name, department, salary, \ + ROW_NUMBER() OVER (PARTITION BY department ORDER BY salary DESC) AS rank, \ + AVG(salary) OVER (PARTITION BY department) AS dept_avg, \ + SUM(salary) OVER (ORDER BY hire_date ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS running_total \ + FROM employees"; + +const SUBQUERY: &str = "SELECT u.name, u.email \ + FROM users u \ + WHERE u.id IN (SELECT DISTINCT user_id FROM orders WHERE total > 500) \ + AND u.department = (SELECT department FROM departments WHERE name = 'Engineering') \ + AND EXISTS (SELECT 1 FROM reviews r WHERE r.user_id = u.id AND r.rating > 4)"; + +const UNION_QUERY: &str = "SELECT id, name, 'customer' AS type FROM customers WHERE active = true \ + UNION ALL \ + SELECT id, name, 'supplier' AS type FROM suppliers WHERE active = true \ + UNION ALL \ + SELECT id, name, 'partner' AS type FROM partners WHERE active = true"; + +const INSERT_QUERY: &str = + "INSERT INTO users (name, email, age, department) VALUES ('John Doe', 'john@example.com', 30, 'Engineering')"; + +const UPDATE_QUERY: &str = "UPDATE employees SET salary = salary * 1.10, \ + updated_at = CURRENT_TIMESTAMP \ + WHERE department = 'Engineering' AND performance_rating > 4"; + +const DELETE_QUERY: &str = + "DELETE FROM sessions WHERE last_active < CURRENT_TIMESTAMP - INTERVAL '30 days'"; + +const CASE_EXPRESSION: &str = "SELECT name, \ + CASE \ + WHEN salary > 100000 THEN 'senior' \ + WHEN salary > 60000 THEN 'mid' \ + WHEN salary > 30000 THEN 'junior' \ + ELSE 'intern' \ + END AS level, \ + CASE department \ + WHEN 'Engineering' THEN 'tech' \ + WHEN 'Marketing' THEN 'business' \ + ELSE 'other' \ + END AS category \ + FROM employees"; + +// --------------------------------------------------------------------------- +// EXPLAIN plan test input +// --------------------------------------------------------------------------- + +const EXPLAIN_OUTPUT: &str = "\ +Sort (cost=100.00..100.25 rows=100 width=40) (actual time=0.500..0.800 rows=95 loops=1) + Sort Key: name + Sort Method: quicksort Memory: 32kB + -> Hash Join (cost=30.00..96.50 rows=100 width=40) (actual time=0.200..0.400 rows=95 loops=1) + Hash Cond: (o.user_id = u.id) + -> Seq Scan on orders o (cost=0.00..55.00 rows=500 width=20) (actual time=0.010..0.100 rows=500 loops=1) + Filter: (total > 100.00) + Rows Removed by Filter: 250 + -> Hash (cost=25.00..25.00 rows=400 width=24) (actual time=0.150..0.150 rows=400 loops=1) + Buckets: 1024 Batches: 1 Memory Usage: 32kB + -> Seq Scan on users u (cost=0.00..25.00 rows=400 width=24) (actual time=0.005..0.080 rows=400 loops=1) + Filter: (active = true) + Rows Removed by Filter: 100 +Planning Time: 0.250 ms +Execution Time: 1.200 ms"; + +// --------------------------------------------------------------------------- +// Benchmark groups +// --------------------------------------------------------------------------- + +fn bench_parsing(c: &mut Criterion) { + let mut group = c.benchmark_group("parsing"); + + let cases = [ + ("simple_select", SIMPLE_SELECT), + ("select_where", SELECT_WITH_WHERE), + ("select_join", SELECT_WITH_JOIN), + ("multi_join", MULTI_JOIN), + ("aggregation", AGGREGATION), + ("cte", CTE_QUERY), + ("window_function", WINDOW_FUNCTION), + ("subquery", SUBQUERY), + ("union", UNION_QUERY), + ("insert", INSERT_QUERY), + ("update", UPDATE_QUERY), + ("delete", DELETE_QUERY), + ("case_expression", CASE_EXPRESSION), + ]; + + for (name, sql) in &cases { + group.bench_with_input(BenchmarkId::new("parse", name), sql, |b, sql| { + b.iter(|| parse_single(black_box(sql)).unwrap()); + }); + } + + group.finish(); +} + +fn bench_compilation(c: &mut Criterion) { + let mut group = c.benchmark_group("compilation"); + + let cases = [ + ("simple_select", SIMPLE_SELECT), + ("select_where", SELECT_WITH_WHERE), + ("select_join", SELECT_WITH_JOIN), + ("multi_join", MULTI_JOIN), + ("aggregation", AGGREGATION), + ("cte", CTE_QUERY), + ("window_function", WINDOW_FUNCTION), + ("subquery", SUBQUERY), + ("union", UNION_QUERY), + ("insert", INSERT_QUERY), + ("update", UPDATE_QUERY), + ("delete", DELETE_QUERY), + ("case_expression", CASE_EXPRESSION), + ]; + + for (name, sql) in &cases { + let ast = parse_single(sql).unwrap(); + group.bench_with_input(BenchmarkId::new("compile", name), &ast, |b, ast| { + b.iter(|| compile(black_box(ast))); + }); + } + + group.finish(); +} + +fn bench_optimization(c: &mut Criterion) { + let mut group = c.benchmark_group("optimization"); + + let optimizer = Optimizer::with_defaults(); + + let cases = [ + ("simple_select", SIMPLE_SELECT), + ("select_where", SELECT_WITH_WHERE), + ("multi_join", MULTI_JOIN), + ("aggregation", AGGREGATION), + ("cte", CTE_QUERY), + ("window_function", WINDOW_FUNCTION), + ("subquery", SUBQUERY), + ]; + + for (name, sql) in &cases { + let ast = parse_single(sql).unwrap(); + group.bench_with_input(BenchmarkId::new("optimize", name), &ast, |b, ast| { + b.iter(|| optimizer.optimize(black_box(ast.clone())).unwrap()); + }); + } + + group.finish(); +} + +fn bench_round_trip(c: &mut Criterion) { + let mut group = c.benchmark_group("round_trip"); + + let optimizer = Optimizer::with_defaults(); + + let cases = [ + ("simple_select", SIMPLE_SELECT), + ("select_join", SELECT_WITH_JOIN), + ("multi_join", MULTI_JOIN), + ("cte", CTE_QUERY), + ("window_function", WINDOW_FUNCTION), + ("subquery", SUBQUERY), + ]; + + for (name, sql) in &cases { + group.bench_with_input( + BenchmarkId::new("parse_optimize_compile", name), + sql, + |b, sql| { + b.iter(|| { + let ast = parse_single(black_box(sql)).unwrap(); + let optimized = optimizer.optimize(ast).unwrap(); + compile(&optimized) + }); + }, + ); + } + + group.finish(); +} + +fn bench_multi_statement(c: &mut Criterion) { + let mut group = c.benchmark_group("multi_statement"); + + let two_stmts = format!("{}; {}", SIMPLE_SELECT, SELECT_WITH_WHERE); + let five_stmts = format!( + "{}; {}; {}; {}; {}", + SIMPLE_SELECT, SELECT_WITH_WHERE, INSERT_QUERY, UPDATE_QUERY, DELETE_QUERY + ); + + group.bench_function("parse_2_statements", |b| { + b.iter(|| parse_sql(black_box(&two_stmts)).unwrap()); + }); + + group.bench_function("parse_5_statements", |b| { + b.iter(|| parse_sql(black_box(&five_stmts)).unwrap()); + }); + + group.finish(); +} + +fn bench_explain_parsing(c: &mut Criterion) { + let mut group = c.benchmark_group("explain"); + + group.bench_function("parse_explain_plan", |b| { + b.iter(|| parse_explain_output(black_box(EXPLAIN_OUTPUT))); + }); + + // Larger explain plan + let large_plan = format!("{}\n{}\n{}", EXPLAIN_OUTPUT, EXPLAIN_OUTPUT, EXPLAIN_OUTPUT); + group.bench_function("parse_explain_plan_large", |b| { + b.iter(|| parse_explain_output(black_box(&large_plan))); + }); + + group.finish(); +} + +criterion_group!( + benches, + bench_parsing, + bench_compilation, + bench_optimization, + bench_round_trip, + bench_multi_statement, + bench_explain_parsing, +); +criterion_main!(benches); diff --git a/src/db/connection.rs b/src/db/connection.rs index 16e2bd0..2a25ae6 100644 --- a/src/db/connection.rs +++ b/src/db/connection.rs @@ -120,6 +120,12 @@ pub struct ConnectionManager { pub current_schema: String, } +impl Default for ConnectionManager { + fn default() -> Self { + Self::new() + } +} + #[allow(dead_code)] impl ConnectionManager { pub fn new() -> Self { diff --git a/src/editor/history.rs b/src/editor/history.rs index 33f5519..f9899de 100644 --- a/src/editor/history.rs +++ b/src/editor/history.rs @@ -62,6 +62,7 @@ impl QueryHistory { self.entries.get(idx) } + #[allow(clippy::should_implement_trait)] pub fn next(&mut self) -> Option<&HistoryEntry> { if self.entries.is_empty() { return None; diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..f5f4111 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,6 @@ +pub mod ast; +pub mod db; +pub mod editor; +pub mod explain; +pub mod export; +pub mod ui; diff --git a/src/main.rs b/src/main.rs index 6671681..dd55a28 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,12 +1,3 @@ -pub mod ast; -mod db; -mod editor; -mod explain; -mod export; -mod ui; - -use crate::db::ConnectionManager; -use crate::ui::App; use anyhow::Result; use clap::Parser; use crossterm::{ @@ -16,6 +7,8 @@ use crossterm::{ execute, terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, }; +use pgrsql::db::ConnectionManager; +use pgrsql::ui::App; use ratatui::{backend::CrosstermBackend, Terminal}; use std::io; @@ -108,7 +101,7 @@ async fn run_app( app: &mut App, ) -> Result<()> { loop { - terminal.draw(|f| ui::draw(f, app))?; + terminal.draw(|f| pgrsql::ui::draw(f, app))?; if event::poll(std::time::Duration::from_millis(100))? { if let Event::Key(key) = event::read()? { diff --git a/src/ui/app.rs b/src/ui/app.rs index cd3e4e5..17cf567 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -335,6 +335,12 @@ pub const SQL_FUNCTIONS: &[&str] = &[ "PG_RELATION_SIZE", ]; +impl Default for App { + fn default() -> Self { + Self::new() + } +} + impl App { pub fn new() -> Self { let query_history = QueryHistory::load().unwrap_or_default(); diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 2ccb97f..913b45c 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -2,6 +2,7 @@ mod app; mod components; mod theme; +#[allow(ambiguous_glob_reexports)] pub use app::*; pub use components::*; pub use theme::*;