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-130000.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
kind: Fixed
body: 'A backslash in a query now survives into the generated SQL constant. The SQL was written into a plain Python string literal, so Python read every escape it recognised: `LIKE ''a\tb''` reached the server with a tab, `''C:\name''` with a newline, and an escape Python does not know (`REGEXP ''\d+''`) raised a SyntaxWarning that is slated to become a SyntaxError. Query text holding a backslash - and the docstring that repeats it - is now emitted as a raw literal. Affects every driver.'
time: 2026-08-16T13:00:00.0000000Z
custom:
Author: Rayakame
PR: "262"
10 changes: 9 additions & 1 deletion internal/render/queries.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"github.com/rayakame/sqlc-gen-better-python/internal/model"
"github.com/rayakame/sqlc-gen-better-python/internal/types"
"github.com/rayakame/sqlc-gen-better-python/internal/utils"
"github.com/rayakame/sqlc-gen-better-python/internal/writer"
"github.com/sqlc-dev/plugin-sdk-go/metadata"
"github.com/sqlc-dev/plugin-sdk-go/plugin"
)
Expand Down Expand Up @@ -72,7 +73,14 @@ func (r *Renderer) renderQueriesModule(moduleName string, queries []model.Query)
}
for _, query := range queries {
constantsBody.WriteLine(
fmt.Sprintf(`%s: typing.Final[%s] = """-- name: %s %s`, query.ConstantName, constType, query.QueryName, query.Cmd),
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(`"""`)
Expand Down
35 changes: 35 additions & 0 deletions internal/render/render_queries_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,41 @@ INSERT INTO test_items (id) VALUES ($1)

async def insert_item(conn: ConnectionLike, *, id_: int) -> None:
await conn.execute(INSERT_ITEM, id_)
`,
},
{
// A plain literal would read "\d" as an unknown escape and "\t" as
// a tab, so SQL holding a backslash is emitted raw.
name: "backslashes make the constant a raw 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 path = 'C:\dir', tag = 'a\tb' WHERE path ~ '\d+' AND 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] = r"""-- name: TagItem :exec
UPDATE test_items SET path = 'C:\dir', tag = 'a\tb' WHERE path ~ '\d+' AND id = $1
"""


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

w.WriteIndentedLine(lvl, `"""`+fmt.Sprintf(summaryFmt, query.QueryName, query.Cmd))
// 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))
w.NewLine()
if emitSQL && !w.docstringOmitSQL {
if writeSQL {
w.WriteIndentedLine(lvl, "```sql")
for _, line := range strings.Split(strings.ReplaceAll(query.SQL, "\r\n", "\n"), "\n") {
// Never write indentation-only lines (ruff W293).
Expand Down
34 changes: 34 additions & 0 deletions internal/writer/docstrings_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -716,6 +716,40 @@ func TestWriteQueryFunctionDocstring(t *testing.T) {
` """`,
),
},
{
// ruff D301 wants the raw prefix on any docstring holding a
// backslash, escaped or not.
name: "backslash in the sql makes the docstring raw",
conv: config.DocstringConventionGoogle,
write: func(w *writer.CodeWriter) {
query := &model.Query{Cmd: metadata.CmdExec, QueryName: "Tag", SQL: `UPDATE t SET tag = 'a\tb' WHERE p ~ '\d+'`}
w.WriteQueryFunctionDocstring(1, query, "", nil, "")
},
want: lines(
" r\"\"\"Execute SQL query with `name: Tag :exec`.",
``,
" ```sql",
` UPDATE t SET tag = 'a\tb' WHERE p ~ '\d+'`,
" ```",
``,
` """`,
),
},
{
// Without the SQL block nothing in the docstring can hold one.
name: "omitted sql needs no raw prefix",
conv: config.DocstringConventionGoogle,
omitSQL: true,
write: func(w *writer.CodeWriter) {
query := &model.Query{Cmd: metadata.CmdExec, QueryName: "Tag", SQL: `UPDATE t SET tag = 'a\tb'`}
w.WriteQueryFunctionDocstring(1, query, "", nil, "")
},
want: lines(
" \"\"\"Execute SQL query with `name: Tag :exec`.",
``,
` """`,
),
},
{
name: "exec google without conn and args",
conv: config.DocstringConventionGoogle,
Expand Down
18 changes: 18 additions & 0 deletions internal/writer/writer.go
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,24 @@ 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.
//
// 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 {
if strings.ContainsRune(text, '\\') {
return "r"
}

return ""
}

// PyQuote returns a complete Python string literal. Go's Quote escaping is a
// compatible subset of Python's; outer quotes flip to single when that
// avoids escaping inner double quotes (ruff Q003).
Expand Down
23 changes: 23 additions & 0 deletions internal/writer/writer_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,29 @@ func TestWriteWrappedCall(t *testing.T) {
}
}

func TestPyRawPrefix(t *testing.T) {
t.Parallel()
cases := []struct {
name string
in string
want string
}{
{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"},
}
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)
}
})
}
}

