Skip to content

Commit f9b7b21

Browse files
kyleconroyclaude
andauthored
compiler: expand stars in the query text on the core path (#4567)
* compiler: expand stars in the query text on the core path The core analyzer already resolved a star to the columns it covers, but only for the result set — the query it handed to codegen still said "SELECT *", so the generated SQL asked the database for whatever the table happened to hold at run time rather than the columns sqlc scanned into. Every case in the corpus that selects a star generated different code through the core than through the legacy path. The analyzer now reports each star along with the columns it stands for, sharing one list with the analyzers of the queries nested in it so a statement reports the stars in its subqueries and CTEs too. The compiler turns those into edits on the query text, which is where the engine's quoting rules and SQLite's jsonb wrapping live and where the legacy path already does the same rewrite — the two now produce byte-identical SQL across the corpus. Rewriting means reparsing to check the edit produced valid SQL, and the core path analyzes statements concurrently. A parser holds too much state for two goroutines to share one, so the compiler keeps the constructor and a goroutine builds its own. 181 of the 527 cases failing under the core context now pass. * compiler: fix what the star dedupe claims, and prove it is needed Review of the previous commit turned up three things. The dedupe in expandCore was justified by a CTE analyzed twice, which does not happen — a CTE is analyzed once and cached. Instrumenting it to panic on a duplicate showed the corpus never hits it at all. The real source is an expression typed a second time when a later clause refers to the output name it was given: "SELECT (SELECT * FROM baz LIMIT 1) AS x FROM foo GROUP BY x" reports the star in the subquery once for GROUP BY and once for the target list, and dropping the dedupe turns that query into an overlapping edit. The comment now says so and the query is part of the star_expansion_core case on every engine, where it generates what the legacy path generates. expandCore returned an error it never produced, so it returns only edits. newParser was set on the core path alone, leaving a nil func field on a Compiler built the other way. Nothing calls it there today, but a field that is only sometimes valid is one to get wrong later, so the legacy path sets it too and takes its own parser from it. * compiler: set newParser for the engines main added, and cover hidden columns Rebasing onto main brought two things this change has to answer for. SQL Server and DuckDB were added to initCore assigning c.parser directly, while this branch had taken to deriving c.parser from c.newParser at the end of the same switch. Neither side conflicted, so the rebase produced a switch that leaves newParser nil for those two engines and then calls it — every SQL Server and DuckDB query set panicked. They set newParser like the rest now. An fts5 table offers columns it does not declare, and a star must not expand to them. The analyzer already skips a hidden column, and the merge carried that into the star's column list along with the result set, which is what it should do. Nothing covered it: the fts5 case selects named columns, and it only runs under the opt-in core context. star_expansion_core selects a star from an fts5 table on sqlite, where it runs in the default suite, and expands to the declared column alone the way the legacy path does. The goldens are regenerated for codegen emitting any over interface{}. --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 5e1deed commit f9b7b21

31 files changed

Lines changed: 1227 additions & 50 deletions

File tree

internal/compiler/engine.go

Lines changed: 19 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,13 @@ type Compiler struct {
4040
coreAnalysis bool
4141
coreDialect core.Option
4242

43+
// newParser builds a parser for the configured engine, and is set for
44+
// every engine either path supports. The core path analyzes statements
45+
// concurrently, and a parser holds enough state that two goroutines cannot
46+
// share one, so a goroutine that has to parse something of its own — the
47+
// query text it just rewrote — builds its own rather than taking c.parser.
48+
newParser func() Parser
49+
4350
schema []string
4451
}
4552

@@ -79,7 +86,7 @@ func NewCompiler(conf config.SQL, combo config.CombinedSettings, parserOpts opts
7986

8087
switch conf.Engine {
8188
case config.EngineSQLite:
82-
c.parser = sqlite.NewParser()
89+
c.newParser = func() Parser { return sqlite.NewParser() }
8390
c.catalog = sqlite.NewCatalog()
8491
c.selector = newSQLiteSelector()
8592

@@ -93,11 +100,11 @@ func NewCompiler(conf config.SQL, combo config.CombinedSettings, parserOpts opts
93100
}
94101
}
95102
case config.EngineMySQL:
96-
c.parser = dolphin.NewParser()
103+
c.newParser = func() Parser { return dolphin.NewParser() }
97104
c.catalog = dolphin.NewCatalog()
98105
c.selector = newDefaultSelector()
99106
case config.EnginePostgreSQL:
100-
c.parser = postgresql.NewParser()
107+
c.newParser = func() Parser { return postgresql.NewParser() }
101108
c.catalog = postgresql.NewCatalog()
102109
c.selector = newDefaultSelector()
103110

@@ -113,6 +120,7 @@ func NewCompiler(conf config.SQL, combo config.CombinedSettings, parserOpts opts
113120
default:
114121
return nil, fmt.Errorf("unknown engine: %s", conf.Engine)
115122
}
123+
c.parser = c.newParser()
116124
return c, nil
117125
}
118126

@@ -122,36 +130,37 @@ func (c *Compiler) initCore() error {
122130
var dialect core.Option
123131
switch c.conf.Engine {
124132
case config.EngineSQLite:
125-
c.parser = sqlite.NewParser()
133+
c.newParser = func() Parser { return sqlite.NewParser() }
126134
c.selector = newSQLiteSelector()
127135
dialect = sqlite.Dialect()
128136
case config.EngineMySQL:
129-
c.parser = dolphin.NewParser()
137+
c.newParser = func() Parser { return dolphin.NewParser() }
130138
c.selector = newDefaultSelector()
131139
dialect = dolphin.Dialect()
132140
case config.EnginePostgreSQL:
133-
c.parser = postgresql.NewParser()
141+
c.newParser = func() Parser { return postgresql.NewParser() }
134142
c.selector = newDefaultSelector()
135143
dialect = postgresql.Dialect()
136144
case config.EngineClickHouse:
137-
c.parser = clickhouse.NewParser()
145+
c.newParser = func() Parser { return clickhouse.NewParser() }
138146
c.selector = newDefaultSelector()
139147
dialect = clickhouse.Dialect()
140148
case config.EngineGoogleSQL:
141-
c.parser = googlesql.NewParser()
149+
c.newParser = func() Parser { return googlesql.NewParser() }
142150
c.selector = newDefaultSelector()
143151
dialect = googlesql.Dialect()
144152
case config.EngineMSSQL:
145-
c.parser = mssql.NewParser()
153+
c.newParser = func() Parser { return mssql.NewParser() }
146154
c.selector = newDefaultSelector()
147155
dialect = mssql.Dialect()
148156
case config.EngineDuckDB:
149-
c.parser = duckdb.NewParser()
157+
c.newParser = func() Parser { return duckdb.NewParser() }
150158
c.selector = newDefaultSelector()
151159
dialect = duckdb.Dialect()
152160
default:
153161
return fmt.Errorf("unknown engine: %s", c.conf.Engine)
154162
}
163+
c.parser = c.newParser()
155164
c.coreDialect = dialect
156165
return nil
157166
}

internal/compiler/expand.go

Lines changed: 33 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,38 @@ func (c *Compiler) quote(x string) string {
7878
}
7979
}
8080

81+
// starOldFunc measures how much of the query text a star reference occupies,
82+
// so an edit replaces the reference and nothing else. Each part is measured
83+
// both bare and quoted: an embed was rewritten to "table.*" in the query text,
84+
// preserving the way the user quoted the table, so it is measured the same way
85+
// as a star reference the user wrote.
86+
func (c *Compiler) starOldFunc(parts []string) func(string) int {
87+
old := make([]string, 0, len(parts))
88+
for _, p := range parts {
89+
if p == "*" {
90+
old = append(old, p)
91+
} else {
92+
old = append(old, c.quoteIdent(p))
93+
}
94+
}
95+
return func(s string) int {
96+
length := 0
97+
for i, o := range old {
98+
if hasSeparator := i > 0; hasSeparator {
99+
length++
100+
}
101+
if strings.HasPrefix(s[length:], o) {
102+
length += len(o)
103+
} else if quoted := c.quote(o); strings.HasPrefix(s[length:], quoted) {
104+
length += len(quoted)
105+
} else {
106+
length += len(o)
107+
}
108+
}
109+
return length
110+
}
111+
}
112+
81113
func (c *Compiler) expandStmt(qc *QueryCatalog, raw *ast.RawStmt, node ast.Node) ([]source.Edit, error) {
82114
tables, err := c.sourceTables(qc, node)
83115
if err != nil {
@@ -168,37 +200,9 @@ func (c *Compiler) expandStmt(qc *QueryCatalog, raw *ast.RawStmt, node ast.Node)
168200
cols = append(cols, cname)
169201
}
170202
}
171-
var old []string
172-
for _, p := range parts {
173-
if p == "*" {
174-
old = append(old, p)
175-
} else {
176-
old = append(old, c.quoteIdent(p))
177-
}
178-
}
179-
180-
// An embed was rewritten to "table.*" in the query text, so it is
181-
// measured the same way as a star reference the user wrote.
182-
oldFunc := func(s string) int {
183-
length := 0
184-
for i, o := range old {
185-
if hasSeparator := i > 0; hasSeparator {
186-
length++
187-
}
188-
if strings.HasPrefix(s[length:], o) {
189-
length += len(o)
190-
} else if quoted := c.quote(o); strings.HasPrefix(s[length:], quoted) {
191-
length += len(quoted)
192-
} else {
193-
length += len(o)
194-
}
195-
}
196-
return length
197-
}
198-
199203
edits = append(edits, source.Edit{
200204
Location: res.Location - raw.StmtLocation,
201-
OldFunc: oldFunc,
205+
OldFunc: c.starOldFunc(parts),
202206
New: strings.Join(cols, ", "),
203207
})
204208
}

