diff --git a/.changes/unreleased/Fixed-20260816-130000.yaml b/.changes/unreleased/Fixed-20260816-130000.yaml new file mode 100644 index 00000000..5e6e4e15 --- /dev/null +++ b/.changes/unreleased/Fixed-20260816-130000.yaml @@ -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" diff --git a/internal/render/queries.go b/internal/render/queries.go index 70bc6aec..a79fe8af 100644 --- a/internal/render/queries.go +++ b/internal/render/queries.go @@ -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" ) @@ -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(`"""`) diff --git a/internal/render/render_queries_test.go b/internal/render/render_queries_test.go index 3ab9729c..ec4fc046 100644 --- a/internal/render/render_queries_test.go +++ b/internal/render/render_queries_test.go @@ -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_) `, }, { diff --git a/internal/writer/docstrings.go b/internal/writer/docstrings.go index 53c298fe..1d9c994d 100644 --- a/internal/writer/docstrings.go +++ b/internal/writer/docstrings.go @@ -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). diff --git a/internal/writer/docstrings_test.go b/internal/writer/docstrings_test.go index 514973ab..07e2410c 100644 --- a/internal/writer/docstrings_test.go +++ b/internal/writer/docstrings_test.go @@ -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, diff --git a/internal/writer/writer.go b/internal/writer/writer.go index ee838764..be26333f 100644 --- a/internal/writer/writer.go +++ b/internal/writer/writer.go @@ -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). diff --git a/internal/writer/writer_test.go b/internal/writer/writer_test.go index b9c18767..5d5d2d1a 100644 --- a/internal/writer/writer_test.go +++ b/internal/writer/writer_test.go @@ -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 { diff --git a/sqlc.yaml b/sqlc.yaml index 0a56599a..7595a3ed 100644 --- a/sqlc.yaml +++ b/sqlc.yaml @@ -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 diff --git a/test/driver_aiosqlite/sqlc-gen-better-python.wasm b/test/driver_aiosqlite/sqlc-gen-better-python.wasm index e65aad36..cdd42a7d 100644 Binary files a/test/driver_aiosqlite/sqlc-gen-better-python.wasm and b/test/driver_aiosqlite/sqlc-gen-better-python.wasm differ diff --git a/test/driver_aiosqlite/sqlc.yaml b/test/driver_aiosqlite/sqlc.yaml index 38bf9327..bfe3f36b 100644 --- a/test/driver_aiosqlite/sqlc.yaml +++ b/test/driver_aiosqlite/sqlc.yaml @@ -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 diff --git a/test/driver_asyncmy/sqlc-gen-better-python.wasm b/test/driver_asyncmy/sqlc-gen-better-python.wasm index e65aad36..cdd42a7d 100755 Binary files a/test/driver_asyncmy/sqlc-gen-better-python.wasm and b/test/driver_asyncmy/sqlc-gen-better-python.wasm differ diff --git a/test/driver_asyncmy/sqlc.yaml b/test/driver_asyncmy/sqlc.yaml index 3357b788..413c4cd5 100644 --- a/test/driver_asyncmy/sqlc.yaml +++ b/test/driver_asyncmy/sqlc.yaml @@ -3,7 +3,7 @@ plugins: - name: python wasm: url: file://sqlc-gen-better-python.wasm - sha256: f9e26e327ba75ccd648dea1a55b9cbecbf6586686b5f7329dc15871b0b3fdc77 + sha256: 2bad9ba936a60fc014eb4503a300a3ebfcd79483bd215002a8d4d03f1b290201 sql: - schema: schema.sql queries: diff --git a/test/driver_asyncpg/sqlc-gen-better-python.wasm b/test/driver_asyncpg/sqlc-gen-better-python.wasm index e65aad36..cdd42a7d 100644 Binary files a/test/driver_asyncpg/sqlc-gen-better-python.wasm and b/test/driver_asyncpg/sqlc-gen-better-python.wasm differ diff --git a/test/driver_asyncpg/sqlc.yaml b/test/driver_asyncpg/sqlc.yaml index 85fdf4ea..440dcee3 100644 --- a/test/driver_asyncpg/sqlc.yaml +++ b/test/driver_asyncpg/sqlc.yaml @@ -3,7 +3,7 @@ plugins: - name: python wasm: url: file://sqlc-gen-better-python.wasm - sha256: f9e26e327ba75ccd648dea1a55b9cbecbf6586686b5f7329dc15871b0b3fdc77 + sha256: 2bad9ba936a60fc014eb4503a300a3ebfcd79483bd215002a8d4d03f1b290201 sql: - schema: schema.sql queries: diff --git a/test/driver_psycopg_async/sqlc-gen-better-python.wasm b/test/driver_psycopg_async/sqlc-gen-better-python.wasm index e65aad36..cdd42a7d 100644 Binary files a/test/driver_psycopg_async/sqlc-gen-better-python.wasm and b/test/driver_psycopg_async/sqlc-gen-better-python.wasm differ diff --git a/test/driver_psycopg_async/sqlc.yaml b/test/driver_psycopg_async/sqlc.yaml index ba945838..159e669d 100644 --- a/test/driver_psycopg_async/sqlc.yaml +++ b/test/driver_psycopg_async/sqlc.yaml @@ -3,7 +3,7 @@ plugins: - name: python wasm: url: file://sqlc-gen-better-python.wasm - sha256: f9e26e327ba75ccd648dea1a55b9cbecbf6586686b5f7329dc15871b0b3fdc77 + sha256: 2bad9ba936a60fc014eb4503a300a3ebfcd79483bd215002a8d4d03f1b290201 sql: - schema: schema.sql queries: diff --git a/test/driver_psycopg_sync/dataclass/functions/queries_backslash.py b/test/driver_psycopg_sync/dataclass/functions/queries_backslash.py new file mode 100644 index 00000000..1e43cd9b --- /dev/null +++ b/test/driver_psycopg_sync/dataclass/functions/queries_backslash.py @@ -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] diff --git a/test/driver_psycopg_sync/dataclass/test_psycopg_sync_dataclass_functions.py b/test/driver_psycopg_sync/dataclass/test_psycopg_sync_dataclass_functions.py index 241df884..d574f357 100644 --- a/test/driver_psycopg_sync/dataclass/test_psycopg_sync_dataclass_functions.py +++ b/test/driver_psycopg_sync/dataclass/test_psycopg_sync_dataclass_functions.py @@ -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 @@ -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 diff --git a/test/driver_psycopg_sync/queries_backslash.sql b/test/driver_psycopg_sync/queries_backslash.sql new file mode 100644 index 00000000..0c99da95 --- /dev/null +++ b/test/driver_psycopg_sync/queries_backslash.sql @@ -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; diff --git a/test/driver_psycopg_sync/sqlc-gen-better-python.wasm b/test/driver_psycopg_sync/sqlc-gen-better-python.wasm index e65aad36..cdd42a7d 100755 Binary files a/test/driver_psycopg_sync/sqlc-gen-better-python.wasm and b/test/driver_psycopg_sync/sqlc-gen-better-python.wasm differ diff --git a/test/driver_psycopg_sync/sqlc.yaml b/test/driver_psycopg_sync/sqlc.yaml index 5cd9f761..5db84dee 100644 --- a/test/driver_psycopg_sync/sqlc.yaml +++ b/test/driver_psycopg_sync/sqlc.yaml @@ -3,7 +3,7 @@ plugins: - name: python wasm: url: file://sqlc-gen-better-python.wasm - sha256: f9e26e327ba75ccd648dea1a55b9cbecbf6586686b5f7329dc15871b0b3fdc77 + sha256: 2bad9ba936a60fc014eb4503a300a3ebfcd79483bd215002a8d4d03f1b290201 sql: - schema: schema.sql queries: @@ -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 diff --git a/test/driver_pymysql/dataclass/functions/queries_backslash.py b/test/driver_pymysql/dataclass/functions/queries_backslash.py new file mode 100644 index 00000000..d7d39c17 --- /dev/null +++ b/test/driver_pymysql/dataclass/functions/queries_backslash.py @@ -0,0 +1,99 @@ +# 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_note", + "get_backslash_pattern", + "insert_backslash_row", +) + +import typing + +if typing.TYPE_CHECKING: + import collections.abc + import pymysql + + +INSERT_BACKSLASH_ROW: typing.Final[str] = """-- name: InsertBackslashRow :exec + +INSERT INTO test_slice (id, name, note) VALUES (%s, %s, %s) +""" + +GET_BACKSLASH_PATTERN: typing.Final[str] = r"""-- name: GetBackslashPattern :one +SELECT 'a\\tb\\d+' AS pattern FROM test_slice WHERE id = %s +""" + +GET_BACKSLASH_NOTE: typing.Final[str] = r"""-- name: GetBackslashNote :one +SELECT note FROM test_slice WHERE note = 'C:\\dir\\name' AND id = %s +""" + + +def insert_backslash_row(conn: pymysql.Connection, *, id_: int, name: str, note: str | None) -> None: + """Execute SQL query with `name: InsertBackslashRow :exec`. + + ```sql + + INSERT INTO test_slice (id, name, note) VALUES (%s, %s, %s) + ``` + + Args: + conn: + Connection object of type `pymysql.Connection` used to execute the query. + id_: int. + name: str. + note: str | None. + """ + with conn.cursor() as cur: + cur.execute(INSERT_BACKSLASH_ROW, (id_, name, note)) + + +def get_backslash_pattern(conn: pymysql.Connection, *, id_: int) -> str | None: + r"""Fetch one from the db using the SQL query with `name: GetBackslashPattern :one`. + + ```sql + SELECT 'a\\tb\\d+' AS pattern FROM test_slice WHERE id = %s + ``` + + Args: + conn: + Connection object of type `pymysql.Connection` used to execute the query. + id_: int. + + Returns: + Result of type `str` fetched from the db. Will be `None` if not found. + """ + with conn.cursor() as cur: + cur.execute(GET_BACKSLASH_PATTERN, (id_,)) + row = cur.fetchone() + if row is None: + return None + return row[0] + + +def get_backslash_note(conn: pymysql.Connection, *, id_: int) -> str | None: + r"""Fetch one from the db using the SQL query with `name: GetBackslashNote :one`. + + ```sql + SELECT note FROM test_slice WHERE note = 'C:\\dir\\name' AND id = %s + ``` + + Args: + conn: + Connection object of type `pymysql.Connection` used to execute the query. + id_: int. + + Returns: + Result of type `str` fetched from the db. Will be `None` if not found. + """ + with conn.cursor() as cur: + cur.execute(GET_BACKSLASH_NOTE, (id_,)) + row = cur.fetchone() + if row is None: + return None + return row[0] diff --git a/test/driver_pymysql/dataclass/test_pymysql_dataclass_functions.py b/test/driver_pymysql/dataclass/test_pymysql_dataclass_functions.py index 172d4181..f0cc3569 100644 --- a/test/driver_pymysql/dataclass/test_pymysql_dataclass_functions.py +++ b/test/driver_pymysql/dataclass/test_pymysql_dataclass_functions.py @@ -36,6 +36,7 @@ from test.driver_pymysql.dataclass.functions import enums from test.driver_pymysql.dataclass.functions import models from test.driver_pymysql.dataclass.functions import queries +from test.driver_pymysql.dataclass.functions import queries_backslash from test.driver_pymysql.dataclass.functions import queries_case from test.driver_pymysql.dataclass.functions import queries_converters from test.driver_pymysql.dataclass.functions import queries_enum_override @@ -57,6 +58,7 @@ THIRD_PARTY_ID = 1860 THIRD_PARTY_TOTAL = 9001 SLICE_ID_BASE = 1900 +BACKSLASH_ID = 1950 SLICE_ROW_COUNT = 4 CONVERTER_ID = 1950 CONVERTER_ID_2 = 1951 @@ -1301,3 +1303,15 @@ def test_enum_override_cleanup(self, pymysql_conn: pymysql.Connection) -> None: with pymysql_conn.cursor() as cur: cur.execute("DELETE FROM test_enum_override WHERE id IN (%s, %s)", (ENUM_OVERRIDE_ID, ENUM_OVERRIDE_ID_2)) assert queries_enum_override.get_enum_override_mood(conn=pymysql_conn, id_=ENUM_OVERRIDE_ID) is None + + @pytest.mark.dependency(name="PymysqlTestDataclassFunctions::backslash_sql") + def test_backslash_sql(self, pymysql_conn: pymysql.Connection) -> None: + # A plain Python literal turns the "\t" into a tab; the "\d" stays two + # characters, but only with an invalid-escape warning that a later + # Python turns into an error. The constant has to be raw for the + # doubled backslashes to reach MySQL, which unescapes them to one each. + queries_backslash.insert_backslash_row(conn=pymysql_conn, id_=BACKSLASH_ID, name="path", note="C:\\dir\\name") + assert queries_backslash.get_backslash_pattern(conn=pymysql_conn, id_=BACKSLASH_ID) == "a\\tb\\d+" + assert queries_backslash.get_backslash_note(conn=pymysql_conn, id_=BACKSLASH_ID) == "C:\\dir\\name" + assert queries_backslash.get_backslash_pattern(conn=pymysql_conn, id_=-1) is None + assert queries_backslash.get_backslash_note(conn=pymysql_conn, id_=-1) is None diff --git a/test/driver_pymysql/queries_backslash.sql b/test/driver_pymysql/queries_backslash.sql new file mode 100644 index 00000000..f58161ac --- /dev/null +++ b/test/driver_pymysql/queries_backslash.sql @@ -0,0 +1,12 @@ +-- A backslash in the SQL has to survive into the generated Python constant. +-- MySQL reads a backslash in a string literal as an escape, so each doubled +-- backslash below is one literal backslash on the server. + +-- name: InsertBackslashRow :exec +INSERT INTO test_slice (id, name, note) VALUES (?, ?, ?); + +-- name: GetBackslashPattern :one +SELECT 'a\\tb\\d+' AS pattern FROM test_slice WHERE id = ?; + +-- name: GetBackslashNote :one +SELECT note FROM test_slice WHERE note = 'C:\\dir\\name' AND id = ?; diff --git a/test/driver_pymysql/sqlc-gen-better-python.wasm b/test/driver_pymysql/sqlc-gen-better-python.wasm index e65aad36..cdd42a7d 100755 Binary files a/test/driver_pymysql/sqlc-gen-better-python.wasm and b/test/driver_pymysql/sqlc-gen-better-python.wasm differ diff --git a/test/driver_pymysql/sqlc.yaml b/test/driver_pymysql/sqlc.yaml index 57439a5d..599ba22a 100644 --- a/test/driver_pymysql/sqlc.yaml +++ b/test/driver_pymysql/sqlc.yaml @@ -3,7 +3,7 @@ plugins: - name: python wasm: url: file://sqlc-gen-better-python.wasm - sha256: f9e26e327ba75ccd648dea1a55b9cbecbf6586686b5f7329dc15871b0b3fdc77 + sha256: 2bad9ba936a60fc014eb4503a300a3ebfcd79483bd215002a8d4d03f1b290201 sql: - schema: schema.sql queries: @@ -98,6 +98,7 @@ sql: - queries_invalid_identifiers.sql - queries_converters.sql - queries_slice.sql + - queries_backslash.sql engine: mysql codegen: - out: /dataclass/functions diff --git a/test/driver_sqlite3/dataclass/functions/queries_backslash.py b/test/driver_sqlite3/dataclass/functions/queries_backslash.py new file mode 100644 index 00000000..998d0ae2 --- /dev/null +++ b/test/driver_sqlite3/dataclass/functions/queries_backslash.py @@ -0,0 +1,94 @@ +# 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_note", + "get_backslash_pattern", + "insert_backslash_row", +) + +import typing + +if typing.TYPE_CHECKING: + import collections.abc + import sqlite3 + + +INSERT_BACKSLASH_ROW: typing.Final[str] = """-- name: InsertBackslashRow :exec + +INSERT INTO test_slice (id, name, note) VALUES (?, ?, ?) +""" + +GET_BACKSLASH_PATTERN: typing.Final[str] = r"""-- name: GetBackslashPattern :one +SELECT 'a\tb\d+' AS pattern FROM test_slice WHERE id = ? +""" + +GET_BACKSLASH_NOTE: typing.Final[str] = r"""-- name: GetBackslashNote :one +SELECT note FROM test_slice WHERE note = 'C:\dir\name' AND id = ? +""" + + +def insert_backslash_row(conn: sqlite3.Connection, *, id_: int, name: str, note: str | None) -> None: + """Execute SQL query with `name: InsertBackslashRow :exec`. + + ```sql + + INSERT INTO test_slice (id, name, note) VALUES (?, ?, ?) + ``` + + Args: + conn: + Connection object of type `sqlite3.Connection` used to execute the query. + id_: int. + name: str. + note: str | None. + """ + conn.execute(INSERT_BACKSLASH_ROW, (id_, name, note)) + + +def get_backslash_pattern(conn: sqlite3.Connection, *, id_: int) -> str | None: + r"""Fetch one from the db using the SQL query with `name: GetBackslashPattern :one`. + + ```sql + SELECT 'a\tb\d+' AS pattern FROM test_slice WHERE id = ? + ``` + + Args: + conn: + Connection object of type `sqlite3.Connection` used to execute the query. + id_: int. + + Returns: + Result of type `str` fetched from the db. Will be `None` if not found. + """ + row = conn.execute(GET_BACKSLASH_PATTERN, (id_,)).fetchone() + if row is None: + return None + return row[0] + + +def get_backslash_note(conn: sqlite3.Connection, *, id_: int) -> str | None: + r"""Fetch one from the db using the SQL query with `name: GetBackslashNote :one`. + + ```sql + SELECT note FROM test_slice WHERE note = 'C:\dir\name' AND id = ? + ``` + + Args: + conn: + Connection object of type `sqlite3.Connection` used to execute the query. + id_: int. + + Returns: + Result of type `str` fetched from the db. Will be `None` if not found. + """ + row = conn.execute(GET_BACKSLASH_NOTE, (id_,)).fetchone() + if row is None: + return None + return row[0] diff --git a/test/driver_sqlite3/dataclass/test_sqlite3_dataclass_functions.py b/test/driver_sqlite3/dataclass/test_sqlite3_dataclass_functions.py index 3f5e78f1..80ad9b7b 100644 --- a/test/driver_sqlite3/dataclass/test_sqlite3_dataclass_functions.py +++ b/test/driver_sqlite3/dataclass/test_sqlite3_dataclass_functions.py @@ -33,6 +33,7 @@ from test.driver_sqlite3.dataclass.functions import models from test.driver_sqlite3.dataclass.functions import queries from test.driver_sqlite3.dataclass.functions import queries_any_param +from test.driver_sqlite3.dataclass.functions import queries_backslash from test.driver_sqlite3.dataclass.functions import queries_case from test.driver_sqlite3.dataclass.functions import queries_named_slice from test.driver_sqlite3.dataclass.functions import queries_override_adapter @@ -48,6 +49,7 @@ UNKNOWN_OVERRIDE_ID = 545454 ANY_PARAM_ID = 565656 SLICE_ID_BASE = 585858 +BACKSLASH_ID = 606060 SLICE_ROW_COUNT = 4 @@ -1191,3 +1193,14 @@ def test_delete_slice_rows(self, sqlite3_conn: sqlite3.Connection) -> None: assert queries_slice.delete_slice_rows(conn=sqlite3_conn, ids=[]) == 0 deleted = queries_slice.delete_slice_rows(conn=sqlite3_conn, ids=[SLICE_ID_BASE + offset for offset in range(SLICE_ROW_COUNT)]) assert deleted == SLICE_ROW_COUNT + + @pytest.mark.dependency(name="Sqlite3TestDataclassFunctions::backslash_sql") + def test_backslash_sql(self, sqlite3_conn: sqlite3.Connection) -> None: + # A plain Python literal turns the "\t" into a tab; the "\d" stays two + # characters, but only with an invalid-escape warning that a later + # Python turns into an error. The constant has to be raw. + queries_backslash.insert_backslash_row(conn=sqlite3_conn, id_=BACKSLASH_ID, name="path", note="C:\\dir\\name") + assert queries_backslash.get_backslash_pattern(conn=sqlite3_conn, id_=BACKSLASH_ID) == "a\\tb\\d+" + assert queries_backslash.get_backslash_note(conn=sqlite3_conn, id_=BACKSLASH_ID) == "C:\\dir\\name" + assert queries_backslash.get_backslash_pattern(conn=sqlite3_conn, id_=-1) is None + assert queries_backslash.get_backslash_note(conn=sqlite3_conn, id_=-1) is None diff --git a/test/driver_sqlite3/queries_backslash.sql b/test/driver_sqlite3/queries_backslash.sql new file mode 100644 index 00000000..b1ae952b --- /dev/null +++ b/test/driver_sqlite3/queries_backslash.sql @@ -0,0 +1,12 @@ +-- A backslash in the SQL has to survive into the generated Python constant. +-- SQLite string literals have no backslash escape, so every backslash below +-- reaches the database as written. + +-- name: InsertBackslashRow :exec +INSERT INTO test_slice (id, name, note) VALUES (?, ?, ?); + +-- name: GetBackslashPattern :one +SELECT 'a\tb\d+' AS pattern FROM test_slice WHERE id = ?; + +-- name: GetBackslashNote :one +SELECT note FROM test_slice WHERE note = 'C:\dir\name' AND id = ?; diff --git a/test/driver_sqlite3/sqlc-gen-better-python.wasm b/test/driver_sqlite3/sqlc-gen-better-python.wasm index e65aad36..cdd42a7d 100644 Binary files a/test/driver_sqlite3/sqlc-gen-better-python.wasm and b/test/driver_sqlite3/sqlc-gen-better-python.wasm differ diff --git a/test/driver_sqlite3/sqlc.yaml b/test/driver_sqlite3/sqlc.yaml index 29fb21be..d2c4882b 100644 --- a/test/driver_sqlite3/sqlc.yaml +++ b/test/driver_sqlite3/sqlc.yaml @@ -3,7 +3,7 @@ plugins: - name: python wasm: url: file://sqlc-gen-better-python.wasm - sha256: f9e26e327ba75ccd648dea1a55b9cbecbf6586686b5f7329dc15871b0b3fdc77 + sha256: 2bad9ba936a60fc014eb4503a300a3ebfcd79483bd215002a8d4d03f1b290201 sql: - schema: schema.sql queries: @@ -109,6 +109,7 @@ sql: - queries_any_param.sql - queries_slice.sql - queries_named_slice.sql + - queries_backslash.sql engine: sqlite codegen: - out: /dataclass/functions diff --git a/test/driver_turso_async/sqlc-gen-better-python.wasm b/test/driver_turso_async/sqlc-gen-better-python.wasm index e65aad36..cdd42a7d 100755 Binary files a/test/driver_turso_async/sqlc-gen-better-python.wasm and b/test/driver_turso_async/sqlc-gen-better-python.wasm differ diff --git a/test/driver_turso_async/sqlc.yaml b/test/driver_turso_async/sqlc.yaml index f6f0335e..e2933a48 100644 --- a/test/driver_turso_async/sqlc.yaml +++ b/test/driver_turso_async/sqlc.yaml @@ -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 diff --git a/test/driver_turso_sync/sqlc-gen-better-python.wasm b/test/driver_turso_sync/sqlc-gen-better-python.wasm index e65aad36..cdd42a7d 100755 Binary files a/test/driver_turso_sync/sqlc-gen-better-python.wasm and b/test/driver_turso_sync/sqlc-gen-better-python.wasm differ diff --git a/test/driver_turso_sync/sqlc.yaml b/test/driver_turso_sync/sqlc.yaml index bd4a7c3c..ef432390 100644 --- a/test/driver_turso_sync/sqlc.yaml +++ b/test/driver_turso_sync/sqlc.yaml @@ -3,7 +3,7 @@ plugins: - name: python wasm: url: file://sqlc-gen-better-python.wasm - sha256: f9e26e327ba75ccd648dea1a55b9cbecbf6586686b5f7329dc15871b0b3fdc77 + sha256: 2bad9ba936a60fc014eb4503a300a3ebfcd79483bd215002a8d4d03f1b290201 sql: - schema: schema.sql queries: