Skip to content

feat: add WithPlaceholderStyle for callers that rebind placeholders - #192

Merged
richardwooding merged 1 commit into
mainfrom
feat/placeholder-style
Sep 9, 2026
Merged

richardwooding merged 1 commit into
mainfrom
feat/placeholder-style

Conversation

@richardwooding

Copy link
Copy Markdown
Contributor

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.Rebind and 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's clause.Expr.Build reacts only to ? and copies every other byte verbatim — so the $1 reaches PostgreSQL unbound.

Until now such callers had to reach into the dialect and wrap it:

// what a downstream consumer is currently forced to write
type qmarkDialect struct{ dialect.Dialect }
func (qmarkDialect) WriteParamPlaceholder(w *strings.Builder, _ int) { w.WriteByte('?') }

What

result, err := cel2sql.ConvertParameterized(ast,
    cel2sql.WithPlaceholderStyle(cel2sql.PlaceholderQuestion))
// result.SQL: "name = ? AND age > ?"

db.Where(result.SQL, result.Parameters...).Find(&users)  // GORM

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 ?. PlaceholderDialect is 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 Dialect interface has only that interface's method set, so it would not satisfy dialect.IndexAdvisor, and AnalyzeQuery would 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), so has() 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, so matches('a$1b') puts a literal $1 in the SQL. A \$\d+ rewrite would corrupt the pattern and leave the parameter count wrong. Only the converter knows which $1 it 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 one writeParamPlaceholder() helper, so all five behave alike by construction.

Backwards compatibility

PlaceholderDialect is the default and the Dialect interface 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; PlaceholderQuestion renders ? everywhere with parameters unchanged; no-op for the ?-native dialects; placeholder count == len(Parameters); interaction with WithParamStartIndex; Convert unaffected; the matches('a$1b') case; and the jsonb guard (fails under Question style, still succeeds under the default).

Verification

make fmt, go build, go vet and golangci-lint run all clean. go test -short ./... passes except the 10 pg/ testcontainer tests, which fail identically on main locally because Docker isn't running here — CI has Docker and will run them.

🤖 Generated with Claude Code

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>
@richardwooding
richardwooding merged commit 455f070 into main Sep 9, 2026
9 checks passed
@richardwooding
richardwooding deleted the feat/placeholder-style branch September 9, 2026 16:12
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