001package gudusoft.gsqlparser.resolver2.format; 002 003import gudusoft.gsqlparser.EDbVendor; 004import gudusoft.gsqlparser.nodes.TObjectName; 005import gudusoft.gsqlparser.nodes.TTable; 006 007/** 008 * Normalizes identifier names for display output. 009 * 010 * <p>This class provides display-focused normalization that is separate from 011 * the matching/equality normalization used during name resolution. The key 012 * principle is:</p> 013 * 014 * <ul> 015 * <li><b>Matching normalization</b> (in {@code IdentifierService}): applies case folding 016 * and other transformations to create a canonical key for comparison</li> 017 * <li><b>Display normalization</b> (this class): strips delimiters but preserves 018 * the original case as written by the user</li> 019 * </ul> 020 * 021 * <h3>Usage Example:</h3> 022 * <pre> 023 * DisplayNameNormalizer normalizer = new DisplayNameNormalizer(EDbVendor.dbvbigquery); 024 * normalizer.setMode(DisplayNameMode.DISPLAY); 025 * 026 * // Strip backticks, preserve case 027 * String display = normalizer.normalizeTableName("`kalyan-DB`.`test-schema`.`t1`"); 028 * // Result: "kalyan-DB.test-schema.t1" 029 * </pre> 030 * 031 * @see DisplayNameMode 032 * @see DisplayNamePolicy 033 */ 034public class DisplayNameNormalizer { 035 036 private final EDbVendor vendor; 037 private DisplayNameMode mode = DisplayNameMode.DISPLAY; 038 private boolean stripDelimiters = true; 039 040 /** 041 * Create a normalizer for the specified database vendor. 042 * 043 * @param vendor the database vendor (determines delimiter style) 044 */ 045 public DisplayNameNormalizer(EDbVendor vendor) { 046 this.vendor = vendor != null ? vendor : EDbVendor.dbvansi; 047 } 048 049 /** 050 * Create a normalizer with default vendor (ANSI). 051 */ 052 public DisplayNameNormalizer() { 053 this(EDbVendor.dbvansi); 054 } 055 056 /** 057 * Get the current display mode. 058 */ 059 public DisplayNameMode getMode() { 060 return mode; 061 } 062 063 /** 064 * Set the display mode. 065 * 066 * @param mode the display mode 067 * @return this normalizer for chaining 068 */ 069 public DisplayNameNormalizer setMode(DisplayNameMode mode) { 070 this.mode = mode != null ? mode : DisplayNameMode.DISPLAY; 071 return this; 072 } 073 074 /** 075 * Check if delimiters should be stripped. 076 */ 077 public boolean isStripDelimiters() { 078 return stripDelimiters; 079 } 080 081 /** 082 * Set whether to strip delimiters. 083 * 084 * @param stripDelimiters true to strip delimiters 085 * @return this normalizer for chaining 086 */ 087 public DisplayNameNormalizer setStripDelimiters(boolean stripDelimiters) { 088 this.stripDelimiters = stripDelimiters; 089 return this; 090 } 091 092 /** 093 * Normalize a table name for display. 094 * 095 * @param table the table object 096 * @return the normalized display name 097 */ 098 public String normalizeTableName(TTable table) { 099 if (table == null) { 100 return null; 101 } 102 103 // Try to get the full name from the table 104 String fullName = table.getFullName(); 105 if (fullName != null && !fullName.isEmpty()) { 106 return normalizeQualifiedName(fullName); 107 } 108 109 // Fallback to table name 110 String name = table.getName(); 111 return name != null ? normalizeIdentifier(name) : null; 112 } 113 114 /** 115 * Normalize an object name (table or column) for display. 116 * 117 * @param objectName the object name node 118 * @return the normalized display name 119 */ 120 public String normalizeObjectName(TObjectName objectName) { 121 if (objectName == null) { 122 return null; 123 } 124 125 // Use toString() which gives the full representation 126 String name = objectName.toString(); 127 return normalizeQualifiedName(name); 128 } 129 130 /** 131 * Normalize a column name for display. 132 * 133 * @param columnName the column name (may include table prefix) 134 * @return the normalized display name 135 */ 136 public String normalizeColumnName(String columnName) { 137 if (columnName == null || columnName.isEmpty()) { 138 return columnName; 139 } 140 return normalizeIdentifier(columnName); 141 } 142 143 /** 144 * Normalize a qualified name (e.g., schema.table.column) for display. 145 * 146 * <p>This method handles multi-part names by normalizing each segment 147 * separately while preserving the dot separators.</p> 148 * 149 * @param qualifiedName the qualified name 150 * @return the normalized display name 151 */ 152 public String normalizeQualifiedName(String qualifiedName) { 153 if (qualifiedName == null || qualifiedName.isEmpty()) { 154 return qualifiedName; 155 } 156 157 if (mode == DisplayNameMode.SQL_RENDER) { 158 // In SQL_RENDER mode, preserve the original form 159 return qualifiedName; 160 } 161 162 // Split by dots, but be careful with quoted identifiers that contain dots 163 // For now, use a simple approach: normalize the whole string if it's a single identifier, 164 // or split and normalize each part 165 166 // Check if the entire name is a single quoted identifier (contains dot inside quotes) 167 if (isSingleQuotedIdentifier(qualifiedName)) { 168 return normalizeIdentifier(qualifiedName); 169 } 170 171 // Split by dots that are not inside quotes 172 String[] parts = splitQualifiedName(qualifiedName); 173 StringBuilder result = new StringBuilder(); 174 175 for (int i = 0; i < parts.length; i++) { 176 if (i > 0) { 177 result.append("."); 178 } 179 result.append(normalizeIdentifier(parts[i])); 180 } 181 182 return result.toString(); 183 } 184 185 /** 186 * Normalize a single identifier (without dots) for display. 187 * 188 * @param identifier the identifier 189 * @return the normalized identifier 190 */ 191 public String normalizeIdentifier(String identifier) { 192 if (identifier == null || identifier.isEmpty()) { 193 return identifier; 194 } 195 196 switch (mode) { 197 case SQL_RENDER: 198 // Preserve original form including delimiters 199 return identifier; 200 201 case CANONICAL: 202 // Apply vendor-specific folding (delegate to IdentifierService if needed) 203 // For now, just strip delimiters - full canonical would need IdentifierService 204 return stripDelimiters ? stripAllDelimiters(identifier) : identifier; 205 206 case DISPLAY: 207 default: 208 // Strip delimiters, preserve case 209 return stripDelimiters ? stripAllDelimiters(identifier) : identifier; 210 } 211 } 212 213 /** 214 * Strip all SQL delimiters from an identifier. 215 * 216 * <p>Handles multiple delimiter styles:</p> 217 * <ul> 218 * <li>Double quotes: {@code "name"} → {@code name}</li> 219 * <li>Backticks: {@code `name`} → {@code name}</li> 220 * <li>Square brackets: {@code [name]} → {@code name}</li> 221 * <li>Single quotes (for some vendors): {@code 'name'} → {@code name}</li> 222 * </ul> 223 * 224 * @param identifier the identifier (may be quoted) 225 * @return the identifier without delimiters 226 */ 227 public String stripAllDelimiters(String identifier) { 228 if (identifier == null || identifier.isEmpty()) { 229 return identifier; 230 } 231 232 String result = identifier; 233 234 // Strip double quotes 235 if (result.startsWith("\"") && result.endsWith("\"") && result.length() > 2) { 236 result = result.substring(1, result.length() - 1); 237 // Handle escaped double quotes inside 238 result = result.replace("\"\"", "\""); 239 } 240 // Strip backticks (MySQL, BigQuery, Databricks, Hive, Spark) 241 else if (result.startsWith("`") && result.endsWith("`") && result.length() > 2) { 242 result = result.substring(1, result.length() - 1); 243 // Handle escaped backticks inside 244 result = result.replace("``", "`"); 245 } 246 // SQL Server square brackets - strip them for deduplication 247 // Previously kept for special names like [1], [2], [3] from PIVOT, but this causes 248 // duplicates when same column is referenced with and without brackets (e.g., [col3] vs col3) 249 else if (result.startsWith("[") && result.endsWith("]") && result.length() > 2) { 250 result = result.substring(1, result.length() - 1); 251 // Handle escaped brackets inside 252 result = result.replace("]]", "]"); 253 } 254 // Strip single quotes (for string literals used as identifiers in some contexts) 255 else if (result.startsWith("'") && result.endsWith("'") && result.length() > 2) { 256 result = result.substring(1, result.length() - 1); 257 // Handle escaped single quotes inside 258 result = result.replace("''", "'"); 259 } 260 261 return result; 262 } 263 264 /** 265 * Check if the name is a single quoted identifier that may contain dots. 266 * A single quoted identifier has matching delimiters at start/end AND 267 * no unquoted dots inside (which would indicate a multi-part name). 268 * 269 * @param name the name to check 270 * @return true if it's a single quoted identifier 271 */ 272 private boolean isSingleQuotedIdentifier(String name) { 273 if (name == null || name.length() < 3) { 274 return false; 275 } 276 277 char first = name.charAt(0); 278 char last = name.charAt(name.length() - 1); 279 char expectedClose; 280 281 // Check for matching delimiters 282 if (first == '"' && last == '"') { 283 expectedClose = '"'; 284 } else if (first == '`' && last == '`') { 285 expectedClose = '`'; 286 } else if (first == '[' && last == ']') { 287 expectedClose = ']'; 288 } else { 289 return false; 290 } 291 292 // Check if there are any unquoted dots inside 293 // If so, this is a multi-part identifier, not a single quoted one 294 boolean inQuote = true; // We start inside the first quote 295 for (int i = 1; i < name.length() - 1; i++) { 296 char c = name.charAt(i); 297 if (c == expectedClose) { 298 inQuote = !inQuote; 299 } else if (c == '.' && !inQuote) { 300 // Found an unquoted dot - this is multi-part 301 return false; 302 } 303 } 304 305 return true; 306 } 307 308 /** 309 * Split a qualified name by dots, respecting quoted identifiers. 310 * 311 * @param qualifiedName the qualified name 312 * @return array of parts 313 */ 314 private String[] splitQualifiedName(String qualifiedName) { 315 if (qualifiedName == null || qualifiedName.isEmpty()) { 316 return new String[0]; 317 } 318 319 java.util.List<String> parts = new java.util.ArrayList<>(); 320 StringBuilder current = new StringBuilder(); 321 boolean inDoubleQuote = false; 322 boolean inBacktick = false; 323 boolean inBracket = false; 324 325 for (int i = 0; i < qualifiedName.length(); i++) { 326 char c = qualifiedName.charAt(i); 327 328 // Track quote state 329 if (c == '"' && !inBacktick && !inBracket) { 330 inDoubleQuote = !inDoubleQuote; 331 current.append(c); 332 } else if (c == '`' && !inDoubleQuote && !inBracket) { 333 inBacktick = !inBacktick; 334 current.append(c); 335 } else if (c == '[' && !inDoubleQuote && !inBacktick && !inBracket) { 336 inBracket = true; 337 current.append(c); 338 } else if (c == ']' && inBracket) { 339 inBracket = false; 340 current.append(c); 341 } else if (c == '.' && !inDoubleQuote && !inBacktick && !inBracket) { 342 // Split point 343 parts.add(current.toString()); 344 current = new StringBuilder(); 345 } else { 346 current.append(c); 347 } 348 } 349 350 // Add the last part 351 if (current.length() > 0) { 352 parts.add(current.toString()); 353 } 354 355 return parts.toArray(new String[0]); 356 } 357 358 /** 359 * Get a display name for a table, handling various TTable configurations. 360 * 361 * @param table the table 362 * @return the display name 363 */ 364 public String getTableDisplayName(TTable table) { 365 if (table == null) { 366 return null; 367 } 368 369 // Build the full qualified name from parts if available 370 StringBuilder sb = new StringBuilder(); 371 372 // Server/catalog 373 String server = table.getPrefixServer(); 374 if (server != null && !server.isEmpty()) { 375 sb.append(normalizeIdentifier(server)); 376 sb.append("."); 377 } 378 379 // Database 380 String database = table.getPrefixDatabase(); 381 if (database != null && !database.isEmpty()) { 382 sb.append(normalizeIdentifier(database)); 383 sb.append("."); 384 } 385 386 // Schema 387 String schema = table.getPrefixSchema(); 388 if (schema != null && !schema.isEmpty()) { 389 sb.append(normalizeIdentifier(schema)); 390 sb.append("."); 391 } 392 393 // Table name 394 String tableName = table.getName(); 395 if (tableName != null && !tableName.isEmpty()) { 396 sb.append(normalizeIdentifier(tableName)); 397 } 398 399 String result = sb.toString(); 400 401 // If we got nothing useful, fall back to getFullName 402 if (result.isEmpty() || result.equals(".")) { 403 String fullName = table.getFullName(); 404 if (fullName != null) { 405 return normalizeQualifiedName(fullName); 406 } 407 } 408 409 return result.isEmpty() ? null : result; 410 } 411 412 @Override 413 public String toString() { 414 return String.format("DisplayNameNormalizer{vendor=%s, mode=%s, stripDelimiters=%s}", 415 vendor, mode, stripDelimiters); 416 } 417}