Repository navigation
Stronger dialect support + 14 new readers - #569
Conversation
…, sqlite, redshift
|
I've been hard at work setting up testing infrastructure for this so we can be somewhat sure that ggsql actually works with the stated backends. For non-cloud backends we have a live CI that runs a battery of queries against a db. For cloud based we have a nightly run backed by credentials in the repo secrets The tests currently miss:
All of this testing has payed off. So many portability issues have been uncovered and fixed at the detriment of the complexity of this PR. Some stat queries have been rewritten, the dialect system expanded, and quoting has become a part of the dialect |
- Error instead of panicking when a scheme without registry ADBC info (monetdb, drill) reaches AdbcReader::from_connection_string, e.g. via the GGSQL_<SCHEME>_ADBC_DRIVER env override. - reader=native no longer falls through to ODBC when no native reader exists or is compiled in; it errors explicitly like reader=adbc. - odbc:// URIs are handed to the driver with ggsql-owned params stripped, so ?cache=off and friends no longer leak into the connection string.
CachingReader::materialize_table executes the body against the primary but quoted aliases with the cache dialect, sending double-quoted identifiers to backtick dialects like MySQL. Regression test included.
- case_greatest/case_least no longer index-panic on empty input. - Date/datetime/time literal defaults clamp out-of-range input instead of panicking (and no longer wrap negative time-of-day via as u32). - drop_stmt_handle skips its debug_assert while unwinding, avoiding an abort on a failed free during panic. - ODBC diagnostic buffer growth is clamped to SqlSmallInt::MAX so the length argument cannot wrap negative.
- Dialect module docs for redshift, exasol, monetdb and bigquery claimed spatial support that supports_spatial() (false by default) blocks; the docs now match the code. - Battery live_skip for the boxplot global-source case extended to mariadb, which shares MySQL's temp-table reopen limitation. - Rename leftovers (sql_percentile -> sql_quantile) and stale doc references cleaned up. - CHANGELOG entries for the API breaks and behavior changes riding along in this PR.
teunbrand
left a comment
There was a problem hiding this comment.
Calling it a day, I'll continue reviewing tomorrow
| /// SQL type name for numeric columns (e.g., "DOUBLE PRECISION"). | ||
| /// Derived from [`type_names`]; override that, not this. | ||
| /// | ||
| /// [`type_names`]: SqlDialect::type_names | ||
| fn number_type_name(&self) -> Option<&str> { |
There was a problem hiding this comment.
Bit frivolous comment, but I get that these accessors were valuable before your PR, but can now mostly be replaced by self.type_names().{number, date, integer, ...} at call sites. Doesn't do any harm to keep them, but it'd clean up the SqlDialect trait a bit.
If we want to keep them, it might make sense to double-down on their accessor role by using a get_-prefix.
| /// `CREATE TABLE AS`). | ||
| SelectInto, | ||
| } | ||
|
|
There was a problem hiding this comment.
From comment it is not clear what role this function serves
| /// Emulate `GENERATE_SERIES(0, n - 1)` for DBs that lack it |
| @@ -46,39 +46,55 @@ use crate::{naming, DataFrame, GgsqlError, Result}; | |||
| /// | |||
| /// Default implementations produce portable ANSI SQL. | |||
| pub trait SqlDialect { | |||
There was a problem hiding this comment.
For our own sense-making efforts, I'd recommend to group the methods into categories. Some of these are pure SQL translations, others are SQL pattern generators, some are self-knowledge (i.e. does this dialect support spatial stuff), there are some spatial specific patterns/translations. The methods are currently somewhat mixed and reordering them gives a semblance of structure.
| select_parts.join(", ") | ||
| ); | ||
| let sql = crate::sql::Select::new(dialect) | ||
| .select(select_parts.join(", ")) |
There was a problem hiding this comment.
| .select(select_parts.join(", ")) | |
| .select_items(select_parts) |
| ); | ||
| let __ggsql_vertices__ = dialect.quote_ident("__ggsql_vertices__"); | ||
| let sql = crate::sql::Select::new(dialect) | ||
| .select(select_parts.join(", ")) |
There was a problem hiding this comment.
| .select(select_parts.join(", ")) | |
| .select_items(select_parts) |
| /// These are consumed during dispatch and must never reach a driver's own | ||
| /// URI parsing or option map — drivers reject unknown keys. | ||
| #[derive(Debug, Default, Clone, PartialEq, Eq)] | ||
| pub struct GgsqlParams { |
There was a problem hiding this comment.
I think the name of this structure is too general, mostly because it implies this'd be a parameter structure we use throughout (like GgsqlError for example). I'd be happy with something like ConnectParams or Own(ed)Params or something.
| /// `cache_ttl=<secs>`: raw value; parsed by | ||
| /// [`ConnUri::cache_config_override`]. | ||
| pub cache_ttl: Option<String>, | ||
| /// `cache_max_bytes=<n|1MB|…>`: raw value. | ||
| pub cache_max_bytes: Option<String>, | ||
| /// `cache_disabled=1|true|yes`. | ||
| pub cache_disabled: Option<bool>, | ||
| /// `stmt.<key>=<value>` params (prefix stripped): driver *statement* | ||
| /// options, applied to every statement the reader creates. |
There was a problem hiding this comment.
Comments here don't immediately make sense to me, so maybe add 1 line of explanatory prose per field (e.g. 'Maximum bytes the cache is allowed to have', or whatever it actually means)
| continue; | ||
| } | ||
| let (key, value) = match segment.split_once('=') { | ||
| Some((k, v)) => (k, Some(v)), |
There was a problem hiding this comment.
This is a nitpick with 0 priority.
We could do this
| Some((k, v)) => (k, Some(v)), | |
| Some((k, v)) => (k, Some(v.to_string())), |
And then the match arms could simplify to just use value instead of value.map(|v| v.to_string()).
There was a problem hiding this comment.
Does this mean that we can only use the datafusion cache when duckdb/sqlite are compiled in?
| @@ -78,48 +191,37 @@ fn cache_uri(scheme: &str) -> Result<&'static str> { | |||
| match scheme { | |||
| "duckdb" => Ok("duckdb://memory"), | |||
| "sqlite" => Ok("sqlite://memory"), | |||
There was a problem hiding this comment.
This differs from L549 (sqlite://:memory:). Should this be unified?
There was a problem hiding this comment.
Mostly LGTM, few comments that deserve a glance but nothing that should stop a merge.
Caveat to my review is that I don't really know the DB connection stuff very well, so I'm mostly trusting you on this.
Few things that didn't map neatly onto changes:
-
Can you update any relevant CLAUDE.mds? I found the testing architecture with e.g. golden.rs living in the execute folder hard to follow, so having this written down somewhere would help me as well as future agents.
-
rewrite_namespaced_sqlin src/parser/sql.rs should probably also use the dialect quoting instead of the naming module. It is now only called in DuckDB/SQLite so not immediately relevant. -
Could have the assistant give another look at comments. I typically ask it to review the comments it made and pay attention to (1) generally terse style (2) delete commentary about decisions (3) delete commentary about history and (4) move implementation details to relevant lines of code. (2) and (3) are mostly because assistants are bad at following CLAUDE.md:121 (Comments describe the current state of the code) directive.
| "AdbcReader::register: empty DataFrame not supported".into(), | ||
| )); | ||
| } | ||
| // Zero-row frames are fine: the CREATE below still runs, leaving an |
There was a problem hiding this comment.
I think the comments here can generally be improved. Many are a full paragraph, contain details that are already abstracted and I've not found them helpful in grokking what the code is doing. This specific one here seems to be misplaced, as it is about logic that is at L625: ~40 lines down the road.
| ))); | ||
| self.registered_tables.note_registered(name); | ||
|
|
||
| if batch.num_rows() > 0 { |
There was a problem hiding this comment.
This is just a personal bias against indentation depth, but if you early exit here if you have less than 1 row, you save 1 level.
| let grp_cols = group_by | ||
| .iter() | ||
| .map(|c| dialect.quote_ident(c)) | ||
| .collect::<Vec<_>>() | ||
| .join(", "); |
There was a problem hiding this comment.
This (quoting vector followed by joining) is a recurring pattern that might be worth a little helper, maybe on the dialect itself?
There are also some variants of this that follow the same pattern (like quoted_groups in boxplot.rs).
| {__bin_src__} AS (SELECT *, {bin_expr} AS {bin_key} FROM {__stat_src__}), \ | ||
| {__binned__} AS (SELECT {binned} FROM {__bin_src__} GROUP BY {group}) \ | ||
| SELECT *, {bin} + {width} AS {bin_end}, \ |
There was a problem hiding this comment.
Oughtn't this be Select-constructed to deal with the wildcard?
| /// registered tables by name; anything unrecognized defaults to | ||
| /// `Float64`, which is the right shape for the derived columns | ||
| /// (bins, densities, quantiles) the pipeline produces. | ||
| pub(crate) struct StubReader { |
There was a problem hiding this comment.
Easily will get confused with stubs in TABULATE (read: I'd like Ctrl/Cmd+F global search on 'stub' to only give tabulate stuff 😁 ), so I'd argue for some different name, e.g. TestReader, DummyReader or SterileReader or something you see fit.
| matrix: | ||
| include: | ||
| - { backend: snowflake, driver: snowflake } | ||
| - { backend: bigquery, driver: bigquery } | ||
| - { backend: databricks, driver: databricks } | ||
|
|
||
| env: | ||
| GGSQL_TEST_URI_SNOWFLAKE: ${{ secrets.GGSQL_TEST_URI_SNOWFLAKE }} | ||
| GGSQL_TEST_URI_BIGQUERY: ${{ secrets.GGSQL_TEST_URI_BIGQUERY }} | ||
| GGSQL_TEST_URI_DATABRICKS: ${{ secrets.GGSQL_TEST_URI_DATABRICKS }} | ||
|
|
There was a problem hiding this comment.
Flagged by assistant: all 3 backends have access to all 3 secrets, whereas snowflake db doesn't need to know about bigquery secret. Probably not too severe, but worth mentioning.
| @@ -0,0 +1,154 @@ | |||
| name: Nightly Cloud Dialect Tests | |||
There was a problem hiding this comment.
Flagged by assistant: workflow has default permissions, whereas it needs only read permissions. Probably not severe, but worth mentioning.
| @@ -718,6 +758,32 @@ fn build_bin_condition( | |||
| oob_squish: bool, | |||
| is_first: bool, | |||
| is_last: bool, | |||
| dialect: &dyn super::SqlDialect, | |||
| ) -> String { | |||
| build_bin_condition_expr( | |||
| &dialect.quote_ident(column_name), | |||
| lower_expr, | |||
| upper_expr, | |||
| closed_left, | |||
| oob_squish, | |||
| is_first, | |||
| is_last, | |||
| dialect, | |||
| ) | |||
| } | |||
There was a problem hiding this comment.
This wrapper seems frivolous (i.e. 20 lines of wrapper + comments to save 1 inline dialect.quote_ident(). Do we really need it?
This PR provides a large swath of readers build on top of the existing ADBC/ODBC support we have. It refactors the dialects into a specific module with dialect detection, expands the dialect to accomodate new wrinkles discovered by the new readers, provides a multitiered selectioon mechanism where ADBC is preferred, but ODBC is tried if no suitable ADBC driver exist, and lastly adds an autowrapping of a cache reader when needed. The latter is supported through the dialect system as some dbs forcefully requires a cache to work. For the others we try a temp table creation and if it fails we use a cache.
The dialects added here are
Fix #341, fix #509, closes #386, closes #458
Changes with runtime-semantics impact riding along in this PR
These commits change behavior rather than just how SQL text is built. They are
called out here so they can be reviewed individually; see also the CHANGELOG
entries.
a687c9c4— Vega-Lite timestamp double-rescaling fix(
src/writer/vegalite/data.rs): unrelated correctness fix for microsecondtimestamps being rescaled twice.
resolution (
src/plot/scale/scale_type/mod.rs,temporal_unit_micros,convert_range_to_transform_unit,check_temporal_domain): new error path,surfaced by the broader multi-backend test matrix.
src/execute/schema.rs,merge_extent_rows):portability fix for MySQL "can't reopen table" / ClickHouse alias reuse;
changes how min/max extents are derived.
CacheBackendmoved to test-support,CachingReader::newis now
#[cfg(test)],Spec::layer_sql/Spec::stat_sqlremoved. Recorded inCHANGELOG under [Unreleased].