Skip to content

feat(plugins): let drivers declare their SQL syntax - #1200

Merged
debba merged 28 commits into
mainfrom
feat/plugin-sql-syntax
Oct 18, 2026
Merged

debba merged 28 commits into
mainfrom
feat/plugin-sql-syntax

Conversation

@debba

@debba debba commented Oct 18, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

sql_dialect only offers fixed presets, so an engine none of them describes (GoogleSQL: triple-quoted and raw strings, # comments, procedural BEGIN ... END blocks, backslash escapes, no LIKE ... ESCAPE, no implicit integer-to-boolean coercion) had its scripts split wrongly and got invalid generated SQL. Instead of hard-coding one more dialect in the host, plugins can now declare their syntax as data and extend a preset.

Changes

  • New optional capability sql_syntax: extends (a preset, defaults to sql_dialect) plus overrides for quotes, triple_quoted_strings, string_prefixes, hash_comments, nested_block_comments, line_comment_requires_space, script_blocks, dollar_quoting, string_escape, like_escape, boolean_literals, formatter (allowlist) and autocomplete keywords / functions. Data only, every field bounded; an invalid field is ignored with a warning and never fails plugin loading.
  • One resolver per side (drivers/sql_syntax.rs, src/utils/sqlSyntax.ts), used by the statement splitter, folding, parameters, invisible-character cleanup, Format SQL, autocomplete, filter bar, foreign-key filters, Copy as INSERT, clipboard import, dumps and notebook literals.
  • Splitter features toggled by the descriptor: triple-quoted strings, letter prefixes, procedural script blocks (BEGIN TRANSACTION stays a plain statement).
  • MCP read-only check: the declared syntax adds one lexer profile to the dialect's set; it can only make the check stricter, never hide a statement the other profiles see.
  • Safety rails: a descriptor can never weaken the escaping of a mysql base, and backslash literals escape NUL as \0.
  • Unknown sql_dialect values load as generic with a warning (manifest) or are ignored (connection metadata), so future dialects never break older hosts.
  • MySQL statements that start with a # comment now take the result-set path.
  • PLUGIN_GUIDE.md documents the capability with a complete GoogleSQL example and asks plugins to declare sql_dialect or sql_syntax. Both schemas declare the field; the Tabularium driver kind already accepts it.

Hosts that predate sql_syntax ignore it and use sql_dialect, so a plugin keeps the closest preset there as a fallback.

Compatibility

Without sql_syntax every path resolves to the same preset as before. Golden tests cover split, batch and scan output for every preset, dumps, clipboard import and generated literals for built-in drivers. No SqlDialect value is removed compared with main.

Tests

debba added 23 commits October 18, 2026 10:48
Plugins can now declare `sql_dialect: "bigquery"`. The statement
splitter follows GoogleSQL lexing and scripting rules:

- backslash escapes in '...', "..." and `...`, no doubled-quote escape
- triple-quoted '''...''' / """...""" strings and r/b/rb/br prefixes
- `#`, `--` and `/* */` comments
- procedural blocks stay one statement: BEGIN ... END (but not
  BEGIN TRANSACTION or BEGIN;), EXCEPTION WHEN ERROR THEN, IF ... END IF,
  LOOP, WHILE, REPEAT, FOR ... DO, CASE ... END CASE, labels and
  CREATE PROCEDURE bodies. Only statement-position keywords open a
  block, since BEGIN/LOOP/WHILE/REPEAT are not reserved and IF/REPEAT
  are also functions.

The Rust SqlDialect enum gains Bigquery. A manifest that declares a
dialect this host doesn't know now loads as generic with a warning
instead of failing the whole plugin, so future dialects don't break
older hosts. Connection metadata overrides stay strict.

Other dialects and existing plugins are unchanged.
…igquery

For plugins that declare the bigquery dialect:

- filter bar, foreign-key filters, copy as INSERT / IN list and clipboard
  import backslash-escape string literals, since GoogleSQL has no
  doubled-quote escape
- "contains" / "starts with" / "ends with" filters escape LIKE wildcards
  with backslashes and omit ESCAPE, which GoogleSQL LIKE doesn't support
- autocomplete adds GoogleSQL keywords and functions (QUALIFY, PIVOT,
  UNNEST, SAFE_CAST, REGEXP_*, JSON_*, ST_*, APPROX_*, date functions...)
- Format SQL uses the formatter's bigquery language

Other dialects keep their current output.
The AI/MCP read-only classifier tokenized SQL one way only, so a payload
could hide a second statement in text that the target engine reads
differently: `#` comments (`SELECT 1 # '<newline>; DROP TABLE t` on MySQL),
backslash escapes in "..." and `...`, MySQL `--` without a space and
executable /*! */ comments, PostgreSQL nested comments, CR-terminated
line comments, `$` inside identifiers and non-ASCII dollar-quote tags,
SQL Server [brackets] and triple-quoted strings.

Statements are now lexed once per tokenization profile plausible for the
connection's sql_dialect capability and are a read only when every pass
sees exactly one read statement. Generic, Oracle, undeclared and future
dialects use every profile, so they fail closed. MCP run_query (initial
and approver-edited queries) passes the driver's dialect, and the
DROP DATABASE sidebar sync uses the same multi-pass separator check.
The database dump picked identifier quoting, schema qualification and
literal syntax from the driver id: every plugin got `"table"` with no
schema, and only the `postgres`/`postgresql` ids scoped the table
listing. A plugin that quotes with backticks and exposes datasets as
schemas produced statements its engine could not read back.

Dump formatting now lives in `DumpDialect`, built from the driver's
`identifier_quote`, `schemas` and `sql_dialect` capabilities. Schema-aware
plugins get a scoped listing, a scoped `get_table_ddl` call and
`schema`.`table` references; drivers without schemas never receive a
stray schema. Golden tests compare the builtin MySQL, PostgreSQL and
SQLite output with the previous implementation byte for byte.
…tifier quote

Cell references (`{{cell_N}}`) are inlined as a CTE whose column aliases
were always double-quoted, and backslash escaping was chosen by comparing
the driver id with "mysql"/"mariadb". Engines that read "..." as a string
literal (BigQuery, MySQL plugins) rejected the aliases.

`variableOptionsForDriver` now derives both settings from the active
capabilities (`identifier_quote`, `sql_dialect`), falling back to the
builtin rules for a bare driver id.
`stripLeadingComments` only knew `--` and `/* */`, so a MySQL statement
starting with a `# comment` line got an empty leading keyword: it was not
treated as a SELECT, result set or explainable statement.

The helper and `leadingKeyword` take a `hashComments` option, and the
splitter passes its dialect's `hashComments` flag, so PostgreSQL (where
`#` is an operator) is unchanged. The dialect-agnostic safety checks
(`isDataModifyingQuery`, `classifyDangerousQuery`) also look past a
leading `#` line, since no dialect can start a statement with `#`.
Column statistics and the filter value picker run `COUNT(DISTINCT col)`
and `GROUP BY col`, which engines with nested types reject for arrays,
structs and repeated records. Plugin drivers report those as
`ARRAY<...>`, `STRUCT<...>`, `MAP<...>`, `RECORD` or `... REPEATED`.

