diff --git a/.changes/unreleased/Fixed-20260721-210000.yaml b/.changes/unreleased/Fixed-20260721-210000.yaml new file mode 100644 index 00000000..f9f454ef --- /dev/null +++ b/.changes/unreleased/Fixed-20260721-210000.yaml @@ -0,0 +1,6 @@ +kind: Fixed +body: '`sqlc.slice` works on the sqlite drivers: generated functions now expand the `/*SLICE:name*/` placeholder at call time, one `?` per element (`NULL` for an empty sequence, so `IN (NULL)` matches no rows), and unpack the sequence into the positional arguments in SQL text order. Previously the placeholder was left in the SQL and binding the sequence raised `sqlite3.ProgrammingError`. Overridden and converted slice parameters keep their element-wise conversion. On PostgreSQL use `= ANY($1::type[])` instead, which sqlc itself intends there.' +time: 2026-07-21T21:00:00.0000000Z +custom: + Author: Rayakame + PR: "211" diff --git a/docs/content/docs/reference/feature-support.md b/docs/content/docs/reference/feature-support.md index bc020d22..1b98903d 100644 --- a/docs/content/docs/reference/feature-support.md +++ b/docs/content/docs/reference/feature-support.md @@ -12,12 +12,12 @@ Every [sqlc macro](https://docs.sqlc.dev/en/latest/reference/macros.html) is supported (`sqlc.arg`, `sqlc.narg`, `sqlc.embed`, `sqlc.slice`). {{< callout type="info" >}} - Known issue in `v0.5.1`: a query using `sqlc.slice` is generated with sqlc's - `/*SLICE:name*/` placeholder left in place, so it fails at runtime. A fix is - landing before the next release - see - [issue #210](https://github.com/rayakame/sqlc-gen-better-python/issues/210). - On PostgreSQL, `= ANY($1::type[])` is the idiomatic form anyway and is - unaffected. + `sqlc.slice` is for the SQLite drivers, where a list cannot be passed to the + `IN` operator: the generated function expands the placeholder at call time, + one `?` per element, and an empty sequence matches no rows. Because the SQL + is built per call, it cannot be used with prepared statements. On PostgreSQL + the macro is not needed - use `= ANY($1::type[])`, which accepts the sequence + directly. {{< /callout >}} ## Query commands diff --git a/internal/driver/common.go b/internal/driver/common.go index b97c3bdc..6016d070 100644 --- a/internal/driver/common.go +++ b/internal/driver/common.go @@ -2,6 +2,7 @@ package driver import ( "fmt" + "strings" "github.com/rayakame/sqlc-gen-better-python/internal/config" "github.com/rayakame/sqlc-gen-better-python/internal/model" @@ -50,22 +51,237 @@ func writeFuncSignature( // ("params.a, params.b") so drivers receive positional values. :copyfrom params // are never passed through here - writeCopyFromBody builds its own records list. func expandParams(query model.Query) []string { - parts := make([]string, 0, len(query.Params)) + return expandParamsImpl(query, false) +} + +// expandParamsFlattenSlices additionally star-unpacks sqlc.slice parameters +// ("*ids"), so after runtime placeholder expansion every "?" binds one element. +func expandParamsFlattenSlices(query model.Query) []string { + return expandParamsImpl(query, true) +} + +func expandParamsImpl(query model.Query, flattenSlices bool) []string { + type part struct { + expr string + // slice is the raw marker name for slice params, "" otherwise. + slice string + } + parts := make([]part, 0, len(query.Params)) + appendPart := func(expr string, typ model.PyType) { + converted := convertParamExpr(expr, typ) + slice := "" + if flattenSlices && typ.SqlcSliceName != "" { + converted = "*" + converted + slice = typ.SqlcSliceName + } + parts = append(parts, part{expr: converted, slice: slice}) + } + for _, param := range query.Params { + if param.IsEmpty() { + continue + } + if param.EmitTable && param.Table != nil { + for _, col := range param.Table.Columns { + appendPart(fmt.Sprintf("%s.%s", param.Name, col.Name), col.Type) + } + + continue + } + appendPart(param.Name, param.Type) + } + + reused := false + for _, p := range parts { + if p.slice != "" && sliceMarkerCount(query, p.slice) > 1 { + reused = true + + break + } + } + if !reused { + out := make([]string, 0, len(parts)) + for _, p := range parts { + out = append(out, p.expr) + } + + return out + } + + // A reused slice binds once per marker occurrence, and other placeholders + // may sit between the use sites, so arguments must follow the SQL text + // order rather than the parameter order. + plain := make([]string, 0, len(parts)) + starred := make(map[string]string, len(parts)) + for _, p := range parts { + if p.slice == "" { + plain = append(plain, p.expr) + } else { + starred[p.slice] = p.expr + } + } + if ordered, ok := orderByPlaceholders(query.SQL, plain, starred); ok { + return ordered + } + + // Unmatchable SQL (hand-built IR in tests): consecutive copies keep the + // argument count right even if the interleaving cannot be derived. + out := make([]string, 0, len(parts)) + for _, p := range parts { + if p.slice != "" { + for range sliceMarkerCount(query, p.slice) { + out = append(out, p.expr) + } + + continue + } + out = append(out, p.expr) + } + + return out +} + +// orderByPlaceholders lines the flattened arguments up with the SQL text's +// placeholder sequence: plain expressions fill "?" slots in order, and every +// marker occurrence gets its slice's starred copy. Reports false when the SQL +// does not account for exactly the given arguments. +func orderByPlaceholders(sql string, plain []string, starred map[string]string) ([]string, bool) { + seq := placeholderSequence(sql) + out := make([]string, 0, len(seq)) + next := 0 + for _, name := range seq { + if name == "" { + if next >= len(plain) { + return nil, false + } + out = append(out, plain[next]) + next++ + + continue + } + expr, ok := starred[name] + if !ok { + return nil, false + } + out = append(out, expr) + } + if next != len(plain) { + return nil, false + } + + return out, true +} + +// placeholderSequence scans the SQL for bindable placeholders in text order: +// the raw slice name for a /*SLICE:name*/? marker, "" for a plain (possibly +// numbered) "?". String literals, quoted identifiers, and comments are +// skipped, so a "?" inside them never counts as a placeholder. +func placeholderSequence(sql string) []string { + var seq []string + for i := 0; i < len(sql); { + rest := sql[i:] + switch { + case strings.HasPrefix(rest, "/*SLICE:"): + end := strings.Index(rest, "*/?") + if end == -1 { + return seq + } + seq = append(seq, rest[len("/*SLICE:"):end]) + i += end + len("*/?") + case strings.HasPrefix(rest, "/*"): + end := strings.Index(rest[len("/*"):], "*/") + if end == -1 { + return seq + } + i += len("/*") + end + len("*/") + case strings.HasPrefix(rest, "--"): + end := strings.IndexByte(rest, '\n') + if end == -1 { + return seq + } + i += end + 1 + case rest[0] == '\'' || rest[0] == '"': + quote := rest[0] + j := i + 1 + for j < len(sql) { + if sql[j] != quote { + j++ + + continue + } + if j+1 < len(sql) && sql[j+1] == quote { + // A doubled quote is an escape, not the end. + j += 2 + + continue + } + + break + } + i = j + 1 + case rest[0] == '?': + seq = append(seq, "") + i++ + for i < len(sql) && sql[i] >= '0' && sql[i] <= '9' { + i++ + } + default: + i++ + } + } + + return seq +} + +type sliceParam struct { + // marker is the raw sqlc.slice name inside the /*SLICE:name*/? placeholder. + marker string + // expr is the Python expression holding the passed sequence. + expr string +} + +// sliceMarker returns the placeholder sqlc leaves in the SQL for a slice name. +func sliceMarker(name string) string { + return "/*SLICE:" + name + "*/?" +} + +// sliceMarkerCount reports how often a slice parameter's placeholder occurs in +// the query. sqlc merges same-named sqlc.slice uses into ONE parameter but +// keeps a marker per use site, so each occurrence needs its own expansion and +// its own copy of the arguments. Clamped to 1 for queries without the marker. +func sliceMarkerCount(query model.Query, name string) int { + if count := strings.Count(query.SQL, sliceMarker(name)); count > 1 { + return count + } + + return 1 +} + +// sliceParams collects the sqlc.slice parameters of a query, including fields +// of a bundled Params class. +func sliceParams(query model.Query) []sliceParam { + var params []sliceParam for _, param := range query.Params { if param.IsEmpty() { continue } if param.EmitTable && param.Table != nil { for _, col := range param.Table.Columns { - parts = append(parts, convertParamExpr(fmt.Sprintf("%s.%s", param.Name, col.Name), col.Type)) + if col.Type.SqlcSliceName != "" { + params = append( + params, + sliceParam{marker: col.Type.SqlcSliceName, expr: fmt.Sprintf("%s.%s", param.Name, col.Name)}, + ) + } } continue } - parts = append(parts, convertParamExpr(param.Name, param.Type)) + if param.Type.SqlcSliceName != "" { + params = append(params, sliceParam{marker: param.Type.SqlcSliceName, expr: param.Name}) + } } - return parts + return params } // writeQueryDocstring writes the docstring for a generated query function. diff --git a/internal/driver/common_test.go b/internal/driver/common_test.go index 43e7a7c2..d110b096 100644 --- a/internal/driver/common_test.go +++ b/internal/driver/common_test.go @@ -191,6 +191,258 @@ func TestExpandParams(t *testing.T) { } } +func TestExpandParamsFlattenSlices(t *testing.T) { + t.Parallel() + cases := []struct { + name string + query model.Query + want []string + }{ + { + name: "slice params are star-unpacked between plain params", + query: model.Query{ + Params: []model.QueryValue{ + {Name: "name", Type: model.PyType{Type: "str", SQLType: "text"}}, + {Name: "ids", Type: model.PyType{Type: "int", SQLType: "integer", IsList: true, SqlcSliceName: "ids"}}, + {Name: "note", Type: model.PyType{Type: "str", SQLType: "text", IsNullable: true}}, + }, + }, + want: []string{"name", "*ids", "note"}, + }, + { + name: "converted slice unpacks the element-wise conversion", + query: model.Query{ + Params: []model.QueryValue{ + {Name: "days", Type: model.PyType{ + Type: "float", + IsList: true, + IsOverride: true, + DefaultType: "datetime.date", + SqlcSliceName: "days", + }}, + }, + }, + want: []string{"*[datetime.date(v) for v in days]"}, + }, + { + name: "reused slice repeats the starred copy per marker occurrence", + query: model.Query{ + SQL: "DELETE FROM t WHERE id IN (/*SLICE:ids*/?) OR ref_id IN (/*SLICE:ids*/?)", + Params: []model.QueryValue{ + {Name: "ids", Type: model.PyType{Type: "int", SQLType: "integer", IsList: true, SqlcSliceName: "ids"}}, + }, + }, + want: []string{"*ids", "*ids"}, + }, + { + // The parameter array puts the merged slice first, but the SQL + // binds name between the two use sites: text order must win. + name: "reused slice interleaves plain params in SQL text order", + query: model.Query{ + SQL: "SELECT id FROM t WHERE id IN (/*SLICE:ids*/?) AND name = ? AND ref_id IN (/*SLICE:ids*/?)", + Params: []model.QueryValue{ + {Name: "ids", Type: model.PyType{Type: "int", SQLType: "integer", IsList: true, SqlcSliceName: "ids"}}, + {Name: "name", Type: model.PyType{Type: "str", SQLType: "text"}}, + }, + }, + want: []string{"*ids", "name", "*ids"}, + }, + { + name: "reused slice with unaccounted plain placeholder falls back to consecutive copies", + query: model.Query{ + SQL: "SELECT id FROM t WHERE id IN (/*SLICE:ids*/?) AND name = ? AND ref_id IN (/*SLICE:ids*/?)", + Params: []model.QueryValue{ + {Name: "ids", Type: model.PyType{Type: "int", SQLType: "integer", IsList: true, SqlcSliceName: "ids"}}, + }, + }, + want: []string{"*ids", "*ids"}, + }, + { + name: "reused slice with unknown marker falls back to consecutive copies", + query: model.Query{ + SQL: "SELECT id FROM t WHERE id IN (/*SLICE:ids*/?) OR a IN (/*SLICE:ids*/?) OR b IN (/*SLICE:other*/?)", + Params: []model.QueryValue{ + {Name: "ids", Type: model.PyType{Type: "int", SQLType: "integer", IsList: true, SqlcSliceName: "ids"}}, + }, + }, + want: []string{"*ids", "*ids"}, + }, + { + name: "reused slice with leftover plain param falls back to consecutive copies", + query: model.Query{ + SQL: "SELECT id FROM t WHERE id IN (/*SLICE:ids*/?) OR ref_id IN (/*SLICE:ids*/?)", + Params: []model.QueryValue{ + {Name: "ids", Type: model.PyType{Type: "int", SQLType: "integer", IsList: true, SqlcSliceName: "ids"}}, + {Name: "ghost", Type: model.PyType{Type: "str", SQLType: "text"}}, + }, + }, + want: []string{"*ids", "*ids", "ghost"}, + }, + { + name: "bundled table field slice unpacks the attribute", + query: model.Query{ + Params: []model.QueryValue{ + { + EmitTable: true, + Name: "params", + Type: model.PyType{Type: "GetRowsParams"}, + Table: &model.Table{ + Name: "GetRowsParams", + Columns: []model.Column{ + {Name: "name", DBName: "name", Type: model.PyType{Type: "str", SQLType: "text"}}, + { + Name: "ids", + DBName: "id", + Type: model.PyType{Type: "int", SQLType: "integer", IsList: true, SqlcSliceName: "ids"}, + }, + }, + }, + }, + }, + }, + want: []string{"params.name", "*params.ids"}, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + if got := expandParamsFlattenSlices(tc.query); !slices.Equal(got, tc.want) { + t.Errorf("expandParamsFlattenSlices() = %q, want %q", got, tc.want) + } + }) + } +} + +func TestPlaceholderSequence(t *testing.T) { + t.Parallel() + cases := []struct { + name string + sql string + want []string + }{ + { + name: "markers and plain placeholders in text order", + sql: "WHERE id IN (/*SLICE:ids*/?) AND name = ? AND ref_id IN (/*SLICE:ids*/?)", + want: []string{"ids", "", "ids"}, + }, + { + name: "question marks inside string literals do not count", + sql: "WHERE note LIKE 'what?%' AND s = 'it''s?' AND id IN (/*SLICE:ids*/?)", + want: []string{"ids"}, + }, + { + name: "quoted identifiers and comments are skipped", + sql: "SELECT \"weird?col\" FROM t /* really? */ WHERE a = ? -- trailing?\nAND b = ?2", + want: []string{"", ""}, + }, + { + name: "unterminated marker stops the scan", + sql: "WHERE a = ? AND id IN (/*SLICE:ids", + want: []string{""}, + }, + { + name: "unterminated comment stops the scan", + sql: "WHERE a = ? /* dangling", + want: []string{""}, + }, + { + name: "unterminated line comment stops the scan", + sql: "WHERE a = ? -- dangling", + want: []string{""}, + }, + { + name: "unterminated string swallows the rest", + sql: "WHERE a = ? AND s = 'open?", + want: []string{""}, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + if got := placeholderSequence(tc.sql); !slices.Equal(got, tc.want) { + t.Errorf("placeholderSequence() = %q, want %q", got, tc.want) + } + }) + } +} + +func TestSliceParams(t *testing.T) { + t.Parallel() + cases := []struct { + name string + query model.Query + want []sliceParam + }{ + { + name: "no slices", + query: model.Query{ + Params: []model.QueryValue{ + {Name: "name", Type: model.PyType{Type: "str", SQLType: "text"}}, + }, + }, + want: nil, + }, + { + name: "empty value skipped", + query: model.Query{ + Params: []model.QueryValue{{}}, + }, + want: nil, + }, + { + name: "plain and escaped slice params keep the raw marker name", + query: model.Query{ + Params: []model.QueryValue{ + {Name: "for_", Type: model.PyType{Type: "int", SQLType: "integer", IsList: true, SqlcSliceName: "for"}}, + {Name: "names", Type: model.PyType{Type: "str", SQLType: "text", IsList: true, SqlcSliceName: "names"}}, + }, + }, + want: []sliceParam{{marker: "for", expr: "for_"}, {marker: "names", expr: "names"}}, + }, + { + name: "bundled table fields contribute their slices", + query: model.Query{ + Params: []model.QueryValue{ + { + EmitTable: true, + Name: "params", + Type: model.PyType{Type: "GetRowsParams"}, + Table: &model.Table{ + Name: "GetRowsParams", + Columns: []model.Column{ + {Name: "name", DBName: "name", Type: model.PyType{Type: "str", SQLType: "text"}}, + { + Name: "ids", + DBName: "id", + Type: model.PyType{Type: "int", SQLType: "integer", IsList: true, SqlcSliceName: "ids"}, + }, + }, + }, + }, + }, + }, + want: []sliceParam{{marker: "ids", expr: "params.ids"}}, + }, + { + name: "emit table without table falls through to the plain path", + query: model.Query{ + Params: []model.QueryValue{ + {EmitTable: true, Name: "params", Type: model.PyType{Type: "GetRowsParams"}}, + }, + }, + want: nil, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + if got := sliceParams(tc.query); !slices.Equal(got, tc.want) { + t.Errorf("sliceParams() = %v, want %v", got, tc.want) + } + }) + } +} + func TestWriteQueryDocstring(t *testing.T) { t.Parallel() cases := []struct { diff --git a/internal/driver/sqlite_base.go b/internal/driver/sqlite_base.go index 18f8071d..d419937b 100644 --- a/internal/driver/sqlite_base.go +++ b/internal/driver/sqlite_base.go @@ -193,12 +193,18 @@ func (sb *sqliteBase) WriteQueryFunc(body *writer.CodeWriter, config *config.Con indent++ writeQueryDocstring(body, sb, config, query, indent, docRetType) + // :many delays this until after the decode hook, whose trailing blank + // line keeps the assignment from touching the nested def (ruff E306). + sqlRef := query.ConstantName + if query.Cmd != metadata.CmdMany { + sqlRef = writeSliceExpansion(body, indent, query) + } // stmt builds the execute-statement head/tail with the correct await // wrapping for the async driver: accessing an attribute or method of the // cursor requires parenthesizing the awaited execute call. stmt := func(prefix, attribute string) (string, string) { - base := fmt.Sprintf("%s.execute(%s", conn, query.ConstantName) + base := fmt.Sprintf("%s.execute(%s", conn, sqlRef) switch { case !sb.async: return prefix + base, ")" + attribute @@ -245,7 +251,8 @@ func (sb *sqliteBase) WriteQueryFunc(body *writer.CodeWriter, config *config.Con case metadata.CmdMany: decodeHook := sb.rows.WriteDecodeHook(body, indent, query, sqliteResultType) - manyArgs := append([]string{conn, query.ConstantName, decodeHook}, expandParams(query)...) + sqlRef = writeSliceExpansion(body, indent, query) + manyArgs := append([]string{conn, sqlRef, decodeHook}, expandParamsFlattenSlices(query)...) // Deliberately unsubscripted: QueryResults[T](...) would go through // typing's _GenericAlias.__call__ on every invocation (~10x call // overhead) for zero benefit - the return annotation carries the type. @@ -253,11 +260,38 @@ func (sb *sqliteBase) WriteQueryFunc(body *writer.CodeWriter, config *config.Con } } +// writeSliceExpansion writes the runtime replacement of every sqlc.slice +// placeholder - one "?" per element, or "NULL" for an empty sequence so that +// "IN (NULL)" matches no rows - and returns the expression holding the final +// SQL: a local "sql" variable, or the untouched constant without slices. +func writeSliceExpansion(body *writer.CodeWriter, indent int, query model.Query) string { + params := sliceParams(query) + if len(params) == 0 { + return query.ConstantName + } + src := query.ConstantName + for _, param := range params { + args := []string{ + writer.PyQuote(sliceMarker(param.marker)), + fmt.Sprintf(`",".join("?" * len(%s)) or "NULL"`, param.expr), + } + // A reused slice has one marker per use site: replace them all, with + // expandParamsFlattenSlices supplying a copy of the args for each. + if sliceMarkerCount(query, param.marker) == 1 { + args = append(args, "1") + } + body.WriteWrappedCall(indent, "sql = "+src+".replace(", args, ")") + src = "sql" + } + + return "sql" +} + // writeSqliteCall writes stmtHead+argsSegment+stmtTail on one line, hoisting a // too-long parameter tuple into a local _args variable first so the statement // stays within the line limit. func writeSqliteCall(body *writer.CodeWriter, indent int, query model.Query, stmtHead, stmtTail string) { - parts := expandParams(query) + parts := expandParamsFlattenSlices(query) segment := "" switch { case len(parts) == 1: diff --git a/internal/driver/sqlite_test.go b/internal/driver/sqlite_test.go index 14cb9bf8..edd30735 100644 --- a/internal/driver/sqlite_test.go +++ b/internal/driver/sqlite_test.go @@ -544,6 +544,119 @@ func TestSqliteWriteQueryFunc(t *testing.T) { "", }, "\n"), }, + { + name: "many struct sync slice expanded after decode hook", + sqlDriver: config.SQLDriverSQLite, + query: model.Query{ + Cmd: metadata.CmdMany, + ConstantName: "GET_ROWS", + FuncName: "get_rows", + Params: []model.QueryValue{ + {Name: "ids", Type: model.PyType{Type: "int", SQLType: "integer", IsList: true, SqlcSliceName: "ids"}}, + }, + Returns: sqliteAuthorReturn(), + }, + want: strings.Join([]string{ + "def get_rows(conn: sqlite3.Connection, *, ids: collections.abc.Sequence[int]) -> QueryResults[models.Author]:", + " def _decode_hook(row: sqlite3.Row) -> models.Author:", + " return models.Author(id=row[0], name=row[1])", + "", + ` sql = GET_ROWS.replace("/*SLICE:ids*/?", ",".join("?" * len(ids)) or "NULL", 1)`, + " return QueryResults(conn, sql, _decode_hook, *ids)", + "", + }, "\n"), + }, + { + name: "one async slice between plain params", + sqlDriver: config.SQLDriverAioSQLite, + query: model.Query{ + Cmd: metadata.CmdOne, + ConstantName: "GET_ROW", + FuncName: "get_row", + Params: []model.QueryValue{ + {Name: "name", Type: model.PyType{Type: "str", SQLType: "text"}}, + {Name: "ids", Type: model.PyType{Type: "int", SQLType: "integer", IsList: true, SqlcSliceName: "ids"}}, + {Name: "note", Type: model.PyType{Type: "str", SQLType: "text", IsNullable: true}}, + }, + Returns: sqliteAuthorReturn(), + }, + want: strings.Join([]string{ + "async def get_row(conn: aiosqlite.Connection, *, name: str, ids: collections.abc.Sequence[int], note: str | None) -> models.Author | None:", + ` sql = GET_ROW.replace("/*SLICE:ids*/?", ",".join("?" * len(ids)) or "NULL", 1)`, + " row = await (await conn.execute(sql, (name, *ids, note))).fetchone()", + " if row is None:", + " return None", + " return models.Author(id=row[0], name=row[1])", + "", + }, "\n"), + }, + { + name: "exec sync two slices replaced sequentially", + sqlDriver: config.SQLDriverSQLite, + query: model.Query{ + Cmd: metadata.CmdExec, + ConstantName: "DELETE_ROWS", + FuncName: "delete_rows", + Params: []model.QueryValue{ + {Name: "ids", Type: model.PyType{Type: "int", SQLType: "integer", IsList: true, SqlcSliceName: "ids"}}, + {Name: "names", Type: model.PyType{Type: "str", SQLType: "text", IsList: true, SqlcSliceName: "names"}}, + }, + Returns: model.QueryValue{Type: model.PyType{Type: "None"}}, + }, + want: strings.Join([]string{ + "def delete_rows(conn: sqlite3.Connection, *, ids: collections.abc.Sequence[int], names: collections.abc.Sequence[str]) -> None:", + ` sql = DELETE_ROWS.replace("/*SLICE:ids*/?", ",".join("?" * len(ids)) or "NULL", 1)`, + ` sql = sql.replace("/*SLICE:names*/?", ",".join("?" * len(names)) or "NULL", 1)`, + " conn.execute(sql, (*ids, *names))", + "", + }, "\n"), + }, + { + // sqlc merges same-named sqlc.slice uses into one parameter but + // keeps a marker per use site: all of them are replaced and the + // arguments are repeated once per occurrence. + name: "exec sync reused slice replaces all markers and repeats args", + sqlDriver: config.SQLDriverSQLite, + query: model.Query{ + Cmd: metadata.CmdExec, + SQL: "DELETE FROM t WHERE id IN (/*SLICE:ids*/?) OR ref_id IN (/*SLICE:ids*/?)", + ConstantName: "DELETE_LINKED", + FuncName: "delete_linked", + Params: []model.QueryValue{ + {Name: "ids", Type: model.PyType{Type: "int", SQLType: "integer", IsList: true, SqlcSliceName: "ids"}}, + }, + Returns: model.QueryValue{Type: model.PyType{Type: "None"}}, + }, + want: strings.Join([]string{ + "def delete_linked(conn: sqlite3.Connection, *, ids: collections.abc.Sequence[int]) -> None:", + ` sql = DELETE_LINKED.replace("/*SLICE:ids*/?", ",".join("?" * len(ids)) or "NULL")`, + " conn.execute(sql, (*ids, *ids))", + "", + }, "\n"), + }, + { + // A plain placeholder between the reuse sites: arguments follow + // the SQL text order, not the parameter order. + name: "exec sync reused slice keeps text order around plain params", + sqlDriver: config.SQLDriverSQLite, + query: model.Query{ + Cmd: metadata.CmdExec, + SQL: "DELETE FROM t WHERE id IN (/*SLICE:ids*/?) AND name = ? AND ref_id IN (/*SLICE:ids*/?)", + ConstantName: "DELETE_BETWEEN", + FuncName: "delete_between", + Params: []model.QueryValue{ + {Name: "ids", Type: model.PyType{Type: "int", SQLType: "integer", IsList: true, SqlcSliceName: "ids"}}, + {Name: "name", Type: model.PyType{Type: "str", SQLType: "text"}}, + }, + Returns: model.QueryValue{Type: model.PyType{Type: "None"}}, + }, + want: strings.Join([]string{ + "def delete_between(conn: sqlite3.Connection, *, ids: collections.abc.Sequence[int], name: str) -> None:", + ` sql = DELETE_BETWEEN.replace("/*SLICE:ids*/?", ",".join("?" * len(ids)) or "NULL")`, + " conn.execute(sql, (*ids, name, *ids))", + "", + }, "\n"), + }, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { diff --git a/internal/model/types.go b/internal/model/types.go index d20c2d01..f36ab920 100644 --- a/internal/model/types.go +++ b/internal/model/types.go @@ -23,6 +23,10 @@ type PyType struct { // replace the default constructor-call conversion in both directions. ConverterTo string ConverterFrom string + + // SqlcSliceName is the raw sqlc.slice parameter name, needed to expand + // the /*SLICE:name*/? placeholder at runtime. Empty for other values. + SqlcSliceName string } // HasConverter reports whether user functions handle this type's conversion. diff --git a/internal/transform/queries.go b/internal/transform/queries.go index ac1f7ef7..45a5cc7a 100644 --- a/internal/transform/queries.go +++ b/internal/transform/queries.go @@ -70,6 +70,16 @@ func (t *Transformer) BuildQueries(tables []model.Table) []model.Query { } else { seen["conn"]++ } + // Slice queries materialize the expanded SQL into a local named + // "sql" before any parameter is read; a parameter with that name + // would be silently overwritten by the query text. + for _, param := range pluginQuery.Params { + if param.GetColumn().GetIsSqlcSlice() { + seen["sql"]++ + + break + } + } for _, param := range pluginQuery.Params { query.Params = append(query.Params, model.QueryValue{ Name: model.DedupName(model.ParamName(param), seen), diff --git a/internal/transform/queries_test.go b/internal/transform/queries_test.go index 05dcfc95..95c1cc8a 100644 --- a/internal/transform/queries_test.go +++ b/internal/transform/queries_test.go @@ -167,20 +167,27 @@ func TestBuildQueriesImplicitArgCollision(t *testing.T) { name string emitClasses bool column string + sqlcSlice bool want string }{ {name: "conn collides in functions mode", emitClasses: false, column: "conn", want: "conn_2"}, {name: "self is free in functions mode", emitClasses: false, column: "self", want: "self"}, {name: "self collides in classes mode", emitClasses: true, column: "self", want: "self_2"}, {name: "conn is free in classes mode", emitClasses: true, column: "conn", want: "conn"}, + // Slice queries write the expanded SQL into a local named "sql" + // before binding, so the name is reserved exactly there. + {name: "sql collides in a slice query", emitClasses: false, column: "sql", sqlcSlice: true, want: "sql_2"}, + {name: "sql is free without slices", emitClasses: false, column: "sql", want: "sql"}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { t.Parallel() + column := queryCol(tc.column, "int4", nil) + column.IsSqlcSlice = tc.sqlcSlice query := buildSingleQuery(t, &config.Config{EmitClasses: tc.emitClasses}, &plugin.Query{ Name: "Ping", Cmd: ":exec", - Params: []*plugin.Parameter{{Number: 1, Column: queryCol(tc.column, "int4", nil)}}, + Params: []*plugin.Parameter{{Number: 1, Column: column}}, }) if query.Params[0].Name != tc.want { diff --git a/internal/transform/type.go b/internal/transform/type.go index aee3472e..d5abdb48 100644 --- a/internal/transform/type.go +++ b/internal/transform/type.go @@ -21,6 +21,11 @@ func (t *Transformer) buildPyType(pluginColumn *plugin.Column) model.PyType { columnType := strings.ToLower(sdk.DataType(pluginColumn.Type)) strType := t.convertType(pluginColumn.Type) + // A sqlc.slice parameter is never optional, even on a nullable column: + // the generated expansion calls len() on it, and "no values" is an empty + // sequence, matching the plain slice parameters of sqlc's own Go codegen. + isNullable := !pluginColumn.GetNotNull() && !pluginColumn.GetIsSqlcSlice() + isEnum := false // Never mutate pluginColumn: buildPyType runs repeatedly on the same @@ -48,13 +53,14 @@ func (t *Transformer) buildPyType(pluginColumn *plugin.Column) model.PyType { if override := t.matchOverride(pluginColumn, columnType); override != nil { pyType := model.PyType{ - SQLType: columnType, - Type: override.PyType.Type, - IsNullable: !pluginColumn.GetNotNull(), - IsList: pluginColumn.GetIsArray() || pluginColumn.GetIsSqlcSlice(), - IsEnum: false, - IsOverride: true, - DefaultType: strType, + SQLType: columnType, + Type: override.PyType.Type, + IsNullable: isNullable, + IsList: pluginColumn.GetIsArray() || pluginColumn.GetIsSqlcSlice(), + IsEnum: false, + IsOverride: true, + DefaultType: strType, + SqlcSliceName: sqlcSliceName(pluginColumn), } if override.Resolved != nil { pyType.ConverterTo = override.Resolved.ToDB @@ -65,16 +71,27 @@ func (t *Transformer) buildPyType(pluginColumn *plugin.Column) model.PyType { } return model.PyType{ - SQLType: columnType, - Type: strType, - IsNullable: !pluginColumn.GetNotNull(), - IsList: pluginColumn.GetIsArray() || pluginColumn.GetIsSqlcSlice(), - IsEnum: isEnum, - IsOverride: false, - DefaultType: strType, + SQLType: columnType, + Type: strType, + IsNullable: isNullable, + IsList: pluginColumn.GetIsArray() || pluginColumn.GetIsSqlcSlice(), + IsEnum: isEnum, + IsOverride: false, + DefaultType: strType, + SqlcSliceName: sqlcSliceName(pluginColumn), } } +// sqlcSliceName returns the raw sqlc.slice parameter name; the marker sqlc +// leaves in the SQL is built from it, not from the escaped Python name. +func sqlcSliceName(pluginColumn *plugin.Column) string { + if pluginColumn.GetIsSqlcSlice() { + return pluginColumn.GetName() + } + + return "" +} + // matchOverride returns the first configured override matching the column, // either by column pattern or by exact SQL type. func (t *Transformer) matchOverride(pluginColumn *plugin.Column, columnType string) *config.Override { diff --git a/internal/transform/type_test.go b/internal/transform/type_test.go index 030138bb..ad5870d5 100644 --- a/internal/transform/type_test.go +++ b/internal/transform/type_test.go @@ -70,7 +70,15 @@ func TestBuildPyType(t *testing.T) { name: "sqlc slice parameter", options: typeTestBaseOptions, column: &plugin.Column{Name: "ids", Type: &plugin.Identifier{Name: "int4"}, NotNull: true, IsSqlcSlice: true}, - want: model.PyType{SQLType: "int4", Type: types.Int, IsList: true, DefaultType: types.Int}, + want: model.PyType{SQLType: "int4", Type: types.Int, IsList: true, DefaultType: types.Int, SqlcSliceName: "ids"}, + }, + { + // The generated expansion calls len() on the sequence, so a slice + // against a nullable column must not become "Sequence[T] | None". + name: "sqlc slice parameter on nullable column stays required", + options: typeTestBaseOptions, + column: &plugin.Column{Name: "notes", Type: &plugin.Identifier{Name: "text"}, IsSqlcSlice: true}, + want: model.PyType{SQLType: "text", Type: "str", IsList: true, DefaultType: "str", SqlcSliceName: "notes"}, }, { // DDL casing survives into the identifier; SQLType must come out @@ -109,6 +117,24 @@ func TestBuildPyType(t *testing.T) { column: &plugin.Column{Name: "uid", Type: &plugin.Identifier{Schema: "pg_catalog", Name: "uuid"}, NotNull: true}, want: model.PyType{SQLType: "pg_catalog.uuid", Type: "str", IsOverride: true, DefaultType: "uuid.UUID"}, }, + { + name: "db_type override on sqlc slice parameter keeps slice name", + options: typeTestOverrideOptions, + column: &plugin.Column{ + Name: "uids", + Type: &plugin.Identifier{Schema: "pg_catalog", Name: "uuid"}, + NotNull: true, + IsSqlcSlice: true, + }, + want: model.PyType{ + SQLType: "pg_catalog.uuid", + Type: "str", + IsList: true, + IsOverride: true, + DefaultType: "uuid.UUID", + SqlcSliceName: "uids", + }, + }, { name: "db_type override on enum column disables enum handling", options: typeTestOverrideOptions, diff --git a/sqlc.yaml b/sqlc.yaml index 4b589225..a16635a6 100644 --- a/sqlc.yaml +++ b/sqlc.yaml @@ -3,7 +3,7 @@ plugins: - name: python wasm: url: file://sqlc-gen-better-python.wasm - sha256: 73861329dc7deb8596b0fafc8514f798fe5c6e4e089bae2f56e974d3a9586c1f + sha256: 78698bcbf8188223f72f98228792f79a1e93dbaa20a6d41995e6eee962a3eb39 sql: - schema: test/schema.sql queries: test/queries.sql diff --git a/test/conftest.py b/test/conftest.py index 353cf79b..db64eaae 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -100,7 +100,7 @@ def sqlite3_conn( conn.commit() yield conn - conn.executescript("DELETE FROM test_sqlite_types;DELETE FROM test_inner_sqlite_types;DELETE FROM test_override_conversion;DELETE FROM test_type_override;DELETE FROM test_case_sensitivity;DELETE FROM test_reserved_args;DELETE FROM test_unknown_override;DELETE FROM test_any_param;") + conn.executescript("DELETE FROM test_sqlite_types;DELETE FROM test_inner_sqlite_types;DELETE FROM test_override_conversion;DELETE FROM test_type_override;DELETE FROM test_case_sensitivity;DELETE FROM test_reserved_args;DELETE FROM test_unknown_override;DELETE FROM test_any_param;DELETE FROM test_slice;") conn.commit() conn.close() @@ -115,7 +115,7 @@ async def aiosqlite_conn( await conn.commit() yield conn - await conn.executescript("""DELETE FROM test_sqlite_types;DELETE FROM test_inner_sqlite_types;DELETE FROM test_type_override;""") + await conn.executescript("""DELETE FROM test_sqlite_types;DELETE FROM test_inner_sqlite_types;DELETE FROM test_type_override;DELETE FROM test_slice;""") await conn.commit() await conn.close() @@ -137,14 +137,16 @@ async def asyncpg_delete_all(dsn: str) -> None: async def aiosqlite_delete_all(dsn: str) -> None: conn = await aiosqlite.connect(dsn, detect_types=sqlite3.PARSE_DECLTYPES) - # DROP IF EXISTS: these tables only exist once the sqlite3 driver schema - # ran, and the schema recreates them (IF NOT EXISTS) on the next run. - # Without this an aborted run leaves the fixed-id rows behind and the next - # run fails with an IntegrityError. + # DROP IF EXISTS: most of these tables only exist once the sqlite3 driver + # schema ran; test_slice is in both schemas but is dropped so older db + # files pick up column changes. The schemas recreate everything + # (IF NOT EXISTS) on the next run. Without this an aborted run leaves the + # fixed-id rows behind and the next run fails with an IntegrityError. await conn.executescript(""" DELETE FROM test_sqlite_types; DELETE FROM test_inner_sqlite_types; DELETE FROM test_type_override; + DROP TABLE IF EXISTS test_slice; DROP TABLE IF EXISTS test_override_conversion; DROP TABLE IF EXISTS test_case_sensitivity; DROP TABLE IF EXISTS test_reserved_args; diff --git a/test/driver_aiosqlite/dataclass/functions/models.py b/test/driver_aiosqlite/dataclass/functions/models.py index a7fea135..104044c9 100644 --- a/test/driver_aiosqlite/dataclass/functions/models.py +++ b/test/driver_aiosqlite/dataclass/functions/models.py @@ -8,6 +8,7 @@ __all__: collections.abc.Sequence[str] = ( "TestInnerSqliteType", + "TestSlice", "TestSqliteType", "TestTypeOverride", ) @@ -89,6 +90,21 @@ class TestInnerSqliteType: json_test: str | None +@dataclasses.dataclass() +class TestSlice: + """Model representing TestSlice. + + Attributes: + id_: int + name: str + note: str | None + """ + + id_: int + name: str + note: str | None + + @dataclasses.dataclass() class TestSqliteType: """Model representing TestSqliteType. diff --git a/test/driver_aiosqlite/dataclass/functions/queries_slice.py b/test/driver_aiosqlite/dataclass/functions/queries_slice.py new file mode 100644 index 00000000..ce2c6569 --- /dev/null +++ b/test/driver_aiosqlite/dataclass/functions/queries_slice.py @@ -0,0 +1,316 @@ +# Code generated by sqlc. DO NOT EDIT. +# versions: +# sqlc v1.31.1 +# sqlc-gen-better-python v0.5.1 +# source file: queries_slice.sql +"""Module containing queries from file queries_slice.sql.""" + +from __future__ import annotations + +__all__: collections.abc.Sequence[str] = ( + "QueryResults", + "delete_slice_rows", + "get_first_slice_name", + "get_slice_row_filtered", + "get_slice_rows", + "get_slice_rows_by_name_or_note", + "get_slice_rows_by_name_or_note_filtered", + "get_slice_rows_by_notes", + "insert_slice_row", +) + +import typing + +if typing.TYPE_CHECKING: + import aiosqlite + import collections.abc + import sqlite3 + + type QueryResultsArgsType = int | float | str | memoryview | collections.abc.Sequence[QueryResultsArgsType] | None + +from test.driver_aiosqlite.dataclass.functions import models + + +INSERT_SLICE_ROW: typing.Final[str] = """-- name: InsertSliceRow :exec +INSERT INTO test_slice (id, name, note) VALUES (?, ?, ?) +""" + +GET_SLICE_ROWS: typing.Final[str] = """-- name: GetSliceRows :many +SELECT id, name, note FROM test_slice WHERE id IN (/*SLICE:ids*/?) ORDER BY id +""" + +GET_SLICE_ROW_FILTERED: typing.Final[str] = """-- name: GetSliceRowFiltered :one +SELECT id, name, note FROM test_slice WHERE name = ? AND id IN (/*SLICE:ids*/?) AND id != ? LIMIT 1 +""" + +GET_SLICE_ROWS_BY_NOTES: typing.Final[str] = """-- name: GetSliceRowsByNotes :many +SELECT id, name, note FROM test_slice WHERE note IN (/*SLICE:notes*/?) ORDER BY id +""" + +GET_FIRST_SLICE_NAME: typing.Final[str] = """-- name: GetFirstSliceName :one +SELECT name FROM test_slice WHERE id IN (/*SLICE:ids*/?) OR name IN (/*SLICE:names*/?) ORDER BY id LIMIT 1 +""" + +GET_SLICE_ROWS_BY_NAME_OR_NOTE: typing.Final[str] = """-- name: GetSliceRowsByNameOrNote :many +SELECT id, name, note FROM test_slice WHERE name IN (/*SLICE:names*/?) OR note IN (/*SLICE:names*/?) ORDER BY id +""" + +GET_SLICE_ROWS_BY_NAME_OR_NOTE_FILTERED: typing.Final[str] = """-- name: GetSliceRowsByNameOrNoteFiltered :many +SELECT id, name, note FROM test_slice WHERE name IN (/*SLICE:names*/?) AND id != ? OR note IN (/*SLICE:names*/?) ORDER BY id +""" + +DELETE_SLICE_ROWS: typing.Final[str] = """-- name: DeleteSliceRows :execrows +DELETE FROM test_slice WHERE id IN (/*SLICE:ids*/?) +""" + + +class QueryResults[T]: + """Helper class that allows both iteration and normal fetching of data from the db.""" + + __slots__ = ("_args", "_conn", "_cursor", "_decode_hook", "_iterator", "_sql") + + def __init__( + self, + conn: aiosqlite.Connection, + sql: str, + decode_hook: collections.abc.Callable[[sqlite3.Row], T], + *args: QueryResultsArgsType, + ) -> None: + """Initialize the QueryResults instance. + + Args: + conn: + The connection object of type `aiosqlite.Connection` used to execute queries. + sql: + The SQL statement that will be executed when fetching/iterating. + decode_hook: + A callback that turns an `sqlite3.Row` object into `T` that will be returned. + *args: + Arguments that should be sent when executing the sql query. + """ + self._conn = conn + self._sql = sql + self._decode_hook = decode_hook + self._args = args + self._cursor: aiosqlite.Cursor | None = None + self._iterator: collections.abc.AsyncIterator[sqlite3.Row] | None = None + + def __aiter__(self) -> QueryResults[T]: + """Initialize iteration support for `async for`. + + Returns: + Self as an asynchronous iterator. + """ + return self + + def __await__( + self, + ) -> collections.abc.Generator[None, None, collections.abc.Sequence[T]]: + """Allow `await` on the object to return all rows as a fully decoded sequence. + + Returns: + A sequence of decoded objects of type `T`. + """ + + async def _wrapper() -> collections.abc.Sequence[T]: + result = await (await self._conn.execute(self._sql, self._args)).fetchall() + return [self._decode_hook(row) for row in result] + + return _wrapper().__await__() + + async def __anext__(self) -> T: + """Yield the next item in the query result using an aiosqlite cursor. + + Returns: + The next decoded result of type `T`. + + Raises: + StopAsyncIteration: When no more records are available. + """ + if self._cursor is None or self._iterator is None: + self._cursor: aiosqlite.Cursor | None = await self._conn.execute(self._sql, self._args) + self._iterator = self._cursor.__aiter__() + try: + record = await self._iterator.__anext__() + except StopAsyncIteration: + self._cursor = None + self._iterator = None + raise + return self._decode_hook(record) + + +async def insert_slice_row(conn: aiosqlite.Connection, *, id_: int, name: str, note: str | None) -> None: + """Execute SQL query with `name: InsertSliceRow :exec`. + + ```sql + INSERT INTO test_slice (id, name, note) VALUES (?, ?, ?) + ``` + + Args: + conn: + Connection object of type `aiosqlite.Connection` used to execute the query. + id_: int. + name: str. + note: str | None. + """ + await conn.execute(INSERT_SLICE_ROW, (id_, name, note)) + + +def get_slice_rows(conn: aiosqlite.Connection, *, ids: collections.abc.Sequence[int]) -> QueryResults[models.TestSlice]: + """Fetch many from the db using the SQL query with `name: GetSliceRows :many`. + + ```sql + SELECT id, name, note FROM test_slice WHERE id IN (/*SLICE:ids*/?) ORDER BY id + ``` + + Args: + conn: + Connection object of type `aiosqlite.Connection` used to execute the query. + ids: collections.abc.Sequence[int]. + + Returns: + Helper class of type `QueryResults[models.TestSlice]` that allows both iteration and normal fetching of data from the db. + """ + + def _decode_hook(row: sqlite3.Row) -> models.TestSlice: + return models.TestSlice(id_=row[0], name=row[1], note=row[2]) + + sql = GET_SLICE_ROWS.replace("/*SLICE:ids*/?", ",".join("?" * len(ids)) or "NULL", 1) + return QueryResults(conn, sql, _decode_hook, *ids) + + +async def get_slice_row_filtered(conn: aiosqlite.Connection, *, name: str, ids: collections.abc.Sequence[int], id_: int) -> models.TestSlice | None: + """Fetch one from the db using the SQL query with `name: GetSliceRowFiltered :one`. + + ```sql + SELECT id, name, note FROM test_slice WHERE name = ? AND id IN (/*SLICE:ids*/?) AND id != ? LIMIT 1 + ``` + + Args: + conn: + Connection object of type `aiosqlite.Connection` used to execute the query. + name: str. + ids: collections.abc.Sequence[int]. + id_: int. + + Returns: + Result of type `models.TestSlice` fetched from the db. Will be `None` if not found. + """ + sql = GET_SLICE_ROW_FILTERED.replace("/*SLICE:ids*/?", ",".join("?" * len(ids)) or "NULL", 1) + row = await (await conn.execute(sql, (name, *ids, id_))).fetchone() + if row is None: + return None + return models.TestSlice(id_=row[0], name=row[1], note=row[2]) + + +def get_slice_rows_by_notes(conn: aiosqlite.Connection, *, notes: collections.abc.Sequence[str]) -> QueryResults[models.TestSlice]: + """Fetch many from the db using the SQL query with `name: GetSliceRowsByNotes :many`. + + ```sql + SELECT id, name, note FROM test_slice WHERE note IN (/*SLICE:notes*/?) ORDER BY id + ``` + + Args: + conn: + Connection object of type `aiosqlite.Connection` used to execute the query. + notes: collections.abc.Sequence[str]. + + Returns: + Helper class of type `QueryResults[models.TestSlice]` that allows both iteration and normal fetching of data from the db. + """ + + def _decode_hook(row: sqlite3.Row) -> models.TestSlice: + return models.TestSlice(id_=row[0], name=row[1], note=row[2]) + + sql = GET_SLICE_ROWS_BY_NOTES.replace("/*SLICE:notes*/?", ",".join("?" * len(notes)) or "NULL", 1) + return QueryResults(conn, sql, _decode_hook, *notes) + + +async def get_first_slice_name(conn: aiosqlite.Connection, *, ids: collections.abc.Sequence[int], names: collections.abc.Sequence[str]) -> str | None: + """Fetch one from the db using the SQL query with `name: GetFirstSliceName :one`. + + ```sql + SELECT name FROM test_slice WHERE id IN (/*SLICE:ids*/?) OR name IN (/*SLICE:names*/?) ORDER BY id LIMIT 1 + ``` + + Args: + conn: + Connection object of type `aiosqlite.Connection` used to execute the query. + ids: collections.abc.Sequence[int]. + names: collections.abc.Sequence[str]. + + Returns: + Result of type `str` fetched from the db. Will be `None` if not found. + """ + sql = GET_FIRST_SLICE_NAME.replace("/*SLICE:ids*/?", ",".join("?" * len(ids)) or "NULL", 1) + sql = sql.replace("/*SLICE:names*/?", ",".join("?" * len(names)) or "NULL", 1) + row = await (await conn.execute(sql, (*ids, *names))).fetchone() + if row is None: + return None + return row[0] + + +def get_slice_rows_by_name_or_note(conn: aiosqlite.Connection, *, names: collections.abc.Sequence[str]) -> QueryResults[models.TestSlice]: + """Fetch many from the db using the SQL query with `name: GetSliceRowsByNameOrNote :many`. + + ```sql + SELECT id, name, note FROM test_slice WHERE name IN (/*SLICE:names*/?) OR note IN (/*SLICE:names*/?) ORDER BY id + ``` + + Args: + conn: + Connection object of type `aiosqlite.Connection` used to execute the query. + names: collections.abc.Sequence[str]. + + Returns: + Helper class of type `QueryResults[models.TestSlice]` that allows both iteration and normal fetching of data from the db. + """ + + def _decode_hook(row: sqlite3.Row) -> models.TestSlice: + return models.TestSlice(id_=row[0], name=row[1], note=row[2]) + + sql = GET_SLICE_ROWS_BY_NAME_OR_NOTE.replace("/*SLICE:names*/?", ",".join("?" * len(names)) or "NULL") + return QueryResults(conn, sql, _decode_hook, *names, *names) + + +def get_slice_rows_by_name_or_note_filtered(conn: aiosqlite.Connection, *, names: collections.abc.Sequence[str], id_: int) -> QueryResults[models.TestSlice]: + """Fetch many from the db using the SQL query with `name: GetSliceRowsByNameOrNoteFiltered :many`. + + ```sql + SELECT id, name, note FROM test_slice WHERE name IN (/*SLICE:names*/?) AND id != ? OR note IN (/*SLICE:names*/?) ORDER BY id + ``` + + Args: + conn: + Connection object of type `aiosqlite.Connection` used to execute the query. + names: collections.abc.Sequence[str]. + id_: int. + + Returns: + Helper class of type `QueryResults[models.TestSlice]` that allows both iteration and normal fetching of data from the db. + """ + + def _decode_hook(row: sqlite3.Row) -> models.TestSlice: + return models.TestSlice(id_=row[0], name=row[1], note=row[2]) + + sql = GET_SLICE_ROWS_BY_NAME_OR_NOTE_FILTERED.replace("/*SLICE:names*/?", ",".join("?" * len(names)) or "NULL") + return QueryResults(conn, sql, _decode_hook, *names, id_, *names) + + +async def delete_slice_rows(conn: aiosqlite.Connection, *, ids: collections.abc.Sequence[int]) -> int: + """Execute SQL query with `name: DeleteSliceRows :execrows` and return the number of affected rows. + + ```sql + DELETE FROM test_slice WHERE id IN (/*SLICE:ids*/?) + ``` + + Args: + conn: + Connection object of type `aiosqlite.Connection` used to execute the query. + ids: collections.abc.Sequence[int]. + + Returns: + The number (`int`) of affected rows. This will be -1 for queries like `CREATE TABLE`. + """ + sql = DELETE_SLICE_ROWS.replace("/*SLICE:ids*/?", ",".join("?" * len(ids)) or "NULL", 1) + return (await conn.execute(sql, (*ids,))).rowcount diff --git a/test/driver_aiosqlite/dataclass/test_aiosqlite_dataclass_functions.py b/test/driver_aiosqlite/dataclass/test_aiosqlite_dataclass_functions.py index 764124bc..c22fa92b 100644 --- a/test/driver_aiosqlite/dataclass/test_aiosqlite_dataclass_functions.py +++ b/test/driver_aiosqlite/dataclass/test_aiosqlite_dataclass_functions.py @@ -32,6 +32,10 @@ from test.driver_aiosqlite.dataclass.functions import models from test.driver_aiosqlite.dataclass.functions import queries +from test.driver_aiosqlite.dataclass.functions import queries_slice + +SLICE_ID_BASE = 595959 +SLICE_ROW_COUNT = 4 @pytest.mark.asyncio(loop_scope="session") @@ -996,3 +1000,122 @@ async def test_get_many_text_type_override(self, aiosqlite_conn: aiosqlite.Conne ) async def test_delete_type_override(self, aiosqlite_conn: aiosqlite.Connection, override_model: models.TestTypeOverride) -> None: await queries.delete_type_override(conn=aiosqlite_conn, id_=override_model.id_) + + @pytest.mark.asyncio(loop_scope="session") + @pytest.mark.dependency(name="AiosqliteTestDataclassFunctions::insert_slice_rows") + async def test_insert_slice_rows(self, aiosqlite_conn: aiosqlite.Connection) -> None: + for offset, (name, note) in enumerate((("a", "x"), ("b", "y"), ("c", None), ("b", "y"))): + await queries_slice.insert_slice_row(conn=aiosqlite_conn, id_=SLICE_ID_BASE + offset, name=name, note=note) + + @pytest.mark.asyncio(loop_scope="session") + @pytest.mark.dependency( + name="AiosqliteTestDataclassFunctions::get_slice_rows", + depends=["AiosqliteTestDataclassFunctions::insert_slice_rows"], + ) + async def test_get_slice_rows(self, aiosqlite_conn: aiosqlite.Connection) -> None: + rows = await queries_slice.get_slice_rows(conn=aiosqlite_conn, ids=[SLICE_ID_BASE, SLICE_ID_BASE + 2]) + assert rows == [ + models.TestSlice(id_=SLICE_ID_BASE, name="a", note="x"), + models.TestSlice(id_=SLICE_ID_BASE + 2, name="c", note=None), + ] + seen = [row async for row in queries_slice.get_slice_rows(conn=aiosqlite_conn, ids=[SLICE_ID_BASE, SLICE_ID_BASE + 2])] + assert seen == rows + + @pytest.mark.asyncio(loop_scope="session") + @pytest.mark.dependency( + name="AiosqliteTestDataclassFunctions::get_slice_rows_empty_slice", + depends=["AiosqliteTestDataclassFunctions::insert_slice_rows"], + ) + async def test_get_slice_rows_empty_slice(self, aiosqlite_conn: aiosqlite.Connection) -> None: + # An empty sequence expands the placeholder to NULL: IN (NULL) + # matches no rows instead of raising. + assert await queries_slice.get_slice_rows(conn=aiosqlite_conn, ids=[]) == [] + + @pytest.mark.asyncio(loop_scope="session") + @pytest.mark.dependency( + name="AiosqliteTestDataclassFunctions::get_slice_row_filtered", + depends=["AiosqliteTestDataclassFunctions::insert_slice_rows"], + ) + async def test_get_slice_row_filtered(self, aiosqlite_conn: aiosqlite.Connection) -> None: + # Plain params surround the slice, so this proves the flattened + # argument tuple binds in SQL text order. + row = await queries_slice.get_slice_row_filtered( + conn=aiosqlite_conn, + name="b", + ids=[SLICE_ID_BASE + 1, SLICE_ID_BASE + 3], + id_=SLICE_ID_BASE + 1, + ) + assert row == models.TestSlice(id_=SLICE_ID_BASE + 3, name="b", note="y") + assert await queries_slice.get_slice_row_filtered(conn=aiosqlite_conn, name="a", ids=[SLICE_ID_BASE], id_=SLICE_ID_BASE) is None + + @pytest.mark.asyncio(loop_scope="session") + @pytest.mark.dependency( + name="AiosqliteTestDataclassFunctions::get_slice_rows_by_notes", + depends=["AiosqliteTestDataclassFunctions::insert_slice_rows"], + ) + async def test_get_slice_rows_by_notes(self, aiosqlite_conn: aiosqlite.Connection) -> None: + # The slice targets a nullable column; the parameter is still a plain + # Sequence, and rows whose note is NULL never match. + rows = await queries_slice.get_slice_rows_by_notes(conn=aiosqlite_conn, notes=["y"]) + assert rows == [ + models.TestSlice(id_=SLICE_ID_BASE + 1, name="b", note="y"), + models.TestSlice(id_=SLICE_ID_BASE + 3, name="b", note="y"), + ] + assert await queries_slice.get_slice_rows_by_notes(conn=aiosqlite_conn, notes=[]) == [] + + @pytest.mark.asyncio(loop_scope="session") + @pytest.mark.dependency( + name="AiosqliteTestDataclassFunctions::get_slice_rows_by_name_or_note", + depends=["AiosqliteTestDataclassFunctions::insert_slice_rows"], + ) + async def test_get_slice_rows_by_name_or_note(self, aiosqlite_conn: aiosqlite.Connection) -> None: + # The same slice name is used twice, so every marker occurrence is + # expanded and the sequence is bound once per occurrence. + rows = await queries_slice.get_slice_rows_by_name_or_note(conn=aiosqlite_conn, names=["b", "x"]) + assert rows == [ + models.TestSlice(id_=SLICE_ID_BASE, name="a", note="x"), + models.TestSlice(id_=SLICE_ID_BASE + 1, name="b", note="y"), + models.TestSlice(id_=SLICE_ID_BASE + 3, name="b", note="y"), + ] + assert await queries_slice.get_slice_rows_by_name_or_note(conn=aiosqlite_conn, names=[]) == [] + + @pytest.mark.asyncio(loop_scope="session") + @pytest.mark.dependency( + name="AiosqliteTestDataclassFunctions::get_slice_rows_by_name_or_note_filtered", + depends=["AiosqliteTestDataclassFunctions::insert_slice_rows"], + ) + async def test_get_slice_rows_by_name_or_note_filtered(self, aiosqlite_conn: aiosqlite.Connection) -> None: + # A plain parameter sits between the two uses of the slice, so this + # proves the flattened arguments follow SQL text order. + rows = await queries_slice.get_slice_rows_by_name_or_note_filtered(conn=aiosqlite_conn, names=["b", "x"], id_=SLICE_ID_BASE + 1) + assert rows == [ + models.TestSlice(id_=SLICE_ID_BASE, name="a", note="x"), + models.TestSlice(id_=SLICE_ID_BASE + 3, name="b", note="y"), + ] + assert await queries_slice.get_slice_rows_by_name_or_note_filtered(conn=aiosqlite_conn, names=[], id_=SLICE_ID_BASE) == [] + + @pytest.mark.asyncio(loop_scope="session") + @pytest.mark.dependency( + name="AiosqliteTestDataclassFunctions::get_first_slice_name_two_slices", + depends=["AiosqliteTestDataclassFunctions::insert_slice_rows"], + ) + async def test_get_first_slice_name_two_slices(self, aiosqlite_conn: aiosqlite.Connection) -> None: + assert await queries_slice.get_first_slice_name(conn=aiosqlite_conn, ids=[SLICE_ID_BASE + 1], names=["a"]) == "a" + assert await queries_slice.get_first_slice_name(conn=aiosqlite_conn, ids=[], names=[]) is None + + @pytest.mark.asyncio(loop_scope="session") + @pytest.mark.dependency( + depends=[ + "AiosqliteTestDataclassFunctions::get_slice_rows", + "AiosqliteTestDataclassFunctions::get_slice_rows_empty_slice", + "AiosqliteTestDataclassFunctions::get_slice_row_filtered", + "AiosqliteTestDataclassFunctions::get_slice_rows_by_notes", + "AiosqliteTestDataclassFunctions::get_slice_rows_by_name_or_note", + "AiosqliteTestDataclassFunctions::get_slice_rows_by_name_or_note_filtered", + "AiosqliteTestDataclassFunctions::get_first_slice_name_two_slices", + ], + ) + async def test_delete_slice_rows(self, aiosqlite_conn: aiosqlite.Connection) -> None: + assert await queries_slice.delete_slice_rows(conn=aiosqlite_conn, ids=[]) == 0 + deleted = await queries_slice.delete_slice_rows(conn=aiosqlite_conn, ids=[SLICE_ID_BASE + offset for offset in range(SLICE_ROW_COUNT)]) + assert deleted == SLICE_ROW_COUNT diff --git a/test/driver_aiosqlite/queries_slice.sql b/test/driver_aiosqlite/queries_slice.sql new file mode 100644 index 00000000..46cab028 --- /dev/null +++ b/test/driver_aiosqlite/queries_slice.sql @@ -0,0 +1,23 @@ +-- name: InsertSliceRow :exec +INSERT INTO test_slice (id, name, note) VALUES (?, ?, ?); + +-- name: GetSliceRows :many +SELECT * FROM test_slice WHERE id IN (sqlc.slice('ids')) ORDER BY id; + +-- name: GetSliceRowFiltered :one +SELECT * FROM test_slice WHERE name = ? AND id IN (sqlc.slice('ids')) AND id != ? LIMIT 1; + +-- name: GetSliceRowsByNotes :many +SELECT * FROM test_slice WHERE note IN (sqlc.slice('notes')) ORDER BY id; + +-- name: GetFirstSliceName :one +SELECT name FROM test_slice WHERE id IN (sqlc.slice('ids')) OR name IN (sqlc.slice('names')) ORDER BY id LIMIT 1; + +-- name: GetSliceRowsByNameOrNote :many +SELECT * FROM test_slice WHERE name IN (sqlc.slice('names')) OR note IN (sqlc.slice('names')) ORDER BY id; + +-- name: GetSliceRowsByNameOrNoteFiltered :many +SELECT * FROM test_slice WHERE name IN (sqlc.slice('names')) AND id != ? OR note IN (sqlc.slice('names')) ORDER BY id; + +-- name: DeleteSliceRows :execrows +DELETE FROM test_slice WHERE id IN (sqlc.slice('ids')); diff --git a/test/driver_aiosqlite/schema.sql b/test/driver_aiosqlite/schema.sql index 3f101cac..5e6009a9 100644 --- a/test/driver_aiosqlite/schema.sql +++ b/test/driver_aiosqlite/schema.sql @@ -82,4 +82,12 @@ CREATE TABLE IF NOT EXISTS test_type_override ( id integer PRIMARY KEY NOT NULL, text_test text -); \ No newline at end of file +); +-- Variable-length IN lists via sqlc.slice: the /*SLICE:name*/ placeholder in +-- the SQL constant is expanded at call time, one "?" per element. +CREATE TABLE IF NOT EXISTS test_slice +( + id integer PRIMARY KEY NOT NULL, + name text NOT NULL, + note text +); diff --git a/test/driver_aiosqlite/sqlc-gen-better-python.wasm b/test/driver_aiosqlite/sqlc-gen-better-python.wasm index d5e8cb16..4e0b96b3 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 852ed99c..0df85f39 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: 73861329dc7deb8596b0fafc8514f798fe5c6e4e089bae2f56e974d3a9586c1f + sha256: 78698bcbf8188223f72f98228792f79a1e93dbaa20a6d41995e6eee962a3eb39 sql: - schema: schema.sql queries: queries.sql @@ -68,7 +68,9 @@ sql: package: UserString type: UserString - schema: schema.sql - queries: queries.sql + queries: + - queries.sql + - queries_slice.sql engine: sqlite codegen: - out: /dataclass/functions diff --git a/test/driver_asyncpg/sqlc-gen-better-python.wasm b/test/driver_asyncpg/sqlc-gen-better-python.wasm index d5e8cb16..4e0b96b3 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 dece6419..6bfd8098 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: 73861329dc7deb8596b0fafc8514f798fe5c6e4e089bae2f56e974d3a9586c1f + sha256: 78698bcbf8188223f72f98228792f79a1e93dbaa20a6d41995e6eee962a3eb39 sql: - schema: schema.sql queries: diff --git a/test/driver_sqlite3/attrs/classes/models.py b/test/driver_sqlite3/attrs/classes/models.py index 88841d8c..ddaad85a 100644 --- a/test/driver_sqlite3/attrs/classes/models.py +++ b/test/driver_sqlite3/attrs/classes/models.py @@ -12,6 +12,7 @@ "TestInnerSqliteType", "TestOverrideConversion", "TestReservedArg", + "TestSlice", "TestSqliteType", "TestTypeOverride", "TestUnknownOverride", @@ -160,6 +161,23 @@ class TestReservedArg: conn: str +@attrs.define() +class TestSlice: + """Model representing TestSlice. + + Attributes + ---------- + id_ : int + name : str + note : str | None + + """ + + id_: int + name: str + note: str | None + + @attrs.define() class TestSqliteType: """Model representing TestSqliteType. diff --git a/test/driver_sqlite3/dataclass/functions/models.py b/test/driver_sqlite3/dataclass/functions/models.py index a7fea135..104044c9 100644 --- a/test/driver_sqlite3/dataclass/functions/models.py +++ b/test/driver_sqlite3/dataclass/functions/models.py @@ -8,6 +8,7 @@ __all__: collections.abc.Sequence[str] = ( "TestInnerSqliteType", + "TestSlice", "TestSqliteType", "TestTypeOverride", ) @@ -89,6 +90,21 @@ class TestInnerSqliteType: json_test: str | None +@dataclasses.dataclass() +class TestSlice: + """Model representing TestSlice. + + Attributes: + id_: int + name: str + note: str | None + """ + + id_: int + name: str + note: str | None + + @dataclasses.dataclass() class TestSqliteType: """Model representing TestSqliteType. diff --git a/test/driver_sqlite3/dataclass/functions/queries_slice.py b/test/driver_sqlite3/dataclass/functions/queries_slice.py new file mode 100644 index 00000000..ee84b3c1 --- /dev/null +++ b/test/driver_sqlite3/dataclass/functions/queries_slice.py @@ -0,0 +1,311 @@ +# Code generated by sqlc. DO NOT EDIT. +# versions: +# sqlc v1.31.1 +# sqlc-gen-better-python v0.5.1 +# source file: queries_slice.sql +"""Module containing queries from file queries_slice.sql.""" + +from __future__ import annotations + +__all__: collections.abc.Sequence[str] = ( + "QueryResults", + "delete_slice_rows", + "get_first_slice_name", + "get_slice_row_filtered", + "get_slice_rows", + "get_slice_rows_by_name_or_note", + "get_slice_rows_by_name_or_note_filtered", + "get_slice_rows_by_notes", + "insert_slice_row", +) + +import typing + +if typing.TYPE_CHECKING: + import collections.abc + import sqlite3 + + type QueryResultsArgsType = int | float | str | memoryview | collections.abc.Sequence[QueryResultsArgsType] | None + +from test.driver_sqlite3.dataclass.functions import models + + +INSERT_SLICE_ROW: typing.Final[str] = """-- name: InsertSliceRow :exec +INSERT INTO test_slice (id, name, note) VALUES (?, ?, ?) +""" + +GET_SLICE_ROWS: typing.Final[str] = """-- name: GetSliceRows :many +SELECT id, name, note FROM test_slice WHERE id IN (/*SLICE:ids*/?) ORDER BY id +""" + +GET_SLICE_ROW_FILTERED: typing.Final[str] = """-- name: GetSliceRowFiltered :one +SELECT id, name, note FROM test_slice WHERE name = ? AND id IN (/*SLICE:ids*/?) AND id != ? LIMIT 1 +""" + +GET_SLICE_ROWS_BY_NOTES: typing.Final[str] = """-- name: GetSliceRowsByNotes :many +SELECT id, name, note FROM test_slice WHERE note IN (/*SLICE:notes*/?) ORDER BY id +""" + +GET_FIRST_SLICE_NAME: typing.Final[str] = """-- name: GetFirstSliceName :one +SELECT name FROM test_slice WHERE id IN (/*SLICE:ids*/?) OR name IN (/*SLICE:names*/?) ORDER BY id LIMIT 1 +""" + +GET_SLICE_ROWS_BY_NAME_OR_NOTE: typing.Final[str] = """-- name: GetSliceRowsByNameOrNote :many +SELECT id, name, note FROM test_slice WHERE name IN (/*SLICE:names*/?) OR note IN (/*SLICE:names*/?) ORDER BY id +""" + +GET_SLICE_ROWS_BY_NAME_OR_NOTE_FILTERED: typing.Final[str] = """-- name: GetSliceRowsByNameOrNoteFiltered :many +SELECT id, name, note FROM test_slice WHERE name IN (/*SLICE:names*/?) AND id != ? OR note IN (/*SLICE:names*/?) ORDER BY id +""" + +DELETE_SLICE_ROWS: typing.Final[str] = """-- name: DeleteSliceRows :execrows +DELETE FROM test_slice WHERE id IN (/*SLICE:ids*/?) +""" + + +class QueryResults[T]: + """Helper class that allows both iteration and normal fetching of data from the db.""" + + __slots__ = ("_args", "_conn", "_cursor", "_decode_hook", "_iterator", "_sql") + + def __init__( + self, + conn: sqlite3.Connection, + sql: str, + decode_hook: collections.abc.Callable[[sqlite3.Row], T], + *args: QueryResultsArgsType, + ) -> None: + """Initialize the QueryResults instance. + + Args: + conn: + The connection object of type `sqlite3.Connection` used to execute queries. + sql: + The SQL statement that will be executed when fetching/iterating. + decode_hook: + A callback that turns an `sqlite3.Row` object into `T` that will be returned. + *args: + Arguments that should be sent when executing the sql query. + """ + self._conn = conn + self._sql = sql + self._decode_hook = decode_hook + self._args = args + self._cursor: sqlite3.Cursor | None = None + self._iterator: collections.abc.Iterator[sqlite3.Row] | None = None + + def __iter__(self) -> QueryResults[T]: + """Initialize iteration support. + + Returns: + Self as an iterator. + """ + return self + + def __call__( + self, + ) -> collections.abc.Sequence[T]: + """Allow calling the object to return all rows as a fully decoded sequence. + + Returns: + A sequence of decoded objects of type `T`. + """ + result = self._conn.execute(self._sql, self._args).fetchall() + return [self._decode_hook(row) for row in result] + + def __next__(self) -> T: + """Yield the next item in the query result using a sqlite3 cursor. + + Returns: + The next decoded result of type `T`. + + Raises: + StopIteration: When no more records are available. + """ + if self._cursor is None or self._iterator is None: + self._cursor: sqlite3.Cursor | None = self._conn.execute(self._sql, self._args) + self._iterator = self._cursor.__iter__() + try: + record = self._iterator.__next__() + except StopIteration: + self._cursor = None + self._iterator = None + raise + return self._decode_hook(record) + + +def insert_slice_row(conn: sqlite3.Connection, *, id_: int, name: str, note: str | None) -> None: + """Execute SQL query with `name: InsertSliceRow :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_SLICE_ROW, (id_, name, note)) + + +def get_slice_rows(conn: sqlite3.Connection, *, ids: collections.abc.Sequence[int]) -> QueryResults[models.TestSlice]: + """Fetch many from the db using the SQL query with `name: GetSliceRows :many`. + + ```sql + SELECT id, name, note FROM test_slice WHERE id IN (/*SLICE:ids*/?) ORDER BY id + ``` + + Args: + conn: + Connection object of type `sqlite3.Connection` used to execute the query. + ids: collections.abc.Sequence[int]. + + Returns: + Helper class of type `QueryResults[models.TestSlice]` that allows both iteration and normal fetching of data from the db. + """ + + def _decode_hook(row: sqlite3.Row) -> models.TestSlice: + return models.TestSlice(id_=row[0], name=row[1], note=row[2]) + + sql = GET_SLICE_ROWS.replace("/*SLICE:ids*/?", ",".join("?" * len(ids)) or "NULL", 1) + return QueryResults(conn, sql, _decode_hook, *ids) + + +def get_slice_row_filtered(conn: sqlite3.Connection, *, name: str, ids: collections.abc.Sequence[int], id_: int) -> models.TestSlice | None: + """Fetch one from the db using the SQL query with `name: GetSliceRowFiltered :one`. + + ```sql + SELECT id, name, note FROM test_slice WHERE name = ? AND id IN (/*SLICE:ids*/?) AND id != ? LIMIT 1 + ``` + + Args: + conn: + Connection object of type `sqlite3.Connection` used to execute the query. + name: str. + ids: collections.abc.Sequence[int]. + id_: int. + + Returns: + Result of type `models.TestSlice` fetched from the db. Will be `None` if not found. + """ + sql = GET_SLICE_ROW_FILTERED.replace("/*SLICE:ids*/?", ",".join("?" * len(ids)) or "NULL", 1) + row = conn.execute(sql, (name, *ids, id_)).fetchone() + if row is None: + return None + return models.TestSlice(id_=row[0], name=row[1], note=row[2]) + + +def get_slice_rows_by_notes(conn: sqlite3.Connection, *, notes: collections.abc.Sequence[str]) -> QueryResults[models.TestSlice]: + """Fetch many from the db using the SQL query with `name: GetSliceRowsByNotes :many`. + + ```sql + SELECT id, name, note FROM test_slice WHERE note IN (/*SLICE:notes*/?) ORDER BY id + ``` + + Args: + conn: + Connection object of type `sqlite3.Connection` used to execute the query. + notes: collections.abc.Sequence[str]. + + Returns: + Helper class of type `QueryResults[models.TestSlice]` that allows both iteration and normal fetching of data from the db. + """ + + def _decode_hook(row: sqlite3.Row) -> models.TestSlice: + return models.TestSlice(id_=row[0], name=row[1], note=row[2]) + + sql = GET_SLICE_ROWS_BY_NOTES.replace("/*SLICE:notes*/?", ",".join("?" * len(notes)) or "NULL", 1) + return QueryResults(conn, sql, _decode_hook, *notes) + + +def get_first_slice_name(conn: sqlite3.Connection, *, ids: collections.abc.Sequence[int], names: collections.abc.Sequence[str]) -> str | None: + """Fetch one from the db using the SQL query with `name: GetFirstSliceName :one`. + + ```sql + SELECT name FROM test_slice WHERE id IN (/*SLICE:ids*/?) OR name IN (/*SLICE:names*/?) ORDER BY id LIMIT 1 + ``` + + Args: + conn: + Connection object of type `sqlite3.Connection` used to execute the query. + ids: collections.abc.Sequence[int]. + names: collections.abc.Sequence[str]. + + Returns: + Result of type `str` fetched from the db. Will be `None` if not found. + """ + sql = GET_FIRST_SLICE_NAME.replace("/*SLICE:ids*/?", ",".join("?" * len(ids)) or "NULL", 1) + sql = sql.replace("/*SLICE:names*/?", ",".join("?" * len(names)) or "NULL", 1) + row = conn.execute(sql, (*ids, *names)).fetchone() + if row is None: + return None + return row[0] + + +def get_slice_rows_by_name_or_note(conn: sqlite3.Connection, *, names: collections.abc.Sequence[str]) -> QueryResults[models.TestSlice]: + """Fetch many from the db using the SQL query with `name: GetSliceRowsByNameOrNote :many`. + + ```sql + SELECT id, name, note FROM test_slice WHERE name IN (/*SLICE:names*/?) OR note IN (/*SLICE:names*/?) ORDER BY id + ``` + + Args: + conn: + Connection object of type `sqlite3.Connection` used to execute the query. + names: collections.abc.Sequence[str]. + + Returns: + Helper class of type `QueryResults[models.TestSlice]` that allows both iteration and normal fetching of data from the db. + """ + + def _decode_hook(row: sqlite3.Row) -> models.TestSlice: + return models.TestSlice(id_=row[0], name=row[1], note=row[2]) + + sql = GET_SLICE_ROWS_BY_NAME_OR_NOTE.replace("/*SLICE:names*/?", ",".join("?" * len(names)) or "NULL") + return QueryResults(conn, sql, _decode_hook, *names, *names) + + +def get_slice_rows_by_name_or_note_filtered(conn: sqlite3.Connection, *, names: collections.abc.Sequence[str], id_: int) -> QueryResults[models.TestSlice]: + """Fetch many from the db using the SQL query with `name: GetSliceRowsByNameOrNoteFiltered :many`. + + ```sql + SELECT id, name, note FROM test_slice WHERE name IN (/*SLICE:names*/?) AND id != ? OR note IN (/*SLICE:names*/?) ORDER BY id + ``` + + Args: + conn: + Connection object of type `sqlite3.Connection` used to execute the query. + names: collections.abc.Sequence[str]. + id_: int. + + Returns: + Helper class of type `QueryResults[models.TestSlice]` that allows both iteration and normal fetching of data from the db. + """ + + def _decode_hook(row: sqlite3.Row) -> models.TestSlice: + return models.TestSlice(id_=row[0], name=row[1], note=row[2]) + + sql = GET_SLICE_ROWS_BY_NAME_OR_NOTE_FILTERED.replace("/*SLICE:names*/?", ",".join("?" * len(names)) or "NULL") + return QueryResults(conn, sql, _decode_hook, *names, id_, *names) + + +def delete_slice_rows(conn: sqlite3.Connection, *, ids: collections.abc.Sequence[int]) -> int: + """Execute SQL query with `name: DeleteSliceRows :execrows` and return the number of affected rows. + + ```sql + DELETE FROM test_slice WHERE id IN (/*SLICE:ids*/?) + ``` + + Args: + conn: + Connection object of type `sqlite3.Connection` used to execute the query. + ids: collections.abc.Sequence[int]. + + Returns: + The number (`int`) of affected rows. This will be -1 for queries like `CREATE TABLE`. + """ + sql = DELETE_SLICE_ROWS.replace("/*SLICE:ids*/?", ",".join("?" * len(ids)) or "NULL", 1) + return conn.execute(sql, (*ids,)).rowcount diff --git a/test/driver_sqlite3/dataclass/test_sqlite3_dataclass_functions.py b/test/driver_sqlite3/dataclass/test_sqlite3_dataclass_functions.py index 5e89ff7f..d679e25d 100644 --- a/test/driver_sqlite3/dataclass/test_sqlite3_dataclass_functions.py +++ b/test/driver_sqlite3/dataclass/test_sqlite3_dataclass_functions.py @@ -36,6 +36,7 @@ from test.driver_sqlite3.dataclass.functions import queries_case from test.driver_sqlite3.dataclass.functions import queries_override_adapter from test.driver_sqlite3.dataclass.functions import queries_override_converter +from test.driver_sqlite3.dataclass.functions import queries_slice from test.driver_sqlite3.dataclass.functions import queries_unknown_override OVERRIDE_PRICE = 12.5 @@ -45,6 +46,8 @@ RESERVED_ARG_ID = 525252 UNKNOWN_OVERRIDE_ID = 545454 ANY_PARAM_ID = 565656 +SLICE_ID_BASE = 585858 +SLICE_ROW_COUNT = 4 class TestSqlite3DataclassFunctions: @@ -1056,3 +1059,98 @@ def test_list_any_param_ids(self, sqlite3_conn: sqlite3.Connection) -> None: def test_iterate_any_param_ids(self, sqlite3_conn: sqlite3.Connection) -> None: seen = list(queries_any_param.list_any_param_ids(conn=sqlite3_conn, tag=pathlib.PurePosixPath("a/b"))) assert seen == [ANY_PARAM_ID] + + @pytest.mark.dependency(name="Sqlite3TestDataclassFunctions::insert_slice_rows") + def test_insert_slice_rows(self, sqlite3_conn: sqlite3.Connection) -> None: + for offset, (name, note) in enumerate((("a", "x"), ("b", "y"), ("c", None), ("b", "y"))): + queries_slice.insert_slice_row(conn=sqlite3_conn, id_=SLICE_ID_BASE + offset, name=name, note=note) + + @pytest.mark.dependency(name="Sqlite3TestDataclassFunctions::get_slice_rows", depends=["Sqlite3TestDataclassFunctions::insert_slice_rows"]) + def test_get_slice_rows(self, sqlite3_conn: sqlite3.Connection) -> None: + result = queries_slice.get_slice_rows(conn=sqlite3_conn, ids=[SLICE_ID_BASE, SLICE_ID_BASE + 2]) + assert isinstance(result, queries_slice.QueryResults) + rows = result() + assert rows == [ + models.TestSlice(id_=SLICE_ID_BASE, name="a", note="x"), + models.TestSlice(id_=SLICE_ID_BASE + 2, name="c", note=None), + ] + assert list(result) == rows + + @pytest.mark.dependency(name="Sqlite3TestDataclassFunctions::get_slice_rows_empty_slice", depends=["Sqlite3TestDataclassFunctions::insert_slice_rows"]) + def test_get_slice_rows_empty_slice(self, sqlite3_conn: sqlite3.Connection) -> None: + # An empty sequence expands the placeholder to NULL: IN (NULL) + # matches no rows instead of raising. + assert queries_slice.get_slice_rows(conn=sqlite3_conn, ids=[])() == [] + + @pytest.mark.dependency(name="Sqlite3TestDataclassFunctions::get_slice_row_filtered", depends=["Sqlite3TestDataclassFunctions::insert_slice_rows"]) + def test_get_slice_row_filtered(self, sqlite3_conn: sqlite3.Connection) -> None: + # Plain params surround the slice, so this proves the flattened + # argument tuple binds in SQL text order. + row = queries_slice.get_slice_row_filtered( + conn=sqlite3_conn, + name="b", + ids=[SLICE_ID_BASE + 1, SLICE_ID_BASE + 3], + id_=SLICE_ID_BASE + 1, + ) + assert row == models.TestSlice(id_=SLICE_ID_BASE + 3, name="b", note="y") + + @pytest.mark.dependency(name="Sqlite3TestDataclassFunctions::get_slice_row_filtered_not_found", depends=["Sqlite3TestDataclassFunctions::insert_slice_rows"]) + def test_get_slice_row_filtered_not_found(self, sqlite3_conn: sqlite3.Connection) -> None: + assert queries_slice.get_slice_row_filtered(conn=sqlite3_conn, name="a", ids=[SLICE_ID_BASE], id_=SLICE_ID_BASE) is None + + @pytest.mark.dependency(name="Sqlite3TestDataclassFunctions::get_slice_rows_by_notes", depends=["Sqlite3TestDataclassFunctions::insert_slice_rows"]) + def test_get_slice_rows_by_notes(self, sqlite3_conn: sqlite3.Connection) -> None: + # The slice targets a nullable column; the parameter is still a plain + # Sequence, and rows whose note is NULL never match. + rows = queries_slice.get_slice_rows_by_notes(conn=sqlite3_conn, notes=["y"])() + assert rows == [ + models.TestSlice(id_=SLICE_ID_BASE + 1, name="b", note="y"), + models.TestSlice(id_=SLICE_ID_BASE + 3, name="b", note="y"), + ] + assert queries_slice.get_slice_rows_by_notes(conn=sqlite3_conn, notes=[])() == [] + + @pytest.mark.dependency(name="Sqlite3TestDataclassFunctions::get_slice_rows_by_name_or_note", depends=["Sqlite3TestDataclassFunctions::insert_slice_rows"]) + def test_get_slice_rows_by_name_or_note(self, sqlite3_conn: sqlite3.Connection) -> None: + # The same slice name is used twice, so every marker occurrence is + # expanded and the sequence is bound once per occurrence. + rows = queries_slice.get_slice_rows_by_name_or_note(conn=sqlite3_conn, names=["b", "x"])() + assert rows == [ + models.TestSlice(id_=SLICE_ID_BASE, name="a", note="x"), + models.TestSlice(id_=SLICE_ID_BASE + 1, name="b", note="y"), + models.TestSlice(id_=SLICE_ID_BASE + 3, name="b", note="y"), + ] + assert queries_slice.get_slice_rows_by_name_or_note(conn=sqlite3_conn, names=[])() == [] + + @pytest.mark.dependency(name="Sqlite3TestDataclassFunctions::get_slice_rows_by_name_or_note_filtered", depends=["Sqlite3TestDataclassFunctions::insert_slice_rows"]) + def test_get_slice_rows_by_name_or_note_filtered(self, sqlite3_conn: sqlite3.Connection) -> None: + # A plain parameter sits between the two uses of the slice, so this + # proves the flattened arguments follow SQL text order. + rows = queries_slice.get_slice_rows_by_name_or_note_filtered(conn=sqlite3_conn, names=["b", "x"], id_=SLICE_ID_BASE + 1)() + assert rows == [ + models.TestSlice(id_=SLICE_ID_BASE, name="a", note="x"), + models.TestSlice(id_=SLICE_ID_BASE + 3, name="b", note="y"), + ] + assert queries_slice.get_slice_rows_by_name_or_note_filtered(conn=sqlite3_conn, names=[], id_=SLICE_ID_BASE)() == [] + + @pytest.mark.dependency(name="Sqlite3TestDataclassFunctions::get_first_slice_name_two_slices", depends=["Sqlite3TestDataclassFunctions::insert_slice_rows"]) + def test_get_first_slice_name_two_slices(self, sqlite3_conn: sqlite3.Connection) -> None: + name = queries_slice.get_first_slice_name(conn=sqlite3_conn, ids=[SLICE_ID_BASE + 1], names=["a"]) + assert name == "a" + assert queries_slice.get_first_slice_name(conn=sqlite3_conn, ids=[], names=[]) is None + + @pytest.mark.dependency( + depends=[ + "Sqlite3TestDataclassFunctions::get_slice_rows", + "Sqlite3TestDataclassFunctions::get_slice_rows_empty_slice", + "Sqlite3TestDataclassFunctions::get_slice_row_filtered", + "Sqlite3TestDataclassFunctions::get_slice_row_filtered_not_found", + "Sqlite3TestDataclassFunctions::get_slice_rows_by_notes", + "Sqlite3TestDataclassFunctions::get_slice_rows_by_name_or_note", + "Sqlite3TestDataclassFunctions::get_slice_rows_by_name_or_note_filtered", + "Sqlite3TestDataclassFunctions::get_first_slice_name_two_slices", + ] + ) + 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 diff --git a/test/driver_sqlite3/queries_slice.sql b/test/driver_sqlite3/queries_slice.sql new file mode 100644 index 00000000..46cab028 --- /dev/null +++ b/test/driver_sqlite3/queries_slice.sql @@ -0,0 +1,23 @@ +-- name: InsertSliceRow :exec +INSERT INTO test_slice (id, name, note) VALUES (?, ?, ?); + +-- name: GetSliceRows :many +SELECT * FROM test_slice WHERE id IN (sqlc.slice('ids')) ORDER BY id; + +-- name: GetSliceRowFiltered :one +SELECT * FROM test_slice WHERE name = ? AND id IN (sqlc.slice('ids')) AND id != ? LIMIT 1; + +-- name: GetSliceRowsByNotes :many +SELECT * FROM test_slice WHERE note IN (sqlc.slice('notes')) ORDER BY id; + +-- name: GetFirstSliceName :one +SELECT name FROM test_slice WHERE id IN (sqlc.slice('ids')) OR name IN (sqlc.slice('names')) ORDER BY id LIMIT 1; + +-- name: GetSliceRowsByNameOrNote :many +SELECT * FROM test_slice WHERE name IN (sqlc.slice('names')) OR note IN (sqlc.slice('names')) ORDER BY id; + +-- name: GetSliceRowsByNameOrNoteFiltered :many +SELECT * FROM test_slice WHERE name IN (sqlc.slice('names')) AND id != ? OR note IN (sqlc.slice('names')) ORDER BY id; + +-- name: DeleteSliceRows :execrows +DELETE FROM test_slice WHERE id IN (sqlc.slice('ids')); diff --git a/test/driver_sqlite3/schema.sql b/test/driver_sqlite3/schema.sql index 2105587e..4d6c4d1d 100644 --- a/test/driver_sqlite3/schema.sql +++ b/test/driver_sqlite3/schema.sql @@ -120,3 +120,12 @@ CREATE TABLE IF NOT EXISTS test_any_param id integer PRIMARY KEY NOT NULL, tag TAGTYPE NOT NULL ); + +-- Variable-length IN lists via sqlc.slice: the /*SLICE:name*/ placeholder in +-- the SQL constant is expanded at call time, one "?" per element. +CREATE TABLE IF NOT EXISTS test_slice +( + id integer PRIMARY KEY NOT NULL, + name text NOT NULL, + note text +); diff --git a/test/driver_sqlite3/sqlc-gen-better-python.wasm b/test/driver_sqlite3/sqlc-gen-better-python.wasm index d5e8cb16..4e0b96b3 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 18687af0..9123dd17 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: 73861329dc7deb8596b0fafc8514f798fe5c6e4e089bae2f56e974d3a9586c1f + sha256: 78698bcbf8188223f72f98228792f79a1e93dbaa20a6d41995e6eee962a3eb39 sql: - schema: schema.sql queries: @@ -106,6 +106,8 @@ sql: - queries_override_converter.sql - queries_case.sql - queries_unknown_override.sql + - queries_any_param.sql + - queries_slice.sql engine: sqlite codegen: - out: /dataclass/functions @@ -130,6 +132,11 @@ sql: - db_type: JULIANDAY py_type: type: str + - db_type: TAGTYPE + py_type: + import: pathlib + package: PurePosixPath + type: PurePosixPath - schema: schema.sql queries: - queries.sql