@@ -100,6 +100,7 @@ func runAttribute(inPath, connStr string, top int) error {
100100 }
101101 defer conn .Close (ctx )
102102
103+ cache := newTypecastCache (conn )
103104 attributed , skipped := 0 , 0
104105 for i := range doc .Clusters {
105106 if top > 0 && i >= top {
@@ -109,7 +110,7 @@ func runAttribute(inPath, connStr string, top int) error {
109110 if c .Fingerprint == "" || c .Canonical == "" {
110111 continue
111112 }
112- params , err := attributeCluster (ctx , conn , c .Canonical )
113+ params , err := attributeCluster (ctx , conn , cache , c .Canonical )
113114 if err != nil {
114115 skipped ++
115116 c .Params = []qshape.ParamAttribution {{Confidence : "none" , Note : err .Error ()}}
@@ -129,10 +130,45 @@ func runAttribute(inPath, connStr string, top int) error {
129130 return enc .Encode (doc )
130131}
131132
132- func attributeCluster (ctx context.Context , conn * pgx.Conn , canonical string ) ([]qshape.ParamAttribution , error ) {
133- var planJSON []byte
134- row := conn .QueryRow (ctx , "EXPLAIN (GENERIC_PLAN, FORMAT JSON) " + canonical )
135- if err := row .Scan (& planJSON ); err != nil {
133+ func attributeCluster (ctx context.Context , conn * pgx.Conn , cache * typecastCache , canonical string ) ([]qshape.ParamAttribution , error ) {
134+ // Re-normalise so clusters.json written by an older qshape version picks
135+ // up current reshape fixes (extract-field recovery, param renumbering).
136+ // Fall back to the stored form if parsing fails
137+ if renormed , err := qshape .Normalize (canonical ); err == nil {
138+ canonical = renormed
139+ }
140+ explainSQL := castFuncParamRefs (ctx , cache , canonical )
141+ // PREPARE + EXPLAIN EXECUTE so Postgres sets up a parameter context
142+ // for the $N placeholders. Works on any PG version (GENERIC_PLAN alone
143+ // requires 16+, and the simple-query parser rejects bare $N otherwise).
144+ // Types come from the typecast pass we just ran; NULL values satisfy
145+ // EXECUTE's arity requirement without affecting the plan
146+ nparams := maxParamNumber (explainSQL )
147+ nulls := "NULL"
148+ for i := 1 ; i < nparams ; i ++ {
149+ nulls += ", NULL"
150+ }
151+ // force_generic_plan keeps $N in the plan output instead of inlining
152+ // the NULL arguments. Without it Postgres produces a custom plan with
153+ // `WHERE col = NULL` filters that walkPlan can't attribute
154+ script := "SET LOCAL plan_cache_mode = force_generic_plan;\n "
155+ script += "PREPARE _qshape_tmp AS " + explainSQL + ";\n "
156+ if nparams > 0 {
157+ script += "EXPLAIN (FORMAT JSON) EXECUTE _qshape_tmp(" + nulls + ");\n "
158+ } else {
159+ script += "EXPLAIN (FORMAT JSON) EXECUTE _qshape_tmp;\n "
160+ }
161+ script += "DEALLOCATE _qshape_tmp;"
162+ // SET LOCAL only applies inside a transaction — wrap the whole script
163+ script = "BEGIN;\n " + script + "\n COMMIT;"
164+ planJSON , err := readPlanJSON (ctx , conn , script )
165+ if err != nil {
166+ // A mid-batch error aborts the BEGIN'd transaction and skips the
167+ // trailing COMMIT in the same simple-query batch, so the connection
168+ // stays in aborted state and every following cluster fails with
169+ // 25P02. ROLLBACK resets it before the next call.
170+ _ , _ = conn .Exec (ctx , "ROLLBACK" )
171+ _ , _ = conn .Exec (ctx , "DEALLOCATE IF EXISTS _qshape_tmp" )
136172 return nil , err
137173 }
138174
@@ -157,6 +193,73 @@ func attributeCluster(ctx context.Context, conn *pgx.Conn, canonical string) ([]
157193 return out , nil
158194}
159195
196+ // readPlanJSON runs a multi-statement script (PREPARE; EXPLAIN; DEALLOCATE)
197+ // through simple-query protocol and returns the first text cell of the
198+ // first result-bearing statement. pgx's Query path sanitises $N against
199+ // bound args; here we send the script verbatim
200+ func readPlanJSON (ctx context.Context , conn * pgx.Conn , script string ) ([]byte , error ) {
201+ mrr := conn .PgConn ().Exec (ctx , script )
202+ defer mrr .Close ()
203+ var out []byte
204+ for mrr .NextResult () {
205+ rr := mrr .ResultReader ()
206+ for rr .NextRow () {
207+ vals := rr .Values ()
208+ if len (vals ) > 0 && out == nil {
209+ out = append ([]byte (nil ), vals [0 ]... )
210+ }
211+ }
212+ if _ , err := rr .Close (); err != nil {
213+ return nil , err
214+ }
215+ }
216+ if err := mrr .Close (); err != nil {
217+ return nil , err
218+ }
219+ if out == nil {
220+ return nil , fmt .Errorf ("no rows returned" )
221+ }
222+ return out , nil
223+ }
224+
225+ // maxParamNumber scans sql for `$N` tokens and returns the highest N seen,
226+ // or 0 if none. Skips $-tags inside string and dollar-quoted contexts
227+ func maxParamNumber (sql string ) int {
228+ max := 0
229+ for i := 0 ; i < len (sql ); i ++ {
230+ c := sql [i ]
231+ switch c {
232+ case '\'' :
233+ // skip to matching quote, honoring doubled ''
234+ i ++
235+ for i < len (sql ) {
236+ if sql [i ] == '\'' {
237+ if i + 1 < len (sql ) && sql [i + 1 ] == '\'' {
238+ i += 2
239+ continue
240+ }
241+ break
242+ }
243+ i ++
244+ }
245+ case '$' :
246+ j := i + 1
247+ n := 0
248+ for j < len (sql ) && sql [j ] >= '0' && sql [j ] <= '9' {
249+ n = n * 10 + int (sql [j ]- '0' )
250+ j ++
251+ }
252+ if j > i + 1 {
253+ if n > max {
254+ max = n
255+ }
256+ i = j - 1
257+ }
258+ }
259+ }
260+ return max
261+ }
262+
160263func walkPlan (raw json.RawMessage , parentSchema , parentTable string , ctx * attrCtx ) {
161264 if len (raw ) == 0 {
162265 return
@@ -166,21 +269,32 @@ func walkPlan(raw json.RawMessage, parentSchema, parentTable string, ctx *attrCt
166269 return
167270 }
168271
169- // Track alias → table mapping so we can resolve `u.id = $1` to users.id
272+ // Track alias → table mapping so we can resolve `u.id = $1` to users.id.
273+ // Function Scan on a system view like pg_catalog.pg_settings leaves
274+ // RelationName empty but still sets Alias; use Alias as the table name
275+ // so conds like `(name = $1)` attribute to pg_settings.name.
170276 aliasToTable := map [string ]tableRef {}
277+ fallbackTable := n .RelationName
278+ fallbackSchema := n .Schema
171279 if n .RelationName != "" {
172280 t := tableRef {Schema : n .Schema , Table : n .RelationName }
173281 aliasToTable [n .RelationName ] = t
174282 if n .Alias != "" && n .Alias != n .RelationName {
175283 aliasToTable [n .Alias ] = t
176284 }
285+ } else if n .Alias != "" {
286+ // Function Scan / Values Scan: no Relation Name but Alias names the
287+ // logical target (e.g. pg_settings). Attribute to the alias.
288+ t := tableRef {Schema : n .Schema , Table : n .Alias }
289+ aliasToTable [n .Alias ] = t
290+ fallbackTable = n .Alias
177291 }
178292
179293 for _ , cond := range []string {n .IndexCond , n .HashCond , n .Filter , n .RecheckCond , n .JoinFilter , n .MergeCond } {
180294 if cond == "" {
181295 continue
182296 }
183- attributeCond (cond , aliasToTable , n . Schema , n . RelationName , ctx )
297+ attributeCond (cond , aliasToTable , fallbackSchema , fallbackTable , ctx )
184298 }
185299
186300 if len (n .Plans ) > 0 {
@@ -223,10 +337,19 @@ func recordParam(aliasOrTable, col, posStr string, aliases map[string]tableRef,
223337 ref , ok := aliases [aliasOrTable ]
224338 confidence := "exact"
225339 if ! ok {
226- // Bare column without an alias — attribute to the current relation.
227340 if fallbackTable != "" {
228341 ref = tableRef {Schema : fallbackSchema , Table : fallbackTable }
229- confidence = "heuristic"
342+ // An unqualified column in plan text (e.g. `Filter: (id = $1)`
343+ // on an Index Scan over `session`) is PG telling us exactly
344+ // which scan node the column belongs to — not a guess. Only
345+ // downgrade to heuristic when we saw a qualifier that didn't
346+ // resolve (stale alias, schema-qualified name we didn't
347+ // track, or a subplan reference).
348+ if aliasOrTable == "" {
349+ confidence = "exact"
350+ } else {
351+ confidence = "heuristic"
352+ }
230353 } else {
231354 confidence = "none"
232355 }
0 commit comments