001package gudusoft.gsqlparser.sqlenv; 002 003import gudusoft.gsqlparser.EDbVendor; 004 005import java.util.Objects; 006 007/** 008 * Vendor-aware quote codec for database identifiers (U2a of the persistent 009 * canonical-identity program). 010 * 011 * <p>This class is the ONE place that knows how each vendor delimits an 012 * identifier and how delimiter characters are escaped inside it. It converts 013 * between the two textual forms of an identifier: 014 * <ul> 015 * <li><b>lexical</b> — the spelling as it appears in SQL text, possibly 016 * wrapped in vendor delimiters (<code>"x"</code>, <code>[x]</code>, 017 * <code>`x`</code>, DAX <code>'x'</code>, Power Query 018 * <code>#"x"</code>, PostgreSQL <code>U&"x"</code>);</li> 019 * <li><b>stored</b> — the undelimited name as the catalog stores it.</li> 020 * </ul> 021 * 022 * <p><b>Role context matters.</b> Every operation takes the 023 * {@link IdentifierProfile} <i>and</i> the {@link ESQLDataObjectType}: quote 024 * rules are not purely per-vendor. DAX quotes <i>table</i> names with 025 * apostrophes while columns/measures use brackets; MySQL treats {@code "} as 026 * an identifier delimiter only under {@code ANSI_QUOTES} 027 * ({@link IdentifierProfile.VendorFlags#mysqlAnsiQuotes}); BigQuery backtick 028 * identifiers use backslash escape sequences, not doubling. 029 * 030 * <p><b>Strictness.</b> {@link #decodeLexical} is strict: input that starts 031 * with a recognized open delimiter but is not a well-formed quoted identifier 032 * (unterminated, unescaped inner delimiter, trailing garbage, invalid escape) 033 * throws {@link MalformedIdentifierException}. This is deliberate — the 034 * legacy first/last-char strip ({@code TBaseType.getTextWithoutQuoted}) 035 * silently accepted malformed spellings like {@code [a.b[} and produced 036 * corrupted stored names. An unrecognized escape sequence throws rather than 037 * being passed through — silently decoding it as literal text would corrupt 038 * the stored name — EXCEPT where the vendor itself defines unknown escapes 039 * as literal: ClickHouse deliberately preserves an unknown escape verbatim 040 * (backslash and character), and this codec follows that dialect behavior. 041 * 042 * <p><b>Transport totality.</b> {@link #encodeStored} is total: it accepts 043 * ANY string — empty, embedded delimiters, NUL, supplementary characters, 044 * unpaired surrogates — and always emits a spelling (quoted, except for 045 * vendors like SOQL with no quoted-identifier syntax, where the bare name is 046 * canonical) that {@link #decodeLexical} maps back to the exact input. Whether that spelling 047 * is also legal SQL for the vendor is a stricter, separately queryable 048 * property: {@link #isVendorValidSpelling}. (BigQuery forbids empty quoted 049 * identifiers, MySQL forbids NUL — such spellings still round-trip through 050 * the codec as transport encodings.) 051 * 052 * <p><b>Canonical encoding always quotes.</b> {@code encodeStored} does not 053 * try to decide whether the name could be spelled bare; emitting the quoted 054 * form is always semantically safe, total, and matches the internal 055 * producers this codec repairs (U2b), which always wrapped. 056 * 057 * <p><b>Recognition set provenance.</b> Canonical delimiters follow the 058 * legacy {@code TSQLEnv.delimitedChar} choice per vendor; additional accepted 059 * delimiters come from vendor documentation. Deltas vs the legacy 060 * {@code TSQLEnv.isDelimitedIdentifier} are deliberate corrections: 061 * mismatched open/close pairs are fixed ({@code [}…{@code ]}), and vendors 062 * the legacy switch mishandled get their documented forms (Spark/Databricks 063 * backticks, PostgreSQL {@code U&"…"}, per-role DAX). SQL Server keeps 064 * apostrophe RECOGNITION in the COLUMN role only: the U2a draft dropped 065 * {@code 'x'} as "not an identifier delimiter", but the string-literal 066 * column-alias form names a real column ({@code AS 'REGKEY'} creates column 067 * {@code REGKEY}) and the U5 swap proved lineage edges are lost when the 068 * spelling does not decode — see {@code RULES_MSSQL_COLUMN} (recognition 069 * only: excluded from {@link #isVendorValidSpelling}, never emitted). 070 * 071 * <p><b>Contract for prefix forms.</b> A PostgreSQL {@code U&"…"} lexical is 072 * decoded with the DEFAULT escape character {@code \}; a {@code UESCAPE} 073 * clause is a separate token that can never be part of a single identifier 074 * token, so it is outside this codec's contract (callers pass one token). 075 * 076 * <p><b>Versioning.</b> {@link #CODEC_REVISION} names the revision of the 077 * delimiter/escape table ("codec/1"). It participates in the persistent-key 078 * {@code policyId} (U3): a change to decoding behavior changes key payloads, 079 * so it must rotate keys. 080 * 081 * @since 4.1.5.6 082 */ 083public final class IdentifierCodec { 084 085 /** 086 * Revision of the per-vendor delimiter/escape table — the "1" of 087 * "codec/1". Bump when any decode/encode mapping changes; the U3 088 * persistent-key policyId includes this value, so a bump rotates keys. 089 */ 090 public static final int CODEC_REVISION = 1; 091 092 private IdentifierCodec() { 093 } 094 095 /** 096 * Thrown by {@link #decodeLexical} when the input starts with a 097 * recognized open delimiter but is not a well-formed quoted identifier. 098 */ 099 public static final class MalformedIdentifierException extends IllegalArgumentException { 100 private static final long serialVersionUID = 1L; 101 102 public MalformedIdentifierException(String message) { 103 super(message); 104 } 105 } 106 107 // ===== The per-vendor delimiter/escape table (revision: CODEC_REVISION) ===== 108 109 /** The close delimiter is escaped by doubling it; no other escapes. */ 110 private static final int ESC_DOUBLING = 0; 111 /** BigQuery backslash escape sequences (GoogleSQL string escapes). */ 112 private static final int ESC_BACKSLASH_BQ = 1; 113 /** No escape mechanism: the first close delimiter terminates (SQLite brackets). */ 114 private static final int ESC_NONE = 2; 115 /** ClickHouse: doubling AND backslash escapes both work. */ 116 private static final int ESC_CLICKHOUSE = 3; 117 /** Power Query M: doubling for the quote, {@code #(...)} character escapes. */ 118 private static final int ESC_POWERQUERY = 4; 119 /** PostgreSQL Unicode identifier: doubling for the quote, default {@code \} escapes. */ 120 private static final int ESC_PG_UNICODE = 5; 121 122 private static final class QuoteRule { 123 /** Opening delimiter; may be multi-character ({@code #"}, {@code U&"}). */ 124 final String open; 125 final char close; 126 final int escape; 127 /** Recognized and decoded, but NOT a vendor-valid identifier spelling 128 * (MSSQL {@code 'alias'}: single quotes delimit string literals per 129 * SET QUOTED_IDENTIFIER; the deprecated string-literal alias form 130 * names a real column, so equality must decode it, but emitting or 131 * blessing it as an identifier spelling would be wrong). */ 132 final boolean recognitionOnly; 133 134 QuoteRule(String open, char close, int escape) { 135 this(open, close, escape, false); 136 } 137 138 QuoteRule(String open, char close, int escape, boolean recognitionOnly) { 139 this.open = open; 140 this.close = close; 141 this.escape = escape; 142 this.recognitionOnly = recognitionOnly; 143 } 144 } 145 146 private static final QuoteRule DQUOTE = new QuoteRule("\"", '"', ESC_DOUBLING); 147 private static final QuoteRule BRACKET = new QuoteRule("[", ']', ESC_DOUBLING); 148 private static final QuoteRule BACKTICK = new QuoteRule("`", '`', ESC_DOUBLING); 149 private static final QuoteRule SQUOTE = new QuoteRule("'", '\'', ESC_DOUBLING); 150 private static final QuoteRule BQ_BACKTICK = new QuoteRule("`", '`', ESC_BACKSLASH_BQ); 151 private static final QuoteRule SQLITE_BRACKET = new QuoteRule("[", ']', ESC_NONE); 152 private static final QuoteRule CH_DQUOTE = new QuoteRule("\"", '"', ESC_CLICKHOUSE); 153 private static final QuoteRule CH_BACKTICK = new QuoteRule("`", '`', ESC_CLICKHOUSE); 154 private static final QuoteRule PQ_QUOTE = new QuoteRule("#\"", '"', ESC_POWERQUERY); 155 private static final QuoteRule PG_UNICODE_UPPER = new QuoteRule("U&\"", '"', ESC_PG_UNICODE); 156 private static final QuoteRule PG_UNICODE_LOWER = new QuoteRule("u&\"", '"', ESC_PG_UNICODE); 157 158 /** ANSI default: double quotes, doubling. */ 159 private static final QuoteRule[] RULES_ANSI = {DQUOTE}; 160 /** PostgreSQL / EDB: double quotes canonical; {@code U&"…"} Unicode form accepted. */ 161 private static final QuoteRule[] RULES_PG = {DQUOTE, PG_UNICODE_UPPER, PG_UNICODE_LOWER}; 162 /** MSSQL string-literal alias form: recognized/decoded, never valid or emitted. */ 163 private static final QuoteRule SQUOTE_ALIAS = new QuoteRule("'", '\'', ESC_DOUBLING, true); 164 165 /** 166 * SQL Server / Azure SQL: brackets canonical (QUOTENAME default), double 167 * quotes under QUOTED_IDENTIFIER ON (the server default). 168 */ 169 private static final QuoteRule[] RULES_MSSQL = {BRACKET, DQUOTE}; 170 /** 171 * SQL Server / Azure SQL COLUMN role additionally accepts the apostrophe 172 * form for RECOGNITION/DECODE only: {@code 'x'} is the deprecated-but- 173 * supported string-literal COLUMN-alias syntax, and the identifier it 174 * names is the unquoted inner text — {@code SELECT c AS 'REGKEY'} creates 175 * column {@code REGKEY}, so the alias spelling must decode to meet its 176 * references (the U5 dataflow-golden gate lost real lineage edges without 177 * this; the U2a draft dropped the form entirely and was wrong). Scoped to 178 * the column role — single quotes are string-literal delimiters in every 179 * other identifier position (SET QUOTED_IDENTIFIER) — and excluded from 180 * {@link #isVendorValidSpelling} via {@code recognitionOnly}. Never the 181 * canonical encoding — {@code encodeStored} still emits brackets 182 * (rules[0]). 183 */ 184 private static final QuoteRule[] RULES_MSSQL_COLUMN = {BRACKET, DQUOTE, SQUOTE_ALIAS}; 185 /** MySQL family, default sql_mode: backtick only. */ 186 private static final QuoteRule[] RULES_MYSQL = {BACKTICK}; 187 /** MySQL / OceanBase with ANSI_QUOTES: double quotes also delimit identifiers. */ 188 private static final QuoteRule[] RULES_MYSQL_ANSI = {BACKTICK, DQUOTE}; 189 /** Hive / Impala / Flink / Couchbase: backtick, doubling. */ 190 private static final QuoteRule[] RULES_BACKTICK = {BACKTICK}; 191 /** 192 * SparkSQL / Databricks: backtick canonical; double quotes also accepted — 193 * both engines have ANSI modes where {@code "x"} delimits an identifier 194 * (Spark requires BOTH {@code spark.sql.ansi.enabled} AND 195 * {@code spark.sql.ansi.doubleQuotedIdentifiers}; Databricks 196 * {@code double_quoted_identifiers}), and a double-quoted spelling that 197 * reaches an identifier position denotes the inner name. Also preserves 198 * the legacy recognizer's behavior for these two vendors (it accepted 199 * {@code "x"} — U5 found display names regress without it). 200 */ 201 private static final QuoteRule[] RULES_SPARK = {BACKTICK, DQUOTE}; 202 /** BigQuery: backtick with backslash escape sequences. */ 203 private static final QuoteRule[] RULES_BIGQUERY = {BQ_BACKTICK}; 204 /** 205 * Athena: backtick canonical (matches legacy {@code delimitedChar} and 206 * Hive-style DDL); double quotes accepted (the Presto/Trino DML engine). 207 */ 208 private static final QuoteRule[] RULES_ATHENA = {BACKTICK, DQUOTE}; 209 /** DAX table names: apostrophes, doubling. */ 210 private static final QuoteRule[] RULES_DAX_TABLE = {SQUOTE}; 211 /** DAX columns/measures: brackets, {@code ]]} doubling. */ 212 private static final QuoteRule[] RULES_DAX_COLUMN = {BRACKET}; 213 /** 214 * SQLite: double quotes canonical; brackets and backticks accepted 215 * (documented compatibility forms). SQLite brackets have NO escape — 216 * the first {@code ]} terminates the identifier. 217 */ 218 private static final QuoteRule[] RULES_SQLITE = {DQUOTE, SQLITE_BRACKET, BACKTICK}; 219 /** Sybase ASE: double quotes canonical (quoted_identifier), T-SQL brackets accepted. */ 220 private static final QuoteRule[] RULES_SYBASE = {DQUOTE, BRACKET}; 221 /** ClickHouse: double quotes canonical, backticks accepted; doubling OR backslash escapes. */ 222 private static final QuoteRule[] RULES_CLICKHOUSE = {CH_DQUOTE, CH_BACKTICK}; 223 /** MDX: brackets with {@code ]]} doubling. */ 224 private static final QuoteRule[] RULES_MDX = {BRACKET}; 225 /** Power Query M: {@code #"…"} with doubling and {@code #(...)} escapes. */ 226 private static final QuoteRule[] RULES_POWERQUERY = {PQ_QUOTE}; 227 /** 228 * Salesforce SOQL has NO quoted-identifier syntax at all: nothing is 229 * recognized as quoted, and the canonical encoding of a stored name is 230 * the bare name itself (decode is the identity, so transport round-trip 231 * still holds exactly). 232 */ 233 private static final QuoteRule[] RULES_NONE = {}; 234 235 /** 236 * 128-bit set of the first character of every open delimiter across ALL 237 * vendor rule tables — a one-branch rejection gate for {@link #isQuoted}'s 238 * dominant input (an unquoted identifier, U5 gate-3 hot path). Computed 239 * from the tables themselves so it can never drift from them; construction 240 * fails if a future rule ever starts with a non-ASCII character (the gate 241 * would then need widening). 242 */ 243 private static final long[] OPEN_FIRST = buildOpenFirst(); 244 245 private static long[] buildOpenFirst() { 246 QuoteRule[][] all = {RULES_ANSI, RULES_PG, RULES_MSSQL, RULES_MSSQL_COLUMN, 247 RULES_MYSQL, RULES_MYSQL_ANSI, RULES_BACKTICK, RULES_SPARK, 248 RULES_BIGQUERY, RULES_ATHENA, RULES_DAX_TABLE, RULES_DAX_COLUMN, 249 RULES_SQLITE, RULES_SYBASE, RULES_CLICKHOUSE, RULES_MDX, 250 RULES_POWERQUERY}; 251 long[] bits = new long[2]; 252 for (QuoteRule[] rules : all) { 253 for (QuoteRule r : rules) { 254 char c = r.open.charAt(0); 255 if (c >= 128) { 256 throw new IllegalStateException("Non-ASCII open delimiter '" 257 + r.open + "' - widen the isQuoted first-char gate"); 258 } 259 bits[c >>> 6] |= 1L << (c & 63); 260 } 261 } 262 return bits; 263 } 264 265 /** 266 * Whether this vendor's quote recognition/decoding consults 267 * {@link IdentifierProfile.VendorFlags#mysqlAnsiQuotes} — i.e. exactly the 268 * vendors whose {@link #rulesFor} branch reads the flag. Consumed by the 269 * V1 policy descriptor ({@code IdentifierPolicyV1}): the flag is part of 270 * payload-producing behavior only where it is honored, so including it 271 * elsewhere would rotate keys it cannot affect. Keep in sync with 272 * {@link #rulesFor}. 273 */ 274 static boolean consultsAnsiQuotes(EDbVendor vendor) { 275 return vendor == EDbVendor.dbvmysql || vendor == EDbVendor.dbvoceanbase; 276 } 277 278 private static QuoteRule[] rulesFor(IdentifierProfile profile, ESQLDataObjectType objectType) { 279 EDbVendor vendor = profile.getVendor(); 280 switch (vendor) { 281 case dbvmssql: 282 case dbvazuresql: 283 return objectType == ESQLDataObjectType.dotColumn 284 ? RULES_MSSQL_COLUMN : RULES_MSSQL; 285 286 case dbvaccess: 287 // Access names are delimited with brackets, and Access SQL goes through the 288 // MSSQL lexer (TGSqlParser), which also reads a double-quoted name as one. 289 return RULES_MSSQL; 290 291 case dbvmysql: 292 case dbvoceanbase: // Phase 1 mirrors MySQL, like its IdentifierRules seeding 293 return profile.getFlags().mysqlAnsiQuotes ? RULES_MYSQL_ANSI : RULES_MYSQL; 294 295 case dbvdoris: 296 case dbvstarrocks: 297 // MySQL-compatible engines WITHOUT documented ANSI_QUOTES 298 // support: the flag is deliberately not honored here. 299 return RULES_MYSQL; 300 301 case dbvbigquery: 302 return RULES_BIGQUERY; 303 304 case dbvhive: 305 case dbvimpala: 306 case dbvflink: 307 case dbvcouchbase: 308 return RULES_BACKTICK; 309 310 case dbvsparksql: 311 case dbvdatabricks: 312 return RULES_SPARK; 313 314 case dbvathena: 315 return RULES_ATHENA; 316 317 case dbvdax: 318 // Explicit per-role table (round 3 of the plan review requires 319 // quoting to depend on the actual syntactic role, not the 320 // fold-policy group): columns and measures are bracketed; 321 // table names are apostrophe-quoted. Roles the DAX object 322 // model does not have (schema, catalog, procedure, ...) are 323 // treated as table-style names — DAX has no quoted spelling 324 // for them, and table rules are the only name-position form. 325 switch (objectType) { 326 case dotColumn: 327 case dotFunction: 328 case dotRoutine: 329 return RULES_DAX_COLUMN; 330 case dotTable: 331 default: 332 return RULES_DAX_TABLE; 333 } 334 335 case dbvsqlite: 336 return RULES_SQLITE; 337 338 case dbvsybase: 339 return RULES_SYBASE; 340 341 case dbvsybasease: 342 // Identical rules to the base by design (HC-9): the fork 343 // must resolve names exactly as dbvsybase does, or every 344 // downstream comparison changes meaning. 345 return RULES_SYBASE; 346 347 case dbvsqlanywhere: 348 // Same reasoning, independent block per HC-5. 349 return RULES_SYBASE; 350 351 case dbvsybaseiq: 352 // Same again, independent block per HC-5. 353 return RULES_SYBASE; 354 355 case dbvclickhouse: 356 return RULES_CLICKHOUSE; 357 358 case dbvmdx: 359 return RULES_MDX; 360 361 case dbvpowerquery: 362 return RULES_POWERQUERY; 363 364 case dbvpostgresql: 365 case dbvedb: 366 return RULES_PG; 367 368 case dbvsoql: 369 return RULES_NONE; 370 371 default: 372 return RULES_ANSI; 373 } 374 } 375 376 private static QuoteRule ruleForOpen(QuoteRule[] rules, String s) { 377 for (QuoteRule r : rules) { 378 if (s.startsWith(r.open)) { 379 return r; 380 } 381 } 382 return null; 383 } 384 385 // ===== Public API ===== 386 387 /** 388 * Decode one lexical identifier to its stored text. 389 * 390 * <p>If the input does not start with a delimiter this vendor/role 391 * recognizes, it is an unquoted identifier and is returned unchanged 392 * (case folding is NOT this codec's job — see 393 * {@link IdentifierRules.CaseFold}). If it does start with a recognized 394 * open delimiter, it must be a complete well-formed quoted identifier; 395 * anything else throws. 396 * 397 * @param profile vendor identifier profile (non-null) 398 * @param objectType syntactic role of the identifier (non-null; DAX 399 * quote rules depend on it) 400 * @param lexical the identifier spelling as written in SQL (non-null) 401 * @return the stored (undelimited, unescaped) text 402 * @throws MalformedIdentifierException if {@code lexical} starts with a 403 * recognized open delimiter but is not well-formed 404 */ 405 public static String decodeLexical(IdentifierProfile profile, 406 ESQLDataObjectType objectType, 407 String lexical) { 408 Objects.requireNonNull(profile, "profile"); 409 Objects.requireNonNull(objectType, "objectType"); 410 Objects.requireNonNull(lexical, "lexical"); 411 if (lexical.isEmpty()) { 412 return lexical; 413 } 414 QuoteRule rule = ruleForOpen(rulesFor(profile, objectType), lexical); 415 if (rule == null) { 416 return lexical; 417 } 418 switch (rule.escape) { 419 case ESC_BACKSLASH_BQ: 420 return decodeBigQuery(lexical, rule); 421 case ESC_NONE: 422 return decodeNoEscape(lexical, rule); 423 case ESC_CLICKHOUSE: 424 return decodeClickHouse(lexical, rule); 425 case ESC_POWERQUERY: 426 return decodePowerQuery(lexical, rule); 427 case ESC_PG_UNICODE: 428 return decodePgUnicode(lexical, rule); 429 default: 430 return decodeDoubling(lexical, rule); 431 } 432 } 433 434 /** 435 * Encode stored text to a canonical lexical spelling. 436 * 437 * <p>Total as a TRANSPORT encoding: any string — empty, delimiters, NUL, 438 * supplementary, unpaired surrogates — round-trips exactly through 439 * {@link #decodeLexical}. The result is quoted with the vendor's 440 * canonical delimiter for the role; for a vendor with no 441 * quoted-identifier syntax at all (SOQL) the bare name is the canonical 442 * spelling and encode is the identity. Vendor-valid-SQL is a stricter, 443 * separately queryable property: {@link #isVendorValidSpelling}. 444 * 445 * @param profile vendor identifier profile (non-null) 446 * @param objectType syntactic role of the identifier (non-null) 447 * @param stored the stored (undelimited) text (non-null) 448 * @return the canonical quoted spelling 449 */ 450 public static String encodeStored(IdentifierProfile profile, 451 ESQLDataObjectType objectType, 452 String stored) { 453 Objects.requireNonNull(profile, "profile"); 454 Objects.requireNonNull(objectType, "objectType"); 455 Objects.requireNonNull(stored, "stored"); 456 QuoteRule[] rules = rulesFor(profile, objectType); 457 if (rules.length == 0) { 458 // no quoted-identifier syntax (SOQL): the bare name IS the 459 // canonical lexical spelling; decode is the identity 460 return stored; 461 } 462 QuoteRule rule = rules[0]; 463 StringBuilder sb = new StringBuilder(stored.length() + rule.open.length() + 1); 464 sb.append(rule.open); 465 for (int i = 0; i < stored.length(); i++) { 466 char c = stored.charAt(i); 467 switch (rule.escape) { 468 case ESC_BACKSLASH_BQ: 469 // GoogleSQL's lexer excludes raw CR/LF from backtick 470 // identifiers: emit their escape forms 471 if (c == (char) 0x0D) { 472 sb.append('\\').append('r'); 473 break; 474 } 475 if (c == (char) 0x0A) { 476 sb.append('\\').append('n'); 477 break; 478 } 479 if (c == '\\' || c == rule.close) { 480 sb.append('\\'); 481 } 482 sb.append(c); 483 break; 484 case ESC_CLICKHOUSE: 485 // backslash is escape-significant on decode, so escape it; 486 // the close delimiter is doubled 487 if (c == '\\') { 488 sb.append('\\').append('\\'); 489 } else if (c == rule.close) { 490 sb.append(rule.close).append(rule.close); 491 } else { 492 sb.append(c); 493 } 494 break; 495 case ESC_POWERQUERY: 496 // '#' could begin a '#(' escape on decode, so always 497 // escape it as #(#); the quote is doubled 498 if (c == '#') { 499 sb.append("#(#)"); 500 } else if (c == rule.close) { 501 sb.append(rule.close).append(rule.close); 502 } else { 503 sb.append(c); 504 } 505 break; 506 default: 507 // ESC_DOUBLING (canonical rules are never ESC_NONE or 508 // ESC_PG_UNICODE: those are recognition/decode-only forms) 509 if (c == rule.close) { 510 sb.append(rule.close); 511 } 512 sb.append(c); 513 break; 514 } 515 } 516 sb.append(rule.close); 517 return sb.toString(); 518 } 519 520 /** 521 * Does this lexical spelling denote a QUOTED identifier for the 522 * vendor/role — i.e. does it start with a recognized open delimiter 523 * (which may be a multi-character prefix such as {@code #"} or 524 * {@code U&"})? 525 * 526 * <p>Recognition only: a {@code true} result does NOT imply the spelling 527 * is well-formed ({@code [a.b[} is recognized as quoted, and 528 * {@link #decodeLexical} then rejects it). {@code null} and the empty 529 * string are not quoted. 530 * 531 * @param profile vendor identifier profile (non-null) 532 * @param objectType syntactic role of the identifier (non-null; DAX 533 * quote RECOGNITION is role-dependent — apostrophes 534 * open quoted table names only) 535 * @param s the spelling to test (may be null) 536 * @return true iff {@code s} starts with a recognized open delimiter 537 */ 538 public static boolean isQuoted(IdentifierProfile profile, 539 ESQLDataObjectType objectType, 540 String s) { 541 Objects.requireNonNull(profile, "profile"); 542 Objects.requireNonNull(objectType, "objectType"); 543 if (s == null || s.isEmpty()) { 544 return false; 545 } 546 // Fast rejection for the dominant case (unquoted identifier): no vendor's 547 // open delimiter starts with this character, so skip the rule-table walk. 548 char c0 = s.charAt(0); 549 if (c0 >= 128 || (OPEN_FIRST[c0 >>> 6] & (1L << (c0 & 63))) == 0) { 550 return false; 551 } 552 return ruleForOpen(rulesFor(profile, objectType), s) != null; 553 } 554 555 /** 556 * Must a name in this role always be written delimited? True only where the delimiters are 557 * the syntax of the reference rather than optional quoting: a DAX column is always written 558 * {@code [Name]}, and a bare word in that position is a table or a function. A renderer must 559 * keep such delimiters even when the name would denote the same object without them. 560 * 561 * @param profile vendor identifier profile (non-null) 562 * @param objectType syntactic role of the identifier (non-null) 563 * @return true iff no bare spelling exists for the role 564 */ 565 public static boolean requiresDelimiters(IdentifierProfile profile, ESQLDataObjectType objectType) { 566 Objects.requireNonNull(profile, "profile"); 567 Objects.requireNonNull(objectType, "objectType"); 568 return profile.getVendor() == EDbVendor.dbvdax && objectType == ESQLDataObjectType.dotColumn; 569 } 570 571 /** 572 * Does this vendor/role have ANY quoted-identifier syntax? {@code false} 573 * only for vendors like SOQL where {@link #encodeStored} is the identity 574 * — callers that need a delimiter for internal-key purposes (protecting 575 * dots from qualified-name splitting) must supply their own fallback. 576 * 577 * @param profile vendor identifier profile (non-null) 578 * @param objectType syntactic role of the identifier (non-null) 579 * @return true iff at least one quote form exists for the vendor/role 580 */ 581 public static boolean hasQuotedForm(IdentifierProfile profile, 582 ESQLDataObjectType objectType) { 583 Objects.requireNonNull(profile, "profile"); 584 Objects.requireNonNull(objectType, "objectType"); 585 return rulesFor(profile, objectType).length > 0; 586 } 587 588 /** 589 * Is this lexical spelling valid SQL for the vendor — a stricter 590 * property than being transport-decodable? 591 * 592 * <p><b>Scope: character- and delimiter-level validity only.</b> The 593 * check affirms spellings whose delimiter form and character content are 594 * valid under the vendor's documented identifier grammar. Reserved-word 595 * status, length limits, and semantic restrictions (e.g. a name being 596 * taken) are explicitly OUT of scope — a {@code true} result does not 597 * promise the bare word {@code SELECT} is usable unquoted. Within its 598 * scope the check is conservative: it may return {@code false} for an 599 * exotic-but-valid spelling, never {@code true} for a spelling whose 600 * characters or delimiters are known-invalid. Enforced facts: 601 * <ul> 602 * <li>a quoted spelling must be well-formed ({@link #decodeLexical} 603 * accepts it) and — except for SQLite and Power Query M, which 604 * both document zero-length quoted identifiers as legal — its 605 * payload must be non-empty (BigQuery documents the empty-backtick 606 * ban explicitly);</li> 607 * <li>no payload may contain an unpaired surrogate (not a character, 608 * so no vendor identifier grammar admits it) or NUL (MySQL documents 609 * the ban explicitly; no vendor's native interface transports NUL 610 * inside a name, so the conservative answer is {@code false} 611 * everywhere);</li> 612 * <li>MySQL family: identifier characters are limited to the BMP 613 * (U+0001..U+FFFF) — supplementary characters are invalid even 614 * quoted; an unquoted identifier may not consist solely of 615 * digits;</li> 616 * <li>an unquoted spelling must match the vendor's (and role's) 617 * unquoted-identifier grammar as modeled per vendor: the strictest 618 * core (first code point an ASCII letter; then ASCII letters, 619 * digits, {@code _}) plus only documented vendor extensions — 620 * e.g. {@code _} start for PostgreSQL/BigQuery/SQL Server but NOT 621 * Oracle or Doris table names; {@code $}/{@code #} body characters 622 * where documented; MySQL's extended U+0080..U+FFFF range; 623 * non-ASCII letters only where unconditionally documented 624 * (charset-dependent vendors like Oracle stay conservative).</li> 625 * </ul> 626 * 627 * @param profile vendor identifier profile (non-null) 628 * @param objectType syntactic role of the identifier (non-null) 629 * @param lexical the spelling to test (may be null; null/empty is invalid) 630 * @return true iff the spelling is known-valid at the character/delimiter level 631 */ 632 public static boolean isVendorValidSpelling(IdentifierProfile profile, 633 ESQLDataObjectType objectType, 634 String lexical) { 635 Objects.requireNonNull(profile, "profile"); 636 Objects.requireNonNull(objectType, "objectType"); 637 if (lexical == null || lexical.isEmpty()) { 638 return false; 639 } 640 EDbVendor vendor = profile.getVendor(); 641 if (isQuoted(profile, objectType, lexical)) { 642 QuoteRule rule = ruleForOpen(rulesFor(profile, objectType), lexical); 643 if (rule != null && rule.recognitionOnly) { 644 // Recognized for equality/decoding only (MSSQL 'alias' form) — 645 // not a valid identifier spelling for the vendor. 646 return false; 647 } 648 String payload; 649 try { 650 payload = decodeLexical(profile, objectType, lexical); 651 } catch (IllegalArgumentException e) { 652 return false; 653 } 654 if (payload.isEmpty() && !allowsEmptyQuoted(vendor)) { 655 return false; 656 } 657 if (hasUnpairedSurrogate(payload)) { 658 return false; 659 } 660 if (payload.indexOf(0x0000) >= 0) { 661 return false; 662 } 663 if (hasSupplementary(payload) && isBmpOnlyVendor(vendor)) { 664 // MySQL documents BMP-only identifier characters; SQL Server 665 // does not support supplementary characters in metadata names 666 return false; 667 } 668 if (vendor == EDbVendor.dbvpowerquery && hasRawControlChar(lexical)) { 669 // M requires non-graphic characters (tab/CR/LF/...) to be 670 // written as #(...) escapes; RAW control characters in the 671 // lexical body are invalid even though transport decode 672 // preserves them 673 return false; 674 } 675 if (vendor == EDbVendor.dbvbigquery 676 && (lexical.indexOf(0x0D) >= 0 || lexical.indexOf(0x0A) >= 0)) { 677 // GoogleSQL's lexer excludes raw CR/LF from backtick 678 // identifiers — they must be spelled \r / \n 679 return false; 680 } 681 return true; 682 } 683 return isValidUnquoted(vendor, profile.groupOf(objectType), lexical); 684 } 685 686 private static boolean isBmpOnlyVendor(EDbVendor vendor) { 687 return isMySqlFamily(vendor) 688 || vendor == EDbVendor.dbvmssql 689 || vendor == EDbVendor.dbvazuresql; 690 } 691 692 /** Any raw ISO control character in the spelling (escaped forms like {@code #(tab)} contain none). */ 693 private static boolean hasRawControlChar(String lexical) { 694 for (int i = 0; i < lexical.length(); i++) { 695 if (Character.isISOControl(lexical.charAt(i))) { 696 return true; 697 } 698 } 699 return false; 700 } 701 702 /** SQLite and Power Query M document zero-length quoted identifiers as legal. */ 703 private static boolean allowsEmptyQuoted(EDbVendor vendor) { 704 return vendor == EDbVendor.dbvsqlite || vendor == EDbVendor.dbvpowerquery; 705 } 706 707 // ===== Decoding ===== 708 709 private static String decodeDoubling(String lexical, QuoteRule rule) { 710 int n = lexical.length(); 711 int start = rule.open.length(); 712 if (n < start + 1 || lexical.charAt(n - 1) != rule.close) { 713 throw new MalformedIdentifierException( 714 "Quoted identifier is not terminated by '" + rule.close + "': " + lexical); 715 } 716 int end = n - 1; 717 StringBuilder sb = new StringBuilder(end - start); 718 int i = start; 719 while (i < end) { 720 char c = lexical.charAt(i); 721 if (c == rule.close) { 722 if (i + 1 < end && lexical.charAt(i + 1) == rule.close) { 723 sb.append(rule.close); 724 i += 2; 725 } else { 726 throw new MalformedIdentifierException( 727 "Unescaped close delimiter '" + rule.close + "' at index " + i 728 + " in quoted identifier: " + lexical); 729 } 730 } else { 731 sb.append(c); 732 i++; 733 } 734 } 735 return sb.toString(); 736 } 737 738 /** SQLite bracket form: no escape exists; the FIRST close bracket must be the last char. */ 739 private static String decodeNoEscape(String lexical, QuoteRule rule) { 740 int n = lexical.length(); 741 int start = rule.open.length(); 742 int idx = lexical.indexOf(rule.close, start); 743 if (idx < 0) { 744 throw new MalformedIdentifierException( 745 "Quoted identifier is not terminated by '" + rule.close + "': " + lexical); 746 } 747 if (idx != n - 1) { 748 throw new MalformedIdentifierException( 749 "Content after closing delimiter at index " + idx 750 + " in quoted identifier (this form has no escape): " + lexical); 751 } 752 return lexical.substring(start, n - 1); 753 } 754 755 private static String decodeBigQuery(String lexical, QuoteRule rule) { 756 int n = lexical.length(); 757 int start = rule.open.length(); 758 StringBuilder sb = new StringBuilder(Math.max(0, n - start - 1)); 759 int i = start; 760 while (i < n) { 761 char c = lexical.charAt(i); 762 if (c == rule.close) { 763 if (i == n - 1) { 764 return sb.toString(); 765 } 766 throw new MalformedIdentifierException( 767 "Content after closing delimiter at index " + i 768 + " in quoted identifier: " + lexical); 769 } 770 if (c == '\\') { 771 i = decodeBigQueryEscape(lexical, i, sb); 772 } else { 773 sb.append(c); 774 i++; 775 } 776 } 777 throw new MalformedIdentifierException( 778 "Quoted identifier is not terminated by '" + rule.close + "': " + lexical); 779 } 780 781 /** 782 * Decode one BigQuery escape sequence starting at {@code start} (which 783 * points at the backslash). Appends the decoded character(s) to 784 * {@code sb} and returns the index just past the sequence. 785 * 786 * <p>GoogleSQL escape set: {@code \a \b \f \n \r \t \v \\ \? \" \' \`}, 787 * {@code \ooo} (exactly 3 octal digits, max {@code \377}), 788 * {@code \xhh}/{@code \Xhh} (exactly 2 ASCII hex digits), 789 * <code>\uhhhh</code> (exactly 4 ASCII hex digits, no surrogates), 790 * {@code \Uhhhhhhhh} (exactly 8 ASCII hex digits, valid non-surrogate 791 * code point). 792 */ 793 private static int decodeBigQueryEscape(String lexical, int start, StringBuilder sb) { 794 int n = lexical.length(); 795 if (start + 1 >= n) { 796 throw new MalformedIdentifierException( 797 "Dangling backslash at index " + start + " in quoted identifier: " + lexical); 798 } 799 char e = lexical.charAt(start + 1); 800 switch (e) { 801 case 'a': sb.append((char) 0x07); return start + 2; 802 case 'b': sb.append((char) 0x08); return start + 2; 803 case 'f': sb.append((char) 0x0C); return start + 2; 804 case 'n': sb.append((char) 0x0A); return start + 2; 805 case 'r': sb.append((char) 0x0D); return start + 2; 806 case 't': sb.append((char) 0x09); return start + 2; 807 case 'v': sb.append((char) 0x0B); return start + 2; 808 case '\\': 809 case '?': 810 case '"': 811 case '\'': 812 case '`': 813 sb.append(e); 814 return start + 2; 815 case 'x': 816 case 'X': { 817 int v = (int) parseAsciiHex(lexical, start + 2, 2); 818 sb.append((char) v); 819 return start + 4; 820 } 821 case 'u': { 822 int v = (int) parseAsciiHex(lexical, start + 2, 4); 823 if (v >= 0xD800 && v <= 0xDFFF) { 824 throw new MalformedIdentifierException( 825 "Unicode escape encodes a surrogate at index " + start 826 + " in quoted identifier: " + lexical); 827 } 828 sb.append((char) v); 829 return start + 6; 830 } 831 case 'U': { 832 // 8 hex digits can exceed Integer.MAX_VALUE: accumulate in long 833 long v = parseAsciiHex(lexical, start + 2, 8); 834 if (v > 0x10FFFFL || (v >= 0xD800L && v <= 0xDFFFL)) { 835 throw new MalformedIdentifierException( 836 "Unicode escape is not a valid code point at index " + start 837 + " in quoted identifier: " + lexical); 838 } 839 sb.appendCodePoint((int) v); 840 return start + 10; 841 } 842 default: 843 if (e >= '0' && e <= '7') { 844 int v = parseOctal(lexical, start + 1); 845 sb.append((char) v); 846 return start + 4; 847 } 848 throw new MalformedIdentifierException( 849 "Invalid escape sequence '\\" + e + "' at index " + start 850 + " in quoted identifier: " + lexical); 851 } 852 } 853 854 /** 855 * ClickHouse quoted identifiers: the close delimiter may be doubled, and 856 * backslash escapes work with ClickHouse's parser semantics 857 * (ReadHelpers.cpp {@code parseComplexEscapeSequence}): lowercase 858 * {@code \xHH} only (uppercase {@code \X} is NOT an escape introducer), 859 * {@code \N} decodes to NOTHING, the mapped set is 860 * {@code \a \b \e \f \n \r \t \v \0 \\ \' \" \` \/ \=}, and an UNKNOWN 861 * escape keeps both the backslash and the character (so {@code \q} 862 * decodes to {@code \q}, and {@code \X41} to {@code \X41}). 863 */ 864 /** 865 * ClickHouse STRING LITERAL decoder (single- or double-quoted), sharing 866 * the escape semantics of {@link #decodeClickHouse} (ReadHelpers.cpp 867 * {@code parseComplexEscapeSequence}): doubled quotes, lowercase 868 * {@code \xHH} only, {@code \N} decodes to nothing, the mapped set is 869 * {@code \a \b \e \f \n \r \t \v \0 \\ \' \" \` \/ \=}, 870 * and an unknown escape keeps both characters. {@code \xHH} sequences 871 * are BYTES: the decoded stream is recombined as UTF-8, so 872 * {@code '\xC3\xA9'} is {@code é}, not two mojibake characters. 873 * 874 * @return the decoded value, or null when the literal is malformed or 875 * its bytes are not valid UTF-8 — callers must treat null as 876 * "identity cannot be represented safely" and fall back. 877 */ 878 public static String decodeClickHouseStringLiteral(String rawLiteral) { 879 if (rawLiteral == null || rawLiteral.length() < 2) { 880 return null; 881 } 882 char quote = rawLiteral.charAt(0); 883 if ((quote != '\'' && quote != '"') || rawLiteral.charAt(rawLiteral.length() - 1) != quote) { 884 return null; 885 } 886 java.io.ByteArrayOutputStream out = new java.io.ByteArrayOutputStream(rawLiteral.length()); 887 // Literal characters accumulate in spans and are encoded as COMPLETE 888 // code points with a REPORT-strict encoder: encoding one char at a 889 // time splits surrogate pairs into isolated surrogates ('db.😀' 890 // became db.?? — a false identity), and an isolated surrogate must 891 // reject the literal rather than corrupt it. 892 StringBuilder span = new StringBuilder(); 893 int n = rawLiteral.length() - 1; // exclusive of closing quote 894 int i = 1; 895 while (i < n) { 896 char c = rawLiteral.charAt(i); 897 if (c == quote) { 898 if (i + 1 < n && rawLiteral.charAt(i + 1) == quote) { 899 span.append(quote); 900 i += 2; 901 continue; 902 } 903 return null; // stray quote inside the body 904 } 905 if (c != '\\') { 906 span.append(c); 907 i++; 908 continue; 909 } 910 if (i + 1 >= n) { 911 return null; // dangling backslash 912 } 913 char e = rawLiteral.charAt(i + 1); 914 switch (e) { 915 case 'a': if (!flushSpan(span, out)) return null; out.write(0x07); break; 916 case 'b': if (!flushSpan(span, out)) return null; out.write(0x08); break; 917 case 'e': if (!flushSpan(span, out)) return null; out.write(0x1B); break; 918 case 'f': if (!flushSpan(span, out)) return null; out.write(0x0C); break; 919 case 'n': if (!flushSpan(span, out)) return null; out.write(0x0A); break; 920 case 'r': if (!flushSpan(span, out)) return null; out.write(0x0D); break; 921 case 't': if (!flushSpan(span, out)) return null; out.write(0x09); break; 922 case 'v': if (!flushSpan(span, out)) return null; out.write(0x0B); break; 923 case '0': if (!flushSpan(span, out)) return null; out.write(0x00); break; 924 case 'N': break; // \N decodes to nothing 925 case '\\': 926 case '\'': 927 case '"': 928 case '`': 929 case '/': 930 case '=': 931 span.append(e); 932 break; 933 case 'x': { 934 if (i + 3 > n) { 935 return null; 936 } 937 if (!flushSpan(span, out)) return null; 938 try { 939 out.write((int) parseAsciiHex(rawLiteral, i + 2, 2)); 940 } catch (Exception malformed) { 941 return null; 942 } 943 i += 4; 944 continue; 945 } 946 default: 947 // unknown escape keeps BOTH characters; the escaped char 948 // joins the span so a following low surrogate stays 949 // adjacent to its pair 950 span.append('\\').append(e); 951 break; 952 } 953 i += 2; 954 } 955 if (!flushSpan(span, out)) { 956 return null; 957 } 958 try { 959 return java.nio.charset.StandardCharsets.UTF_8.newDecoder() 960 .onMalformedInput(java.nio.charset.CodingErrorAction.REPORT) 961 .onUnmappableCharacter(java.nio.charset.CodingErrorAction.REPORT) 962 .decode(java.nio.ByteBuffer.wrap(out.toByteArray())).toString(); 963 } catch (java.nio.charset.CharacterCodingException notUtf8) { 964 return null; // \xHH bytes are not a representable identity 965 } 966 } 967 968 /** Strict-encodes the pending literal span (complete code points) into 969 * the byte stream; false when the span is not well-formed UTF-16 970 * (isolated surrogate) — the literal has no safe identity. */ 971 private static boolean flushSpan(StringBuilder span, java.io.ByteArrayOutputStream out) { 972 if (span.length() == 0) { 973 return true; 974 } 975 try { 976 java.nio.ByteBuffer encoded = java.nio.charset.StandardCharsets.UTF_8.newEncoder() 977 .onMalformedInput(java.nio.charset.CodingErrorAction.REPORT) 978 .onUnmappableCharacter(java.nio.charset.CodingErrorAction.REPORT) 979 .encode(java.nio.CharBuffer.wrap(span)); 980 out.write(encoded.array(), encoded.arrayOffset() + encoded.position(), 981 encoded.remaining()); 982 span.setLength(0); 983 return true; 984 } catch (Exception malformed) { 985 return false; 986 } 987 } 988 989 private static String decodeClickHouse(String lexical, QuoteRule rule) { 990 int n = lexical.length(); 991 int start = rule.open.length(); 992 StringBuilder sb = new StringBuilder(Math.max(0, n - start - 1)); 993 int i = start; 994 while (i < n) { 995 char c = lexical.charAt(i); 996 if (c == rule.close) { 997 if (i + 1 < n && lexical.charAt(i + 1) == rule.close) { 998 sb.append(rule.close); 999 i += 2; 1000 } else if (i == n - 1) { 1001 return sb.toString(); 1002 } else { 1003 throw new MalformedIdentifierException( 1004 "Content after closing delimiter at index " + i 1005 + " in quoted identifier: " + lexical); 1006 } 1007 } else if (c == '\\') { 1008 if (i + 1 >= n) { 1009 throw new MalformedIdentifierException( 1010 "Dangling backslash at index " + i + " in quoted identifier: " + lexical); 1011 } 1012 char e = lexical.charAt(i + 1); 1013 switch (e) { 1014 case 'a': sb.append((char) 0x07); break; 1015 case 'b': sb.append((char) 0x08); break; 1016 case 'e': sb.append((char) 0x1B); break; 1017 case 'f': sb.append((char) 0x0C); break; 1018 case 'n': sb.append((char) 0x0A); break; 1019 case 'r': sb.append((char) 0x0D); break; 1020 case 't': sb.append((char) 0x09); break; 1021 case 'v': sb.append((char) 0x0B); break; 1022 case '0': sb.append((char) 0x00); break; 1023 case 'N': break; // \N decodes to nothing 1024 case '\\': 1025 case '\'': 1026 case '"': 1027 case '`': 1028 case '/': 1029 case '=': 1030 sb.append(e); 1031 break; 1032 case 'x': { // lowercase introducer only; hex DIGITS are either case 1033 int v = (int) parseAsciiHex(lexical, i + 2, 2); 1034 sb.append((char) v); 1035 i += 4; 1036 continue; 1037 } 1038 default: 1039 // ClickHouse keeps unknown escapes verbatim: backslash AND char 1040 sb.append('\\').append(e); 1041 break; 1042 } 1043 i += 2; 1044 } else { 1045 sb.append(c); 1046 i++; 1047 } 1048 } 1049 throw new MalformedIdentifierException( 1050 "Quoted identifier is not terminated by '" + rule.close + "': " + lexical); 1051 } 1052 1053 /** 1054 * Power Query M quoted identifier {@code #"…"}: the quote is escaped by 1055 * doubling; {@code #(item,item,...)} escape sequences encode characters, 1056 * where an item is {@code cr}, {@code lf}, {@code tab}, {@code #}, or a 1057 * code point as exactly 4 or 8 ASCII hex digits. A bare {@code #} not 1058 * followed by {@code (} is literal. 1059 */ 1060 private static String decodePowerQuery(String lexical, QuoteRule rule) { 1061 int n = lexical.length(); 1062 int start = rule.open.length(); 1063 StringBuilder sb = new StringBuilder(Math.max(0, n - start - 1)); 1064 int i = start; 1065 while (i < n) { 1066 char c = lexical.charAt(i); 1067 if (c == rule.close) { 1068 if (i + 1 < n && lexical.charAt(i + 1) == rule.close) { 1069 sb.append(rule.close); 1070 i += 2; 1071 } else if (i == n - 1) { 1072 return sb.toString(); 1073 } else { 1074 throw new MalformedIdentifierException( 1075 "Content after closing delimiter at index " + i 1076 + " in quoted identifier: " + lexical); 1077 } 1078 } else if (c == '#' && i + 1 < n && lexical.charAt(i + 1) == '(') { 1079 i = decodePowerQueryEscape(lexical, i, sb); 1080 } else { 1081 sb.append(c); 1082 i++; 1083 } 1084 } 1085 throw new MalformedIdentifierException( 1086 "Quoted identifier is not terminated by '" + rule.close + "': " + lexical); 1087 } 1088 1089 /** Decode one {@code #(...)} sequence; {@code start} points at {@code #}. Returns the index past {@code )}. */ 1090 private static int decodePowerQueryEscape(String lexical, int start, StringBuilder sb) { 1091 int n = lexical.length(); 1092 int i = start + 2; // past "#(" 1093 while (true) { 1094 int itemStart = i; 1095 while (i < n && lexical.charAt(i) != ',' && lexical.charAt(i) != ')') { 1096 i++; 1097 } 1098 if (i >= n) { 1099 throw new MalformedIdentifierException( 1100 "Unterminated #( escape at index " + start + " in quoted identifier: " + lexical); 1101 } 1102 String item = lexical.substring(itemStart, i); 1103 if ("cr".equals(item)) { 1104 sb.append((char) 0x0D); 1105 } else if ("lf".equals(item)) { 1106 sb.append((char) 0x0A); 1107 } else if ("tab".equals(item)) { 1108 sb.append((char) 0x09); 1109 } else if ("#".equals(item)) { 1110 sb.append('#'); 1111 } else if (item.length() == 4 || item.length() == 8) { 1112 long v = parseAsciiHex(lexical, itemStart, item.length()); 1113 if (v > 0x10FFFFL) { 1114 throw new MalformedIdentifierException( 1115 "Escape is not a valid code point at index " + itemStart 1116 + " in quoted identifier: " + lexical); 1117 } 1118 if (v <= 0xFFFFL) { 1119 sb.append((char) v); // UTF-16 unit; pairs may be spelled as two items 1120 } else { 1121 sb.appendCodePoint((int) v); 1122 } 1123 } else { 1124 throw new MalformedIdentifierException( 1125 "Invalid #( escape item '" + item + "' at index " + itemStart 1126 + " in quoted identifier: " + lexical); 1127 } 1128 char sep = lexical.charAt(i); 1129 i++; 1130 if (sep == ')') { 1131 return i; 1132 } 1133 // sep == ',': next item 1134 } 1135 } 1136 1137 /** 1138 * PostgreSQL {@code U&"…"} Unicode identifier with the DEFAULT escape 1139 * character {@code \}: {@code ""} doubling for the quote, {@code \\} for 1140 * a literal backslash, {@code \XXXX} (4 ASCII hex) for a UTF-16 unit 1141 * (surrogate pairs may be spelled as two consecutive escapes), and 1142 * {@code \+XXXXXX} (6 ASCII hex) for a code point. Unpaired surrogates 1143 * in the decoded result are rejected, as PostgreSQL rejects them. 1144 */ 1145 private static String decodePgUnicode(String lexical, QuoteRule rule) { 1146 int n = lexical.length(); 1147 int start = rule.open.length(); 1148 StringBuilder sb = new StringBuilder(Math.max(0, n - start - 1)); 1149 int i = start; 1150 while (i < n) { 1151 char c = lexical.charAt(i); 1152 if (c == rule.close) { 1153 if (i + 1 < n && lexical.charAt(i + 1) == rule.close) { 1154 sb.append(rule.close); 1155 i += 2; 1156 } else if (i == n - 1) { 1157 String payload = sb.toString(); 1158 if (hasUnpairedSurrogate(payload)) { 1159 throw new MalformedIdentifierException( 1160 "Unicode escapes form an unpaired surrogate in quoted identifier: " 1161 + lexical); 1162 } 1163 return payload; 1164 } else { 1165 throw new MalformedIdentifierException( 1166 "Content after closing delimiter at index " + i 1167 + " in quoted identifier: " + lexical); 1168 } 1169 } else if (c == '\\') { 1170 if (i + 1 >= n) { 1171 throw new MalformedIdentifierException( 1172 "Dangling escape character at index " + i + " in quoted identifier: " + lexical); 1173 } 1174 char e = lexical.charAt(i + 1); 1175 if (e == '\\') { 1176 sb.append('\\'); 1177 i += 2; 1178 } else if (e == '+') { 1179 long v = parseAsciiHex(lexical, i + 2, 6); 1180 if (v > 0x10FFFFL) { 1181 throw new MalformedIdentifierException( 1182 "Unicode escape is not a valid code point at index " + i 1183 + " in quoted identifier: " + lexical); 1184 } 1185 if (v <= 0xFFFFL) { 1186 sb.append((char) v); 1187 } else { 1188 sb.appendCodePoint((int) v); 1189 } 1190 i += 8; 1191 } else if (asciiHexValue(e) >= 0) { 1192 int v = (int) parseAsciiHex(lexical, i + 1, 4); 1193 sb.append((char) v); 1194 i += 5; 1195 } else { 1196 throw new MalformedIdentifierException( 1197 "Invalid escape sequence '\\" + e + "' at index " + i 1198 + " in quoted identifier: " + lexical); 1199 } 1200 } else { 1201 sb.append(c); 1202 i++; 1203 } 1204 } 1205 throw new MalformedIdentifierException( 1206 "Quoted identifier is not terminated by '" + rule.close + "': " + lexical); 1207 } 1208 1209 // ===== Hex/octal helpers (ASCII-only — Character.digit would accept 1210 // non-ASCII Unicode digits, which no vendor's escape grammar does) ===== 1211 1212 private static int asciiHexValue(char c) { 1213 if (c >= '0' && c <= '9') return c - '0'; 1214 if (c >= 'a' && c <= 'f') return c - 'a' + 10; 1215 if (c >= 'A' && c <= 'F') return c - 'A' + 10; 1216 return -1; 1217 } 1218 1219 private static long parseAsciiHex(String s, int from, int digits) { 1220 if (from + digits > s.length()) { 1221 throw new MalformedIdentifierException( 1222 "Truncated escape sequence at index " + from + " in quoted identifier: " + s); 1223 } 1224 long v = 0; 1225 for (int i = from; i < from + digits; i++) { 1226 int d = asciiHexValue(s.charAt(i)); 1227 if (d < 0) { 1228 throw new MalformedIdentifierException( 1229 "Invalid hex digit at index " + i + " in quoted identifier: " + s); 1230 } 1231 v = (v << 4) | d; 1232 } 1233 return v; 1234 } 1235 1236 /** Exactly three octal digits, value at most \377 (255). */ 1237 private static int parseOctal(String s, int from) { 1238 if (from + 3 > s.length()) { 1239 throw new MalformedIdentifierException( 1240 "Truncated octal escape at index " + (from - 1) 1241 + " in quoted identifier: " + s); 1242 } 1243 int v = 0; 1244 for (int i = from; i < from + 3; i++) { 1245 char c = s.charAt(i); 1246 if (c < '0' || c > '7') { 1247 throw new MalformedIdentifierException( 1248 "Invalid octal digit at index " + i + " in quoted identifier: " + s); 1249 } 1250 v = (v << 3) | (c - '0'); 1251 } 1252 if (v > 0xFF) { 1253 throw new MalformedIdentifierException( 1254 "Octal escape above \\377 at index " + (from - 1) 1255 + " in quoted identifier: " + s); 1256 } 1257 return v; 1258 } 1259 1260 // ===== Vendor-valid helpers ===== 1261 1262 private static boolean isMySqlFamily(EDbVendor vendor) { 1263 switch (vendor) { 1264 case dbvmysql: 1265 case dbvoceanbase: 1266 case dbvdoris: 1267 case dbvstarrocks: 1268 return true; 1269 default: 1270 return false; 1271 } 1272 } 1273 1274 private static boolean hasUnpairedSurrogate(String s) { 1275 for (int i = 0; i < s.length(); i++) { 1276 char c = s.charAt(i); 1277 if (Character.isHighSurrogate(c)) { 1278 if (i + 1 >= s.length() || !Character.isLowSurrogate(s.charAt(i + 1))) { 1279 return true; 1280 } 1281 i++; 1282 } else if (Character.isLowSurrogate(c)) { 1283 return true; 1284 } 1285 } 1286 return false; 1287 } 1288 1289 private static boolean hasSupplementary(String s) { 1290 for (int i = 0; i < s.length(); i++) { 1291 if (Character.isHighSurrogate(s.charAt(i))) { 1292 return true; // paired → supplementary; unpaired is rejected separately 1293 } 1294 } 1295 return false; 1296 } 1297 1298 // ===== Per-vendor unquoted-identifier grammar ===== 1299 // 1300 // The strictest core — first code point an ASCII letter, then ASCII 1301 // letters/digits/'_' — is valid for every supported vendor. Everything 1302 // beyond it is a DOCUMENTED vendor extension, enabled per vendor (and 1303 // role: Doris table and column grammars differ). Unknown vendors get the 1304 // bare core, which can only under-affirm, never over-affirm. 1305 1306 /** {@code _} may start an identifier. NOT Oracle, NOT Doris table names, NOT the unknown-vendor core. */ 1307 private static final int UG_UNDERSCORE_START = 1; 1308 /** {@code @}/{@code #} may start an identifier (T-SQL variables / temp objects). */ 1309 private static final int UG_AT_HASH_START = 2; 1310 /** A digit may start an identifier (MySQL; the spelling may still not be all digits). */ 1311 private static final int UG_DIGIT_START = 4; 1312 /** {@code $} allowed in body positions. */ 1313 private static final int UG_DOLLAR_PART = 8; 1314 /** {@code #} allowed in body positions. */ 1315 private static final int UG_HASH_PART = 16; 1316 /** Non-ASCII Unicode LETTERS allowed (only where unconditionally documented, e.g. PostgreSQL). */ 1317 private static final int UG_UNICODE_LETTERS = 32; 1318 /** MySQL extended range: ANY character U+0080..U+FFFF is allowed (start and body). */ 1319 private static final int UG_MYSQL_EXTENDED = 64; 1320 /** Non-ASCII Unicode letters restricted to the BMP (SQL Server: no supplementary characters in names). */ 1321 private static final int UG_UNICODE_LETTERS_BMP = 128; 1322 1323 private static int unquotedGrammar(EDbVendor vendor, IdentifierProfile.ObjectGroup group) { 1324 switch (vendor) { 1325 case dbvoracle: 1326 case dbvdameng: 1327 case dbvdb2: 1328 case dbvnetezza: 1329 case dbvexasol: 1330 case dbvhana: 1331 // letter start only (Oracle: "must begin with an alphabetic 1332 // character"); $ and # documented in body; non-ASCII letters 1333 // are database-charset-dependent → conservative false 1334 return UG_DOLLAR_PART | UG_HASH_PART; 1335 1336 case dbvsnowflake: 1337 // Snowflake: start with a letter or underscore; body letters, 1338 // underscores, digits, $ — NO # 1339 return UG_UNDERSCORE_START | UG_DOLLAR_PART; 1340 1341 case dbvpostgresql: 1342 case dbvduckdb: 1343 case dbvgreenplum: 1344 case dbvgaussdb: 1345 case dbvedb: 1346 case dbvredshift: 1347 case dbvsqlite: 1348 // PostgreSQL: letters (incl. non-Latin), _, digits, $ 1349 return UG_UNDERSCORE_START | UG_DOLLAR_PART | UG_UNICODE_LETTERS; 1350 1351 case dbvmysql: 1352 case dbvoceanbase: 1353 // MySQL: ASCII [0-9a-zA-Z$_] plus ANY char U+0080..U+FFFF; 1354 // digit-first legal but not all-digits 1355 return UG_UNDERSCORE_START | UG_DIGIT_START | UG_DOLLAR_PART | UG_MYSQL_EXTENDED; 1356 1357 case dbvdoris: 1358 case dbvstarrocks: 1359 // Doris: table names must BEGIN WITH A LETTER; column names 1360 // may also begin with an underscore — the role changes the 1361 // grammar, which is why objectType flows into this check 1362 return group == IdentifierProfile.ObjectGroup.COLUMN_GROUP 1363 ? UG_UNDERSCORE_START 1364 : 0; 1365 1366 case dbvmssql: 1367 case dbvazuresql: 1368 case dbvsybase: 1369 // T-SQL: letters "as defined in Unicode", _ @ # start, 1370 // @ $ # _ in body; SQL Server does not support supplementary 1371 // characters in metadata names → BMP-only letters 1372 return UG_UNDERSCORE_START | UG_AT_HASH_START | UG_DOLLAR_PART 1373 | UG_HASH_PART | UG_UNICODE_LETTERS_BMP; 1374 1375 case dbvsybasease: 1376 // Same T-SQL identifier character classes as the base. 1377 return UG_UNDERSCORE_START | UG_AT_HASH_START | UG_DOLLAR_PART 1378 | UG_HASH_PART | UG_UNICODE_LETTERS_BMP; 1379 1380 case dbvsqlanywhere: 1381 // Same classes again, independent block per HC-5. 1382 return UG_UNDERSCORE_START | UG_AT_HASH_START | UG_DOLLAR_PART 1383 | UG_HASH_PART | UG_UNICODE_LETTERS_BMP; 1384 1385 case dbvsybaseiq: 1386 // Same again, independent block per HC-5. 1387 return UG_UNDERSCORE_START | UG_AT_HASH_START | UG_DOLLAR_PART 1388 | UG_HASH_PART | UG_UNICODE_LETTERS_BMP; 1389 1390 case dbvbigquery: 1391 // GoogleSQL: [A-Za-z_][A-Za-z_0-9]* — ASCII only, no $ 1392 return UG_UNDERSCORE_START; 1393 1394 case dbvhive: 1395 case dbvsparksql: 1396 case dbvimpala: 1397 case dbvdatabricks: 1398 case dbvflink: 1399 return UG_UNDERSCORE_START; 1400 1401 default: 1402 return 0; // strictest core only 1403 } 1404 } 1405 1406 private static boolean isValidUnquoted(EDbVendor vendor, 1407 IdentifierProfile.ObjectGroup group, 1408 String lexical) { 1409 if (hasUnpairedSurrogate(lexical)) { 1410 return false; 1411 } 1412 int g = unquotedGrammar(vendor, group); 1413 boolean mysqlFamily = isMySqlFamily(vendor); 1414 int first = lexical.codePointAt(0); 1415 if (!isUnquotedStart(first, g)) { 1416 return false; 1417 } 1418 boolean allDigits = isAsciiDigit(first); 1419 int i = Character.charCount(first); 1420 while (i < lexical.length()) { 1421 int cp = lexical.codePointAt(i); 1422 if (!isUnquotedPart(cp, g)) { 1423 return false; 1424 } 1425 if (!isAsciiDigit(cp)) { 1426 allDigits = false; 1427 } 1428 i += Character.charCount(cp); 1429 } 1430 if (mysqlFamily && allDigits) { 1431 // MySQL: an unquoted identifier may not consist solely of digits 1432 return false; 1433 } 1434 return true; 1435 } 1436 1437 private static boolean isAsciiDigit(int cp) { 1438 return cp >= '0' && cp <= '9'; 1439 } 1440 1441 private static boolean isAsciiLetter(int cp) { 1442 return (cp >= 'a' && cp <= 'z') || (cp >= 'A' && cp <= 'Z'); 1443 } 1444 1445 private static boolean isUnquotedStart(int cp, int g) { 1446 if (isAsciiLetter(cp)) { 1447 return true; 1448 } 1449 if (cp == '_') { 1450 return (g & UG_UNDERSCORE_START) != 0; 1451 } 1452 if (cp == '@' || cp == '#') { 1453 return (g & UG_AT_HASH_START) != 0; 1454 } 1455 if (isAsciiDigit(cp)) { 1456 return (g & UG_DIGIT_START) != 0; 1457 } 1458 if ((g & UG_MYSQL_EXTENDED) != 0 && cp >= 0x80 && cp <= 0xFFFF) { 1459 return true; 1460 } 1461 return nonAsciiLetterAllowed(cp, g); 1462 } 1463 1464 private static boolean nonAsciiLetterAllowed(int cp, int g) { 1465 if (cp <= 0x7F || !Character.isLetter(cp)) { 1466 return false; 1467 } 1468 if ((g & UG_UNICODE_LETTERS) != 0) { 1469 return true; 1470 } 1471 return (g & UG_UNICODE_LETTERS_BMP) != 0 && cp <= 0xFFFF; 1472 } 1473 1474 private static boolean isUnquotedPart(int cp, int g) { 1475 if (isAsciiLetter(cp) || isAsciiDigit(cp) || cp == '_') { 1476 return true; 1477 } 1478 if (cp == '$') { 1479 return (g & UG_DOLLAR_PART) != 0; 1480 } 1481 if (cp == '#') { 1482 return (g & UG_HASH_PART) != 0; 1483 } 1484 if (cp == '@') { 1485 // T-SQL documents @ in body positions as well as at the start 1486 return (g & UG_AT_HASH_START) != 0; 1487 } 1488 if ((g & UG_MYSQL_EXTENDED) != 0 && cp >= 0x80 && cp <= 0xFFFF) { 1489 return true; 1490 } 1491 return nonAsciiLetterAllowed(cp, g); 1492 } 1493}