internal/compiler/expand_core.go

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
package compiler
2+
3+
import (
4+
"strings"
5+
6+
"github.com/sqlc-dev/sqlc/internal/core"
7+
"github.com/sqlc-dev/sqlc/internal/source"
8+
"github.com/sqlc-dev/sqlc/internal/sql/ast"
9+
)
10+
11+
// expandCore rewrites the stars in a query's text with the columns the core
12+
// analyzer resolved them to. The analyzer has already walked the statement and
13+
// its subqueries, so there is nothing left to look up here: what remains is
14+
// deciding how each name is written, which is the engine's business and not
15+
// the core's.
16+
func (c *Compiler) expandCore(raw *ast.RawStmt, stars []core.StarExpansion) []source.Edit {
17+
if len(stars) == 0 {
18+
return nil
19+
}
20+
edits := make([]source.Edit, 0, len(stars))
21+
seen := make(map[int]bool, len(stars))
22+
for _, star := range stars {
23+
// The analyzer types an expression again when a later clause refers to
24+
// the output name it was given, so "SELECT (SELECT * FROM t) AS x ...
25+
// GROUP BY x" reports the star in the subquery once per pass. The
26+
// passes agree on what the star covers, and editing the same reference
27+
// twice would overlap, so only the first is kept.
28+
if seen[star.Location] {
29+
continue
30+
}
31+
seen[star.Location] = true
32+
33+
// Everything before the star qualifies it: "foo.*" is scoped to foo,
34+
// while a bare "*" covers every relation in the FROM clause.
35+
scope := strings.Join(star.Fields[:len(star.Fields)-1], ".")
36+
37+
// An unqualified star that covers more than one relation may name the
38+
// same column twice, so those are written with their relation.
39+
counts := map[string]int{}
40+
if scope == "" {
41+
for _, col := range star.Columns {
42+
counts[col.Name]++
43+
}
44+
}
45+
46+
cols := make([]string, 0, len(star.Columns))
47+
for _, col := range star.Columns {
48+
cname := col.Name
49+
if star.Alias != "" {
50+
cname = star.Alias
51+
}
52+
cname = c.quoteIdent(cname)
53+
if scope != "" {
54+
cname = c.quoteIdent(scope) + "." + cname
55+
}
56+
if counts[cname] > 1 {
57+
cname = c.quoteIdent(col.Relation) + "." + cname
58+
}
59+
60+
// This is important for SQLite in particular which needs to wrap
61+
// jsonb column values with `json(colname)` so they're in a publicly
62+
// usable format (i.e. not jsonb).
63+
cols = append(cols, c.selector.ColumnExpr(cname, &Column{
64+
Name: col.Name,
65+
DataType: col.DataType,
66+
}))
67+
}
68+
69+
edits = append(edits, source.Edit{
70+
Location: star.Location - raw.StmtLocation,
71+
OldFunc: c.starOldFunc(star.Fields),
72+
New: strings.Join(cols, ", "),
73+
})
74+
}
75+
return edits
76+
}

internal/compiler/parse_core.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package compiler
22

