Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changes/unreleased/Fixed-20260816-140000.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
kind: Fixed
body: 'A query containing `"""` no longer produces a module that does not parse. The SQL went into a `"""` literal, so three consecutive double quotes in the query - from a string literal, or from a quoted identifier ending in a quote - closed the constant early and left the rest of the file as stray syntax. The constant and the docstring now pick a delimiter the query does not contain. A query holding both `"""` and `''''''` has no block spelling left: the constant falls back to an escaped one-liner, and the docstring, which cannot be escaped without tripping ruff D301, leaves the SQL out.'
time: 2026-08-16T14:00:00.0000000Z
custom:
Author: Rayakame
PR: "263"
24 changes: 12 additions & 12 deletions internal/render/queries.go
Original file line number Diff line number Diff line change
Expand Up @@ -72,18 +72,18 @@ func (r *Renderer) renderQueriesModule(moduleName string, queries []model.Query)
constType = "typing.LiteralString"
}
for _, query := range queries {
constantsBody.WriteLine(
fmt.Sprintf(
`%s: typing.Final[%s] = %s"""-- name: %s %s`,
query.ConstantName,
constType,
writer.PyRawPrefix(query.SQL),
query.QueryName,
query.Cmd,
),
)
constantsBody.WriteLine(query.SQL)
constantsBody.WriteLine(`"""`)
assignment := fmt.Sprintf(`%s: typing.Final[%s] = `, query.ConstantName, constType)
header := fmt.Sprintf("-- name: %s %s", query.QueryName, query.Cmd)
if literal, ok := writer.PyTripleQuotedFor(query.SQL); ok {
constantsBody.WriteLine(assignment + literal.Prefix + literal.Delimiter + header)
constantsBody.WriteLine(query.SQL)
constantsBody.WriteLine(literal.Delimiter)
} else {
// SQL holding both triple quotes has no block spelling left. An
// escaped one-liner still carries it exactly; only the constant's
// resemblance to the query is lost.
constantsBody.WriteLine(assignment + writer.PyQuote(header+"\n"+query.SQL+"\n"))
}
constantsBody.NewLine()

if query.Returns.EmitTable {
Expand Down
67 changes: 67 additions & 0 deletions internal/render/render_queries_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,73 @@ UPDATE test_items SET path = 'C:\dir', tag = 'a\tb' WHERE path ~ '\d+' AND id =
"""


async def tag_item(conn: ConnectionLike, *, id_: int) -> None:
await conn.execute(TAG_ITEM, id_)
`,
},
{
// The literal cannot be delimited by a quote run it contains.
name: "a triple double quote switches the delimiter",
engine: "postgresql",
options: `{"package":"testpkg","sql_driver":"asyncpg","emit_init_file":false}`,
queries: []*plugin.Query{{
Name: "TagItem",
Cmd: metadata.CmdExec,
Text: `UPDATE test_items SET tag = 'a"""b' WHERE id = $1`,
Filename: "queries.sql",
Params: []*plugin.Parameter{pgParam(pgColumn("id", "int4", true))},
}},
want: sqlcFileHeader("queries.sql") + `from __future__ import annotations

__all__: collections.abc.Sequence[str] = ("tag_item",)

import typing

if typing.TYPE_CHECKING:
import asyncpg
import collections.abc

type ConnectionLike = asyncpg.Connection[asyncpg.Record] | asyncpg.pool.PoolConnectionProxy[asyncpg.Record]


TAG_ITEM: typing.Final[str] = '''-- name: TagItem :exec
UPDATE test_items SET tag = 'a"""b' WHERE id = $1
'''


async def tag_item(conn: ConnectionLike, *, id_: int) -> None:
await conn.execute(TAG_ITEM, id_)
`,
},
{
// Holding both quote runs leaves no block spelling, so the
// constant falls back to an escaped one-liner.
name: "both triple quotes fall back to an escaped literal",
engine: "postgresql",
options: `{"package":"testpkg","sql_driver":"asyncpg","emit_init_file":false}`,
queries: []*plugin.Query{{
Name: "TagItem",
Cmd: metadata.CmdExec,
Text: `UPDATE test_items SET tag = 'a"""b' || 'c''''''d' WHERE id = $1`,
Filename: "queries.sql",
Params: []*plugin.Parameter{pgParam(pgColumn("id", "int4", true))},
}},
want: sqlcFileHeader("queries.sql") + `from __future__ import annotations

__all__: collections.abc.Sequence[str] = ("tag_item",)

import typing

if typing.TYPE_CHECKING:
import asyncpg
import collections.abc

type ConnectionLike = asyncpg.Connection[asyncpg.Record] | asyncpg.pool.PoolConnectionProxy[asyncpg.Record]


TAG_ITEM: typing.Final[str] = "-- name: TagItem :exec\nUPDATE test_items SET tag = 'a\"\"\"b' || 'c''''''d' WHERE id = $1\n"


async def tag_item(conn: ConnectionLike, *, id_: int) -> None:
await conn.execute(TAG_ITEM, id_)
`,
Expand Down
23 changes: 13 additions & 10 deletions internal/writer/docstrings.go
Original file line number Diff line number Diff line change
Expand Up @@ -461,14 +461,17 @@ func (w *CodeWriter) WriteQueryFunctionDocstring(lvl int, query *model.Query, co
return
}

// Only the embedded SQL can carry a backslash - the rest of the docstring
// is generated prose - and the prefix has to be written before it.
writeSQL := emitSQL && !w.docstringOmitSQL
rawPrefix := ""
if writeSQL {
rawPrefix = PyRawPrefix(query.SQL)
}
w.WriteIndentedLine(lvl, rawPrefix+`"""`+fmt.Sprintf(summaryFmt, query.QueryName, query.Cmd))
// Only the embedded SQL can carry a backslash or a triple quote - the rest
// of the docstring is generated prose - and how the docstring opens
// depends on both, so it is settled before the summary is written. SQL
// that no docstring can spell is left out of it; the constant still
// carries the query.
literal, canEmbed := PyTripleQuotedFor(query.SQL)
writeSQL := emitSQL && !w.docstringOmitSQL && canEmbed
if !writeSQL {
literal = PyTripleQuoted{Prefix: "", Delimiter: pyTripleQuote}
}
w.WriteIndentedLine(lvl, literal.Prefix+literal.Delimiter+fmt.Sprintf(summaryFmt, query.QueryName, query.Cmd))
w.NewLine()
if writeSQL {
w.WriteIndentedLine(lvl, "```sql")
Expand All @@ -491,7 +494,7 @@ func (w *CodeWriter) WriteQueryFunctionDocstring(lvl int, query *model.Query, co
if wroteArgs && w.docstringConvention == config.DocstringConventionNumpy {
w.NewLine()
}
w.WriteIndentedLine(lvl, `"""`)
w.WriteIndentedLine(lvl, literal.Delimiter)

return
}
Expand All @@ -500,7 +503,7 @@ func (w *CodeWriter) WriteQueryFunctionDocstring(lvl int, query *model.Query, co
w.NewLine()
}
w.writeDocReturnsSection(lvl, ret)
w.WriteIndentedLine(lvl, `"""`)
w.WriteIndentedLine(lvl, literal.Delimiter)
}

// writeDocArgsSection writes the Parameters/Args/Arguments section and reports
Expand Down
33 changes: 33 additions & 0 deletions internal/writer/docstrings_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -735,6 +735,39 @@ func TestWriteQueryFunctionDocstring(t *testing.T) {
` """`,
),
},
{
// The docstring cannot be delimited by a quote run the SQL holds.
name: "a triple double quote switches the delimiter",
conv: config.DocstringConventionGoogle,
write: func(w *writer.CodeWriter) {
query := &model.Query{Cmd: metadata.CmdExec, QueryName: "Tag", SQL: `UPDATE t SET tag = 'a"""b'`}
w.WriteQueryFunctionDocstring(1, query, "", nil, "")
},
want: lines(
" '''Execute SQL query with `name: Tag :exec`.",
``,
" ```sql",
` UPDATE t SET tag = 'a"""b'`,
" ```",
``,
` '''`,
),
},
{
// Holding both quote runs leaves no docstring spelling at all, and
// escaping is not open to one. The constant still carries the SQL.
name: "both triple quotes drop the sql block",
conv: config.DocstringConventionGoogle,
write: func(w *writer.CodeWriter) {
query := &model.Query{Cmd: metadata.CmdExec, QueryName: "Tag", SQL: `UPDATE t SET tag = 'a"""b' || 'c''''''d'`}
w.WriteQueryFunctionDocstring(1, query, "", nil, "")
},
want: lines(
" \"\"\"Execute SQL query with `name: Tag :exec`.",
``,
` """`,
),
},
{
// Without the SQL block nothing in the docstring can hold one.
name: "omitted sql needs no raw prefix",
Expand Down
49 changes: 37 additions & 12 deletions internal/writer/writer.go
Original file line number Diff line number Diff line change
Expand Up @@ -160,22 +160,47 @@ func (w *CodeWriter) indent(level int) string {
return strings.Repeat(w.indentChar, level*w.charsPerIndentLevel)
}

// PyRawPrefix returns the prefix a triple-quoted Python literal needs to carry
// text through verbatim: "r" when the text holds a backslash, "" otherwise.
// PyTripleQuoted is how to spell a triple-quoted Python literal that carries a
// block of text verbatim: the prefix and the delimiter that opens and closes
// it.
type PyTripleQuoted struct {
// Prefix is "r" when the text holds a backslash, "" otherwise. A plain
// literal reads "\t" as a tab and "\n" as a newline, so SQL carrying
// either reaches the server changed, and an escape Python does not know
// ("\d") is a SyntaxWarning today and a SyntaxError in a later version.
// Escaping is not an option for docstrings - ruff D301 wants the raw
// prefix whatever the backslash spells - so both emitters use this rule.
Prefix string
// Delimiter is the triple quote the text does not contain itself.
Delimiter string
}

// pyTripleQuote is what a triple-quoted literal is delimited with unless its
// own text contains it; pyTripleQuotes lists both spellings in preference
// order.
const pyTripleQuote = `"""`

var pyTripleQuotes = []string{pyTripleQuote, `'''`} //nolint:gochecknoglobals

// PyTripleQuotedFor returns how to spell a literal holding text, and false
// when no triple-quoted literal can hold it - text containing both delimiters
// can only be escaped, which a docstring cannot be.
//
// A plain literal reads "\t" as a tab and "\n" as a newline, so SQL carrying
// either reaches the server changed, and an escape Python does not know ("\d")
// is a SyntaxWarning today and a SyntaxError in a later version. Escaping is
// not an option for docstrings - ruff D301 wants the raw prefix whatever the
// backslash spells - so both emitters use this one rule. It is only safe
// because they put the closing delimiter on its own line: a raw literal cannot
// end in a backslash.
func PyRawPrefix(text string) string {
// The spelling is only safe because callers put the closing delimiter on its
// own line: a raw literal cannot end in a backslash, and a quote right before
// the delimiter would extend it.
func PyTripleQuotedFor(text string) (PyTripleQuoted, bool) {
prefix := ""
if strings.ContainsRune(text, '\\') {
return "r"
prefix = "r"
}
for _, delimiter := range pyTripleQuotes {
if !strings.Contains(text, delimiter) {
return PyTripleQuoted{Prefix: prefix, Delimiter: delimiter}, true
}
}

return ""
return PyTripleQuoted{Prefix: "", Delimiter: ""}, false
}

// PyQuote returns a complete Python string literal. Go's Quote escaping is a
Expand Down
72 changes: 61 additions & 11 deletions internal/writer/writer_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -243,24 +243,74 @@ func TestWriteWrappedCall(t *testing.T) {
}
}

func TestPyRawPrefix(t *testing.T) {
func TestPyTripleQuotedFor(t *testing.T) {
t.Parallel()
cases := []struct {
name string
in string
want string
name string
in string
wantOK bool
want writer.PyTripleQuoted
}{
{name: "empty text", in: "", want: ""},
{name: "no backslash", in: "SELECT 1", want: ""},
{name: "escape python knows", in: `LIKE 'a\tb'`, want: "r"},
{name: "escape python does not know", in: `REGEXP '\d+'`, want: "r"},
{name: "doubled backslash", in: `LIKE 'C:\\n%'`, want: "r"},
{name: "empty text", in: "", wantOK: true, want: writer.PyTripleQuoted{Prefix: "", Delimiter: `"""`}},
{name: "plain text", in: "SELECT 1", wantOK: true, want: writer.PyTripleQuoted{Prefix: "", Delimiter: `"""`}},
{
name: "escape python knows",
in: `LIKE 'a\tb'`,
wantOK: true,
want: writer.PyTripleQuoted{Prefix: "r", Delimiter: `"""`},
},
{
name: "escape python does not know",
in: `REGEXP '\d+'`,
wantOK: true,
want: writer.PyTripleQuoted{Prefix: "r", Delimiter: `"""`},
},
{
name: "doubled backslash",
in: `LIKE 'C:\\n%'`,
wantOK: true,
want: writer.PyTripleQuoted{Prefix: "r", Delimiter: `"""`},
},
{
name: "two double quotes still fit",
in: `= 'a""b'`,
wantOK: true,
want: writer.PyTripleQuoted{Prefix: "", Delimiter: `"""`},
},
{
name: "triple double quote falls back",
in: `= 'a"""b'`,
wantOK: true,
want: writer.PyTripleQuoted{Prefix: "", Delimiter: "'''"},
},
{
name: "triple single quote keeps double",
in: `= "a'''b"`,
wantOK: true,
want: writer.PyTripleQuoted{Prefix: "", Delimiter: `"""`},
},
{
name: "backslash with a triple double quote",
in: `= 'a"""b\d'`,
wantOK: true,
want: writer.PyTripleQuoted{Prefix: "r", Delimiter: "'''"},
},
{
name: "both delimiters have no spelling",
in: `= 'a"""b' || 'c''''''d'`,
wantOK: false,
want: writer.PyTripleQuoted{Prefix: "", Delimiter: ""},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
if got := writer.PyRawPrefix(tc.in); got != tc.want {
t.Errorf("PyRawPrefix(%q) = %q, want %q", tc.in, got, tc.want)
got, ok := writer.PyTripleQuotedFor(tc.in)
if ok != tc.wantOK {
t.Fatalf("PyTripleQuotedFor(%q) ok = %v, want %v", tc.in, ok, tc.wantOK)
}
if got != tc.want {
t.Errorf("PyTripleQuotedFor(%q) = %+v, want %+v", tc.in, got, tc.want)
}
})
}
Expand Down
2 changes: 1 addition & 1 deletion sqlc.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ plugins:
- name: python
wasm:
url: file://sqlc-gen-better-python.wasm
sha256: 2bad9ba936a60fc014eb4503a300a3ebfcd79483bd215002a8d4d03f1b290201
sha256: ea40a229077a701acb2d5282e95d8dd7cf9759ca8fe2558fe7b2fe0062f63627
sql:
- schema: test/schema.sql
queries: test/queries.sql
Expand Down
Binary file modified test/driver_aiosqlite/sqlc-gen-better-python.wasm
Binary file not shown.
2 changes: 1 addition & 1 deletion test/driver_aiosqlite/sqlc.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ plugins:
- name: python
wasm:
url: file://sqlc-gen-better-python.wasm
sha256: 2bad9ba936a60fc014eb4503a300a3ebfcd79483bd215002a8d4d03f1b290201
sha256: ea40a229077a701acb2d5282e95d8dd7cf9759ca8fe2558fe7b2fe0062f63627
sql:
- schema: schema.sql
queries: queries.sql
Expand Down
Binary file modified test/driver_asyncmy/sqlc-gen-better-python.wasm
Binary file not shown.
2 changes: 1 addition & 1 deletion test/driver_asyncmy/sqlc.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ plugins:
- name: python
wasm:
url: file://sqlc-gen-better-python.wasm
sha256: 2bad9ba936a60fc014eb4503a300a3ebfcd79483bd215002a8d4d03f1b290201
sha256: ea40a229077a701acb2d5282e95d8dd7cf9759ca8fe2558fe7b2fe0062f63627
sql:
- schema: schema.sql
queries:
Expand Down
Binary file modified test/driver_asyncpg/sqlc-gen-better-python.wasm
Binary file not shown.
2 changes: 1 addition & 1 deletion test/driver_asyncpg/sqlc.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ plugins:
- name: python
wasm:
url: file://sqlc-gen-better-python.wasm
sha256: 2bad9ba936a60fc014eb4503a300a3ebfcd79483bd215002a8d4d03f1b290201
sha256: ea40a229077a701acb2d5282e95d8dd7cf9759ca8fe2558fe7b2fe0062f63627
sql:
- schema: schema.sql
queries:
Expand Down
Binary file modified test/driver_psycopg_async/sqlc-gen-better-python.wasm
Binary file not shown.
2 changes: 1 addition & 1 deletion test/driver_psycopg_async/sqlc.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ plugins:
- name: python
wasm:
url: file://sqlc-gen-better-python.wasm
sha256: 2bad9ba936a60fc014eb4503a300a3ebfcd79483bd215002a8d4d03f1b290201
sha256: ea40a229077a701acb2d5282e95d8dd7cf9759ca8fe2558fe7b2fe0062f63627
sql:
- schema: schema.sql
queries:
Expand Down
Binary file modified test/driver_psycopg_sync/sqlc-gen-better-python.wasm
Binary file not shown.
2 changes: 1 addition & 1 deletion test/driver_psycopg_sync/sqlc.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ plugins:
- name: python
wasm:
url: file://sqlc-gen-better-python.wasm
sha256: 2bad9ba936a60fc014eb4503a300a3ebfcd79483bd215002a8d4d03f1b290201
sha256: ea40a229077a701acb2d5282e95d8dd7cf9759ca8fe2558fe7b2fe0062f63627
sql:
- schema: schema.sql
queries:
Expand Down
Binary file modified test/driver_pymysql/sqlc-gen-better-python.wasm
Binary file not shown.
Loading