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 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&nbsp;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>&#92;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}