`isNestedColumnType` matches those spellings without a driver check; such
columns get row and NULL counts only and no value picker. A bare `ARRAY`
(PostgreSQL's report for `int[]`, which supports equality) is unchanged.
Instead of a hard-coded `bigquery` SqlDialect, a driver plugin now
describes how its engine lexes and quotes SQL in the new optional
`capabilities.sql_syntax` object. It extends one of the existing
presets (`extends`, default `sql_dialect`) and overrides individual
rules: quotes, triple-quoted strings, string prefixes, `#` comments,
nested comments, `--` spacing, script blocks, dollar quoting, how
generated string literals are escaped, LIKE ESCAPE support, the
formatter language and extra autocomplete words. It is data only.

`sql_syntax` is separate from `sql_dialect`, so older hosts ignore it
and keep using `sql_dialect` as the fallback. Every field is validated
and bounded; an invalid field is logged and ignored and never fails the
manifest. The sanitized descriptor is what the frontend receives.

`SqlSyntax::resolve` turns capabilities into the effective rules. A
driver without `sql_syntax` resolves to exactly its `sql_dialect`
preset. Clipboard import and dump literals now follow the resolved
`string_escape`, so built-in drivers keep their output and a GoogleSQL
plugin gets backslash-escaped literals.

The `bigquery` SqlDialect variant is gone; a manifest that still names
it loads as `generic` through the lenient dialect parser.
The AI/MCP read-only classifier lexes a query once per tokenization
profile of the driver's `sql_dialect` and treats it as a read only when
every pass sees one read statement. A driver that declares `sql_syntax`
now adds one more pass, derived from that syntax (backslash escapes per
quote, `#` comments, `--` spacing, nesting, triple quotes, dollar
quotes, bracket identifiers). Rules the descriptor can't set come from
the preset it extends.

The pass is added, never substituted, so a descriptor can only make the
check stricter: a wrong or hostile descriptor can't hide a `;` that the
dialect's own passes see, and generic, oracle or undeclared dialects
still run every profile. The trade-off is fail-closed false positives
when the fallback dialect reads a literal differently, e.g.
`'''it's; ok'''` with a `mysql` fallback.

MCP run_query and approver-edited queries classify with the driver's
capabilities. The former BigQuery lexer profile stays in the
all-profiles set as a dialect-neutral triple-quote/backslash pass.
`strip_leading_sql_comments` and `returns_result_set` only knew `--` and
`/* */`, so on MySQL a statement starting with a `# note` line went down
the affected-rows path instead of fetching its rows, and a commented
`START TRANSACTION` missed the text protocol.

New `_for` variants take whether the resolved syntax has `#` comments.
The MySQL driver passes its resolved syntax's `hash_comments` (true);
the existing functions keep ignoring `#` for every other caller.
`resolveSqlSyntax(capabilities)` (src/utils/sqlSyntax.ts) is the single
place the frontend turns a driver into its SQL rules: splitter options,
literal escape style, LIKE ESCAPE support, formatter language and extra
autocomplete words. It extends the `sql_dialect` preset with the
declared `sql_syntax`, validating and bounding every field as the
backend does. Without `sql_syntax` it returns the exact preset object,
cached, so it is as stable as the dialect string it replaces.

The splitter entry points, non-code scanning, query parameters, folding,
invisible-character cleanup, Format SQL and the Editor accept the
resolved syntax wherever they took a dialect name. Filters, FK filters,
Copy as INSERT / IN list and notebook cell CTEs take their literal
style from it (`stringEscapeStyle` / `quoteSqlString` in sqlLiteral.ts),
and literal LIKE filters drop `ESCAPE` when `like_escape` is false.

GoogleSQL support moves to plugin data. The BIGQUERY splitter preset
and keyword list are removed; the splitter keeps triple-quoted strings
and script blocks as generic features and gains `stringPrefixes` and a
`kind` per quote rule. The former BigQuery tests now run through the
GoogleSQL descriptor fixture (tests/fixtures/sql-syntax), and new tests
check that every preset resolves and splits exactly as before.
Adds a SQL Syntax section to the plugin guide with every field, its
default and limits, what a descriptor can't change, how it affects the
AI/MCP read-only check, and the full GoogleSQL descriptor a BigQuery
driver ships with `sql_dialect: "mysql"` as its fallback.

`sql_syntax` joins the manifest schema and the Tabularium extension
schema, and `bigquery` leaves the `sql_dialect` enum. Connection
metadata can't override `sql_syntax`.

Tests on both sides check the schemas against the host limits and
formatter allowlist, and the GoogleSQL fixture against both schemas.
Dump booleans: only PostgreSQL (the postgres dialect, or the postgres /
postgresql driver ids) writes TRUE/FALSE. Plugins with no sql_dialect or
a generic one go back to the 1/0 the dump always wrote, since an engine
nobody verified may not accept the keywords. A postgres-flavoured driver
without the schemas capability is still schema-qualified, as before.

The golden tests now compare against the old id-based implementation for
the builtin drivers, the postgresql plugin (with and without a dialect)
and dialect-less plugins: literals are byte-identical. The one intended
difference is table references for a plugin that declares a non-default
identifier_quote or the schemas capability: they are now quoted with that
quote and schema-qualified instead of always being written as "table".

Notebook variables: when the capabilities name no concrete dialect (none
or generic), the old driver-id rule applies again, so a mariadb plugin
without sql_dialect keeps doubling backslashes in cell literals.
Option::is_none_or is stable since Rust 1.82, above the crate's
rust-version 1.77.2. Use map_or(true, ...) in the dash-comment check.
…syntax

DumpDialect keeps the resolved string_escape and takes the restored
boolean rule (TRUE/FALSE only for PostgreSQL, 1/0 otherwise).

Notebook literals use the resolved syntax via stringEscapeStyle, which
now takes the active driver id: when the capabilities declare neither
sql_syntax nor a concrete sql_dialect (none or generic), the mysql /
mariadb id rule still applies.
std::sync::LazyLock is stable since Rust 1.80, above the crate's
rust-version 1.77.2. Use once_cell::sync::Lazy like the rest of the
crate.
ConnectionCapabilityOverrides.sql_dialect was parsed strictly, so a
plugin returning a dialect this host doesn't know from
get_connection_metadata failed discovery for the whole connection.

Parse it leniently like the manifest, but drop the unknown override
(with a warning) instead of mapping it to generic, so the manifest's
dialect stays in force. A non-string value is still rejected.
A descriptor declaring string_escape "standard" on a mysql base
(extends mysql, or sql_dialect mysql without extends) made generated
literals stop doubling backslashes, although the engine still reads `\`
as an escape character: a value ending in `\` could swallow its closing
quote. Both resolvers (Rust SqlSyntax::resolve and TS resolveSqlSyntax)
now keep the mysql style and log a warning; "backslash" is still
honoured since it escapes backslashes too.

The backslash literal style also writes NUL as `\0` in Rust and TS, as
the mysql style already did.
Explain what a plugin without a dialect gets: the strictest AI/MCP
read-only classification (with the kinds of reads it may reject) and
standard literals with 1/0 booleans in dumps. Document that
string_escape "standard" is ignored on a mysql base and that the
backslash style escapes NUL.
GoogleSQL has no implicit INT64 to BOOL coercion, so the 1/0 a dump writes
for a plugin on the mysql fallback makes the generated INSERTs fail. A
plugin can now declare `sql_syntax.boolean_literals: "keywords" | "numeric"`.

The declared style is used by dumps, Copy as INSERT / IN list, foreign-key
and cell-value filters, filters on BOOL/BOOLEAN columns, notebook cell
CTEs and the BOOLEAN columns a clipboard import creates. Without the field
every generator keeps its historical form (dumps: TRUE/FALSE on postgres,
1/0 elsewhere), so built-in drivers and existing plugins are unchanged. An
invalid value is logged and ignored like other descriptor fields.

The GoogleSQL fixture, both manifest schemas and PLUGIN_GUIDE.md declare
and document the field.
// `execute()` so we can return the actual `rows_affected`. In text mode
// they must use `raw_sql` (COM_QUERY) since the bastion rejects prepares.
if !crate::drivers::common::returns_result_set(query) {
if !crate::drivers::common::returns_result_set_for(query, *HASH_COMMENTS) {

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.

WARNING: #-comment handling is only half applied on the MySQL path

returns_result_set_for(query, *HASH_COMMENTS) now treats # note\nSELECT 1 as a result-set statement, but is_select_query(query) a few lines below (line 1461) still uses strip_leading_sql_comments, which keeps a leading #. Such a query takes the fetch path with is_select == false, so no LIMIT/OFFSET pagination is pushed down and pagination stays None (only the client-side manual_limit cap applies). is_explainable_query (drivers/common/query.rs) has the same blind spot. The doc on is_select_query says it must match the comment stripping used for result-set detection, so route it through strip_leading_sql_comments_for(query, *HASH_COMMENTS) for MySQL, and add a test for is_select_query with a leading #.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in e6ab202. Added is_select_query_for and is_explainable_query_for (the plain versions delegate with hash_comments = false). The MySQL driver now uses is_select_query_for(query, *HASH_COMMENTS), so # note\nSELECT 1 gets LIMIT/OFFSET pagination. explain_query_plan now runs the explainable check after resolving the driver, using the hash_comments of its resolved SqlSyntax. Tests cover both helpers with and without hash comments.

Comment thread src/utils/sqlLiteral.ts Outdated
case "backslash":
return `'${escapeBackslashString(value)}'`;
case "mysql":
return `'${value.replace(/\\/g, "\\\\").replace(/'/g, "''")}'`;

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.

