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&nbsp;Query
018 *     <code>#"x"</code>, PostgreSQL <code>U&amp;"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&nbsp;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>&#92;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}