Add DuckDB support including specific syntax parsing
This commit is contained in:
@@ -2,6 +2,7 @@
|
||||
|
||||
## unreleased
|
||||
- fix: `sqlpage.variables()` now does not return json objects with duplicate keys when post, get and set variables of the same name are present. The semantics of the returned values remains the same (precedence: set > post > get).
|
||||
- add support for some duckdb-specific syntax like `select {'a': 1, 'b': 2}` when connected to duckdb through odbc.
|
||||
|
||||
## 0.41.0 (2025-12-28)
|
||||
- **New Function**: `sqlpage.oidc_logout_url(redirect_uri)` - Generates a secure logout URL for OIDC-authenticated users with support for [RP-Initiated Logout](https://openid.net/specs/openid-connect-rpinitiated-1_0.html#RPLogout)
|
||||
|
||||
+2
-1
@@ -4,7 +4,8 @@
|
||||
"includes": [
|
||||
"**",
|
||||
"!examples/official-site/pgconf/**",
|
||||
"!tests/end-to-end/test-results/**"
|
||||
"!tests/end-to-end/test-results/**",
|
||||
"!.zed/*.json"
|
||||
],
|
||||
"ignoreUnknown": true
|
||||
},
|
||||
|
||||
@@ -59,7 +59,7 @@ impl Database {
|
||||
let dbms_name: String = pool.acquire().await?.dbms_name().await?;
|
||||
let database_type = SupportedDatabase::from_dbms_name(&dbms_name);
|
||||
|
||||
log::debug!("Initialized {dbms_name} database pool: {pool:#?}");
|
||||
log::debug!("Initialized {dbms_name:?} database pool: {pool:#?}");
|
||||
Ok(Database {
|
||||
connection: pool,
|
||||
info: DbInfo {
|
||||
|
||||
@@ -19,6 +19,7 @@ use sqlx::any::AnyKind;
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum SupportedDatabase {
|
||||
Sqlite,
|
||||
Duckdb,
|
||||
Postgres,
|
||||
MySql,
|
||||
Mssql,
|
||||
@@ -32,6 +33,7 @@ impl SupportedDatabase {
|
||||
pub fn from_dbms_name(dbms_name: &str) -> Self {
|
||||
match dbms_name.to_lowercase().as_str() {
|
||||
"sqlite" | "sqlite3" => Self::Sqlite,
|
||||
"duckdb" | "d\0\0\0\0\0" => Self::Duckdb, // ducksdb incorrectly truncates the db name: https://github.com/duckdb/duckdb-odbc/issues/350
|
||||
"postgres" | "postgresql" => Self::Postgres,
|
||||
"mysql" | "mariadb" => Self::MySql,
|
||||
"mssql" | "sql server" | "microsoft sql server" => Self::Mssql,
|
||||
@@ -45,6 +47,7 @@ impl SupportedDatabase {
|
||||
pub fn display_name(self) -> &'static str {
|
||||
match self {
|
||||
Self::Sqlite => "SQLite",
|
||||
Self::Duckdb => "DuckDB",
|
||||
Self::Postgres => "PostgreSQL",
|
||||
Self::MySql => "MySQL",
|
||||
Self::Mssql => "Microsoft SQL Server",
|
||||
|
||||
@@ -16,7 +16,8 @@ use sqlparser::ast::{
|
||||
VisitMut, Visitor, VisitorMut,
|
||||
};
|
||||
use sqlparser::dialect::{
|
||||
Dialect, MsSqlDialect, MySqlDialect, PostgreSqlDialect, SQLiteDialect, SnowflakeDialect,
|
||||
Dialect, DuckDbDialect, GenericDialect, MsSqlDialect, MySqlDialect, PostgreSqlDialect,
|
||||
SQLiteDialect, SnowflakeDialect,
|
||||
};
|
||||
use sqlparser::parser::{Parser, ParserError};
|
||||
use sqlparser::tokenizer::Token::{self, SemiColon, EOF};
|
||||
@@ -36,8 +37,12 @@ pub struct ParsedSqlFile {
|
||||
impl ParsedSqlFile {
|
||||
#[must_use]
|
||||
pub fn new(db: &Database, sql: &str, source_path: &Path) -> ParsedSqlFile {
|
||||
log::debug!("Parsing SQL file {}", source_path.display());
|
||||
let dialect = dialect_for_db(db.info.database_type);
|
||||
log::debug!(
|
||||
"Parsing SQL file {} using dialect {:?}",
|
||||
source_path.display(),
|
||||
dialect
|
||||
);
|
||||
let parsed_statements = match parse_sql(&db.info, dialect.as_ref(), sql) {
|
||||
Ok(parsed) => parsed,
|
||||
Err(err) => return Self::from_err(err, source_path),
|
||||
@@ -269,7 +274,9 @@ fn syntax_error(err: ParserError, parser: &Parser, sql: &str) -> ParsedStatement
|
||||
|
||||
fn dialect_for_db(dbms: SupportedDatabase) -> Box<dyn Dialect> {
|
||||
match dbms {
|
||||
SupportedDatabase::Postgres | SupportedDatabase::Generic => Box::new(PostgreSqlDialect {}), // Default to PostgreSQL dialect for Generic
|
||||
SupportedDatabase::Duckdb => Box::new(DuckDbDialect {}),
|
||||
SupportedDatabase::Postgres => Box::new(PostgreSqlDialect {}),
|
||||
SupportedDatabase::Generic => Box::new(GenericDialect {}),
|
||||
SupportedDatabase::Mssql => Box::new(MsSqlDialect {}),
|
||||
SupportedDatabase::MySql => Box::new(MySqlDialect {}),
|
||||
SupportedDatabase::Sqlite => Box::new(SQLiteDialect {}),
|
||||
@@ -1195,6 +1202,33 @@ mod test {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_duckdb_odbc_dialect_selection() {
|
||||
use std::any::Any;
|
||||
|
||||
let dbms = SupportedDatabase::from_dbms_name("DuckDB");
|
||||
assert_eq!(dbms, SupportedDatabase::Duckdb);
|
||||
let dialect = dialect_for_db(dbms);
|
||||
assert_eq!(dialect.as_ref().type_id(), (DuckDbDialect {}).type_id());
|
||||
|
||||
let sql = "select {'a': 1, 'b': 2} as payload";
|
||||
let db_info = create_test_db_info(dbms);
|
||||
let mut parsed = parse_sql(&db_info, dialect.as_ref(), sql).unwrap();
|
||||
let stmt = parsed.next().expect("expected one statement");
|
||||
assert!(
|
||||
!matches!(stmt, ParsedStatement::Error(_)),
|
||||
"duckdb dictionary literals should parse"
|
||||
);
|
||||
|
||||
let pg_info = create_test_db_info(SupportedDatabase::Postgres);
|
||||
let mut parsed = parse_sql(&pg_info, &PostgreSqlDialect {}, sql).unwrap();
|
||||
let stmt = parsed.next().expect("expected one statement");
|
||||
assert!(
|
||||
matches!(stmt, ParsedStatement::Error(_)),
|
||||
"postgres should reject duckdb dictionary literals"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_toplevel_delayed_functions() {
|
||||
let mut ast = parse_stmt(
|
||||
@@ -1533,13 +1567,13 @@ mod test {
|
||||
FROM generate_series(1, 3) x
|
||||
JOIN generate_series(4, 6) y ON true
|
||||
)
|
||||
SELECT
|
||||
SELECT
|
||||
json_object('key', 'value') AS json_col1,
|
||||
json_array(1, 2, 3) AS json_col2,
|
||||
(SELECT json_build_object('nested', subq.val)
|
||||
(SELECT json_build_object('nested', subq.val)
|
||||
FROM (SELECT AVG(x) AS val FROM generate_series(1, 5) x) subq
|
||||
) AS json_col3, -- not supported because of the subquery
|
||||
CASE
|
||||
CASE
|
||||
WHEN EXISTS (SELECT 1 FROM json_cte WHERE cte_json->>'a' = '2')
|
||||
THEN to_json(ARRAY(SELECT cte_json FROM json_cte))
|
||||
ELSE json_build_array()
|
||||
@@ -1607,7 +1641,7 @@ mod test {
|
||||
#[test]
|
||||
fn test_extract_json_columns_from_literal() {
|
||||
let sql = r#"
|
||||
SELECT
|
||||
SELECT
|
||||
'Pro Plan' as title,
|
||||
JSON('{"icon":"database","color":"blue","description":"1GB Database"}') as item,
|
||||
JSON('{"icon":"headset","color":"green","description":"Priority Support"}') as item
|
||||
|
||||
Reference in New Issue
Block a user