diff --git a/Cargo.lock b/Cargo.lock index 734fa61..7e5cb38 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -945,6 +945,15 @@ version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -1242,6 +1251,7 @@ dependencies = [ "dirs", "native-tls", "postgres-native-tls", + "pyo3", "ratatui", "rpassword", "serde", @@ -1314,6 +1324,12 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + [[package]] name = "postgres-native-tls" version = "0.5.2" @@ -1401,6 +1417,69 @@ dependencies = [ "num-traits", ] +[[package]] +name = "pyo3" +version = "0.22.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f402062616ab18202ae8319da13fa4279883a2b8a9d9f83f20dbade813ce1884" +dependencies = [ + "cfg-if", + "indoc", + "libc", + "memoffset", + "once_cell", + "portable-atomic", + "pyo3-build-config", + "pyo3-ffi", + "pyo3-macros", + "unindent", +] + +[[package]] +name = "pyo3-build-config" +version = "0.22.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b14b5775b5ff446dd1056212d778012cbe8a0fbffd368029fd9e25b514479c38" +dependencies = [ + "once_cell", + "target-lexicon", +] + +[[package]] +name = "pyo3-ffi" +version = "0.22.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ab5bcf04a2cdcbb50c7d6105de943f543f9ed92af55818fd17b660390fc8636" +dependencies = [ + "libc", + "pyo3-build-config", +] + +[[package]] +name = "pyo3-macros" +version = "0.22.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fd24d897903a9e6d80b968368a34e1525aeb719d568dba8b3d4bfa5dc67d453" +dependencies = [ + "proc-macro2", + "pyo3-macros-backend", + "quote", + "syn", +] + +[[package]] +name = "pyo3-macros-backend" +version = "0.22.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36c011a03ba1e50152b4b394b479826cad97e7a21eb52df179cd91ac411cbfbe" +dependencies = [ + "heck", + "proc-macro2", + "pyo3-build-config", + "quote", + "syn", +] + [[package]] name = "quick-error" version = "2.0.1" @@ -1837,6 +1916,12 @@ dependencies = [ "yaml-rust", ] +[[package]] +name = "target-lexicon" +version = "0.12.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" + [[package]] name = "tempfile" version = "3.25.0" @@ -2196,6 +2281,12 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "unindent" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7264e107f553ccae879d21fbea1d6724ac785e8c3bfc762137959b5802826ef3" + [[package]] name = "utf8parse" version = "0.2.2" diff --git a/Cargo.toml b/Cargo.toml index 6196121..9265a54 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -62,6 +62,18 @@ sqlparser = "0.53" tracing = "0.1" tracing-subscriber = "0.3" +# Python bindings (optional) +pyo3 = { version = "0.22", features = ["extension-module"], optional = true } + +[features] +default = [] +python = ["pyo3"] + +[lib] +name = "pgrsql" +path = "src/lib.rs" +crate-type = ["lib"] + [[bin]] name = "pgrsql" path = "src/main.rs" diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..a32b807 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,30 @@ +[build-system] +requires = ["maturin>=1.0,<2.0"] +build-backend = "maturin" + +[project] +name = "pgrsql" +version = "0.1.3" +description = "High-performance SQL parsing, optimization, and analysis engine powered by Rust" +requires-python = ">=3.9" +license = { text = "MIT" } +keywords = ["sql", "postgresql", "parser", "optimizer", "database"] +classifiers = [ + "Development Status :: 3 - Alpha", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Rust", + "Topic :: Database", + "Topic :: Software Development :: Libraries", +] + +[tool.maturin] +features = ["python"] +module-name = "pgrsql._pgrsql" +python-source = "python" diff --git a/python/pgrsql/__init__.py b/python/pgrsql/__init__.py new file mode 100644 index 0000000..44624a2 --- /dev/null +++ b/python/pgrsql/__init__.py @@ -0,0 +1,41 @@ +"""pgrsql - High-performance SQL parsing, optimization, and analysis engine. + +Built on a Rust core with PyO3 bindings for maximum performance. + +Usage: + import pgrsql + + # Parse SQL to normalized form + statements = pgrsql.parse_sql("SELECT * FROM users WHERE age > 18") + + # Format and optimize SQL + formatted = pgrsql.format_sql("select id,name from users where age>18") + + # Analyze query structure + analysis = pgrsql.analyze_query("SELECT * FROM a JOIN b ON a.id = b.id") + # {'has_select': True, 'has_joins': True, 'join_count': 1, ...} + + # Parse EXPLAIN output + plan = pgrsql.parse_explain("Seq Scan on users (cost=0.00..35.50 rows=100 width=36)") + + # Check if a query is an EXPLAIN query + pgrsql.is_explain_query("EXPLAIN SELECT 1") # True +""" + +from pgrsql._pgrsql import ( + __version__, + analyze_query, + format_sql, + is_explain_query, + parse_explain, + parse_sql, +) + +__all__ = [ + "__version__", + "parse_sql", + "format_sql", + "analyze_query", + "parse_explain", + "is_explain_query", +] 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..fd96e63 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,9 @@ +pub mod ast; +pub mod db; +pub mod editor; +pub mod explain; +pub mod export; +pub mod ui; + +#[cfg(feature = "python")] +pub mod python; 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/python.rs b/src/python.rs new file mode 100644 index 0000000..ef29b2c --- /dev/null +++ b/src/python.rs @@ -0,0 +1,224 @@ +//! Optional Python bindings for pgrsql via PyO3. +//! +//! Provides a Python API for SQL parsing, AST analysis, query compilation, +//! and EXPLAIN plan parsing. Enabled with the `python` feature flag. +//! +//! ## Usage from Python +//! +//! ```python +//! import pgrsql +//! +//! # Parse SQL to AST and compile back +//! result = pgrsql.parse_sql("SELECT * FROM users WHERE age > 18") +//! print(result) # Compiled SQL string +//! +//! # Format SQL +//! formatted = pgrsql.format_sql("select id,name from users where age>18") +//! print(formatted) +//! +//! # Analyze query structure +//! analysis = pgrsql.analyze_query("SELECT * FROM a JOIN b ON a.id = b.id") +//! print(analysis) # {'has_select': True, 'has_joins': True, ...} +//! +//! # Parse EXPLAIN output +//! plan = pgrsql.parse_explain("Seq Scan on users (cost=0.00..35.50 rows=100 width=36)") +//! print(plan) +//! ``` + +use pyo3::prelude::*; +use pyo3::types::PyDict; + +use crate::ast::{compile, parse_sql as ast_parse_sql, Optimizer}; +use crate::explain; + +/// Parse one or more SQL statements and compile them back to normalized SQL. +/// +/// Args: +/// sql: SQL string containing one or more statements. +/// +/// Returns: +/// A list of compiled SQL strings, one per statement. +/// +/// Raises: +/// ValueError: If the SQL cannot be parsed. +#[pyfunction] +fn parse_sql(sql: &str) -> PyResult> { + let queries = ast_parse_sql(sql) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(format!("SQL parse error: {}", e)))?; + Ok(queries.iter().map(compile).collect()) +} + +/// Parse a single SQL statement, optimize it, and compile back to SQL. +/// +/// Args: +/// sql: A single SQL statement. +/// +/// Returns: +/// The optimized, compiled SQL string. +/// +/// Raises: +/// ValueError: If the SQL cannot be parsed or contains multiple statements. +#[pyfunction] +fn format_sql(sql: &str) -> PyResult { + let query = crate::ast::parse_single(sql) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(format!("SQL parse error: {}", e)))?; + let optimizer = Optimizer::with_defaults(); + let optimized = optimizer.optimize(query).map_err(|e| { + pyo3::exceptions::PyValueError::new_err(format!("Optimization error: {}", e)) + })?; + Ok(compile(&optimized)) +} + +/// Analyze a SQL query and return structural metadata. +/// +/// Args: +/// sql: A single SQL statement to analyze. +/// +/// Returns: +/// A dictionary with boolean flags for detected features: +/// has_select, has_joins, has_aggregation, has_window_functions, +/// has_subqueries, has_cte, has_recursive_cte, has_set_operations, +/// has_json_operations, join_count, etc. +/// +/// Raises: +/// ValueError: If the SQL cannot be parsed. +#[pyfunction] +fn analyze_query(py: Python<'_>, sql: &str) -> PyResult> { + let query = crate::ast::parse_single(sql) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(format!("SQL parse error: {}", e)))?; + let analysis = crate::ast::analyze_query(&query); + + let dict = PyDict::new(py); + dict.set_item("has_select", analysis.has_select)?; + dict.set_item("has_insert", analysis.has_insert)?; + dict.set_item("has_update", analysis.has_update)?; + dict.set_item("has_delete", analysis.has_delete)?; + dict.set_item("has_distinct", analysis.has_distinct)?; + dict.set_item("has_joins", analysis.has_joins)?; + dict.set_item("join_count", analysis.join_count)?; + dict.set_item("has_aggregation", analysis.has_aggregation)?; + dict.set_item("has_window_functions", analysis.has_window_functions)?; + dict.set_item("has_subqueries", analysis.has_subqueries)?; + dict.set_item("has_cte", analysis.has_cte)?; + dict.set_item("has_recursive_cte", analysis.has_recursive_cte)?; + dict.set_item("has_set_operations", analysis.has_set_operations)?; + dict.set_item("has_json_operations", analysis.has_json_operations)?; + Ok(dict.into()) +} + +/// Parse PostgreSQL EXPLAIN output into a structured representation. +/// +/// Args: +/// text: The text output from EXPLAIN or EXPLAIN ANALYZE. +/// +/// Returns: +/// A dictionary with plan details, or None if parsing fails. +/// Contains: node_type, estimated_cost, actual_time, planning_time, +/// execution_time, total_time, and children (recursive). +#[pyfunction] +fn parse_explain(py: Python<'_>, text: &str) -> PyResult>> { + match explain::parse_explain_output(text) { + Some(plan) => { + let dict = PyDict::new(py); + dict.set_item("node_type", &plan.root.node_type)?; + if let Some((start, end)) = plan.root.estimated_cost { + dict.set_item("estimated_cost_start", start)?; + dict.set_item("estimated_cost_end", end)?; + } + if let Some((start, end)) = plan.root.actual_time { + dict.set_item("actual_time_start", start)?; + dict.set_item("actual_time_end", end)?; + } + if let Some(rows) = plan.root.estimated_rows { + dict.set_item("estimated_rows", rows)?; + } + if let Some(rows) = plan.root.actual_rows { + dict.set_item("actual_rows", rows)?; + } + if let Some(t) = plan.total_time { + dict.set_item("total_time", t)?; + } + if let Some(t) = plan.planning_time { + dict.set_item("planning_time", t)?; + } + if let Some(t) = plan.execution_time { + dict.set_item("execution_time", t)?; + } + dict.set_item("children_count", plan.root.children.len())?; + Ok(Some(dict.into())) + } + None => Ok(None), + } +} + +/// Check if a SQL string is an EXPLAIN query. +/// +/// Args: +/// sql: The SQL string to check. +/// +/// Returns: +/// True if the query starts with EXPLAIN. +#[pyfunction] +fn is_explain_query(sql: &str) -> bool { + explain::is_explain_query(sql) +} + +/// pgrsql Python module. +/// +/// Provides SQL parsing, analysis, formatting, and EXPLAIN plan parsing +/// powered by pgrsql's Rust engine. +#[pymodule] +fn _pgrsql(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_function(wrap_pyfunction!(parse_sql, m)?)?; + m.add_function(wrap_pyfunction!(format_sql, m)?)?; + m.add_function(wrap_pyfunction!(analyze_query, m)?)?; + m.add_function(wrap_pyfunction!(parse_explain, m)?)?; + m.add_function(wrap_pyfunction!(is_explain_query, m)?)?; + m.add("__version__", env!("CARGO_PKG_VERSION"))?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_sql_simple() { + let result = parse_sql("SELECT * FROM users").unwrap(); + assert_eq!(result.len(), 1); + assert!(result[0].contains("SELECT")); + assert!(result[0].contains("users")); + } + + #[test] + fn test_parse_sql_multiple() { + let result = parse_sql("SELECT 1; SELECT 2").unwrap(); + assert_eq!(result.len(), 2); + } + + #[test] + fn test_parse_sql_error() { + let result = parse_sql("SELCT * FORM users"); + assert!(result.is_err()); + } + + #[test] + fn test_format_sql() { + let result = format_sql("SELECT id,name FROM users WHERE age>18").unwrap(); + assert!(result.contains("SELECT")); + assert!(result.contains("WHERE")); + } + + #[test] + fn test_format_sql_error() { + let result = format_sql("NOT VALID SQL AT ALL %%"); + assert!(result.is_err()); + } + + #[test] + fn test_is_explain() { + assert!(is_explain_query("EXPLAIN SELECT 1")); + assert!(is_explain_query("explain analyze select * from t")); + assert!(!is_explain_query("SELECT 1")); + } +} 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::*;