Skip to content

Add bindsyntax: a seam for a non-two-way bind spelling - #29

Closed
mpyw wants to merge 5 commits into
mainfrom
feat/bindsyntax
Closed

Add bindsyntax: a seam for a non-two-way bind spelling#29
mpyw wants to merge 5 commits into
mainfrom
feat/bindsyntax

Conversation

@mpyw

@mpyw mpyw commented Aug 20, 2026

Copy link
Copy Markdown
Owner

Draft. Five commits: the seam, the implementation, and three corrections found by
measuring sqlc rather than reasoning about it.

Why

Two-way binding needs a literal at the bind site — that is what makes
/*status*/'active' run unmodified in a client. A static analyzer such as sqlc
needs a marker there instead, or it sees a constant. No single text is both.

That is why a two-way template can be fully checked by sqlc — SQL, catalog, result
columns — while its arguments stay invisible. The bind syntax is the only thing
standing in the way, and it is separable: /*%if*/ and /*%for*/ are comments
under any bind spelling, so the structure half needs no change at all.

WithBindSyntax(bindsyntax.SqlcNamed) trades the runnable-as-is property, for
values only, to get the typing back.

Form Binds
sqlc.arg(name) one parameter; quoting the name is what allows a dot ('c.name')
sqlc.narg(name) one parameter; identical at build time, the distinction is for the analyzer
sqlc.slice(name) a placeholder list, with the parens left to the template
@name a shortcut for sqlc.arg(name) — except under MySQL

The constant is SqlcNamed, not Named: this is not a generic named-parameter
form but one tool's spelling, down to which call wrappers exist, what each promises,
and which of them a given engine supports.

The governing rule

bisql has to recognize exactly what sqlc recognizes. A spelling one of them
binds and the other does not is the single failure this arrangement exists to
avoid — sqlc would type a parameter that never gets bound, or reject a template
bisql renders fine. Every correction in this branch is an instance of that rule,
and each was found by running sqlc, not by reading its docs:

  • @name is not a bind under MySQL, where it is a user variable and sqlc does
    not support the shortcut. bisql was binding it anyway, so
    select @row_number := @row_number + 1 rendered as select ? := ? + 1 with nil
    arguments. Whether the shortcut applies now depends on the dialect, as it does
    for sqlc.
  • sqlc.arg(name) with a bare name is sqlc's own documented spelling and
    accepted by every engine; bisql rejected it as malformed. Accepted now, with the
    unquoted form restricted to a bare identifier because sqlc rejects an unquoted
    dotted name too.
  • A prefix that could only have been a marker but cannot be one is rejected.
    @c.name bound c and rendered $1.name, without complaint. sqlc reads it the
    same way and then rejects the edited query for being invalid SQL — but that
    second step is exactly what a renderer does not have, so the spelling has to be
    refused up front.

Other implementation decisions

A bind marker is opaque text, not a comment, so it has to be recognized before
the surrounding word absorbs it — @status would otherwise lex as a single word.
Recognition of @ requires that what follows can start an identifier, so @> and
@@version are names under no dialect.

Who owns the parentheses differs, and it has to. A parenthesized test literal
is the parentheses, so the renderer replaces them. A template written for sqlc
has to be valid SQL before rendering, and in (sqlc.slice(ids)) needs its parens
written — emitting another pair would double them.

The two forms that read a test literal are rejected, not reinterpreted: the
two-way directive, which would degrade into a comment followed by a literal; and
/*^ */, which inlines its value as text, so an analyzer sees a constant and can
vouch for nothing. The alternatives are a real parameter, or a whitelisted
/*%if*/ toggle for an identifier or a sort direction.

sqlc.embed(table) needs no work. It is a result-column construct, not a bind,
and stays opaque. Worth recording that sqlc expands it into an explicit column list
in the SQL it hands back, so a renderer fed that SQL never meets the call — one fed
the original template would, and would send it to the database.

What is untouched

Block directives, @include, and every design invariant. Placeholder numbering
stays one renderer-global counter, which is precisely what lets a branch-dependent
parameter set number without gaps:

/*%if activeOnly*/ and status = @status /*%end*/     -- not taken
/*%if minAge != null*/ and age >= @min_age /*%end*/  -- $1
/*%for kw in keywords*/ and name like @kw /*%end*/   -- $2, $3

No SQL grammar parsing either: the new code scans identifiers, not syntax.

Verification

mise run check clean: fmt, build, vet, golangci-lint (0 issues), deadcode,
go test -race. Tests cover the lexer (recognition and its boundaries, line
tracking across a multi-line marker), the parser (round-tripping Text() == src,
list expansion, the rejections), and the end-to-end build for every form and both
dialect rules. Every silent render the corrections remove was measured first, so
the tests assert against what the code did rather than what it was meant to do.