33
import (
44
"errors"
5+
"fmt"
56
"strings"
67

78
"github.com/sqlc-dev/sqlc/internal/core"
@@ -64,6 +65,17 @@ func (c *Compiler) parseQueryCore(raw *ast.RawStmt, src string, pre *preprocess.
6465
for _, p := range res.Parameters {
6566
params = append(params, Parameter{Number: p.Number, Column: coreParamColumn(p, namedParams)})
6667
}
68+
expanded, err = source.Mutate(rawSQL, c.expandCore(raw, res.Stars))
69+
if err != nil {
70+
return nil, err
71+
}
72+
}
73+
74+
// If the query string was edited, make sure the syntax is valid
75+
if expanded != rawSQL {
76+
if _, err := c.newParser().Parse(strings.NewReader(expanded)); err != nil {
77+
return nil, fmt.Errorf("edited query syntax is invalid: %w", err)
78+
}
6779
}
6880

6981
trimmed, comments, err := source.StripComments(expanded)

internal/core/analysis.go

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,39 @@ const (
1010
)
1111

1212
type PrepareResult struct {
13-
Command Command `json:"command,omitempty"`
14-
Columns []Column `json:"columns"`
15-
Parameters []Parameter `json:"parameters"`
13+
Command Command `json:"command,omitempty"`
14+
Columns []Column `json:"columns"`
15+
Parameters []Parameter `json:"parameters"`
16+
Stars []StarExpansion `json:"stars,omitempty"`
17+
}
18+
19+
// StarExpansion is what a star in a target list stands for. The analyzer
20+
// resolves the reference against the query's scope and reports the columns it
21+
// covers; rewriting the query text with them is the caller's to do, since only
22+
// it knows how the engine quotes an identifier.
23+
type StarExpansion struct {
24+
// Location is where the target the star belongs to starts, measured the
25+
// way the AST measures a node: from the beginning of the file the
26+
// statement was parsed from.
27+
Location int `json:"location"`
28+
29+
// Fields is the reference as it was written, with the star as its last
30+
// element: ["*"] for a bare star and ["foo", "*"] for a qualified one.
31+
Fields []string `json:"fields"`
32+
33+
// Alias is the output name the target was given, if any.
34+
Alias string `json:"alias,omitempty"`
35+
36+
Columns []StarColumn `json:"columns"`
37+
}
38+
39+
// StarColumn is a single column a star expanded to.
40+
type StarColumn struct {
41+
// Relation is the name the column's relation goes by in the query, which
42+
// is its alias when it was given one.
43+
Relation string `json:"relation,omitempty"`
44+
Name string `json:"name"`
45+
DataType string `json:"data_type,omitempty"`
1646
}
1747

1848
type ColumnSource struct {

internal/core/analyzer/analyzer.go

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ func Prepare(cat *core.Catalog, stmt ast.Node) (core.PrepareResult, error) {
1414
a := &analyzer{
1515
cat: cat,
1616
params: map[int]core.Parameter{},
17+
stars: &[]core.StarExpansion{},
1718
}
1819
switch s := stmt.(type) {
1920
case *ast.SelectStmt:
@@ -63,6 +64,18 @@ type analyzer struct {
6364

6465
// resolving guards against an alias that refers to itself.
6566
resolving map[string]bool
67+
68+
// stars are the expansions every star in the statement asked for, shared
69+
// with the analyzers of the queries nested in it so one statement reports
70+
// all of them.
71+
stars *[]core.StarExpansion
72+
}
73+
74+
func (a *analyzer) recordStar(s core.StarExpansion) {
75+
if a.stars == nil {
76+
return
77+
}
78+
*a.stars = append(*a.stars, s)
6679
}
6780

6881
// subquery analyzes a nested SELECT. It shares the parameter set, so a
@@ -74,6 +87,7 @@ func (a *analyzer) subquery(s *ast.SelectStmt) (*analyzer, error) {
7487
params: a.params,
7588
outer: a.scope,
7689
ctes: a.ctes,
90+
stars: a.stars,
7791
}
7892
if err := sub.analyzeSelect(s); err != nil {
7993
return nil, err
@@ -105,11 +119,15 @@ func derivedRel(alias string, cols []core.Column) scopeRel {
105119
}
106120

107121
func (a *analyzer) result() core.PrepareResult {
108-
return core.PrepareResult{
122+
res := core.PrepareResult{
109123
Command: a.command,
110124
Columns: a.columns,
111125
Parameters: orderedParams(a.params),
112126
}
127+
if a.stars != nil {
128+
res.Stars = *a.stars
129+
}
130+
return res
113131
}
114132

115133
func orderedParams(m map[int]core.Parameter) []core.Parameter {

0 commit comments

Comments
 (0)