WARNING: TS mysql literal style does not escape NUL, unlike the Rust side

The PR states backslash literals escape NUL as \0, and Rust quote_string_literal for LiteralStyle::Mysql does .replace('\0', "\\0") (asserted in sql_syntax_tests.rs and dump_utils/tests.rs). This mysql branch only doubles backslashes and quotes, so Copy as INSERT / filters / notebook literals for a MySQL value containing NUL differ from the dump output. Add .replace(/\0/g, "\\0") here and a test for the mysql style (the existing NUL test in tests/utils/sqlLiteral.test.ts only covers backslash).


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in cad2aa6: the mysql style now writes NUL as \0, same as the Rust side. I also added a shared fixture, tests/fixtures/sql-syntax/string-literals.json, that both the Rust quote_string_literal test and the TS quoteSqlStringWithStyle test read, covering all three styles. That keeps the two sides identical from now on.

Comment thread src/utils/sqlSplitter/scriptBlocks.ts Outdated
continue;
}
if (options.blockComments && source.startsWith('/*', p)) {
const close = source.indexOf('*/', p + 2);

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.

SUGGESTION: skipTrivia does not follow the tokenizer's comment rules

The look-ahead ends a block comment at the first */ and treats every -- as a line comment, while the tokenizer honours nested_block_comments and line_comment_requires_space. For a plugin declaring script_blocks together with either option, END /* /* x */ */ IF or END --IF is read differently by the look-ahead (peekWord / beginsTransaction) than by the main scanner, which can mis-detect block closers. Reuse the tokenizer's comment scanning (e.g. scanBlockCommentLength) or pass the options through.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in ddd29f1. The comment scanning moved out of scanToken into an exported scanComment in tokenizer.ts, and skipTrivia now uses it. The look-ahead therefore follows hash_comments, nested_block_comments and the -- space rule exactly like the main scanner (an operator -- or an executable comment counts as code). Tests cover END IF closers past nested comments, --x with and without the space rule, # comments and BEGIN TRANSACTION.

@kilo-code-bot

kilo-code-bot Bot commented Oct 18, 2026 •

Copy link
Copy Markdown
Contributor

Code Review Summary

Status: No Issues Found | Recommendation: Merge

All three previous findings are resolved in the latest commits:

  • #-comment handling on the MySQL path: is_select_query_for / is_explainable_query_for now take hash_comments, and both the MySQL driver and explain_query_plan use the driver's resolved syntax (with tests).
  • TS mysql literal style now escapes NUL as \0, and a shared JSON fixture keeps the Rust and TS quoting tests identical.
  • skipTrivia now reuses the tokenizer's exported scanComment, so the script-block look-ahead follows hash_comments, nested block comments and the -- space rule (with tests). No circular import: the tokenizer imports types only.
Files Reviewed (incremental, PR-specific commits e6ab202, cad2aa6, ddd29f1)
  • src-tauri/src/commands.rs
  • src-tauri/src/drivers/common.rs, common/query.rs, common/tests.rs
  • src-tauri/src/drivers/mysql/mod.rs
  • src-tauri/src/drivers/sql_syntax_tests.rs
  • src/utils/sqlLiteral.ts
  • src/utils/sqlSplitter/scriptBlocks.ts, tokenizer.ts
  • tests/utils/sqlLiteral.test.ts, tests/utils/sqlSplitter/scriptBlocks.test.ts, tests/fixtures/sql-syntax/string-literals.json

The remaining incremental diff comes from merging main (#629, #1197, #1198) and was not re-reviewed here.

Previous Review Summary (commit b8adba5)

Current summary above is authoritative. Previous snapshots are kept for context only.

Previous review (commit b8adba5)

Status: 3 Issues Found | Recommendation: Address before merge

Overview

Severity Count
CRITICAL 0
WARNING 2
SUGGESTION 1
Issue Details (click to expand)

WARNING

File Line Issue
src-tauri/src/drivers/mysql/mod.rs 1441 #-comment handling is half applied: returns_result_set_for now honours #, but is_select_query (line 1461) and is_explainable_query still don't, so # note\nSELECT ... skips pagination push-down.
src/utils/sqlLiteral.ts 79 TS mysql literal style does not escape NUL as \0, unlike Rust quote_string_literal; also untested for the mysql style.

SUGGESTION

File Line Issue
src/utils/sqlSplitter/scriptBlocks.ts 225 skipTrivia ignores nested_block_comments and line_comment_requires_space, so script-block look-ahead can disagree with the tokenizer.

Other notes (not inline, low severity):

  • tests/utils/sqlSyntaxSchema.test.ts (bigquery check) is vacuous for the extensions schema, which has no sql_dialect property.
  • Schema keywords/functions pattern accepts trailing or double spaces that the Rust/TS word validator rejects, so a schema-valid manifest can lose the whole array at runtime (warning only).
  • MCP read-only gate uses manifest capabilities, not per-connection overrides; this can only affect which lexer profiles are added and cannot weaken the check for dialect-less/generic plugins.
  • SqlEditorWrapper.tsx/Editor.tsx: activeDialect is now always a resolved object, so the cursor-statement highlight gate is always on, including drivers without a declared dialect (likely intended).

Fix these issues in Kilo Cloud

Files Reviewed (70 files)
  • src-tauri/src/drivers/mysql/mod.rs - 1 issue
  • src/utils/sqlLiteral.ts - 1 issue
  • src/utils/sqlSplitter/scriptBlocks.ts - 1 issue
  • src-tauri/src/drivers/sql_syntax.rs, sql_dialect.rs, driver_trait.rs, common.rs, common/query.rs
  • src-tauri/src/ai_activity.rs, mcp/mod.rs, plugins/connection_metadata.rs, lib.rs
  • src-tauri/src/clipboard_import.rs, dump_commands.rs, dump_utils.rs, sql_database_statements.rs
  • src/utils/sqlSyntax.ts, sqlSplitter/*, autocomplete.ts, filterBar.ts, clipboard.ts, sql.ts, and other TS consumers
  • src/components/*, src/pages/Editor.tsx
  • plugins/*.md, plugins/*.schema.json
  • All added/updated Rust and TS tests and fixtures

Reviewed by claude-sonnet-5.5 · Input: 256 · Output: 2.1K · Cached: 263.8K

Review guidance: REVIEW.md from base branch main

# Conflicts:
#	src-tauri/src/ai_activity.rs
#	src-tauri/src/ai_activity_lexer_tests.rs
#	src-tauri/src/dump_utils.rs
#	src-tauri/src/dump_utils/tests.rs
#	src-tauri/src/mcp/mod.rs
#	src/utils/notebookVariables.ts
#	tests/utils/notebookVariables.test.ts
@github-actions

github-actions Bot commented Oct 18, 2026 •

Copy link
Copy Markdown
Contributor

Preview build

The preview build of commit 5c5e230 failed (skipped). See the workflow run for logs.

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.

1 participant