func TestPyQuote(t *testing.T) {
t.Parallel()
cases := []struct {
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: f9e26e327ba75ccd648dea1a55b9cbecbf6586686b5f7329dc15871b0b3fdc77
sha256: 2bad9ba936a60fc014eb4503a300a3ebfcd79483bd215002a8d4d03f1b290201
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: f9e26e327ba75ccd648dea1a55b9cbecbf6586686b5f7329dc15871b0b3fdc77
sha256: 2bad9ba936a60fc014eb4503a300a3ebfcd79483bd215002a8d4d03f1b290201
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: f9e26e327ba75ccd648dea1a55b9cbecbf6586686b5f7329dc15871b0b3fdc77
sha256: 2bad9ba936a60fc014eb4503a300a3ebfcd79483bd215002a8d4d03f1b290201
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: f9e26e327ba75ccd648dea1a55b9cbecbf6586686b5f7329dc15871b0b3fdc77
sha256: 2bad9ba936a60fc014eb4503a300a3ebfcd79483bd215002a8d4d03f1b290201
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: f9e26e327ba75ccd648dea1a55b9cbecbf6586686b5f7329dc15871b0b3fdc77
sha256: 2bad9ba936a60fc014eb4503a300a3ebfcd79483bd215002a8d4d03f1b290201
sql:
- schema: schema.sql
queries:
Expand Down
74 changes: 74 additions & 0 deletions test/driver_psycopg_sync/dataclass/functions/queries_backslash.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
# Code generated by sqlc. DO NOT EDIT.
# versions:
# sqlc v1.31.1
# sqlc-gen-better-python v0.8.0
# source file: queries_backslash.sql
"""Module containing queries from file queries_backslash.sql."""

from __future__ import annotations

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

import typing

if typing.TYPE_CHECKING:
import collections.abc
import psycopg
import psycopg.rows

type ConnectionLike = psycopg.Connection[psycopg.rows.TupleRow]


GET_BACKSLASH_PATTERN: typing.Final[typing.LiteralString] = r"""-- name: GetBackslashPattern :one

SELECT 'a\tb\d+'::text AS pattern
"""

MATCH_BACKSLASH_PATH: typing.Final[typing.LiteralString] = r"""-- name: MatchBackslashPath :one
SELECT 'C:\dir\name'::text = %(p1)s::text AS matches
"""


def get_backslash_pattern(conn: ConnectionLike) -> str | None:
r"""Fetch one from the db using the SQL query with `name: GetBackslashPattern :one`.

```sql

SELECT 'a\tb\d+'::text AS pattern
```

Args:
conn:
Connection object of type `ConnectionLike` used to execute the query.

Returns:
Result of type `str` fetched from the db. Will be `None` if not found.
"""
row = conn.execute(GET_BACKSLASH_PATTERN).fetchone()
if row is None:
return None
return row[0]


def match_backslash_path(conn: ConnectionLike, *, probe: str) -> bool | None:
r"""Fetch one from the db using the SQL query with `name: MatchBackslashPath :one`.

```sql
SELECT 'C:\dir\name'::text = %(p1)s::text AS matches
```

Args:
conn:
Connection object of type `ConnectionLike` used to execute the query.
probe: str.

Returns:
Result of type `bool` fetched from the db. Will be `None` if not found.
"""
row = conn.execute(MATCH_BACKSLASH_PATH, {"p1": probe}).fetchone()
if row is None:
return None
return row[0]
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
from test.driver_psycopg_sync.dataclass.functions import enums
from test.driver_psycopg_sync.dataclass.functions import models
from test.driver_psycopg_sync.dataclass.functions import queries
from test.driver_psycopg_sync.dataclass.functions import queries_backslash
from test.driver_psycopg_sync.dataclass.functions import queries_converters
from test.driver_psycopg_sync.dataclass.functions import queries_enum_override
from test.driver_psycopg_sync.dataclass.functions import queries_invalid_identifiers
Expand Down Expand Up @@ -959,3 +960,17 @@ def test_find_converter_array_by_labels(self, psycopg_sync_conn: psycopg.Connect
dollar_1=[pathlib.PurePosixPath("a/b"), pathlib.PurePosixPath("c/d")],
)()
assert rows == [CONVERTER_ARRAY_ID]

@pytest.mark.dependency(name="TestDataclassFunctions::backslash_sql")
def test_backslash_sql(self, psycopg_sync_conn: psycopg.Connection[psycopg.rows.TupleRow]) -> None:
# A plain Python literal would read the "\t" as a tab and the "\n" of
# the path as a newline, so the constant has to be a raw string for the
# backslashes to reach the server.
assert queries_backslash.get_backslash_pattern(conn=psycopg_sync_conn) == "a\\tb\\d+"
assert queries_backslash.match_backslash_path(conn=psycopg_sync_conn, probe="C:\\dir\\name") is True
assert queries_backslash.match_backslash_path(conn=psycopg_sync_conn, probe="C:/dir/name") is False

def test_backslash_sql_no_row(self) -> None:
conn = typing.cast("psycopg.Connection[psycopg.rows.TupleRow]", NoRowConn())
assert queries_backslash.get_backslash_pattern(conn=conn) is None
assert queries_backslash.match_backslash_path(conn=conn, probe="C:\\dir\\name") is None
9 changes: 9 additions & 0 deletions test/driver_psycopg_sync/queries_backslash.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
-- A backslash in the SQL has to survive into the generated Python constant.
-- PostgreSQL string literals are standard-conforming, so a backslash below is
-- an ordinary character.

-- name: GetBackslashPattern :one
SELECT 'a\tb\d+'::text AS pattern;

-- name: MatchBackslashPath :one
SELECT 'C:\dir\name'::text = sqlc.arg(probe)::text AS matches;
Binary file modified test/driver_psycopg_sync/sqlc-gen-better-python.wasm
Binary file not shown.
3 changes: 2 additions & 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: f9e26e327ba75ccd648dea1a55b9cbecbf6586686b5f7329dc15871b0b3fdc77
sha256: 2bad9ba936a60fc014eb4503a300a3ebfcd79483bd215002a8d4d03f1b290201
sql:
- schema: schema.sql
queries:
Expand Down Expand Up @@ -108,6 +108,7 @@ sql:
- queries_field_namings.sql
- queries_invalid_identifiers.sql
- queries_converters.sql
- queries_backslash.sql
engine: postgresql
codegen:
- out: /dataclass/functions
Expand Down
Loading