🤖 Generated with Claude Code

@codecov

codecov Bot commented Aug 20, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 94.50549% with 10 lines in your changes missing coverage. Please review.
✅ Project coverage is 96.12%. Comparing base (bc443d9) to head (05a9539).

Files with missing lines Patch % Lines
bindsyntax/bindsyntax.go 94.49% 6 Missing ⚠️
internal/sqltmpl/parser/parser.go 84.00% 2 Missing and 2 partials ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main      #29      +/-   ##
==========================================
- Coverage   96.52%   96.12%   -0.41%     
==========================================
  Files          11       12       +1     
  Lines         951     1109     +158     
==========================================
+ Hits          918     1066     +148     
- Misses         21       29       +8     
- Partials       12       14       +2     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Two-way binding needs a literal at the bind site so the template stays runnable;
a static analyzer needs a marker there so it can resolve the bind against a
catalog. No single text is both, which is the whole reason a two-way template's
arguments are invisible to a tool like sqlc even though its SQL and its result
columns are not.

bindsyntax names that choice and gives a lexer the recognizer for the other side
of it: @name, sqlc.arg('name'), sqlc.narg('name'), sqlc.slice('name'). The
alternative to TwoWay is called SqlcNamed rather than Named, because it is not a
generic named-parameter form — it is one tool's spelling, down to which call
wrappers exist and what each promises about nullability. Recognize looks only at
the prefix it is handed, so a caller that already tracks quotes and comments keeps
that tracking. Structure directives are unaffected by the choice — /*%if*/ and
/*%for*/ are comments under any bind syntax.

WithBindSyntax selects it, and Parse rejects anything but TwoWay for now: the
lexer still recognizes only the two-way form, so accepting SqlcNamed would read a
named template as opaque text with no binds at all. Failing is the better answer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@mpyw
mpyw force-pushed the feat/bindsyntax branch from 63fc7c1 to 44e1d04 Compare August 20, 2026 00:30
mpyw and others added 4 commits August 20, 2026 09:43
The lexer now recognizes a bind that is spelled in the SQL rather than in a
comment, so SqlcNamed stops being a rejection and starts being a mode.

A bind marker is opaque text, which means it has to be recognized before the
surrounding word absorbs it — @status would otherwise lex as one word. Recognition
is deliberately narrow: what follows @ must be able to start an identifier, so @>
and MySQL's @@Version stay operators, and only the three sqlc call forms are
matched, so an unrelated schema-qualified call stays opaque. Under the two-way
syntax none of this is looked at, and the same text remains ordinary SQL.

A bind that carries its own name has no test literal, so BindValue.Test is nil
there and ExpandList carries what a parenthesized test used to imply. The two
cases also differ in who owns the parentheses, and they have to: a parenthesized
test literal *is* the parentheses, so the renderer replaces them, while a template
written for sqlc has to be valid SQL before rendering — in (sqlc.slice('ids'))
needs its parens written — so emitting another pair would double them.

The two forms that read a test literal are rejected rather than reinterpreted. The
two-way directive would degrade into a comment followed by a literal, giving a
query that runs while ignoring a value. /*^ */ inlines its value as text, so an
analyzer reading the template sees a constant and can vouch for nothing about it;
the alternatives are a real parameter, or a whitelisted /*%if*/ toggle for an
identifier or a sort direction.

Everything else is untouched. Block directives are comments under either syntax,
@include runs before lexing, and placeholder numbering remains one
renderer-global counter — which is what lets a branch-dependent parameter set
number without gaps.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@c.name bound "c" and left ".name" behind, rendering as "$1.name" with the whole
value of c as the argument. sqlc.arg(x) matched nothing and was emitted verbatim,
becoming a call to a function that does not exist. Neither raised anything.

Both are mistakes with no valid reading, and this is the only place they can be
caught: bisql does not parse SQL as a grammar, so nothing downstream would notice.
sqlc makes the same reading of @c.name — it recognizes @c, substitutes, and then
rejects the edited query for being invalid SQL — but that second step is exactly
what a renderer does not have.

Malformed reports the reason and the lexer fails on it, which keeps Recognize
matching what sqlc recognizes rather than teaching it to disagree.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The note claimed that a bind under one syntax is opaque under the other, and that
this is what keeps @> and MySQL's @variables working. Three things wrong with it.

The symmetry does not hold. A named marker is opaque under the two-way syntax, but
a two-way directive under SqlcNamed is an error, not text.

