Skip to content

feat: the MySQL 8.4 dialect - #32

Merged
christiangda merged 1 commit into
mainfrom
feat/the-mysql-dialect
Oct 24, 2026
Merged

christiangda merged 1 commit into
mainfrom
feat/the-mysql-dialect

Conversation

@christiangda

Copy link
Copy Markdown
Contributor

What was wrong

qfv read every filter's strings as PostgreSQL does, with a backslash just a character. MySQL 8.4 reads a backslash inside a string as an escape. So a parser in front of MySQL could accept a filter whose predicates it never saw.

With the allow-list ["name"], qfv v1.1.1 accepts this as one comparison of name with a string:

name = '\'' OR id > 0 #'

MySQL 8.4.13 runs it, followed by a newline as in a SQL template, as a comparison of name with a quote, then id > 0, then a comment. It returned 3 rows where qfv's reading returns 0: a predicate on id, a field the allow-list does not have.

No consumer of ours runs on MySQL; core and the template run on PostgreSQL 18. But anyone using qfv in front of MySQL had this hole, and there was no MySQL dialect to choose.

What changes

  • DialectMySQL: MySQL 8.4's grammar with its default sql_mode.
    • Reads strings as MySQL does, with backslash escapes. The proof filter is refused: id is not allowed. NewLexer keeps PostgreSQL's reading for every existing caller; the parser builds its lexer for the dialect.
    • Refuses what MySQL 8.4 does not run: ILIKE, the ~~ and ~ operators, SIMILAR TO, IS [NOT] DISTINCT FROM, BETWEEN SYMMETRIC/ASYMMETRIC, and ISNULL/NOTNULL. Each spelling-level refusal names what to write instead.
    • Keeps != and an empty ESCAPE, which MySQL runs.
    • Says what it cannot see: a server with NO_BACKSLASH_ESCAPES reads strings the other way.
  • ESCAPE length in characters: a doubled quote counts once in every dialect, and a backslash pair once in MySQL's.
    • MySQL's ESCAPE '\\' is one backslash.
    • Four single quotes are one quote, where they were counted as empty. Both databases ran that row.
  • Scope: <=> and REGEXP are not parsed yet. Each would be a new operator and node, changing what every dialect sees, so they are left for a later minor. The docs say so.

How it was checked

  • Every row against MySQL 8.4.13, default sql_mode read from the server. Each statement was sent as a hex literal through PREPARE, so that only the server parsed it: the mysql client parses backslashes itself and would have misled. 47 inputs, each answer in the test's comment.
  • TestTheMySQLDialectReadsStringsAsMySQLDoes:
    • PostgreSQL's dialect reads the proof filter as one comparison; MySQL's refuses it on id;
    • strings with \', \\, '' and \% are each one token;
    • a string ending on a backslash, or on its escaped quote, is unclosed;
    • NewLexer is unchanged.
  • TestTheMySQLDialect: 24 accepted inputs and 16 refused, each with its message.
  • Mutants: 11 of 11 caught, each compiling.
    • The lexer ignoring backslashes.
    • MySQL reading strings as PostgreSQL does.
    • NewLexer reading as MySQL.
    • MySQL having every operator.
    • MySQL having IS DISTINCT FROM.
    • MySQL having BETWEEN SYMMETRIC.
    • No empty ESCAPE in MySQL.
    • A backslash pair counted as two.
    • A doubled quote counted as two.
    • SYMMETRIC not checked.
    • A string ending on its backslash counted as closed. That one survived the first run and has a test now.
  • The docs test now reads one refusals table per dialect, including the proof row with its own allow-list. A changed message or allow-list on the page fails it.
  • go test -race passes, coverage is 98.2%, and golangci-lint reports 0 issues.

Docs, every page, each changed diagram rendered:

  • docs/dialects.md: MySQL in every table and diagram, a section with a diagram on how a string is read, and MySQL's refusals;
  • migration.md: v1.1.1 → v1.2.0, with "if your filters run on MySQL, pass WithDialect(qfv.DialectMySQL)";
  • README.md, docs/README.md, configuration.md, getting-started.md, filtering.md, error-handling.md, and the package doc;
  • a runnable ExampleWithDialect_mySQL.

v1.2.0. An independent review that tries to break the string claim against the same server is running; its findings will go in a follow-up commit.

🤖 Generated with Claude Code

DialectMySQL is MySQL 8.4's grammar with its default sql_mode: PostgreSQL
18's without what MySQL does not run (ILIKE, the ~~ and ~ operators,
SIMILAR TO, IS DISTINCT FROM, BETWEEN SYMMETRIC and ASYMMETRIC, ISNULL
and NOTNULL), keeping != and an empty ESCAPE, which it runs. Every row
was run against MySQL 8.4.13.

And it reads strings as MySQL does, with backslash escapes. A parser
reading them as PostgreSQL does, in front of MySQL, accepted
name = '\'' OR id > 0 #' with the allow-list [name] as one comparison of
name with a string; MySQL 8.4 runs it, with a newline after it as in a
SQL template, as a comparison of name with a quote, then id > 0, then a
comment: every row, on a field the allow-list does not have. In MySQL's
dialect it is refused. NewLexer reads strings as before; the parser
builds its lexer for the dialect.

An ESCAPE string is measured in characters, a doubled quote counted
once in every dialect and a backslash pair once in MySQL's: MySQL's
ESCAPE '\\' is one backslash, and four single quotes are one quote, where
they were counted as empty.

docs/dialects.md has MySQL in every table and diagram, a section on how
a string is read, and its refusals, held to Parse by the docs test, which
now reads one table per dialect. Every other page names MySQL 8.4.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@christiangda christiangda self-assigned this Oct 24, 2026
@christiangda
christiangda merged commit 83d8945 into main Oct 24, 2026
1 check passed
@christiangda
christiangda deleted the feat/the-mysql-dialect branch October 24, 2026 18:25
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