Skip to content

fix(cli): quote identifiers in generated migrations - #29

Open
xico42 wants to merge 2 commits into
thecodearcher:masterfrom
xico42:fix/quote-generated-ddl-identifiers
Open

fix(cli): quote identifiers in generated migrations#29
xico42 wants to merge 2 commits into
thecodearcher:masterfrom
xico42:fix/quote-generated-ddl-identifiers

Conversation

@xico42

@xico42 xico42 commented Aug 8, 2026

Copy link
Copy Markdown

The problem

cmd/limen's migration generator interpolates every identifier with %s. Table, column, index, constraint and referenced-table names all reach the emitted DDL unquoted — there is no quoting helper anywhere in the module.

The default schema names (users, sessions, accounts, ...) are lowercase and non-reserved, which is why this has gone unnoticed. It is reachable only through WithUserTableName and its siblings, where a caller naturally picks a singular name to match their project's convention:

limen.WithSchemaUser(limen.WithUserTableName("user"))

user is reserved in PostgreSQL (pg_get_keywords() classes it R) and in MySQL, so the generated migration is:

CREATE TABLE IF NOT EXISTS user (
  id BIGSERIAL,
  ...
ERROR:  syntax error at or near "user"

The migration cannot be applied at all, and there is no way to work around it short of hand-editing generated SQL or picking a different table name.

The quieter half

adapters/sql quotes every identifier it emits (quoteIdent, which also doubles embedded quote chars). So unquoted DDL does not merely look different from what the adapter expects — it creates a different name.

A table configured as AppUser is folded to appuser by the database, while the adapter goes on asking for "AppUser". The migration succeeds, the service starts, and every query fails against a relation that does not exist. That failure mode is strictly worse than the reserved-word one, because nothing fails at migration time.

The fix

QuoteIdentifier(name string) string on the Driver interface, wired through the eleven interpolation sites in migration_generator.go and the five Drop*SQL helpers. joinCustomStringSlice is replaced by a quoteAll that quotes each element — it existed only to build unquoted column lists.

Quoting belongs to the driver rather than to a shared helper, since the dialects disagree on the quote character:

  • PostgreSQL defers to pgx.Identifier{name}.Sanitize(). pgx is already this driver's connection library and needs no new dependency (go.mod/go.sum are unchanged); beyond doubling embedded quotes it also strips NUL bytes, which PostgreSQL rejects outright.
  • MySQL/MariaDB double backticks by hand, because go-sql-driver/mysql exports no equivalent. Backticks are accepted in every sql_mode, including ANSI_QUOTES.

Neither implementation lives on baseDriver: a method there cannot reach the outer type's QuoteIdentifier, since Go embedding does not dispatch back, so PostgreSQL would silently fall back to the generic implementation.

Second commit: unterminated DROP INDEX

Found while testing the above. A down migration for a table that already exists emitted the index drops and the ALTER TABLE joined by newlines alone:

DROP INDEX IF EXISTS "idx_user_order"
ALTER TABLE "user"
DROP CONSTRAINT "fk_user_order",
DROP COLUMN "order";
ERROR:  syntax error at or near "ALTER"

DropColumnSQL and DropForeignKeySQL are fragments of that ALTER TABLE and correctly carry no terminator, but DropIndexSQL is a statement in its own right. Reachable whenever a diff adds an index alongside a column or foreign key — an ordinary schema change on a live table. It costs nothing until someone rolls back, which is the worst moment to discover a migration does not parse.

The semicolon is added at the call site rather than inside DropIndexSQL, so the driver methods stay uniformly fragment-shaped.

Tests

migration_generator.go had no test coverage at all — the four existing tests in cmd/limen all exercise migration.go. More significantly, the two halves of the library were never tested together: adapters/sql/adapter_test.go builds its fixture tables from hand-written, correctly quoted DDL, so the generator's output never reaches the adapter that must later query it. That seam is where this bug lived.

19 tests added, pinning every affected statement for both drivers using reserved words throughout (user, order, select, index, table). All were written failing first against the unfixed generator.

Verification

Beyond the unit tests, I executed the generated DDL against real engines using those same reserved names:

Engine Result
PostgreSQL 18.4 DDL applies; insert/select/count round-trip through the exact SQL adapters/sql emits
MySQL 8.4 DDL applies; SHOW CREATE TABLE confirms names stored verbatim; round-trip passes
MariaDB 11.8 DDL applies; round-trip passes

Also confirmed backticks survive sql_mode='ANSI_QUOTES', and that the full workspace suite passes under CI's own go test work across all modules.

Compatibility

  • All-lowercase names: quoted and unquoted are the same identifier, so existing users see no change in behaviour. Regenerating existing migrations produces textually different files with an identical resulting catalog — I verified this by applying both and diffing information_schema.columns.
  • Mixed-case names: already broken before this change (unquoted DDL folded to lowercase while the adapter queried quoted), so this fixes them rather than breaking them.
  • Dotted names such as WithUserTableName("public.users") previously worked as schema-qualified and now quote to a single identifier. This matches adapters/sql, which would have quoted it the same way and failed at runtime regardless — the generator and the adapter now agree, which is the point.

SQLite is unaffected: adapters/sql supports it at runtime but the CLI registers no SQLite driver, so it generates no DDL.

xico42 and others added 2 commits August 8, 2026 19:14
The migration generator interpolated every identifier with %s, so table,
column, index, constraint and referenced-table names all reached the
emitted DDL unquoted. The default schema names (`users`, `sessions`, ...)
are lowercase and non-reserved, which is why this went unnoticed; it is
reachable only through WithUserTableName and its siblings, where a caller
naturally picks names like `user` or `order`. Both are reserved in
PostgreSQL and MySQL, so `CREATE TABLE IF NOT EXISTS user` is a syntax
error and the migration cannot be applied at all.

The quieter half of the bug is worse. adapters/sql quotes every identifier
it emits, so unquoted DDL does not merely look different — it creates a
different name. A table configured as `AppUser` is folded to `appuser` by
the database, while the adapter goes on asking for "AppUser": the
migration succeeds, the service starts, and every query fails against a
relation that does not exist.

Quoting therefore belongs to the driver rather than to a shared helper,
since PostgreSQL and MySQL disagree on the quote character. PostgreSQL
defers to pgx's Identifier.Sanitize — already a dependency here, and it
strips NUL bytes on top of doubling embedded quotes. MySQL and MariaDB
double backticks by hand, because go-sql-driver/mysql exports no
equivalent. Neither lives on baseDriver: a method there could not reach
the outer type's implementation, so PostgreSQL would silently fall back to
the generic one.

The generator had no tests at all, and the two halves of the library were
never exercised together — adapters/sql builds its fixture tables from
hand-written, correctly quoted DDL, so the generator's output never
reached the adapter that must later query it. The tests added here pin
every affected statement for both drivers, using reserved words
throughout.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A down migration for a table that already exists emitted the index drops
and the ALTER TABLE joined by newlines alone. DropColumnSQL and
DropForeignKeySQL are fragments of that ALTER TABLE and correctly carry no
terminator, but DropIndexSQL is a statement in its own right, so the two
ran together as `DROP INDEX IF EXISTS "x" ALTER TABLE "y" ...` — rejected
outright with a syntax error at or near "ALTER".

Reachable whenever a diff adds an index alongside a column or a foreign
key, which is what an ordinary schema change on a live table looks like.
It cost nothing until someone rolled back, which is the worst moment to
discover a migration does not parse.

The semicolon is added at the call site rather than inside DropIndexSQL,
so the driver methods stay uniformly fragment-shaped and only the code
assembling statements decides how they are separated.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@xico42
xico42 force-pushed the fix/quote-generated-ddl-identifiers branch from c23007d to 462fd34 Compare August 8, 2026 22:14
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