feat: add WithPlaceholderStyle for callers that rebind placeholders - #192
Merged
Merged
Conversation
Each dialect renders its native placeholder: $1 for PostgreSQL and DuckDB, @p1 for BigQuery, ? for MySQL, SQLite and Spark. That leaves no way to serve a caller whose driver or query builder does the numbering itself. GORM, sqlx.Rebind and squirrel each number placeholders from their own running count across the whole statement and then rewrite them for the target driver. A fragment arriving already numbered is passed through untouched — GORM's clause.Expr.Build reacts only to ?, copying every other byte verbatim — so the $1 reaches PostgreSQL unbound. Until now such callers had to wrap the dialect to override WriteParamPlaceholder. WithPlaceholderStyle(PlaceholderQuestion) emits ? for every parameter and leaves the numbering to them. The style is orthogonal to the dialect: it changes only the placeholder syntax, never the SQL around it, and is a no-op for the three dialects whose native placeholder is already ?. Implemented as a converter field rather than a dialect decorator, on purpose. A struct embedding the Dialect interface has only that interface's method set, so a wrapper would not satisfy dialect.IndexAdvisor and AnalyzeQuery would silently stop recommending indexes. The five identical increment-write-append triples in visitConst collapse into one writeParamPlaceholder helper, so all five paths behave alike by construction. PlaceholderDialect is the default and output is unchanged for every existing caller — the existing parameterized tests pass with no expectation edits. Rewriting $n to ? after conversion is not a safe substitute: matches() inlines its pattern as a string literal, so matches('a$1b') puts a literal $1 in the SQL that such a rewrite would corrupt. Only the converter knows which $1 it wrote. One expression is rejected rather than silently mis-bound. PostgreSQL spells jsonb existence as the ? operator, so has() on a JSONB column emits a ? that a placeholder-scanning consumer cannot tell from a bind marker; it would bind a value to the operator and shift every parameter after it. Conversion now fails when the ? count exceeds the parameter count. Only PostgreSQL is affected — every other dialect writes JSON existence as a function call. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
Each dialect renders its native placeholder —
$1(PostgreSQL, DuckDB),?(MySQL, SQLite, Spark),@p1(BigQuery). There's no way to serve a caller whose driver or query builder does the numbering itself.GORM,
sqlx.Rebindand squirrel each number placeholders from their own running count across the whole statement, then rewrite them for the target driver. A fragment that arrives already numbered is passed through untouched — GORM'sclause.Expr.Buildreacts only to?and copies every other byte verbatim — so the$1reaches PostgreSQL unbound.Until now such callers had to reach into the dialect and wrap it:
What
The style is orthogonal to the dialect — it changes only the placeholder syntax, never the SQL around it — and is a no-op for the three dialects whose native placeholder is already
?.PlaceholderDialectis the default.Three decisions worth reviewing
1. A converter field, not a dialect decorator. The obvious implementation wraps the dialect. Don't: a struct embedding the
Dialectinterface has only that interface's method set, so it would not satisfydialect.IndexAdvisor, andAnalyzeQuerywould silently stop recommending indexes. The converter field sidesteps the type-identity problem entirely.2. It rejects one expression rather than silently mis-binding. PostgreSQL spells jsonb existence as the
?operator (dialect/postgres/dialect.go:220), sohas()on a JSONB column emits a?that a placeholder-scanning consumer cannot distinguish from a bind marker — it would bind a value to the operator and shift every parameter after it. Wrong rows, no error. Conversion now fails when the?count exceeds the parameter count.A survey of all six dialects confirms PostgreSQL is the only one affected — DuckDB, MySQL, SQLite, Spark and BigQuery all write JSON existence as a function call. Emitting
jsonb_exists(...)under this style would lift the restriction, but the operator form is what the planner reliably matches against a GIN index, so that's a performance trade rather than a free win. Recorded in the CHANGELOG as a known limitation.3. Why an option at all, rather than letting callers rewrite
$n→?.matches()inlines its pattern as a string literal, somatches('a$1b')puts a literal$1in the SQL. A\$\d+rewrite would corrupt the pattern and leave the parameter count wrong. Only the converter knows which$1it wrote. There's a test for exactly this.Refactor
The five identical increment-write-append triples in
visitConst(string/int64/uint64/double/bytes) collapse into onewriteParamPlaceholder()helper, so all five behave alike by construction.Backwards compatibility
PlaceholderDialectis the default and theDialectinterface is untouched — no change for the six dialects or any out-of-tree one. The existing parameterized tests pass with no expectation edits, which is the regression guard that matters here.Tests
New
placeholder_style_test.go, driving all six dialects: default output byte-identical;PlaceholderQuestionrenders?everywhere with parameters unchanged; no-op for the?-native dialects; placeholder count ==len(Parameters); interaction withWithParamStartIndex;Convertunaffected; thematches('a$1b')case; and the jsonb guard (fails under Question style, still succeeds under the default).Verification
make fmt,go build,go vetandgolangci-lint runall clean.go test -short ./...passes except the 10pg/testcontainer tests, which fail identically onmainlocally because Docker isn't running here — CI has Docker and will run them.🤖 Generated with Claude Code