Skip to content

feat(db): add Apache Druid provider (Druid SQL over HTTP, no native dependency) - #271

Merged
cevheri merged 4 commits into
mainfrom
feat/druid-provider
Aug 4, 2026
Merged

feat(db): add Apache Druid provider (Druid SQL over HTTP, no native dependency)#271
cevheri merged 4 commits into
mainfrom
feat/druid-provider

Conversation

@cevheri

@cevheri cevheri commented Aug 3, 2026

Copy link
Copy Markdown
Member

Closes #265.

Apache Druid over its SQL HTTP API, extending SQLBaseProvider — the ClickHouse (#264) shape, not the
Couchbase directory. package.json is untouched: the provider speaks POST /druid/v2/sql with
fetch and nothing else.

Everything below was verified against a live Apache Druid 37.0.0 cluster, and the live pass
corrected three of the issue's own assumptions.

Where the issue was wrong, and what the cluster said instead

# The issue assumed Live reality
1 EXPLAIN "does not fit the existing tree render model", so start with supportsExplain: false The native-query plan is a nested tree — dataSource recurses through join / query / union. It renders as { kind: "tree" } honestly, so supportsExplain: true
2 Errors carry error, errorMessage, errorClass, host Two envelopes coexist, and error is a discriminator whose value is the literal string "druidException"
3 resultFormat: "object" plus the header flags is enough object silently drops duplicate columnsSELECT 1 AS c, 2 AS c returns only {c: 2}

1. EXPLAIN renders as a tree, and it is honest

The recursion through dataSource is the operator tree. What keeps it honest is what is left out:
no metrics on any node, because Druid's planner emits no cost and no row estimates.
ExplainTreeNode.metrics is optional, so the tree carries structure and nothing it cannot support.

useNativeQueryExplain: false also works and returns an indented Calcite RelNode plan. Rejected:
it depends on a non-default context flag, and it is indentation-parsed text where the default is
structured JSON.

buildSql returns EXPLAIN PLAN FOR … for both modes, so the direct Explain button is live and
not just the background pre-warm — the defect #265 explicitly warned about.

2. Errors: classify on category, never on the status code

error is a discriminator, so showing it would print "druidException" to whoever mistyped a
datasource name. The message always comes from errorMessage. And the status alone misclassifies:

SELECT 1/0  ->  HTTP 500, persona ADMIN, category UNCATEGORIZED, "/ by zero"

A plain user typo, reported as an admin-facing 500.

3. Wire format is array, for correctness

Duplicate output names are legal SQL and arrive from real joins, so the transport asks for array
with three header rows and rebuilds the row objects itself.

The 64-bit integer bug

A BIGINT holding 9007199254740993 arrives as an unquoted JSON number, and Druid has no
server-side quoting setting (unlike ClickHouse's output_format_json_quote_64bit_integers), so
JSON.parse rounds it to …992 with no error:

raw body from Druid:       [["snowflake_id"],["LONG"],["BIGINT"],[9007199254740993]]
naive JSON.parse gives:    9007199254740992      <- silently wrong
after quoteUnsafeIntegers: "9007199254740993"    <- exact

A string-aware scanner (db/utils/json-integers.ts) quotes unsafe literals before parsing, as the
pg driver already does for int8. It is shared with the explain strategy, whose second parse of
the PLAN column is an independent chance to round the same value — that was a real bug found in
review and fixed here.

Three defects in shared code that this provider exposed

These are not Druid-specific and are worth reviewing on their own merits.

  1. query-generators.ts quoted no Druid identifier. Calcite reserves many plain lowercase words,
    so "Generate SELECT" and the auto-executing datasource click emitted hard syntax errors for a
    column named count, value, start, end, date, time, year or rows. count matters
    most: it is Druid's own conventional rollup metric name, so the standard rollup ingestion creates
    one. Druid now quotes unconditionally, exactly as the Couchbase branch above it already does.
    SELECT count FROM libredb_demo    -> 400 "Received an unexpected token [count FROM]"
    SELECT "count" FROM libredb_demo  -> 400 "Column 'count' not found"   (syntax now fine)
    
  2. PerformanceMetrics.cacheHitRatio is now optional. A provider that cannot measure one had to
    report 0, which DEFAULT_THRESHOLDS scores as critical (direction: "below", critical: 80)
    — so every healthy Druid cluster showed a red cache fault. The monitoring tabs already defaulted
    the threshold to a healthy 100 when absent; they now also withhold the percentage and the
    Excellent/Good/Poor rating instead of printing a fabricated 0.0% / Poor. Ripple was two call
    sites, both routed through one shared formatter so no unreachable branch was added.
  3. The Connections card divided by maxConnections without guarding zero, which means "no limit
    published" for Druid and for SQL Server (whose own comment says so), and rendered the literal
    "NaN% used".

Capabilities, and why each false is real

queryLanguage: "sql", supportsExplain: true, explainFormat: "druid-native",
supportsExternalQueryLimiting: true, supportsCreateTable: false,
supportsMaintenance: false, maintenanceOperations: [],
supportsConnectionString: false, defaultPort: 8888,
  • supportsCreateTable: falseCREATE TABLE is not merely unimplemented, it is not in the
    grammar
    : 400 "Incorrect syntax near the keyword 'CREATE'". Datasources come from ingestion.
  • supportsMaintenance: false — nothing in MaintenanceType has a SQL-reachable analogue, and there
    is no sys.queries catalog to read a cancellable query id from.
  • supportsConnectionString: false — Druid has no URI convention for its HTTP SQL API (its JDBC
    driver uses jdbc:avatica:remote:url=…), and http(s):// is already claimed by ClickHouse.
    connection-string-parser.ts is therefore untouched, with negative tests pinning the absence.
  • No database field in the connection form: INFORMATION_SCHEMA.SCHEMATA reports exactly one
    catalog, always druid, so the control would have no effect.

prepareQuery has exactly one override: a statement ending in OFFSET with no LIMIT, because
Druid rejects OFFSET start LIMIT count outright. Such a statement is returned untouched — rewriting
wrongly fails the query, leaving it alone only returns more rows.

Druid SQL cannot write, and the app says so clearly

Verified through the app's own query route. None of these returns "0 rows"; each returns Druid's own
message, which names the reason and the alternative better than anything we would substitute:

statement response
INSERT INTO … INSERT operations are not supported by requested SQL engine [native], consider using MSQ.
UPDATE … SET … Unsupported SQL statement [UPDATE]
DELETE FROM … Unsupported SQL statement [DELETE]
REPLACE INTO … REPLACE operations are not supported by the requested SQL engine [native]. Consider using MSQ.
CREATE TABLE … syntax error — not in the grammar

Druid removes data by marking segments unused and running a kill task, through the Coordinator. MSQ
ingestion on /druid/v2/sql/task and the async /statements endpoint are out of scope per the issue,
and recorded as follow-ups.

The catalog is a view of what is servable, not of what exists

The most surprising thing about Druid introspection, and the one most likely to be mistaken for a bug
in the editor. Two independent demonstrations:

  • Marking every segment unused removes the datasource from INFORMATION_SCHEMA.TABLES entirely. So
    there is no empty-datasource case to render — the opposite of Couchbase's empty collection.
  • Stopping the Historical makes an existing datasource report as a typo:
    HTTP 400 { "category": "INVALID_INPUT", "persona": "USER",
               "errorMessage": "Object 'libredb_demo' not found (line [1], column [27])" }
    
    The datasource still exists in the metadata store. The failure is classified INVALID_INPUT,
    blaming the statement, and is indistinguishable from genuinely mistyping the name. Nothing in
    this provider can improve that message, so docs/providers/druid.md is the mitigation.

Live end-to-end pass

Ran the built app against the real cluster and drove every capability the UI offers, through both the
Router (8888) and the Broker (8082) — the issue asked for both to be confirmed:

  • Connect, Online / healthy; schema tree lists 2 datasources with 8 columns at their SQL types
  • Clicking the datasource generated SELECT * FROM "libredb_demo" LIMIT 50;quoted — returning
    50 rows in 14 ms, with snowflake_id rendered as the exact "9007199254740993"
  • SELECT: count, aggregate + GROUP BY + ORDER BY, filter, join across two datasources, LIMIT/OFFSET,
    unbounded (the route injects a LIMIT), and the OFFSET-only override left untouched at 48 rows
  • All five write statements and five error paths surfaced as real errors, never as zero rows
  • Explain on a statement that had never been run: a 12-node tree, no fabricated metrics
  • Monitoring: version 37.0.0 from sys.servers, uptime, DB size, per-datasource table stats, and
    both new honest empties rendering green rather than red — Cache Hit: N/A · Not measured and
    Connections: 0 · no limit published

One pre-existing gap the live pass found

The monitoring Tables tab renders Analyze / Vacuum / Reindex per row unconditionally —
TablesTab.tsx never reads getCapabilities() — so for Druid every click answers
400 {"error":"Maintenance operations not supported for this database"}. Not introduced here:
libredb.ts also sets supportsMaintenance: false and has the same dead buttons today, so gating
that tab is a change for every provider at once. Documented in docs/providers/druid.md section 8
rather than left as a doc that describes the intent instead of the software; follow-up to file.

Verification

All six local gates green in a clean worktree, plus the coverage gate:

format · lint (0 errors) · typecheck · knip · test (~5,185 tests, 0 failures) · build
check-coverage: OK — 28515/28515 lines (100.00%)

Every file this PR touches is at 100% individually, verified per-file in the merged lcov.

Reproducing the live pass

database-compose.yml gains seven profile-gated services. Druid is a distributed system with no
single-container mode, so gating keeps the default up -d from growing from 8 containers to 15:

docker compose -f database-compose.yml --profile druid up -d

Only 8888 (Router) and 8082 (Broker) are published; 8091 would have collided with Couchbase. A
datasource can only be created by ingestion, and docs/providers/druid.md shows the native batch task
with an inline input source so the next person can load data.

Review notes

  • Tri-sync holds: code, docs/providers/druid.md (1483 lines) and
    tests/integration/db/druid-provider.test.ts all land together.
  • docs/ADDING_A_PROVIDER.md had listed Druid as a candidate whose EXPLAIN "does not fit the tree
    render model". That verdict is now corrected, and the HTTP-traps section gained the generalisable
    lesson about unquoted 64-bit integers.
  • No === "druid" anywhere outside the provider directory and db-ui-config.ts.

…ependency)

Closes #265. Extends SQLBaseProvider over the SQL HTTP API, following the
ClickHouse (#264) shape rather than the Couchbase directory. package.json is
untouched: the provider speaks POST /druid/v2/sql with fetch and nothing else.

Verified against a live Apache Druid 37.0.0 cluster throughout, and the live pass
corrected three of the issue's own assumptions.

EXPLAIN is supported, contrary to the issue's draft. The native-query plan is a
genuine nested tree - dataSource recurses through join/query/union - so it renders
as { kind: "tree" } honestly, with no metrics at all because Druid's planner emits
no cost or row estimates. useNativeQueryExplain=false was rejected: it needs a
non-default context flag and is indentation-parsed text where the default is JSON.

resultFormat is "array", not "object": the object form silently drops every
duplicate output column but the last (SELECT 1 AS c, 2 AS c returns only {c:2}).

64-bit integers arrive as unquoted JSON numbers and Druid has no server-side
quoting setting, so JSON.parse rounds 9007199254740993 to ...992 with no error.
A string-aware scanner (db/utils/json-integers.ts) quotes unsafe literals before
parsing, as the pg driver does for int8. It is shared with the explain strategy,
whose second parse of the PLAN column is an independent chance to round the same
value.

Errors: two envelopes coexist, and `error` is a discriminator whose value is the
literal "druidException", so the message always comes from `errorMessage`.
Classification is on `category`, never on the HTTP status - SELECT 1/0 answers 500
with persona ADMIN for what is a plain user mistake.

Druid SQL cannot write: no UPDATE, no DELETE, no CREATE TABLE, and INSERT/REPLACE
need the MSQ task engine. Those are surfaced with Druid's own messages rather than
special-cased, and supportsCreateTable / supportsMaintenance are false for reasons
the doc records.

Also fixes three defects this provider exposed in shared code:

- query-generators.ts quoted no Druid identifier, and Calcite reserves many plain
  lowercase words, so Generate SELECT emitted hard syntax errors for a column
  named count (Druid's own conventional rollup metric name), value, start, end,
  date, time, year or rows. Druid now quotes unconditionally, as Couchbase does.
- PerformanceMetrics.cacheHitRatio is now optional. A provider that cannot measure
  one had to report 0, which DEFAULT_THRESHOLDS scores as CRITICAL, so every
  healthy Druid cluster showed a red cache fault. The monitoring tabs already
  defaulted the threshold to a healthy 100 when absent; they now also withhold the
  percentage and the rating instead of printing a fabricated 0.0% / Poor.
- The Connections card divided by maxConnections without guarding zero, which
  means "no limit published" for Druid and for SQL Server, and rendered the
  literal "NaN% used".

database-compose.yml gains seven profile-gated services: Druid is a distributed
system with no single-container mode, so `--profile druid` keeps the default
`up -d` from growing to 15 containers. Only 8888 and 8082 are published.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds an Apache Druid provider using its SQL HTTP API, integrating querying, schema discovery, monitoring, EXPLAIN, UI configuration, and documentation without a native dependency.

Changes:

  • Adds the Druid transport, provider, introspection, EXPLAIN strategy, and exact 64-bit JSON handling.
  • Registers Druid across connection, seed, factory, UI, and query-generation surfaces.
  • Updates monitoring behavior, development infrastructure, documentation, and comprehensive tests.

Reviewed changes

Copilot reviewed 47 out of 47 changed files in this pull request and generated 6 comments.

Show a summary per file
File Description
README.md Documents Druid support and setup.
database-compose.yml Adds profile-gated Druid services.
docs/ADDING_A_PROVIDER.md Adds Druid provider guidance.
docs/API_DOCS.md Documents Druid API behavior.
docs/ARCHITECTURE.md Adds Druid to architecture.
docs/DATABASE_PROVIDERS.md Documents provider implementation.
docs/FEATURES.md Lists Druid capabilities.
docs/SEED_CONNECTIONS.md Documents seeded Druid connections.
docs/providers/README.md Registers provider documentation.
docs/providers/druid.md Provides the Druid reference.
src/components/icons/db-icons.tsx Adds the Druid icon.
src/components/monitoring/tabs/OverviewTab.tsx Handles unavailable monitoring values.
src/components/monitoring/tabs/PerformanceTab.tsx Handles unmeasured cache ratios.
src/hooks/use-connection-form.ts Adds Druid to the picker.
src/lib/db-ui-config.ts Defines Druid UI configuration.
src/lib/db/factory.ts Creates Druid providers.
src/lib/db/providers/document/couchbase/index.ts Uses shared cache formatting.
src/lib/db/providers/sql/clickhouse/index.ts Uses shared cache formatting.
src/lib/db/providers/sql/druid/http-transport.ts Implements the HTTP protocol.
src/lib/db/providers/sql/druid/index.ts Implements the provider strategy.
src/lib/db/providers/sql/druid/introspect.ts Implements schema and monitoring reads.
src/lib/db/providers/sql/druid/transport.ts Defines the transport contract.
src/lib/db/types.ts Adds capabilities and optional metrics.
src/lib/db/utils/json-integers.ts Preserves unsafe JSON integers.
src/lib/explain/druid-native.ts Builds Druid EXPLAIN trees.
src/lib/explain/index.ts Registers the EXPLAIN strategy.
src/lib/monitoring-cache-ratio.ts Centralizes cache-ratio formatting.
src/lib/query-generators.ts Adds Druid identifier quoting.
src/lib/seed/types.ts Accepts Druid seed connections.
src/lib/types.ts Adds the Druid type identifier.
tests/components/monitoring/OverviewTab.test.tsx Tests unavailable overview metrics.
tests/components/monitoring/PerformanceTab.test.tsx Tests cache-ratio presentation.
tests/hooks/use-connection-form.test.ts Tests picker registration.
tests/integration/db/druid-provider.test.ts Tests provider integration.
tests/unit/db/druid/http-transport.test.ts Tests HTTP encoding and decoding.
tests/unit/db/druid/introspect.test.ts Tests introspection and monitoring.
tests/unit/db/druid/seam-guard.test.ts Enforces the transport boundary.
tests/unit/db/druid/transport.test.ts Tests the transport contract.
tests/unit/db/factory.test.ts Tests factory registration.
tests/unit/db/json-integers.test.ts Tests integer preservation.
tests/unit/lib/connection-string-parser.test.ts Tests absence of Druid URIs.
tests/unit/lib/db-icons.test.tsx Tests the Druid icon.
tests/unit/lib/db-ui-config.test.ts Tests Druid UI settings.
tests/unit/lib/explain/druid-native.test.ts Tests EXPLAIN conversion.
tests/unit/lib/monitoring-cache-ratio.test.ts Tests ratio formatting.
tests/unit/lib/query-generators.test.ts Tests Druid SQL generation.
tests/unit/seed/types.test.ts Tests Druid seed validation.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/lib/db/providers/sql/druid/introspect.ts Outdated
Comment thread src/lib/db/providers/sql/druid/http-transport.ts
Comment thread src/lib/db/providers/sql/druid/http-transport.ts Outdated
Comment thread src/lib/db/providers/sql/druid/http-transport.ts
Comment thread src/components/monitoring/tabs/PerformanceTab.tsx
Comment thread src/lib/explain/druid-native.ts Outdated
… check

`buildSql` refused anything not starting literally with SELECT, so the Explain
button was dead for two ordinary statements that Druid explains perfectly well.
Both live-verified as accepted on 37.0.0, and both render a tree end to end:

    EXPLAIN PLAN FOR WITH t AS (...) SELECT * FROM t     -> tree root "groupBy"
    EXPLAIN PLAN FOR -- note\nSELECT ...                 -> tree root "groupBy"
    EXPLAIN PLAN FOR /* header */ SELECT ...             -> tree root "scan"

The CTE case was also an internal inconsistency: the shared `analyzeQuery` already
classifies `WITH ... SELECT` as a SELECT and injects a LIMIT into one, so the
explain strategy refusing it disagreed with the rest of the pipeline.

The pattern is deliberately not the obvious spelling. A leading `\s*` in front of
an alternation that also contains `\s` gives two ways to match one run of spaces,
and a lazy `[\s\S]*?\*\/` inside a `*` quantifier lets one iteration swallow
several comments. Both are ambiguous, and a non-matching input pays for it:

    20k leading spaces + non-SELECT      ambiguous 958ms   tempered 0.4ms
    4 KB of block comments + non-SELECT  ambiguous 852ms   tempered 0.0ms

`buildSql` runs on the editor's contents every time a query is executed, so a
buffer opening with a large commented-out block ahead of a non-SELECT is reachable.
The tempered form accepts and rejects exactly the same statements - verified
identical on 21 cases - so this costs nothing. It follows the care that already
made `query-limiter.ts` hand-write its semicolon strip "without regex to avoid
ReDoS".

Tests cover the CTE, both comment styles, comments stacked ahead of a CTE, and the
near misses that must still be refused (comment-only, empty, SELECTED, WITHER, an
unterminated block comment). The backtracking guard asserts both the correct answer
and a bounded time; reverting to the ambiguous pattern fails it at 1274ms against a
200ms bound.

The five sibling strategies still carry the narrow check and are untouched here.
Comment thread src/lib/explain/druid-native.ts Fixed
cevheri added 2 commits August 4, 2026 01:44
Five findings, all reproduced before being accepted.

1. `__time` is no longer marked `isPrimary`. It is mandatory, it is the partition
   and sort key, and it is the only column Druid reports NOT NULL - but a primary
   key is UNIQUE and it is not: live, libredb_demo has 50 rows and 30 distinct
   `__time` values. Nothing in a datasource is unique. And `isPrimary` is not a
   hint - `sql-completions.ts` appends "(PK)", `use-ai-chat.ts` puts ", PK" into
   the schema context the model reasons from, and `schema-diff/diff-engine.ts`
   reports "Primary key changed", so two datasources differing only in this would
   diff as a key change. The time column stays identifiable by name and by being
   the one `nullable: false` column.

2. An integral `number` outside the safe range is refused instead of sent. By the
   time it arrives it is already wrong: a caller writing 9007199254740993 handed us
   9007199254740992, and nothing here can recover the digit. Sending it would
   filter on a value the user never wrote and return a plausible wrong row set, so
   the error names the fix - pass a bigint, which binds exactly. The same check
   catches an integral double past Druid's own BIGINT range.

3. The bigint marker is gone. The parameters array is now serialized by hand and
   spliced into the envelope at its closing brace, whose position is known because
   JSON.stringify just produced it. The previous NUL sentinel was unsound: a
   sentinel is only as private as the values flowing through it, so a caller whose
   VARCHAR parameter contained it would have had that string silently unquoted into
   a number. Emitting the literal in the first place cannot collide, because no
   marker exists. Verified live: a VARCHAR of exactly NUL + digits + NUL now
   round-trips as a string, alongside an exact bigint in the same request.

4. A payload shorter than the three header rows now raises instead of reporting an
   empty result. Live-verified, there is no legitimate way to receive one: with all
   three flags set even a zero-row result answers
   [["id","name"],["LONG","STRING"],["BIGINT","VARCHAR"]], and a bare SET - the only
   other statement form the grammar accepts - is rejected outright. What does
   produce a short payload is a truncated body or a proxy rewrite, and `{rows: []}`
   there is the most convincing possible lie: a successful query over the right
   datasource that happens to have found nothing. A genuine empty result still
   succeeds, fully described.

5. The cache-hit TREND no longer plots zero for missing samples. The current-value
   card already withheld the number, but `cacheHistory` still mapped absent to 0,
   which drew a measured 0% line - the same fabricated metric. Missing samples are
   dropped, and the card says "Not measured" when none remain. Samples that do
   carry a ratio still plot.

The sixth finding - that the explainable check rejected CTEs - was already fixed in
the preceding commit.

Test literals for the rounding cases use Number("...") rather than wide numeric
literals: oxlint's no-loss-of-precision is right that such a literal cannot be held
exactly, which is the very thing under test.
…ranch

CodeQL alert 107, and it is right: `--[^\n]*` inside a `*` quantifier is ambiguous.
`[^\n]*` can give characters back and let a later iteration match `--` again, so a
run of bare dashes partitions exponentially. Measured on the previous pattern, with
a tail that never reaches SELECT:

    "--" x 12  ->    0.2ms
    "--" x 16  ->    7.4ms
    "--" x 20  ->  250.7ms
    "--" x 22  ->  632.0ms      <- a 45-character input

Roughly fourfold per two extra dashes. That is far cheaper to trigger than either
ambiguity fixed in 510c697 - those needed 4 KB to 20 KB of input, this needs
forty-five characters - so removing the `(?:\n|$)` tail in that commit made the
pattern worse overall, not better. Restoring the tail forces the branch to run to
the newline or to the end, leaving nothing to give back.

The fix is to combine all three unambiguous forms rather than choose between them:
no leading `\s*` (whitespace is already an alternative), a tempered block-comment
body that cannot span two comments, and now a line comment anchored to the newline
or end of input. Every adversarial input measured is under 1.5ms, and the pattern
accepts and rejects exactly the same statements - verified identical on 23 cases.

The regression guard gains the bare-dash cases, which is the gap that let this
through: it already covered `-- a\n` repetitions, and the newline is precisely what
makes that branch unambiguous, so no amount of that input could have caught it.
Removing the tail again fails the guard at 636ms.
@sonarqubecloud

sonarqubecloud Bot commented Aug 3, 2026

Copy link
Copy Markdown

@cevheri
cevheri merged commit ffb3b4c into main Aug 4, 2026
18 checks passed
@cevheri
cevheri deleted the feat/druid-provider branch August 4, 2026 05:56
cevheri added a commit that referenced this pull request Aug 4, 2026
…rategies (#274)

* fix(explain): share one explainable-statement check across all six strategies

Every strategy carried its own `/^\s*SELECT\b/i`, and every one of them refused two
statements its engine explains perfectly well: a CTE, and a SELECT behind a comment.
The Explain button was simply absent for both. #271 fixed it for Druid only; this
extends it to the other five and moves the check into one place,
`explain/select-prefix.ts`.

The CTE case was also an internal disagreement: the shared `analyzeQuery` already
classifies `WITH ... SELECT` as a SELECT and injects a LIMIT into one, so six
strategies declining it contradicted the rest of the pipeline.

Verified per dialect rather than assumed - each statement below was wrapped by the
REAL strategy, executed against the REAL engine, and rendered:

    postgres 18     CTE, line comment, block comment      all explain
    mysql 9         CTE, line comment, block comment      all explain
    sqlite          CTE, line comment, block comment      all explain
    clickhouse 26.7 CTE, line comment, block comment      all explain
    couchbase 8.0.2 WITH binding, line, block comment     all explain
    druid 37        CTE, line comment, block comment      all explain

Couchbase needed its own spelling: SQL++ `WITH alias AS (<expression>)` binds a
value rather than a subquery.

PostgreSQL is the one dialect that could not simply take the shared answer, and
finding out why is the reason this was verified per engine. Its strategy emits
`EXPLAIN (ANALYZE, ...)`, which EXECUTES the statement, and a data-modifying CTE is
a write wearing a WITH:

    EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON)
      WITH t AS (INSERT INTO probe(id) VALUES (42) RETURNING id) SELECT * FROM t

    -> 0 rows in the table before, 1 row (42) after. Explaining performed the insert.

So accepting WITH there without a screen would have turned the Explain button into a
write. `postgres-json.ts` pairs the classification with `hasDataModifyingStatement()`
and applies it ONLY to the `with` case: PostgreSQL refuses a data-modifying CTE
anywhere but the top level ("WITH clause containing a data-modifying statement must be
at the top level", verified), so one cannot hide behind a leading SELECT, and
screening SELECTs too would strip the button off `SELECT 'insert'`, which explains
fine today. The screen errs toward refusing - a read-only CTE that merely mentions a
keyword loses its button rather than risking a write.

`classifySelectPrefix` returns `"select" | "with" | null` rather than a boolean
precisely so that asymmetry is expressible.

The regex keeps the shape arrived at in #271, where all three of its alternatives had
to be made unambiguous independently - no leading `\s*`, a line comment anchored to
newline-or-end, and a tempered block-comment body. Its bounded-time guard moves here
with it, since the property belongs to the regex rather than to Druid; Druid keeps a
lighter test proving it still routes through.

Six gates green plus coverage at 100% (28534 lines), and all nine explain files at
100% individually.

* test(explain): pin the PostgreSQL over-reach at the strategy boundary

Review follow-ups on #274, all verified before being accepted.

`select-prefix.test.ts` already covered `hasDataModifyingStatement` on a read-only CTE
that merely mentions a keyword, but nothing pinned what `postgresJsonStrategy.buildSql`
does with it. That is where the documented "errs toward refusing" behaviour actually
matters, so it now has a test:

    WITH t AS (SELECT 'insert' AS x) SELECT * FROM t   -> null

Paired with the LIMIT of that over-reach, which is the more interesting half and was
untested: the word boundary is what keeps the screen from swallowing every CTE that
touches an `updated_at` column, so

    WITH t AS (SELECT updated_at FROM u) SELECT * FROM t

still explains. Writing that pair is what caught a wrong assertion in the first draft -
I had expected `updated_at` to be refused, and it is not, correctly.

Also records two facts about DATA_MODIFYING's membership that were previously assumed:

- MERGE is a REAL carrier, not a defensive guess. Live on PostgreSQL 18,
  `EXPLAIN (ANALYZE, FORMAT JSON) WITH t AS (MERGE INTO probe ... RETURNING id)
  SELECT * FROM t` really inserted the row.
- TRUNCATE is deliberately absent. It cannot ride inside a WITH at all -
  `WITH t AS (TRUNCATE probe) SELECT 1` is a SYNTAX error - and a statement leading with
  it never reaches the screen because the prefix classification already refuses anything
  but SELECT or WITH. Tested for TRUNCATE, DROP and CREATE together.

The third review point - that `analyzeQuery` still carries its own regexes - turned out
to hide a live defect rather than only a drift risk: a leading comment makes it classify
a SELECT as OTHER, so `prepareQuery` injects no LIMIT and the query runs unbounded.
Filed as #275 with the reproduction; it feeds the query path of all ten providers and
belongs in its own change rather than in an explain PR.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[FEATURE] Add Apache Druid provider (Druid SQL over HTTP, no native dependency)

3 participants