001package gudusoft.gsqlparser.pp2.layout.rules;
002
003import gudusoft.gsqlparser.ETokenType;
004import gudusoft.gsqlparser.pp2.island.BlockScopeDetector.BlockScopeResult;
005import gudusoft.gsqlparser.pp2.island.ClausePart;
006import gudusoft.gsqlparser.pp2.island.ClauseScopeAnnotator.ClauseScopeResult;
007import gudusoft.gsqlparser.pp2.layout.LayoutContext;
008import gudusoft.gsqlparser.pp2.layout.LayoutPriorities;
009import gudusoft.gsqlparser.pp2.layout.LayoutRule;
010import gudusoft.gsqlparser.pp2.token.Pp2Token;
011import gudusoft.gsqlparser.pp2.token.Pp2TokenStream;
012
013import java.util.Arrays;
014import java.util.Collections;
015import java.util.HashSet;
016import java.util.Locale;
017import java.util.Set;
018
019/**
020 * Lays out joins vertically: a linebreak before the start of each join phrase
021 * ({@code JOIN}, {@code LEFT OUTER JOIN}, {@code INNER JOIN}, {@code CROSS APPLY},
022 * ...) and before each {@code ON} / {@code USING} that opens a join condition.
023 *
024 * <h2>Robust join detection</h2>
025 *
026 * <ul>
027 *   <li>A break before a join phrase fires only when the maximal forward run of
028 *       join keywords actually contains the operator {@code JOIN} or
029 *       {@code APPLY}. This stops a {@code LEFT(} string function inside an
030 *       {@code ON} predicate ({@code ... ON left(x,1) = y}) from being mistaken
031 *       for a {@code LEFT JOIN}.</li>
032 *   <li>{@code ON}/{@code USING} break only at the join operator's own block
033 *       (paren) depth, so a structural-looking keyword inside a function call
034 *       (e.g. JSON {@code ... NULL ON ERROR ...}) does not trigger a break.</li>
035 * </ul>
036 *
037 * <p>Gated to tokens whose S21 {@link ClausePart} is {@link ClausePart#JOIN}.
038 * Priority {@link LayoutPriorities#JOIN_PAREN_COMMA}. Needs the S21 clause and
039 * S19 block analyses on the context. Iterative; read-only.
040 *
041 * <p>Plan reference: §7.3/S26, §7.4/S26.
042 */
043public final class JoinRules implements LayoutRule {
044
045    private static final Set<String> JOIN_PHRASE_WORDS;
046    static {
047        Set<String> s = new HashSet<String>(Arrays.asList(
048            "JOIN", "INNER", "LEFT", "RIGHT", "FULL", "CROSS", "NATURAL", "OUTER", "APPLY"));
049        JOIN_PHRASE_WORDS = Collections.unmodifiableSet(s);
050    }
051
052    @Override
053    public int priority() { return LayoutPriorities.JOIN_PAREN_COMMA; }
054
055    @Override
056    public String name() { return "JoinRules"; }
057
058    @Override
059    public void apply(LayoutContext context) {
060        ClauseScopeResult clause = context.getClauseScope();
061        if (clause == null) return;
062        BlockScopeResult block = context.getBlockScope(); // may be null
063        Pp2TokenStream stream = context.getStream();
064        int n = Math.min(stream.size(), clause.size());
065
066        int lastJoinDepth = -1; // block depth of the most recent join operator
067        for (int i = 1; i < n; i++) {
068            if (clause.partAt(i) != ClausePart.JOIN) continue;
069            String u = upper(stream.get(i));
070            if (JOIN_PHRASE_WORDS.contains(u)) {
071                int prev = prevSolid(stream, i);
072                String pu = prev < 0 ? null : upper(stream.get(prev));
073                boolean phraseStart = pu == null || !JOIN_PHRASE_WORDS.contains(pu);
074                if (phraseStart && runContainsJoinOperator(stream, clause, i)) {
075                    context.requestLinebreaksBefore(i, 1);
076                    context.requestBlanksBefore(i, 0);
077                    lastJoinDepth = depthAt(block, i);
078                }
079            } else if ("ON".equals(u) || "USING".equals(u)) {
080                // Only the join condition opener at the join operator's depth.
081                if (lastJoinDepth >= 0 && depthAt(block, i) == lastJoinDepth) {
082                    context.requestLinebreaksBefore(i, 1);
083                    context.requestBlanksBefore(i, 0);
084                }
085            }
086        }
087    }
088
089    /** Scan the maximal forward run of join keywords from {@code start}; true if it has JOIN/APPLY. */
090    private static boolean runContainsJoinOperator(Pp2TokenStream stream,
091                                                   ClauseScopeResult clause, int start) {
092        int n = Math.min(stream.size(), clause.size());
093        for (int j = start; j < n; j++) {
094            if (clause.partAt(j) != ClausePart.JOIN) break;
095            String u = upper(stream.get(j));
096            if (!JOIN_PHRASE_WORDS.contains(u)) break;
097            if ("JOIN".equals(u) || "APPLY".equals(u)) return true;
098        }
099        return false;
100    }
101
102    /** Block depth at i, or 0 when no block analysis is attached. */
103    private static int depthAt(BlockScopeResult block, int i) {
104        if (block == null || i >= block.size()) return 0;
105        return block.depthAt(i);
106    }
107
108    private static String upper(Pp2Token t) {
109        if (t.getSourceToken().tokentype != ETokenType.ttkeyword) return "";
110        String s = t.getText();
111        return s == null ? "" : s.toUpperCase(Locale.ROOT);
112    }
113
114    private static int prevSolid(Pp2TokenStream stream, int i) {
115        for (int j = i - 1; j >= 0; j--) {
116            ETokenType type = stream.get(j).getSourceToken().tokentype;
117            if (type == ETokenType.ttsimplecomment
118                || type == ETokenType.ttbracketedcomment
119                || type == ETokenType.ttCPPComment) {
120                continue;
121            }
122            return j;
123        }
124        return -1;
125    }
126}