Skip to content

fix(editor): lex SQL the way each engine does, so Safe Mode sees every statement - #3005

Merged
datlechin merged 6 commits into
mainfrom
refactor/sql-lexical-grammar
Sep 20, 2026
Merged

datlechin merged 6 commits into
mainfrom
refactor/sql-lexical-grammar

Conversation

@datlechin

Copy link
Copy Markdown
Member

Safe Mode, the MCP and AI gates and the iOS write check all decide what a statement is by reading the SQL, and the app read it with several lexers that disagreed with each other and with the engines. Where the app's reading was wrong, text it counted as one safe statement ran as several on the server.

Measured live, each one a hidden DROP TABLE users behind a leading SELECT the classifier tiered safe:

  • Backslash in a string. SELECT 'C:\' AS p; DROP TABLE users dropped the table on PostgreSQL 17 (the driver forces standard_conforming_strings on and sends through PQexec), on DuckDB, on SQL Server through FreeTDS, and on Dameng. The app treated \' as an escape for every engine but Oracle. MySQL, MariaDB, ClickHouse, Snowflake and GoogleSQL do escape with it, so the rule is per engine, not a constant.
  • Nested block comments. SELECT 1 /* /* */ ' */; DROP TABLE users; --' dropped the table on PostgreSQL 17, DuckDB 1.5.4 and Azure SQL Edge. PostgreSQL, DuckDB and SQL Server nest; SQLite, MySQL, Oracle and DM8 do not.
  • Bracketed identifiers. SELECT [it's] FROM t; DROP TABLE users; SELECT 'x' was one safe statement and dropped the table on SQL Server, where [...] quotes an identifier and ]] escapes.
  • Dollar-quoted strings. SELECT $$it's$$; DROP TABLE users; SELECT 'x' did the same on DuckDB. $$ bodies were read only on PostgreSQL, so Snowflake, Cassandra, Databend and DuckDB were all exposed, and PostgreSQL itself missed non-ASCII tags such as $ü$.
  • iOS. SQLWriteClassifier.isWriteQuery called SELECT $$'$$; DELETE FROM t a read on PostgreSQL, and the server deleted every row. The same held for E'\'', MySQL 'a\'', T-SQL [a'b] and Oracle q'[it's]'.

What changed

  • A new zero-dependency package target, TableProSQLGrammar, owns the lexer, the statement scanner, the boundary trackers and one grammar value type. Both apps read it, so the Mac and iOS now lex a statement the same way. The first commit is the move with no behaviour change; the second is the behaviour.
  • SQLLexicalGrammar states the facts a lexer needs (which quotes escape with a backslash, whether block comments nest, bracket and backtick identifiers, dollar-quote style, # and // comments, q'[...]', E'', triple quotes, /*! */, -- needing whitespace, a carriage return ending a line comment, $/# in identifiers). SQLLexicalProfile carries one curated profile per engine TablePro ships, each with the server and version it was measured on in its doc comment, and marked "not measured" with the reference it came from where no server was available (ClickHouse, Snowflake, Databend, CQL, SurrealQL, BigQuery).
  • Three sources, in that order: the curated profile (authoritative, because a registry plugin may be built against an older kit), then what a plugin declares on SQLDialectDescriptor.lexicalFeatures, then what the connected driver reports about its own session (PluginDatabaseDriver.sessionLexicalState: MySQL's NO_BACKSLASH_ESCAPES, Dameng's BACKSLASH_ESCAPE). The session only decides how a script is split for execution.
  • Where a fact is in doubt, the gates classify under every reading the engine could have and take the worst: the higher statement count and the more severe tier. SQLLexicalReadings.distinct(for:) drops any fact the text cannot exercise first, so a query with no backslash is still lexed once.
  • Every reader now takes the grammar: the scanner, the fold scanner, the import parser, the token cursor, the limit detector, the source-table parser, the confusable-character scanner, QueryClassifier (through one code projection, replacing strippingStringLiterals/skipQuoted), CatalogChangeClassifier, and iOS SQLWriteClassifier. SqlDialect stays what it was, the SQL generation dialect for export and statement building; it no longer decides how text is read.
  • PluginKit SQLStatementSplitting gets the dollar-quote state its doc comment always claimed, through a grammar-aware overload. DuckDB's transaction tracking and the MySQL session footprint pass their own grammar, so a Databend connection served by the MySQL plugin is lexed as Databend.
  • A routine definition is no longer escalated by what its body would run: CREATE PROCEDURE p IS BEGIN EXECUTE IMMEDIATE 'DROP TABLE users'; END; stays a write, as fix(plugin-oracle): run PL/SQL blocks and units whole, with the terminator Oracle needs #2988 settled, while an anonymous block that runs it is still destructive.

ABI

Additive, checked with scripts/check-pluginkit-abi.sh against the merge base: no symbol removed. SQLDialectDescriptor keeps its existing initializer exactly as it was, marked @_disfavoredOverload, beside a new one that takes lexicalFeatures. PluginDatabaseDriver gains sessionLexicalState with a default. Kit 33 is pending and unreleased, so no bump.

Tests

  • Per-engine lexical corpora for every input above, asserting the statement count and the tier per engine, with the measured server behaviour in the test names.
  • Gate tests: ExternalStatementGateLexicalTests, MCPStatementGateTests, QueryClassifierLexicalTests, and SQLWriteClassifierTests in the package for iOS.
  • SQLLexicalFeatureMappingTests holds every grammar fact to its PluginKit feature bit, so the two sets cannot drift.
  • 1111 cases over the 41 touched suites, plus the TableProCore package tests, AllPlugins, the iOS build and the shared-isolation check.

Measured against live PostgreSQL 17.11, MySQL 8.4.11, MariaDB 11.8.9, SQLite 3.54, DuckDB 1.5.2 and 1.5.4, Oracle 23.26, Dameng DM8 (compatibility modes 0, 2, 4 and 7), Azure SQL Edge 15.0 and the Spanner emulator in both dialects.

Not in this PR

The statement boundary work that sits on top of this: per-engine routine-body trackers (a column named begin still merges statements on PostgreSQL, MySQL, SQLite and SQL Server), the T-SQL batch classifier, the import parser's boundaries, Dameng's PL/SQL units, Snowflake Scripting blocks, and enforcing one statement at the engine (PostgreSQL's extended protocol refuses a merged text; measured).

@mintlify

mintlify Bot commented Sep 20, 2026

Copy link
Copy Markdown

Preview deployment for your docs. Learn more about Mintlify Previews.

Project Status Preview Updated
TablePro 🟢 Ready View Preview Sep 20, 2026, 4:14 AM

💡 Tip: Enable Automations to automatically generate PRs for you.

@datlechin
datlechin merged commit a93c9d3 into main Sep 20, 2026
10 of 14 checks passed
@datlechin
datlechin deleted the refactor/sql-lexical-grammar branch September 20, 2026 06:18
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