001package gudusoft.gsqlparser.resolver2.format; 002 003import gudusoft.gsqlparser.EDbVendor; 004import gudusoft.gsqlparser.nodes.TObjectName; 005import gudusoft.gsqlparser.nodes.TTable; 006import gudusoft.gsqlparser.sqlenv.ESQLDataObjectType; 007import gudusoft.gsqlparser.sqlenv.IdentifierCodec; 008import gudusoft.gsqlparser.sqlenv.IdentifierProfile; 009import gudusoft.gsqlparser.sqlenv.IdentifierService; 010import gudusoft.gsqlparser.sqlenv.SqlNameGenerator; 011import gudusoft.gsqlparser.util.SQLUtil; 012 013import java.util.List; 014 015/** 016 * Normalizes identifier names for display output. 017 * 018 * <p>This class provides display-focused normalization that is separate from 019 * the matching/equality normalization used during name resolution. The key 020 * principle is:</p> 021 * 022 * <ul> 023 * <li><b>Matching normalization</b> (in {@code IdentifierService}): applies case folding 024 * and other transformations to create a canonical key for comparison</li> 025 * <li><b>Display normalization</b> (this class): strips delimiters but preserves 026 * the original case as written by the user</li> 027 * </ul> 028 * 029 * <h3>Usage Example:</h3> 030 * <pre> 031 * DisplayNameNormalizer normalizer = new DisplayNameNormalizer(EDbVendor.dbvbigquery); 032 * normalizer.setMode(DisplayNameMode.DISPLAY); 033 * 034 * // Strip backticks, preserve case 035 * String display = normalizer.normalizeTableName("`kalyan-DB`.`test-schema`.`t1`"); 036 * // Result: "kalyan-DB.test-schema.t1" 037 * </pre> 038 * 039 * @see DisplayNameMode 040 * @see DisplayNamePolicy 041 */ 042public class DisplayNameNormalizer { 043 044 private final EDbVendor vendor; 045 private DisplayNameMode mode = DisplayNameMode.DISPLAY; 046 private boolean stripDelimiters = true; 047 048 /** How much delimiting {@link DisplayNameMode#SQL_RENDER} applies; see {@link #setQuotePolicy}. */ 049 private SqlNameGenerator.QuotePolicy quotePolicy = SqlNameGenerator.QuotePolicy.WHEN_NEEDED; 050 051 /** 052 * Vendor identifier profile, built lazily and reused. This is what makes the 053 * delimiter rules come from the unified name service instead of this class. 054 */ 055 private IdentifierProfile profileCache; 056 057 /** Name service used for CANONICAL folding, built lazily and reused. */ 058 private IdentifierService serviceCache; 059 060 private IdentifierProfile profile() { 061 if (profileCache == null) { 062 profileCache = IdentifierProfile.forVendor(vendor, IdentifierProfile.VendorFlags.defaults()); 063 } 064 return profileCache; 065 } 066 067 private IdentifierService service() { 068 if (serviceCache == null) { 069 // Collator provider is SQL Server specific and nullable; display folding 070 // does not need collation, which is a comparison concern. 071 serviceCache = new IdentifierService(profile(), null); 072 } 073 return serviceCache; 074 } 075 076 /** 077 * Create a normalizer for the specified database vendor. 078 * 079 * @param vendor the database vendor (determines delimiter style) 080 */ 081 public DisplayNameNormalizer(EDbVendor vendor) { 082 this.vendor = vendor != null ? vendor : EDbVendor.dbvansi; 083 } 084 085 /** 086 * Create a normalizer with default vendor (ANSI). 087 */ 088 public DisplayNameNormalizer() { 089 this(EDbVendor.dbvansi); 090 } 091 092 /** 093 * Get the current display mode. 094 */ 095 public DisplayNameMode getMode() { 096 return mode; 097 } 098 099 /** 100 * Set the display mode. 101 * 102 * @param mode the display mode 103 * @return this normalizer for chaining 104 */ 105 public DisplayNameNormalizer setMode(DisplayNameMode mode) { 106 this.mode = mode != null ? mode : DisplayNameMode.DISPLAY; 107 return this; 108 } 109 110 public SqlNameGenerator.QuotePolicy getQuotePolicy() { 111 return quotePolicy; 112 } 113 114 /** 115 * Choose the spelling {@link DisplayNameMode#SQL_RENDER} generates. "Valid SQL that names 116 * this object" is a constraint, not a value: {@code dbo} and {@code [dbo]} both satisfy it. 117 * This parameter is what makes the answer unique. 118 * 119 * <ul> 120 * <li>{@code WHEN_NEEDED} (default): delimit only where the bare word would be invalid, 121 * would fold to another object, or is a keyword — {@code dbo.SalesOrder}, 122 * {@code [Order ID]}.</li> 123 * <li>{@code ALWAYS}: delimit every segment — {@code [dbo].[SalesOrder]}.</li> 124 * </ul> 125 */ 126 public DisplayNameNormalizer setQuotePolicy(SqlNameGenerator.QuotePolicy quotePolicy) { 127 this.quotePolicy = quotePolicy != null ? quotePolicy : SqlNameGenerator.QuotePolicy.WHEN_NEEDED; 128 return this; 129 } 130 131 /** 132 * Check if delimiters should be stripped. 133 */ 134 public boolean isStripDelimiters() { 135 return stripDelimiters; 136 } 137 138 /** 139 * Set whether to strip delimiters. 140 * 141 * @param stripDelimiters true to strip delimiters 142 * @return this normalizer for chaining 143 */ 144 public DisplayNameNormalizer setStripDelimiters(boolean stripDelimiters) { 145 this.stripDelimiters = stripDelimiters; 146 return this; 147 } 148 149 /** 150 * Normalize a table name for display. 151 * 152 * @param table the table object 153 * @return the normalized display name 154 */ 155 public String normalizeTableName(TTable table) { 156 if (table == null) { 157 return null; 158 } 159 160 // Try to get the full name from the table 161 String fullName = table.getFullName(); 162 if (fullName != null && !fullName.isEmpty()) { 163 return normalizeQualifiedName(fullName, ESQLDataObjectType.dotTable); 164 } 165 166 // Fallback to table name 167 String name = table.getName(); 168 return name != null ? normalizeIdentifier(name, ESQLDataObjectType.dotTable) : null; 169 } 170 171 /** 172 * Normalize an object name (table or column) for display. 173 * 174 * @param objectName the object name node 175 * @return the normalized display name 176 */ 177 public String normalizeObjectName(TObjectName objectName) { 178 if (objectName == null) { 179 return null; 180 } 181 182 // Use toString() which gives the full representation 183 String name = objectName.toString(); 184 return normalizeQualifiedName(name); 185 } 186 187 /** 188 * Normalize a column name for display. 189 * 190 * @param columnName the column name (may include table prefix) 191 * @return the normalized display name 192 */ 193 public String normalizeColumnName(String columnName) { 194 if (columnName == null || columnName.isEmpty()) { 195 return columnName; 196 } 197 return normalizeIdentifier(columnName, ESQLDataObjectType.dotColumn); 198 } 199 200 /** 201 * Normalize a qualified name (e.g., schema.table.column) for display. 202 * 203 * <p>This method handles multi-part names by normalizing each segment 204 * separately while preserving the dot separators.</p> 205 * 206 * @param qualifiedName the qualified name 207 * @return the normalized display name 208 */ 209 public String normalizeQualifiedName(String qualifiedName) { 210 return normalizeQualifiedName(qualifiedName, ESQLDataObjectType.dotColumn); 211 } 212 213 /** 214 * Normalize a qualified name for display, stating the syntactic role of its 215 * segments so delimiter rules come from the vendor profile. 216 * 217 * <p>Every segment is decoded with the same {@code objectType}, which is what 218 * this class has always done. Per-segment role typing (catalog / schema / leaf) 219 * is a separate change: it would alter output for vendors whose quote rules 220 * differ by role, so it needs its own measurement.</p> 221 * 222 * @param qualifiedName the qualified name 223 * @param objectType syntactic role of the identifier segments 224 * @return the normalized display name 225 */ 226 public String normalizeQualifiedName(String qualifiedName, ESQLDataObjectType objectType) { 227 if (qualifiedName == null || qualifiedName.isEmpty()) { 228 return qualifiedName; 229 } 230 231 // Segmentation belongs to the name service, not to this class: it knows 232 // which dots are separators and which sit inside a delimited segment, and 233 // it carries the vendor quirks (MSSQL "db..table", BigQuery backticks). 234 List<String> parts = SQLUtil.parseNames(qualifiedName, vendor); 235 if (parts == null || parts.isEmpty()) { 236 return normalizeIdentifier(qualifiedName, objectType); 237 } 238 239 // The name service may hand back segments with their delimiters already removed 240 // (BigQuery writes `proj.ds.tbl` inside ONE pair of backticks). Those are stored names, 241 // not SQL spellings, and SQL_RENDER has to write them back from that starting point. 242 boolean storedSegments = false; 243 if (mode == DisplayNameMode.SQL_RENDER) { 244 StringBuilder rejoined = new StringBuilder(); 245 for (int i = 0; i < parts.size(); i++) { 246 if (i > 0) rejoined.append("."); 247 rejoined.append(parts.get(i)); 248 } 249 storedSegments = !rejoined.toString().equals(qualifiedName); // exact text, not an identifier compare 250 } 251 252 StringBuilder result = new StringBuilder(); 253 for (int i = 0; i < parts.size(); i++) { 254 if (i > 0) { 255 result.append("."); 256 } 257 result.append(storedSegments 258 ? renderStoredAsSql(parts.get(i), objectType) 259 : normalizeIdentifier(parts.get(i), objectType)); 260 } 261 262 return result.toString(); 263 } 264 265 /** 266 * Normalize a single identifier (without dots) for display. 267 * 268 * @param identifier the identifier 269 * @return the normalized identifier 270 */ 271 public String normalizeIdentifier(String identifier) { 272 return normalizeIdentifier(identifier, ESQLDataObjectType.dotColumn); 273 } 274 275 /** 276 * Normalize a single identifier (without dots) for display, stating its 277 * syntactic role so delimiter rules come from the vendor profile. 278 * 279 * @param identifier the identifier 280 * @param objectType syntactic role of the identifier 281 * @return the normalized identifier 282 */ 283 public String normalizeIdentifier(String identifier, ESQLDataObjectType objectType) { 284 if (identifier == null || identifier.isEmpty()) { 285 return identifier; 286 } 287 288 switch (mode) { 289 case SQL_RENDER: 290 return renderAsSql(identifier, objectType); 291 292 case CANONICAL: 293 // Undelimit AND apply the vendor's case folding, via the name service. 294 // This is deliberately NOT a rendered CanonKey: a comparison key and a 295 // display string are two different contracts, and nothing may re-derive 296 // identity by comparing what this returns. 297 return canonicalize(identifier, objectType); 298 299 case DISPLAY: 300 default: 301 // Strip delimiters, preserve case 302 return stripDelimiters ? stripAllDelimiters(identifier, objectType) : identifier; 303 } 304 } 305 306 /** 307 * Strip all SQL delimiters from an identifier. 308 * 309 * <p>Handles multiple delimiter styles:</p> 310 * <ul> 311 * <li>Double quotes: {@code "name"} → {@code name}</li> 312 * <li>Backticks: {@code `name`} → {@code name}</li> 313 * <li>Square brackets: {@code [name]} → {@code name}</li> 314 * <li>Single quotes (for some vendors): {@code 'name'} → {@code name}</li> 315 * </ul> 316 * 317 * @param identifier the identifier (may be quoted) 318 * @return the identifier without delimiters 319 */ 320 /** 321 * Write a name as valid SQL for the vendor: GENERATED from the stored name, not echoed from 322 * the reference. 323 * 324 * <p>Echoing kept whatever delimiters the author happened to type, so one object had as many 325 * SQL_RENDER spellings as the script had ways of writing it ({@code dbo} and {@code [dbo]}). 326 * {@link SqlNameGenerator#respell} instead decides the delimiters by rule, governed by 327 * {@link #setQuotePolicy}: a delimited name is decoded to its exact stored text and 328 * re-delimited only if it must be.</p> 329 * 330 * <p>A name written WITHOUT delimiters is returned as written. It is already valid SQL for 331 * that object, and folding its case would need the catalog's stored case, which SQL text 332 * does not reveal. So {@code Sales} and {@code SALES} may both come back for one Oracle 333 * table; a listing shows one of them through identity grouping and a 334 * {@link DisplayNamePolicy}.</p> 335 */ 336 /** Write a STORED name (no source spelling) as SQL; shown as given when none can be generated. */ 337 private String renderStoredAsSql(String stored, ESQLDataObjectType objectType) { 338 if (stored == null || stored.isEmpty()) return stored; 339 SqlNameGenerator.Result r = SqlNameGenerator.generate(vendor, quotePolicy, 340 SqlNameGenerator.Segment.of(objectType, stored)); 341 return r.isOk() ? r.getSql() : stored; 342 } 343 344 private String renderAsSql(String identifier, ESQLDataObjectType objectType) { 345 // A bare asterisk in SQL text can only be the wildcard: a column that is really named 346 // "*" cannot be written without delimiters. This reads the LEXICAL input, so it does not 347 // confuse the two the way a rendered name would. 348 if ("*".equals(identifier)) { 349 return identifier; 350 } 351 try { 352 SqlNameGenerator.Result r = SqlNameGenerator.respell(vendor, quotePolicy, objectType, identifier); 353 if (r.isOk()) { 354 return r.getSql(); 355 } 356 } catch (RuntimeException e) { 357 // Malformed spelling: fall through. 358 } 359 // Nothing valid could be generated. Show the reference as written rather than drop it. 360 return identifier; 361 } 362 363 /** 364 * Render an identifier in the vendor's canonical form: delimiters removed and 365 * case folded by the vendor's own rule (Oracle {@code MyTable} -> {@code MYTABLE}, 366 * while {@code "MyTable"} keeps its case because quoting is part of identity). 367 * 368 * <p>The folding rule belongs to {@link IdentifierService}; this class only asks 369 * for it. The result is a DISPLAY string, not a comparison key — use 370 * {@code SQLUtil.sameName} / {@code canonKey} to compare names, never this.</p> 371 */ 372 private String canonicalize(String identifier, ESQLDataObjectType objectType) { 373 try { 374 String folded = service().normalizeSegment(identifier, objectType); 375 if (folded != null && !folded.isEmpty()) { 376 return folded; 377 } 378 } catch (RuntimeException e) { 379 // Malformed spelling: fall back to the undelimited form rather than 380 // dropping the name from the listing. 381 } 382 return stripDelimiters ? stripAllDelimiters(identifier, objectType) : identifier; 383 } 384 385 public String stripAllDelimiters(String identifier) { 386 return stripAllDelimiters(identifier, ESQLDataObjectType.dotColumn); 387 } 388 389 /** 390 * Strip SQL delimiters from an identifier, using the vendor's real quote rules. 391 * 392 * <p>The decision of what counts as a delimiter belongs to the unified name 393 * service, not to the display layer, so this delegates to 394 * {@link IdentifierCodec#decodeLexical}. Two display-specific rules are layered 395 * on top of it, and both exist to keep output byte-identical to the behavior 396 * this class had before it delegated:</p> 397 * 398 * <ul> 399 * <li><b>Never fail on a malformed spelling.</b> {@code decodeLexical} throws 400 * {@code MalformedIdentifierException} for input such as {@code "unclosed}. 401 * A display layer must not abort a whole Tables/Fields listing because one 402 * name is malformed, so a throw falls through to the legacy strip.</li> 403 * <li><b>Never display an empty name.</b> {@code decodeLexical} decodes 404 * {@code ""}, {@code []} and {@code ''} to the empty string. Rendering a 405 * column as nothing is worse than rendering its raw spelling, so an empty 406 * decode falls through as well.</li> 407 * </ul> 408 * 409 * <p><b>Known gap, deliberately preserved.</b> The legacy fallback also strips 410 * delimiters the vendor does not own (backticks for Oracle, brackets for MySQL, 411 * single quotes for everyone). Routing those through the profile instead would 412 * be the semantically correct behavior, but it changes output for 14 of 15 413 * vendors measured, so it is a separate decision with its own golden review — 414 * not something to slip in under a refactor.</p> 415 * 416 * @param identifier the identifier (may be quoted) 417 * @param objectType syntactic role of the identifier (quote rules can depend on it) 418 * @return the identifier without delimiters 419 */ 420 public String stripAllDelimiters(String identifier, ESQLDataObjectType objectType) { 421 if (identifier == null || identifier.isEmpty()) { 422 return identifier; 423 } 424 425 try { 426 String decoded = IdentifierCodec.decodeLexical(profile(), objectType, identifier); 427 // An empty decode means the spelling was an empty delimited name such as 428 // "" or []. Rendering a column as nothing is worse than rendering what 429 // was written, so show the spelling instead. This is a display policy 430 // applied to the codec's answer, not a second opinion about delimiters. 431 return decoded.isEmpty() ? identifier : decoded; 432 } catch (RuntimeException e) { 433 // Malformed spelling (an unterminated delimiter). A display layer must 434 // not abort a whole listing over one bad name, so show it as written. 435 return identifier; 436 } 437 } 438 439 440 441 442 /** 443 * Get a display name for a table, handling various TTable configurations. 444 * 445 * @param table the table 446 * @return the display name 447 */ 448 public String getTableDisplayName(TTable table) { 449 if (table == null) { 450 return null; 451 } 452 453 // Build the full qualified name from parts if available 454 StringBuilder sb = new StringBuilder(); 455 456 // Server/catalog 457 String server = table.getPrefixServer(); 458 if (server != null && !server.isEmpty()) { 459 sb.append(normalizeIdentifier(server, ESQLDataObjectType.dotServer)); 460 sb.append("."); 461 } 462 463 // Database 464 String database = table.getPrefixDatabase(); 465 if (database != null && !database.isEmpty()) { 466 sb.append(normalizeIdentifier(database, ESQLDataObjectType.dotCatalog)); 467 sb.append("."); 468 } 469 470 // Schema 471 String schema = table.getPrefixSchema(); 472 if (schema != null && !schema.isEmpty()) { 473 sb.append(normalizeIdentifier(schema, ESQLDataObjectType.dotSchema)); 474 sb.append("."); 475 } 476 477 // Table name 478 String tableName = table.getName(); 479 if (tableName != null && !tableName.isEmpty()) { 480 sb.append(normalizeIdentifier(tableName, ESQLDataObjectType.dotTable)); 481 } 482 483 String result = sb.toString(); 484 485 // If we got nothing useful, fall back to getFullName 486 if (result.isEmpty() || result.equals(".")) { 487 String fullName = table.getFullName(); 488 if (fullName != null) { 489 return normalizeQualifiedName(fullName, ESQLDataObjectType.dotTable); 490 } 491 } 492 493 return result.isEmpty() ? null : result; 494 } 495 496 @Override 497 public String toString() { 498 return String.format("DisplayNameNormalizer{vendor=%s, mode=%s, stripDelimiters=%s}", 499 vendor, mode, stripDelimiters); 500 } 501}