Skip to content
Open
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
38 changes: 28 additions & 10 deletions internal/compiler/find_params.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,18 +20,20 @@ func findParameters(root ast.Node) ([]paramRef, []error) {
}

type paramRef struct {
parent ast.Node
rv *ast.RangeVar
ref *ast.ParamRef
name string // Named parameter support
parent ast.Node
rv *ast.RangeVar
ref *ast.ParamRef
name string // Named parameter support
inSubquery bool // true if this ParamRef sits inside a SubLink's subselect; gates scope narrowing to subqueries only (issue #4251)
}

type paramSearch struct {
parent ast.Node
rangeVar *ast.RangeVar
refs *[]paramRef
seen map[int]struct{}
errs *[]error
parent ast.Node
rangeVar *ast.RangeVar
refs *[]paramRef
seen map[int]struct{}
errs *[]error
inSubquery bool // true once we have descended into a SubLink's subselect; propagates to ParamRefs encountered below it (issue #4251)

// XXX: Gross state hack for limit
limitCount ast.Node
Expand Down Expand Up @@ -139,6 +141,22 @@ func (p paramSearch) Visit(node ast.Node) astutils.Visitor {
case *ast.ResTarget:
p.parent = node

case *ast.SubLink:
// Issue #4251: when descending into a SubLink's subselect (e.g.
// "x IN (SELECT y FROM t WHERE id = $1)"), capture the subselect's
// first FROM-clause RangeVar so ParamRefs inside its WHERE/GROUP/etc.
// resolve against the inner scope. Only narrow when the subselect's
// FROM is unambiguous on its own (single RangeVar; no JOINs / no
// multi-table FROM / no nested subselect). Scoped to SubLinks
// specifically: top-level INSERT-SELECT / JOIN / etc. continue to use
// the full-table search the resolver has always done.
p.inSubquery = true
if sel, ok := n.Subselect.(*ast.SelectStmt); ok && sel.FromClause != nil && len(sel.FromClause.Items) == 1 {
if rv, ok := sel.FromClause.Items[0].(*ast.RangeVar); ok && rv != nil && rv.Relname != nil {
p.rangeVar = rv
}
}

case *ast.SelectStmt:
if n.LimitCount != nil {
p.limitCount = n.LimitCount
Expand Down Expand Up @@ -186,7 +204,7 @@ func (p paramSearch) Visit(node ast.Node) astutils.Visitor {
}

if set {
*p.refs = append(*p.refs, paramRef{parent: parent, ref: n, rv: p.rangeVar})
*p.refs = append(*p.refs, paramRef{parent: parent, ref: n, rv: p.rangeVar, inSubquery: p.inSubquery})
p.seen[n.Location] = struct{}{}
}
return nil
Expand Down
53 changes: 53 additions & 0 deletions internal/compiler/resolve.go
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,10 @@ func (comp *Compiler) resolveCatalogRefs(qc *QueryCatalog, rvs []*ast.RangeVar,
}
}
}
} else if ref.inSubquery {
if scoped := narrowToInnermostScope(tables, typeMap, c.DefaultSchema, ref.rv, key); scoped != nil {
search = scoped
}
}

var found int
Expand Down Expand Up @@ -581,6 +585,10 @@ func (comp *Compiler) resolveCatalogRefs(qc *QueryCatalog, rvs []*ast.RangeVar,
}
}
}
} else if ref.inSubquery {
if scoped := narrowToInnermostScope(tables, typeMap, c.DefaultSchema, ref.rv, key); scoped != nil {
search = scoped
}
}

for _, table := range search {
Expand Down Expand Up @@ -636,3 +644,48 @@ func (comp *Compiler) resolveCatalogRefs(qc *QueryCatalog, rvs []*ast.RangeVar,
}
return a, nil
}

