diff --git a/.changes/unreleased/Fixed-20260816-120000.yaml b/.changes/unreleased/Fixed-20260816-120000.yaml new file mode 100644 index 00000000..9cd8be29 --- /dev/null +++ b/.changes/unreleased/Fixed-20260816-120000.yaml @@ -0,0 +1,6 @@ +kind: Fixed +body: 'SQLite-family queries that combine a `sqlc.slice` with a named argument (`sqlc.arg`, `@name`, `:name`) now bind their arguments to the right columns. sqlc numbers every placeholder of such a query (`?1`, `?2`, ...) and assumes the slice marker occupies one slot, but the marker expands to one slot per element, so every index after it named the wrong slot: the query returned wrong rows, or the driver rejected it. The generated SQL constant now carries bare `?` placeholders and the arguments are passed in text order, which is correct for any slice length including an empty one.' +time: 2026-08-16T12:00:00.0000000Z +custom: + Author: Rayakame + PR: "260" diff --git a/internal/config/constants.go b/internal/config/constants.go index cacbda0b..57d75ddc 100644 --- a/internal/config/constants.go +++ b/internal/config/constants.go @@ -33,6 +33,13 @@ func (dr SQLDriver) IsPsycopg() bool { return dr == SQLDriverPsycopgAsync || dr == SQLDriverPsycopgSync } +// IsSqliteFamily reports whether the driver executes SQLite SQL: the two +// sqlite modules and both pyturso flavors share sqlc's sqlite engine and its +// placeholder numbering. +func (dr SQLDriver) IsSqliteFamily() bool { + return driversEngine[dr] == engineSQLite +} + // IsTurso reports whether the driver is one of the two pyturso flavors, // which share inline type conversion in both directions - pyturso has no // adapter/converter registry and binds only None, numbers, strings, and diff --git a/internal/config/constants_test.go b/internal/config/constants_test.go index cd4231cc..22e99cb7 100644 --- a/internal/config/constants_test.go +++ b/internal/config/constants_test.go @@ -183,6 +183,25 @@ func TestSQLDriverIsPsycopg(t *testing.T) { } } +func TestSQLDriverIsSqliteFamily(t *testing.T) { + t.Parallel() + for driver, want := range map[config.SQLDriver]bool{ + config.SQLDriverSQLite: true, + config.SQLDriverAioSQLite: true, + config.SQLDriverTursoSync: true, + config.SQLDriverTursoAsync: true, + config.SQLDriverAsyncpg: false, + config.SQLDriverPsycopgSync: false, + config.SQLDriverPsycopgAsync: false, + config.SQLDriverPymysql: false, + config.SQLDriverAsyncmy: false, + } { + if got := driver.IsSqliteFamily(); got != want { + t.Errorf("IsSqliteFamily(%q) = %v, want %v", driver, got, want) + } + } +} + func TestSQLDriverIsTurso(t *testing.T) { t.Parallel() for driver, want := range map[config.SQLDriver]bool{ diff --git a/internal/sqllex/lex.go b/internal/sqllex/lex.go index f7034c65..bd839e0a 100644 --- a/internal/sqllex/lex.go +++ b/internal/sqllex/lex.go @@ -25,6 +25,9 @@ const sliceMarkerPrefix = "/*SLICE:" // dashComment is the line-comment introducer both engines share. const dashComment = "--" +// decimalBase is the radix of a numbered placeholder's index. +const decimalBase = 10 + // Token is one scanned span of the input, [Start, End). type Token struct { Kind Kind @@ -32,6 +35,9 @@ type Token struct { End int // Name is the sqlc.slice name of a KindSliceMarker token. Name string + // Number is the explicit index of a numbered placeholder (sqlite's ?N), + // or 0 when the placeholder carries none. + Number int // MarkerEnd splits a KindSliceMarker: [Start, MarkerEnd) is the comment, // [MarkerEnd, End) the placeholder it binds. Zero for other kinds. MarkerEnd int @@ -43,6 +49,11 @@ type Token struct { type Slot struct { Name string Marker string + // Number is the explicit index of a numbered placeholder (sqlite's ?N), + // or 0 when the placeholder carries none. SQLite binds ?N to slot N and + // a bare ? to one past the highest slot seen so far, so the two spell + // different bindings for the same text position. + Number int } // Scan splits sql into tokens under the given dialect. Unterminated strings, @@ -53,12 +64,12 @@ func Scan(sql string, d Dialect) []Token { text := 0 flush := func(end int) { if end > text { - tokens = append(tokens, Token{Kind: KindText, Start: text, End: end, Name: "", MarkerEnd: 0}) + tokens = append(tokens, Token{Kind: KindText, Start: text, End: end, Name: "", Number: 0, MarkerEnd: 0}) } } skip := func(start, end int) { flush(start) - tokens = append(tokens, Token{Kind: KindSkipped, Start: start, End: end, Name: "", MarkerEnd: 0}) + tokens = append(tokens, Token{Kind: KindSkipped, Start: start, End: end, Name: "", Number: 0, MarkerEnd: 0}) text = end } @@ -78,6 +89,7 @@ func Scan(sql string, d Dialect) []Token { Start: i, End: token, Name: d.sliceName(sql[i+len(sliceMarkerPrefix) : end]), + Number: 0, MarkerEnd: end, }) i, text = token, token @@ -119,12 +131,19 @@ func Scan(sql string, d Dialect) []Token { case d.placeholder != "" && strings.HasPrefix(rest, d.placeholder): flush(i) end := i + len(d.placeholder) + number := 0 if d.numbered { + digits := end for end < len(sql) && sql[end] >= '0' && sql[end] <= '9' { end++ } + // Overflow cannot happen in practice: sqlc rejects gaps, so + // the highest index it emits is the parameter count. + for _, b := range []byte(sql[digits:end]) { + number = number*decimalBase + int(b-'0') + } } - tokens = append(tokens, Token{Kind: KindPlaceholder, Start: i, End: end, Name: "", MarkerEnd: 0}) + tokens = append(tokens, Token{Kind: KindPlaceholder, Start: i, End: end, Name: "", Number: number, MarkerEnd: 0}) i, text = end, end default: i++ @@ -156,9 +175,9 @@ func Slots(sql string, d Dialect) []Slot { for _, token := range Scan(sql, d) { switch token.Kind { case KindPlaceholder: - slots = append(slots, Slot{Name: "", Marker: ""}) + slots = append(slots, Slot{Name: "", Marker: "", Number: token.Number}) case KindSliceMarker: - slots = append(slots, Slot{Name: token.Name, Marker: sql[token.Start:token.End]}) + slots = append(slots, Slot{Name: token.Name, Marker: sql[token.Start:token.End], Number: 0}) case KindText, KindSkipped: } } diff --git a/internal/sqllex/lex_test.go b/internal/sqllex/lex_test.go index 59c20f80..7fff670d 100644 --- a/internal/sqllex/lex_test.go +++ b/internal/sqllex/lex_test.go @@ -8,7 +8,7 @@ import ( ) // plain is a bind slot that is not a sqlc.slice marker. -var plain = sqllex.Slot{Name: "", Marker: ""} //nolint:gochecknoglobals +var plain = sqllex.Slot{Name: "", Marker: "", Number: 0} //nolint:gochecknoglobals func TestSlotsMySQLRaw(t *testing.T) { t.Parallel() @@ -26,16 +26,16 @@ func TestSlotsMySQLRaw(t *testing.T) { { name: "slice marker carries its text", sql: "SELECT a FROM t WHERE id IN (/*SLICE:ids*/?)", - want: []sqllex.Slot{{Name: "ids", Marker: "/*SLICE:ids*/?"}}, + want: []sqllex.Slot{{Name: "ids", Marker: "/*SLICE:ids*/?", Number: 0}}, }, { // The ordering case the drivers depend on. name: "reused marker interleaved with a plain slot", sql: "SELECT a FROM t WHERE x IN (/*SLICE:ids*/?) AND y = ? OR z IN (/*SLICE:ids*/?)", want: []sqllex.Slot{ - {Name: "ids", Marker: "/*SLICE:ids*/?"}, + {Name: "ids", Marker: "/*SLICE:ids*/?", Number: 0}, plain, - {Name: "ids", Marker: "/*SLICE:ids*/?"}, + {Name: "ids", Marker: "/*SLICE:ids*/?", Number: 0}, }, }, { @@ -133,7 +133,7 @@ func TestSlotsMySQLPyformat(t *testing.T) { { name: "rewritten marker carries its text", sql: "SELECT a FROM t WHERE id IN (/*SLICE:ids*/%s)", - want: []sqllex.Slot{{Name: "ids", Marker: "/*SLICE:ids*/%s"}}, + want: []sqllex.Slot{{Name: "ids", Marker: "/*SLICE:ids*/%s", Number: 0}}, }, { // The rewriter doubles a percent inside the marker; the name has @@ -146,9 +146,9 @@ func TestSlotsMySQLPyformat(t *testing.T) { name: "reused marker interleaved with a plain slot", sql: "SELECT a FROM t WHERE x IN (/*SLICE:ids*/%s) AND y = %s OR z IN (/*SLICE:ids*/%s)", want: []sqllex.Slot{ - {Name: "ids", Marker: "/*SLICE:ids*/%s"}, + {Name: "ids", Marker: "/*SLICE:ids*/%s", Number: 0}, plain, - {Name: "ids", Marker: "/*SLICE:ids*/%s"}, + {Name: "ids", Marker: "/*SLICE:ids*/%s", Number: 0}, }, }, } @@ -163,23 +163,27 @@ func TestSlotsSQLite(t *testing.T) { want []sqllex.Slot }{ { - // sqlc numbers SQLite parameters; the digits belong to the slot. - name: "numbered placeholders count once each", + // sqlc numbers SQLite parameters; the digits belong to the slot + // and name the index SQLite will bind it to. + name: "numbered placeholders report their index", sql: "SELECT a FROM t WHERE b = ?1 AND c = ?12", - want: []sqllex.Slot{plain, plain}, + want: []sqllex.Slot{ + {Name: "", Marker: "", Number: 1}, + {Name: "", Marker: "", Number: 12}, + }, }, { name: "slice marker carries its text", sql: "SELECT a FROM t WHERE id IN (/*SLICE:ids*/?)", - want: []sqllex.Slot{{Name: "ids", Marker: "/*SLICE:ids*/?"}}, + want: []sqllex.Slot{{Name: "ids", Marker: "/*SLICE:ids*/?", Number: 0}}, }, { name: "reused marker interleaved with a numbered slot", sql: "SELECT a FROM t WHERE x IN (/*SLICE:ids*/?) AND y = ?2 OR z IN (/*SLICE:ids*/?)", want: []sqllex.Slot{ - {Name: "ids", Marker: "/*SLICE:ids*/?"}, - plain, - {Name: "ids", Marker: "/*SLICE:ids*/?"}, + {Name: "ids", Marker: "/*SLICE:ids*/?", Number: 0}, + {Name: "", Marker: "", Number: 2}, + {Name: "ids", Marker: "/*SLICE:ids*/?", Number: 0}, }, }, { diff --git a/internal/transform/queries.go b/internal/transform/queries.go index d8e62c5a..d7707818 100644 --- a/internal/transform/queries.go +++ b/internal/transform/queries.go @@ -5,6 +5,7 @@ import ( "strings" "github.com/rayakame/sqlc-gen-better-python/internal/model" + "github.com/rayakame/sqlc-gen-better-python/internal/sqllex" "github.com/rayakame/sqlc-gen-better-python/internal/utils" "github.com/sqlc-dev/plugin-sdk-go/metadata" "github.com/sqlc-dev/plugin-sdk-go/plugin" @@ -287,6 +288,15 @@ func (t *Transformer) BuildQueries(tables []model.Table) []model.Query { } else { query.Params = t.plainParams(pluginQuery, pluginParams) } + // sqlc numbers SQLite placeholders as soon as a query uses a named + // argument, and those indexes stop matching once a sqlc.slice marker + // expands. Strip them and bind by position instead - for the fields of + // a bundled Params class too, which the drivers expand positionally. + if t.config.SqlDriver.IsSqliteFamily() { + if slots := sqllex.Slots(query.SQL, sqllex.SQLite); needsSlotRewrite(slots) && reorderBindOrder(&query, slots) { + query.SQL = rewriteSQLiteSQL(query.SQL) + } + } if query.Cmd == metadata.CmdExecLastId { query.Returns.Type = model.PyType{Type: "int", IsNullable: true} diff --git a/internal/transform/queries_test.go b/internal/transform/queries_test.go index 90939d34..41b2a490 100644 --- a/internal/transform/queries_test.go +++ b/internal/transform/queries_test.go @@ -2,6 +2,7 @@ package transform_test import ( "reflect" + "strings" "testing" "github.com/rayakame/sqlc-gen-better-python/internal/config" @@ -851,6 +852,97 @@ func TestBuildQueriesSqliteKeepsSameNamedParams(t *testing.T) { } } +func TestBuildQueriesSqliteSliceWithNamedArg(t *testing.T) { + t.Parallel() + // sqlc numbers the placeholders of a query that uses a named argument and + // assumes the marker takes one slot; it takes one per element. The indexes + // go, and the arguments follow the text instead - the reused one twice. + sliceCol := func() *plugin.Column { + column := queryCol("ids", "int4", nil) + column.IsSqlcSlice = true + + return column + } + query := buildSingleQuery(t, &config.Config{SqlDriver: config.SQLDriverSQLite}, &plugin.Query{ + Name: "ListAuthors", + Cmd: ":many", + Text: "SELECT id FROM test_authors WHERE id IN (/*SLICE:ids*/?) AND name = ?2 AND name != ?2", + Params: []*plugin.Parameter{ + {Number: 1, Column: sliceCol()}, + {Number: 2, Column: queryCol("n", "text", nil)}, + }, + }) + want := []struct { + name string + repeated bool + }{{"ids", false}, {"n", false}, {"n", true}} + if len(query.Params) != len(want) { + t.Fatalf("params = %+v, want %d entries", query.Params, len(want)) + } + for i, tc := range want { + if query.Params[i].Name != tc.name || query.Params[i].Repeated != tc.repeated { + t.Errorf("param %d = (%q, repeated=%v), want (%q, repeated=%v)", + i, query.Params[i].Name, query.Params[i].Repeated, tc.name, tc.repeated) + } + } + if strings.Contains(query.SQL, "?2") { + t.Errorf("SQL = %q, want the numbered placeholders stripped", query.SQL) + } +} + +func TestBuildQueriesSqliteKeepsNumberingWithoutSlice(t *testing.T) { + t.Parallel() + // Without a marker every index holds, and sqlc's own numbering is left + // alone: the reused argument stays one bound value. + query := buildSingleQuery(t, &config.Config{SqlDriver: config.SQLDriverSQLite}, &plugin.Query{ + Name: "ListAuthors", + Cmd: ":many", + Text: "SELECT id FROM test_authors WHERE name = ?1 AND name != ?1", + Params: []*plugin.Parameter{ + {Number: 1, Column: queryCol("n", "text", nil)}, + }, + }) + if len(query.Params) != 1 || query.Params[0].Name != "n" { + t.Fatalf("params = %+v, want a single n", query.Params) + } + if !strings.Contains(query.SQL, "?1") { + t.Errorf("SQL = %q, want sqlc's numbering kept", query.SQL) + } +} + +func TestBuildQueriesSqliteBundledFieldsFollowBindOrder(t *testing.T) { + t.Parallel() + // A bundled Params class is expanded field by field, so its field order is + // the bind order: the marker binds first even though sqlc numbered its + // parameter second. + sliceCol := func() *plugin.Column { + column := queryCol("ids", "int4", nil) + column.IsSqlcSlice = true + + return column + } + conf := &config.Config{SqlDriver: config.SQLDriverSQLite, QueryParameterLimit: utils.ToPtr(1)} + query := buildSingleQuery(t, conf, &plugin.Query{ + Name: "ListAuthors", + Cmd: ":many", + Text: "SELECT id, name FROM test_authors WHERE id IN (/*SLICE:ids*/?) AND name = ?1", + Params: []*plugin.Parameter{ + {Number: 1, Column: queryCol("n", "text", nil)}, + {Number: 2, Column: sliceCol()}, + }, + }) + if len(query.Params) != 1 || query.Params[0].Table == nil { + t.Fatalf("params = %+v, want a single bundled Params class", query.Params) + } + columns := query.Params[0].Table.Columns + if len(columns) != 2 || columns[0].Name != "ids" || columns[1].Name != "n" { + t.Fatalf("columns = %+v, want ids before n", columns) + } + if strings.Contains(query.SQL, "?1") { + t.Errorf("SQL = %q, want the numbered placeholder stripped", query.SQL) + } +} + func TestBuildQueriesMySQLSliceDedup(t *testing.T) { t.Parallel() sliceCol := func() *plugin.Column { diff --git a/internal/transform/sqlite_sql.go b/internal/transform/sqlite_sql.go new file mode 100644 index 00000000..d44cdd14 --- /dev/null +++ b/internal/transform/sqlite_sql.go @@ -0,0 +1,174 @@ +package transform + +import ( + "strings" + + "github.com/rayakame/sqlc-gen-better-python/internal/model" + "github.com/rayakame/sqlc-gen-better-python/internal/sqllex" +) + +// rewriteSQLiteSQL strips the index from sqlc's numbered SQLite placeholders, +// leaving every bind slot as a bare "?". +// +// SQLite binds "?N" to slot N and a bare "?" to one past the highest slot it +// has seen, and sqlc numbers its placeholders assuming each sqlc.slice marker +// occupies exactly one slot. The marker expands at call time into one slot per +// element - or none for an empty sequence - so every "?N" after a marker names +// the wrong slot, and the arguments land on the wrong columns or the statement +// is rejected outright. Bare placeholders have no such coupling: SQLite +// numbers them left to right, which is exactly the order the drivers pass +// arguments in, whatever a marker expanded to. +func rewriteSQLiteSQL(sql string) string { + var out strings.Builder + out.Grow(len(sql)) + for _, token := range sqllex.Scan(sql, sqllex.SQLite) { + if token.Kind == sqllex.KindPlaceholder && token.Number != 0 { + out.WriteString(sqllex.SQLite.Placeholder()) + + continue + } + out.WriteString(sql[token.Start:token.End]) + } + + return out.String() +} + +// needsSlotRewrite reports whether the query's numbering has to be undone. It +// takes both halves of the problem: sqlc numbers placeholders as soon as a +// query uses a named argument, and only a sqlc.slice marker - the one bind +// slot whose width is unknown until call time - can desynchronize them. +// Without a marker sqlc's indexes hold, and the SQL is left alone. +func needsSlotRewrite(slots []sqllex.Slot) bool { + numbered, sliced := false, false + for _, slot := range slots { + switch { + case slot.Name != "": + sliced = true + case slot.Number != 0: + numbered = true + } + } + + return numbered && sliced +} + +// reorderBindOrder puts the query's arguments in bind-slot order, reporting +// whether it could. Callers must leave the SQL numbered when it could not: +// sqlc's order and sqlc's numbering agree with each other, so a query this +// rewrite does not understand stays exactly as it was. +func reorderBindOrder(query *model.Query, slots []sqllex.Slot) bool { + if bundled := bundledTable(query.Params); bundled != nil { + ordered, ok := orderColumnsBySlot(bundled.Columns, slots) + if ok { + bundled.Columns = ordered + } + + return ok + } + ordered, ok := orderParamsBySlot(query.Params, slots) + if ok { + query.Params = ordered + } + + return ok +} + +// bundledTable returns the Params class the query's parameters were bundled +// into, or nil when the query takes its parameters one by one. +func bundledTable(params []model.QueryValue) *model.Table { + for _, param := range params { + if param.EmitTable { + return param.Table + } + } + + return nil +} + +// orderParamsBySlot rebuilds a plain parameter list in bind-slot order. +func orderParamsBySlot(params []model.QueryValue, slots []sqllex.Slot) ([]model.QueryValue, bool) { + return orderBySlot(params, slots, + func(param model.QueryValue) (string, int32) { return param.Type.SqlcSliceName, param.Number }, + func(param *model.QueryValue) { param.Repeated = true }, + ) +} + +// orderColumnsBySlot rebuilds the fields of a bundled Params class in bind-slot +// order. The drivers expand the class positionally, field by field, so the +// field order is the bind order. +func orderColumnsBySlot(columns []model.Column, slots []sqllex.Slot) ([]model.Column, bool) { + return orderBySlot(columns, slots, + func(column model.Column) (string, int32) { return column.Type.SqlcSliceName, column.Number }, + func(column *model.Column) { column.Repeated = true }, + ) +} + +// orderBySlot rebuilds a bind list in slot order, which is what positional +// binding needs and what sqlc's own order is not: with a numbered query sqlc +// sorts parameters by index, and an explicit index can sit anywhere in the +// text. +// +// An item bound at several slots is emitted once per slot, the later ones +// flagged Repeated so they keep their binding slot without repeating in the +// signature or the Params class. A sqlc.slice is the exception: its marker +// expands to as many slots as the sequence is long, and the drivers already +// replay its argument per marker occurrence, so it is emitted once. +// +// It reports false when the scan and sqlc's parameters do not line up exactly. +// Guessing there would bind an argument that does not exist. +func orderBySlot[T any](items []T, slots []sqllex.Slot, keyOf func(T) (string, int32), markRepeated func(*T)) ([]T, bool) { + byNumber := make(map[int]int, len(items)) + bySlice := make(map[string]int, len(items)) + for i, item := range items { + sliceName, number := keyOf(item) + if sliceName != "" { + bySlice[sliceName] = i + } + byNumber[int(number)] = i + } + + ordered := make([]T, 0, len(slots)) + seen := make(map[int]struct{}, len(items)) + for _, slot := range slots { + var idx int + var found bool + switch { + case slot.Name != "": + idx, found = bySlice[slot.Name] + if _, repeat := seen[idx]; found && repeat { + // The marker occurs again; the driver replays the argument. + continue + } + case slot.Number != 0: + idx, found = byNumber[slot.Number] + default: + // sqlc numbers every placeholder of a query that has a named + // argument, so the only unnumbered slot is a marker's. Anything + // else is a shape whose bind order can only be guessed at. + return nil, false + } + if !found { + // sqlc reported nothing for this slot (it aliases a slice name, or + // dropped the parameter). + return nil, false + } + if sliceName, _ := keyOf(items[idx]); slot.Name == "" && sliceName != "" { + // A plain slot resolving to a slice means sqlc aliased an argument + // and a sqlc.slice sharing one name into a single parameter, + // leaving this slot unbindable. Not ours to repair. + return nil, false + } + item := items[idx] + if _, repeat := seen[idx]; repeat { + markRepeated(&item) + } + seen[idx] = struct{}{} + ordered = append(ordered, item) + } + if len(seen) != len(items) { + // Something binds nowhere the scan could see. + return nil, false + } + + return ordered, true +} diff --git a/internal/transform/sqlite_sql_test.go b/internal/transform/sqlite_sql_test.go new file mode 100644 index 00000000..6182bdbc --- /dev/null +++ b/internal/transform/sqlite_sql_test.go @@ -0,0 +1,229 @@ +package transform + +import ( + "reflect" + "testing" + + "github.com/rayakame/sqlc-gen-better-python/internal/model" + "github.com/rayakame/sqlc-gen-better-python/internal/sqllex" + "github.com/rayakame/sqlc-gen-better-python/internal/types" +) + +func TestRewriteSQLiteSQL(t *testing.T) { + t.Parallel() + cases := []struct { + name string + sql string + want string + }{ + {name: "no placeholders", sql: "SELECT 1", want: "SELECT 1"}, + {name: "bare placeholders are left alone", sql: "WHERE a = ? AND b = ?", want: "WHERE a = ? AND b = ?"}, + {name: "numbered placeholders lose their index", sql: "WHERE a = ?1 AND b = ?2", want: "WHERE a = ? AND b = ?"}, + {name: "multi digit index", sql: "WHERE a = ?12", want: "WHERE a = ?"}, + {name: "reused index becomes two slots", sql: "WHERE a = ?1 AND b = ?1", want: "WHERE a = ? AND b = ?"}, + { + name: "slice markers keep their bare placeholder", + sql: "WHERE id IN (/*SLICE:ids*/?) AND a = ?2", + want: "WHERE id IN (/*SLICE:ids*/?) AND a = ?", + }, + { + // A "?N" inside a literal or comment is text, not a placeholder. + name: "quoted and commented text is untouched", + sql: "WHERE a = '?1' AND b = ?1 -- ?2\nAND c = `?3`", + want: "WHERE a = '?1' AND b = ? -- ?2\nAND c = `?3`", + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + if got := rewriteSQLiteSQL(tc.sql); got != tc.want { + t.Errorf("rewriteSQLiteSQL() = %q, want %q", got, tc.want) + } + }) + } +} + +func TestOrderParamsBySlot(t *testing.T) { + t.Parallel() + param := func(name string, number int32) model.QueryValue { + return model.QueryValue{Name: name, Number: number, Type: model.PyType{Type: "str"}} + } + slice := func(name string, number int32) model.QueryValue { + return model.QueryValue{ + Name: name, + Number: number, + Type: model.PyType{Type: types.Int, IsList: true, SqlcSliceName: name}, + } + } + repeat := func(qv model.QueryValue) model.QueryValue { + qv.Repeated = true + + return qv + } + cases := []struct { + name string + sql string + params []model.QueryValue + want []model.QueryValue + }{ + { + // sqlc sorts a numbered query's parameters by index, which is not + // the text order once an explicit index sits after a marker. + name: "slice before a numbered argument", + sql: "WHERE id IN (/*SLICE:ids*/?) AND a = ?2", + params: []model.QueryValue{slice("ids", 1), param("x", 2)}, + want: []model.QueryValue{slice("ids", 1), param("x", 2)}, + }, + { + name: "numbered argument before the slice", + sql: "WHERE a = ?1 AND id IN (/*SLICE:ids*/?)", + params: []model.QueryValue{param("x", 1), slice("ids", 2)}, + want: []model.QueryValue{param("x", 1), slice("ids", 2)}, + }, + { + // The index sqlc assigned puts x first, but the marker binds first. + name: "explicit index after a marker follows text order", + sql: "WHERE id IN (/*SLICE:ids*/?) AND a = ?1", + params: []model.QueryValue{param("x", 1), slice("ids", 2)}, + want: []model.QueryValue{slice("ids", 2), param("x", 1)}, + }, + { + // The reused argument is one parameter but two bind slots; the + // repeat keeps the slot and drops out of the signature. + name: "reused argument binds at every slot", + sql: "WHERE id IN (/*SLICE:ids*/?) AND a = ?2 AND b = ?3 AND c = ?2", + params: []model.QueryValue{slice("ids", 1), param("x", 2), param("y", 3)}, + want: []model.QueryValue{ + slice("ids", 1), + param("x", 2), + param("y", 3), + repeat(param("x", 2)), + }, + }, + { + name: "reused marker is emitted once", + sql: "WHERE id IN (/*SLICE:ids*/?) AND a = ?2 AND id IN (/*SLICE:ids*/?)", + params: []model.QueryValue{slice("ids", 1), param("x", 2)}, + want: []model.QueryValue{slice("ids", 1), param("x", 2)}, + }, + { + // sqlc numbers every placeholder of a query that has a named + // argument, so a bare one is a shape this does not know. + name: "unnumbered plain slot is rejected", + sql: "WHERE id IN (/*SLICE:ids*/?) AND a = ?2 AND b = ?", + params: []model.QueryValue{slice("ids", 1), param("x", 2), param("y", 3)}, + }, + { + // sqlc aliases a slice and an argument sharing a name into one + // parameter, leaving a slot nothing can bind. + name: "slot resolving to a slice is rejected", + sql: "WHERE id IN (/*SLICE:v*/?) AND a = ?1", + params: []model.QueryValue{slice("v", 1)}, + }, + { + // sqlc dropped the parameter the slot names: emitting an argument + // for it is not an option. + name: "slot without a parameter is rejected", + sql: "WHERE id IN (/*SLICE:ids*/?) AND a = ?3", + params: []model.QueryValue{slice("ids", 1), param("x", 2)}, + }, + { + name: "parameter binding nowhere is rejected", + sql: "WHERE id IN (/*SLICE:ids*/?) AND a = ?2", + params: []model.QueryValue{slice("ids", 1), param("x", 2), param("ghost", 3)}, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + got, ok := orderParamsBySlot(tc.params, sqllex.Slots(tc.sql, sqllex.SQLite)) + if ok != (tc.want != nil) { + t.Fatalf("orderParamsBySlot() ok = %v, want %v", ok, tc.want != nil) + } + if !reflect.DeepEqual(got, tc.want) { + t.Errorf("orderParamsBySlot() = %+v, want %+v", got, tc.want) + } + }) + } +} + +func TestOrderColumnsBySlot(t *testing.T) { + t.Parallel() + column := func(name string, number int32) model.Column { + return model.Column{Name: name, Number: number, Type: model.PyType{Type: "str"}} + } + slice := func(name string, number int32) model.Column { + return model.Column{ + Name: name, + Number: number, + Type: model.PyType{Type: types.Int, IsList: true, SqlcSliceName: name}, + } + } + cases := []struct { + name string + sql string + columns []model.Column + want []model.Column + }{ + { + // The class is expanded field by field, so the marker's fields + // have to come first even though sqlc numbered them second. + name: "explicit index after a marker follows text order", + sql: "WHERE id IN (/*SLICE:ids*/?) AND a = ?1", + columns: []model.Column{column("x", 1), slice("ids", 2)}, + want: []model.Column{slice("ids", 2), column("x", 1)}, + }, + { + // The repeat keeps its binding slot; the class emits one field. + name: "reused argument binds at every slot", + sql: "WHERE id IN (/*SLICE:ids*/?) AND a = ?2 AND b = ?3 AND c = ?2", + columns: []model.Column{slice("ids", 1), column("x", 2), column("y", 3)}, + want: []model.Column{ + slice("ids", 1), + column("x", 2), + column("y", 3), + {Name: "x", Number: 2, Type: model.PyType{Type: "str"}, Repeated: true}, + }, + }, + { + name: "slot without a field is rejected", + sql: "WHERE id IN (/*SLICE:ids*/?) AND a = ?3", + columns: []model.Column{slice("ids", 1), column("x", 2)}, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + got, ok := orderColumnsBySlot(tc.columns, sqllex.Slots(tc.sql, sqllex.SQLite)) + if ok != (tc.want != nil) { + t.Fatalf("orderColumnsBySlot() ok = %v, want %v", ok, tc.want != nil) + } + if !reflect.DeepEqual(got, tc.want) { + t.Errorf("orderColumnsBySlot() = %+v, want %+v", got, tc.want) + } + }) + } +} + +func TestNeedsSlotRewrite(t *testing.T) { + t.Parallel() + cases := []struct { + name string + sql string + want bool + }{ + {name: "bare only", sql: "WHERE a = ? AND b = ?", want: false}, + {name: "slice marker only", sql: "WHERE id IN (/*SLICE:ids*/?)", want: false}, + {name: "numbered without a marker", sql: "WHERE a = ?1 AND b = ?1", want: false}, + {name: "numbered after a marker", sql: "WHERE id IN (/*SLICE:ids*/?) AND a = ?2", want: true}, + {name: "numbered before a marker", sql: "WHERE a = ?1 AND id IN (/*SLICE:ids*/?)", want: true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + if got := needsSlotRewrite(sqllex.Slots(tc.sql, sqllex.SQLite)); got != tc.want { + t.Errorf("needsSlotRewrite(%q) = %v, want %v", tc.sql, got, tc.want) + } + }) + } +} diff --git a/sqlc.yaml b/sqlc.yaml index 84be385f..0a56599a 100644 --- a/sqlc.yaml +++ b/sqlc.yaml @@ -3,7 +3,7 @@ plugins: - name: python wasm: url: file://sqlc-gen-better-python.wasm - sha256: 6b9df77e00051cc9128c70cd35dc645120c5b2eff1c56e6dc5ac99e34485c4ef + sha256: f9e26e327ba75ccd648dea1a55b9cbecbf6586686b5f7329dc15871b0b3fdc77 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 bda78044..e65aad36 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 fdf3c6bc..38bf9327 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: 6b9df77e00051cc9128c70cd35dc645120c5b2eff1c56e6dc5ac99e34485c4ef + sha256: f9e26e327ba75ccd648dea1a55b9cbecbf6586686b5f7329dc15871b0b3fdc77 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 bda78044..e65aad36 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 fbd97814..3357b788 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: 6b9df77e00051cc9128c70cd35dc645120c5b2eff1c56e6dc5ac99e34485c4ef + sha256: f9e26e327ba75ccd648dea1a55b9cbecbf6586686b5f7329dc15871b0b3fdc77 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 bda78044..e65aad36 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 2ef920b6..85fdf4ea 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: 6b9df77e00051cc9128c70cd35dc645120c5b2eff1c56e6dc5ac99e34485c4ef + sha256: f9e26e327ba75ccd648dea1a55b9cbecbf6586686b5f7329dc15871b0b3fdc77 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 bda78044..e65aad36 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 a47e96c3..ba945838 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: 6b9df77e00051cc9128c70cd35dc645120c5b2eff1c56e6dc5ac99e34485c4ef + sha256: f9e26e327ba75ccd648dea1a55b9cbecbf6586686b5f7329dc15871b0b3fdc77 sql: - schema: schema.sql queries: diff --git a/test/driver_psycopg_sync/sqlc-gen-better-python.wasm b/test/driver_psycopg_sync/sqlc-gen-better-python.wasm index bda78044..e65aad36 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 2e69afd0..5cd9f761 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: 6b9df77e00051cc9128c70cd35dc645120c5b2eff1c56e6dc5ac99e34485c4ef + sha256: f9e26e327ba75ccd648dea1a55b9cbecbf6586686b5f7329dc15871b0b3fdc77 sql: - schema: schema.sql queries: diff --git a/test/driver_pymysql/sqlc-gen-better-python.wasm b/test/driver_pymysql/sqlc-gen-better-python.wasm index bda78044..e65aad36 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 52ff05f4..57439a5d 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: 6b9df77e00051cc9128c70cd35dc645120c5b2eff1c56e6dc5ac99e34485c4ef + sha256: f9e26e327ba75ccd648dea1a55b9cbecbf6586686b5f7329dc15871b0b3fdc77 sql: - schema: schema.sql queries: diff --git a/test/driver_sqlite3/dataclass/functions/queries_named_slice.py b/test/driver_sqlite3/dataclass/functions/queries_named_slice.py new file mode 100644 index 00000000..8efa9305 --- /dev/null +++ b/test/driver_sqlite3/dataclass/functions/queries_named_slice.py @@ -0,0 +1,211 @@ +# Code generated by sqlc. DO NOT EDIT. +# versions: +# sqlc v1.31.1 +# sqlc-gen-better-python v0.8.0 +# source file: queries_named_slice.sql +"""Module containing queries from file queries_named_slice.sql.""" + +from __future__ import annotations + +__all__: collections.abc.Sequence[str] = ( + "QueryResults", + "get_named_slice_row", + "get_named_slice_rows", + "get_named_slice_rows_arg_first", + "get_named_slice_rows_reused", +) + +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 + + +GET_NAMED_SLICE_ROWS: typing.Final[str] = """-- name: GetNamedSliceRows :many + +SELECT id, name, note FROM test_slice WHERE id IN (/*SLICE:ids*/?) AND name = ? ORDER BY id +""" + +GET_NAMED_SLICE_ROWS_REUSED: typing.Final[str] = """-- name: GetNamedSliceRowsReused :many +SELECT id, name, note FROM test_slice WHERE id IN (/*SLICE:ids*/?) AND name = ? AND (note IS NULL OR name = ?) AND id IN (/*SLICE:ids*/?) ORDER BY id +""" + +GET_NAMED_SLICE_ROWS_ARG_FIRST: typing.Final[str] = """-- name: GetNamedSliceRowsArgFirst :many +SELECT id, name, note FROM test_slice WHERE name = ? AND id IN (/*SLICE:ids*/?) ORDER BY id +""" + +GET_NAMED_SLICE_ROW: typing.Final[str] = """-- name: GetNamedSliceRow :one +SELECT id, name, note FROM test_slice WHERE id IN (/*SLICE:ids*/?) AND name = ? ORDER BY id LIMIT 1 +""" + + +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 get_named_slice_rows(conn: sqlite3.Connection, *, ids: collections.abc.Sequence[int], wanted: str) -> QueryResults[models.TestSlice]: + """Fetch many from the db using the SQL query with `name: GetNamedSliceRows :many`. + + ```sql + + SELECT id, name, note FROM test_slice WHERE id IN (/*SLICE:ids*/?) AND name = ? ORDER BY id + ``` + + Args: + conn: + Connection object of type `sqlite3.Connection` used to execute the query. + ids: collections.abc.Sequence[int]. + wanted: 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_NAMED_SLICE_ROWS.replace("/*SLICE:ids*/?", ",".join("?" * len(ids)) or "NULL", 1) + return QueryResults(conn, sql, _decode_hook, *ids, wanted) + + +def get_named_slice_rows_reused(conn: sqlite3.Connection, *, ids: collections.abc.Sequence[int], wanted: str) -> QueryResults[models.TestSlice]: + """Fetch many from the db using the SQL query with `name: GetNamedSliceRowsReused :many`. + + ```sql + SELECT id, name, note FROM test_slice WHERE id IN (/*SLICE:ids*/?) AND name = ? AND (note IS NULL OR name = ?) AND 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]. + wanted: 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_NAMED_SLICE_ROWS_REUSED.replace("/*SLICE:ids*/?", ",".join("?" * len(ids)) or "NULL") + return QueryResults(conn, sql, _decode_hook, *ids, wanted, wanted, *ids) + + +def get_named_slice_rows_arg_first(conn: sqlite3.Connection, *, wanted: str, ids: collections.abc.Sequence[int]) -> QueryResults[models.TestSlice]: + """Fetch many from the db using the SQL query with `name: GetNamedSliceRowsArgFirst :many`. + + ```sql + SELECT id, name, note FROM test_slice WHERE name = ? AND id IN (/*SLICE:ids*/?) ORDER BY id + ``` + + Args: + conn: + Connection object of type `sqlite3.Connection` used to execute the query. + wanted: str. + 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_NAMED_SLICE_ROWS_ARG_FIRST.replace("/*SLICE:ids*/?", ",".join("?" * len(ids)) or "NULL", 1) + return QueryResults(conn, sql, _decode_hook, wanted, *ids) + + +def get_named_slice_row(conn: sqlite3.Connection, *, ids: collections.abc.Sequence[int], wanted: str) -> models.TestSlice | None: + """Fetch one from the db using the SQL query with `name: GetNamedSliceRow :one`. + + ```sql + SELECT id, name, note FROM test_slice WHERE id IN (/*SLICE:ids*/?) AND name = ? ORDER BY id LIMIT 1 + ``` + + Args: + conn: + Connection object of type `sqlite3.Connection` used to execute the query. + ids: collections.abc.Sequence[int]. + wanted: str. + + Returns: + Result of type `models.TestSlice` fetched from the db. Will be `None` if not found. + """ + sql = GET_NAMED_SLICE_ROW.replace("/*SLICE:ids*/?", ",".join("?" * len(ids)) or "NULL", 1) + row = conn.execute(sql, (*ids, wanted)).fetchone() + if row is None: + return None + return models.TestSlice(id_=row[0], name=row[1], note=row[2]) diff --git a/test/driver_sqlite3/dataclass/test_sqlite3_dataclass_functions.py b/test/driver_sqlite3/dataclass/test_sqlite3_dataclass_functions.py index d679e25d..3f5e78f1 100644 --- a/test/driver_sqlite3/dataclass/test_sqlite3_dataclass_functions.py +++ b/test/driver_sqlite3/dataclass/test_sqlite3_dataclass_functions.py @@ -34,6 +34,7 @@ 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_case +from test.driver_sqlite3.dataclass.functions import queries_named_slice 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 @@ -1138,6 +1139,40 @@ def test_get_first_slice_name_two_slices(self, sqlite3_conn: sqlite3.Connection) assert name == "a" assert queries_slice.get_first_slice_name(conn=sqlite3_conn, ids=[], names=[]) is None + @pytest.mark.dependency( + name="Sqlite3TestDataclassFunctions::named_slice_rows", + depends=["Sqlite3TestDataclassFunctions::insert_slice_rows"], + ) + def test_named_slice_rows(self, sqlite3_conn: sqlite3.Connection) -> None: + # sqlc numbers the placeholders of a query using a named argument, and + # those indexes no longer line up once the marker expands. Every + # length matters: one element used to pass by accident. + # Rows BASE..BASE+3 are named a, b, c, b - so "b" discriminates and + # the id list is not simply echoed back. + for ids, want in ( + ([], []), + ([SLICE_ID_BASE + 1], [SLICE_ID_BASE + 1]), + ([SLICE_ID_BASE + 1, SLICE_ID_BASE + 3], [SLICE_ID_BASE + 1, SLICE_ID_BASE + 3]), + ([SLICE_ID_BASE, SLICE_ID_BASE + 1, SLICE_ID_BASE + 3], [SLICE_ID_BASE + 1, SLICE_ID_BASE + 3]), + ): + rows = queries_named_slice.get_named_slice_rows(conn=sqlite3_conn, ids=ids, wanted="b")() + assert [row.id_ for row in rows] == want + reused = queries_named_slice.get_named_slice_rows_reused(conn=sqlite3_conn, ids=ids, wanted="b")() + assert [row.id_ for row in reused] == want + first = queries_named_slice.get_named_slice_rows_arg_first(conn=sqlite3_conn, wanted="b", ids=ids)() + assert [row.id_ for row in first] == want + one = queries_named_slice.get_named_slice_row(conn=sqlite3_conn, ids=ids, wanted="b") + assert (one.id_ if one is not None else None) == (want[0] if want else None) + + @pytest.mark.dependency( + name="Sqlite3TestDataclassFunctions::named_slice_rows_iter", + depends=["Sqlite3TestDataclassFunctions::named_slice_rows"], + ) + def test_named_slice_rows_iter(self, sqlite3_conn: sqlite3.Connection) -> None: + ids = [SLICE_ID_BASE + 1, SLICE_ID_BASE + 3] + rows = list(queries_named_slice.get_named_slice_rows_reused(conn=sqlite3_conn, ids=ids, wanted="b")) + assert [row.id_ for row in rows] == ids + @pytest.mark.dependency( depends=[ "Sqlite3TestDataclassFunctions::get_slice_rows", @@ -1148,6 +1183,8 @@ def test_get_first_slice_name_two_slices(self, sqlite3_conn: sqlite3.Connection) "Sqlite3TestDataclassFunctions::get_slice_rows_by_name_or_note", "Sqlite3TestDataclassFunctions::get_slice_rows_by_name_or_note_filtered", "Sqlite3TestDataclassFunctions::get_first_slice_name_two_slices", + "Sqlite3TestDataclassFunctions::named_slice_rows", + "Sqlite3TestDataclassFunctions::named_slice_rows_iter", ] ) def test_delete_slice_rows(self, sqlite3_conn: sqlite3.Connection) -> None: diff --git a/test/driver_sqlite3/queries_named_slice.sql b/test/driver_sqlite3/queries_named_slice.sql new file mode 100644 index 00000000..f2a8ed85 --- /dev/null +++ b/test/driver_sqlite3/queries_named_slice.sql @@ -0,0 +1,15 @@ +-- sqlc numbers SQLite placeholders as soon as a query uses a named argument, +-- and those indexes stop matching once a sqlc.slice marker expands to more +-- (or fewer) than one placeholder. Every query here mixes the two. + +-- name: GetNamedSliceRows :many +SELECT * FROM test_slice WHERE id IN (sqlc.slice('ids')) AND name = sqlc.arg(wanted) ORDER BY id; + +-- name: GetNamedSliceRowsReused :many +SELECT * FROM test_slice WHERE id IN (sqlc.slice('ids')) AND name = sqlc.arg(wanted) AND (note IS NULL OR name = sqlc.arg(wanted)) AND id IN (sqlc.slice('ids')) ORDER BY id; + +-- name: GetNamedSliceRowsArgFirst :many +SELECT * FROM test_slice WHERE name = sqlc.arg(wanted) AND id IN (sqlc.slice('ids')) ORDER BY id; + +-- name: GetNamedSliceRow :one +SELECT * FROM test_slice WHERE id IN (sqlc.slice('ids')) AND name = sqlc.arg(wanted) ORDER BY id LIMIT 1; diff --git a/test/driver_sqlite3/sqlc-gen-better-python.wasm b/test/driver_sqlite3/sqlc-gen-better-python.wasm index bda78044..e65aad36 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 de1108e8..29fb21be 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: 6b9df77e00051cc9128c70cd35dc645120c5b2eff1c56e6dc5ac99e34485c4ef + sha256: f9e26e327ba75ccd648dea1a55b9cbecbf6586686b5f7329dc15871b0b3fdc77 sql: - schema: schema.sql queries: @@ -108,6 +108,7 @@ sql: - queries_unknown_override.sql - queries_any_param.sql - queries_slice.sql + - queries_named_slice.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 bda78044..e65aad36 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 0e6a5d81..f6f0335e 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: 6b9df77e00051cc9128c70cd35dc645120c5b2eff1c56e6dc5ac99e34485c4ef + sha256: f9e26e327ba75ccd648dea1a55b9cbecbf6586686b5f7329dc15871b0b3fdc77 sql: - schema: schema.sql queries: queries.sql diff --git a/test/driver_turso_sync/dataclass/functions/queries_named_slice.py b/test/driver_turso_sync/dataclass/functions/queries_named_slice.py new file mode 100644 index 00000000..67f28571 --- /dev/null +++ b/test/driver_turso_sync/dataclass/functions/queries_named_slice.py @@ -0,0 +1,207 @@ +# Code generated by sqlc. DO NOT EDIT. +# versions: +# sqlc v1.31.1 +# sqlc-gen-better-python v0.8.0 +# source file: queries_named_slice.sql +"""Module containing queries from file queries_named_slice.sql.""" + +from __future__ import annotations + +__all__: collections.abc.Sequence[str] = ( + "QueryResults", + "get_named_slice_row", + "get_named_slice_rows", + "get_named_slice_rows_arg_first", + "get_named_slice_rows_reused", +) + +import typing + +if typing.TYPE_CHECKING: + import collections.abc + import turso + + type QueryResultsArgsType = int | float | str | memoryview | bytes | collections.abc.Sequence[QueryResultsArgsType] | None + +from test.driver_turso_sync.dataclass.functions import models + + +GET_NAMED_SLICE_ROWS: typing.Final[str] = """-- name: GetNamedSliceRows :many + +SELECT id, name, note FROM test_slice WHERE id IN (/*SLICE:ids*/?) AND name = ? ORDER BY id +""" + +GET_NAMED_SLICE_ROWS_REUSED: typing.Final[str] = """-- name: GetNamedSliceRowsReused :many +SELECT id, name, note FROM test_slice WHERE id IN (/*SLICE:ids*/?) AND name = ? AND (note IS NULL OR name = ?) AND id IN (/*SLICE:ids*/?) ORDER BY id +""" + +GET_NAMED_SLICE_ROWS_ARG_FIRST: typing.Final[str] = """-- name: GetNamedSliceRowsArgFirst :many +SELECT id, name, note FROM test_slice WHERE name = ? AND id IN (/*SLICE:ids*/?) ORDER BY id +""" + +GET_NAMED_SLICE_ROW: typing.Final[str] = """-- name: GetNamedSliceRow :one +SELECT id, name, note FROM test_slice WHERE id IN (/*SLICE:ids*/?) AND name = ? ORDER BY id LIMIT 1 +""" + + +class QueryResults[T]: + """Helper class that allows both iteration and normal fetching of data from the db.""" + + __slots__ = ("_args", "_conn", "_cursor", "_decode_hook", "_sql") + + def __init__( + self, + conn: turso.Connection, + sql: str, + decode_hook: collections.abc.Callable[[tuple[typing.Any, ...]], T], + *args: QueryResultsArgsType, + ) -> None: + """Initialize the QueryResults instance. + + Args: + conn: + The connection object of type `turso.Connection` used to execute queries. + sql: + The SQL statement that will be executed when fetching/iterating. + decode_hook: + A callback that turns an `tuple[typing.Any, ...]` 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: turso.Cursor | 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 turso cursor. + + Returns: + The next decoded result of type `T`. + + Raises: + StopIteration: When no more records are available. + """ + if self._cursor is None: + self._cursor = self._conn.execute(self._sql, self._args) + record = self._cursor.fetchone() + if record is None: + self._cursor = None + raise StopIteration + return self._decode_hook(record) + + +def get_named_slice_rows(conn: turso.Connection, *, ids: collections.abc.Sequence[int], wanted: str) -> QueryResults[models.TestSlice]: + """Fetch many from the db using the SQL query with `name: GetNamedSliceRows :many`. + + ```sql + + SELECT id, name, note FROM test_slice WHERE id IN (/*SLICE:ids*/?) AND name = ? ORDER BY id + ``` + + Args: + conn: + Connection object of type `turso.Connection` used to execute the query. + ids: collections.abc.Sequence[int]. + wanted: 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: tuple[typing.Any, ...]) -> models.TestSlice: + return models.TestSlice(id_=row[0], name=row[1], note=row[2]) + + sql = GET_NAMED_SLICE_ROWS.replace("/*SLICE:ids*/?", ",".join("?" * len(ids)) or "NULL", 1) + return QueryResults(conn, sql, _decode_hook, *ids, wanted) + + +def get_named_slice_rows_reused(conn: turso.Connection, *, ids: collections.abc.Sequence[int], wanted: str) -> QueryResults[models.TestSlice]: + """Fetch many from the db using the SQL query with `name: GetNamedSliceRowsReused :many`. + + ```sql + SELECT id, name, note FROM test_slice WHERE id IN (/*SLICE:ids*/?) AND name = ? AND (note IS NULL OR name = ?) AND id IN (/*SLICE:ids*/?) ORDER BY id + ``` + + Args: + conn: + Connection object of type `turso.Connection` used to execute the query. + ids: collections.abc.Sequence[int]. + wanted: 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: tuple[typing.Any, ...]) -> models.TestSlice: + return models.TestSlice(id_=row[0], name=row[1], note=row[2]) + + sql = GET_NAMED_SLICE_ROWS_REUSED.replace("/*SLICE:ids*/?", ",".join("?" * len(ids)) or "NULL") + return QueryResults(conn, sql, _decode_hook, *ids, wanted, wanted, *ids) + + +def get_named_slice_rows_arg_first(conn: turso.Connection, *, wanted: str, ids: collections.abc.Sequence[int]) -> QueryResults[models.TestSlice]: + """Fetch many from the db using the SQL query with `name: GetNamedSliceRowsArgFirst :many`. + + ```sql + SELECT id, name, note FROM test_slice WHERE name = ? AND id IN (/*SLICE:ids*/?) ORDER BY id + ``` + + Args: + conn: + Connection object of type `turso.Connection` used to execute the query. + wanted: str. + 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: tuple[typing.Any, ...]) -> models.TestSlice: + return models.TestSlice(id_=row[0], name=row[1], note=row[2]) + + sql = GET_NAMED_SLICE_ROWS_ARG_FIRST.replace("/*SLICE:ids*/?", ",".join("?" * len(ids)) or "NULL", 1) + return QueryResults(conn, sql, _decode_hook, wanted, *ids) + + +def get_named_slice_row(conn: turso.Connection, *, ids: collections.abc.Sequence[int], wanted: str) -> models.TestSlice | None: + """Fetch one from the db using the SQL query with `name: GetNamedSliceRow :one`. + + ```sql + SELECT id, name, note FROM test_slice WHERE id IN (/*SLICE:ids*/?) AND name = ? ORDER BY id LIMIT 1 + ``` + + Args: + conn: + Connection object of type `turso.Connection` used to execute the query. + ids: collections.abc.Sequence[int]. + wanted: str. + + Returns: + Result of type `models.TestSlice` fetched from the db. Will be `None` if not found. + """ + sql = GET_NAMED_SLICE_ROW.replace("/*SLICE:ids*/?", ",".join("?" * len(ids)) or "NULL", 1) + row = conn.execute(sql, (*ids, wanted)).fetchone() + if row is None: + return None + return models.TestSlice(id_=row[0], name=row[1], note=row[2]) diff --git a/test/driver_turso_sync/dataclass/test_turso_sync_dataclass_functions.py b/test/driver_turso_sync/dataclass/test_turso_sync_dataclass_functions.py index 7486e661..05a3d64f 100644 --- a/test/driver_turso_sync/dataclass/test_turso_sync_dataclass_functions.py +++ b/test/driver_turso_sync/dataclass/test_turso_sync_dataclass_functions.py @@ -32,6 +32,7 @@ from test.driver_turso_sync.dataclass.functions import models from test.driver_turso_sync.dataclass.functions import queries from test.driver_turso_sync.dataclass.functions import queries_case +from test.driver_turso_sync.dataclass.functions import queries_named_slice from test.driver_turso_sync.dataclass.functions import queries_override_adapter from test.driver_turso_sync.dataclass.functions import queries_override_converter from test.driver_turso_sync.dataclass.functions import queries_slice @@ -1130,6 +1131,40 @@ def test_get_first_slice_name_two_slices(self, turso_sync_conn: turso.Connection assert name == "a" assert queries_slice.get_first_slice_name(conn=turso_sync_conn, ids=[], names=[]) is None + @pytest.mark.dependency( + name="TursoSyncTestDataclassFunctions::named_slice_rows", + depends=["TursoSyncTestDataclassFunctions::insert_slice_rows"], + ) + def test_named_slice_rows(self, turso_sync_conn: turso.Connection) -> None: + # sqlc numbers the placeholders of a query using a named argument, and + # those indexes no longer line up once the marker expands. Every + # length matters: one element used to pass by accident. + # Rows BASE..BASE+3 are named a, b, c, b - so "b" discriminates and + # the id list is not simply echoed back. + for ids, want in ( + ([], []), + ([SLICE_ID_BASE + 1], [SLICE_ID_BASE + 1]), + ([SLICE_ID_BASE + 1, SLICE_ID_BASE + 3], [SLICE_ID_BASE + 1, SLICE_ID_BASE + 3]), + ([SLICE_ID_BASE, SLICE_ID_BASE + 1, SLICE_ID_BASE + 3], [SLICE_ID_BASE + 1, SLICE_ID_BASE + 3]), + ): + rows = queries_named_slice.get_named_slice_rows(conn=turso_sync_conn, ids=ids, wanted="b")() + assert [row.id_ for row in rows] == want + reused = queries_named_slice.get_named_slice_rows_reused(conn=turso_sync_conn, ids=ids, wanted="b")() + assert [row.id_ for row in reused] == want + first = queries_named_slice.get_named_slice_rows_arg_first(conn=turso_sync_conn, wanted="b", ids=ids)() + assert [row.id_ for row in first] == want + one = queries_named_slice.get_named_slice_row(conn=turso_sync_conn, ids=ids, wanted="b") + assert (one.id_ if one is not None else None) == (want[0] if want else None) + + @pytest.mark.dependency( + name="TursoSyncTestDataclassFunctions::named_slice_rows_iter", + depends=["TursoSyncTestDataclassFunctions::named_slice_rows"], + ) + def test_named_slice_rows_iter(self, turso_sync_conn: turso.Connection) -> None: + ids = [SLICE_ID_BASE + 1, SLICE_ID_BASE + 3] + rows = list(queries_named_slice.get_named_slice_rows_reused(conn=turso_sync_conn, ids=ids, wanted="b")) + assert [row.id_ for row in rows] == ids + @pytest.mark.dependency( depends=[ "TursoSyncTestDataclassFunctions::get_slice_rows", @@ -1140,6 +1175,8 @@ def test_get_first_slice_name_two_slices(self, turso_sync_conn: turso.Connection "TursoSyncTestDataclassFunctions::get_slice_rows_by_name_or_note", "TursoSyncTestDataclassFunctions::get_slice_rows_by_name_or_note_filtered", "TursoSyncTestDataclassFunctions::get_first_slice_name_two_slices", + "TursoSyncTestDataclassFunctions::named_slice_rows", + "TursoSyncTestDataclassFunctions::named_slice_rows_iter", ] ) def test_delete_slice_rows(self, turso_sync_conn: turso.Connection) -> None: diff --git a/test/driver_turso_sync/queries_named_slice.sql b/test/driver_turso_sync/queries_named_slice.sql new file mode 100644 index 00000000..f2a8ed85 --- /dev/null +++ b/test/driver_turso_sync/queries_named_slice.sql @@ -0,0 +1,15 @@ +-- sqlc numbers SQLite placeholders as soon as a query uses a named argument, +-- and those indexes stop matching once a sqlc.slice marker expands to more +-- (or fewer) than one placeholder. Every query here mixes the two. + +-- name: GetNamedSliceRows :many +SELECT * FROM test_slice WHERE id IN (sqlc.slice('ids')) AND name = sqlc.arg(wanted) ORDER BY id; + +-- name: GetNamedSliceRowsReused :many +SELECT * FROM test_slice WHERE id IN (sqlc.slice('ids')) AND name = sqlc.arg(wanted) AND (note IS NULL OR name = sqlc.arg(wanted)) AND id IN (sqlc.slice('ids')) ORDER BY id; + +-- name: GetNamedSliceRowsArgFirst :many +SELECT * FROM test_slice WHERE name = sqlc.arg(wanted) AND id IN (sqlc.slice('ids')) ORDER BY id; + +-- name: GetNamedSliceRow :one +SELECT * FROM test_slice WHERE id IN (sqlc.slice('ids')) AND name = sqlc.arg(wanted) ORDER BY id LIMIT 1; diff --git a/test/driver_turso_sync/sqlc-gen-better-python.wasm b/test/driver_turso_sync/sqlc-gen-better-python.wasm index bda78044..e65aad36 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 de9ce518..bd4a7c3c 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: 6b9df77e00051cc9128c70cd35dc645120c5b2eff1c56e6dc5ac99e34485c4ef + sha256: f9e26e327ba75ccd648dea1a55b9cbecbf6586686b5f7329dc15871b0b3fdc77 sql: - schema: schema.sql queries: @@ -107,6 +107,7 @@ sql: - queries_case.sql - queries_unknown_override.sql - queries_slice.sql + - queries_named_slice.sql engine: sqlite codegen: - out: /dataclass/functions