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 dbvmysql: 287 case dbvoceanbase: // Phase 1 mirrors MySQL, like its IdentifierRules seeding 288 return profile.getFlags().mysqlAnsiQuotes ? RULES_MYSQL_ANSI : RULES_MYSQL; 289 290 case dbvdoris: 291 case dbvstarrocks: 292 // MySQL-compatible engines WITHOUT documented ANSI_QUOTES 293 // support: the flag is deliberately not honored here. 294 return RULES_MYSQL; 295 296 case dbvbigquery: 297 return RULES_BIGQUERY; 298 299 case dbvhive: 300 case dbvimpala: 301 case dbvflink: 302 case dbvcouchbase: 303 return RULES_BACKTICK; 304 305 case dbvsparksql: 306 case dbvdatabricks: 307 return RULES_SPARK; 308 309 case dbvathena: 310 return RULES_ATHENA; 311 312 case dbvdax: 313 // Explicit per-role table (round 3 of the plan review requires 314 // quoting to depend on the actual syntactic role, not the 315 // fold-policy group): columns and measures are bracketed; 316 // table names are apostrophe-quoted. Roles the DAX object 317 // model does not have (schema, catalog, procedure, ...) are 318 // treated as table-style names — DAX has no quoted spelling 319 // for them, and table rules are the only name-position form. 320 switch (objectType) { 321 case dotColumn: 322 case dotFunction: 323 case dotRoutine: 324 return RULES_DAX_COLUMN; 325 case dotTable: 326 default: 327 return RULES_DAX_TABLE; 328 } 329 330 case dbvsqlite: 331 return RULES_SQLITE; 332 333 case dbvsybase: 334 return RULES_SYBASE; 335 336 case dbvsybasease: 337 // Identical rules to the base by design (HC-9): the fork 338 // must resolve names exactly as dbvsybase does, or every 339 // downstream comparison changes meaning. 340 return RULES_SYBASE; 341 342 case dbvsqlanywhere: 343 // Same reasoning, independent block per HC-5. 344 return RULES_SYBASE; 345 346 case dbvsybaseiq: 347 // Same again, independent block per HC-5. 348 return RULES_SYBASE; 349 350 case dbvclickhouse: 351 return RULES_CLICKHOUSE; 352 353 case dbvmdx: 354 return RULES_MDX; 355 356 case dbvpowerquery: 357 return RULES_POWERQUERY; 358 359 case dbvpostgresql: 360 case dbvedb: 361 return RULES_PG; 362 363 case dbvsoql: 364 return RULES_NONE; 365 366 default: 367 return RULES_ANSI; 368 } 369 } 370 371 private static QuoteRule ruleForOpen(QuoteRule[] rules, String s) { 372 for (QuoteRule r : rules) { 373 if (s.startsWith(r.open)) { 374 return r; 375 } 376 } 377 return null; 378 } 379 380 // ===== Public API ===== 381 382 /** 383 * Decode one lexical identifier to its stored text. 384 * 385 * <p>If the input does not start with a delimiter this vendor/role 386 * recognizes, it is an unquoted identifier and is returned unchanged 387 * (case folding is NOT this codec's job — see 388 * {@link IdentifierRules.CaseFold}). If it does start with a recognized 389 * open delimiter, it must be a complete well-formed quoted identifier; 390 * anything else throws. 391 * 392 * @param profile vendor identifier profile (non-null) 393 * @param objectType syntactic role of the identifier (non-null; DAX 394 * quote rules depend on it) 395 * @param lexical the identifier spelling as written in SQL (non-null) 396 * @return the stored (undelimited, unescaped) text 397 * @throws MalformedIdentifierException if {@code lexical} starts with a 398 * recognized open delimiter but is not well-formed 399 */ 400 public static String decodeLexical(IdentifierProfile profile, 401 ESQLDataObjectType objectType, 402 String lexical) { 403 Objects.requireNonNull(profile, "profile"); 404 Objects.requireNonNull(objectType, "objectType"); 405 Objects.requireNonNull(lexical, "lexical"); 406 if (lexical.isEmpty()) { 407 return lexical; 408 } 409 QuoteRule rule = ruleForOpen(rulesFor(profile, objectType), lexical); 410 if (rule == null) { 411 return lexical; 412 } 413 switch (rule.escape) { 414 case ESC_BACKSLASH_BQ: 415 return decodeBigQuery(lexical, rule); 416 case ESC_NONE: 417 return decodeNoEscape(lexical, rule); 418 case ESC_CLICKHOUSE: 419 return decodeClickHouse(lexical, rule); 420 case ESC_POWERQUERY: 421 return decodePowerQuery(lexical, rule); 422 case ESC_PG_UNICODE: 423 return decodePgUnicode(lexical, rule); 424 default: 425 return decodeDoubling(lexical, rule); 426 } 427 } 428 429 /** 430 * Encode stored text to a canonical lexical spelling. 431 * 432 * <p>Total as a TRANSPORT encoding: any string — empty, delimiters, NUL, 433 * supplementary, unpaired surrogates — round-trips exactly through 434 * {@link #decodeLexical}. The result is quoted with the vendor's 435 * canonical delimiter for the role; for a vendor with no 436 * quoted-identifier syntax at all (SOQL) the bare name is the canonical 437 * spelling and encode is the identity. Vendor-valid-SQL is a stricter, 438 * separately queryable property: {@link #isVendorValidSpelling}. 439 * 440 * @param profile vendor identifier profile (non-null) 441 * @param objectType syntactic role of the identifier (non-null) 442 * @param stored the stored (undelimited) text (non-null) 443 * @return the canonical quoted spelling 444 */ 445 public static String encodeStored(IdentifierProfile profile, 446 ESQLDataObjectType objectType, 447 String stored) { 448 Objects.requireNonNull(profile, "profile"); 449 Objects.requireNonNull(objectType, "objectType"); 450 Objects.requireNonNull(stored, "stored"); 451 QuoteRule[] rules = rulesFor(profile, objectType); 452 if (rules.length == 0) { 453 // no quoted-identifier syntax (SOQL): the bare name IS the 454 // canonical lexical spelling; decode is the identity 455 return stored; 456 } 457 QuoteRule rule = rules[0]; 458 StringBuilder sb = new StringBuilder(stored.length() + rule.open.length() + 1); 459 sb.append(rule.open); 460 for (int i = 0; i < stored.length(); i++) { 461 char c = stored.charAt(i); 462 switch (rule.escape) { 463 case ESC_BACKSLASH_BQ: 464 // GoogleSQL's lexer excludes raw CR/LF from backtick 465 // identifiers: emit their escape forms 466 if (c == (char) 0x0D) { 467 sb.append('\\').append('r'); 468 break; 469 } 470 if (c == (char) 0x0A) { 471 sb.append('\\').append('n'); 472 break; 473 } 474 if (c == '\\' || c == rule.close) { 475 sb.append('\\'); 476 } 477 sb.append(c); 478 break; 479 case ESC_CLICKHOUSE: 480 // backslash is escape-significant on decode, so escape it; 481 // the close delimiter is doubled 482 if (c == '\\') { 483 sb.append('\\').append('\\'); 484 } else if (c == rule.close) { 485 sb.append(rule.close).append(rule.close); 486 } else { 487 sb.append(c); 488 } 489 break; 490 case ESC_POWERQUERY: 491 // '#' could begin a '#(' escape on decode, so always 492 // escape it as #(#); the quote is doubled 493 if (c == '#') { 494 sb.append("#(#)"); 495 } else if (c == rule.close) { 496 sb.append(rule.close).append(rule.close); 497 } else { 498 sb.append(c); 499 } 500 break; 501 default: 502 // ESC_DOUBLING (canonical rules are never ESC_NONE or 503 // ESC_PG_UNICODE: those are recognition/decode-only forms) 504 if (c == rule.close) { 505 sb.append(rule.close); 506 } 507 sb.append(c); 508 break; 509 } 510 } 511 sb.append(rule.close); 512 return sb.toString(); 513 } 514 515 /** 516 * Does this lexical spelling denote a QUOTED identifier for the 517 * vendor/role — i.e. does it start with a recognized open delimiter 518 * (which may be a multi-character prefix such as {@code #"} or 519 * {@code U&"})? 520 * 521 * <p>Recognition only: a {@code true} result does NOT imply the spelling 522 * is well-formed ({@code [a.b[} is recognized as quoted, and 523 * {@link #decodeLexical} then rejects it). {@code null} and the empty 524 * string are not quoted. 525 * 526 * @param profile vendor identifier profile (non-null) 527 * @param objectType syntactic role of the identifier (non-null; DAX 528 * quote RECOGNITION is role-dependent — apostrophes 529 * open quoted table names only) 530 * @param s the spelling to test (may be null) 531 * @return true iff {@code s} starts with a recognized open delimiter 532 */ 533 public static boolean isQuoted(IdentifierProfile profile, 534 ESQLDataObjectType objectType, 535 String s) { 536 Objects.requireNonNull(profile, "profile"); 537 Objects.requireNonNull(objectType, "objectType"); 538 if (s == null || s.isEmpty()) { 539 return false; 540 } 541 // Fast rejection for the dominant case (unquoted identifier): no vendor's 542 // open delimiter starts with this character, so skip the rule-table walk. 543 char c0 = s.charAt(0); 544 if (c0 >= 128 || (OPEN_FIRST[c0 >>> 6] & (1L << (c0 & 63))) == 0) { 545 return false; 546 } 547 return ruleForOpen(rulesFor(profile, objectType), s) != null; 548 } 549 550 /** 551 * Does this vendor/role have ANY quoted-identifier syntax? {@code false} 552 * only for vendors like SOQL where {@link #encodeStored} is the identity 553 * — callers that need a delimiter for internal-key purposes (protecting 554 * dots from qualified-name splitting) must supply their own fallback. 555 * 556 * @param profile vendor identifier profile (non-null) 557 * @param objectType syntactic role of the identifier (non-null) 558 * @return true iff at least one quote form exists for the vendor/role 559 */ 560 public static boolean hasQuotedForm(IdentifierProfile profile, 561 ESQLDataObjectType objectType) { 562 Objects.requireNonNull(profile, "profile"); 563 Objects.requireNonNull(objectType, "objectType"); 564 return rulesFor(profile, objectType).length > 0; 565 } 566 567 /** 568 * Is this lexical spelling valid SQL for the vendor — a stricter 569 * property than being transport-decodable? 570 * 571 * <p><b>Scope: character- and delimiter-level validity only.</b> The 572 * check affirms spellings whose delimiter form and character content are 573 * valid under the vendor's documented identifier grammar. Reserved-word 574 * status, length limits, and semantic restrictions (e.g. a name being 575 * taken) are explicitly OUT of scope — a {@code true} result does not 576 * promise the bare word {@code SELECT} is usable unquoted. Within its 577 * scope the check is conservative: it may return {@code false} for an 578 * exotic-but-valid spelling, never {@code true} for a spelling whose 579 * characters or delimiters are known-invalid. Enforced facts: 580 * <ul> 581 * <li>a quoted spelling must be well-formed ({@link #decodeLexical} 582 * accepts it) and — except for SQLite and Power Query M, which 583 * both document zero-length quoted identifiers as legal — its 584 * payload must be non-empty (BigQuery documents the empty-backtick 585 * ban explicitly);</li> 586 * <li>no payload may contain an unpaired surrogate (not a character, 587 * so no vendor identifier grammar admits it) or NUL (MySQL documents 588 * the ban explicitly; no vendor's native interface transports NUL 589 * inside a name, so the conservative answer is {@code false} 590 * everywhere);</li> 591 * <li>MySQL family: identifier characters are limited to the BMP 592 * (U+0001..U+FFFF) — supplementary characters are invalid even 593 * quoted; an unquoted identifier may not consist solely of 594 * digits;</li> 595 * <li>an unquoted spelling must match the vendor's (and role's) 596 * unquoted-identifier grammar as modeled per vendor: the strictest 597 * core (first code point an ASCII letter; then ASCII letters, 598 * digits, {@code _}) plus only documented vendor extensions — 599 * e.g. {@code _} start for PostgreSQL/BigQuery/SQL Server but NOT 600 * Oracle or Doris table names; {@code $}/{@code #} body characters 601 * where documented; MySQL's extended U+0080..U+FFFF range; 602 * non-ASCII letters only where unconditionally documented 603 * (charset-dependent vendors like Oracle stay conservative).</li> 604 * </ul> 605 * 606 * @param profile vendor identifier profile (non-null) 607 * @param objectType syntactic role of the identifier (non-null) 608 * @param lexical the spelling to test (may be null; null/empty is invalid) 609 * @return true iff the spelling is known-valid at the character/delimiter level 610 */ 611 public static boolean isVendorValidSpelling(IdentifierProfile profile, 612 ESQLDataObjectType objectType, 613 String lexical) { 614 Objects.requireNonNull(profile, "profile"); 615 Objects.requireNonNull(objectType, "objectType"); 616 if (lexical == null || lexical.isEmpty()) { 617 return false; 618 } 619 EDbVendor vendor = profile.getVendor(); 620 if (isQuoted(profile, objectType, lexical)) { 621 QuoteRule rule = ruleForOpen(rulesFor(profile, objectType), lexical); 622 if (rule != null && rule.recognitionOnly) { 623 // Recognized for equality/decoding only (MSSQL 'alias' form) — 624 // not a valid identifier spelling for the vendor. 625 return false; 626 } 627 String payload; 628 try { 629 payload = decodeLexical(profile, objectType, lexical); 630 } catch (IllegalArgumentException e) { 631 return false; 632 } 633 if (payload.isEmpty() && !allowsEmptyQuoted(vendor)) { 634 return false; 635 } 636 if (hasUnpairedSurrogate(payload)) { 637 return false; 638 } 639 if (payload.indexOf(0x0000) >= 0) { 640 return false; 641 } 642 if (hasSupplementary(payload) && isBmpOnlyVendor(vendor)) { 643 // MySQL documents BMP-only identifier characters; SQL Server 644 // does not support supplementary characters in metadata names 645 return false; 646 } 647 if (vendor == EDbVendor.dbvpowerquery && hasRawControlChar(lexical)) { 648 // M requires non-graphic characters (tab/CR/LF/...) to be 649 // written as #(...) escapes; RAW control characters in the 650 // lexical body are invalid even though transport decode 651 // preserves them 652 return false; 653 } 654 if (vendor == EDbVendor.dbvbigquery 655 && (lexical.indexOf(0x0D) >= 0 || lexical.indexOf(0x0A) >= 0)) { 656 // GoogleSQL's lexer excludes raw CR/LF from backtick 657 // identifiers — they must be spelled \r / \n 658 return false; 659 } 660 return true; 661 } 662 return isValidUnquoted(vendor, profile.groupOf(objectType), lexical); 663 } 664 665 private static boolean isBmpOnlyVendor(EDbVendor vendor) { 666 return isMySqlFamily(vendor) 667 || vendor == EDbVendor.dbvmssql 668 || vendor == EDbVendor.dbvazuresql; 669 } 670 671 /** Any raw ISO control character in the spelling (escaped forms like {@code #(tab)} contain none). */ 672 private static boolean hasRawControlChar(String lexical) { 673 for (int i = 0; i < lexical.length(); i++) { 674 if (Character.isISOControl(lexical.charAt(i))) { 675 return true; 676 } 677 } 678 return false; 679 } 680 681 /** SQLite and Power Query M document zero-length quoted identifiers as legal. */ 682 private static boolean allowsEmptyQuoted(EDbVendor vendor) { 683 return vendor == EDbVendor.dbvsqlite || vendor == EDbVendor.dbvpowerquery; 684 } 685 686 // ===== Decoding ===== 687 688 private static String decodeDoubling(String lexical, QuoteRule rule) { 689 int n = lexical.length(); 690 int start = rule.open.length(); 691 if (n < start + 1 || lexical.charAt(n - 1) != rule.close) { 692 throw new MalformedIdentifierException( 693 "Quoted identifier is not terminated by '" + rule.close + "': " + lexical); 694 } 695 int end = n - 1; 696 StringBuilder sb = new StringBuilder(end - start); 697 int i = start; 698 while (i < end) { 699 char c = lexical.charAt(i); 700 if (c == rule.close) { 701 if (i + 1 < end && lexical.charAt(i + 1) == rule.close) { 702 sb.append(rule.close); 703 i += 2; 704 } else { 705 throw new MalformedIdentifierException( 706 "Unescaped close delimiter '" + rule.close + "' at index " + i 707 + " in quoted identifier: " + lexical); 708 } 709 } else { 710 sb.append(c); 711 i++; 712 } 713 } 714 return sb.toString(); 715 } 716 717 /** SQLite bracket form: no escape exists; the FIRST close bracket must be the last char. */ 718 private static String decodeNoEscape(String lexical, QuoteRule rule) { 719 int n = lexical.length(); 720 int start = rule.open.length(); 721 int idx = lexical.indexOf(rule.close, start); 722 if (idx < 0) { 723 throw new MalformedIdentifierException( 724 "Quoted identifier is not terminated by '" + rule.close + "': " + lexical); 725 } 726 if (idx != n - 1) { 727 throw new MalformedIdentifierException( 728 "Content after closing delimiter at index " + idx 729 + " in quoted identifier (this form has no escape): " + lexical); 730 } 731 return lexical.substring(start, n - 1); 732 } 733 734 private static String decodeBigQuery(String lexical, QuoteRule rule) { 735 int n = lexical.length(); 736 int start = rule.open.length(); 737 StringBuilder sb = new StringBuilder(Math.max(0, n - start - 1)); 738 int i = start; 739 while (i < n) { 740 char c = lexical.charAt(i); 741 if (c == rule.close) { 742 if (i == n - 1) { 743 return sb.toString(); 744 } 745 throw new MalformedIdentifierException( 746 "Content after closing delimiter at index " + i 747 + " in quoted identifier: " + lexical); 748 } 749 if (c == '\\') { 750 i = decodeBigQueryEscape(lexical, i, sb); 751 } else { 752 sb.append(c); 753 i++; 754 } 755 } 756 throw new MalformedIdentifierException( 757 "Quoted identifier is not terminated by '" + rule.close + "': " + lexical); 758 } 759 760 /** 761 * Decode one BigQuery escape sequence starting at {@code start} (which 762 * points at the backslash). Appends the decoded character(s) to 763 * {@code sb} and returns the index just past the sequence. 764 * 765 * <p>GoogleSQL escape set: {@code \a \b \f \n \r \t \v \\ \? \" \' \`}, 766 * {@code \ooo} (exactly 3 octal digits, max {@code \377}), 767 * {@code \xhh}/{@code \Xhh} (exactly 2 ASCII hex digits), 768 * <code>\uhhhh</code> (exactly 4 ASCII hex digits, no surrogates), 769 * {@code \Uhhhhhhhh} (exactly 8 ASCII hex digits, valid non-surrogate 770 * code point). 771 */ 772 private static int decodeBigQueryEscape(String lexical, int start, StringBuilder sb) { 773 int n = lexical.length(); 774 if (start + 1 >= n) { 775 throw new MalformedIdentifierException( 776 "Dangling backslash at index " + start + " in quoted identifier: " + lexical); 777 } 778 char e = lexical.charAt(start + 1); 779 switch (e) { 780 case 'a': sb.append((char) 0x07); return start + 2; 781 case 'b': sb.append((char) 0x08); return start + 2; 782 case 'f': sb.append((char) 0x0C); return start + 2; 783 case 'n': sb.append((char) 0x0A); return start + 2; 784 case 'r': sb.append((char) 0x0D); return start + 2; 785 case 't': sb.append((char) 0x09); return start + 2; 786 case 'v': sb.append((char) 0x0B); return start + 2; 787 case '\\': 788 case '?': 789 case '"': 790 case '\'': 791 case '`': 792 sb.append(e); 793 return start + 2; 794 case 'x': 795 case 'X': { 796 int v = (int) parseAsciiHex(lexical, start + 2, 2); 797 sb.append((char) v); 798 return start + 4; 799 } 800 case 'u': { 801 int v = (int) parseAsciiHex(lexical, start + 2, 4); 802 if (v >= 0xD800 && v <= 0xDFFF) { 803 throw new MalformedIdentifierException( 804 "Unicode escape encodes a surrogate at index " + start 805 + " in quoted identifier: " + lexical); 806 } 807 sb.append((char) v); 808 return start + 6; 809 } 810 case 'U': { 811 // 8 hex digits can exceed Integer.MAX_VALUE: accumulate in long 812 long v = parseAsciiHex(lexical, start + 2, 8); 813 if (v > 0x10FFFFL || (v >= 0xD800L && v <= 0xDFFFL)) { 814 throw new MalformedIdentifierException( 815 "Unicode escape is not a valid code point at index " + start 816 + " in quoted identifier: " + lexical); 817 } 818 sb.appendCodePoint((int) v); 819 return start + 10; 820 } 821 default: 822 if (e >= '0' && e <= '7') { 823 int v = parseOctal(lexical, start + 1); 824 sb.append((char) v); 825 return start + 4; 826 } 827 throw new MalformedIdentifierException( 828 "Invalid escape sequence '\\" + e + "' at index " + start 829 + " in quoted identifier: " + lexical); 830 } 831 } 832 833 /** 834 * ClickHouse quoted identifiers: the close delimiter may be doubled, and 835 * backslash escapes work with ClickHouse's parser semantics 836 * (ReadHelpers.cpp {@code parseComplexEscapeSequence}): lowercase 837 * {@code \xHH} only (uppercase {@code \X} is NOT an escape introducer), 838 * {@code \N} decodes to NOTHING, the mapped set is 839 * {@code \a \b \e \f \n \r \t \v \0 \\ \' \" \` \/ \=}, and an UNKNOWN 840 * escape keeps both the backslash and the character (so {@code \q} 841 * decodes to {@code \q}, and {@code \X41} to {@code \X41}). 842 */ 843 /** 844 * ClickHouse STRING LITERAL decoder (single- or double-quoted), sharing 845 * the escape semantics of {@link #decodeClickHouse} (ReadHelpers.cpp 846 * {@code parseComplexEscapeSequence}): doubled quotes, lowercase 847 * {@code \xHH} only, {@code \N} decodes to nothing, the mapped set is 848 * {@code \a \b \e \f \n \r \t \v \0 \\ \' \" \` \/ \=}, 849 * and an unknown escape keeps both characters. {@code \xHH} sequences 850 * are BYTES: the decoded stream is recombined as UTF-8, so 851 * {@code '\xC3\xA9'} is {@code é}, not two mojibake characters. 852 * 853 * @return the decoded value, or null when the literal is malformed or 854 * its bytes are not valid UTF-8 — callers must treat null as 855 * "identity cannot be represented safely" and fall back. 856 */ 857 public static String decodeClickHouseStringLiteral(String rawLiteral) { 858 if (rawLiteral == null || rawLiteral.length() < 2) { 859 return null; 860 } 861 char quote = rawLiteral.charAt(0); 862 if ((quote != '\'' && quote != '"') || rawLiteral.charAt(rawLiteral.length() - 1) != quote) { 863 return null; 864 } 865 java.io.ByteArrayOutputStream out = new java.io.ByteArrayOutputStream(rawLiteral.length()); 866 // Literal characters accumulate in spans and are encoded as COMPLETE 867 // code points with a REPORT-strict encoder: encoding one char at a 868 // time splits surrogate pairs into isolated surrogates ('db.😀' 869 // became db.?? — a false identity), and an isolated surrogate must 870 // reject the literal rather than corrupt it. 871 StringBuilder span = new StringBuilder(); 872 int n = rawLiteral.length() - 1; // exclusive of closing quote 873 int i = 1; 874 while (i < n) { 875 char c = rawLiteral.charAt(i); 876 if (c == quote) { 877 if (i + 1 < n && rawLiteral.charAt(i + 1) == quote) { 878 span.append(quote); 879 i += 2; 880 continue; 881 } 882 return null; // stray quote inside the body 883 } 884 if (c != '\\') { 885 span.append(c); 886 i++; 887 continue; 888 } 889 if (i + 1 >= n) { 890 return null; // dangling backslash 891 } 892 char e = rawLiteral.charAt(i + 1); 893 switch (e) { 894 case 'a': if (!flushSpan(span, out)) return null; out.write(0x07); break; 895 case 'b': if (!flushSpan(span, out)) return null; out.write(0x08); break; 896 case 'e': if (!flushSpan(span, out)) return null; out.write(0x1B); break; 897 case 'f': if (!flushSpan(span, out)) return null; out.write(0x0C); break; 898 case 'n': if (!flushSpan(span, out)) return null; out.write(0x0A); break; 899 case 'r': if (!flushSpan(span, out)) return null; out.write(0x0D); break; 900 case 't': if (!flushSpan(span, out)) return null; out.write(0x09); break; 901 case 'v': if (!flushSpan(span, out)) return null; out.write(0x0B); break; 902 case '0': if (!flushSpan(span, out)) return null; out.write(0x00); break; 903 case 'N': break; // \N decodes to nothing 904 case '\\': 905 case '\'': 906 case '"': 907 case '`': 908 case '/': 909 case '=': 910 span.append(e); 911 break; 912 case 'x': { 913 if (i + 3 > n) { 914 return null; 915 } 916 if (!flushSpan(span, out)) return null; 917 try { 918 out.write((int) parseAsciiHex(rawLiteral, i + 2, 2)); 919 } catch (Exception malformed) { 920 return null; 921 } 922 i += 4; 923 continue; 924 } 925 default: 926 // unknown escape keeps BOTH characters; the escaped char 927 // joins the span so a following low surrogate stays 928 // adjacent to its pair 929 span.append('\\').append(e); 930 break; 931 } 932 i += 2; 933 } 934 if (!flushSpan(span, out)) { 935 return null; 936 } 937 try { 938 return java.nio.charset.StandardCharsets.UTF_8.newDecoder() 939 .onMalformedInput(java.nio.charset.CodingErrorAction.REPORT) 940 .onUnmappableCharacter(java.nio.charset.CodingErrorAction.REPORT) 941 .decode(java.nio.ByteBuffer.wrap(out.toByteArray())).toString(); 942 } catch (java.nio.charset.CharacterCodingException notUtf8) { 943 return null; // \xHH bytes are not a representable identity 944 } 945 } 946 947 /** Strict-encodes the pending literal span (complete code points) into 948 * the byte stream; false when the span is not well-formed UTF-16 949 * (isolated surrogate) — the literal has no safe identity. */ 950 private static boolean flushSpan(StringBuilder span, java.io.ByteArrayOutputStream out) { 951 if (span.length() == 0) { 952 return true; 953 } 954 try { 955 java.nio.ByteBuffer encoded = java.nio.charset.StandardCharsets.UTF_8.newEncoder() 956 .onMalformedInput(java.nio.charset.CodingErrorAction.REPORT) 957 .onUnmappableCharacter(java.nio.charset.CodingErrorAction.REPORT) 958 .encode(java.nio.CharBuffer.wrap(span)); 959 out.write(encoded.array(), encoded.arrayOffset() + encoded.position(), 960 encoded.remaining()); 961 span.setLength(0); 962 return true; 963 } catch (Exception malformed) { 964 return false; 965 } 966 } 967 968 private static String decodeClickHouse(String lexical, QuoteRule rule) { 969 int n = lexical.length(); 970 int start = rule.open.length(); 971 StringBuilder sb = new StringBuilder(Math.max(0, n - start - 1)); 972 int i = start; 973 while (i < n) { 974 char c = lexical.charAt(i); 975 if (c == rule.close) { 976 if (i + 1 < n && lexical.charAt(i + 1) == rule.close) { 977 sb.append(rule.close); 978 i += 2; 979 } else if (i == n - 1) { 980 return sb.toString(); 981 } else { 982 throw new MalformedIdentifierException( 983 "Content after closing delimiter at index " + i 984 + " in quoted identifier: " + lexical); 985 } 986 } else if (c == '\\') { 987 if (i + 1 >= n) { 988 throw new MalformedIdentifierException( 989 "Dangling backslash at index " + i + " in quoted identifier: " + lexical); 990 } 991 char e = lexical.charAt(i + 1); 992 switch (e) { 993 case 'a': sb.append((char) 0x07); break; 994 case 'b': sb.append((char) 0x08); break; 995 case 'e': sb.append((char) 0x1B); break; 996 case 'f': sb.append((char) 0x0C); break; 997 case 'n': sb.append((char) 0x0A); break; 998 case 'r': sb.append((char) 0x0D); break; 999 case 't': sb.append((char) 0x09); break; 1000 case 'v': sb.append((char) 0x0B); break; 1001 case '0': sb.append((char) 0x00); break; 1002 case 'N': break; // \N decodes to nothing 1003 case '\\': 1004 case '\'': 1005 case '"': 1006 case '`': 1007 case '/': 1008 case '=': 1009 sb.append(e); 1010 break; 1011 case 'x': { // lowercase introducer only; hex DIGITS are either case 1012 int v = (int) parseAsciiHex(lexical, i + 2, 2); 1013 sb.append((char) v); 1014 i += 4; 1015 continue; 1016 } 1017 default: 1018 // ClickHouse keeps unknown escapes verbatim: backslash AND char 1019 sb.append('\\').append(e); 1020 break; 1021 } 1022 i += 2; 1023 } else { 1024 sb.append(c); 1025 i++; 1026 } 1027 } 1028 throw new MalformedIdentifierException( 1029 "Quoted identifier is not terminated by '" + rule.close + "': " + lexical); 1030 } 1031 1032 /** 1033 * Power Query M quoted identifier {@code #"…"}: the quote is escaped by 1034 * doubling; {@code #(item,item,...)} escape sequences encode characters, 1035 * where an item is {@code cr}, {@code lf}, {@code tab}, {@code #}, or a 1036 * code point as exactly 4 or 8 ASCII hex digits. A bare {@code #} not 1037 * followed by {@code (} is literal. 1038 */ 1039 private static String decodePowerQuery(String lexical, QuoteRule rule) { 1040 int n = lexical.length(); 1041 int start = rule.open.length(); 1042 StringBuilder sb = new StringBuilder(Math.max(0, n - start - 1)); 1043 int i = start; 1044 while (i < n) { 1045 char c = lexical.charAt(i); 1046 if (c == rule.close) { 1047 if (i + 1 < n && lexical.charAt(i + 1) == rule.close) { 1048 sb.append(rule.close); 1049 i += 2; 1050 } else if (i == n - 1) { 1051 return sb.toString(); 1052 } else { 1053 throw new MalformedIdentifierException( 1054 "Content after closing delimiter at index " + i 1055 + " in quoted identifier: " + lexical); 1056 } 1057 } else if (c == '#' && i + 1 < n && lexical.charAt(i + 1) == '(') { 1058 i = decodePowerQueryEscape(lexical, i, sb); 1059 } else { 1060 sb.append(c); 1061 i++; 1062 } 1063 } 1064 throw new MalformedIdentifierException( 1065 "Quoted identifier is not terminated by '" + rule.close + "': " + lexical); 1066 } 1067 1068 /** Decode one {@code #(...)} sequence; {@code start} points at {@code #}. Returns the index past {@code )}. */ 1069 private static int decodePowerQueryEscape(String lexical, int start, StringBuilder sb) { 1070 int n = lexical.length(); 1071 int i = start + 2; // past "#(" 1072 while (true) { 1073 int itemStart = i; 1074 while (i < n && lexical.charAt(i) != ',' && lexical.charAt(i) != ')') { 1075 i++; 1076 } 1077 if (i >= n) { 1078 throw new MalformedIdentifierException( 1079 "Unterminated #( escape at index " + start + " in quoted identifier: " + lexical); 1080 } 1081 String item = lexical.substring(itemStart, i); 1082 if ("cr".equals(item)) { 1083 sb.append((char) 0x0D); 1084 } else if ("lf".equals(item)) { 1085 sb.append((char) 0x0A); 1086 } else if ("tab".equals(item)) { 1087 sb.append((char) 0x09); 1088 } else if ("#".equals(item)) { 1089 sb.append('#'); 1090 } else if (item.length() == 4 || item.length() == 8) { 1091 long v = parseAsciiHex(lexical, itemStart, item.length()); 1092 if (v > 0x10FFFFL) { 1093 throw new MalformedIdentifierException( 1094 "Escape is not a valid code point at index " + itemStart 1095 + " in quoted identifier: " + lexical); 1096 } 1097 if (v <= 0xFFFFL) { 1098 sb.append((char) v); // UTF-16 unit; pairs may be spelled as two items 1099 } else { 1100 sb.appendCodePoint((int) v); 1101 } 1102 } else { 1103 throw new MalformedIdentifierException( 1104 "Invalid #( escape item '" + item + "' at index " + itemStart 1105 + " in quoted identifier: " + lexical); 1106 } 1107 char sep = lexical.charAt(i); 1108 i++; 1109 if (sep == ')') { 1110 return i; 1111 } 1112 // sep == ',': next item 1113 } 1114 } 1115 1116 /** 1117 * PostgreSQL {@code U&"…"} Unicode identifier with the DEFAULT escape 1118 * character {@code \}: {@code ""} doubling for the quote, {@code \\} for 1119 * a literal backslash, {@code \XXXX} (4 ASCII hex) for a UTF-16 unit 1120 * (surrogate pairs may be spelled as two consecutive escapes), and 1121 * {@code \+XXXXXX} (6 ASCII hex) for a code point. Unpaired surrogates 1122 * in the decoded result are rejected, as PostgreSQL rejects them. 1123 */ 1124 private static String decodePgUnicode(String lexical, QuoteRule rule) { 1125 int n = lexical.length(); 1126 int start = rule.open.length(); 1127 StringBuilder sb = new StringBuilder(Math.max(0, n - start - 1)); 1128 int i = start; 1129 while (i < n) { 1130 char c = lexical.charAt(i); 1131 if (c == rule.close) { 1132 if (i + 1 < n && lexical.charAt(i + 1) == rule.close) { 1133 sb.append(rule.close); 1134 i += 2; 1135 } else if (i == n - 1) { 1136 String payload = sb.toString(); 1137 if (hasUnpairedSurrogate(payload)) { 1138 throw new MalformedIdentifierException( 1139 "Unicode escapes form an unpaired surrogate in quoted identifier: " 1140 + lexical); 1141 } 1142 return payload; 1143 } else { 1144 throw new MalformedIdentifierException( 1145 "Content after closing delimiter at index " + i 1146 + " in quoted identifier: " + lexical); 1147 } 1148 } else if (c == '\\') { 1149 if (i + 1 >= n) { 1150 throw new MalformedIdentifierException( 1151 "Dangling escape character at index " + i + " in quoted identifier: " + lexical); 1152 } 1153 char e = lexical.charAt(i + 1); 1154 if (e == '\\') { 1155 sb.append('\\'); 1156 i += 2; 1157 } else if (e == '+') { 1158 long v = parseAsciiHex(lexical, i + 2, 6); 1159 if (v > 0x10FFFFL) { 1160 throw new MalformedIdentifierException( 1161 "Unicode escape is not a valid code point at index " + i 1162 + " in quoted identifier: " + lexical); 1163 } 1164 if (v <= 0xFFFFL) { 1165 sb.append((char) v); 1166 } else { 1167 sb.appendCodePoint((int) v); 1168 } 1169 i += 8; 1170 } else if (asciiHexValue(e) >= 0) { 1171 int v = (int) parseAsciiHex(lexical, i + 1, 4); 1172 sb.append((char) v); 1173 i += 5; 1174 } else { 1175 throw new MalformedIdentifierException( 1176 "Invalid escape sequence '\\" + e + "' at index " + i 1177 + " in quoted identifier: " + lexical); 1178 } 1179 } else { 1180 sb.append(c); 1181 i++; 1182 } 1183 } 1184 throw new MalformedIdentifierException( 1185 "Quoted identifier is not terminated by '" + rule.close + "': " + lexical); 1186 } 1187 1188 // ===== Hex/octal helpers (ASCII-only — Character.digit would accept 1189 // non-ASCII Unicode digits, which no vendor's escape grammar does) ===== 1190 1191 private static int asciiHexValue(char c) { 1192 if (c >= '0' && c <= '9') return c - '0'; 1193 if (c >= 'a' && c <= 'f') return c - 'a' + 10; 1194 if (c >= 'A' && c <= 'F') return c - 'A' + 10; 1195 return -1; 1196 } 1197 1198 private static long parseAsciiHex(String s, int from, int digits) { 1199 if (from + digits > s.length()) { 1200 throw new MalformedIdentifierException( 1201 "Truncated escape sequence at index " + from + " in quoted identifier: " + s); 1202 } 1203 long v = 0; 1204 for (int i = from; i < from + digits; i++) { 1205 int d = asciiHexValue(s.charAt(i)); 1206 if (d < 0) { 1207 throw new MalformedIdentifierException( 1208 "Invalid hex digit at index " + i + " in quoted identifier: " + s); 1209 } 1210 v = (v << 4) | d; 1211 } 1212 return v; 1213 } 1214 1215 /** Exactly three octal digits, value at most \377 (255). */ 1216 private static int parseOctal(String s, int from) { 1217 if (from + 3 > s.length()) { 1218 throw new MalformedIdentifierException( 1219 "Truncated octal escape at index " + (from - 1) 1220 + " in quoted identifier: " + s); 1221 } 1222 int v = 0; 1223 for (int i = from; i < from + 3; i++) { 1224 char c = s.charAt(i); 1225 if (c < '0' || c > '7') { 1226 throw new MalformedIdentifierException( 1227 "Invalid octal digit at index " + i + " in quoted identifier: " + s); 1228 } 1229 v = (v << 3) | (c - '0'); 1230 } 1231 if (v > 0xFF) { 1232 throw new MalformedIdentifierException( 1233 "Octal escape above \\377 at index " + (from - 1) 1234 + " in quoted identifier: " + s); 1235 } 1236 return v; 1237 } 1238 1239 // ===== Vendor-valid helpers ===== 1240 1241 private static boolean isMySqlFamily(EDbVendor vendor) { 1242 switch (vendor) { 1243 case dbvmysql: 1244 case dbvoceanbase: 1245 case dbvdoris: 1246 case dbvstarrocks: 1247 return true; 1248 default: 1249 return false; 1250 } 1251 } 1252 1253 private static boolean hasUnpairedSurrogate(String s) { 1254 for (int i = 0; i < s.length(); i++) { 1255 char c = s.charAt(i); 1256 if (Character.isHighSurrogate(c)) { 1257 if (i + 1 >= s.length() || !Character.isLowSurrogate(s.charAt(i + 1))) { 1258 return true; 1259 } 1260 i++; 1261 } else if (Character.isLowSurrogate(c)) { 1262 return true; 1263 } 1264 } 1265 return false; 1266 } 1267 1268 private static boolean hasSupplementary(String s) { 1269 for (int i = 0; i < s.length(); i++) { 1270 if (Character.isHighSurrogate(s.charAt(i))) { 1271 return true; // paired → supplementary; unpaired is rejected separately 1272 } 1273 } 1274 return false; 1275 } 1276 1277 // ===== Per-vendor unquoted-identifier grammar ===== 1278 // 1279 // The strictest core — first code point an ASCII letter, then ASCII 1280 // letters/digits/'_' — is valid for every supported vendor. Everything 1281 // beyond it is a DOCUMENTED vendor extension, enabled per vendor (and 1282 // role: Doris table and column grammars differ). Unknown vendors get the 1283 // bare core, which can only under-affirm, never over-affirm. 1284 1285 /** {@code _} may start an identifier. NOT Oracle, NOT Doris table names, NOT the unknown-vendor core. */ 1286 private static final int UG_UNDERSCORE_START = 1; 1287 /** {@code @}/{@code #} may start an identifier (T-SQL variables / temp objects). */ 1288 private static final int UG_AT_HASH_START = 2; 1289 /** A digit may start an identifier (MySQL; the spelling may still not be all digits). */ 1290 private static final int UG_DIGIT_START = 4; 1291 /** {@code $} allowed in body positions. */ 1292 private static final int UG_DOLLAR_PART = 8; 1293 /** {@code #} allowed in body positions. */ 1294 private static final int UG_HASH_PART = 16; 1295 /** Non-ASCII Unicode LETTERS allowed (only where unconditionally documented, e.g. PostgreSQL). */ 1296 private static final int UG_UNICODE_LETTERS = 32; 1297 /** MySQL extended range: ANY character U+0080..U+FFFF is allowed (start and body). */ 1298 private static final int UG_MYSQL_EXTENDED = 64; 1299 /** Non-ASCII Unicode letters restricted to the BMP (SQL Server: no supplementary characters in names). */ 1300 private static final int UG_UNICODE_LETTERS_BMP = 128; 1301 1302 private static int unquotedGrammar(EDbVendor vendor, IdentifierProfile.ObjectGroup group) { 1303 switch (vendor) { 1304 case dbvoracle: 1305 case dbvdameng: 1306 case dbvdb2: 1307 case dbvnetezza: 1308 case dbvexasol: 1309 case dbvhana: 1310 // letter start only (Oracle: "must begin with an alphabetic 1311 // character"); $ and # documented in body; non-ASCII letters 1312 // are database-charset-dependent → conservative false 1313 return UG_DOLLAR_PART | UG_HASH_PART; 1314 1315 case dbvsnowflake: 1316 // Snowflake: start with a letter or underscore; body letters, 1317 // underscores, digits, $ — NO # 1318 return UG_UNDERSCORE_START | UG_DOLLAR_PART; 1319 1320 case dbvpostgresql: 1321 case dbvduckdb: 1322 case dbvgreenplum: 1323 case dbvgaussdb: 1324 case dbvedb: 1325 case dbvredshift: 1326 case dbvsqlite: 1327 // PostgreSQL: letters (incl. non-Latin), _, digits, $ 1328 return UG_UNDERSCORE_START | UG_DOLLAR_PART | UG_UNICODE_LETTERS; 1329 1330 case dbvmysql: 1331 case dbvoceanbase: 1332 // MySQL: ASCII [0-9a-zA-Z$_] plus ANY char U+0080..U+FFFF; 1333 // digit-first legal but not all-digits 1334 return UG_UNDERSCORE_START | UG_DIGIT_START | UG_DOLLAR_PART | UG_MYSQL_EXTENDED; 1335 1336 case dbvdoris: 1337 case dbvstarrocks: 1338 // Doris: table names must BEGIN WITH A LETTER; column names 1339 // may also begin with an underscore — the role changes the 1340 // grammar, which is why objectType flows into this check 1341 return group == IdentifierProfile.ObjectGroup.COLUMN_GROUP 1342 ? UG_UNDERSCORE_START 1343 : 0; 1344 1345 case dbvmssql: 1346 case dbvazuresql: 1347 case dbvsybase: 1348 // T-SQL: letters "as defined in Unicode", _ @ # start, 1349 // @ $ # _ in body; SQL Server does not support supplementary 1350 // characters in metadata names → BMP-only letters 1351 return UG_UNDERSCORE_START | UG_AT_HASH_START | UG_DOLLAR_PART 1352 | UG_HASH_PART | UG_UNICODE_LETTERS_BMP; 1353 1354 case dbvsybasease: 1355 // Same T-SQL identifier character classes as the base. 1356 return UG_UNDERSCORE_START | UG_AT_HASH_START | UG_DOLLAR_PART 1357 | UG_HASH_PART | UG_UNICODE_LETTERS_BMP; 1358 1359 case dbvsqlanywhere: 1360 // Same classes again, independent block per HC-5. 1361 return UG_UNDERSCORE_START | UG_AT_HASH_START | UG_DOLLAR_PART 1362 | UG_HASH_PART | UG_UNICODE_LETTERS_BMP; 1363 1364 case dbvsybaseiq: 1365 // Same again, independent block per HC-5. 1366 return UG_UNDERSCORE_START | UG_AT_HASH_START | UG_DOLLAR_PART 1367 | UG_HASH_PART | UG_UNICODE_LETTERS_BMP; 1368 1369 case dbvbigquery: 1370 // GoogleSQL: [A-Za-z_][A-Za-z_0-9]* — ASCII only, no $ 1371 return UG_UNDERSCORE_START; 1372 1373 case dbvhive: 1374 case dbvsparksql: 1375 case dbvimpala: 1376 case dbvdatabricks: 1377 case dbvflink: 1378 return UG_UNDERSCORE_START; 1379 1380 default: 1381 return 0; // strictest core only 1382 } 1383 } 1384 1385 private static boolean isValidUnquoted(EDbVendor vendor, 1386 IdentifierProfile.ObjectGroup group, 1387 String lexical) { 1388 if (hasUnpairedSurrogate(lexical)) { 1389 return false; 1390 } 1391 int g = unquotedGrammar(vendor, group); 1392 boolean mysqlFamily = isMySqlFamily(vendor); 1393 int first = lexical.codePointAt(0); 1394 if (!isUnquotedStart(first, g)) { 1395 return false; 1396 } 1397 boolean allDigits = isAsciiDigit(first); 1398 int i = Character.charCount(first); 1399 while (i < lexical.length()) { 1400 int cp = lexical.codePointAt(i); 1401 if (!isUnquotedPart(cp, g)) { 1402 return false; 1403 } 1404 if (!isAsciiDigit(cp)) { 1405 allDigits = false; 1406 } 1407 i += Character.charCount(cp); 1408 } 1409 if (mysqlFamily && allDigits) { 1410 // MySQL: an unquoted identifier may not consist solely of digits 1411 return false; 1412 } 1413 return true; 1414 } 1415 1416 private static boolean isAsciiDigit(int cp) { 1417 return cp >= '0' && cp <= '9'; 1418 } 1419 1420 private static boolean isAsciiLetter(int cp) { 1421 return (cp >= 'a' && cp <= 'z') || (cp >= 'A' && cp <= 'Z'); 1422 } 1423 1424 private static boolean isUnquotedStart(int cp, int g) { 1425 if (isAsciiLetter(cp)) { 1426 return true; 1427 } 1428 if (cp == '_') { 1429 return (g & UG_UNDERSCORE_START) != 0; 1430 } 1431 if (cp == '@' || cp == '#') { 1432 return (g & UG_AT_HASH_START) != 0; 1433 } 1434 if (isAsciiDigit(cp)) { 1435 return (g & UG_DIGIT_START) != 0; 1436 } 1437 if ((g & UG_MYSQL_EXTENDED) != 0 && cp >= 0x80 && cp <= 0xFFFF) { 1438 return true; 1439 } 1440 return nonAsciiLetterAllowed(cp, g); 1441 } 1442 1443 private static boolean nonAsciiLetterAllowed(int cp, int g) { 1444 if (cp <= 0x7F || !Character.isLetter(cp)) { 1445 return false; 1446 } 1447 if ((g & UG_UNICODE_LETTERS) != 0) { 1448 return true; 1449 } 1450 return (g & UG_UNICODE_LETTERS_BMP) != 0 && cp <= 0xFFFF; 1451 } 1452 1453 private static boolean isUnquotedPart(int cp, int g) { 1454 if (isAsciiLetter(cp) || isAsciiDigit(cp) || cp == '_') { 1455 return true; 1456 } 1457 if (cp == '$') { 1458 return (g & UG_DOLLAR_PART) != 0; 1459 } 1460 if (cp == '#') { 1461 return (g & UG_HASH_PART) != 0; 1462 } 1463 if (cp == '@') { 1464 // T-SQL documents @ in body positions as well as at the start 1465 return (g & UG_AT_HASH_START) != 0; 1466 } 1467 if ((g & UG_MYSQL_EXTENDED) != 0 && cp >= 0x80 && cp <= 0xFFFF) { 1468 return true; 1469 } 1470 return nonAsciiLetterAllowed(cp, g); 1471 } 1472}