// narrowToInnermostScope returns a single-element search slice when the
// parameter reference (ref.rv) points to a known table that actually
// contains the column being resolved. It implements the lexical-scope rule
// real PostgreSQL applies inside subqueries: an unqualified column reference
// is bound to the innermost FROM-clause table that defines it. If the
// innermost scope is unknown (rv nil) or the column is not present there
// (correlated-subquery referring to an outer column), it returns nil so the
// caller falls back to the full table list. See issue #4251.
func narrowToInnermostScope(
tables []*ast.TableName,
typeMap map[string]map[string]map[string]*catalog.Column,
defaultSchema string,
rv *ast.RangeVar,
column string,
) []*ast.TableName {
if rv == nil || rv.Relname == nil {
return nil
}
innerName := *rv.Relname
innerSchema := ""
if rv.Schemaname != nil {
innerSchema = *rv.Schemaname
}
lookupSchema := innerSchema
if lookupSchema == "" {
lookupSchema = defaultSchema
}
// Only narrow if the column actually exists in the innermost scope.
// Falling back to the full search preserves correlated-subquery
// behavior (e.g. an inner WHERE referring to an outer column).
if _, ok := typeMap[lookupSchema][innerName][column]; !ok {
return nil
}
for _, t := range tables {
tSchema := t.Schema
if tSchema == "" {
tSchema = defaultSchema
}
if t.Name == innerName && tSchema == lookupSchema {
return []*ast.TableName{t}
}
}
return nil
}
71 changes: 71 additions & 0 deletions internal/compiler/resolve_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
package compiler

import (
"testing"

"github.com/sqlc-dev/sqlc/internal/sql/ast"
"github.com/sqlc-dev/sqlc/internal/sql/catalog"
)

// TestNarrowToInnermostScope covers the lexical-scope rule used by the
// resolver to disambiguate column references that live inside subqueries.
// See issue #4251.
func TestNarrowToInnermostScope(t *testing.T) {
t.Parallel()

str := func(s string) *string { return &s }
tables := []*ast.TableName{
{Name: "t1"},
{Name: "t2"},
}
typeMap := map[string]map[string]map[string]*catalog.Column{
"public": {
"t1": {"id": {Name: "id"}},
"t2": {"id": {Name: "id"}, "t1_id": {Name: "t1_id"}},
},
}

t.Run("nil_rv_returns_nil", func(t *testing.T) {
got := narrowToInnermostScope(tables, typeMap, "public", nil, "id")
if got != nil {
t.Fatalf("expected nil (no narrowing) got %v", got)
}
})

t.Run("nil_relname_returns_nil", func(t *testing.T) {
got := narrowToInnermostScope(tables, typeMap, "public", &ast.RangeVar{}, "id")
if got != nil {
t.Fatalf("expected nil (no narrowing) got %v", got)
}
})

t.Run("column_in_inner_scope_narrows_to_inner_table", func(t *testing.T) {
// The repro shape: ParamRef in inner SELECT (rv=t2) resolving column "id".
// id exists in t2 -> narrow search to [t2] so the outer t1.id doesn't
// trigger a spurious "ambiguous" error.
rv := &ast.RangeVar{Relname: str("t2")}
got := narrowToInnermostScope(tables, typeMap, "public", rv, "id")
if len(got) != 1 || got[0].Name != "t2" {
t.Fatalf("expected narrow to [t2], got %v", got)
}
})

t.Run("column_absent_from_inner_falls_back_to_full_scope", func(t *testing.T) {
// Correlated-subquery shape: inner SELECT (rv=t2) references an outer
// column not present in t2. Returning nil tells the caller to keep the
// full tables list, which lets the outer-scope match win.
rv := &ast.RangeVar{Relname: str("t2")}
got := narrowToInnermostScope(tables, typeMap, "public", rv, "not_a_t2_column")
if got != nil {
t.Fatalf("expected nil (fall back to all tables), got %v", got)
}
})

t.Run("rv_points_to_unknown_table_falls_back", func(t *testing.T) {
rv := &ast.RangeVar{Relname: str("nonexistent")}
got := narrowToInnermostScope(tables, typeMap, "public", rv, "id")
if got != nil {
t.Fatalf("expected nil (fall back), got %v", got)
}
})
}

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
-- name: GetT1FromT2 :many
-- Unqualified `id` inside the subquery must bind to t2.id (innermost
-- FROM-clause scope), not be flagged as ambiguous against t1.id. See
-- issue #4251.
SELECT id FROM t1
WHERE id IN (
SELECT t1_id
FROM t2
WHERE id = $1
);
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
CREATE TABLE t1 (
id UUID PRIMARY KEY
);

CREATE TABLE t2 (
id UUID,
t1_id UUID REFERENCES t1(id) ON DELETE CASCADE
);
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"version": "1",
"packages": [
{
"path": "go",
"engine": "postgresql",
"sql_package": "pgx/v5",
"name": "querytest",
"schema": "schema.sql",
"queries": "query.sql"
}
]
}
Loading