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}