001package gudusoft.gsqlparser.resolver2.namespace; 002 003import gudusoft.gsqlparser.nodes.TObjectName; 004import gudusoft.gsqlparser.nodes.TTable; 005import gudusoft.gsqlparser.resolver2.ColumnLevel; 006import gudusoft.gsqlparser.resolver2.matcher.INameMatcher; 007import gudusoft.gsqlparser.resolver2.matcher.DefaultNameMatcher; 008import gudusoft.gsqlparser.resolver2.matcher.VendorNameMatcher; 009import gudusoft.gsqlparser.resolver2.model.ColumnReference; 010import gudusoft.gsqlparser.resolver2.model.ColumnSource; 011import gudusoft.gsqlparser.resolver2.model.ColumnSourceWithReferences; 012import gudusoft.gsqlparser.sqlenv.CanonKey; 013 014import java.util.*; 015 016/** 017 * Abstract base class for all namespaces. 018 * Provides common functionality for column resolution and caching. 019 * 020 * <p>Enhanced with reference traceability support: multiple syntactically different 021 * identifiers that refer to the same semantic column can be tracked and traced back.</p> 022 */ 023public abstract class AbstractNamespace implements INamespace { 024 025 /** Associated AST node */ 026 protected final Object node; 027 028 /** Whether this namespace has been validated */ 029 protected boolean validated = false; 030 031 /** Cached column sources (populated during validation) - keyed by normalized name */ 032 protected Map<String, ColumnSource> columnSources = null; 033 034 /** 035 * Enhanced column sources with reference traceability (keyed by normalized name). 036 * When enabled, this map stores ColumnSourceWithReferences that track all original 037 * references to each semantic column. 038 */ 039 protected Map<String, ColumnSourceWithReferences> columnSourcesWithRefs = null; 040 041 /** Whether reference traceability is enabled */ 042 protected boolean referenceTraceabilityEnabled = false; 043 044 /** Name matcher for column name comparisons */ 045 protected final INameMatcher nameMatcher; 046 047 /** 048 * Canonical-key index over {@link #columnSources} (Mantis 4684). 049 * 050 * <p>The matcher fallback loops in {@link #hasColumn}/{@link #resolveColumn} 051 * are O(map size) with a full identifier comparison per entry — on SQL Server 052 * each comparison is a {@link java.text.RuleBasedCollator} run. Inferred 053 * columns ({@code TableNamespace.resolveColumn}) grow the map by one per 054 * unresolved reference, so a wide select list makes resolution O(N²) in 055 * collator comparisons (a 2000-column select took ~24s).</p> 056 * 057 * <p>This index maps {@link CanonKey} → first map key carrying that key. 058 * Since P0d.1, {@code IdentifierService.areEqual} IS canonical-key equality 059 * (collation-based dialects use {@link java.text.CollationKey} bytes from the 060 * SAME cached collator), so an index probe is exactly equivalent to the 061 * matcher loop; {@code putIfAbsent} in insertion order preserves the loop's 062 * first-match-wins choice. The index is synced lazily: {@code columnSources} 063 * is append-only in every namespace (no removals exist), so a sync only 064 * indexes the entries added since the last one. Any name outside the 065 * canonical-key domain (multi-segment, malformed quoting) permanently 066 * disables the index for this namespace and the loop takes over.</p> 067 */ 068 private Map<CanonKey, String> canonKeyIndex = null; 069 /** Identity of the map instance the index was built over (subclasses reassign {@link #columnSources}). */ 070 private Map<String, ColumnSource> canonIndexedMap = null; 071 /** How many insertion-order entries of {@link #canonIndexedMap} are indexed. */ 072 private int canonIndexedCount = 0; 073 /** Set when a name refuses canonical-key construction; falls back to the matcher loop. */ 074 private boolean canonIndexUnusable = false; 075 076 /** 077 * Strategy for handling ambiguous columns. 078 * Defaults to -1, which means use global TBaseType.GUESS_COLUMN_STRATEGY. 079 * Can be overridden per-namespace instance. 080 */ 081 protected int guessColumnStrategy = -1; 082 083 protected AbstractNamespace(Object node, INameMatcher nameMatcher) { 084 this.node = node; 085 this.nameMatcher = nameMatcher != null ? nameMatcher : new DefaultNameMatcher(); 086 } 087 088 protected AbstractNamespace(Object node) { 089 this(node, new DefaultNameMatcher()); 090 } 091 092 /** 093 * Set the strategy for handling ambiguous columns. 094 * @param strategy One of TBaseType.GUESS_COLUMN_STRATEGY_* constants, or -1 to use global default 095 */ 096 public void setGuessColumnStrategy(int strategy) { 097 this.guessColumnStrategy = strategy; 098 } 099 100 /** 101 * Get the strategy for handling ambiguous columns. 102 * Returns the instance-level strategy if set, otherwise returns the global default. 103 * @return The strategy constant 104 */ 105 public int getGuessColumnStrategy() { 106 if (guessColumnStrategy >= 0) { 107 return guessColumnStrategy; 108 } 109 return gudusoft.gsqlparser.TBaseType.GUESS_COLUMN_STRATEGY; 110 } 111 112 @Override 113 public Object getNode() { 114 return node; 115 } 116 117 @Override 118 public boolean isValidated() { 119 return validated; 120 } 121 122 @Override 123 public void validate() { 124 if (!validated) { 125 doValidate(); 126 validated = true; 127 } 128 } 129 130 /** 131 * Subclasses override this to perform actual validation logic. 132 */ 133 protected abstract void doValidate(); 134 135 /** 136 * Enable reference traceability for this namespace. 137 * When enabled, all column additions will track original references. 138 */ 139 public void enableReferenceTraceability() { 140 this.referenceTraceabilityEnabled = true; 141 if (columnSourcesWithRefs == null) { 142 columnSourcesWithRefs = new LinkedHashMap<>(); 143 } 144 } 145 146 /** 147 * Check if reference traceability is enabled. 148 * 149 * @return true if traceability is enabled 150 */ 151 public boolean isReferenceTraceabilityEnabled() { 152 return referenceTraceabilityEnabled; 153 } 154 155 /** 156 * Add a column source with reference traceability support. 157 * 158 * <p>This method normalizes the column name and either:</p> 159 * <ul> 160 * <li>Creates a new entry if this is the first reference</li> 161 * <li>Adds a reference to an existing entry if the column already exists</li> 162 * </ul> 163 * 164 * @param columnName original column name (may include quotes) 165 * @param source the column source 166 * @param objectName the original AST node for traceability (may be null) 167 */ 168 protected void addColumnSource(String columnName, ColumnSource source, TObjectName objectName) { 169 if (columnSources == null) { 170 columnSources = new LinkedHashMap<>(); 171 } 172 173 String normalizedKey = nameMatcher.normalize(columnName); 174 175 // Add to basic map if not exists 176 if (!columnSources.containsKey(normalizedKey)) { 177 columnSources.put(normalizedKey, source); 178 noteColumnSourceAdded(normalizedKey); 179 } 180 181 // Add to enhanced map with references if traceability is enabled 182 if (referenceTraceabilityEnabled) { 183 if (columnSourcesWithRefs == null) { 184 columnSourcesWithRefs = new LinkedHashMap<>(); 185 } 186 187 ColumnSourceWithReferences enhanced = columnSourcesWithRefs.computeIfAbsent( 188 normalizedKey, 189 k -> new ColumnSourceWithReferences(normalizedKey, source) 190 ); 191 192 if (objectName != null) { 193 enhanced.addReference(new ColumnReference(objectName)); 194 } 195 } 196 } 197 198 /** 199 * Add a column source (backward compatible - no traceability). 200 * 201 * @param columnName original column name 202 * @param source the column source 203 */ 204 protected void addColumnSource(String columnName, ColumnSource source) { 205 addColumnSource(columnName, source, null); 206 } 207 208 /** 209 * Slice S1: matcher-aware {@code containsKey} replacement for column maps 210 * that need vendor-specific identifier rules to govern dedupe / lookup. 211 * 212 * <p>Per-vendor identifier rules differ on whether {@code MyCol} and {@code mycol} 213 * are the same column (BigQuery / MySQL / SQL Server: yes for columns; 214 * Oracle / Postgres unquoted: yes via folding; quoted Oracle / Postgres: no). 215 * A raw {@code map.containsKey(name)} bypasses these rules and produces 216 * duplicate-key drift; a fixed {@code equalsIgnoreCase} loop is wrong for 217 * vendors where columns are case-sensitive (BigQuery tables, Oracle quoted). 218 * 219 * <p>This helper: 220 * <ol> 221 * <li>tries an O(1) normalized-key probe (fast path for vendors that 222 * fold unquoted identifiers to a canonical form, where the stored 223 * raw key is itself the folded form — e.g. Postgres stored 224 * {@code "mycol"} probed by query {@code "MYCOL"} that normalizes 225 * to {@code "mycol"}),</li> 226 * <li>tries an O(1) raw-key probe for the exact-match common case,</li> 227 * <li>falls back to a matcher loop that compares against {@link 228 * ColumnSource#getExposedName()} (when values are {@code ColumnSource}) 229 * so quote state is preserved on quoted-sensitive dialects.</li> 230 * </ol> 231 * 232 * <p><strong>Quote-state preservation (codex round 1 + round 2).</strong> 233 * The map values must hold the original-cased identifier (with quotes). 234 * Storage keys are also raw (= {@code exposedName}) — round 2 caught 235 * that storing under {@code nameMatcher.normalize(name)} can collide 236 * two matcher-distinct identifiers (e.g. Postgres quoted {@code "mycol"} 237 * vs unquoted {@code MYCOL} both normalize to {@code mycol}) into the 238 * same key and lose information. With raw-keyed storage, the normalize 239 * fast-probe only hits when the stored raw key already equals the 240 * normalized form, and in that case {@link 241 * gudusoft.gsqlparser.resolver2.matcher.INameMatcher#matches} agrees; 242 * the matcher loop catches the case-only-different case. 243 */ 244 protected boolean containsColumnByMatcher(Map<String, ?> map, String columnName) { 245 if (map == null || map.isEmpty() || columnName == null) { 246 return false; 247 } 248 String normalizedKey = nameMatcher.normalize(columnName); 249 if (normalizedKey != null && map.containsKey(normalizedKey)) { 250 return true; 251 } 252 if (map.containsKey(columnName)) { 253 return true; 254 } 255 for (Map.Entry<String, ?> entry : map.entrySet()) { 256 String compareName = entry.getKey(); 257 Object value = entry.getValue(); 258 if (value instanceof ColumnSource) { 259 String exposed = ((ColumnSource) value).getExposedName(); 260 if (exposed != null) { 261 compareName = exposed; 262 } 263 } 264 if (compareName != null && nameMatcher.matches(compareName, columnName)) { 265 return true; 266 } 267 } 268 return false; 269 } 270 271 /** 272 * Slice S1: matcher-aware {@code Set#contains} replacement. See 273 * {@link #containsColumnByMatcher(Map, String)} for the rationale. 274 */ 275 protected boolean containsColumnNameByMatcher(Set<String> set, String columnName) { 276 if (set == null || set.isEmpty() || columnName == null) { 277 return false; 278 } 279 if (set.contains(columnName)) { 280 return true; 281 } 282 String normalizedKey = nameMatcher.normalize(columnName); 283 if (normalizedKey != null && set.contains(normalizedKey)) { 284 return true; 285 } 286 for (String existing : set) { 287 if (nameMatcher.matches(existing, columnName)) { 288 return true; 289 } 290 } 291 return false; 292 } 293 294 @Override 295 public ColumnLevel hasColumn(String columnName) { 296 ensureValidated(); 297 298 if (columnSources == null) { 299 return ColumnLevel.NOT_EXISTS; 300 } 301 302 // Use normalized key for O(1) lookup 303 String normalizedKey = nameMatcher.normalize(columnName); 304 if (columnSources.containsKey(normalizedKey)) { 305 return ColumnLevel.EXISTS; 306 } 307 308 // Matcher-equality fallback (canonical-key indexed — see canonKeyIndex) 309 if (findColumnKeyByMatcher(columnName) != null) { 310 return ColumnLevel.EXISTS; 311 } 312 313 return ColumnLevel.NOT_EXISTS; 314 } 315 316 @Override 317 public ColumnSource resolveColumn(String columnName) { 318 ensureValidated(); 319 320 if (columnSources == null) { 321 return null; 322 } 323 324 // Use normalized key for O(1) lookup 325 String normalizedKey = nameMatcher.normalize(columnName); 326 ColumnSource source = columnSources.get(normalizedKey); 327 if (source != null) { 328 return source; 329 } 330 331 // Matcher-equality fallback (canonical-key indexed — see canonKeyIndex) 332 String matchedKey = findColumnKeyByMatcher(columnName); 333 return matchedKey != null ? columnSources.get(matchedKey) : null; 334 } 335 336 /** 337 * Find the first insertion-order key of {@link #columnSources} that the 338 * {@link #nameMatcher} considers equal to {@code columnName}, or null when 339 * no key matches. 340 * 341 * <p>Equivalent to the historical 342 * {@code for (key : columnSources.keySet()) if (nameMatcher.matches(key, columnName))} 343 * loop, but near-O(1) via the canonical-key index when the matcher is a 344 * {@link VendorNameMatcher} (Mantis 4684 — the loop made wide select lists 345 * quadratic with a collator comparison per entry on SQL Server).</p> 346 */ 347 protected String findColumnKeyByMatcher(String columnName) { 348 if (columnSources == null || columnSources.isEmpty() || columnName == null) { 349 return null; 350 } 351 // Exact class only: a VendorNameMatcher SUBCLASS may override matches(), 352 // and the index would bypass the override (Codex round-1 P2). 353 if (!canonIndexUnusable && nameMatcher.getClass() == VendorNameMatcher.class) { 354 try { 355 syncCanonKeyIndex(); 356 return canonKeyIndex.get(canonKeyOf(columnName)); 357 } catch (RuntimeException e) { 358 // Name outside the canonical-key domain (multi-segment or 359 // malformed quoting): give up on the index for this namespace. 360 canonIndexUnusable = true; 361 canonKeyIndex = null; 362 canonIndexedMap = null; 363 } 364 } 365 for (String existingCol : columnSources.keySet()) { 366 if (nameMatcher.matches(existingCol, columnName)) { 367 return existingCol; 368 } 369 } 370 return null; 371 } 372 373 /** 374 * Canonical key of one name under the matcher's OWN identifier service and object type: the 375 * same service {@link VendorNameMatcher#matches} compares with, so the index finds exactly the 376 * columns the matcher equates. Keying by the vendor's default service instead dropped a 377 * configured profile: under {@code Latin1_General_CI_AI}, {@code d.cafe} missed the column 378 * {@code [café]} that the matcher equates with it, and under {@code Latin1_General_CS_AI} 379 * {@code d.CAFÉ} resolved to {@code [café]} that the matcher keeps apart. 380 */ 381 private CanonKey canonKeyOf(String name) { 382 VendorNameMatcher vm = (VendorNameMatcher) nameMatcher; 383 return vm.getIdentifierService().canonKey(name, vm.getDefaultObjectType()); 384 } 385 386 /** 387 * Incremental index maintenance for the append-one case (Codex round-1 P1): 388 * without this, every {@link #syncCanonKeyIndex} after a single-key insert 389 * re-walks the already-indexed prefix just to skip it — O(N²) iterator 390 * steps on the wide-select workload even with O(N) canonical keys. Call 391 * after putting {@code key} into {@link #columnSources}. A batch of puts 392 * without notes is still safe: the size check fails and the next sync 393 * catches up with the (now rare) prefix walk. 394 */ 395 protected void noteColumnSourceAdded(String key) { 396 if (canonIndexUnusable || canonKeyIndex == null || canonIndexedMap != columnSources) { 397 return; // index not built yet or map replaced; next sync handles it 398 } 399 if (columnSources.size() != canonIndexedCount + 1) { 400 return; // overwrite (size unchanged) or un-noted batch; sync catches up 401 } 402 try { 403 canonKeyIndex.putIfAbsent(canonKeyOf(key), key); 404 canonIndexedCount++; 405 } catch (RuntimeException e) { 406 canonIndexUnusable = true; 407 canonKeyIndex = null; 408 canonIndexedMap = null; 409 } 410 } 411 412 /** 413 * Bring {@link #canonKeyIndex} up to date with {@link #columnSources}. 414 * The map is append-only between syncs, so only the entries beyond 415 * {@link #canonIndexedCount} need indexing; a reassigned map instance or a 416 * shrunken size (defensive — no remover exists today) rebuilds from scratch. 417 */ 418 private void syncCanonKeyIndex() { 419 if (canonKeyIndex == null || canonIndexedMap != columnSources 420 || columnSources.size() < canonIndexedCount) { 421 canonKeyIndex = new HashMap<>(); 422 canonIndexedMap = columnSources; 423 canonIndexedCount = 0; 424 } 425 int size = columnSources.size(); 426 if (canonIndexedCount == size) { 427 return; 428 } 429 int i = 0; 430 for (String key : columnSources.keySet()) { 431 if (i++ < canonIndexedCount) { 432 continue; 433 } 434 // First entry with a given canonical key wins, matching the scan. 435 canonKeyIndex.putIfAbsent(canonKeyOf(key), key); 436 } 437 canonIndexedCount = size; 438 } 439 440 @Override 441 public Map<String, ColumnSource> getAllColumnSources() { 442 ensureValidated(); 443 return columnSources != null 444 ? Collections.unmodifiableMap(columnSources) 445 : Collections.emptyMap(); 446 } 447 448 /** 449 * Get all column references for a specific column. 450 * 451 * <p>Requires reference traceability to be enabled.</p> 452 * 453 * @param columnName the column name (normalized or original) 454 * @return list of all references, empty if not found or traceability not enabled 455 */ 456 public List<ColumnReference> getColumnReferences(String columnName) { 457 if (columnSourcesWithRefs == null) { 458 return Collections.emptyList(); 459 } 460 461 String normalizedKey = nameMatcher.normalize(columnName); 462 ColumnSourceWithReferences enhanced = columnSourcesWithRefs.get(normalizedKey); 463 464 return enhanced != null 465 ? enhanced.getAllReferences() 466 : Collections.emptyList(); 467 } 468 469 /** 470 * Get all unique columns with their references. 471 * 472 * <p>Requires reference traceability to be enabled.</p> 473 * 474 * @return collection of enhanced column sources, empty if traceability not enabled 475 */ 476 public Collection<ColumnSourceWithReferences> getAllUniqueColumns() { 477 if (columnSourcesWithRefs == null) { 478 return Collections.emptyList(); 479 } 480 return Collections.unmodifiableCollection(columnSourcesWithRefs.values()); 481 } 482 483 /** 484 * Get enhanced column source with references for a specific column. 485 * 486 * @param columnName the column name 487 * @return enhanced column source, or null if not found 488 */ 489 public ColumnSourceWithReferences getColumnSourceWithReferences(String columnName) { 490 if (columnSourcesWithRefs == null) { 491 return null; 492 } 493 String normalizedKey = nameMatcher.normalize(columnName); 494 return columnSourcesWithRefs.get(normalizedKey); 495 } 496 497 @Override 498 public List<TTable> getAllFinalTables() { 499 TTable finalTable = getFinalTable(); 500 if (finalTable != null) { 501 return Collections.singletonList(finalTable); 502 } 503 return Collections.emptyList(); 504 } 505 506 /** 507 * Ensure this namespace is validated before accessing column info 508 */ 509 protected void ensureValidated() { 510 if (!validated) { 511 validate(); 512 } 513 } 514 515 /** 516 * Get the name matcher used by this namespace. 517 * 518 * @return the name matcher 519 */ 520 public INameMatcher getNameMatcher() { 521 return nameMatcher; 522 } 523 524 @Override 525 public String toString() { 526 return getDisplayName(); 527 } 528}