The causation is invented. @> and @@Version survive under either syntax because
recognizing @name requires that what follows the @ can start an identifier — not
because of anything the two-way syntax does.

And a MySQL user variable does not survive. A single @ followed by a name is
exactly what a bind marker is, so SqlcNamed reads @row_number as a bind and
renders "select ? := ? + 1" with nil arguments. That is a real limitation, and the
note buried it inside a reassurance. It is inherited rather than chosen — sqlc
reads it the same way, so a template meant for sqlc could not use one regardless —
but it belongs in the open, with a test pinning it so a change has to be
deliberate.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two divergences, both measured against sqlc v1.31.1 rather than reasoned about.

sqlc does not support the @name shortcut for MySQL, where @name is a user
variable, and bisql was binding it anyway: "select @row_number := @row_number + 1"
rendered as "select ? := ? + 1" with nil arguments. So whether the shortcut is a
bind now depends on the dialect, as it does for sqlc. This is not a wart in the
lexer — a spelling one of them binds and the other does not is the single
divergence this whole arrangement exists to avoid, and it is worth carrying the
dialect into the rules to prevent.

The other direction was worse: sqlc.arg(name) with a bare name is sqlc's own
documented spelling and accepted by every engine, and bisql rejected it as
malformed. It is accepted now, and the unquoted form is restricted to a bare
identifier because sqlc rejects an unquoted dotted name too — a dotted name has
only the quoted spelling.

Recognize and Malformed move onto a Rules value that carries the resolved policy,
so the knowledge of which spellings exist stays in bindsyntax while the dialect
that decides it stays where dialects are known.

Also confirmed, and no work: sqlc.embed is a result-column construct rather than a
bind, and stays opaque. sqlc expands it into an explicit column list in the SQL it
returns, so a renderer fed that SQL never meets the call — one fed the original
template would, and would send it to the database.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@mpyw

mpyw commented Aug 20, 2026

Copy link
Copy Markdown
Owner Author

Closing unmerged. The measurements hold; the placement was wrong.

main never took any of this, so there is nothing to revert — the branch stays as
the record.

Why this does not belong in bisql

The mode cost 422 lines added to bisql's core (~1480 lines), and every one of them
is a conditional for the other product: Rules/AtForm, the two rejections,
token.NamedBind, BindValue.Test == nil as a second state, ExpandList, and the
split in visitBind over who owns the parentheses.

Worse, the last commit put knowledge of sqlc into bisql: that sqlc does not
support the @ shortcut for MySQL, hardcoded as a dialect-name comparison. That is
a fact about a tool bisql does not depend on, it is undocumented behaviour that
will drift, and bisql is the wrong place to keep it.

And it dilutes the definition in CLAUDE.md. bisql is a two-way SQL template engine;
this mode is explicitly not two-way, and it was accumulating carve-outs against
that identity rather than extending it.

Where it goes

mpyw/sqlc-gen-go-dynamic, as a sibling with its own directive engine. That engine
is not a fork of this one — it is a strict simplification, because the plugin needs
none of what makes this one complex:

Dropped Why
preprocess + include.go (247 lines) @include cannot work downstream of sqlc: the fragment is a comment to sqlc, so its binds are never typed
dialect.Literal / FormatLiteral exists for /*^ */, which that mode rejects
bindReducer, literalReducer, test-literal collection the two-way bind form only
Oracle, SQL Server sqlc has no such engine
Rules, the rejections, the parenthesis split unnecessary once there is one bind syntax

Roughly 1900 lines become under 1000, and none of them are branches on a mode.

The cost is a second lexer to maintain. The shared part — quote and comment
scanning, block nesting, verbatim emit against one global counter — is small and
stable, and the parts where bugs actually live are the parts that are not shared.
If the same bug ever has to be fixed twice, that is the signal to extract a shared
core; doing it now would mean committing to the internal AST as public API, which
CLAUDE.md deliberately avoids.

What carries over

Everything measured against sqlc v1.31.1, which is the durable part of this branch:

  • @name is a bind under PostgreSQL and SQLite but not MySQL, where it is a user
    variable.
  • A call argument may be bare or single-quoted, and only the quoted spelling may
    carry a dot.
  • @c.name and sqlc.arg(c.name) have to be refused up front: sqlc reads them the
    same way and then rejects the edited query, and a renderer has no such second
    step.
  • The template owns the parentheses around a placeholder list, since it had to be
    valid SQL before rendering.
  • /*^ */ has to be refused, because an analyzer sees a constant there and can
    vouch for nothing.

🤖 Generated with Claude Code

@mpyw mpyw closed this Aug 20, 2026
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