Add bindsyntax: a seam for a non-two-way bind spelling - #29
Conversation
Codecov Report❌ Patch coverage is
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. 🚀 New features to boost your workflow:
|
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>
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>
|
Closing unmerged. The measurements hold; the placement was wrong.
Why this does not belong in bisqlThe mode cost 422 lines added to bisql's core (~1480 lines), and every one of them Worse, the last commit put knowledge of sqlc into bisql: that sqlc does not And it dilutes the definition in CLAUDE.md. bisql is a two-way SQL template engine; Where it goes
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 What carries overEverything measured against sqlc v1.31.1, which is the durable part of this branch:
🤖 Generated with Claude Code |
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 sqlcneeds 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 commentsunder any bind spelling, so the structure half needs no change at all.
WithBindSyntax(bindsyntax.SqlcNamed)trades the runnable-as-is property, forvalues only, to get the typing back.
sqlc.arg(name)'c.name')sqlc.narg(name)sqlc.slice(name)@namesqlc.arg(name)— except under MySQLThe constant is
SqlcNamed, notNamed: this is not a generic named-parameterform 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:
@nameis not a bind under MySQL, where it is a user variable and sqlc doesnot support the shortcut. bisql was binding it anyway, so
select @row_number := @row_number + 1rendered asselect ? := ? + 1with nilarguments. 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 andaccepted 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.
@c.nameboundcand rendered$1.name, without complaint. sqlc reads it thesame 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 —
@statuswould otherwise lex as a single word.Recognition of
@requires that what follows can start an identifier, so@>and@@versionare 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 parenswritten — 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 canvouch 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 numberingstays one renderer-global counter, which is precisely what lets a branch-dependent
parameter set number without gaps:
No SQL grammar parsing either: the new code scans identifiers, not syntax.
Verification
mise run checkclean: fmt, build, vet, golangci-lint (0 issues), deadcode,go test -race. Tests cover the lexer (recognition and its boundaries, linetracking 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