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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changes/unreleased/Fixed-20260721-210000.yaml
Original file line number Diff line number Diff line change
@@ -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"
12 changes: 6 additions & 6 deletions docs/content/docs/reference/feature-support.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
224 changes: 220 additions & 4 deletions internal/driver/common.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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.
Expand Down
Loading
Loading