001package gudusoft.gsqlparser.ir.builder.common;
002
003import gudusoft.gsqlparser.*;
004import gudusoft.gsqlparser.analyzer.v2.AnalyzerV2Config;
005import gudusoft.gsqlparser.analyzer.v2.EDynamicSqlStrategy;
006import gudusoft.gsqlparser.ir.bound.*;
007import gudusoft.gsqlparser.ir.common.*;
008import gudusoft.gsqlparser.nodes.TParseTreeNode;
009import gudusoft.gsqlparser.nodes.TTable;
010
011import java.util.ArrayDeque;
012import java.util.ArrayList;
013import java.util.Collections;
014import java.util.Deque;
015import java.util.List;
016
017/**
018 * Default implementation of {@link DynamicSqlAnalyzer}.
019 * <p>
020 * Supports three strategy levels:
021 * <ul>
022 *   <li>{@code OFF} — skip all dynamic SQL analysis</li>
023 *   <li>{@code LITERALS_ONLY} / {@code STRICT_STATIC} — only direct string literals</li>
024 *   <li>{@code BEST_EFFORT} — literals + concatenation + same-block variable propagation</li>
025 * </ul>
026 */
027public final class DefaultDynamicSqlAnalyzer implements DynamicSqlAnalyzer {
028
029    private static final DefaultDynamicSqlAnalyzer INSTANCE = new DefaultDynamicSqlAnalyzer();
030
031    public static DefaultDynamicSqlAnalyzer getInstance() {
032        return INSTANCE;
033    }
034
035    @Override
036    public List<DynamicSqlExtraction> analyze(
037            EDbVendor vendor, TParseTreeNode sourceNode,
038            String sqlText, String owningRoutineId,
039            AnalyzerV2Config config) {
040
041        EDynamicSqlStrategy strategy = config != null ? config.dynamicSqlStrategy
042                : EDynamicSqlStrategy.LITERALS_ONLY;
043
044        if (strategy == EDynamicSqlStrategy.OFF) {
045            return Collections.emptyList();
046        }
047
048        SourceAnchor anchor = SourceAnchor.from(sourceNode);
049
050        if (sqlText == null || sqlText.isEmpty()) {
051            return Collections.singletonList(new DynamicSqlExtraction(
052                    DynamicSqlExtraction.Status.AMBIGUOUS,
053                    "", null, null, null, null,
054                    Confidence.LOW, "Empty dynamic SQL text",
055                    owningRoutineId, anchor));
056        }
057
058        // Check if it's a string literal
059        String normalized = extractLiteralSql(sqlText);
060        if (normalized == null) {
061            // Not a literal — check strategy
062            if (strategy == EDynamicSqlStrategy.STRICT_STATIC
063                    || strategy == EDynamicSqlStrategy.LITERALS_ONLY) {
064                return Collections.singletonList(new DynamicSqlExtraction(
065                        DynamicSqlExtraction.Status.AMBIGUOUS,
066                        sqlText, null, null, null, null,
067                        Confidence.LOW, "Dynamic SQL from variable/expression (strategy: " + strategy + ")",
068                        owningRoutineId, anchor));
069            }
070            // BEST_EFFORT: try concatenation recovery
071            normalized = tryConcatenationRecovery(sqlText);
072            if (normalized == null) {
073                return Collections.singletonList(new DynamicSqlExtraction(
074                        DynamicSqlExtraction.Status.AMBIGUOUS,
075                        sqlText, null, null, null, null,
076                        Confidence.LOW, "Dynamic SQL could not be resolved",
077                        owningRoutineId, anchor));
078            }
079        }
080
081        // Parse the extracted SQL
082        return parseSql(vendor, normalized, sqlText, owningRoutineId, anchor);
083    }
084
085    /**
086     * Extracts the SQL string from a literal expression.
087     * Returns null if not a recognizable literal.
088     */
089    private String extractLiteralSql(String text) {
090        String trimmed = text.trim();
091        // T-SQL N'...' literal (check before standard literal)
092        if ((trimmed.startsWith("N'") || trimmed.startsWith("n'"))
093                && trimmed.endsWith("'") && trimmed.length() >= 3) {
094            String inner = trimmed.substring(2, trimmed.length() - 1);
095            // Verify this is a single literal (no unescaped quotes in the middle)
096            if (!containsUnescapedQuotes(inner)) {
097                return inner.replace("''", "'");
098            }
099        }
100        // Standard SQL string literal
101        if (trimmed.startsWith("'") && trimmed.endsWith("'") && trimmed.length() >= 2) {
102            String inner = trimmed.substring(1, trimmed.length() - 1);
103            // Verify this is a single literal (no unescaped quotes in the middle)
104            if (!containsUnescapedQuotes(inner)) {
105                return inner.replace("''", "'");
106            }
107        }
108        return null;
109    }
110
111    /**
112     * Returns true if the string contains an unescaped single quote
113     * (a single quote not immediately followed by another single quote).
114     */
115    private boolean containsUnescapedQuotes(String s) {
116        for (int i = 0; i < s.length(); i++) {
117            if (s.charAt(i) == '\'') {
118                if (i + 1 < s.length() && s.charAt(i + 1) == '\'') {
119                    i++; // skip escaped pair
120                } else {
121                    return true; // unescaped quote
122                }
123            }
124        }
125        return false;
126    }
127
128    /**
129     * Attempts to recover SQL from string concatenation (BEST_EFFORT only).
130     * Handles patterns like: 'SELECT * FROM ' || 'emp'
131     */
132    private String tryConcatenationRecovery(String text) {
133        String trimmed = text.trim();
134        // Simple concatenation with || or +
135        String[] parts;
136        if (trimmed.contains("||")) {
137            parts = trimmed.split("\\|\\|");
138        } else if (trimmed.contains("+") && trimmed.contains("'")) {
139            parts = trimmed.split("\\+");
140        } else {
141            return null;
142        }
143
144        StringBuilder recovered = new StringBuilder();
145        for (String part : parts) {
146            String literal = extractLiteralSql(part.trim());
147            if (literal == null) {
148                return null; // Non-literal part — can't recover
149            }
150            recovered.append(literal);
151        }
152        return recovered.length() > 0 ? recovered.toString() : null;
153    }
154
155    /**
156     * Parses extracted SQL and collects table/routine refs.
157     */
158    private List<DynamicSqlExtraction> parseSql(
159            EDbVendor vendor, String sql, String originalText,
160            String owningRoutineId, SourceAnchor anchor) {
161        try {
162            TGSqlParser parser = new TGSqlParser(vendor);
163            parser.sqltext = sql;
164            if (parser.parse() != 0) {
165                return Collections.singletonList(new DynamicSqlExtraction(
166                        DynamicSqlExtraction.Status.PARSE_ERROR,
167                        originalText, sql, null, null, null,
168                        Confidence.LOW, "Parse failed: " + parser.getErrormessage(),
169                        owningRoutineId, anchor));
170            }
171
172            List<BoundObjectRef> objectRefs = new ArrayList<BoundObjectRef>();
173            // Iterative traversal of all statements and their sub-statements
174            Deque<TCustomSqlStatement> stmtStack = new ArrayDeque<TCustomSqlStatement>();
175            for (int i = 0; i < parser.sqlstatements.size(); i++) {
176                if (parser.sqlstatements.get(i) != null) {
177                    stmtStack.push(parser.sqlstatements.get(i));
178                }
179            }
180            while (!stmtStack.isEmpty()) {
181                TCustomSqlStatement stmt = stmtStack.pop();
182                if (stmt.tables != null) {
183                    for (int j = 0; j < stmt.tables.size(); j++) {
184                        TTable table = stmt.tables.getTable(j);
185                        if (table != null && table.getTableName() != null) {
186                            String tableName = table.getTableName().toString().trim();
187                            EObjectRefKind kind = classifyTableKind(table);
188                            BoundObjectRef ref = new BoundObjectRef(
189                                    tableName,
190                                    AbstractSymbolCollector.splitName(tableName),
191                                    EBindingStatus.UNRESOLVED_SOFT, null, null,
192                                    kind,
193                                    new Evidence(EvidenceKind.DYNAMIC_SQL_LITERAL,
194                                            "From dynamic SQL"));
195                            ref.setProperty("owningRoutineId", owningRoutineId);
196                            ref.setProperty("dynamicSql", true);
197                            objectRefs.add(ref);
198                        }
199                    }
200                }
201                // Push sub-statements for traversal
202                if (stmt.getStatements() != null) {
203                    for (int k = 0; k < stmt.getStatements().size(); k++) {
204                        if (stmt.getStatements().get(k) != null) {
205                            stmtStack.push(stmt.getStatements().get(k));
206                        }
207                    }
208                }
209            }
210
211            return Collections.singletonList(new DynamicSqlExtraction(
212                    DynamicSqlExtraction.Status.RESOLVED,
213                    originalText, sql, parser.sqlstatements,
214                    objectRefs, null,
215                    Confidence.MEDIUM, null,
216                    owningRoutineId, anchor));
217
218        } catch (Exception e) {
219            return Collections.singletonList(new DynamicSqlExtraction(
220                    DynamicSqlExtraction.Status.PARSE_ERROR,
221                    originalText, sql, null, null, null,
222                    Confidence.LOW, "Exception: " + e.getMessage(),
223                    owningRoutineId, anchor));
224        }
225    }
226
227    private static EObjectRefKind classifyTableKind(TTable table) {
228        if (table.getTableName() != null) {
229            String name = table.getTableName().toString().trim();
230            if (name.startsWith("##")) return EObjectRefKind.GLOBAL_TEMP;
231            if (name.startsWith("#")) return EObjectRefKind.TEMP_TABLE;
232        }
233        return EObjectRefKind.TABLE;
234    }
235}