001package gudusoft.gsqlparser.resolver2.binding;
002
003import gudusoft.gsqlparser.EExpressionType;
004import gudusoft.gsqlparser.EJoinType;
005import gudusoft.gsqlparser.ESqlClause;
006import gudusoft.gsqlparser.ETableSource;
007import gudusoft.gsqlparser.TCustomSqlStatement;
008import gudusoft.gsqlparser.TStatementList;
009import gudusoft.gsqlparser.nodes.TColumnDefinition;
010import gudusoft.gsqlparser.nodes.TColumnDefinitionList;
011import gudusoft.gsqlparser.nodes.TJoinExpr;
012import gudusoft.gsqlparser.nodes.TObjectName;
013import gudusoft.gsqlparser.nodes.TParseTreeNode;
014import gudusoft.gsqlparser.nodes.TParseTreeVisitor;
015import gudusoft.gsqlparser.nodes.TResultColumn;
016import gudusoft.gsqlparser.nodes.TResultColumnList;
017import gudusoft.gsqlparser.nodes.TTable;
018import gudusoft.gsqlparser.resolver2.ColumnLevel;
019import gudusoft.gsqlparser.resolver2.ScopeBuildResult;
020import gudusoft.gsqlparser.resolver2.TSQLResolver2;
021import gudusoft.gsqlparser.resolver2.TSQLResolverConfig;
022import gudusoft.gsqlparser.resolver2.model.AmbiguousColumnSource;
023import gudusoft.gsqlparser.resolver2.model.ColumnSource;
024import gudusoft.gsqlparser.resolver2.model.ResolutionContext;
025import gudusoft.gsqlparser.resolver2.model.ResolutionResult;
026import gudusoft.gsqlparser.resolver2.namespace.CTENamespace;
027import gudusoft.gsqlparser.resolver2.namespace.INamespace;
028import gudusoft.gsqlparser.resolver2.namespace.MetadataState;
029import gudusoft.gsqlparser.resolver2.namespace.SubqueryNamespace;
030import gudusoft.gsqlparser.resolver2.namespace.UnionNamespace;
031import gudusoft.gsqlparser.resolver2.scope.IScope;
032import gudusoft.gsqlparser.stmt.TCreateTableSqlStatement;
033import gudusoft.gsqlparser.stmt.TSelectSqlStatement;
034
035import java.util.ArrayDeque;
036import java.util.ArrayList;
037import java.util.Collections;
038import java.util.Deque;
039import java.util.HashSet;
040import java.util.IdentityHashMap;
041import java.util.List;
042import java.util.Locale;
043import java.util.Set;
044
045/**
046 * Slice S5: orchestrator that walks the recorded binding trace
047 * ({@link ResolutionContext#getColumnResolutionResult}) once after
048 * iterative resolution converges and converts {@link ResolutionResult}s
049 * into a public {@link BindingResult}.
050 *
051 * <p>The class is {@code public} because {@link TSQLResolver2} (in the
052 * parent {@code resolver2} package) instantiates it directly. The static
053 * invocation counter accessors remain package-private — they are
054 * test-only.</p>
055 *
056 * <p>Plan §5.6.1 critical clarification — the post-pass MUST NOT perform
057 * name binding. It only reads resolver2's final state and decides which
058 * stable, public {@link BindingDiagnostic} to surface. In particular it
059 * never re-invokes {@code NameResolver.resolve(...)} or mutates AST
060 * state.</p>
061 *
062 * <p>This slice ships the first three diagnostic codes ({@code
063 * UNKNOWN_COLUMN}, {@code AMBIGUOUS_COLUMN}, {@code
064 * UNBOUND_COLUMN_REFERENCE}). Strict-mode codes ({@code UNKNOWN_TABLE},
065 * {@code UNKNOWN_ALIAS}, {@code CATALOG_METADATA_UNAVAILABLE}) and clause
066 * attribution land in S6; deeper codes (CTE / subquery output, set-op
067 * arity, USING / NATURAL, DML) land in S8–S13.</p>
068 */
069public final class BindingDiagnosticPostPass {
070
071    /**
072     * Static invocation counter used by {@code BindingPostPassInvocationTest}
073     * to confirm the post-pass runs at most once per resolver call regardless
074     * of how many iterations the resolver loop executed (plan §13 risk row).
075     * Test-only — not part of the public surface.
076     */
077    private static volatile int invocationCount = 0;
078
079    /**
080     * How many times the binding diagnostic post-pass has run in this JVM.
081     *
082     * <p>PUBLIC ON PURPOSE, for the same reason as
083     * {@link #resetInvocationCounter()}: package-private members are renamed in
084     * every shipped (ProGuard-obfuscated) jar, so a test asserting on this
085     * counter cannot reach it when run against the real released bytes.</p>
086     *
087     * @return the invocation count since the last reset
088     * @since 4.2.6
089     */
090    public static int getInvocationCount() {
091        return invocationCount;
092    }
093
094    /**
095     * Resets the post-pass invocation counter. Test support only.
096     *
097     * <p>PUBLIC ON PURPOSE: every shipped jar is ProGuard-obfuscated under a
098     * compatibility-first policy that preserves public and protected members
099     * and renames everything else. A package-private method is therefore
100     * unreachable from the released jar, which is what the test suite runs
101     * against in the nightly obfuscated-jar gate. Keeping this public is what
102     * lets that gate execute the real shipped bytes.</p>
103     *
104     * @since 4.2.6
105     */
106    public static void resetInvocationCounter() {
107        invocationCount = 0;
108    }
109
110    private final TSQLResolver2 resolver;
111    private final TSQLResolverConfig config;
112    private BindingClauseMapper mapper = BindingClauseMapper.empty();
113
114    public BindingDiagnosticPostPass(TSQLResolver2 resolver, TSQLResolverConfig config) {
115        this.resolver = resolver;
116        this.config = config;
117    }
118
119    /**
120     * Run the post-pass exactly once and return the populated
121     * {@link BindingResult}.
122     *
123     * <p>Always non-null. Returns {@link BindingResult#empty()} when the
124     * inputs do not yet satisfy the minimum invariants (no scope build
125     * result, no resolution context, trace not enabled) — never throws.</p>
126     */
127    public BindingResult run() {
128        invocationCount++;
129
130        ScopeBuildResult sbr = resolver != null ? resolver.getScopeBuildResult() : null;
131        ResolutionContext ctx = resolver != null ? resolver.getContext() : null;
132        if (sbr == null || ctx == null || !ctx.isBindingTraceEnabled()) {
133            return BindingResult.empty();
134        }
135
136        mapper = BindingClauseMapper.of(resolver.getStatements());
137
138        boolean includeSuccessful = config != null
139            && config.isBindingIncludeSuccessfulReferences();
140
141        List<BindingDiagnostic> diagnostics = new ArrayList<BindingDiagnostic>();
142        List<BindingReference> references = new ArrayList<BindingReference>();
143
144        collectSetOperationDiagnostics(diagnostics);
145        collectJoinDiagnostics(sbr, diagnostics);
146
147        List<TObjectName> refs = sbr.getAllColumnReferences();
148        if (refs == null || refs.isEmpty()) {
149            return diagnostics.isEmpty() ? BindingResult.empty()
150                : BindingResult.of(diagnostics, references);
151        }
152
153        // Identity-keyed dedup so re-emitted diagnostics for the same
154        // TObjectName do not stack (e.g., two passes touching the same
155        // reference). The trace itself is identity-keyed for the same
156        // reason — see ResolutionContext#columnResolutionResults.
157        IdentityHashMap<TObjectName, BindingDiagnostic> seenDiagnostics =
158            new IdentityHashMap<TObjectName, BindingDiagnostic>();
159        IdentityHashMap<TObjectName, BindingReference> seenReferences =
160            new IdentityHashMap<TObjectName, BindingReference>();
161
162        for (TObjectName ref : refs) {
163            if (ref == null) continue;
164
165            BindingSkipReason skip = ctx.getColumnSkipReason(ref);
166            if (skip != null) {
167                continue;
168            }
169
170            // S13: DML target/definition columns are NOT user references — they
171            // declare WHERE the write goes, not what it reads. Skip silently and
172            // tag the trace so consumers reading ResolutionContext directly can
173            // see intent (plan §10.5 / §13 failure-handling row).
174            if (isDmlTargetColumn(ref)) {
175                ctx.recordColumnSkipReason(ref, BindingSkipReason.TARGET_COLUMN);
176                continue;
177            }
178
179            // CREATE TABLE column names remain in ScopeBuildResult for the
180            // long-standing get-table-column compatibility surface, but they
181            // are definitions rather than read-side references. Keep the
182            // compatibility data and suppress only binding diagnostics.
183            if (isCreateTableColumnDefinition(ref)) {
184                ctx.recordColumnSkipReason(ref,
185                        BindingSkipReason.TARGET_COLUMN);
186                continue;
187            }
188
189            ResolutionResult result = ctx.getColumnResolutionResult(ref);
190            if (result == null) {
191                // No recorded resolution. This is normal for synthetic clones
192                // (already filtered above via SYNTHETIC_STAR_CLONE) and for
193                // references the resolver intentionally skipped. The S3
194                // coverage gate ensures every other reference is recorded;
195                // we trust that gate here.
196                continue;
197            }
198
199            if (!seenDiagnostics.containsKey(ref)) {
200                BindingDiagnostic setOpOutputDiagnostic = buildSetOperationOutputDiagnostic(ref, sbr);
201                if (setOpOutputDiagnostic != null) {
202                    seenDiagnostics.put(ref, setOpOutputDiagnostic);
203                    diagnostics.add(setOpOutputDiagnostic);
204
205                    if (includeSuccessful && !seenReferences.containsKey(ref)) {
206                        BindingReference br = buildReference(ref, sbr, /* bound */ false);
207                        if (br != null) {
208                            seenReferences.put(ref, br);
209                            references.add(br);
210                        }
211                    }
212                    continue;
213                }
214                BindingDiagnostic cteOutputDiagnostic = buildCteOutputDiagnostic(ref, sbr);
215                if (cteOutputDiagnostic != null) {
216                    seenDiagnostics.put(ref, cteOutputDiagnostic);
217                    diagnostics.add(cteOutputDiagnostic);
218
219                    if (includeSuccessful && !seenReferences.containsKey(ref)) {
220                        BindingReference br = buildReference(ref, sbr, /* bound */ false);
221                        if (br != null) {
222                            seenReferences.put(ref, br);
223                            references.add(br);
224                        }
225                    }
226                    continue;
227                }
228                BindingDiagnostic subqueryOutputDiagnostic = buildSubqueryOutputDiagnostic(ref, sbr);
229                if (subqueryOutputDiagnostic != null) {
230                    seenDiagnostics.put(ref, subqueryOutputDiagnostic);
231                    diagnostics.add(subqueryOutputDiagnostic);
232
233                    if (includeSuccessful && !seenReferences.containsKey(ref)) {
234                        BindingReference br = buildReference(ref, sbr, /* bound */ false);
235                        if (br != null) {
236                            seenReferences.put(ref, br);
237                            references.add(br);
238                        }
239                    }
240                    continue;
241                }
242            }
243
244            if (result.isExactMatch()) {
245                // S16c (plan §5.6.2): EXACT_MATCH means resolver2 picked one
246                // SQL source for this reference, but it does NOT mean catalog
247                // metadata has confirmed the column actually lives on the
248                // selected source. Read the resolver2-selected namespace from
249                // the recorded ResolutionResult and ask the catalog only.
250                // We never call NameResolver.resolve(...) again, never search
251                // the SQL scope, and never mutate AST state.
252                if (!seenDiagnostics.containsKey(ref)) {
253                    BindingDiagnostic exactMatchDiag =
254                        verifyExactMatchAgainstCatalog(ref, result, sbr);
255                    if (exactMatchDiag != null) {
256                        seenDiagnostics.put(ref, exactMatchDiag);
257                        diagnostics.add(exactMatchDiag);
258                        if (includeSuccessful && !seenReferences.containsKey(ref)) {
259                            BindingReference br = buildReference(ref, sbr, /* bound */ false);
260                            if (br != null) {
261                                seenReferences.put(ref, br);
262                                references.add(br);
263                            }
264                        }
265                        continue;
266                    }
267                }
268                // In strict mode, even an exact-match resolution may be
269                // suspect when the backing namespace has unreliable metadata
270                // (e.g. resolver inferred a 0.80-confidence match for a table
271                // that is NOT_FOUND_IN_CATALOG or METADATA_UNAVAILABLE).
272                if (config != null && config.isBindingStrictCatalogValidation()
273                        && !seenDiagnostics.containsKey(ref)) {
274                    BindingDiagnostic strictDiag = buildStrictModeNamespaceDiagnostic(ref, sbr);
275                    if (strictDiag != null) {
276                        seenDiagnostics.put(ref, strictDiag);
277                        diagnostics.add(strictDiag);
278                        continue;
279                    }
280                }
281                if (includeSuccessful && !seenReferences.containsKey(ref)) {
282                    BindingReference br = buildReference(ref, sbr, /* bound */ true);
283                    if (br != null) {
284                        seenReferences.put(ref, br);
285                        references.add(br);
286                    }
287                }
288                continue;
289            }
290
291            if (seenDiagnostics.containsKey(ref)) {
292                continue;
293            }
294
295            BindingDiagnostic diagnostic = null;
296            if (result.isAmbiguous()) {
297                diagnostic = buildAmbiguousDiagnostic(ref, result, sbr);
298            } else if (result.isNotFound()) {
299                diagnostic = buildNotFoundDiagnostic(ref, ctx, sbr);
300            }
301
302            if (diagnostic != null) {
303                seenDiagnostics.put(ref, diagnostic);
304                diagnostics.add(diagnostic);
305
306                if (includeSuccessful && !seenReferences.containsKey(ref)) {
307                    BindingReference br = buildReference(ref, sbr, /* bound */ false);
308                    if (br != null) {
309                        seenReferences.put(ref, br);
310                        references.add(br);
311                    }
312                }
313            }
314        }
315
316        if (diagnostics.isEmpty() && references.isEmpty()) {
317            return BindingResult.empty();
318        }
319        return BindingResult.of(diagnostics, references);
320    }
321
322    // ===== AMBIGUOUS =====
323
324    private BindingDiagnostic buildAmbiguousDiagnostic(TObjectName ref,
325                                                       ResolutionResult result,
326                                                       ScopeBuildResult sbr) {
327        AmbiguousColumnSource ambiguous = result.getAmbiguousSource();
328        List<String> candidates = candidatesOf(ambiguous);
329        String text = ref.toString();
330
331        BindingDiagnosticSeverity severity = severityFor(BindingDiagnosticCode.AMBIGUOUS_COLUMN);
332        BindingDiagnostic.Builder b = BindingDiagnosticBuilder
333            .forCode(BindingDiagnosticCode.AMBIGUOUS_COLUMN)
334            .severity(severity)
335            .message("Column " + text + " is ambiguous between "
336                + (candidates.isEmpty() ? "multiple sources" : candidates) + ".")
337            .objectNameText(text)
338            .candidates(candidates)
339            .site(siteFor(ref))
340            .statement(statementFor(ref, sbr));
341        return b.build();
342    }
343
344    private static List<String> candidatesOf(AmbiguousColumnSource ambiguous) {
345        if (ambiguous == null) {
346            return Collections.emptyList();
347        }
348        List<String> out = new ArrayList<String>();
349        for (ColumnSource cs : ambiguous.getCandidates()) {
350            if (cs == null) continue;
351            String label = describeCandidate(cs, ambiguous.getColumnName());
352            if (label != null) {
353                out.add(label);
354            }
355        }
356        return out;
357    }
358
359    private static String describeCandidate(ColumnSource cs, String columnName) {
360        if (cs == null) return null;
361        String tableLabel = null;
362        if (cs.getFinalTable() != null && cs.getFinalTable().getName() != null) {
363            tableLabel = cs.getFinalTable().getName().toString();
364        } else if (cs.getSourceNamespace() != null) {
365            tableLabel = cs.getSourceNamespace().getDisplayName();
366        }
367        String exposed = cs.getExposedName();
368        String name = (exposed != null && !exposed.isEmpty()) ? exposed : columnName;
369        if (tableLabel == null || tableLabel.isEmpty()) {
370            return name;
371        }
372        return tableLabel + "." + name;
373    }
374
375    // ===== SET operations =====
376
377    private void collectSetOperationDiagnostics(final List<BindingDiagnostic> diagnostics) {
378        if (diagnostics == null) return;
379        TStatementList statements = resolver != null ? resolver.getStatements() : null;
380        if (statements == null) return;
381
382        final Set<TSelectSqlStatement> covered =
383            Collections.newSetFromMap(new IdentityHashMap<TSelectSqlStatement, Boolean>());
384        for (int i = 0; i < statements.size(); i++) {
385            TCustomSqlStatement stmt = statements.get(i);
386            if (stmt == null) continue;
387            try {
388                stmt.acceptChildren(new TParseTreeVisitor() {
389                    @Override
390                    public void preVisit(TSelectSqlStatement node) {
391                        collectSetOperationDiagnostic(node, diagnostics, covered);
392                    }
393                });
394            } catch (Throwable ignore) {
395                // Defensive: set-operation diagnostics must never fail the post-pass.
396            }
397        }
398    }
399
400    private void collectSetOperationDiagnostic(TSelectSqlStatement stmt,
401                                               List<BindingDiagnostic> diagnostics,
402                                               Set<TSelectSqlStatement> covered) {
403        if (stmt == null || !stmt.isCombinedQuery() || covered.contains(stmt)) {
404            return;
405        }
406
407        SetOperationShape shape = analyzeSetOperationShape(stmt);
408        if (!shape.authoritative || !shape.hasMismatch()) {
409            return;
410        }
411
412        diagnostics.add(buildSetOperationArityDiagnostic(stmt, shape));
413        covered.addAll(collectCombinedSetOperationNodes(stmt));
414    }
415
416    private SetOperationShape analyzeSetOperationShape(TSelectSqlStatement stmt) {
417        List<TSelectSqlStatement> branches = flattenSetOperationBranches(stmt);
418        List<Integer> counts = new ArrayList<Integer>();
419        if (branches.size() < 2) {
420            return SetOperationShape.nonAuthoritative(counts);
421        }
422
423        for (TSelectSqlStatement branch : branches) {
424            if (branch == null || branch.isCombinedQuery()) {
425                return SetOperationShape.nonAuthoritative(counts);
426            }
427            TResultColumnList selectList = branch.getResultColumnList();
428            if (selectList == null || containsStarResultColumn(selectList)) {
429                return SetOperationShape.nonAuthoritative(counts);
430            }
431            counts.add(Integer.valueOf(selectList.size()));
432        }
433
434        return SetOperationShape.authoritative(counts);
435    }
436
437    private static List<TSelectSqlStatement> flattenSetOperationBranches(TSelectSqlStatement stmt) {
438        List<TSelectSqlStatement> branches = new ArrayList<TSelectSqlStatement>();
439        if (stmt == null) {
440            return branches;
441        }
442
443        Deque<TSelectSqlStatement> stack = new ArrayDeque<TSelectSqlStatement>();
444        stack.push(stmt);
445        while (!stack.isEmpty()) {
446            TSelectSqlStatement current = stack.pop();
447            if (current == null) continue;
448            if (current.isCombinedQuery()) {
449                if (current.getRightStmt() != null) {
450                    stack.push(current.getRightStmt());
451                }
452                if (current.getLeftStmt() != null) {
453                    stack.push(current.getLeftStmt());
454                }
455            } else {
456                branches.add(current);
457            }
458        }
459        return branches;
460    }
461
462    private static Set<TSelectSqlStatement> collectCombinedSetOperationNodes(TSelectSqlStatement stmt) {
463        Set<TSelectSqlStatement> nodes =
464            Collections.newSetFromMap(new IdentityHashMap<TSelectSqlStatement, Boolean>());
465        if (stmt == null) {
466            return nodes;
467        }
468
469        Deque<TSelectSqlStatement> stack = new ArrayDeque<TSelectSqlStatement>();
470        stack.push(stmt);
471        while (!stack.isEmpty()) {
472            TSelectSqlStatement current = stack.pop();
473            if (current == null || !current.isCombinedQuery() || !nodes.add(current)) {
474                continue;
475            }
476            if (current.getRightStmt() != null) {
477                stack.push(current.getRightStmt());
478            }
479            if (current.getLeftStmt() != null) {
480                stack.push(current.getLeftStmt());
481            }
482        }
483        return nodes;
484    }
485
486    private static boolean containsStarResultColumn(TResultColumnList selectList) {
487        if (selectList == null) {
488            return true;
489        }
490        for (int i = 0; i < selectList.size(); i++) {
491            TResultColumn resultColumn = selectList.getResultColumn(i);
492            if (isStarResultColumn(resultColumn)) {
493                return true;
494            }
495        }
496        return false;
497    }
498
499    private static boolean isStarResultColumn(TResultColumn resultColumn) {
500        if (resultColumn == null) {
501            return false;
502        }
503
504        // Star modifiers such as BigQuery SELECT * EXCEPT/REPLACE still have
505        // non-authoritative expanded output for S11 arity and missing-output
506        // diagnostics. Treat those as star-derived even when the full result
507        // column text no longer equals "*" or "t.*".
508        if (resultColumn.getExceptColumnList() != null
509                || (resultColumn.getReplaceExprAsIdentifiers() != null
510                    && !resultColumn.getReplaceExprAsIdentifiers().isEmpty())
511                || (resultColumn.getExprAsIdentifiers() != null
512                    && !resultColumn.getExprAsIdentifiers().isEmpty())) {
513            return true;
514        }
515
516        if (resultColumn.getExpr() != null
517                && resultColumn.getExpr().getExpressionType() == EExpressionType.simple_object_name_t
518                && resultColumn.getExpr().getObjectOperand() != null) {
519            String starText = resultColumn.getExpr().getObjectOperand().toString();
520            if (starText != null) {
521                starText = starText.trim();
522                if ("*".equals(starText) || starText.endsWith(".*")) {
523                    return true;
524                }
525            }
526        }
527
528        String text = resultColumn.toString();
529        if (text == null) {
530            return false;
531        }
532        text = text.trim();
533        return "*".equals(text) || text.endsWith(".*");
534    }
535
536    private BindingDiagnostic buildSetOperationArityDiagnostic(TSelectSqlStatement stmt,
537                                                               SetOperationShape shape) {
538        String label = setOperationLabel(stmt);
539        int expected = shape.counts.get(0).intValue();
540        int mismatchIndex = shape.firstMismatchIndex();
541        int actual = shape.counts.get(mismatchIndex).intValue();
542
543        return BindingDiagnosticBuilder
544            .forCode(BindingDiagnosticCode.SET_OPERATION_ARITY_MISMATCH)
545            .severity(severityFor(BindingDiagnosticCode.SET_OPERATION_ARITY_MISMATCH))
546            .message("Set operation " + label
547                + " has branch column count mismatch: branch 1 has "
548                + expected + " column(s), branch " + (mismatchIndex + 1)
549                + " has " + actual + " column(s).")
550            .objectNameText(label)
551            .candidates(branchCountCandidates(shape.counts))
552            .site(setOperationSite(label))
553            .statement(stmt)
554            .build();
555    }
556
557    private static List<String> branchCountCandidates(List<Integer> counts) {
558        if (counts == null || counts.isEmpty()) {
559            return Collections.emptyList();
560        }
561        List<String> out = new ArrayList<String>();
562        for (int i = 0; i < counts.size(); i++) {
563            out.add("branch " + (i + 1) + ": " + counts.get(i));
564        }
565        return out;
566    }
567
568    private static BindingReferenceSite setOperationSite(String label) {
569        return BindingReferenceSite.builder()
570            .clause(BindingClause.OTHER)
571            .referenceText(label == null || label.isEmpty() ? "SET OPERATION" : label)
572            .build();
573    }
574
575    private static String setOperationLabel(TSelectSqlStatement stmt) {
576        if (stmt == null || stmt.getSetOperatorType() == null) {
577            return "SET OPERATION";
578        }
579        String type = stmt.getSetOperatorType().name();
580        if (type == null || "none".equals(type)) {
581            return "SET OPERATION";
582        }
583        String label = type.toUpperCase(Locale.ROOT);
584        if (stmt.isAll()) {
585            label += " ALL";
586        } else if (stmt.isSetOpDistinct()) {
587            label += " DISTINCT";
588        }
589        return label;
590    }
591
592    private static final class SetOperationShape {
593        private final boolean authoritative;
594        private final List<Integer> counts;
595
596        private SetOperationShape(boolean authoritative, List<Integer> counts) {
597            this.authoritative = authoritative;
598            this.counts = counts != null
599                ? Collections.unmodifiableList(new ArrayList<Integer>(counts))
600                : Collections.<Integer>emptyList();
601        }
602
603        static SetOperationShape authoritative(List<Integer> counts) {
604            return new SetOperationShape(true, counts);
605        }
606
607        static SetOperationShape nonAuthoritative(List<Integer> counts) {
608            return new SetOperationShape(false, counts);
609        }
610
611        boolean hasMismatch() {
612            return firstMismatchIndex() >= 0;
613        }
614
615        int firstMismatchIndex() {
616            if (!authoritative || counts.size() < 2) {
617                return -1;
618            }
619            int expected = counts.get(0).intValue();
620            for (int i = 1; i < counts.size(); i++) {
621                if (counts.get(i).intValue() != expected) {
622                    return i;
623                }
624            }
625            return -1;
626        }
627    }
628
629    // ===== JOIN diagnostics =====
630
631    private void collectJoinDiagnostics(ScopeBuildResult sbr,
632                                        List<BindingDiagnostic> diagnostics) {
633        if (sbr == null || diagnostics == null) return;
634        collectUsingColumnDiagnostics(sbr, diagnostics);
635        collectNaturalJoinDiagnostics(diagnostics);
636    }
637
638    private void collectUsingColumnDiagnostics(final ScopeBuildResult sbr,
639                                               final List<BindingDiagnostic> diagnostics) {
640        TStatementList statements = resolver != null ? resolver.getStatements() : null;
641        if (statements == null) return;
642        final Set<TJoinExpr> seen = Collections.newSetFromMap(new IdentityHashMap<TJoinExpr, Boolean>());
643        for (int i = 0; i < statements.size(); i++) {
644            TCustomSqlStatement stmt = statements.get(i);
645            if (stmt == null) continue;
646            try {
647                stmt.acceptChildren(new TParseTreeVisitor() {
648                    @Override
649                    public void preVisit(TJoinExpr node) {
650                        if (node == null || !seen.add(node)
651                                || node.getUsingColumns() == null
652                                || node.getUsingColumns().size() == 0) {
653                            return;
654                        }
655                        Set<TTable> leftTables = collectInputTables(node.getLeftTable());
656                        Set<TTable> rightTables = collectInputTables(node.getRightTable());
657                        if (leftTables.isEmpty() || rightTables.isEmpty()) return;
658                        for (int c = 0; c < node.getUsingColumns().size(); c++) {
659                            TObjectName col = node.getUsingColumns().getObjectName(c);
660                            String name = usingColumnName(col);
661                            if (name == null) continue;
662                            Boolean leftPresent = inputExposesColumn(sbr, leftTables, name);
663                            Boolean rightPresent = inputExposesColumn(sbr, rightTables, name);
664                            // Catalog-honesty rule: emit only when both JOIN
665                            // inputs have authoritative metadata. For a
666                            // composite left input, one table exposing the
667                            // column is sufficient: SQL visibility is over the
668                            // join input, not every physical table beneath it.
669                            if (leftPresent == null || rightPresent == null) continue;
670                            if (leftPresent.booleanValue() && rightPresent.booleanValue()) continue;
671                            diagnostics.add(BindingDiagnosticBuilder
672                                .forCode(BindingDiagnosticCode.USING_COLUMN_NOT_COMMON)
673                                .severity(severityFor(BindingDiagnosticCode.USING_COLUMN_NOT_COMMON))
674                                .message("USING column " + name
675                                    + " is not common to both JOIN inputs.")
676                                .objectNameText(col != null ? col.toString() : name)
677                                .site(joinSite(col != null ? col.toString() : name))
678                                .statement(null)
679                                .build());
680                        }
681                    }
682                });
683            } catch (Throwable ignore) {
684                // Defensive: join diagnostics must never fail the post-pass.
685            }
686        }
687    }
688
689    /**
690     * @return TRUE when at least one table in the input authoritatively exposes
691     * the column, FALSE when all authoritative tables lack it, null when the
692     * input has only unavailable/ambiguous metadata or mixed unavailable misses.
693     */
694    private static Boolean inputExposesColumn(ScopeBuildResult sbr,
695                                             Set<TTable> tables,
696                                             String columnName) {
697        boolean sawAuthoritativeAbsent = false;
698        boolean sawUnavailable = false;
699        for (TTable table : tables) {
700            if (table == null) {
701                sawUnavailable = true;
702                continue;
703            }
704            INamespace ns = sbr.getNamespaceForTable(table);
705            ColumnAuthority authority = BindingMetadataAuthority.lookup(ns, columnName);
706            if (authority == ColumnAuthority.AUTHORITATIVE_PRESENT) return Boolean.TRUE;
707            if (authority == ColumnAuthority.AUTHORITATIVE_ABSENT) {
708                sawAuthoritativeAbsent = true;
709            } else {
710                sawUnavailable = true;
711            }
712        }
713        if (sawUnavailable) return null;
714        return sawAuthoritativeAbsent ? Boolean.FALSE : null;
715    }
716
717    private static Set<TTable> collectInputTables(TTable table) {
718        Set<TTable> out = new HashSet<TTable>();
719        collectInputTables(table, out);
720        return out;
721    }
722
723    private static void collectInputTables(TTable table, Set<TTable> out) {
724        if (table == null || out == null) return;
725        if (table.getTableType() == ETableSource.join && table.getJoinExpr() != null) {
726            TJoinExpr join = table.getJoinExpr();
727            collectInputTables(join.getLeftTable(), out);
728            collectInputTables(join.getRightTable(), out);
729            return;
730        }
731        out.add(table);
732    }
733
734    private void collectNaturalJoinDiagnostics(final List<BindingDiagnostic> diagnostics) {
735        if (config == null || !config.isBindingStrictCatalogValidation()) return;
736        TStatementList statements = resolver != null ? resolver.getStatements() : null;
737        if (statements == null) return;
738        final Set<TJoinExpr> seen = Collections.newSetFromMap(new IdentityHashMap<TJoinExpr, Boolean>());
739        for (int i = 0; i < statements.size(); i++) {
740            TCustomSqlStatement stmt = statements.get(i);
741            if (stmt == null) continue;
742            try {
743                stmt.acceptChildren(new TParseTreeVisitor() {
744                    @Override
745                    public void preVisit(TJoinExpr node) {
746                        if (node == null || !seen.add(node) || !isNaturalJoin(node.getJointype())) return;
747                        String text = node.toString();
748                        diagnostics.add(BindingDiagnosticBuilder
749                            .forCode(BindingDiagnosticCode.UNSUPPORTED_BINDING_SCOPE)
750                            .severity(severityFor(BindingDiagnosticCode.UNSUPPORTED_BINDING_SCOPE))
751                            .message("NATURAL JOIN binding semantics are not yet modeled.")
752                            .objectNameText(text != null && !text.isEmpty() ? text : "NATURAL JOIN")
753                            .site(joinSite("NATURAL JOIN"))
754                            .statement(null)
755                            .build());
756                    }
757                });
758            } catch (Throwable ignore) {
759                // Defensive: join diagnostics must never fail the post-pass.
760            }
761        }
762    }
763
764    private static boolean isNaturalJoin(EJoinType type) {
765        return type == EJoinType.natural
766            || type == EJoinType.natural_inner
767            || type == EJoinType.natural_left
768            || type == EJoinType.natural_right
769            || type == EJoinType.natural_full
770            || type == EJoinType.natural_leftouter
771            || type == EJoinType.natural_rightouter
772            || type == EJoinType.natural_fullouter;
773    }
774
775    private static String usingColumnName(TObjectName col) {
776        if (col == null) return null;
777        String name = col.getColumnNameOnly();
778        if (name == null || name.isEmpty()) return null;
779        // S14: keep the column name in its original form (with quotes if any).
780        // {@link BindingMetadataAuthority#lookup} passes the name to
781        // {@code namespace.hasColumn(...)} which routes through {@code
782        // INameMatcher} — VendorNameMatcher honors per-dialect quoted vs
783        // unquoted compare rules. Stripping quotes here would lose that
784        // distinction (e.g. Oracle USING("Id") would have falsely matched a
785        // catalog column folded to ID).
786        return name;
787    }
788
789    private static BindingReferenceSite joinSite(String text) {
790        return BindingReferenceSite.builder()
791            .clause(BindingClause.OTHER)
792            .referenceText(text == null || text.isEmpty() ? "JOIN" : text)
793            .build();
794    }
795
796    // ===== NOT_FOUND =====
797
798    private BindingDiagnostic buildNotFoundDiagnostic(TObjectName ref,
799                                                     ResolutionContext ctx,
800                                                     ScopeBuildResult sbr) {
801        String columnName = ref.getColumnNameOnly();
802        if (columnName == null || columnName.isEmpty()) {
803            return null;
804        }
805
806        String qualifier = qualifierOf(ref);
807        IScope scope = sbr.getScopeForColumn(ref);
808
809        // Star reference (`t.*` or bare `*`): wildcard expansion is not a column
810        // lookup. We MUST NOT emit UNKNOWN_COLUMN regardless of namespace state.
811        // Plan §7.3 S7: user-authored `unknown_alias.*` emits
812        // INVALID_STAR_QUALIFIER; everything else is silent.
813        if (isStarReference(ref, columnName)) {
814            if (qualifier == null || qualifier.isEmpty()) {
815                return null;
816            }
817            INamespace starNs = (scope != null) ? scope.resolveTable(qualifier) : null;
818            if (starNs == null) {
819                return BindingDiagnosticBuilder
820                    .forCode(BindingDiagnosticCode.INVALID_STAR_QUALIFIER)
821                    .severity(severityFor(BindingDiagnosticCode.INVALID_STAR_QUALIFIER))
822                    .message("Star qualifier " + qualifier
823                        + " does not match any table or alias in scope.")
824                    .objectNameText(ref.toString())
825                    .site(siteFor(ref))
826                    .statement(statementFor(ref, sbr))
827                    .build();
828            }
829            return null;
830        }
831
832        if (qualifier != null && !qualifier.isEmpty()) {
833            INamespace ns = (scope != null) ? scope.resolveTable(qualifier) : null;
834            if (ns == null) {
835                if (config != null && config.isBindingStrictCatalogValidation()) {
836                    return BindingDiagnosticBuilder
837                        .forCode(BindingDiagnosticCode.UNKNOWN_ALIAS)
838                        .severity(severityFor(BindingDiagnosticCode.UNKNOWN_ALIAS))
839                        .message("Qualifier " + qualifier
840                            + " does not match any table or alias in scope.")
841                        .objectNameText(ref.toString())
842                        .site(siteFor(ref))
843                        .statement(statementFor(ref, sbr))
844                        .build();
845                }
846                return null;
847            }
848            MetadataState state = ns.getMetadataState();
849            if (state == MetadataState.NOT_FOUND_IN_CATALOG) {
850                if (config != null && config.isBindingStrictCatalogValidation()) {
851                    return BindingDiagnosticBuilder
852                        .forCode(BindingDiagnosticCode.UNKNOWN_TABLE)
853                        .severity(severityFor(BindingDiagnosticCode.UNKNOWN_TABLE))
854                        .message("Table " + qualifier + " was not found in the catalog.")
855                        .objectNameText(ref.toString())
856                        .site(siteFor(ref))
857                        .statement(statementFor(ref, sbr))
858                        .build();
859                }
860                return null;
861            }
862            if (state == MetadataState.METADATA_UNAVAILABLE) {
863                if (config != null && config.isBindingStrictCatalogValidation()) {
864                    return BindingDiagnosticBuilder
865                        .forCode(BindingDiagnosticCode.CATALOG_METADATA_UNAVAILABLE)
866                        .severity(severityFor(BindingDiagnosticCode.CATALOG_METADATA_UNAVAILABLE))
867                        .message("Catalog metadata is unavailable for table " + qualifier + ".")
868                        .objectNameText(ref.toString())
869                        .site(siteFor(ref))
870                        .statement(statementFor(ref, sbr))
871                        .build();
872                }
873                return null;
874            }
875            ColumnAuthority authority = BindingMetadataAuthority.lookup(ns, columnName);
876            if (authority != ColumnAuthority.AUTHORITATIVE_ABSENT) {
877                return null;
878            }
879
880            String tableLabel = describeTable(ns);
881            return BindingDiagnosticBuilder
882                .forCode(BindingDiagnosticCode.UNKNOWN_COLUMN)
883                .severity(severityFor(BindingDiagnosticCode.UNKNOWN_COLUMN))
884                .message("Column " + ref.toString()
885                    + " was not found in catalog table " + tableLabel + ".")
886                .objectNameText(ref.toString())
887                .site(siteFor(ref))
888                .statement(statementFor(ref, sbr))
889                .build();
890        }
891
892        // Unqualified miss. Plan §7.3 S5: UNBOUND_COLUMN_REFERENCE fires when
893        // status NOT_FOUND AND no qualifier AND no in-scope table. Otherwise
894        // wait for S6+ to add ambiguous/contextual handling.
895        if (!hasInScopeTable(scope)) {
896            return BindingDiagnosticBuilder
897                .forCode(BindingDiagnosticCode.UNBOUND_COLUMN_REFERENCE)
898                .severity(severityFor(BindingDiagnosticCode.UNBOUND_COLUMN_REFERENCE))
899                .message("Column " + columnName
900                    + " could not be bound: no tables are in scope.")
901                .objectNameText(ref.toString())
902                .site(siteFor(ref))
903                .statement(statementFor(ref, sbr))
904                .build();
905        }
906        return null;
907    }
908
909    private BindingDiagnostic buildSetOperationOutputDiagnostic(TObjectName ref,
910                                                                ScopeBuildResult sbr) {
911        if (ref == null || sbr == null) return null;
912
913        String columnName = ref.getColumnNameOnly();
914        if (columnName == null || columnName.isEmpty()
915                || isStarReference(ref, columnName)) {
916            return null;
917        }
918
919        String qualifier = qualifierOf(ref);
920        if (qualifier == null || qualifier.isEmpty()) return null;
921
922        IScope scope = sbr.getScopeForColumn(ref);
923        INamespace ns = (scope != null) ? scope.resolveTable(qualifier) : null;
924        if (!(ns instanceof UnionNamespace)) return null;
925
926        UnionNamespace union = (UnionNamespace) ns;
927        ColumnLevel level = union.hasAuthoritativeOutputColumn(columnName);
928        if (level != ColumnLevel.NOT_EXISTS) return null;
929
930        String displayName = union.getDisplayName();
931        if (displayName == null || displayName.isEmpty()) {
932            displayName = qualifier;
933        }
934        return BindingDiagnosticBuilder
935            .forCode(BindingDiagnosticCode.SUBQUERY_OUTPUT_COLUMN_MISSING)
936            .severity(severityFor(BindingDiagnosticCode.SUBQUERY_OUTPUT_COLUMN_MISSING))
937            .message("Column " + ref.toString()
938                + " is not exposed by set-operation derived table "
939                + displayName + ".")
940            .objectNameText(ref.toString())
941            .site(siteFor(ref))
942            .statement(statementFor(ref, sbr))
943            .build();
944    }
945
946    private BindingDiagnostic buildCteOutputDiagnostic(TObjectName ref,
947                                                       ScopeBuildResult sbr) {
948        if (ref == null || sbr == null) return null;
949
950        String columnName = ref.getColumnNameOnly();
951        if (columnName == null || columnName.isEmpty()
952                || isStarReference(ref, columnName)) {
953            return null;
954        }
955
956        String qualifier = qualifierOf(ref);
957        if (qualifier == null || qualifier.isEmpty()) return null;
958
959        IScope scope = sbr.getScopeForColumn(ref);
960        INamespace ns = (scope != null) ? scope.resolveTable(qualifier) : null;
961        if (!(ns instanceof CTENamespace)) return null;
962
963        CTENamespace cte = (CTENamespace) ns;
964        ColumnLevel level = cte.hasAuthoritativeOutputColumn(columnName);
965        if (level != ColumnLevel.NOT_EXISTS) return null;
966
967        String cteName = cte.getDisplayName();
968        if (cteName == null || cteName.isEmpty()) {
969            cteName = qualifier;
970        }
971        return BindingDiagnosticBuilder
972            .forCode(BindingDiagnosticCode.CTE_OUTPUT_COLUMN_MISSING)
973            .severity(severityFor(BindingDiagnosticCode.CTE_OUTPUT_COLUMN_MISSING))
974            .message("Column " + ref.toString()
975                + " is not exposed by CTE " + cteName + ".")
976            .objectNameText(ref.toString())
977            .site(siteFor(ref))
978            .statement(statementFor(ref, sbr))
979            .build();
980    }
981
982    private BindingDiagnostic buildSubqueryOutputDiagnostic(TObjectName ref,
983                                                            ScopeBuildResult sbr) {
984        if (ref == null || sbr == null) return null;
985
986        String columnName = ref.getColumnNameOnly();
987        if (columnName == null || columnName.isEmpty()
988                || isStarReference(ref, columnName)) {
989            return null;
990        }
991
992        String qualifier = qualifierOf(ref);
993        if (qualifier == null || qualifier.isEmpty()) return null;
994
995        IScope scope = sbr.getScopeForColumn(ref);
996        INamespace ns = (scope != null) ? scope.resolveTable(qualifier) : null;
997        if (!(ns instanceof SubqueryNamespace)) return null;
998
999        SubqueryNamespace subquery = (SubqueryNamespace) ns;
1000        ColumnLevel level = subquery.hasAuthoritativeOutputColumn(columnName);
1001        if (level != ColumnLevel.NOT_EXISTS) return null;
1002
1003        String displayName = subquery.getDisplayName();
1004        if (displayName == null || displayName.isEmpty()) {
1005            displayName = qualifier;
1006        }
1007        return BindingDiagnosticBuilder
1008            .forCode(BindingDiagnosticCode.SUBQUERY_OUTPUT_COLUMN_MISSING)
1009            .severity(severityFor(BindingDiagnosticCode.SUBQUERY_OUTPUT_COLUMN_MISSING))
1010            .message("Column " + ref.toString()
1011                + " is not exposed by derived table " + displayName + ".")
1012            .objectNameText(ref.toString())
1013            .site(siteFor(ref))
1014            .statement(statementFor(ref, sbr))
1015            .build();
1016    }
1017
1018    /**
1019     * For strict mode: checks a qualified reference's namespace state even when
1020     * the resolver produced an exact-match result (inferred at low confidence
1021     * against a NOT_FOUND_IN_CATALOG or METADATA_UNAVAILABLE table).
1022     * Returns a diagnostic when the namespace warrants one, null otherwise.
1023     */
1024    private BindingDiagnostic buildStrictModeNamespaceDiagnostic(TObjectName ref,
1025                                                                  ScopeBuildResult sbr) {
1026        String qualifier = qualifierOf(ref);
1027        if (qualifier == null || qualifier.isEmpty()) return null;
1028        IScope scope = sbr.getScopeForColumn(ref);
1029        INamespace ns = (scope != null) ? scope.resolveTable(qualifier) : null;
1030        if (ns == null) return null;
1031        MetadataState state = ns.getMetadataState();
1032        if (state == MetadataState.NOT_FOUND_IN_CATALOG) {
1033            return BindingDiagnosticBuilder
1034                .forCode(BindingDiagnosticCode.UNKNOWN_TABLE)
1035                .severity(severityFor(BindingDiagnosticCode.UNKNOWN_TABLE))
1036                .message("Table " + qualifier + " was not found in the catalog.")
1037                .objectNameText(ref.toString())
1038                .site(siteFor(ref))
1039                .statement(statementFor(ref, sbr))
1040                .build();
1041        }
1042        if (state == MetadataState.METADATA_UNAVAILABLE) {
1043            return BindingDiagnosticBuilder
1044                .forCode(BindingDiagnosticCode.CATALOG_METADATA_UNAVAILABLE)
1045                .severity(severityFor(BindingDiagnosticCode.CATALOG_METADATA_UNAVAILABLE))
1046                .message("Catalog metadata is unavailable for table " + qualifier + ".")
1047                .objectNameText(ref.toString())
1048                .site(siteFor(ref))
1049                .statement(statementFor(ref, sbr))
1050                .build();
1051        }
1052        return null;
1053    }
1054
1055    /**
1056     * Slice S16c (plan §5.6.2): verify a resolver2 {@link
1057     * gudusoft.gsqlparser.resolver2.ResolutionStatus#EXACT_MATCH} against
1058     * authoritative catalog metadata.
1059     *
1060     * <p>Resolver2's exact match proves only that SQL scope binding selected
1061     * one source; the column may still be authoritatively absent from that
1062     * source's catalog metadata. The post-pass therefore reads the resolver2-
1063     * selected {@link INamespace} out of the recorded {@link ResolutionResult}
1064     * and asks {@link BindingMetadataAuthority#lookup(INamespace, String)}
1065     * only — it never re-binds names, searches SQL scope, or mutates AST
1066     * state.</p>
1067     *
1068     * <ul>
1069     *   <li>{@link ColumnAuthority#AUTHORITATIVE_PRESENT} — keep the resolver2
1070     *       binding; emit no diagnostic. Successful references picked up
1071     *       upstream still flow.</li>
1072     *   <li>{@link ColumnAuthority#AUTHORITATIVE_ABSENT} — emit
1073     *       {@link BindingDiagnosticCode#UNKNOWN_COLUMN} against the
1074     *       resolver2-selected source. Resolver2's binding state is NOT
1075     *       mutated.</li>
1076     *   <li>{@link ColumnAuthority#METADATA_UNAVAILABLE} — return null. Strict
1077     *       mode's {@link #buildStrictModeNamespaceDiagnostic} may then emit
1078     *       {@link BindingDiagnosticCode#CATALOG_METADATA_UNAVAILABLE} based on
1079     *       namespace state; non-strict mode stays silent.</li>
1080     * </ul>
1081     *
1082     * <p>Star references (bare {@code *} or {@code t.*}) are wildcard
1083     * expansion sites, not column lookups, and are skipped here even if the
1084     * resolver tagged them as {@code EXACT_MATCH}.</p>
1085     *
1086     * <p>The column-name compare flows through
1087     * {@link BindingMetadataAuthority#lookup} → {@link INamespace#hasColumn}
1088     * → vendor {@link gudusoft.gsqlparser.resolver2.matcher.INameMatcher}
1089     * (typically {@code VendorNameMatcher} when the parser auto-wires the
1090     * resolver), so per-dialect quoted-vs-unquoted rules are honored without
1091     * any bespoke string compare here. Quotes on the column token are
1092     * preserved (we never call {@code stripQuotes}).</p>
1093     */
1094    private BindingDiagnostic verifyExactMatchAgainstCatalog(TObjectName ref,
1095                                                             ResolutionResult result,
1096                                                             ScopeBuildResult sbr) {
1097        if (ref == null || result == null) return null;
1098
1099        String columnName = ref.getColumnNameOnly();
1100        if (columnName == null || columnName.isEmpty()) return null;
1101
1102        // Star references are not column lookups — skip catalog verification.
1103        if (isStarReference(ref, columnName)) return null;
1104
1105        ColumnSource cs = result.getColumnSource();
1106        if (cs == null) return null;
1107        INamespace ns = cs.getSourceNamespace();
1108        if (ns == null) return null;
1109
1110        ColumnAuthority verdict = BindingMetadataAuthority.lookup(ns, columnName);
1111        if (verdict != ColumnAuthority.AUTHORITATIVE_ABSENT) {
1112            // PRESENT → resolver2 binding catalog-verified; no diagnostic.
1113            // METADATA_UNAVAILABLE → defer to strict-mode namespace handler.
1114            return null;
1115        }
1116
1117        String tableLabel = describeTable(ns);
1118        return BindingDiagnosticBuilder
1119            .forCode(BindingDiagnosticCode.UNKNOWN_COLUMN)
1120            .severity(severityFor(BindingDiagnosticCode.UNKNOWN_COLUMN))
1121            .message("Column " + ref.toString()
1122                + " was not found in catalog table " + tableLabel + ".")
1123            .objectNameText(ref.toString())
1124            .site(siteFor(ref))
1125            .statement(statementFor(ref, sbr))
1126            .build();
1127    }
1128
1129    /**
1130     * S13 — DML target column entries are column DECLARATIONS, not column
1131     * references. They must never trigger {@code UNKNOWN_COLUMN},
1132     * {@code UNBOUND_COLUMN_REFERENCE}, or any other binding diagnostic
1133     * regardless of whether the column exists in the catalog (plan §7.3 S13:
1134     * "target columns produce zero diagnostics").
1135     *
1136     * <p>Detection is via {@link ESqlClause} location stamped by the
1137     * statement's {@code doParseStatement} when populating the target column
1138     * list:</p>
1139     *
1140     * <ul>
1141     *   <li>{@link ESqlClause#insertColumn} — {@code INSERT INTO t (a,b,c)} target
1142     *       columns.</li>
1143     *   <li>{@link ESqlClause#mergeInsert} — {@code MERGE … WHEN NOT MATCHED THEN
1144     *       INSERT (a,b,c)} target columns.</li>
1145     *   <li>{@link ESqlClause#set} — {@code UPDATE … SET col = …} LHS targets
1146     *       (and the equivalent {@code MERGE … WHEN MATCHED THEN UPDATE SET}
1147     *       LHS in dialects where it inherits the {@code set} location).
1148     *       The legacy linker pre-resolves these against the target table so
1149     *       they are usually silent already, but tagging here records
1150     *       {@link BindingSkipReason#TARGET_COLUMN} explicitly so consumers
1151     *       reading {@code ResolutionContext} directly see intent rather
1152     *       than inferring it from absence.</li>
1153     * </ul>
1154     *
1155     * <p>RHS expressions ({@link ESqlClause#setValue}) are NOT skipped — they
1156     * are bona-fide value references and continue to emit when missing.</p>
1157     */
1158    private static boolean isDmlTargetColumn(TObjectName ref) {
1159        if (ref == null) return false;
1160        ESqlClause loc = ref.getLocation();
1161        return loc == ESqlClause.insertColumn
1162            || loc == ESqlClause.mergeInsert
1163            || loc == ESqlClause.set;
1164    }
1165
1166    private boolean isCreateTableColumnDefinition(TObjectName ref) {
1167        if (ref == null || resolver == null
1168                || resolver.getStatements() == null) {
1169            return false;
1170        }
1171        TStatementList statements = resolver.getStatements();
1172        for (int statementIndex = 0;
1173                statementIndex < statements.size(); statementIndex++) {
1174            TCustomSqlStatement statement =
1175                    statements.get(statementIndex);
1176            if (!(statement instanceof TCreateTableSqlStatement)) {
1177                continue;
1178            }
1179            TColumnDefinitionList columns =
1180                    ((TCreateTableSqlStatement) statement).getColumnList();
1181            if (columns == null) {
1182                continue;
1183            }
1184            for (int columnIndex = 0;
1185                    columnIndex < columns.size(); columnIndex++) {
1186                TColumnDefinition column = columns.getColumn(columnIndex);
1187                if (column != null && column.getColumnName() == ref) {
1188                    return true;
1189                }
1190            }
1191        }
1192        return false;
1193    }
1194
1195    private static boolean isStarReference(TObjectName ref, String columnName) {
1196        if ("*".equals(columnName)) {
1197            return true;
1198        }
1199        if (ref == null) return false;
1200        String text = ref.toString();
1201        return text != null && text.endsWith(".*");
1202    }
1203
1204    private static String qualifierOf(TObjectName ref) {
1205        if (ref == null) return null;
1206        String tbl = ref.getTableString();
1207        if (tbl != null && !tbl.isEmpty()) {
1208            // Slice S15: preserve quotes so downstream {@link
1209            // IScope#resolveTable(String)} (now matcher-routed in
1210            // ListBasedScope) can apply per-dialect quoted-vs-unquoted alias
1211            // rules (e.g. Oracle quoted alias is case-sensitive, unquoted
1212            // folds to upper). Stripping quotes here drops that distinction.
1213            return tbl;
1214        }
1215        return null;
1216    }
1217
1218    private static boolean hasInScopeTable(IScope scope) {
1219        if (scope == null) return false;
1220        try {
1221            List<INamespace> visible = scope.getVisibleNamespaces();
1222            return visible != null && !visible.isEmpty();
1223        } catch (Throwable ignore) {
1224            return false;
1225        }
1226    }
1227
1228    private static String describeTable(INamespace ns) {
1229        if (ns == null) return "<unknown>";
1230        if (ns.getSourceTable() != null && ns.getSourceTable().getName() != null) {
1231            return ns.getSourceTable().getName().toString();
1232        }
1233        if (ns.getFinalTable() != null && ns.getFinalTable().getName() != null) {
1234            return ns.getFinalTable().getName().toString();
1235        }
1236        String display = ns.getDisplayName();
1237        return (display == null || display.isEmpty()) ? "<unknown>" : display;
1238    }
1239
1240    // ===== shared =====
1241
1242    private BindingDiagnosticSeverity severityFor(BindingDiagnosticCode code) {
1243        if (config == null) {
1244            return code.defaultSeverity();
1245        }
1246        BindingDiagnosticSeverity sev = config.getBindingSeverityFor(code);
1247        return sev != null ? sev : code.defaultSeverity();
1248    }
1249
1250    private BindingReferenceSite siteFor(TObjectName ref) {
1251        String text = ref.toString();
1252        if (text == null || text.isEmpty()) {
1253            text = "<unknown>";
1254        }
1255        BindingClause clause = mapper.map(ref);
1256        return BindingReferenceSite.builder()
1257            .clause(clause)
1258            .referenceText(text)
1259            .build();
1260    }
1261
1262    private static TCustomSqlStatement statementFor(TObjectName ref, ScopeBuildResult sbr) {
1263        if (sbr == null || ref == null) return null;
1264        IScope scope = sbr.getScopeForColumn(ref);
1265        while (scope != null) {
1266            TParseTreeNode node = scope.getNode();
1267            if (node instanceof TCustomSqlStatement) {
1268                return (TCustomSqlStatement) node;
1269            }
1270            if (node instanceof TSelectSqlStatement) {
1271                return (TCustomSqlStatement) node;
1272            }
1273            IScope parent = scope.getParent();
1274            if (parent == scope) break;
1275            scope = parent;
1276        }
1277        return null;
1278    }
1279
1280    private BindingReference buildReference(TObjectName ref,
1281                                                   ScopeBuildResult sbr,
1282                                                   boolean bound) {
1283        if (ref == null) return null;
1284        String text = ref.toString();
1285        if (text == null || text.isEmpty()) return null;
1286        BindingReferenceKind kind = ref.toString().endsWith("*")
1287            ? BindingReferenceKind.STAR
1288            : BindingReferenceKind.COLUMN;
1289        return BindingReference.builder()
1290            .kind(kind)
1291            .objectNameText(text)
1292            .site(siteFor(ref))
1293            .bound(bound)
1294            .build();
1295    }
1296}