from __future__ import annotations

import itertools
import logging
import re
import typing as t
from builtins import type as Type
from collections import defaultdict
from collections.abc import Sequence

from sqlglot import exp
from sqlglot._typing import F
from sqlglot.errors import (
    ErrorLevel,
    ParseError,
    TokenError,
    concat_messages,
    highlight_sql,
    merge_errors,
)
from sqlglot.expressions import apply_index_offset
from sqlglot.helper import ensure_list, i64, seq_get
from sqlglot.optimizer.scope import find_in_scope
from sqlglot.time import format_time
from sqlglot.tokens import Token, Tokenizer, TokenType
from sqlglot.trie import TrieResult, in_trie, new_trie

if t.TYPE_CHECKING:
    from re import Pattern

    from sqlglot._typing import BuilderArgs, E
    from sqlglot.dialects.dialect import Dialect, DialectType
    from sqlglot.expressions import ExpOrStr

    T = t.TypeVar("T")
    TCeilFloor = t.TypeVar("TCeilFloor", exp.Ceil, exp.Floor)

logger = logging.getLogger("sqlglot")

OPTIONS_TYPE = dict[str, Sequence[t.Union[Sequence[str], str]]]

# Excludes bare strings, which are also collections of strings, so that a single keyword
# can't accidentally be matched with substring semantics (e.g. _match_texts("FOO"))
TEXTS_TYPE = t.Union[tuple[str, ...], list[str], t.AbstractSet[str], t.Mapping[str, t.Any]]

# Used to detect alphabetical characters and +/- in timestamp literals
TIME_ZONE_RE: Pattern[str] = re.compile(r":.*?[a-zA-Z\+\-]")


def build_var_map(args: BuilderArgs) -> exp.StarMap | exp.VarMap:
    if len(args) == 1 and args[0].is_star:
        return exp.StarMap(this=args[0])

    keys: list[ExpOrStr] = []
    values: list[ExpOrStr] = []
    for i in range(0, len(args), 2):
        keys.append(args[i])
        values.append(args[i + 1])

    return exp.VarMap(keys=exp.array(*keys, copy=False), values=exp.array(*values, copy=False))


def build_like(args: BuilderArgs) -> exp.Escape | exp.Like:
    like = exp.Like(this=seq_get(args, 1), expression=seq_get(args, 0))
    return exp.Escape(this=like, expression=seq_get(args, 2)) if len(args) > 2 else like


def binary_range_parser(
    expr_type: Type[exp.Expr], reverse_args: bool = False
) -> t.Callable[[Parser, exp.Expr | None], exp.Expr | None]:
    def _parse_binary_range(self: Parser, this: exp.Expr | None) -> exp.Expr | None:
        expression = self._parse_bitwise()
        if reverse_args:
            this, expression = expression, this
        return self._parse_escape(self.expression(expr_type(this=this, expression=expression)))

    return _parse_binary_range


def build_logarithm(args: BuilderArgs, dialect: Dialect) -> exp.Func:
    # Default argument order is base, expression
    this = seq_get(args, 0)
    expression = seq_get(args, 1)

    if expression:
        if not dialect.LOG_BASE_FIRST:
            this, expression = expression, this
        return exp.Log(this=this, expression=expression)

    return (exp.Ln if dialect.parser_class.LOG_DEFAULTS_TO_LN else exp.Log)(this=this)


def build_hex(args: BuilderArgs, dialect: Dialect) -> exp.Hex | exp.LowerHex:
    arg = seq_get(args, 0)
    return exp.LowerHex(this=arg) if dialect.HEX_LOWERCASE else exp.Hex(this=arg)


def build_lower(args: BuilderArgs) -> exp.Lower | exp.Hex:
    # LOWER(HEX(..)) can be simplified to LowerHex to simplify its transpilation
    arg = seq_get(args, 0)
    return exp.LowerHex(this=arg.this) if isinstance(arg, exp.Hex) else exp.Lower(this=arg)


def build_upper(args: BuilderArgs) -> exp.Upper | exp.Hex:
    # UPPER(HEX(..)) can be simplified to Hex to simplify its transpilation
    arg = seq_get(args, 0)
    return exp.Hex(this=arg.this) if isinstance(arg, exp.Hex) else exp.Upper(this=arg)


def build_extract_json_with_path(
    expr_type: Type[E],
) -> t.Callable[[BuilderArgs, Dialect], E]:
    def _builder(args: BuilderArgs, dialect: Dialect) -> E:
        expression = expr_type(
            this=seq_get(args, 0), expression=dialect.to_json_path(seq_get(args, 1))
        )
        if len(args) > 2 and expr_type is exp.JSONExtract:
            expression.set("expressions", args[2:])
        if expr_type is exp.JSONExtractScalar:
            expression.set("scalar_only", dialect.JSON_EXTRACT_SCALAR_SCALAR_ONLY)

        return expression

    return _builder


def build_mod(args: BuilderArgs) -> exp.Mod:
    this = seq_get(args, 0)
    expression = seq_get(args, 1)

    # Wrap the operands if they are binary nodes, e.g. MOD(a + 1, 7) -> (a + 1) % 7
    this = exp.Paren(this=this) if isinstance(this, exp.Binary) else this
    expression = exp.Paren(this=expression) if isinstance(expression, exp.Binary) else expression

    return exp.Mod(this=this, expression=expression)


def build_pad(args: BuilderArgs, is_left: bool = True):
    return exp.Pad(
        this=seq_get(args, 0),
        expression=seq_get(args, 1),
        fill_pattern=seq_get(args, 2),
        is_left=is_left,
    )


def build_array_constructor(
    exp_class: Type[E], args: list[t.Any], bracket_kind: TokenType, dialect: Dialect
) -> exp.Expr:
    array_exp = exp_class(expressions=args)

    if exp_class == exp.Array and dialect.HAS_DISTINCT_ARRAY_CONSTRUCTORS:
        array_exp.set("bracket_notation", bracket_kind == TokenType.L_BRACKET)

    return array_exp


def build_convert_timezone(
    args: BuilderArgs, default_source_tz: str | None = None
) -> exp.ConvertTimezone | exp.Anonymous:
    if len(args) == 2:
        source_tz = exp.Literal.string(default_source_tz) if default_source_tz else None
        return exp.ConvertTimezone(
            source_tz=source_tz, target_tz=seq_get(args, 0), timestamp=seq_get(args, 1)
        )

    return exp.ConvertTimezone.from_arg_list(args)


def build_trim(args: BuilderArgs, is_left: bool = True, reverse_args: bool = False) -> exp.Trim:
    this, expression = seq_get(args, 0), seq_get(args, 1)

    if expression and reverse_args:
        this, expression = expression, this

    return exp.Trim(this=this, expression=expression, position="LEADING" if is_left else "TRAILING")


def build_coalesce(
    args: BuilderArgs, is_nvl: bool | None = None, is_null: bool | None = None
) -> exp.Coalesce:
    return exp.Coalesce(this=seq_get(args, 0), expressions=args[1:], is_nvl=is_nvl, is_null=is_null)


def build_locate_strposition(args: BuilderArgs) -> exp.StrPosition:
    return exp.StrPosition(
        this=seq_get(args, 1),
        substr=seq_get(args, 0),
        position=seq_get(args, 2),
    )


def build_array_append(args: BuilderArgs, dialect: Dialect) -> exp.ArrayAppend:
    """
    Builds ArrayAppend with NULL propagation semantics based on the dialect configuration.

    Some dialects (Databricks, Spark, Snowflake) return NULL when the input array is NULL.
    Others (DuckDB, PostgreSQL) create a new single-element array instead.

    Args:
        args: Function arguments [array, element]
        dialect: The dialect to read ARRAY_FUNCS_PROPAGATES_NULLS from

    Returns:
        ArrayAppend expression with appropriate null_propagation flag
    """
    return exp.ArrayAppend(
        this=seq_get(args, 0),
        expression=seq_get(args, 1),
        null_propagation=dialect.ARRAY_FUNCS_PROPAGATES_NULLS,
    )


def build_array_prepend(args: BuilderArgs, dialect: Dialect) -> exp.ArrayPrepend:
    """
    Builds ArrayPrepend with NULL propagation semantics based on the dialect configuration.

    Some dialects (Databricks, Spark, Snowflake) return NULL when the input array is NULL.
    Others (DuckDB, PostgreSQL) create a new single-element array instead.

    Args:
        args: Function arguments [array, element]
        dialect: The dialect to read ARRAY_FUNCS_PROPAGATES_NULLS from

    Returns:
        ArrayPrepend expression with appropriate null_propagation flag
    """
    return exp.ArrayPrepend(
        this=seq_get(args, 0),
        expression=seq_get(args, 1),
        null_propagation=dialect.ARRAY_FUNCS_PROPAGATES_NULLS,
    )


def build_array_concat(args: BuilderArgs, dialect: Dialect) -> exp.ArrayConcat:
    """
    Builds ArrayConcat with NULL propagation semantics based on the dialect configuration.

    Some dialects (Redshift, Snowflake) return NULL when any input array is NULL.
    Others (DuckDB, PostgreSQL) skip NULL arrays and continue concatenation.

    Args:
        args: Function arguments [array1, array2, ...] (variadic)
        dialect: The dialect to read ARRAY_FUNCS_PROPAGATES_NULLS from

    Returns:
        ArrayConcat expression with appropriate null_propagation flag
    """
    return exp.ArrayConcat(
        this=seq_get(args, 0),
        expressions=args[1:],
        null_propagation=dialect.ARRAY_FUNCS_PROPAGATES_NULLS,
    )


def build_array_remove(args: BuilderArgs, dialect: Dialect) -> exp.ArrayRemove:
    """
    Builds ArrayRemove with NULL propagation semantics based on the dialect configuration.

    Some dialects (Snowflake) return NULL when the removal value is NULL.
    Others (DuckDB) may return empty array due to NULL comparison semantics.

    Args:
        args: Function arguments [array, value_to_remove]
        dialect: The dialect to read ARRAY_FUNCS_PROPAGATES_NULLS from

    Returns:
        ArrayRemove expression with appropriate null_propagation flag
    """
    return exp.ArrayRemove(
        this=seq_get(args, 0),
        expression=seq_get(args, 1),
        null_propagation=dialect.ARRAY_FUNCS_PROPAGATES_NULLS,
    )


def _resolve_dialect(dialect: DialectType) -> Dialect:
    from sqlglot.dialects.dialect import Dialect

    return Dialect.get_or_raise(dialect)


def _unpivot_target(expr: exp.Expr) -> exp.Expr:
    # UNPIVOT's pre-FOR values and FOR field are new output names, not column references.
    if isinstance(expr, exp.Column) and not expr.table:
        return expr.this
    if isinstance(expr, exp.Tuple):
        expr.set("expressions", [_unpivot_target(e) for e in expr.expressions])
    return expr


SENTINEL_NONE: Token = Token(TokenType.SENTINEL, "SENTINEL")


class Parser:
    """
    Parser consumes a list of tokens produced by the Tokenizer and produces a parsed syntax tree.

    Args:
        error_level: The desired error level.
            Default: ErrorLevel.IMMEDIATE
        error_message_context: The amount of context to capture from a query string when displaying
            the error message (in number of characters).
            Default: 100
        max_errors: Maximum number of error messages to include in a raised ParseError.
            This is only relevant if error_level is ErrorLevel.RAISE.
            Default: 3
        max_nodes: Maximum number of AST nodes to prevent memory exhaustion.
            Set to -1 (default) to disable the check.
    """

    __slots__ = (
        "error_level",
        "error_message_context",
        "max_errors",
        "max_nodes",
        "dialect",
        "sql",
        "errors",
        "_tokens",
        "_index",
        "_curr",
        "_next",
        "_prev",
        "_prev_comments",
        "_pipe_cte_counter",
        "_chunks",
        "_chunk_index",
        "_tokens_size",
        "_node_count",
    )

    FUNCTIONS: t.ClassVar[dict[str, t.Callable]] = {
        **{name: func.from_arg_list for name, func in exp.FUNCTION_BY_NAME.items()},
        **dict.fromkeys(("COALESCE", "IFNULL", "NVL"), build_coalesce),
        "ARRAY": lambda args, dialect: exp.Array(expressions=args),
        "ARRAYAGG": lambda args, dialect: exp.ArrayAgg(
            this=seq_get(args, 0), nulls_excluded=dialect.ARRAY_AGG_INCLUDES_NULLS is None or None
        ),
        "ARRAY_AGG": lambda args, dialect: exp.ArrayAgg(
            this=seq_get(args, 0), nulls_excluded=dialect.ARRAY_AGG_INCLUDES_NULLS is None or None
        ),
        "ARRAY_APPEND": build_array_append,
        "ARRAY_CAT": build_array_concat,
        "ARRAY_CONCAT": build_array_concat,
        "ARRAY_INTERSECT": lambda args: exp.ArrayIntersect(expressions=args),
        "ARRAY_INTERSECTION": lambda args: exp.ArrayIntersect(expressions=args),
        "ARRAY_PREPEND": build_array_prepend,
        "ARRAY_REMOVE": build_array_remove,
        "COUNT": lambda args: exp.Count(this=seq_get(args, 0), expressions=args[1:], big_int=True),
        "CONCAT": lambda args, dialect: exp.Concat(
            expressions=args,
            safe=not dialect.STRICT_STRING_CONCAT,
            coalesce=dialect.CONCAT_COALESCE,
        ),
        "CONCAT_WS": lambda args, dialect: exp.ConcatWs(
            expressions=args,
            safe=not dialect.STRICT_STRING_CONCAT,
            coalesce=dialect.CONCAT_WS_COALESCE,
        ),
        "CONVERT_TIMEZONE": build_convert_timezone,
        "DATE_TO_DATE_STR": lambda args: exp.Cast(
            this=seq_get(args, 0),
            to=exp.DataType(this=exp.DType.TEXT),
        ),
        "GENERATE_DATE_ARRAY": lambda args: exp.GenerateDateArray(
            start=seq_get(args, 0),
            end=seq_get(args, 1),
            step=seq_get(args, 2) or exp.Interval(this=exp.Literal.string(1), unit=exp.var("DAY")),
        ),
        "GENERATE_UUID": lambda args, dialect: exp.Uuid(
            is_string=dialect.UUID_IS_STRING_TYPE or None
        ),
        "GLOB": lambda args: exp.Glob(this=seq_get(args, 1), expression=seq_get(args, 0)),
        "GREATEST": lambda args, dialect: exp.Greatest(
            this=seq_get(args, 0),
            expressions=args[1:],
            ignore_nulls=dialect.LEAST_GREATEST_IGNORES_NULLS,
        ),
        "LEAST": lambda args, dialect: exp.Least(
            this=seq_get(args, 0),
            expressions=args[1:],
            ignore_nulls=dialect.LEAST_GREATEST_IGNORES_NULLS,
        ),
        "HEX": build_hex,
        "JSON_EXTRACT": build_extract_json_with_path(exp.JSONExtract),
        "JSON_EXTRACT_SCALAR": build_extract_json_with_path(exp.JSONExtractScalar),
        "JSON_EXTRACT_PATH_TEXT": build_extract_json_with_path(exp.JSONExtractScalar),
        "JSON_KEYS": lambda args, dialect: exp.JSONKeys(
            this=seq_get(args, 0), expression=dialect.to_json_path(seq_get(args, 1))
        ),
        "LIKE": build_like,
        "LOG": build_logarithm,
        "LOG2": lambda args: exp.Log(this=exp.Literal.number(2), expression=seq_get(args, 0)),
        "LOG10": lambda args: exp.Log(this=exp.Literal.number(10), expression=seq_get(args, 0)),
        "LOWER": build_lower,
        "LPAD": lambda args: build_pad(args),
        "LEFTPAD": lambda args: build_pad(args),
        "LTRIM": lambda args: build_trim(args),
        "MOD": build_mod,
        "RIGHTPAD": lambda args: build_pad(args, is_left=False),
        "RPAD": lambda args: build_pad(args, is_left=False),
        "RTRIM": lambda args: build_trim(args, is_left=False),
        "SCOPE_RESOLUTION": lambda args: (
            exp.ScopeResolution(expression=seq_get(args, 0))
            if len(args) != 2
            else exp.ScopeResolution(this=seq_get(args, 0), expression=seq_get(args, 1))
        ),
        "STRPOS": exp.StrPosition.from_arg_list,
        "CHARINDEX": lambda args: build_locate_strposition(args),
        "INSTR": exp.StrPosition.from_arg_list,
        "LOCATE": lambda args: build_locate_strposition(args),
        "TIME_TO_TIME_STR": lambda args: exp.Cast(
            this=seq_get(args, 0),
            to=exp.DataType(this=exp.DType.TEXT),
        ),
        "TO_HEX": build_hex,
        "TS_OR_DS_TO_DATE_STR": lambda args: exp.Substring(
            this=exp.Cast(
                this=seq_get(args, 0),
                to=exp.DataType(this=exp.DType.TEXT),
            ),
            start=exp.Literal.number(1),
            length=exp.Literal.number(10),
        ),
        "UNNEST": lambda args: exp.Unnest(expressions=ensure_list(seq_get(args, 0))),
        "UPPER": build_upper,
        "UUID": lambda args, dialect: exp.Uuid(is_string=dialect.UUID_IS_STRING_TYPE or None),
        "UUID_STRING": lambda args, dialect: exp.Uuid(
            this=seq_get(args, 0),
            name=seq_get(args, 1),
            is_string=dialect.UUID_IS_STRING_TYPE or None,
        ),
        "VAR_MAP": build_var_map,
    }

    NO_PAREN_FUNCTIONS: t.ClassVar[dict] = {
        TokenType.CURRENT_DATE: exp.CurrentDate,
        TokenType.CURRENT_DATETIME: exp.CurrentDate,
        TokenType.CURRENT_TIME: exp.CurrentTime,
        TokenType.CURRENT_TIMESTAMP: exp.CurrentTimestamp,
        TokenType.CURRENT_USER: exp.CurrentUser,
        TokenType.CURRENT_ROLE: exp.CurrentRole,
    }

    STRUCT_TYPE_TOKENS: t.ClassVar = {
        TokenType.NESTED,
        TokenType.OBJECT,
        TokenType.STRUCT,
        TokenType.UNION,
    }

    NESTED_TYPE_TOKENS: t.ClassVar = {
        TokenType.ARRAY,
        TokenType.LIST,
        TokenType.LOWCARDINALITY,
        TokenType.MAP,
        TokenType.NULLABLE,
        TokenType.RANGE,
        *STRUCT_TYPE_TOKENS,
    }

    ENUM_TYPE_TOKENS: t.ClassVar = {
        TokenType.DYNAMIC,
        TokenType.ENUM,
        TokenType.ENUM8,
        TokenType.ENUM16,
    }

    AGGREGATE_TYPE_TOKENS: t.ClassVar = {
        TokenType.AGGREGATEFUNCTION,
        TokenType.SIMPLEAGGREGATEFUNCTION,
    }

    TYPE_TOKENS: t.ClassVar = {
        TokenType.BIT,
        TokenType.BOOLEAN,
        TokenType.TINYINT,
        TokenType.UTINYINT,
        TokenType.SMALLINT,
        TokenType.USMALLINT,
        TokenType.INT,
        TokenType.UINT,
        TokenType.BIGINT,
        TokenType.UBIGINT,
        TokenType.BIGNUM,
        TokenType.INT128,
        TokenType.UINT128,
        TokenType.INT256,
        TokenType.UINT256,
        TokenType.MEDIUMINT,
        TokenType.UMEDIUMINT,
        TokenType.FIXEDSTRING,
        TokenType.FLOAT,
        TokenType.DOUBLE,
        TokenType.UDOUBLE,
        TokenType.CHAR,
        TokenType.NCHAR,
        TokenType.VARCHAR,
        TokenType.NVARCHAR,
        TokenType.BPCHAR,
        TokenType.TEXT,
        TokenType.MEDIUMTEXT,
        TokenType.LONGTEXT,
        TokenType.BLOB,
        TokenType.MEDIUMBLOB,
        TokenType.LONGBLOB,
        TokenType.BINARY,
        TokenType.VARBINARY,
        TokenType.JSON,
        TokenType.JSONB,
        TokenType.INTERVAL,
        TokenType.TINYBLOB,
        TokenType.TINYTEXT,
        TokenType.TIME,
        TokenType.TIMETZ,
        TokenType.TIME_NS,
        TokenType.TIMESTAMP,
        TokenType.TIMESTAMP_S,
        TokenType.TIMESTAMP_MS,
        TokenType.TIMESTAMP_NS,
        TokenType.TIMESTAMPTZ,
        TokenType.TIMESTAMPLTZ,
        TokenType.TIMESTAMPNTZ,
        TokenType.DATETIME,
        TokenType.DATETIME2,
        TokenType.DATETIME64,
        TokenType.SMALLDATETIME,
        TokenType.DATE,
        TokenType.DATE32,
        TokenType.INT4RANGE,
        TokenType.INT4MULTIRANGE,
        TokenType.INT8RANGE,
        TokenType.INT8MULTIRANGE,
        TokenType.NUMRANGE,
        TokenType.NUMMULTIRANGE,
        TokenType.TSRANGE,
        TokenType.TSMULTIRANGE,
        TokenType.TSTZRANGE,
        TokenType.TSTZMULTIRANGE,
        TokenType.DATERANGE,
        TokenType.DATEMULTIRANGE,
        TokenType.DECIMAL,
        TokenType.DECIMAL32,
        TokenType.DECIMAL64,
        TokenType.DECIMAL128,
        TokenType.DECIMAL256,
        TokenType.DECFLOAT,
        TokenType.UDECIMAL,
        TokenType.BIGDECIMAL,
        TokenType.UUID,
        TokenType.GEOGRAPHY,
        TokenType.GEOGRAPHYPOINT,
        TokenType.GEOMETRY,
        TokenType.POINT,
        TokenType.RING,
        TokenType.LINESTRING,
        TokenType.MULTILINESTRING,
        TokenType.POLYGON,
        TokenType.MULTIPOLYGON,
        TokenType.HLLSKETCH,
        TokenType.HSTORE,
        TokenType.PSEUDO_TYPE,
        TokenType.SUPER,
        TokenType.SERIAL,
        TokenType.SMALLSERIAL,
        TokenType.BIGSERIAL,
        TokenType.XML,
        TokenType.YEAR,
        TokenType.USERDEFINED,
        TokenType.MONEY,
        TokenType.SMALLMONEY,
        TokenType.ROWVERSION,
        TokenType.IMAGE,
        TokenType.VARIANT,
        TokenType.VECTOR,
        TokenType.VOID,
        TokenType.OBJECT,
        TokenType.OBJECT_IDENTIFIER,
        TokenType.INET,
        TokenType.IPADDRESS,
        TokenType.IPPREFIX,
        TokenType.IPV4,
        TokenType.IPV6,
        TokenType.UNKNOWN,
        TokenType.NOTHING,
        TokenType.NULL,
        TokenType.NAME,
        TokenType.TDIGEST,
        TokenType.DYNAMIC,
        *ENUM_TYPE_TOKENS,
        *NESTED_TYPE_TOKENS,
        *AGGREGATE_TYPE_TOKENS,
    }

    SIGNED_TO_UNSIGNED_TYPE_TOKEN: t.ClassVar = {
        TokenType.BIGINT: TokenType.UBIGINT,
        TokenType.INT: TokenType.UINT,
        TokenType.MEDIUMINT: TokenType.UMEDIUMINT,
        TokenType.SMALLINT: TokenType.USMALLINT,
        TokenType.TINYINT: TokenType.UTINYINT,
        TokenType.DECIMAL: TokenType.UDECIMAL,
        TokenType.DOUBLE: TokenType.UDOUBLE,
    }

    SUBQUERY_PREDICATES: t.ClassVar = {
        TokenType.ANY: exp.Any,
        TokenType.ALL: exp.All,
        TokenType.EXISTS: exp.Exists,
        TokenType.SOME: exp.Any,
    }

    SUBQUERY_TOKENS: t.ClassVar = {
        TokenType.SELECT,
        TokenType.WITH,
        TokenType.FROM,
    }

    RESERVED_TOKENS: t.ClassVar = {
        *Tokenizer.SINGLE_TOKENS.values(),
        TokenType.SELECT,
    } - {TokenType.IDENTIFIER}

    # Tokens whose text is extracted from delimited source text (e.g. quoted identifiers,
    # string literals), so they must never be treated as keywords when matching by text
    TEXT_MATCH_EXCLUDED_TOKENS: t.ClassVar[frozenset] = frozenset(
        {
            TokenType.BIT_STRING,
            TokenType.BYTE_STRING,
            TokenType.HEREDOC_STRING,
            TokenType.HEX_STRING,
            TokenType.IDENTIFIER,
            TokenType.NATIONAL_STRING,
            TokenType.RAW_STRING,
            TokenType.STRING,
            TokenType.UNICODE_STRING,
        }
    )

    DB_CREATABLES: t.ClassVar = {
        TokenType.DATABASE,
        TokenType.DICTIONARY,
        TokenType.FILE_FORMAT,
        TokenType.MODEL,
        TokenType.NAMESPACE,
        TokenType.SCHEMA,
        TokenType.SEMANTIC_VIEW,
        TokenType.SEQUENCE,
        TokenType.SINK,
        TokenType.SOURCE,
        TokenType.STAGE,
        TokenType.STORAGE_INTEGRATION,
        TokenType.STREAMLIT,
        TokenType.TABLE,
        TokenType.TAG,
        TokenType.VIEW,
        TokenType.WAREHOUSE,
    }

    CREATABLES: t.ClassVar = {
        TokenType.COLUMN,
        TokenType.CONSTRAINT,
        TokenType.FOREIGN_KEY,
        TokenType.FUNCTION,
        TokenType.INDEX,
        TokenType.PROCEDURE,
        TokenType.TRIGGER,
        TokenType.TYPE,
        *DB_CREATABLES,
    }

    TRIGGER_EVENTS: t.ClassVar = {
        TokenType.INSERT,
        TokenType.UPDATE,
        TokenType.DELETE,
        TokenType.TRUNCATE,
    }

    ALTERABLES: t.ClassVar = {
        TokenType.INDEX,
        TokenType.TABLE,
        TokenType.VIEW,
        TokenType.SESSION,
    }

    # Tokens that can represent identifiers
    ID_VAR_TOKENS: t.ClassVar[set] = {
        TokenType.ALL,
        TokenType.ANALYZE,
        TokenType.ATTACH,
        TokenType.VAR,
        TokenType.ANTI,
        TokenType.APPLY,
        TokenType.ASC,
        TokenType.ASOF,
        TokenType.AUTO_INCREMENT,
        TokenType.BEGIN,
        TokenType.BPCHAR,
        TokenType.CACHE,
        TokenType.CASE,
        TokenType.COLLATE,
        TokenType.COMMAND,
        TokenType.COMMENT,
        TokenType.COMMIT,
        TokenType.CONSTRAINT,
        TokenType.COPY,
        TokenType.CUBE,
        TokenType.CURRENT_SCHEMA,
        TokenType.DECLARE,
        TokenType.DEFAULT,
        TokenType.DELETE,
        TokenType.DESC,
        TokenType.DESCRIBE,
        TokenType.DETACH,
        TokenType.DICTIONARY,
        TokenType.DIV,
        TokenType.END,
        TokenType.EXECUTE,
        TokenType.EXPORT,
        TokenType.ESCAPE,
        TokenType.FALSE,
        TokenType.FIRST,
        TokenType.FILE,
        TokenType.FILTER,
        TokenType.FINAL,
        TokenType.FORMAT,
        TokenType.FULL,
        TokenType.GET,
        TokenType.IDENTIFIER,
        TokenType.INOUT,
        TokenType.IS,
        TokenType.ISNULL,
        TokenType.INTERVAL,
        TokenType.KEEP,
        TokenType.KILL,
        TokenType.LEFT,
        TokenType.LIMIT,
        TokenType.LOAD,
        TokenType.LOCK,
        TokenType.MATCH,
        TokenType.MERGE,
        TokenType.NATURAL,
        TokenType.NEXT,
        TokenType.OFFSET,
        TokenType.OPERATOR,
        TokenType.ORDINALITY,
        TokenType.OUT,
        TokenType.OVER,
        TokenType.OVERLAPS,
        TokenType.OVERWRITE,
        TokenType.PARTITION,
        TokenType.PERCENT,
        TokenType.PIVOT,
        TokenType.PROJECTION,
        TokenType.PRAGMA,
        TokenType.PUT,
        TokenType.RANGE,
        TokenType.RECURSIVE,
        TokenType.REFERENCES,
        TokenType.REFRESH,
        TokenType.RENAME,
        TokenType.REPLACE,
        TokenType.RIGHT,
        TokenType.ROLLUP,
        TokenType.ROW,
        TokenType.ROWS,
        TokenType.SEMI,
        TokenType.SET,
        TokenType.SETTINGS,
        TokenType.SHOW,
        TokenType.STREAM,
        TokenType.STREAMLIT,
        TokenType.TEMPORARY,
        TokenType.TOP,
        TokenType.TRUE,
        TokenType.TRUNCATE,
        TokenType.UNIQUE,
        TokenType.UNNEST,
        TokenType.UNPIVOT,
        TokenType.UPDATE,
        TokenType.USE,
        TokenType.VOLATILE,
        TokenType.WINDOW,
        TokenType.CURRENT_CATALOG,
        TokenType.LOCALTIME,
        TokenType.LOCALTIMESTAMP,
        TokenType.SESSION_USER,
        TokenType.STRAIGHT_JOIN,
        *ALTERABLES,
        *CREATABLES,
        *SUBQUERY_PREDICATES,
        *TYPE_TOKENS,
        *NO_PAREN_FUNCTIONS,
    } - {TokenType.UNION}

    TABLE_ALIAS_TOKENS: t.ClassVar[set] = ID_VAR_TOKENS - {
        TokenType.ANTI,
        TokenType.ASOF,
        TokenType.FULL,
        TokenType.LEFT,
        TokenType.LOCK,
        TokenType.NATURAL,
        TokenType.RIGHT,
        TokenType.SEMI,
        TokenType.WINDOW,
    }

    ALIAS_TOKENS: t.ClassVar = ID_VAR_TOKENS

    COLON_PLACEHOLDER_TOKENS: t.ClassVar = ID_VAR_TOKENS

    ARRAY_CONSTRUCTORS: t.ClassVar = {
        "ARRAY": exp.Array,
        "LIST": exp.List,
    }

    COMMENT_TABLE_ALIAS_TOKENS: t.ClassVar = TABLE_ALIAS_TOKENS - {TokenType.IS}

    UPDATE_ALIAS_TOKENS: t.ClassVar = TABLE_ALIAS_TOKENS - {TokenType.SET}

    TRIM_TYPES: t.ClassVar = {"LEADING", "TRAILING", "BOTH"}

    # Tokens that indicate a simple column reference
    IDENTIFIER_TOKENS: t.ClassVar[frozenset] = frozenset({TokenType.VAR, TokenType.IDENTIFIER})

    BRACKETS: t.ClassVar[frozenset] = frozenset({TokenType.L_BRACKET, TokenType.L_BRACE})

    # Postfix tokens that prevent the bare column fast path
    COLUMN_POSTFIX_TOKENS: t.ClassVar[frozenset] = frozenset(
        {
            TokenType.L_PAREN,
            TokenType.L_BRACKET,
            TokenType.L_BRACE,
            TokenType.COLON,
            TokenType.JOIN_MARKER,
        }
    )

    TABLE_POSTFIX_TOKENS: t.ClassVar[frozenset] = frozenset(
        {
            TokenType.L_PAREN,
            TokenType.L_BRACKET,
            TokenType.L_BRACE,
            TokenType.PIVOT,
            TokenType.UNPIVOT,
            TokenType.TABLE_SAMPLE,
        }
    )

    FUNC_TOKENS: t.ClassVar = {
        TokenType.COLLATE,
        TokenType.COMMAND,
        TokenType.CURRENT_DATE,
        TokenType.CURRENT_DATETIME,
        TokenType.CURRENT_SCHEMA,
        TokenType.CURRENT_TIMESTAMP,
        TokenType.CURRENT_TIME,
        TokenType.CURRENT_USER,
        TokenType.CURRENT_CATALOG,
        TokenType.DECLARE,
        TokenType.FILTER,
        TokenType.FIRST,
        TokenType.FORMAT,
        TokenType.GET,
        TokenType.GLOB,
        TokenType.IDENTIFIER,
        TokenType.INDEX,
        TokenType.ISNULL,
        TokenType.ILIKE,
        TokenType.INSERT,
        TokenType.LIKE,
        TokenType.LOCALTIME,
        TokenType.LOCALTIMESTAMP,
        TokenType.MERGE,
        TokenType.NEXT,
        TokenType.OFFSET,
        TokenType.PRIMARY_KEY,
        TokenType.RANGE,
        TokenType.REPLACE,
        TokenType.RLIKE,
        TokenType.ROW,
        TokenType.SESSION_USER,
        TokenType.UNNEST,
        TokenType.VAR,
        TokenType.LEFT,
        TokenType.RIGHT,
        TokenType.SEQUENCE,
        TokenType.DATE,
        TokenType.DATETIME,
        TokenType.TABLE,
        TokenType.TIMESTAMP,
        TokenType.TIMESTAMPTZ,
        TokenType.TRUNCATE,
        TokenType.UTC_DATE,
        TokenType.UTC_TIME,
        TokenType.UTC_TIMESTAMP,
        TokenType.WINDOW,
        TokenType.XOR,
        *TYPE_TOKENS,
        *SUBQUERY_PREDICATES,
    }

    CONJUNCTION: t.ClassVar[dict[TokenType, type[exp.Expr]]] = {
        TokenType.AND: exp.And,
    }

    ASSIGNMENT: t.ClassVar[dict[TokenType, type[exp.Expr]]] = {
        TokenType.COLON_EQ: exp.PropertyEQ,
    }

    DISJUNCTION: t.ClassVar[dict[TokenType, type[exp.Expr]]] = {
        TokenType.OR: exp.Or,
    }

    EQUALITY: t.ClassVar = {
        TokenType.EQ: exp.EQ,
        TokenType.NEQ: exp.NEQ,
        TokenType.NULLSAFE_EQ: exp.NullSafeEQ,
    }

    COMPARISON: t.ClassVar = {
        TokenType.GT: exp.GT,
        TokenType.GTE: exp.GTE,
        TokenType.LT: exp.LT,
        TokenType.LTE: exp.LTE,
    }

    BITWISE: t.ClassVar = {
        TokenType.AMP: exp.BitwiseAnd,
        TokenType.CARET: exp.BitwiseXor,
        TokenType.PIPE: exp.BitwiseOr,
    }

    TERM: t.ClassVar = {
        TokenType.DASH: exp.Sub,
        TokenType.PLUS: exp.Add,
        TokenType.MOD: exp.Mod,
        TokenType.COLLATE: exp.Collate,
    }

    FACTOR: t.ClassVar = {
        TokenType.DIV: exp.IntDiv,
        TokenType.LR_ARROW: exp.Distance,
        TokenType.LLRR_ARROW: exp.DistanceNd,
        TokenType.SLASH: exp.Div,
        TokenType.STAR: exp.Mul,
    }

    EXPONENT: t.ClassVar[dict[TokenType, type[exp.Expr]]] = {}

    TIMES: t.ClassVar = {
        TokenType.TIME,
        TokenType.TIMETZ,
    }

    TIMESTAMPS: t.ClassVar = {
        TokenType.TIMESTAMP,
        TokenType.TIMESTAMPNTZ,
        TokenType.TIMESTAMPTZ,
        TokenType.TIMESTAMPLTZ,
        *TIMES,
    }

    SET_OPERATIONS: t.ClassVar = {
        TokenType.UNION,
        TokenType.INTERSECT,
        TokenType.EXCEPT,
    }

    JOIN_METHODS: t.ClassVar = {
        TokenType.ASOF,
        TokenType.NATURAL,
        TokenType.POSITIONAL,
    }

    JOIN_SIDES: t.ClassVar = {
        TokenType.LEFT,
        TokenType.RIGHT,
        TokenType.FULL,
    }

    JOIN_KINDS: t.ClassVar = {
        TokenType.ANTI,
        TokenType.CROSS,
        TokenType.INNER,
        TokenType.OUTER,
        TokenType.SEMI,
        TokenType.STRAIGHT_JOIN,
    }

    JOIN_HINTS: t.ClassVar[set[str]] = set()

    # Tokens that unambiguously end a table reference on the fast path
    TABLE_TERMINATORS: t.ClassVar[frozenset] = frozenset(
        {
            TokenType.COMMA,
            TokenType.GROUP_BY,
            TokenType.HAVING,
            TokenType.JOIN,
            TokenType.LIMIT,
            TokenType.ON,
            TokenType.ORDER_BY,
            TokenType.R_PAREN,
            TokenType.SEMICOLON,
            TokenType.SENTINEL,
            TokenType.WHERE,
            *SET_OPERATIONS,
            *JOIN_KINDS,
            *JOIN_METHODS,
            *JOIN_SIDES,
        }
    )

    LAMBDAS: t.ClassVar = {
        TokenType.ARROW: lambda self, expressions: self.expression(
            exp.Lambda(
                this=self._replace_lambda(
                    self._parse_disjunction(),
                    expressions,
                ),
                expressions=expressions,
            )
        ),
        TokenType.FARROW: lambda self, expressions: self.expression(
            exp.Kwarg(
                this=exp.var(expressions[0].name),
                expression=self._parse_disjunction() or self._parse_select(),
            )
        ),
    }

    # Whether lambda args include type annotations, e.g. TRANSFORM(arr, x INT -> x + 1) in Snowflake
    TYPED_LAMBDA_ARGS: t.ClassVar[bool] = False

    LAMBDA_ARG_TERMINATORS: t.ClassVar[frozenset] = frozenset({TokenType.COMMA, TokenType.R_PAREN})

    COLUMN_OPERATORS: t.ClassVar = {
        TokenType.DOT: None,
        TokenType.DOTCOLON: lambda self, this, to: self.expression(exp.JSONCast(this=this, to=to)),
        TokenType.DCOLON: lambda self, this, to: self.build_cast(
            strict=self.STRICT_CAST, this=this, to=to
        ),
        TokenType.ARROW: lambda self, this, path: self.expression(
            exp.JSONExtract(
                this=this,
                expression=self.dialect.to_json_path(path),
                only_json_types=self.JSON_ARROWS_REQUIRE_JSON_TYPE,
            )
        ),
        TokenType.DARROW: lambda self, this, path: self.expression(
            exp.JSONExtractScalar(
                this=this,
                expression=self.dialect.to_json_path(path),
                only_json_types=self.JSON_ARROWS_REQUIRE_JSON_TYPE,
                scalar_only=self.dialect.JSON_EXTRACT_SCALAR_SCALAR_ONLY,
            )
        ),
        TokenType.HASH_ARROW: lambda self, this, path: self.expression(
            exp.JSONBExtract(this=this, expression=path)
        ),
        TokenType.DHASH_ARROW: lambda self, this, path: self.expression(
            exp.JSONBExtractScalar(this=this, expression=path)
        ),
        TokenType.PLACEHOLDER: lambda self, this, key: self.expression(
            exp.JSONBContains(this=this, expression=key)
        ),
    }

    CAST_COLUMN_OPERATORS: t.ClassVar = {
        TokenType.DOTCOLON,
        TokenType.DCOLON,
    }

    EXPRESSION_PARSERS: t.ClassVar = {
        exp.Cluster: lambda self: self._parse_sort(exp.Cluster, TokenType.CLUSTER_BY),
        exp.Column: lambda self: self._parse_column(),
        exp.ColumnDef: lambda self: self._parse_column_def(self._parse_column()),
        exp.Condition: lambda self: self._parse_disjunction(),
        exp.DataType: lambda self: self._parse_types(allow_identifiers=False, schema=True),
        exp.Expr: lambda self: self._parse_expression(),
        exp.From: lambda self: self._parse_from(joins=True),
        exp.GrantPrincipal: lambda self: self._parse_grant_principal(),
        exp.GrantPrivilege: lambda self: self._parse_grant_privilege(),
        exp.Group: lambda self: self._parse_group(),
        exp.Having: lambda self: self._parse_having(),
        exp.Hint: lambda self: self._parse_hint_body(),
        exp.Identifier: lambda self: self._parse_id_var(),
        exp.Join: lambda self: self._parse_join(),
        exp.Lambda: lambda self: self._parse_lambda(),
        exp.Lateral: lambda self: self._parse_lateral(),
        exp.Limit: lambda self: self._parse_limit(),
        exp.Offset: lambda self: self._parse_offset(),
        exp.Order: lambda self: self._parse_order(),
        exp.Ordered: lambda self: self._parse_ordered(),
        exp.Properties: lambda self: self._parse_properties(),
        exp.PartitionedByProperty: lambda self: self._parse_partitioned_by(),
        exp.Qualify: lambda self: self._parse_qualify(),
        exp.Returning: lambda self: self._parse_returning(),
        exp.Select: lambda self: self._parse_select(),
        exp.Sort: lambda self: self._parse_sort(exp.Sort, TokenType.SORT_BY),
        exp.Table: lambda self: self._parse_table_parts(),
        exp.TableAlias: lambda self: self._parse_table_alias(),
        exp.Tuple: lambda self: self._parse_value(values=False),
        exp.Whens: lambda self: self._parse_when_matched(),
        exp.Where: lambda self: self._parse_where(),
        exp.Window: lambda self: self._parse_named_window(),
        exp.With: lambda self: self._parse_with(),
    }

    STATEMENT_PARSERS: t.ClassVar = {
        TokenType.ALTER: lambda self: self._parse_alter(),
        TokenType.ANALYZE: lambda self: self._parse_analyze(),
        TokenType.BEGIN: lambda self: self._parse_transaction(),
        TokenType.CACHE: lambda self: self._parse_cache(),
        TokenType.COMMENT: lambda self: self._parse_comment(),
        TokenType.COMMIT: lambda self: self._parse_commit_or_rollback(),
        TokenType.COPY: lambda self: self._parse_copy(),
        TokenType.CREATE: lambda self: self._parse_create(),
        TokenType.DECLARE: lambda self: self._parse_declare(),
        TokenType.DELETE: lambda self: self._parse_delete(),
        TokenType.DESC: lambda self: self._parse_describe(),
        TokenType.DESCRIBE: lambda self: self._parse_describe(),
        TokenType.DROP: lambda self: self._parse_drop(),
        TokenType.GRANT: lambda self: self._parse_grant(),
        TokenType.REVOKE: lambda self: self._parse_revoke(),
        TokenType.INSERT: lambda self: self._parse_insert(),
        TokenType.KILL: lambda self: self._parse_kill(),
        TokenType.LOAD: lambda self: self._parse_load(),
        TokenType.MERGE: lambda self: self._parse_merge(),
        TokenType.PIVOT: lambda self: self._parse_simplified_pivot(),
        TokenType.PRAGMA: lambda self: self.expression(exp.Pragma(this=self._parse_expression())),
        TokenType.REFRESH: lambda self: self._parse_refresh(),
        TokenType.ROLLBACK: lambda self: self._parse_commit_or_rollback(),
        TokenType.SET: lambda self: self._parse_set(),
        TokenType.TRUNCATE: lambda self: self._parse_truncate_table(),
        TokenType.UNCACHE: lambda self: self._parse_uncache(),
        TokenType.UNPIVOT: lambda self: self._parse_simplified_pivot(is_unpivot=True),
        TokenType.UPDATE: lambda self: self._parse_update(),
        TokenType.USE: lambda self: self._parse_use(),
        TokenType.SEMICOLON: lambda self: exp.Semicolon(),
    }

    UNARY_PARSERS: t.ClassVar = {
        TokenType.PLUS: lambda self: self._parse_unary(),  # Unary + is handled as a no-op
        TokenType.NOT: lambda self: self.expression(exp.Not(this=self._parse_equality())),
        TokenType.TILDE: lambda self: self.expression(exp.BitwiseNot(this=self._parse_unary())),
        TokenType.DASH: lambda self: self.expression(exp.Neg(this=self._parse_unary())),
        TokenType.PIPE_SLASH: lambda self: self.expression(exp.Sqrt(this=self._parse_unary())),
        TokenType.DPIPE_SLASH: lambda self: self.expression(exp.Cbrt(this=self._parse_unary())),
    }

    STRING_PARSERS: t.ClassVar = {
        TokenType.HEREDOC_STRING: lambda self, token: self.expression(
            exp.RawString(this=token.text), token
        ),
        TokenType.NATIONAL_STRING: lambda self, token: self.expression(
            exp.National(this=token.text), token
        ),
        TokenType.RAW_STRING: lambda self, token: self.expression(
            exp.RawString(this=token.text), token
        ),
        TokenType.STRING: lambda self, token: self.expression(
            exp.Literal(this=token.text, is_string=True), token
        ),
        TokenType.UNICODE_STRING: lambda self, token: self.expression(
            exp.UnicodeString(
                this=token.text, escape=self._match_text_seq("UESCAPE") and self._parse_string()
            ),
            token,
        ),
    }

    NUMERIC_PARSERS: t.ClassVar = {
        TokenType.BIT_STRING: lambda self, token: self.expression(
            exp.BitString(this=token.text), token
        ),
        TokenType.BYTE_STRING: lambda self, token: self.expression(
            exp.ByteString(
                this=token.text, is_bytes=self.dialect.BYTE_STRING_IS_BYTES_TYPE or None
            ),
            token,
        ),
        TokenType.HEX_STRING: lambda self, token: self.expression(
            exp.HexString(
                this=token.text, is_integer=self.dialect.HEX_STRING_IS_INTEGER_TYPE or None
            ),
            token,
        ),
        TokenType.NUMBER: lambda self, token: self.expression(
            exp.Literal(this=token.text, is_string=False), token
        ),
    }

    PRIMARY_PARSERS: t.ClassVar = {
        **STRING_PARSERS,
        **NUMERIC_PARSERS,
        TokenType.INTRODUCER: lambda self, token: self._parse_introducer(token),
        TokenType.NULL: lambda self, _: self.expression(exp.Null()),
        TokenType.TRUE: lambda self, _: self.expression(exp.Boolean(this=True)),
        TokenType.FALSE: lambda self, _: self.expression(exp.Boolean(this=False)),
        TokenType.SESSION_PARAMETER: lambda self, _: self._parse_session_parameter(),
        TokenType.STAR: lambda self, _: self._parse_star_ops(),
    }

    PLACEHOLDER_PARSERS: t.ClassVar = {
        TokenType.PLACEHOLDER: lambda self: self.expression(exp.Placeholder()),
        TokenType.PARAMETER: lambda self: self._parse_parameter(),
        TokenType.COLON: lambda self: (
            self.expression(exp.Placeholder(this=self._prev.text))
            if self._match_set(self.COLON_PLACEHOLDER_TOKENS)
            else None
        ),
    }

    RANGE_PARSERS: t.ClassVar = {
        TokenType.AT_GT: binary_range_parser(exp.ArrayContainsAll),
        TokenType.BETWEEN: lambda self, this: self._parse_between(this),
        TokenType.GLOB: binary_range_parser(exp.Glob),
        TokenType.ILIKE: binary_range_parser(exp.ILike),
        TokenType.IN: lambda self, this: self._parse_in(this),
        TokenType.IRLIKE: binary_range_parser(exp.RegexpILike),
        TokenType.IS: lambda self, this: self._parse_is(this),
        TokenType.LIKE: binary_range_parser(exp.Like),
        TokenType.LT_AT: binary_range_parser(exp.ArrayContainedBy),
        TokenType.OVERLAPS: binary_range_parser(exp.Overlaps),
        TokenType.RLIKE: binary_range_parser(exp.RegexpLike),
        TokenType.SIMILAR_TO: binary_range_parser(exp.SimilarTo),
        TokenType.FOR: lambda self, this: self._parse_comprehension(this),
        TokenType.QMARK_AMP: binary_range_parser(exp.JSONBContainsAllTopKeys),
        TokenType.QMARK_PIPE: binary_range_parser(exp.JSONBContainsAnyTopKeys),
        TokenType.HASH_DASH: binary_range_parser(exp.JSONBDeleteAtPath),
        TokenType.AT_QMARK: binary_range_parser(exp.JSONBPathExists),
        TokenType.ADJACENT: binary_range_parser(exp.Adjacent),
        TokenType.OPERATOR: lambda self, this: self._parse_operator(this),
        TokenType.AMP_LT: binary_range_parser(exp.ExtendsLeft),
        TokenType.AMP_GT: binary_range_parser(exp.ExtendsRight),
    }

    PIPE_SYNTAX_TRANSFORM_PARSERS: t.ClassVar = {
        "AGGREGATE": lambda self, query: self._parse_pipe_syntax_aggregate(query),
        "AS": lambda self, query: self._build_pipe_cte(
            query, [exp.Star()], self._parse_table_alias()
        ),
        "DISTINCT": lambda self, query: self._advance() or query.distinct(copy=False),
        "EXTEND": lambda self, query: self._parse_pipe_syntax_extend(query),
        "LIMIT": lambda self, query: self._parse_pipe_syntax_limit(query),
        "ORDER BY": lambda self, query: query.order_by(
            self._parse_order(), append=False, copy=False
        ),
        "PIVOT": lambda self, query: self._parse_pipe_syntax_pivot(query),
        "SELECT": lambda self, query: self._parse_pipe_syntax_select(query),
        "TABLESAMPLE": lambda self, query: self._parse_pipe_syntax_tablesample(query),
        "UNPIVOT": lambda self, query: self._parse_pipe_syntax_pivot(query),
        "WHERE": lambda self, query: query.where(self._parse_where(), copy=False),
    }

    PROPERTY_PARSERS: t.ClassVar[dict[str, t.Callable]] = {
        "ALLOWED_VALUES": lambda self: self.expression(
            exp.AllowedValuesProperty(expressions=self._parse_csv(self._parse_primary))
        ),
        "ALGORITHM": lambda self: self._parse_property_assignment(exp.AlgorithmProperty),
        "AUTO": lambda self: self._parse_auto_property(),
        "AUTO_INCREMENT": lambda self: self._parse_property_assignment(exp.AutoIncrementProperty),
        "BACKUP": lambda self: self.expression(
            exp.BackupProperty(this=self._parse_var(any_token=True))
        ),
        "BLOCKCOMPRESSION": lambda self: self._parse_blockcompression(),
        "CALLED": lambda self: self._parse_called_on_null_input_property(),
        "CHARSET": lambda self, **kwargs: self._parse_character_set(**kwargs),
        "CHECKSUM": lambda self: self._parse_checksum(),
        "CLUSTER BY": lambda self: self._parse_cluster_property(),
        "CLUSTERED": lambda self: self._parse_clustered_by(),
        "COLLATE": lambda self, **kwargs: self._parse_property_assignment(
            exp.CollateProperty, **kwargs
        ),
        "COMMENT": lambda self: self._parse_property_assignment(exp.SchemaCommentProperty),
        "CONTAINS": lambda self: self._parse_contains_property(),
        "COPY": lambda self: self._parse_copy_property(),
        "DATABLOCKSIZE": lambda self, **kwargs: self._parse_datablocksize(**kwargs),
        "DATA_DELETION": lambda self: self._parse_data_deletion_property(),
        "DEFINER": lambda self: self._parse_definer(),
        "DETERMINISTIC": lambda self: self.expression(
            exp.StabilityProperty(this=exp.Literal.string("IMMUTABLE"))
        ),
        "DISTRIBUTED": lambda self: self._parse_distributed_property(),
        "DUPLICATE": lambda self: self._parse_composite_key_property(exp.DuplicateKeyProperty),
        "DYNAMIC": lambda self: self.expression(exp.DynamicProperty()),
        "DISTKEY": lambda self: self._parse_distkey(),
        "DISTSTYLE": lambda self: self._parse_property_assignment(exp.DistStyleProperty),
        "EMPTY": lambda self: self.expression(exp.EmptyProperty()),
        "ENGINE": lambda self: self._parse_property_assignment(exp.EngineProperty),
        "ENVIRONMENT": lambda self: self.expression(
            exp.EnviromentProperty(expressions=self._parse_wrapped_csv(self._parse_assignment))
        ),
        "HANDLER": lambda self: self._parse_property_assignment(exp.HandlerProperty),
        "EXECUTE": lambda self: self._parse_property_assignment(exp.ExecuteAsProperty),
        "EXTERNAL": lambda self: self.expression(exp.ExternalProperty()),
        "FALLBACK": lambda self, **kwargs: self._parse_fallback(**kwargs),
        "FORMAT": lambda self: self._parse_property_assignment(exp.FileFormatProperty),
        "FREESPACE": lambda self: self._parse_freespace(),
        "GLOBAL": lambda self: self.expression(exp.GlobalProperty()),
        "HEAP": lambda self: self.expression(exp.HeapProperty()),
        "ICEBERG": lambda self: self.expression(exp.IcebergProperty()),
        "IMMUTABLE": lambda self: self.expression(
            exp.StabilityProperty(this=exp.Literal.string("IMMUTABLE"))
        ),
        "INHERITS": lambda self: self.expression(
            exp.InheritsProperty(expressions=self._parse_wrapped_csv(self._parse_table))
        ),
        "INPUT": lambda self: self.expression(exp.InputModelProperty(this=self._parse_schema())),
        "JOURNAL": lambda self, **kwargs: self._parse_journal(**kwargs),
        "LANGUAGE": lambda self: self._parse_property_assignment(exp.LanguageProperty),
        "LAYOUT": lambda self: self._parse_dict_property(this="LAYOUT"),
        "LIFETIME": lambda self: self._parse_dict_range(this="LIFETIME"),
        "LIKE": lambda self: self._parse_create_like(),
        "LOCATION": lambda self: self._parse_property_assignment(exp.LocationProperty),
        "LOCK": lambda self: self._parse_locking(),
        "LOCKING": lambda self: self._parse_locking(),
        "LOG": lambda self, **kwargs: self._parse_log(**kwargs),
        "MATERIALIZED": lambda self: self.expression(exp.MaterializedProperty()),
        "MERGEBLOCKRATIO": lambda self, **kwargs: self._parse_mergeblockratio(**kwargs),
        "MODIFIES": lambda self: self._parse_modifies_property(),
        "MULTISET": lambda self: self.expression(exp.SetProperty(multi=True)),
        "NO": lambda self: self._parse_no_property(),
        "ON": lambda self: self._parse_on_property(),
        "ORDER BY": lambda self: self._parse_order(skip_order_token=True),
        "OUTPUT": lambda self: self.expression(exp.OutputModelProperty(this=self._parse_schema())),
        "PARTITION": lambda self: self._parse_partitioned_of(),
        "PARTITION BY": lambda self: self._parse_partitioned_by(),
        "PARTITIONED BY": lambda self: self._parse_partitioned_by(),
        "PARTITIONED_BY": lambda self: self._parse_partitioned_by(),
        "PRIMARY KEY": lambda self: self._parse_primary_key(in_props=True),
        "RANGE": lambda self: self._parse_dict_range(this="RANGE"),
        "READS": lambda self: self._parse_reads_property(),
        "REMOTE": lambda self: self._parse_remote_with_connection(),
        "RETURNS": lambda self: self._parse_returns(),
        "STRICT": lambda self: self.expression(exp.StrictProperty()),
        "STREAMING": lambda self: self.expression(exp.StreamingTableProperty()),
        "ROW": lambda self: self._parse_row(),
        "ROW_FORMAT": lambda self: self._parse_property_assignment(exp.RowFormatProperty),
        "SAMPLE": lambda self: self.expression(
            exp.SampleProperty(this=self._match_text_seq("BY") and self._parse_bitwise())
        ),
        "SECURE": lambda self: self.expression(exp.SecureProperty()),
        "SECURITY": lambda self: self._parse_sql_security(),
        "SQL SECURITY": lambda self: self._parse_sql_security(),
        "SET": lambda self: self.expression(exp.SetProperty(multi=False)),
        "SETTINGS": lambda self: self._parse_settings_property(),
        "SHARING": lambda self: self._parse_property_assignment(exp.SharingProperty),
        "SORTKEY": lambda self: self._parse_sortkey(),
        "SOURCE": lambda self: self._parse_dict_property(this="SOURCE"),
        "STABLE": lambda self: self.expression(
            exp.StabilityProperty(this=exp.Literal.string("STABLE"))
        ),
        "STORED": lambda self: self._parse_stored(),
        "SYSTEM_VERSIONING": lambda self: self._parse_system_versioning_property(),
        "TBLPROPERTIES": lambda self: self._parse_wrapped_properties(),
        "TEMP": lambda self: self.expression(exp.TemporaryProperty()),
        "TEMPORARY": lambda self: self.expression(exp.TemporaryProperty()),
        "TO": lambda self: self._parse_to_table(),
        "TRANSIENT": lambda self: self.expression(exp.TransientProperty()),
        "TRANSFORM": lambda self: self.expression(
            exp.TransformModelProperty(expressions=self._parse_wrapped_csv(self._parse_expression))
        ),
        "TTL": lambda self: self._parse_ttl(),
        "USING": lambda self: self._parse_property_assignment(exp.FileFormatProperty),
        "UNLOGGED": lambda self: self.expression(exp.UnloggedProperty()),
        "VOLATILE": lambda self: self._parse_volatile_property(),
        "WITH": lambda self: self._parse_with_property(),
    }

    CONSTRAINT_PARSERS: t.ClassVar = {
        "AUTOINCREMENT": lambda self: self._parse_auto_increment(),
        "AUTO_INCREMENT": lambda self: self._parse_auto_increment(),
        "CASESPECIFIC": lambda self: self.expression(exp.CaseSpecificColumnConstraint(not_=False)),
        "CHECK": lambda self: self._parse_check_constraint(),
        "COLLATE": lambda self: self.expression(
            exp.CollateColumnConstraint(this=self._parse_identifier() or self._parse_column())
        ),
        "COMMENT": lambda self: self.expression(
            exp.CommentColumnConstraint(this=self._parse_string())
        ),
        "COMPRESS": lambda self: self._parse_compress(),
        "CLUSTERED": lambda self: self.expression(
            exp.ClusteredColumnConstraint(this=self._parse_wrapped_csv(self._parse_ordered))
        ),
        "NONCLUSTERED": lambda self: self.expression(
            exp.NonClusteredColumnConstraint(this=self._parse_wrapped_csv(self._parse_ordered))
        ),
        "DEFAULT": lambda self: self.expression(
            exp.DefaultColumnConstraint(this=self._parse_bitwise())
        ),
        "ENCODE": lambda self: self.expression(exp.EncodeColumnConstraint(this=self._parse_var())),
        "EPHEMERAL": lambda self: self.expression(
            exp.EphemeralColumnConstraint(this=self._parse_bitwise())
        ),
        "EXCLUDE": lambda self: self.expression(
            exp.ExcludeColumnConstraint(this=self._parse_index_params())
        ),
        "FOREIGN KEY": lambda self: self._parse_foreign_key(),
        "FORMAT": lambda self: self.expression(
            exp.DateFormatColumnConstraint(this=self._parse_var_or_string())
        ),
        "GENERATED": lambda self: self._parse_generated_as_identity(),
        "IDENTITY": lambda self: self._parse_auto_increment(),
        "INLINE": lambda self: self._parse_inline(),
        "LIKE": lambda self: self._parse_create_like(),
        "NOT": lambda self: self._parse_not_constraint(),
        "NULL": lambda self: self.expression(exp.NotNullColumnConstraint(allow_null=True)),
        "ON": lambda self: (
            (
                self._match(TokenType.UPDATE)
                and self.expression(exp.OnUpdateColumnConstraint(this=self._parse_function()))
            )
            or self.expression(exp.OnProperty(this=self._parse_id_var()))
        ),
        "PATH": lambda self: self.expression(exp.PathColumnConstraint(this=self._parse_string())),
        "PERIOD": lambda self: self._parse_period_for_system_time(),
        "PRIMARY KEY": lambda self: self._parse_primary_key(),
        "REFERENCES": lambda self: self._parse_references(match=False),
        "TITLE": lambda self: self.expression(
            exp.TitleColumnConstraint(this=self._parse_var_or_string())
        ),
        "TTL": lambda self: self.expression(exp.MergeTreeTTL(expressions=[self._parse_bitwise()])),
        "UNIQUE": lambda self: self._parse_unique(),
        "UPPERCASE": lambda self: self.expression(exp.UppercaseColumnConstraint()),
        "WITH": lambda self: self.expression(
            exp.Properties(expressions=self._parse_wrapped_properties())
        ),
        "BUCKET": lambda self: self._parse_partitioned_by_bucket_or_truncate(),
        "TRUNCATE": lambda self: self._parse_partitioned_by_bucket_or_truncate(),
    }

    def _parse_partitioned_by_bucket_or_truncate(self) -> exp.Expr | None:
        if not self._match(TokenType.L_PAREN, advance=False):
            # Partitioning by bucket or truncate follows the syntax:
            # PARTITION BY (BUCKET(..) | TRUNCATE(..))
            # If we don't have parenthesis after each keyword, we should instead parse this as an identifier
            self._retreat(self._index - 1)
            return None

        klass = (
            exp.PartitionedByBucket
            if self._prev.text.upper() == "BUCKET"
            else exp.PartitionByTruncate
        )

        args = self._parse_wrapped_csv(lambda: self._parse_primary() or self._parse_column())
        this, expression = seq_get(args, 0), seq_get(args, 1)

        if isinstance(this, exp.Literal):
            # Check for Iceberg partition transforms (bucket / truncate) and ensure their arguments are in the right order
            #  - For Hive, it's `bucket(<num buckets>, <col name>)` or `truncate(<num_chars>, <col_name>)`
            #  - For Trino, it's reversed - `bucket(<col name>, <num buckets>)` or `truncate(<col_name>, <num_chars>)`
            # Both variants are canonicalized in the latter i.e `bucket(<col name>, <num buckets>)`
            #
            # Hive ref: https://docs.aws.amazon.com/athena/latest/ug/querying-iceberg-creating-tables.html#querying-iceberg-partitioning
            # Trino ref: https://docs.aws.amazon.com/athena/latest/ug/create-table-as.html#ctas-table-properties
            this, expression = expression, this

        return self.expression(klass(this=this, expression=expression))

    ALTER_PARSERS: t.ClassVar = {
        "ADD": lambda self: self._parse_alter_table_add(),
        "AS": lambda self: self._parse_select(),
        "ALTER": lambda self: self._parse_alter_table_alter(),
        "CLUSTER BY": lambda self: self._parse_cluster_property(),
        "DELETE": lambda self: self.expression(exp.Delete(where=self._parse_where())),
        "DROP": lambda self: self._parse_alter_table_drop(),
        "RENAME": lambda self: self._parse_alter_table_rename(),
        "SET": lambda self: self._parse_alter_table_set(),
        "SWAP": lambda self: self.expression(
            exp.SwapTable(this=self._match(TokenType.WITH) and self._parse_table(schema=True))
        ),
    }

    ALTER_ALTER_PARSERS: t.ClassVar = {
        "DISTKEY": lambda self: self._parse_alter_diststyle(),
        "DISTSTYLE": lambda self: self._parse_alter_diststyle(),
        "SORTKEY": lambda self: self._parse_alter_sortkey(),
        "COMPOUND": lambda self: self._parse_alter_sortkey(compound=True),
    }

    SCHEMA_UNNAMED_CONSTRAINTS: t.ClassVar = {
        "CHECK",
        "EXCLUDE",
        "FOREIGN KEY",
        "LIKE",
        "PERIOD",
        "PRIMARY KEY",
        "UNIQUE",
        "BUCKET",
        "TRUNCATE",
    }

    NO_PAREN_FUNCTION_PARSERS: t.ClassVar = {
        "ANY": lambda self: self.expression(exp.Any(this=self._parse_bitwise())),
        "CASE": lambda self: self._parse_case(),
        "CONNECT_BY_ROOT": lambda self: self.expression(
            exp.ConnectByRoot(this=self._parse_column())
        ),
        "IF": lambda self: self._parse_if(),
    }

    INVALID_FUNC_NAME_TOKENS: t.ClassVar = {
        TokenType.IDENTIFIER,
        TokenType.STRING,
    }

    FUNCTIONS_WITH_ALIASED_ARGS: t.ClassVar = {"STRUCT"}

    KEY_VALUE_DEFINITIONS: t.ClassVar = (exp.Alias, exp.EQ, exp.PropertyEQ, exp.Slice)

    FUNCTION_PARSERS: t.ClassVar[dict[str, t.Callable]] = {
        **{
            name: lambda self: self._parse_distinct_arg_function(exp.ArgMax)
            for name in exp.ArgMax.sql_names()
        },
        **{
            name: lambda self: self._parse_distinct_arg_function(exp.ArgMin)
            for name in exp.ArgMin.sql_names()
        },
        "CAST": lambda self: self._parse_cast(self.STRICT_CAST),
        "CEIL": lambda self: self._parse_ceil_floor(exp.Ceil),
        "CONVERT": lambda self: self._parse_convert(self.STRICT_CAST),
        "CHAR": lambda self: self._parse_char(),
        "CHR": lambda self: self._parse_char(),
        "DECODE": lambda self: self._parse_decode(),
        "EXTRACT": lambda self: self._parse_extract(),
        "FLOOR": lambda self: self._parse_ceil_floor(exp.Floor),
        "GAP_FILL": lambda self: self._parse_gap_fill(),
        "INITCAP": lambda self: self._parse_initcap(),
        "JSON_OBJECT": lambda self: self._parse_json_object(),
        "JSON_OBJECTAGG": lambda self: self._parse_json_object(agg=True),
        "JSON_TABLE": lambda self: self._parse_json_table(),
        "MATCH": lambda self: self._parse_match_against(),
        "NORMALIZE": lambda self: self._parse_normalize(),
        "OPENJSON": lambda self: self._parse_open_json(),
        "OVERLAY": lambda self: self._parse_overlay(),
        "POSITION": lambda self: self._parse_position(),
        "SAFE_CAST": lambda self: self._parse_cast(False, safe=True),
        "STRING_AGG": lambda self: self._parse_string_agg(),
        "SUBSTRING": lambda self: self._parse_substring(),
        "TRIM": lambda self: self._parse_trim(),
        "TRY_CAST": lambda self: self._parse_cast(False, safe=True),
        "TRY_CONVERT": lambda self: self._parse_convert(False, safe=True),
        "XMLELEMENT": lambda self: self._parse_xml_element(),
        "XMLTABLE": lambda self: self._parse_xml_table(),
    }

    QUERY_MODIFIER_PARSERS: t.ClassVar = {
        TokenType.MATCH_RECOGNIZE: lambda self: ("match", self._parse_match_recognize()),
        TokenType.PREWHERE: lambda self: ("prewhere", self._parse_prewhere()),
        TokenType.WHERE: lambda self: ("where", self._parse_where()),
        TokenType.GROUP_BY: lambda self: ("group", self._parse_group()),
        TokenType.HAVING: lambda self: ("having", self._parse_having()),
        TokenType.QUALIFY: lambda self: ("qualify", self._parse_qualify()),
        TokenType.WINDOW: lambda self: ("windows", self._parse_window_clause()),
        TokenType.ORDER_BY: lambda self: ("order", self._parse_order()),
        TokenType.LIMIT: lambda self: ("limit", self._parse_limit()),
        TokenType.FETCH: lambda self: ("limit", self._parse_limit()),
        TokenType.OFFSET: lambda self: ("offset", self._parse_offset()),
        TokenType.FOR: lambda self: ("locks", self._parse_locks()),
        TokenType.LOCK: lambda self: ("locks", self._parse_locks()),
        TokenType.TABLE_SAMPLE: lambda self: ("sample", self._parse_table_sample(as_modifier=True)),
        TokenType.USING: lambda self: ("sample", self._parse_table_sample(as_modifier=True)),
        TokenType.CLUSTER_BY: lambda self: (
            "cluster",
            self._parse_cluster(),
        ),
        TokenType.DISTRIBUTE_BY: lambda self: (
            "distribute",
            self._parse_sort(exp.Distribute, TokenType.DISTRIBUTE_BY),
        ),
        TokenType.SORT_BY: lambda self: ("sort", self._parse_sort(exp.Sort, TokenType.SORT_BY)),
        TokenType.CONNECT_BY: lambda self: ("connect", self._parse_connect(skip_start_token=True)),
    }
    QUERY_MODIFIER_TOKENS: t.ClassVar = set(QUERY_MODIFIER_PARSERS)

    SET_PARSERS: t.ClassVar = {
        "GLOBAL": lambda self: self._parse_set_item_assignment("GLOBAL"),
        "LOCAL": lambda self: self._parse_set_item_assignment("LOCAL"),
        "SESSION": lambda self: self._parse_set_item_assignment("SESSION"),
        "TRANSACTION": lambda self: self._parse_set_transaction(),
    }

    SHOW_PARSERS: t.ClassVar[dict[str, t.Callable]] = {}

    TYPE_LITERAL_PARSERS: t.ClassVar = {
        exp.DType.JSON: lambda self, this, _: self.expression(exp.ParseJSON(this=this)),
    }

    TYPE_CONVERTERS: t.ClassVar[dict[exp.DType, t.Callable[[exp.DataType], exp.DataType]]] = {}

    DDL_SELECT_TOKENS: t.ClassVar = {TokenType.SELECT, TokenType.WITH, TokenType.L_PAREN}

    PRE_VOLATILE_TOKENS: t.ClassVar = {TokenType.CREATE, TokenType.REPLACE, TokenType.UNIQUE}

    TRANSACTION_KIND: t.ClassVar = {"DEFERRED", "IMMEDIATE", "EXCLUSIVE"}
    TRANSACTION_CHARACTERISTICS: t.ClassVar[OPTIONS_TYPE] = {
        "ISOLATION": (
            ("LEVEL", "REPEATABLE", "READ"),
            ("LEVEL", "READ", "COMMITTED"),
            ("LEVEL", "READ", "UNCOMITTED"),
            ("LEVEL", "SERIALIZABLE"),
        ),
        "READ": ("WRITE", "ONLY"),
    }

    CONFLICT_ACTIONS: t.ClassVar[OPTIONS_TYPE] = {
        **dict.fromkeys(("ABORT", "FAIL", "IGNORE", "REPLACE", "ROLLBACK", "UPDATE"), tuple()),
        "DO": ("NOTHING", "UPDATE"),
    }

    TRIGGER_TIMING: t.ClassVar[OPTIONS_TYPE] = {
        "INSTEAD": (("OF",),),
        "BEFORE": tuple(),
        "AFTER": tuple(),
    }

    TRIGGER_DEFERRABLE: t.ClassVar[OPTIONS_TYPE] = {
        "NOT": (("DEFERRABLE",),),
        "DEFERRABLE": tuple(),
    }

    CREATE_SEQUENCE: t.ClassVar[OPTIONS_TYPE] = {
        "SCALE": ("EXTEND", "NOEXTEND"),
        "SHARD": ("EXTEND", "NOEXTEND"),
        "NO": ("CYCLE", "CACHE", "MAXVALUE", "MINVALUE"),
        **dict.fromkeys(
            (
                "SESSION",
                "GLOBAL",
                "KEEP",
                "NOKEEP",
                "ORDER",
                "NOORDER",
                "NOCACHE",
                "CYCLE",
                "NOCYCLE",
                "NOMINVALUE",
                "NOMAXVALUE",
                "NOSCALE",
                "NOSHARD",
            ),
            tuple(),
        ),
    }

    ISOLATED_LOADING_OPTIONS: t.ClassVar[OPTIONS_TYPE] = {"FOR": ("ALL", "INSERT", "NONE")}

    USABLES: t.ClassVar[OPTIONS_TYPE] = dict.fromkeys(
        ("ROLE", "WAREHOUSE", "DATABASE", "SCHEMA", "CATALOG"), tuple()
    )

    CAST_ACTIONS: t.ClassVar[OPTIONS_TYPE] = dict.fromkeys(("RENAME", "ADD"), ("FIELDS",))

    SCHEMA_BINDING_OPTIONS: t.ClassVar[OPTIONS_TYPE] = {
        "TYPE": ("EVOLUTION",),
        **dict.fromkeys(("BINDING", "COMPENSATION", "EVOLUTION"), tuple()),
    }

    PROCEDURE_OPTIONS: t.ClassVar[OPTIONS_TYPE] = {}

    EXECUTE_AS_OPTIONS: t.ClassVar[OPTIONS_TYPE] = dict.fromkeys(
        ("CALLER", "SELF", "OWNER"), tuple()
    )

    KEY_CONSTRAINT_OPTIONS: t.ClassVar[OPTIONS_TYPE] = {
        "NOT": ("ENFORCED",),
        "MATCH": (
            "FULL",
            "PARTIAL",
            "SIMPLE",
        ),
        "INITIALLY": ("DEFERRED", "IMMEDIATE"),
        "USING": (
            "BTREE",
            "HASH",
        ),
        **dict.fromkeys(("DEFERRABLE", "NORELY", "RELY"), tuple()),
    }

    WINDOW_EXCLUDE_OPTIONS: t.ClassVar[OPTIONS_TYPE] = {
        "NO": ("OTHERS",),
        "CURRENT": ("ROW",),
        **dict.fromkeys(("GROUP", "TIES"), tuple()),
    }

    INSERT_ALTERNATIVES: t.ClassVar = {"ABORT", "FAIL", "IGNORE", "REPLACE", "ROLLBACK"}

    CLONE_KEYWORDS: t.ClassVar = {"CLONE", "COPY"}
    # Time travel clause prefixes, mapped to whether they pin a timestamp or a version
    VERSION_PHRASES: t.ClassVar[dict[tuple[str, ...], str]] = {
        ("FOR", "SYSTEM_TIME"): "TIMESTAMP",
        ("FOR", "SYSTEM", "TIME"): "TIMESTAMP",
        ("FOR", "TIMESTAMP"): "TIMESTAMP",
        ("FOR", "VERSION"): "VERSION",
        ("TIMESTAMP", "AS", "OF"): "TIMESTAMP",
        ("VERSION", "AS", "OF"): "VERSION",
    }

    HISTORICAL_DATA_PREFIX: t.ClassVar = {"AT", "BEFORE", "END"}
    HISTORICAL_DATA_KIND: t.ClassVar = {"OFFSET", "STATEMENT", "STREAM", "TIMESTAMP", "VERSION"}

    OPCLASS_FOLLOW_KEYWORDS: t.ClassVar = {"ASC", "DESC", "NULLS", "WITH"}

    OPTYPE_FOLLOW_TOKENS: t.ClassVar = {TokenType.COMMA, TokenType.R_PAREN}

    TABLE_INDEX_HINT_TOKENS: t.ClassVar = {TokenType.FORCE, TokenType.IGNORE, TokenType.USE}

    VIEW_ATTRIBUTES: t.ClassVar = {"ENCRYPTION", "SCHEMABINDING", "VIEW_METADATA"}

    WINDOW_ALIAS_TOKENS: t.ClassVar = ID_VAR_TOKENS - {TokenType.RANGE, TokenType.ROWS}
    WINDOW_BEFORE_PAREN_TOKENS: t.ClassVar = {TokenType.OVER}
    WINDOW_SIDES: t.ClassVar = {"FOLLOWING", "PRECEDING"}

    JSON_KEY_VALUE_SEPARATOR_TOKENS: t.ClassVar = {TokenType.COLON, TokenType.COMMA, TokenType.IS}

    FETCH_TOKENS: t.ClassVar = ID_VAR_TOKENS - {TokenType.ROW, TokenType.ROWS, TokenType.PERCENT}

    ADD_CONSTRAINT_TOKENS: t.ClassVar = {
        TokenType.CONSTRAINT,
        TokenType.FOREIGN_KEY,
        TokenType.INDEX,
        TokenType.KEY,
        TokenType.PRIMARY_KEY,
        TokenType.UNIQUE,
    }

    DISTINCT_TOKENS: t.ClassVar = {TokenType.DISTINCT}

    UNNEST_OFFSET_ALIAS_TOKENS: t.ClassVar = TABLE_ALIAS_TOKENS - SET_OPERATIONS

    SELECT_START_TOKENS: t.ClassVar = {TokenType.L_PAREN, TokenType.WITH, TokenType.SELECT}

    COPY_INTO_VARLEN_OPTIONS: t.ClassVar = {
        "FILE_FORMAT",
        "COPY_OPTIONS",
        "FORMAT_OPTIONS",
        "CREDENTIAL",
    }

    IS_JSON_PREDICATE_KIND: t.ClassVar = {"VALUE", "SCALAR", "ARRAY", "OBJECT"}

    ODBC_DATETIME_LITERALS: t.ClassVar[dict[str, type[exp.Expr]]] = {}

    ON_CONDITION_TOKENS: t.ClassVar = {"ERROR", "NULL", "TRUE", "FALSE", "EMPTY"}

    PRIVILEGE_FOLLOW_TOKENS: t.ClassVar = {TokenType.ON, TokenType.COMMA, TokenType.L_PAREN}

    # The style options for the DESCRIBE statement
    DESCRIBE_STYLES: t.ClassVar = {"ANALYZE", "EXTENDED", "FORMATTED", "HISTORY"}

    SET_ASSIGNMENT_DELIMITERS: t.ClassVar = {"=", ":=", "TO"}

    # The style options for the ANALYZE statement
    ANALYZE_STYLES: t.ClassVar = {
        "BUFFER_USAGE_LIMIT",
        "FULL",
        "LOCAL",
        "NO_WRITE_TO_BINLOG",
        "SAMPLE",
        "SKIP_LOCKED",
        "VERBOSE",
    }

    ANALYZE_EXPRESSION_PARSERS: t.ClassVar = {
        "ALL": lambda self: self._parse_analyze_columns(),
        "COMPUTE": lambda self: self._parse_analyze_statistics(),
        "DELETE": lambda self: self._parse_analyze_delete(),
        "DROP": lambda self: self._parse_analyze_histogram(),
        "ESTIMATE": lambda self: self._parse_analyze_statistics(),
        "LIST": lambda self: self._parse_analyze_list(),
        "PREDICATE": lambda self: self._parse_analyze_columns(),
        "UPDATE": lambda self: self._parse_analyze_histogram(),
        "VALIDATE": lambda self: self._parse_analyze_validate(),
    }

    PARTITION_KEYWORDS: t.ClassVar = {"PARTITION", "SUBPARTITION"}

    AMBIGUOUS_ALIAS_TOKENS: t.ClassVar = (TokenType.LIMIT, TokenType.OFFSET)

    OPERATION_MODIFIERS: t.ClassVar[set[str]] = set()

    RECURSIVE_CTE_SEARCH_KIND: t.ClassVar = {"BREADTH", "DEPTH", "CYCLE"}

    SECURITY_PROPERTY_KEYWORDS: t.ClassVar = {"DEFINER", "INVOKER", "NONE"}

    MODIFIABLES: t.ClassVar = (exp.Query, exp.Table, exp.TableFromRows, exp.Values)

    STRICT_CAST: t.ClassVar = True

    PREFIXED_PIVOT_COLUMNS: t.ClassVar = False
    IDENTIFY_PIVOT_STRINGS: t.ClassVar = False
    # Whether an UNPIVOT outputs its value column(s) before the name column
    UNPIVOT_VALUE_COLUMNS_FIRST: t.ClassVar = False
    # Controls when an aggregation's name is included in a pivoted column's name:
    # "agg_name_if_aliased" - only for aggregations that carry an explicit alias
    # "agg_name_if_aliased_or_multiple" - if aliased, or whenever there are multiple aggregations
    # "agg_name_if_multiple" - only when there are multiple aggregations (a lone agg is value-only)
    PIVOT_COLUMN_NAMING: t.ClassVar[str] = "agg_name_if_aliased"

    LOG_DEFAULTS_TO_LN: t.ClassVar = False

    # Whether the table sample clause expects CSV syntax
    TABLESAMPLE_CSV: t.ClassVar = False

    # The default method used for table sampling
    DEFAULT_SAMPLING_METHOD: t.ClassVar[str | None] = None

    # Whether the SET command needs a delimiter (e.g. "=") for assignments
    SET_REQUIRES_ASSIGNMENT_DELIMITER: t.ClassVar = True

    # Whether the TRIM function expects the characters to trim as its first argument
    TRIM_PATTERN_FIRST: t.ClassVar = False

    # Whether string aliases are supported `SELECT COUNT(*) 'count'`
    STRING_ALIASES: t.ClassVar = False

    # Whether query modifiers such as LIMIT are attached to the UNION node (vs its right operand)
    MODIFIERS_ATTACHED_TO_SET_OP: t.ClassVar = True
    SET_OP_MODIFIERS: t.ClassVar = {"order", "limit", "offset"}

    # Whether to parse IF statements that aren't followed by a left parenthesis as commands
    NO_PAREN_IF_COMMANDS: t.ClassVar = True

    # Whether the -> and ->> operators expect documents of type JSON (e.g. Postgres)
    JSON_ARROWS_REQUIRE_JSON_TYPE: t.ClassVar = False

    # Whether the `:` operator is used to extract a value from a VARIANT column
    COLON_IS_VARIANT_EXTRACT: t.ClassVar = False

    # Whether a chain of colon extractions (x:y:z) is a single extraction with a merged
    # path (x:y.z, e.g. Snowflake) or each colon extracts from the previous result (e.g. Databricks)
    COLON_CHAIN_IS_SINGLE_EXTRACT: t.ClassVar = True

    # Whether or not a VALUES keyword needs to be followed by '(' to form a VALUES clause.
    # If this is True and '(' is not found, the keyword will be treated as an identifier
    VALUES_FOLLOWED_BY_PAREN: t.ClassVar = True

    # Whether implicit unnesting is supported, e.g. SELECT 1 FROM y.z AS z, z.a (Redshift)
    SUPPORTS_IMPLICIT_UNNEST: t.ClassVar = False

    # Whether or not interval spans are supported, INTERVAL 1 YEAR TO MONTHS
    INTERVAL_SPANS: t.ClassVar = True

    # Whether a PARTITION clause can follow a table reference
    SUPPORTS_PARTITION_SELECTION: t.ClassVar = False

    # Whether the `name AS expr` schema/column constraint requires parentheses around `expr`
    WRAPPED_TRANSFORM_COLUMN_CONSTRAINT: t.ClassVar = True

    # Whether the 'AS' keyword is optional in the CTE definition syntax
    OPTIONAL_ALIAS_TOKEN_CTE: t.ClassVar = True

    # Whether renaming a column with an ALTER statement requires the presence of the COLUMN keyword
    ALTER_RENAME_REQUIRES_COLUMN: t.ClassVar = True

    # Whether Alter statements are allowed to contain Partition specifications
    ALTER_TABLE_PARTITIONS: t.ClassVar = False

    # Whether all join types have the same precedence, i.e., they "naturally" produce a left-deep tree.
    # In standard SQL, joins that use the JOIN keyword take higher precedence than comma-joins. That is
    # to say, JOIN operators happen before comma operators. This is not the case in some dialects, such
    # as BigQuery, where all joins have the same precedence.
    JOINS_HAVE_EQUAL_PRECEDENCE: t.ClassVar = False

    # Whether TIMESTAMP <literal> can produce a zone-aware timestamp
    ZONE_AWARE_TIMESTAMP_CONSTRUCTOR: t.ClassVar = False

    # Whether map literals support arbitrary expressions as keys.
    # When True, allows complex keys like arrays or literals: {[1, 2]: 3}, {1: 2} (e.g. DuckDB).
    # When False, keys are typically restricted to identifiers.
    MAP_KEYS_ARE_ARBITRARY_EXPRESSIONS: t.ClassVar = False

    # Whether JSON_EXTRACT requires a JSON expression as the first argument, e.g this
    # is true for Snowflake but not for BigQuery which can also process strings
    JSON_EXTRACT_REQUIRES_JSON_EXPRESSION: t.ClassVar = False

    # Dialects like Databricks support JOINS without join criteria
    # Adding an ON TRUE, makes transpilation semantically correct for other dialects
    ADD_JOIN_ON_TRUE: t.ClassVar = False

    # Whether INTERVAL spans with literal format '\d+ hh:[mm:[ss[.ff]]]'
    # can omit the span unit `DAY TO MINUTE` or `DAY TO SECOND`
    SUPPORTS_OMITTED_INTERVAL_SPAN_UNIT: t.ClassVar = False

    # Whether adjacent string literals like 'foo' 'bar' require a whitespace or comment between them
    # to be considered valid syntactically. Such expressions evaluate to the strings' concatenation.
    ADJACENT_STRINGS_CANNOT_BE_CONNECTED: t.ClassVar = False

    SHOW_TRIE: t.ClassVar[dict] = new_trie(key.split(" ") for key in SHOW_PARSERS)
    SET_TRIE: t.ClassVar[dict] = new_trie(key.split(" ") for key in SET_PARSERS)

    def __init__(
        self,
        error_level: ErrorLevel | None = None,
        error_message_context: int = 100,
        max_errors: int = 3,
        max_nodes: int = -1,
        dialect: DialectType = None,
    ):
        self.error_level: ErrorLevel = error_level or ErrorLevel.IMMEDIATE
        self.error_message_context: int = error_message_context
        self.max_errors: int = max_errors
        self.max_nodes: int = max_nodes
        self.dialect: t.Any = _resolve_dialect(dialect)
        self.sql: str = ""
        self.errors: list[ParseError] = []
        self._tokens: list[Token] = []
        self._tokens_size: i64 = 0
        self._index: i64 = 0
        self._curr: Token = SENTINEL_NONE
        self._next: Token = SENTINEL_NONE
        self._prev: Token = SENTINEL_NONE
        self._prev_comments: list[str] = []
        self._pipe_cte_counter: int = 0
        self._chunks: list[list[Token]] = []
        self._chunk_index: i64 = 0
        self._node_count: int = 0

    def reset(self) -> None:
        self.sql = ""
        self.errors = []
        self._tokens = []
        self._tokens_size = 0
        self._index = 0
        self._curr = SENTINEL_NONE
        self._next = SENTINEL_NONE
        self._prev = SENTINEL_NONE
        self._prev_comments = []
        self._pipe_cte_counter = 0
        self._chunks = []
        self._chunk_index = 0
        self._node_count = 0

    def _advance(self, times: i64 = 1) -> None:
        index = self._index + times
        self._index = index
        tokens = self._tokens
        size = self._tokens_size
        self._curr = tokens[index] if index < size else SENTINEL_NONE
        self._next = tokens[index + 1] if index + 1 < size else SENTINEL_NONE

        if index > 0:
            prev = tokens[index - 1]
            self._prev = prev
            self._prev_comments = prev.comments
        else:
            self._prev = SENTINEL_NONE
            self._prev_comments = []

    def _advance_chunk(self) -> None:
        self._index = -1
        self._tokens = self._chunks[self._chunk_index]
        self._tokens_size = i64(len(self._tokens))
        self._chunk_index += 1
        self._advance()

    def _retreat(self, index: i64) -> None:
        if index != self._index:
            self._advance(index - self._index)

    def _add_comments(self, expression: exp.Expr | None) -> None:
        if expression and self._prev_comments:
            expression.add_comments(self._prev_comments)
            self._prev_comments = []

    def _match(
        self, token_type: TokenType, advance: bool = True, expression: exp.Expr | None = None
    ) -> bool:
        if self._curr.token_type == token_type:
            if advance:
                self._advance()
            self._add_comments(expression)
            return True
        return False

    def _match_set(self, types: t.Collection[TokenType], advance: bool = True) -> bool:
        if self._curr.token_type in types:
            if advance:
                self._advance()
            return True
        return False

    def _match_pair(
        self, token_type_a: TokenType, token_type_b: TokenType, advance: bool = True
    ) -> bool:
        if self._curr.token_type == token_type_a and self._next.token_type == token_type_b:
            if advance:
                self._advance(2)
            return True
        return False

    def _match_texts(self, texts: TEXTS_TYPE, advance: bool = True) -> bool:
        if (
            self._curr.token_type not in self.TEXT_MATCH_EXCLUDED_TOKENS
            and self._curr.text.upper() in texts
        ):
            if advance:
                self._advance()
            return True
        return False

    def _match_text_seq(self, *texts: str, advance: bool = True) -> bool:
        index = self._index
        excluded_tokens = self.TEXT_MATCH_EXCLUDED_TOKENS
        for text in texts:
            if self._curr.token_type not in excluded_tokens and self._curr.text.upper() == text:
                self._advance()
            else:
                self._retreat(index)
                return False

        if not advance:
            self._retreat(index)

        return True

    def _is_connected(self) -> bool:
        prev = self._prev
        curr = self._curr
        return bool(prev and curr and prev.end + 1 == curr.start)

    def _find_sql(self, start: Token, end: Token) -> str:
        return self.sql[start.start : end.end + 1]

    def raise_error(self, message: str, token: Token = SENTINEL_NONE) -> None:
        token = token or self._curr or self._prev or Token.string("")
        formatted_sql, start_context, highlight, end_context = highlight_sql(
            sql=self.sql,
            positions=[(token.start, token.end)],
            context_length=self.error_message_context,
        )
        formatted_message = f"{message}. Line {token.line}, Col: {token.col}.\n  {formatted_sql}"

        error = ParseError.new(
            formatted_message,
            description=message,
            line=token.line,
            col=token.col,
            start_context=start_context,
            highlight=highlight,
            end_context=end_context,
        )

        if self.error_level == ErrorLevel.IMMEDIATE:
            raise error

        self.errors.append(error)

    def validate_expression(self, expression: E, args: list | None = None) -> E:
        if self.max_nodes > -1:
            self._node_count += 1
            if self._node_count > self.max_nodes:
                self.raise_error(f"Maximum number of AST nodes ({self.max_nodes}) exceeded")
        if self.error_level != ErrorLevel.IGNORE:
            for error_message in expression.error_messages(args):
                self.raise_error(error_message)
        return expression

    def _try_parse(self, parse_method: t.Callable[[], T], retreat: bool = False) -> T | None:
        index = self._index
        error_level = self.error_level
        this: T | None = None

        self.error_level = ErrorLevel.IMMEDIATE
        try:
            this = parse_method()
        except ParseError:
            this = None
        finally:
            if not this or retreat:
                self._retreat(index)
            self.error_level = error_level

        return this

    def parse(self, raw_tokens: list[Token], sql: str) -> list[exp.Expr | None]:
        """
        Parses a list of tokens and returns a list of syntax trees, one tree
        per parsed SQL statement.

        Args:
            raw_tokens: The list of tokens.
            sql: The original SQL string.

        Returns:
            The list of the produced syntax trees.
        """
        return self._parse(
            parse_method=self.__class__._parse_statement, raw_tokens=raw_tokens, sql=sql
        )

    def parse_into(
        self,
        expression_types: exp.IntoType,
        raw_tokens: list[Token],
        sql: str | None = None,
    ) -> list[exp.Expr | None]:
        """
        Parses a list of tokens into a given Expr type. If a collection of Expr
        types is given instead, this method will try to parse the token list into each one
        of them, stopping at the first for which the parsing succeeds.

        Args:
            expression_types: The expression type(s) to try and parse the token list into.
            raw_tokens: The list of tokens.
            sql: The original SQL string, used to produce helpful debug messages.

        Returns:
            The target Expr.
        """
        errors = []
        for expression_type in ensure_list(expression_types):
            parser = self.EXPRESSION_PARSERS.get(t.cast(type[exp.Expr], expression_type))
            if not parser:
                raise TypeError(f"No parser registered for {expression_type}")

            try:
                return self._parse(parser, raw_tokens, sql)
            except ParseError as e:
                e.errors[0]["into_expression"] = expression_type
                errors.append(e)

        raise ParseError(
            f"Failed to parse '{sql or raw_tokens}' into {expression_types}",
            errors=merge_errors(errors),
        ) from errors[-1]

    def check_errors(self) -> None:
        """Logs or raises any found errors, depending on the chosen error level setting."""
        if self.error_level == ErrorLevel.WARN:
            for error in self.errors:
                logger.error(str(error))
        elif self.error_level == ErrorLevel.RAISE and self.errors:
            raise ParseError(
                concat_messages(self.errors, self.max_errors),
                errors=merge_errors(self.errors),
            )

    def expression(
        self,
        instance: E,
        token: Token | None = None,
        comments: list[str] | None = None,
    ) -> E:
        if token:
            instance.update_positions(token)
        instance.add_comments(comments) if comments else self._add_comments(instance)
        if not instance.is_primitive:
            instance = self.validate_expression(instance)
        return instance

    def _parse_batch_statements(
        self,
        parse_method: t.Callable[[Parser], exp.Expr | None],
        sep_first_statement: bool = True,
    ) -> list[exp.Expr | None]:
        expressions = []

        # Chunkification binds if/while statements with the first statement of the body
        if sep_first_statement:
            self._match(TokenType.BEGIN)
            expressions.append(parse_method(self))

        chunks_length = len(self._chunks)
        while self._chunk_index < chunks_length:
            self._advance_chunk()

            if self._match(TokenType.ELSE, advance=False):
                return expressions

            if expressions and not self._next and self._match(TokenType.END):
                expressions.append(exp.EndStatement())
                continue

            expressions.append(parse_method(self))

            if self._index < self._tokens_size:
                self.raise_error("Invalid expression / Unexpected token")

            self.check_errors()

        return expressions

    def _parse(
        self,
        parse_method: t.Callable[[Parser], exp.Expr | None],
        raw_tokens: list[Token],
        sql: str | None = None,
    ) -> list[exp.Expr | None]:
        self.reset()
        self.sql = sql or ""

        total = len(raw_tokens)
        chunks: list[list[Token]] = [[]]

        for i, token in enumerate(raw_tokens):
            if token.token_type == TokenType.SEMICOLON:
                if token.comments:
                    chunks.append([token])

                if i < total - 1:
                    chunks.append([])
            else:
                chunks[-1].append(token)

        self._chunks = chunks

        return self._parse_batch_statements(parse_method=parse_method, sep_first_statement=False)

    def _warn_unsupported(self) -> None:
        if self._tokens_size <= 1:
            return

        # We use _find_sql because self.sql may comprise multiple chunks, and we're only
        # interested in emitting a warning for the one being currently processed.
        sql = self._find_sql(self._tokens[0], self._tokens[-1])[: self.error_message_context]

        logger.warning(
            f"'{sql}' contains unsupported syntax. Falling back to parsing as a 'Command'."
        )

    def _parse_command(self) -> exp.Command:
        self._warn_unsupported()
        comments = self._prev_comments
        return self.expression(
            exp.Command(this=self._prev.text.upper(), expression=self._parse_string()),
            comments=comments,
        )

    def _parse_comment(self, allow_exists: bool = True) -> exp.Expr:
        start = self._prev
        exists = self._parse_exists() if allow_exists else None

        self._match(TokenType.ON)

        materialized = self._match_text_seq("MATERIALIZED")
        kind = self._match_set(self.CREATABLES) and self._prev
        if not kind:
            return self._parse_as_command(start)

        if kind.token_type in (TokenType.FUNCTION, TokenType.PROCEDURE):
            this = self._parse_user_defined_function(kind=kind.token_type)
        elif kind.token_type == TokenType.TABLE:
            this = self._parse_table(alias_tokens=self.COMMENT_TABLE_ALIAS_TOKENS)
        elif kind.token_type == TokenType.COLUMN:
            this = self._parse_column()
        else:
            this = self._parse_table_parts(schema=True)

        self._match(TokenType.IS)

        return self.expression(
            exp.Comment(
                this=this,
                kind=kind.text,
                expression=self._parse_string(),
                exists=exists,
                materialized=materialized,
            )
        )

    def _parse_to_table(
        self,
    ) -> exp.ToTableProperty:
        table = self._parse_table_parts(schema=True)
        return self.expression(exp.ToTableProperty(this=table))

    # https://clickhouse.com/docs/en/engines/table-engines/mergetree-family/mergetree#mergetree-table-ttl
    def _parse_ttl(self) -> exp.Expr:
        def _parse_ttl_action() -> exp.Expr | None:
            this = self._parse_bitwise()

            if self._match_text_seq("DELETE"):
                return self.expression(exp.MergeTreeTTLAction(this=this, delete=True))
            if self._match_text_seq("RECOMPRESS"):
                return self.expression(
                    exp.MergeTreeTTLAction(this=this, recompress=self._parse_bitwise())
                )
            if self._match_text_seq("TO", "DISK"):
                return self.expression(
                    exp.MergeTreeTTLAction(this=this, to_disk=self._parse_string())
                )
            if self._match_text_seq("TO", "VOLUME"):
                return self.expression(
                    exp.MergeTreeTTLAction(this=this, to_volume=self._parse_string())
                )

            return this

        expressions = self._parse_csv(_parse_ttl_action)
        where = self._parse_where()
        group = self._parse_group()

        aggregates = None
        if group and self._match(TokenType.SET):
            aggregates = self._parse_csv(self._parse_set_item)

        return self.expression(
            exp.MergeTreeTTL(
                expressions=expressions, where=where, group=group, aggregates=aggregates
            )
        )

    def _parse_condition(self) -> exp.Expr | None:
        return self._parse_wrapped(parse_method=self._parse_expression, optional=True)

    def _parse_block(self) -> exp.Block:
        return self.expression(
            exp.Block(
                expressions=self._parse_batch_statements(
                    parse_method=lambda self: self._parse_statement()
                )
            )
        )

    def _parse_whileblock(self) -> exp.WhileBlock:
        return self.expression(
            exp.WhileBlock(this=self._parse_condition(), body=self._parse_block())
        )

    def _parse_statement(self) -> exp.Expr | None:
        if not self._curr:
            return None

        if self._match_set(self.STATEMENT_PARSERS):
            comments = self._prev_comments
            stmt = self.STATEMENT_PARSERS[self._prev.token_type](self)
            stmt.add_comments(comments, prepend=True)
            return stmt

        if self._match_set(self.dialect.tokenizer_class.COMMANDS):
            return self._parse_command()

        if self._match_text_seq("WHILE"):
            return self._parse_whileblock()

        expression = self._parse_expression()
        expression = self._parse_set_operations(expression) if expression else self._parse_select()

        if isinstance(expression, exp.Subquery) and self._match(TokenType.PIPE_GT, advance=False):
            expression = self._parse_pipe_syntax_query(expression)

        return self._parse_query_modifiers(expression)

    def _parse_drop(self, exists: bool = False) -> exp.Drop | exp.Command:
        start = self._prev
        temporary = self._match(TokenType.TEMPORARY)
        materialized = self._match_text_seq("MATERIALIZED")
        iceberg = self._match_text_seq("ICEBERG")

        kind = self._match_set(self.CREATABLES) and self._prev.text.upper()
        if not kind or (iceberg and kind and kind != "TABLE"):
            return self._parse_as_command(start)

        concurrently = self._match_text_seq("CONCURRENTLY")
        if_exists = exists or self._parse_exists()

        if kind == "COLUMN":
            this = self._parse_column()
        else:
            this = self._parse_table_parts(schema=True, is_db_reference=kind == "SCHEMA")

        cluster = self._parse_on_property() if self._match(TokenType.ON) else None

        if self._match(TokenType.L_PAREN, advance=False):
            expressions = self._parse_wrapped_csv(self._parse_types)
        else:
            expressions = None

        cascade_or_restrict = self._match_texts(("CASCADE", "RESTRICT")) and self._prev.text.upper()

        return self.expression(
            exp.Drop(
                exists=if_exists,
                this=this,
                expressions=expressions,
                kind=self.dialect.CREATABLE_KIND_MAPPING.get(kind) or kind,
                temporary=temporary,
                materialized=materialized,
                cascade=cascade_or_restrict == "CASCADE",
                restrict=cascade_or_restrict == "RESTRICT",
                constraints=self._match_text_seq("CONSTRAINTS"),
                purge=self._match_text_seq("PURGE"),
                cluster=cluster,
                concurrently=concurrently,
                sync=self._match_text_seq("SYNC"),
                iceberg=iceberg,
                force=self._match_text_seq("FORCE"),
            )
        )

    def _parse_exists(self, not_: bool = False) -> bool | None:
        return (
            self._match_text_seq("IF")
            and (not not_ or self._match(TokenType.NOT))
            and self._match(TokenType.EXISTS)
        )

    def _parse_create(self) -> exp.Create | exp.Command:
        # Note: this can't be None because we've matched a statement parser
        start = self._prev

        replace = (
            start.token_type == TokenType.REPLACE
            or self._match_pair(TokenType.OR, TokenType.REPLACE)
            or self._match_pair(TokenType.OR, TokenType.ALTER)
        )
        refresh = self._match_pair(TokenType.OR, TokenType.REFRESH)

        unique = self._match(TokenType.UNIQUE)

        if self._match_text_seq("CLUSTERED", "COLUMNSTORE"):
            clustered = True
        elif self._match_text_seq("NONCLUSTERED", "COLUMNSTORE") or self._match_text_seq(
            "COLUMNSTORE"
        ):
            clustered = False
        else:
            clustered = None

        if self._match_pair(TokenType.TABLE, TokenType.FUNCTION, advance=False):
            self._advance()

        properties = None
        create_token = self._match_set(self.CREATABLES) and self._prev

        if not create_token:
            # exp.Properties.Location.POST_CREATE
            properties = self._parse_properties()
            create_token = self._match_set(self.CREATABLES) and self._prev

            if not properties or not create_token:
                return self._parse_as_command(start)

        create_token_type = t.cast(Token, create_token).token_type

        concurrently = self._match_text_seq("CONCURRENTLY")
        exists = self._parse_exists(not_=True)
        this = None
        expression: exp.Expr | None = None
        indexes = None
        no_schema_binding = None
        begin = None
        clone = None

        def extend_props(temp_props: exp.Properties | None) -> None:
            nonlocal properties
            if properties and temp_props:
                properties.expressions.extend(temp_props.expressions)
            elif temp_props:
                properties = temp_props

        if create_token_type in (TokenType.FUNCTION, TokenType.PROCEDURE):
            this = self._parse_user_defined_function(kind=create_token_type)

            # exp.Properties.Location.POST_SCHEMA ("schema" here is the UDF's type signature)
            extend_props(self._parse_properties())

            expression = self._parse_heredoc() if self._match(TokenType.ALIAS) else None

            if (
                not expression
                and create_token_type == TokenType.FUNCTION
                and isinstance(this, exp.UserDefinedFunction)
                and this.args.get("wrapped")
            ):
                pre_table_index = self._index
                is_table = self._match(TokenType.TABLE)

                expression = self._parse_expression()
                overload_mode = bool(
                    expression
                    and self._curr.token_type == TokenType.COMMA
                    and self._next.token_type == TokenType.L_PAREN
                )
                if not overload_mode:
                    self._retreat(pre_table_index)
                    is_table = False
                    expression = None
            else:
                is_table = False
                overload_mode = False

            extend_props(self._parse_function_properties())

            if not expression:
                if self._match(TokenType.COMMAND):
                    expression = self._parse_as_command(self._prev)
                else:
                    begin = self._match(TokenType.BEGIN)
                    return_ = self._match_text_seq("RETURN")

                    if self._match(TokenType.STRING, advance=False):
                        # Takes care of BigQuery's JavaScript UDF definitions that end in an OPTIONS property
                        # # https://cloud.google.com/bigquery/docs/reference/standard-sql/data-definition-language#create_function_statement
                        expression = self._parse_string()
                        extend_props(self._parse_properties())
                    else:
                        expression = (
                            self._parse_user_defined_function_expression()
                            if create_token_type == TokenType.FUNCTION
                            else self._parse_block()
                        )

                    if return_:
                        expression = self.expression(exp.Return(this=expression))

            if overload_mode and expression:
                expression = self._parse_macro_overloads(
                    t.cast(exp.UserDefinedFunction, this), expression, is_table
                )
        elif create_token_type == TokenType.INDEX:
            # Postgres allows anonymous indexes, eg. CREATE INDEX IF NOT EXISTS ON t(c)
            if not self._match(TokenType.ON):
                index = self._parse_id_var()
                anonymous = False
            else:
                index = None
                anonymous = True

            this = self._parse_index(index=index, anonymous=anonymous)
        elif (
            create_token_type == TokenType.CONSTRAINT and self._match(TokenType.TRIGGER)
        ) or create_token_type == TokenType.TRIGGER:
            if is_constraint := (create_token_type == TokenType.CONSTRAINT):
                create_token = self._prev

            trigger_name = self._parse_id_var()
            if not trigger_name:
                return self._parse_as_command(start)

            timing_var = self._parse_var_from_options(self.TRIGGER_TIMING, raise_unmatched=False)
            timing = timing_var.this if timing_var else None
            if not timing:
                return self._parse_as_command(start)

            events = self._parse_trigger_events()
            if not self._match(TokenType.ON):
                self.raise_error("Expected ON in trigger definition")

            table = self._parse_table_parts()
            referenced_table = self._parse_table_parts() if self._match(TokenType.FROM) else None
            deferrable, initially = self._parse_trigger_deferrable()
            referencing = self._parse_trigger_referencing()
            for_each = self._parse_trigger_for_each()
            when = self._match_text_seq("WHEN") and self._parse_wrapped(
                self._parse_disjunction, optional=True
            )
            execute = self._parse_trigger_execute()

            if execute is None:
                return self._parse_as_command(start)

            trigger_props = self.expression(
                exp.TriggerProperties(
                    table=table,
                    timing=timing,
                    events=events,
                    execute=execute,
                    constraint=is_constraint,
                    referenced_table=referenced_table,
                    deferrable=deferrable,
                    initially=initially,
                    referencing=referencing,
                    for_each=for_each,
                    when=when,
                )
            )

            this = trigger_name
            extend_props(exp.Properties(expressions=[trigger_props] if trigger_props else []))
        elif create_token_type == TokenType.TYPE:
            this = self._parse_table_parts(schema=True)
            if not this or not self._match(TokenType.ALIAS):
                return self._parse_as_command(start)

            if self._match(TokenType.ENUM):
                expression = exp.DataType(
                    this=exp.DType.ENUM,
                    expressions=self._parse_wrapped_csv(self._parse_string),
                )
            elif self._match(TokenType.L_PAREN, advance=False):
                expression = self._parse_schema()
            else:
                return self._parse_as_command(start)
        elif create_token_type in self.DB_CREATABLES:
            table_parts = self._parse_table_parts(
                schema=True, is_db_reference=create_token_type == TokenType.SCHEMA
            )

            # exp.Properties.Location.POST_NAME
            self._match(TokenType.COMMA)
            extend_props(self._parse_properties(before=True))

            this = self._parse_schema(this=table_parts)

            # exp.Properties.Location.POST_SCHEMA and POST_WITH
            extend_props(self._parse_properties())

            has_alias = self._match(TokenType.ALIAS)
            if not self._match_set(self.DDL_SELECT_TOKENS, advance=False):
                # exp.Properties.Location.POST_ALIAS
                extend_props(self._parse_properties())

            if create_token_type == TokenType.SEQUENCE:
                expression = self._parse_types()
                props = self._parse_properties()
                if props:
                    sequence_props = exp.SequenceProperties()
                    options = []
                    for prop in props:
                        if isinstance(prop, exp.SequenceProperties):
                            for arg, value in prop.args.items():
                                if arg == "options":
                                    options.extend(value)
                                else:
                                    sequence_props.set(arg, value)
                            prop.pop()

                    if options:
                        sequence_props.set("options", options)

                    props.append("expressions", sequence_props)
                    extend_props(props)
            else:
                expression = self._parse_ddl_select()

                # Some dialects also support using a table as an alias instead of a SELECT.
                # Here we fallback to this as an alternative.
                if not expression and has_alias:
                    expression = self._try_parse(self._parse_table_parts)

            if create_token_type == TokenType.TABLE:
                # exp.Properties.Location.POST_EXPRESSION
                extend_props(self._parse_properties())

                indexes = []
                while True:
                    index = self._parse_index()

                    # exp.Properties.Location.POST_INDEX
                    extend_props(self._parse_properties())
                    if not index:
                        break
                    else:
                        self._match(TokenType.COMMA)
                        indexes.append(index)
            elif create_token_type == TokenType.VIEW:
                if self._match_text_seq("WITH", "NO", "SCHEMA", "BINDING"):
                    no_schema_binding = True
            elif create_token_type in (TokenType.SINK, TokenType.SOURCE):
                extend_props(self._parse_properties())

            shallow = self._match_text_seq("SHALLOW")

            if self._match_texts(self.CLONE_KEYWORDS):
                copy = self._prev.text.lower() == "copy"
                clone = self.expression(
                    exp.Clone(this=self._parse_table(schema=True), shallow=shallow, copy=copy)
                )

        if self._curr and not self._match_set((TokenType.R_PAREN, TokenType.COMMA), advance=False):
            return self._parse_as_command(start)

        create_kind_text = create_token.text.upper()
        return self.expression(
            exp.Create(
                this=this,
                kind=self.dialect.CREATABLE_KIND_MAPPING.get(create_kind_text) or create_kind_text,
                replace=replace,
                refresh=refresh,
                unique=unique,
                expression=expression,
                exists=exists,
                properties=properties,
                indexes=indexes,
                no_schema_binding=no_schema_binding,
                begin=begin,
                clone=clone,
                concurrently=concurrently,
                clustered=clustered,
            )
        )

    def _parse_sequence_properties(self) -> exp.SequenceProperties | None:
        seq = exp.SequenceProperties()

        options = []
        index = self._index

        while self._curr:
            self._match(TokenType.COMMA)
            if self._match_text_seq("INCREMENT"):
                self._match_text_seq("BY")
                self._match_text_seq("=")
                seq.set("increment", self._parse_term())
            elif self._match_text_seq("MINVALUE"):
                seq.set("minvalue", self._parse_term())
            elif self._match_text_seq("MAXVALUE"):
                seq.set("maxvalue", self._parse_term())
            elif self._match_text_seq("START"):
                self._match_text_seq("WITH")
                self._match_text_seq("=")
                seq.set("start", self._parse_term())
            elif self._match_text_seq("CACHE"):
                # T-SQL allows empty CACHE which is initialized dynamically
                seq.set("cache", self._parse_number() or True)
            elif self._match_text_seq("OWNED", "BY"):
                # "OWNED BY NONE" is the default
                seq.set("owned", None if self._match_text_seq("NONE") else self._parse_column())
            else:
                opt = self._parse_var_from_options(self.CREATE_SEQUENCE, raise_unmatched=False)
                if opt:
                    options.append(opt)
                else:
                    break

        seq.set("options", options if options else None)
        return None if self._index == index else seq

    def _parse_trigger_events(self) -> list[exp.TriggerEvent]:
        events = []

        while True:
            event_type = self._match_set(self.TRIGGER_EVENTS) and self._prev.text.upper()

            if not event_type:
                self.raise_error("Expected trigger event (INSERT, UPDATE, DELETE, TRUNCATE)")

            columns = (
                self._parse_csv(self._parse_column)
                if event_type == "UPDATE" and self._match_text_seq("OF")
                else None
            )

            events.append(self.expression(exp.TriggerEvent(this=event_type, columns=columns)))

            if not self._match(TokenType.OR):
                break

        return events

    def _parse_trigger_deferrable(
        self,
    ) -> tuple[str | None, str | None]:
        deferrable_var = self._parse_var_from_options(
            self.TRIGGER_DEFERRABLE, raise_unmatched=False
        )
        deferrable = deferrable_var.this if deferrable_var else None

        initially = None
        if deferrable and self._match_text_seq("INITIALLY"):
            initially = (
                self._prev.text.upper() if self._match_texts(("IMMEDIATE", "DEFERRED")) else None
            )

        return deferrable, initially

    def _parse_trigger_referencing_clause(self, keyword: str) -> exp.Expr | None:
        if not self._match_text_seq(keyword):
            return None
        if not self._match_text_seq("TABLE"):
            self.raise_error(f"Expected TABLE after {keyword} in REFERENCING clause")
        self._match_text_seq("AS")
        return self._parse_id_var()

    def _parse_trigger_referencing(self) -> exp.TriggerReferencing | None:
        if not self._match_text_seq("REFERENCING"):
            return None

        old_alias = None
        new_alias = None

        while True:
            if alias := self._parse_trigger_referencing_clause("OLD"):
                if old_alias is not None:
                    self.raise_error("Duplicate OLD clause in REFERENCING")
                old_alias = alias
            elif alias := self._parse_trigger_referencing_clause("NEW"):
                if new_alias is not None:
                    self.raise_error("Duplicate NEW clause in REFERENCING")
                new_alias = alias
            else:
                break

        if old_alias is None and new_alias is None:
            self.raise_error("REFERENCING clause requires at least OLD TABLE or NEW TABLE")

        return self.expression(exp.TriggerReferencing(old=old_alias, new=new_alias))

    def _parse_trigger_for_each(self) -> str | None:
        if not self._match_text_seq("FOR", "EACH"):
            return None

        return self._prev.text.upper() if self._match_texts(("ROW", "STATEMENT")) else None

    def _parse_trigger_execute(self) -> exp.TriggerExecute | None:
        if not self._match(TokenType.EXECUTE):
            return None

        if not self._match_set((TokenType.FUNCTION, TokenType.PROCEDURE)):
            self.raise_error("Expected FUNCTION or PROCEDURE after EXECUTE")

        func_call = self._parse_column()
        return self.expression(exp.TriggerExecute(this=func_call))

    def _parse_property_before(self) -> exp.Expr | list[exp.Expr] | None:
        # only used for teradata currently
        self._match(TokenType.COMMA)

        kwargs = {
            "no": self._match_text_seq("NO"),
            "dual": self._match_text_seq("DUAL"),
            "before": self._match_text_seq("BEFORE"),
            "default": self._match_text_seq("DEFAULT"),
            "local": (self._match_text_seq("LOCAL") and "LOCAL")
            or (self._match_text_seq("NOT", "LOCAL") and "NOT LOCAL"),
            "after": self._match_text_seq("AFTER"),
            "minimum": self._match_texts(("MIN", "MINIMUM")),
            "maximum": self._match_texts(("MAX", "MAXIMUM")),
        }

        if self._match_texts(self.PROPERTY_PARSERS):
            parser = self.PROPERTY_PARSERS[self._prev.text.upper()]
            try:
                return parser(self, **{k: v for k, v in kwargs.items() if v})
            except TypeError:
                self.raise_error(f"Cannot parse property '{self._prev.text}'")

        if self._match_text_seq("CHARACTER", "SET"):
            return self._parse_character_set(default=bool(kwargs["default"]))

        return None

    def _parse_wrapped_properties(self) -> list[exp.Expr | list[exp.Expr]]:
        return self._parse_wrapped_csv(self._parse_property)

    def _parse_property(self) -> exp.Expr | list[exp.Expr] | None:
        if self._match_texts(self.PROPERTY_PARSERS):
            return self.PROPERTY_PARSERS[self._prev.text.upper()](self)

        if self._match_text_seq("CHARACTER", "SET"):
            return self._parse_character_set()

        if self._match(TokenType.DEFAULT):
            if self._match_texts(self.PROPERTY_PARSERS):
                return self.PROPERTY_PARSERS[self._prev.text.upper()](self, default=True)

            if self._match_text_seq("CHARACTER", "SET"):
                return self._parse_character_set(default=True)

        if self._match_text_seq("COMPOUND", "SORTKEY"):
            return self._parse_sortkey(compound=True)

        if self._match_text_seq("PARAMETER", "STYLE", "PANDAS"):
            return self.expression(exp.ParameterStyleProperty(this="PANDAS"))

        index = self._index

        seq_props = self._parse_sequence_properties()
        if seq_props:
            return seq_props

        self._retreat(index)
        return self._parse_key_value_property()

    def _parse_key_value_property(
        self, parse_value: t.Callable[[], exp.Expr | None] | None = None
    ) -> exp.Property | None:
        index = self._index
        key = self._parse_column()

        if not self._match(TokenType.EQ):
            self._retreat(index)
            return None

        # Transform the key to exp.Dot if it's dotted identifiers wrapped in exp.Column or to exp.Var otherwise
        if isinstance(key, exp.Column):
            key = key.to_dot() if len(key.parts) > 1 else exp.var(key.name)

        value = (
            parse_value()
            if parse_value
            else self._parse_bitwise() or self._parse_var(any_token=True)
        )

        # Transform the value to exp.Var if it was parsed as exp.Column(exp.Identifier())
        if isinstance(value, exp.Column):
            value = exp.var(value.name)

        return self.expression(exp.Property(this=key, value=value))

    def _parse_stored(self) -> exp.FileFormatProperty | exp.StorageHandlerProperty:
        if self._match_text_seq("BY"):
            return self.expression(exp.StorageHandlerProperty(this=self._parse_var_or_string()))

        self._match(TokenType.ALIAS)
        input_format = self._parse_string() if self._match_text_seq("INPUTFORMAT") else None
        output_format = self._parse_string() if self._match_text_seq("OUTPUTFORMAT") else None

        return self.expression(
            exp.FileFormatProperty(
                this=(
                    self.expression(
                        exp.InputOutputFormat(
                            input_format=input_format, output_format=output_format
                        )
                    )
                    if input_format or output_format
                    else self._parse_var_or_string() or self._parse_number() or self._parse_id_var()
                ),
                hive_format=True,
            )
        )

    def _parse_unquoted_field(self) -> exp.Expr | None:
        field = self._parse_field()
        if isinstance(field, exp.Identifier) and not field.quoted:
            field = exp.var(field)

        return field

    def _parse_property_assignment(self, exp_class: type[E], **kwargs: t.Any) -> E:
        self._match(TokenType.EQ)
        self._match(TokenType.ALIAS)

        return self.expression(exp_class(this=self._parse_unquoted_field(), **kwargs))

    def _parse_properties(self, before: bool | None = None) -> exp.Properties | None:
        properties = []
        while True:
            if before:
                prop = self._parse_property_before()
            else:
                prop = self._parse_property()
            if not prop:
                break
            for p in ensure_list(prop):
                properties.append(p)

        if properties:
            return self.expression(exp.Properties(expressions=properties))

        return None

    def _parse_fallback(self, no: bool = False) -> exp.FallbackProperty:
        return self.expression(
            exp.FallbackProperty(no=no, protection=self._match_text_seq("PROTECTION"))
        )

    def _parse_sql_security(self) -> exp.SqlSecurityProperty:
        return self.expression(
            exp.SqlSecurityProperty(
                this=self._match_texts(self.SECURITY_PROPERTY_KEYWORDS) and self._prev.text.upper()
            )
        )

    def _parse_settings_property(self) -> exp.SettingsProperty:
        return self.expression(
            exp.SettingsProperty(expressions=self._parse_csv(self._parse_assignment))
        )

    def _parse_called_on_null_input_property(self) -> exp.CalledOnNullInputProperty | None:
        if not self._match_text_seq("ON", "NULL", "INPUT"):
            self._retreat(self._index - 1)
            return None

        return self.expression(exp.CalledOnNullInputProperty())

    def _parse_volatile_property(self) -> exp.VolatileProperty | exp.StabilityProperty:
        if self._index >= 2:
            pre_volatile_token = self._tokens[self._index - 2]
        else:
            pre_volatile_token = None

        if pre_volatile_token and pre_volatile_token.token_type in self.PRE_VOLATILE_TOKENS:
            return exp.VolatileProperty()

        return self.expression(exp.StabilityProperty(this=exp.Literal.string("VOLATILE")))

    def _parse_retention_period(self) -> exp.Var:
        # Parse TSQL's HISTORY_RETENTION_PERIOD: {INFINITE | <number> DAY | DAYS | MONTH ...}
        number = self._parse_number()
        number_str = f"{number} " if number else ""
        unit = self._parse_var(any_token=True)
        return exp.var(f"{number_str}{unit}")

    def _parse_system_versioning_property(
        self, with_: bool = False
    ) -> exp.WithSystemVersioningProperty:
        self._match(TokenType.EQ)
        prop = self.expression(exp.WithSystemVersioningProperty(on=True, with_=with_))

        if self._match_text_seq("OFF"):
            prop.set("on", False)
            return prop

        self._match(TokenType.ON)
        if self._match(TokenType.L_PAREN):
            while self._curr and not self._match(TokenType.R_PAREN):
                if self._match_text_seq("HISTORY_TABLE", "="):
                    prop.set("this", self._parse_table_parts())
                elif self._match_text_seq("DATA_CONSISTENCY_CHECK", "="):
                    prop.set("data_consistency", self._advance_any() and self._prev.text.upper())
                elif self._match_text_seq("HISTORY_RETENTION_PERIOD", "="):
                    prop.set("retention_period", self._parse_retention_period())

                self._match(TokenType.COMMA)

        return prop

    def _parse_data_deletion_property(self) -> exp.DataDeletionProperty:
        self._match(TokenType.EQ)
        on = self._match_text_seq("ON") or not self._match_text_seq("OFF")
        prop = self.expression(exp.DataDeletionProperty(on=on))

        if self._match(TokenType.L_PAREN):
            while self._curr and not self._match(TokenType.R_PAREN):
                if self._match_text_seq("FILTER_COLUMN", "="):
                    prop.set("filter_column", self._parse_column())
                elif self._match_text_seq("RETENTION_PERIOD", "="):
                    prop.set("retention_period", self._parse_retention_period())

                self._match(TokenType.COMMA)

        return prop

    def _parse_distributed_property(self) -> exp.DistributedByProperty:
        kind = "HASH"
        expressions: list[exp.Expr] | None = None
        if self._match_text_seq("BY", "HASH"):
            expressions = self._parse_wrapped_csv(self._parse_id_var)
        elif self._match_text_seq("BY", "RANDOM"):
            kind = "RANDOM"

        # If the BUCKETS keyword is not present, the number of buckets is AUTO
        buckets: exp.Expr | None = None
        if self._match_text_seq("BUCKETS") and not self._match_text_seq("AUTO"):
            buckets = self._parse_number()

        return self.expression(
            exp.DistributedByProperty(
                expressions=expressions, kind=kind, buckets=buckets, order=self._parse_order()
            )
        )

    def _parse_composite_key_property(self, expr_type: type[E]) -> E:
        self._match_text_seq("KEY")
        expressions = self._parse_wrapped_id_vars()
        return self.expression(expr_type(expressions=expressions))

    def _parse_with_property(self) -> exp.Expr | None | list[exp.Expr]:
        if self._match_text_seq("(", "SYSTEM_VERSIONING"):
            prop = self._parse_system_versioning_property(with_=True)
            self._match_r_paren()
            return prop

        if self._match(TokenType.L_PAREN, advance=False):
            result: list[exp.Expr] = []
            for i in self._parse_wrapped_properties():
                result.extend(i) if isinstance(i, list) else result.append(i)
            return result

        if self._match_text_seq("JOURNAL"):
            return self._parse_withjournaltable()

        if self._match_texts(self.VIEW_ATTRIBUTES):
            return self.expression(exp.ViewAttributeProperty(this=self._prev.text.upper()))

        if self._match_text_seq("DATA"):
            return self._parse_withdata(no=False)
        elif self._match_text_seq("NO", "DATA"):
            return self._parse_withdata(no=True)

        if self._match(TokenType.SERDE_PROPERTIES, advance=False):
            return self._parse_serde_properties(with_=True)

        if self._match(TokenType.SCHEMA):
            return self.expression(
                exp.WithSchemaBindingProperty(
                    this=self._parse_var_from_options(self.SCHEMA_BINDING_OPTIONS)
                )
            )

        if self._match_texts(self.PROCEDURE_OPTIONS, advance=False):
            return self.expression(
                exp.WithProcedureOptions(expressions=self._parse_csv(self._parse_procedure_option))
            )

        if not self._next:
            return None

        return self._parse_withisolatedloading()

    def _parse_procedure_option(self) -> exp.Expr | None:
        if self._match_text_seq("EXECUTE", "AS"):
            return self.expression(
                exp.ExecuteAsProperty(
                    this=self._parse_var_from_options(
                        self.EXECUTE_AS_OPTIONS, raise_unmatched=False
                    )
                    or self._parse_string()
                )
            )

        return self._parse_var_from_options(self.PROCEDURE_OPTIONS)

    # https://dev.mysql.com/doc/refman/8.0/en/create-view.html
    def _parse_definer(self) -> exp.DefinerProperty | None:
        self._match(TokenType.EQ)

        user = self._parse_id_var()
        self._match(TokenType.PARAMETER)
        host = self._parse_id_var() or (self._match(TokenType.MOD) and self._prev.text)

        if not user or not host:
            return None

        return exp.DefinerProperty(this=f"{user}@{host}")

    def _parse_withjournaltable(self) -> exp.WithJournalTableProperty:
        self._match(TokenType.TABLE)
        self._match(TokenType.EQ)
        return self.expression(exp.WithJournalTableProperty(this=self._parse_table_parts()))

    def _parse_log(self, no: bool = False) -> exp.LogProperty:
        return self.expression(exp.LogProperty(no=no))

    def _parse_journal(self, **kwargs) -> exp.JournalProperty:
        return self.expression(exp.JournalProperty(**kwargs))

    def _parse_checksum(self) -> exp.ChecksumProperty:
        self._match(TokenType.EQ)

        on = None
        if self._match(TokenType.ON):
            on = True
        elif self._match_text_seq("OFF"):
            on = False

        return self.expression(exp.ChecksumProperty(on=on, default=self._match(TokenType.DEFAULT)))

    def _parse_cluster(self) -> exp.Cluster:
        self._match(TokenType.CLUSTER_BY)
        return self.expression(
            exp.Cluster(
                expressions=self._parse_csv(self._parse_column),
            )
        )

    def _parse_cluster_property(self) -> exp.ClusterProperty:
        return self.expression(
            exp.ClusterProperty(
                expressions=self._parse_wrapped_csv(self._parse_column),
            )
        )

    def _parse_clustered_by(self) -> exp.ClusteredByProperty:
        self._match_text_seq("BY")

        self._match_l_paren()
        expressions = self._parse_csv(self._parse_column)
        self._match_r_paren()

        if self._match_text_seq("SORTED", "BY"):
            self._match_l_paren()
            sorted_by = self._parse_csv(self._parse_ordered)
            self._match_r_paren()
        else:
            sorted_by = None

        self._match(TokenType.INTO)
        buckets = self._parse_number()
        self._match_text_seq("BUCKETS")

        return self.expression(
            exp.ClusteredByProperty(expressions=expressions, sorted_by=sorted_by, buckets=buckets)
        )

    def _parse_copy_property(self) -> exp.CopyGrantsProperty | None:
        if not self._match_text_seq("GRANTS"):
            self._retreat(self._index - 1)
            return None

        return self.expression(exp.CopyGrantsProperty())

    def _parse_freespace(self) -> exp.FreespaceProperty:
        self._match(TokenType.EQ)
        return self.expression(
            exp.FreespaceProperty(this=self._parse_number(), percent=self._match(TokenType.PERCENT))
        )

    def _parse_mergeblockratio(
        self, no: bool = False, default: bool = False
    ) -> exp.MergeBlockRatioProperty:
        if self._match(TokenType.EQ):
            return self.expression(
                exp.MergeBlockRatioProperty(
                    this=self._parse_number(), percent=self._match(TokenType.PERCENT)
                )
            )

        return self.expression(exp.MergeBlockRatioProperty(no=no, default=default))

    def _parse_datablocksize(
        self,
        default: bool | None = None,
        minimum: bool | None = None,
        maximum: bool | None = None,
    ) -> exp.DataBlocksizeProperty:
        self._match(TokenType.EQ)
        size = self._parse_number()

        units = None
        if self._match_texts(("BYTES", "KBYTES", "KILOBYTES")):
            units = self._prev.text

        return self.expression(
            exp.DataBlocksizeProperty(
                size=size, units=units, default=default, minimum=minimum, maximum=maximum
            )
        )

    def _parse_blockcompression(self) -> exp.BlockCompressionProperty:
        self._match(TokenType.EQ)
        always = self._match_text_seq("ALWAYS")
        manual = self._match_text_seq("MANUAL")
        never = self._match_text_seq("NEVER")
        default = self._match_text_seq("DEFAULT")

        autotemp = None
        if self._match_text_seq("AUTOTEMP"):
            autotemp = self._parse_schema()

        return self.expression(
            exp.BlockCompressionProperty(
                always=always, manual=manual, never=never, default=default, autotemp=autotemp
            )
        )

    def _parse_withisolatedloading(self) -> exp.IsolatedLoadingProperty | None:
        index = self._index
        no = self._match_text_seq("NO")
        concurrent = self._match_text_seq("CONCURRENT")

        if not self._match_text_seq("ISOLATED", "LOADING"):
            self._retreat(index)
            return None

        target = self._parse_var_from_options(self.ISOLATED_LOADING_OPTIONS, raise_unmatched=False)
        return self.expression(
            exp.IsolatedLoadingProperty(no=no, concurrent=concurrent, target=target)
        )

    def _parse_locking(self) -> exp.LockingProperty:
        if self._match(TokenType.TABLE):
            kind = "TABLE"
        elif self._match(TokenType.VIEW):
            kind = "VIEW"
        elif self._match(TokenType.ROW):
            kind = "ROW"
        elif self._match_text_seq("DATABASE"):
            kind = "DATABASE"
        else:
            kind = None

        if kind in ("DATABASE", "TABLE", "VIEW"):
            this = self._parse_table_parts()
        else:
            this = None

        if self._match(TokenType.FOR):
            for_or_in = "FOR"
        elif self._match(TokenType.IN):
            for_or_in = "IN"
        else:
            for_or_in = None

        if self._match_text_seq("ACCESS"):
            lock_type = "ACCESS"
        elif self._match_texts(("EXCL", "EXCLUSIVE")):
            lock_type = "EXCLUSIVE"
        elif self._match_text_seq("SHARE"):
            lock_type = "SHARE"
        elif self._match_text_seq("READ"):
            lock_type = "READ"
        elif self._match_text_seq("WRITE"):
            lock_type = "WRITE"
        elif self._match_text_seq("CHECKSUM"):
            lock_type = "CHECKSUM"
        else:
            lock_type = None

        override = self._match_text_seq("OVERRIDE")

        return self.expression(
            exp.LockingProperty(
                this=this, kind=kind, for_or_in=for_or_in, lock_type=lock_type, override=override
            )
        )

    def _parse_partition_by(self) -> list[exp.Expr]:
        if self._match(TokenType.PARTITION_BY):
            return self._parse_csv(self._parse_disjunction)
        return []

    def _parse_partition_bound_spec(self) -> exp.PartitionBoundSpec:
        def _parse_partition_bound_expr() -> exp.Expr | None:
            if self._match_text_seq("MINVALUE"):
                return exp.var("MINVALUE")
            if self._match_text_seq("MAXVALUE"):
                return exp.var("MAXVALUE")
            return self._parse_bitwise()

        this: exp.Expr | list[exp.Expr] | None = None
        expression = None
        from_expressions = None
        to_expressions = None

        if self._match(TokenType.IN):
            this = self._parse_wrapped_csv(self._parse_bitwise)
        elif self._match(TokenType.FROM):
            from_expressions = self._parse_wrapped_csv(_parse_partition_bound_expr)
            self._match_text_seq("TO")
            to_expressions = self._parse_wrapped_csv(_parse_partition_bound_expr)
        elif self._match_text_seq("WITH", "(", "MODULUS"):
            this = self._parse_number()
            self._match_text_seq(",", "REMAINDER")
            expression = self._parse_number()
            self._match_r_paren()
        else:
            self.raise_error("Failed to parse partition bound spec.")

        return self.expression(
            exp.PartitionBoundSpec(
                this=this,
                expression=expression,
                from_expressions=from_expressions,
                to_expressions=to_expressions,
            )
        )

    # https://www.postgresql.org/docs/current/sql-createtable.html
    def _parse_partitioned_of(self) -> exp.PartitionedOfProperty | None:
        if not self._match_text_seq("OF"):
            self._retreat(self._index - 1)
            return None

        this = self._parse_table(schema=True)

        if self._match(TokenType.DEFAULT):
            expression: exp.Var | exp.PartitionBoundSpec = exp.var("DEFAULT")
        elif self._match_text_seq("FOR", "VALUES"):
            expression = self._parse_partition_bound_spec()
        else:
            self.raise_error("Expecting either DEFAULT or FOR VALUES clause.")

        return self.expression(exp.PartitionedOfProperty(this=this, expression=expression))

    def _parse_partitioned_by(self) -> exp.PartitionedByProperty:
        self._match(TokenType.EQ)
        return self.expression(
            exp.PartitionedByProperty(
                this=self._parse_schema() or self._parse_bracket(self._parse_field())
            )
        )

    def _parse_withdata(self, no: bool = False) -> exp.WithDataProperty:
        if self._match_text_seq("AND", "STATISTICS"):
            statistics = True
        elif self._match_text_seq("AND", "NO", "STATISTICS"):
            statistics = False
        else:
            statistics = None

        return self.expression(exp.WithDataProperty(no=no, statistics=statistics))

    def _parse_contains_property(self) -> exp.SqlReadWriteProperty | None:
        if self._match_text_seq("SQL"):
            return self.expression(exp.SqlReadWriteProperty(this="CONTAINS SQL"))
        return None

    def _parse_modifies_property(self) -> exp.SqlReadWriteProperty | None:
        if self._match_text_seq("SQL", "DATA"):
            return self.expression(exp.SqlReadWriteProperty(this="MODIFIES SQL DATA"))
        return None

    def _parse_no_property(self) -> exp.Expr | None:
        if self._match_text_seq("PRIMARY", "INDEX"):
            return exp.NoPrimaryIndexProperty()
        if self._match_text_seq("SQL"):
            return self.expression(exp.SqlReadWriteProperty(this="NO SQL"))
        return None

    def _parse_on_property(self) -> exp.Expr | None:
        if self._match_text_seq("COMMIT", "PRESERVE", "ROWS"):
            return exp.OnCommitProperty()
        if self._match_text_seq("COMMIT", "DELETE", "ROWS"):
            return exp.OnCommitProperty(delete=True)
        return self.expression(exp.OnProperty(this=self._parse_schema(self._parse_id_var())))

    def _parse_reads_property(self) -> exp.SqlReadWriteProperty | None:
        if self._match_text_seq("SQL", "DATA"):
            return self.expression(exp.SqlReadWriteProperty(this="READS SQL DATA"))
        return None

    def _parse_distkey(self) -> exp.DistKeyProperty:
        return self.expression(exp.DistKeyProperty(this=self._parse_wrapped(self._parse_id_var)))

    def _parse_create_like(self) -> exp.LikeProperty | None:
        table = self._parse_table(schema=True)

        options = []
        while self._match_texts(("INCLUDING", "EXCLUDING")):
            this = self._prev.text.upper()

            id_var = self._parse_id_var()
            if not id_var:
                return None

            options.append(
                self.expression(exp.Property(this=this, value=exp.var(id_var.this.upper())))
            )

        return self.expression(exp.LikeProperty(this=table, expressions=options))

    def _parse_sortkey(self, compound: bool = False) -> exp.SortKeyProperty:
        return self.expression(
            exp.SortKeyProperty(this=self._parse_wrapped_id_vars(), compound=compound)
        )

    def _parse_character_set(self, default: bool = False) -> exp.CharacterSetProperty:
        self._match(TokenType.EQ)
        return self.expression(
            exp.CharacterSetProperty(this=self._parse_var_or_string(), default=default)
        )

    def _parse_remote_with_connection(self) -> exp.RemoteWithConnectionModelProperty:
        self._match_text_seq("WITH", "CONNECTION")
        return self.expression(
            exp.RemoteWithConnectionModelProperty(this=self._parse_table_parts())
        )

    def _parse_returns(self) -> exp.ReturnsProperty:
        value: exp.Expr | None
        null = None
        is_table = self._match(TokenType.TABLE)

        if is_table:
            if self._match(TokenType.LT):
                value = self.expression(
                    exp.Schema(this="TABLE", expressions=self._parse_csv(self._parse_struct_types))
                )
                if not self._match(TokenType.GT):
                    self.raise_error("Expecting >")
            else:
                value = self._parse_schema(exp.var("TABLE"))
        elif self._match_text_seq("NULL", "ON", "NULL", "INPUT"):
            null = True
            value = None
        else:
            value = self._parse_types()

        return self.expression(exp.ReturnsProperty(this=value, is_table=is_table, null=null))

    def _parse_describe(self) -> exp.Describe:
        kind = self._prev.text if self._match_set(self.CREATABLES) else None
        style: str | None = (
            self._prev.text.upper() if self._match_texts(self.DESCRIBE_STYLES) else None
        )
        if self._match(TokenType.DOT):
            style = None
            self._retreat(self._index - 2)

        format = self._parse_property() if self._match(TokenType.FORMAT, advance=False) else None

        if self._match_set(self.STATEMENT_PARSERS, advance=False):
            this = self._parse_statement()
        else:
            this = self._parse_table(schema=True)

        properties = self._parse_properties()
        expressions = properties.expressions if properties else None
        partition = self._parse_partition()
        return self.expression(
            exp.Describe(
                this=this,
                style=style,
                kind=kind,
                expressions=expressions,
                partition=partition,
                format=format,
                as_json=self._match_text_seq("AS", "JSON"),
            )
        )

    def _parse_multitable_inserts(self, comments: list[str] | None) -> exp.MultitableInserts:
        kind = self._prev.text.upper()
        expressions = []

        def parse_conditional_insert() -> exp.ConditionalInsert | None:
            if self._match(TokenType.WHEN):
                expression = self._parse_disjunction()
                self._match(TokenType.THEN)
            else:
                expression = None

            else_ = self._match(TokenType.ELSE)

            if not self._match(TokenType.INTO):
                return None

            return self.expression(
                exp.ConditionalInsert(
                    this=self.expression(
                        exp.Insert(
                            this=self._parse_table(schema=True),
                            expression=self._parse_derived_table_values(),
                        )
                    ),
                    expression=expression,
                    else_=else_,
                )
            )

        expression = parse_conditional_insert()
        while expression is not None:
            expressions.append(expression)
            expression = parse_conditional_insert()

        return self.expression(
            exp.MultitableInserts(kind=kind, expressions=expressions, source=self._parse_table()),
            comments=comments,
        )

    def _parse_insert(self) -> exp.Insert | exp.MultitableInserts:
        comments: list[str] = []
        hint = self._parse_hint()
        overwrite = self._match(TokenType.OVERWRITE)
        ignore = self._match(TokenType.IGNORE)
        local = self._match_text_seq("LOCAL")
        alternative = None
        is_function = None

        if self._match_text_seq("DIRECTORY"):
            this: exp.Expr | None = self.expression(
                exp.Directory(
                    this=self._parse_var_or_string(),
                    local=local,
                    row_format=self._parse_row_format(match_row=True),
                )
            )
        else:
            if self._match_set((TokenType.FIRST, TokenType.ALL)):
                comments += ensure_list(self._prev_comments)
                return self._parse_multitable_inserts(comments)

            if self._match(TokenType.OR):
                alternative = self._match_texts(self.INSERT_ALTERNATIVES) and self._prev.text

            self._match(TokenType.INTO)
            comments += ensure_list(self._prev_comments)
            self._match(TokenType.TABLE)
            is_function = self._match(TokenType.FUNCTION)

            this = self._parse_function() if is_function else self._parse_insert_table()

        # MySQL's INSERT ... SET is normalized into the INSERT ... (cols) VALUES (vals) variant
        set_values = None
        if self._match(TokenType.SET):
            columns = []
            values = []

            def _parse_set_assignment() -> exp.Expr | None:
                target = self._parse_column()
                if isinstance(target, exp.Column) and self._match(TokenType.EQ):
                    if self.dialect.SUPPORTS_VALUES_DEFAULT and self._match(TokenType.DEFAULT):
                        value: exp.Expr | None = exp.var(self._prev.text.upper())
                    else:
                        value = self._parse_disjunction()

                    if value:
                        columns.append(target.this)
                        values.append(value)
                        return value

                self.raise_error("Expected column assignment in INSERT ... SET")
                return None

            self._parse_csv(_parse_set_assignment)

            this = self.expression(exp.Schema(this=this, expressions=columns))
            set_values = self.expression(
                exp.Values(
                    expressions=[exp.Tuple(expressions=values)],
                    alias=self._parse_table_alias(),
                )
            )

        returning = self._parse_returning()  # TSQL allows RETURNING before source

        stored = self._match_text_seq("STORED") and self._parse_stored()
        by_name = self._match_text_seq("BY", "NAME")
        exists = self._parse_exists()
        replace_where = None
        replace_using = None

        if self._match(TokenType.REPLACE):
            if self._match(TokenType.WHERE):
                replace_where = self._parse_disjunction()
            elif self._match(TokenType.USING):
                replace_using = self._parse_using_identifiers()

        return self.expression(
            exp.Insert(
                hint=hint,
                is_function=is_function,
                this=this,
                stored=stored,
                by_name=by_name,
                exists=exists,
                where=replace_where,
                using=replace_using,
                partition=self._match(TokenType.PARTITION_BY) and self._parse_partitioned_by(),
                settings=self._match_text_seq("SETTINGS") and self._parse_settings_property(),
                default=self._match_text_seq("DEFAULT", "VALUES"),
                expression=set_values
                or self._parse_derived_table_values()
                or self._parse_ddl_select(),
                conflict=self._parse_on_conflict(),
                returning=returning or self._parse_returning(),
                overwrite=overwrite,
                alternative=alternative,
                ignore=ignore,
                source=self._match(TokenType.TABLE) and self._parse_table(),
            ),
            comments=comments,
        )

    def _parse_insert_table(self) -> exp.Expr | None:
        this = self._parse_table(schema=True, parse_partition=True)
        if isinstance(this, exp.Table) and self._match(TokenType.ALIAS, advance=False):
            this.set("alias", self._parse_table_alias())
        return this

    def _parse_kill(self) -> exp.Kill:
        kind = exp.var(self._prev.text) if self._match_texts(("CONNECTION", "QUERY")) else None

        return self.expression(exp.Kill(this=self._parse_primary(), kind=kind))

    def _parse_on_conflict(self) -> exp.OnConflict | None:
        conflict = self._match_text_seq("ON", "CONFLICT")
        duplicate = self._match_text_seq("ON", "DUPLICATE", "KEY")

        if not conflict and not duplicate:
            return None

        conflict_keys = None
        constraint = None

        if conflict:
            if self._match_text_seq("ON", "CONSTRAINT"):
                constraint = self._parse_id_var()
            elif self._match(TokenType.L_PAREN):
                conflict_keys = self._parse_csv(self._parse_indexed_column)
                self._match_r_paren()

        index_predicate = self._parse_where()

        action = self._parse_var_from_options(self.CONFLICT_ACTIONS)
        if self._prev.token_type == TokenType.UPDATE:
            self._match(TokenType.SET)
            expressions = self._parse_csv(self._parse_equality)
        else:
            expressions = None

        return self.expression(
            exp.OnConflict(
                duplicate=duplicate,
                expressions=expressions,
                action=action,
                conflict_keys=conflict_keys,
                index_predicate=index_predicate,
                constraint=constraint,
                where=self._parse_where(),
            )
        )

    def _parse_returning(self) -> exp.Returning | None:
        if not self._match(TokenType.RETURNING):
            return None
        return self.expression(
            exp.Returning(
                expressions=self._parse_csv(self._parse_expression),
                into=self._match(TokenType.INTO) and self._parse_table_part(),
            )
        )

    def _parse_row(self) -> exp.RowFormatSerdeProperty | exp.RowFormatDelimitedProperty | None:
        if not self._match(TokenType.FORMAT):
            return None
        return self._parse_row_format()

    def _parse_serde_properties(self, with_: bool = False) -> exp.SerdeProperties | None:
        index = self._index
        with_ = with_ or self._match_text_seq("WITH")

        if not self._match(TokenType.SERDE_PROPERTIES):
            self._retreat(index)
            return None
        return self.expression(
            exp.SerdeProperties(expressions=self._parse_wrapped_properties(), with_=with_)
        )

    def _parse_row_format(
        self, match_row: bool = False
    ) -> exp.RowFormatSerdeProperty | exp.RowFormatDelimitedProperty | None:
        if match_row and not self._match_pair(TokenType.ROW, TokenType.FORMAT):
            return None

        if self._match_text_seq("SERDE"):
            this = self._parse_string()

            serde_properties = self._parse_serde_properties()

            return self.expression(
                exp.RowFormatSerdeProperty(this=this, serde_properties=serde_properties)
            )

        self._match_text_seq("DELIMITED")

        kwargs = {}

        if self._match_text_seq("FIELDS", "TERMINATED", "BY"):
            kwargs["fields"] = self._parse_string()
            if self._match_text_seq("ESCAPED", "BY"):
                kwargs["escaped"] = self._parse_string()
        if self._match_text_seq("COLLECTION", "ITEMS", "TERMINATED", "BY"):
            kwargs["collection_items"] = self._parse_string()
        if self._match_text_seq("MAP", "KEYS", "TERMINATED", "BY"):
            kwargs["map_keys"] = self._parse_string()
        if self._match_text_seq("LINES", "TERMINATED", "BY"):
            kwargs["lines"] = self._parse_string()
        if self._match_text_seq("NULL", "DEFINED", "AS"):
            kwargs["null"] = self._parse_string()

        return self.expression(exp.RowFormatDelimitedProperty(**kwargs))  # type: ignore

    def _parse_load(self) -> exp.LoadData | exp.Command:
        if self._match_text_seq("DATA"):
            local = self._match_text_seq("LOCAL")
            self._match_text_seq("INPATH")
            inpath = self._parse_string()
            overwrite = self._match(TokenType.OVERWRITE)
            temp: bool | None = None
            if self._match(TokenType.INTO):
                temp = self._match(TokenType.TEMPORARY)
                self._match(TokenType.TABLE)

            return self.expression(
                exp.LoadData(
                    this=self._parse_table(schema=True),
                    local=local,
                    overwrite=overwrite,
                    temp=temp,
                    inpath=inpath,
                    files=self._match_text_seq("FROM", "FILES")
                    and exp.Properties(expressions=self._parse_wrapped_properties()),
                    partition=self._parse_partition(),
                    input_format=self._match_text_seq("INPUTFORMAT") and self._parse_string(),
                    serde=self._match_text_seq("SERDE") and self._parse_string(),
                )
            )
        return self._parse_as_command(self._prev)

    def _parse_delete(self) -> exp.Delete:
        hint = self._parse_hint()

        # This handles MySQL's "Multiple-Table Syntax"
        # https://dev.mysql.com/doc/refman/8.0/en/delete.html
        tables = None
        if not self._match(TokenType.FROM, advance=False):
            tables = self._parse_csv(self._parse_table) or None

        returning = self._parse_returning()

        return self.expression(
            exp.Delete(
                hint=hint,
                tables=tables,
                this=self._match(TokenType.FROM) and self._parse_table(joins=True),
                using=self._match(TokenType.USING)
                and self._parse_csv(lambda: self._parse_table(joins=True)),
                cluster=self._match(TokenType.ON) and self._parse_on_property(),
                where=self._parse_where(),
                returning=returning or self._parse_returning(),
                order=self._parse_order(),
                limit=self._parse_limit(),
            )
        )

    def _parse_update(self) -> exp.Update:
        hint = self._parse_hint()
        kwargs: dict[str, object] = {
            "hint": hint,
            "this": self._parse_table(joins=True, alias_tokens=self.UPDATE_ALIAS_TOKENS),
        }
        while self._curr:
            if self._match(TokenType.SET):
                kwargs["expressions"] = self._parse_csv(self._parse_equality)
            elif self._match(TokenType.RETURNING, advance=False):
                kwargs["returning"] = self._parse_returning()
            elif self._match(TokenType.FROM, advance=False):
                from_ = self._parse_from(joins=True)
                table = from_.this if from_ else None
                if isinstance(table, exp.Subquery) and self._match(TokenType.JOIN, advance=False):
                    table.set("joins", list(self._parse_joins()) or None)

                kwargs["from_"] = from_
            elif self._match(TokenType.WHERE, advance=False):
                kwargs["where"] = self._parse_where()
            elif self._match(TokenType.ORDER_BY, advance=False):
                kwargs["order"] = self._parse_order()
            elif self._match(TokenType.LIMIT, advance=False):
                kwargs["limit"] = self._parse_limit()
            else:
                break

        return self.expression(exp.Update(**kwargs))

    def _parse_use(self) -> exp.Use:
        return self.expression(
            exp.Use(
                kind=self._parse_var_from_options(self.USABLES, raise_unmatched=False),
                this=self._parse_table(schema=False),
            )
        )

    def _parse_uncache(self) -> exp.Uncache:
        if not self._match(TokenType.TABLE):
            self.raise_error("Expecting TABLE after UNCACHE")

        return self.expression(
            exp.Uncache(exists=self._parse_exists(), this=self._parse_table(schema=True))
        )

    def _parse_cache(self) -> exp.Cache:
        lazy = self._match_text_seq("LAZY")
        self._match(TokenType.TABLE)
        table = self._parse_table(schema=True)

        options = []
        if self._match_text_seq("OPTIONS"):
            self._match_l_paren()
            k = self._parse_string()
            self._match(TokenType.EQ)
            v = self._parse_string()
            options = [k, v]
            self._match_r_paren()

        self._match(TokenType.ALIAS)
        return self.expression(
            exp.Cache(
                this=table, lazy=lazy, options=options, expression=self._parse_select(nested=True)
            )
        )

    def _parse_partition(self) -> exp.Partition | None:
        if not self._match_texts(self.PARTITION_KEYWORDS):
            return None

        return self.expression(
            exp.Partition(
                subpartition=self._prev.text.upper() == "SUBPARTITION",
                expressions=self._parse_wrapped_csv(self._parse_disjunction),
            )
        )

    def _parse_value(self, values: bool = True) -> exp.Tuple | None:
        def _parse_value_expression() -> exp.Expr | None:
            if self.dialect.SUPPORTS_VALUES_DEFAULT and self._match(TokenType.DEFAULT):
                return exp.var(self._prev.text.upper())
            return self._parse_expression()

        if self._match(TokenType.L_PAREN):
            expressions = self._parse_csv(_parse_value_expression)
            self._match_r_paren()
            return self.expression(exp.Tuple(expressions=expressions))

        # In some dialects we can have VALUES 1, 2 which results in 1 column & 2 rows.
        expression = self._parse_expression()
        if expression:
            return self.expression(exp.Tuple(expressions=[expression]))
        return None

    def _parse_projections(
        self,
    ) -> tuple[list[exp.Expr], list[exp.Expr] | None]:
        return self._parse_expressions(), None

    def _parse_wrapped_select(self, table: bool = False) -> exp.Expr | None:
        if self._match_set((TokenType.PIVOT, TokenType.UNPIVOT)):
            this: exp.Expr | None = self._parse_simplified_pivot(
                is_unpivot=self._prev.token_type == TokenType.UNPIVOT
            )
        elif self._match(TokenType.FROM):
            from_ = self._parse_from(joins=True, skip_from_token=True, consume_pipe=True)
            # Support parentheses for duckdb FROM-first syntax
            select = self._parse_select(from_=from_)
            if select:
                if not select.args.get("from_"):
                    select.set("from_", from_)
                this = select
            else:
                this = exp.select("*").from_(t.cast(exp.From, from_))
                this = self._parse_query_modifiers(self._parse_set_operations(this))
        else:
            this = (
                self._parse_table(consume_pipe=True)
                if table
                else self._parse_select(nested=True, parse_set_operation=False)
            )

            # Transform exp.Values into a exp.Table to pass through parse_query_modifiers
            # in case a modifier (e.g. join) is following
            if table and isinstance(this, exp.Values) and this.alias:
                alias = this.args["alias"].pop()
                this = exp.Table(this=this, alias=alias)

            this = self._parse_query_modifiers(self._parse_set_operations(this))

        return this

    def _parse_select(
        self,
        nested: bool = False,
        table: bool = False,
        parse_subquery_alias: bool = True,
        parse_set_operation: bool = True,
        consume_pipe: bool = True,
        from_: exp.From | None = None,
    ) -> exp.Expr | None:
        query = self._parse_select_query(
            nested=nested,
            table=table,
            parse_subquery_alias=parse_subquery_alias,
            parse_set_operation=parse_set_operation,
        )

        if consume_pipe and self._match(TokenType.PIPE_GT, advance=False):
            if not query and from_:
                query = exp.select("*").from_(from_)
            if isinstance(query, exp.Query):
                query = self._parse_pipe_syntax_query(query)
                query = query.subquery(copy=False) if query and table else query

        return query

    def _parse_select_query(
        self,
        nested: bool = False,
        table: bool = False,
        parse_subquery_alias: bool = True,
        parse_set_operation: bool = True,
    ) -> exp.Expr | None:
        cte = self._parse_with()

        if cte:
            this = self._parse_statement()

            if not this:
                self.raise_error("Failed to parse any statement following CTE")
                return cte

            while isinstance(this, exp.Subquery) and this.is_wrapper:
                this = this.this

            assert this is not None
            if "with_" in this.arg_types:
                if inner_cte := this.args.get("with_"):
                    cte.set("expressions", cte.expressions + inner_cte.expressions)
                    if inner_cte.args.get("recursive"):
                        cte.set("recursive", True)
                this.set("with_", cte)
            else:
                self.raise_error(f"{this.key} does not support CTE")
                this = cte

            return this

        # duckdb supports leading with FROM x
        from_ = (
            self._parse_from(joins=True, consume_pipe=True)
            if self._match(TokenType.FROM, advance=False)
            else None
        )

        if self._match(TokenType.SELECT):
            comments = self._prev_comments

            hint = self._parse_hint()

            if self._next and not self._next.token_type == TokenType.DOT:
                all_ = self._match(TokenType.ALL)
                matched_distinct = self._match_set(self.DISTINCT_TOKENS)
            else:
                all_, matched_distinct = None, False

            kind = (
                self._prev.text.upper()
                if self._match(TokenType.ALIAS) and self._match_texts(("STRUCT", "VALUE"))
                else None
            )

            distinct: exp.Expr | None = (
                self.expression(
                    exp.Distinct(
                        on=self._parse_value(values=False) if self._match(TokenType.ON) else None
                    )
                )
                if matched_distinct
                else None
            )

            operation_modifiers = []
            while self._curr and self._match_texts(self.OPERATION_MODIFIERS):
                operation_modifiers.append(exp.var(self._prev.text.upper()))

            limit = self._parse_limit(top=True)

            # Some dialects (e.g. Redshift, T-SQL) allow SELECT TOP N DISTINCT ...
            if limit and not matched_distinct and not all_:
                matched_distinct = self._match_set(self.DISTINCT_TOKENS)
                if matched_distinct:
                    distinct = self.expression(
                        exp.Distinct(
                            on=self._parse_value(values=False)
                            if self._match(TokenType.ON)
                            else None
                        )
                    )
                else:
                    all_ = self._match(TokenType.ALL)

            if all_ and distinct:
                self.raise_error("Cannot specify both ALL and DISTINCT after SELECT")

            projections, exclude = self._parse_projections()

            this = self.expression(
                exp.Select(
                    kind=kind,
                    hint=hint,
                    distinct=distinct,
                    expressions=projections,
                    limit=limit,
                    exclude=exclude,
                    operation_modifiers=operation_modifiers or None,
                )
            )
            this.comments = comments

            into = self._parse_into()
            if into:
                this.set("into", into)

            if not from_:
                from_ = self._parse_from()

            if from_:
                this.set("from_", from_)

            this = self._parse_query_modifiers(this)
        elif (table or nested) and self._match(TokenType.L_PAREN):
            comments = self._prev_comments
            this = self._parse_wrapped_select(table=table)

            if this:
                this.add_comments(comments, prepend=True)

            # We return early here so that the UNION isn't attached to the subquery by the
            # following call to _parse_set_operations, but instead becomes the parent node
            self._match_r_paren()
            return self._parse_subquery(this, parse_alias=parse_subquery_alias)
        elif self._match(TokenType.VALUES, advance=False):
            this = self._parse_derived_table_values()
        elif from_:
            this = exp.select("*").from_(from_.this, copy=False)
            this = self._parse_query_modifiers(this)
        elif self._match(TokenType.SUMMARIZE):
            table = self._match(TokenType.TABLE)
            this = self._parse_select() or self._parse_string() or self._parse_table()
            return self.expression(exp.Summarize(this=this, table=table))
        elif self._match(TokenType.DESCRIBE):
            this = self._parse_describe()
        else:
            this = None

        return self._parse_set_operations(this) if parse_set_operation else this

    def _parse_recursive_with_search(self) -> exp.RecursiveWithSearch | None:
        self._match_text_seq("SEARCH")

        kind = self._match_texts(self.RECURSIVE_CTE_SEARCH_KIND) and self._prev.text.upper()

        if not kind:
            return None

        self._match_text_seq("FIRST", "BY")

        return self.expression(
            exp.RecursiveWithSearch(
                kind=kind,
                this=self._parse_id_var(),
                expression=self._match_text_seq("SET") and self._parse_id_var(),
                using=self._match_text_seq("USING") and self._parse_id_var(),
            )
        )

    def _parse_with(self, skip_with_token: bool = False) -> exp.With | None:
        if not skip_with_token and not self._match(TokenType.WITH):
            return None

        comments = self._prev_comments
        recursive = self._match(TokenType.RECURSIVE)

        last_comments = None
        expressions = []
        udfs = []
        while True:
            cte = self._parse_cte()
            if cte:
                if isinstance(cte, exp.FunctionSpecification):
                    udfs.append(cte)
                else:
                    expressions.append(cte)

                if last_comments:
                    cte.add_comments(last_comments)

            if not self._match(TokenType.COMMA) and not self._match(TokenType.WITH):
                break
            else:
                self._match(TokenType.WITH)
                recursive = self._match(TokenType.RECURSIVE) or recursive

            last_comments = self._prev_comments

        return self.expression(
            exp.With(
                expressions=expressions,
                recursive=recursive or None,
                search=self._parse_recursive_with_search(),
                udfs=udfs or None,
            ),
            comments=comments,
        )

    def _parse_cte(self) -> exp.CTE | exp.FunctionSpecification | None:
        index = self._index

        alias = self._parse_table_alias(self.ID_VAR_TOKENS)
        if not alias or not alias.this:
            self.raise_error("Expected CTE to have alias")

        key_expressions = (
            self._parse_wrapped_id_vars() if self._match_text_seq("USING", "KEY") else None
        )

        if not self._match(TokenType.ALIAS) and not self.OPTIONAL_ALIAS_TOKEN_CTE:
            self._retreat(index)
            return None

        comments = self._prev_comments

        if self._match_text_seq("NOT", "MATERIALIZED"):
            materialized = False
        elif self._match_text_seq("MATERIALIZED"):
            materialized = True
        else:
            materialized = None

        cte = self.expression(
            exp.CTE(
                this=self._parse_wrapped(self._parse_statement),
                alias=alias,
                materialized=materialized,
                key_expressions=key_expressions,
            ),
            comments=comments,
        )

        values = cte.this
        if isinstance(values, exp.Values):
            cte.set("this", self._values_to_select(values))

        return cte

    def _values_to_select(self, values: exp.Values) -> exp.Select:
        if values.alias:
            return exp.select("*").from_(values)
        return exp.select("*").from_(exp.alias_(values, "_values", table=True))

    def _parse_table_alias(
        self, alias_tokens: t.Collection[TokenType] | None = None
    ) -> exp.TableAlias | None:
        # In some dialects, LIMIT and OFFSET can act as both identifiers and keywords (clauses)
        # so this section tries to parse the clause version and if it fails, it treats the token
        # as an identifier (alias)
        if self._can_parse_limit_or_offset():
            return None

        # START is never treated as an implicit alias when followed by WITH, since that
        # would swallow the beginning of a START WITH ... CONNECT BY clause
        if self._curr.text.upper() == "START" and self._next.text.upper() == "WITH":
            return None

        any_token = self._match(TokenType.ALIAS)
        alias = (
            self._parse_id_var(any_token=any_token, tokens=alias_tokens or self.TABLE_ALIAS_TOKENS)
            or self._parse_string_as_identifier()
        )

        index = self._index
        if self._match(TokenType.L_PAREN):
            columns = self._parse_csv(self._parse_function_parameter)
            self._match_r_paren() if columns else self._retreat(index)
        else:
            columns = None

        if not alias and not columns:
            return None

        table_alias = self.expression(exp.TableAlias(this=alias, columns=columns))

        # We bubble up comments from the Identifier to the TableAlias
        if isinstance(alias, exp.Identifier):
            table_alias.add_comments(alias.pop_comments())

        return table_alias

    def _parse_subquery(
        self, this: exp.Expr | None, parse_alias: bool = True
    ) -> exp.Subquery | None:
        if not this:
            return None

        return self.expression(
            exp.Subquery(
                this=this,
                pivots=self._parse_pivots(),
                alias=self._parse_table_alias() if parse_alias else None,
                sample=self._parse_table_sample(),
            )
        )

    def _implicit_unnests_to_explicit(self, this: E) -> E:
        from sqlglot.optimizer.normalize_identifiers import normalize_identifiers as _norm

        refs = {_norm(this.args["from_"].this.copy(), dialect=self.dialect).alias_or_name}
        for i, join in enumerate(this.args.get("joins") or []):
            table = join.this
            normalized_table = table.copy()
            normalized_table.meta["maybe_column"] = True
            normalized_table = _norm(normalized_table, dialect=self.dialect)

            if isinstance(table, exp.Table) and not join.args.get("on"):
                if len(normalized_table.parts) > 1 and normalized_table.parts[0].name in refs:
                    table_as_column = table.to_column()
                    unnest = exp.Unnest(expressions=[table_as_column])

                    # Table.to_column creates a parent Alias node that we want to convert to
                    # a TableAlias and attach to the Unnest, so it matches the parser's output
                    if isinstance(table.args.get("alias"), exp.TableAlias):
                        table_as_column.replace(table_as_column.this)
                        exp.alias_(unnest, None, table=[table.args["alias"].this], copy=False)

                    table.replace(unnest)

            refs.add(normalized_table.alias_or_name)

        return this

    @t.overload
    def _parse_query_modifiers(self, this: E) -> E: ...

    @t.overload
    def _parse_query_modifiers(self, this: None) -> None: ...

    def _parse_query_modifiers(self, this):
        if isinstance(this, self.MODIFIABLES):
            for join in self._parse_joins():
                this.append("joins", join)
            for lateral in iter(self._parse_lateral, None):
                this.append("laterals", lateral)

            while True:
                if self._match_set(self.QUERY_MODIFIER_PARSERS, advance=False):
                    modifier_token = self._curr
                    parser = self.QUERY_MODIFIER_PARSERS[modifier_token.token_type]
                    key, expression = parser(self)

                    if expression:
                        if this.args.get(key):
                            self.raise_error(
                                f"Found multiple '{modifier_token.text.upper()}' clauses",
                                token=modifier_token,
                            )

                        this.set(key, expression)
                        if key == "limit":
                            offset = expression.args.get("offset")
                            expression.set("offset", None)

                            if offset:
                                offset = exp.Offset(expression=offset)
                                this.set("offset", offset)

                                limit_by_expressions = expression.expressions
                                expression.set("expressions", None)
                                offset.set("expressions", limit_by_expressions)
                        continue

                if self._curr.text.upper() == "START":
                    modifier_token = self._curr
                    connect = self._parse_connect()
                    if connect:
                        if this.args.get("connect"):
                            self.raise_error(
                                "Found multiple 'START WITH' clauses", token=modifier_token
                            )

                        this.set("connect", connect)
                        continue
                break

        if self.SUPPORTS_IMPLICIT_UNNEST and this and this.args.get("from_"):
            this = self._implicit_unnests_to_explicit(this)

        return this

    def _parse_hint_fallback_to_string(self) -> exp.Hint | None:
        start = self._curr
        while self._curr:
            self._advance()

        end = self._tokens[self._index - 1]
        return exp.Hint(expressions=[self._find_sql(start, end)])

    def _parse_hint_function_call(self) -> exp.Expr | None:
        return self._parse_function_call()

    def _parse_hint_body(self) -> exp.Hint | None:
        start_index = self._index
        should_fallback_to_string = False

        hints = []
        try:
            for hint in iter(
                lambda: self._parse_csv(
                    lambda: self._parse_hint_function_call() or self._parse_var(upper=True),
                ),
                [],
            ):
                hints.extend(hint)
        except ParseError:
            should_fallback_to_string = True

        if should_fallback_to_string or self._curr:
            self._retreat(start_index)
            return self._parse_hint_fallback_to_string()

        return self.expression(exp.Hint(expressions=hints))

    def _parse_hint(self) -> exp.Hint | None:
        if self._match(TokenType.HINT) and self._prev_comments:
            return exp.maybe_parse(self._prev_comments[0], into=exp.Hint, dialect=self.dialect)

        return None

    def _parse_into(self) -> exp.Into | None:
        if not self._match(TokenType.INTO):
            return None

        temp = self._match(TokenType.TEMPORARY)
        unlogged = self._match_text_seq("UNLOGGED")
        self._match(TokenType.TABLE)

        return self.expression(
            exp.Into(this=self._parse_table(schema=True), temporary=temp, unlogged=unlogged)
        )

    def _parse_from(
        self,
        joins: bool = False,
        skip_from_token: bool = False,
        consume_pipe: bool = False,
    ) -> exp.From | None:
        if not skip_from_token and not self._match(TokenType.FROM):
            return None

        comments = self._prev_comments
        return self.expression(
            exp.From(this=self._parse_table(joins=joins, consume_pipe=consume_pipe)),
            comments=comments,
        )

    def _parse_match_recognize_measure(self) -> exp.MatchRecognizeMeasure:
        return self.expression(
            exp.MatchRecognizeMeasure(
                window_frame=self._match_texts(("FINAL", "RUNNING")) and self._prev.text.upper(),
                this=self._parse_expression(),
            )
        )

    def _parse_match_recognize(self) -> exp.MatchRecognize | None:
        if not self._match(TokenType.MATCH_RECOGNIZE):
            return None

        self._match_l_paren()

        partition = self._parse_partition_by()
        order = self._parse_order()

        measures = (
            self._parse_csv(self._parse_match_recognize_measure)
            if self._match_text_seq("MEASURES")
            else None
        )

        if self._match_text_seq("ONE", "ROW", "PER", "MATCH"):
            rows = exp.var("ONE ROW PER MATCH")
        elif self._match_text_seq("ALL", "ROWS", "PER", "MATCH"):
            text = "ALL ROWS PER MATCH"
            if self._match_text_seq("SHOW", "EMPTY", "MATCHES"):
                text += " SHOW EMPTY MATCHES"
            elif self._match_text_seq("OMIT", "EMPTY", "MATCHES"):
                text += " OMIT EMPTY MATCHES"
            elif self._match_text_seq("WITH", "UNMATCHED", "ROWS"):
                text += " WITH UNMATCHED ROWS"
            rows = exp.var(text)
        else:
            rows = None

        if self._match_text_seq("AFTER", "MATCH", "SKIP"):
            text = "AFTER MATCH SKIP"
            if self._match_text_seq("PAST", "LAST", "ROW"):
                text += " PAST LAST ROW"
            elif self._match_text_seq("TO", "NEXT", "ROW"):
                text += " TO NEXT ROW"
            elif self._match_text_seq("TO", "FIRST") or self._match_text_seq("TO", "LAST"):
                direction = self._prev.text.upper()
                pattern_var = self._advance_any()
                if not pattern_var:
                    self.raise_error(
                        f"Expecting pattern variable after AFTER MATCH SKIP TO {direction}"
                    )
                text += f" TO {direction} {pattern_var.text if pattern_var else ''}"
            after = exp.var(text)
        else:
            after = None

        if self._match_text_seq("PATTERN"):
            self._match_l_paren()

            if not self._curr:
                self.raise_error("Expecting )", self._curr)

            paren = 1
            start = self._curr

            while self._curr and paren > 0:
                if self._curr.token_type == TokenType.L_PAREN:
                    paren += 1
                if self._curr.token_type == TokenType.R_PAREN:
                    paren -= 1

                end = self._prev
                self._advance()

            if paren > 0:
                self.raise_error("Expecting )", self._curr)

            pattern = exp.var(self._find_sql(start, end))
        else:
            pattern = None

        define = (
            self._parse_csv(self._parse_name_as_expression)
            if self._match_text_seq("DEFINE")
            else None
        )

        self._match_r_paren()

        return self.expression(
            exp.MatchRecognize(
                partition_by=partition,
                order=order,
                measures=measures,
                rows=rows,
                after=after,
                pattern=pattern,
                define=define,
                alias=self._parse_table_alias(),
            )
        )

    def _parse_lateral(self) -> exp.Lateral | None:
        cross_apply: bool | None = None
        if self._match_pair(TokenType.CROSS, TokenType.APPLY):
            cross_apply = True
        elif self._match_pair(TokenType.OUTER, TokenType.APPLY):
            cross_apply = False

        if cross_apply is not None:
            this = self._parse_select(table=True)
            view = None
            outer = None
        elif self._match(TokenType.LATERAL):
            this = self._parse_select(table=True)
            view = self._match(TokenType.VIEW)
            outer = self._match(TokenType.OUTER)
        else:
            return None

        if not this:
            this = (
                self._parse_unnest()
                or self._parse_function()
                or self._parse_id_var(any_token=False)
            )

            while self._match(TokenType.DOT):
                this = exp.Dot(
                    this=this,
                    expression=self._parse_function() or self._parse_id_var(any_token=False),
                )

        ordinality: bool | None = None

        if view:
            table = self._parse_id_var(any_token=False)
            columns = self._parse_csv(self._parse_id_var) if self._match(TokenType.ALIAS) else []
            table_alias: exp.TableAlias | None = self.expression(
                exp.TableAlias(this=table, columns=columns)
            )
        elif isinstance(this, (exp.Subquery, exp.Unnest)) and this.alias:
            # We move the alias from the lateral's child node to the lateral itself
            table_alias = this.args["alias"].pop()
        else:
            ordinality = self._match_pair(TokenType.WITH, TokenType.ORDINALITY)
            table_alias = self._parse_table_alias()

        return self.expression(
            exp.Lateral(
                this=this,
                view=view,
                outer=outer,
                alias=table_alias,
                cross_apply=cross_apply,
                ordinality=ordinality,
            )
        )

    def _parse_stream(self) -> exp.Stream | None:
        index = self._index
        if self._match(TokenType.STREAM):
            if this := self._try_parse(self._parse_table):
                return self.expression(exp.Stream(this=this))
            self._retreat(index)
        return None

    def _parse_join_parts(
        self,
    ) -> tuple[Token | None, Token | None, Token | None]:
        return (
            self._prev if self._match_set(self.JOIN_METHODS) else None,
            self._prev if self._match_set(self.JOIN_SIDES) else None,
            self._prev if self._match_set(self.JOIN_KINDS) else None,
        )

    def _parse_using_identifiers(self) -> list[exp.Expr]:
        def _parse_column_as_identifier() -> exp.Expr | None:
            this = self._parse_column()
            if isinstance(this, exp.Column):
                return this.this
            return this

        return self._parse_wrapped_csv(_parse_column_as_identifier, optional=True)

    def _parse_join(
        self,
        skip_join_token: bool = False,
        parse_bracket: bool = False,
        alias_tokens: t.Collection[TokenType] | None = None,
    ) -> exp.Join | None:
        if self._match(TokenType.COMMA):
            table = self._try_parse(lambda: self._parse_table(alias_tokens=alias_tokens))
            cross_join = self.expression(exp.Join(this=table)) if table else None

            if cross_join and self.JOINS_HAVE_EQUAL_PRECEDENCE:
                cross_join.set("kind", "CROSS")

            return cross_join

        index = self._index
        method, side, kind = self._parse_join_parts()
        directed = self._match_text_seq("DIRECTED")
        hint = self._prev.text if self._match_texts(self.JOIN_HINTS) else None
        join = self._match(TokenType.JOIN) or (kind and kind.token_type == TokenType.STRAIGHT_JOIN)
        join_comments = self._prev_comments

        if not skip_join_token and not join:
            self._retreat(index)
            kind = None
            method = None
            side = None

        outer_apply = self._match_pair(TokenType.OUTER, TokenType.APPLY, False)
        cross_apply = self._match_pair(TokenType.CROSS, TokenType.APPLY, False)

        if not skip_join_token and not join and not outer_apply and not cross_apply:
            return None

        kwargs: dict[str, t.Any] = {
            "this": self._parse_table(parse_bracket=parse_bracket, alias_tokens=alias_tokens)
        }
        if kind and kind.token_type == TokenType.ARRAY and self._match(TokenType.COMMA):
            kwargs["expressions"] = self._parse_csv(
                lambda: self._parse_table(parse_bracket=parse_bracket, alias_tokens=alias_tokens)
            )

        if method:
            kwargs["method"] = method.text.upper()
        if side:
            kwargs["side"] = side.text.upper()
        if kind:
            kwargs["kind"] = kind.text.upper()
        if hint:
            kwargs["hint"] = hint

        if self._match(TokenType.MATCH_CONDITION):
            kwargs["match_condition"] = self._parse_wrapped(self._parse_comparison)

        if self._match(TokenType.ON):
            kwargs["on"] = self._parse_disjunction()
        elif self._match(TokenType.USING):
            kwargs["using"] = self._parse_using_identifiers()
        elif (
            not method
            and not (outer_apply or cross_apply)
            and not isinstance(kwargs["this"], exp.Unnest)
            and not (kind and kind.token_type in (TokenType.CROSS, TokenType.ARRAY))
        ):
            index = self._index
            joins: list | None = list(self._parse_joins(alias_tokens=alias_tokens))

            if joins and self._match(TokenType.ON):
                kwargs["on"] = self._parse_disjunction()
            elif joins and self._match(TokenType.USING):
                kwargs["using"] = self._parse_using_identifiers()
            else:
                joins = None
                self._retreat(index)

            kwargs["this"].set("joins", joins if joins else None)

        kwargs["pivots"] = self._parse_pivots()

        comments = [c for token in (method, side, kind) if token for c in token.comments]
        comments = (join_comments or []) + comments

        if (
            self.ADD_JOIN_ON_TRUE
            and not kwargs.get("on")
            and not kwargs.get("using")
            and not kwargs.get("method")
            and kwargs.get("kind") in (None, "INNER", "OUTER")
        ):
            kwargs["on"] = exp.true()

        if directed:
            kwargs["directed"] = directed

        return self.expression(exp.Join(**kwargs), comments=comments)

    def _parse_opclass(self) -> exp.Expr | None:
        this = self._parse_disjunction()

        if self._match_texts(self.OPCLASS_FOLLOW_KEYWORDS, advance=False):
            return this

        if not self._match_set(self.OPTYPE_FOLLOW_TOKENS, advance=False):
            return self.expression(exp.Opclass(this=this, expression=self._parse_table_parts()))

        return this

    def _parse_index_params(self) -> exp.IndexParameters:
        using = self._parse_var(any_token=True) if self._match(TokenType.USING) else None

        if self._match(TokenType.L_PAREN, advance=False):
            columns = self._parse_wrapped_csv(self._parse_with_operator)
        else:
            columns = None

        include = self._parse_wrapped_id_vars() if self._match_text_seq("INCLUDE") else None
        partition_by = self._parse_partition_by()
        with_storage = self._match(TokenType.WITH) and self._parse_wrapped_properties()
        tablespace = (
            self._parse_var(any_token=True)
            if self._match_text_seq("USING", "INDEX", "TABLESPACE")
            else None
        )
        where = self._parse_where()

        on = self._parse_field() if self._match(TokenType.ON) else None

        return self.expression(
            exp.IndexParameters(
                using=using,
                columns=columns,
                include=include,
                partition_by=partition_by,
                where=where,
                with_storage=with_storage,
                tablespace=tablespace,
                on=on,
            )
        )

    def _parse_index(
        self, index: exp.Expr | None = None, anonymous: bool = False
    ) -> exp.Index | None:
        if index or anonymous:
            unique = None
            primary = None
            amp = None

            self._match(TokenType.ON)
            self._match(TokenType.TABLE)  # hive
            table = self._parse_table_parts(schema=True)
        else:
            unique = self._match(TokenType.UNIQUE)
            primary = self._match_text_seq("PRIMARY")
            amp = self._match_text_seq("AMP")

            if not self._match(TokenType.INDEX):
                return None

            index = self._parse_id_var()
            table = None

        params = self._parse_index_params()

        return self.expression(
            exp.Index(
                this=index, table=table, unique=unique, primary=primary, amp=amp, params=params
            )
        )

    def _parse_table_hints(self) -> list[exp.Expr] | None:
        hints: list[exp.Expr] = []
        if self._match_pair(TokenType.WITH, TokenType.L_PAREN):
            # https://learn.microsoft.com/en-us/sql/t-sql/queries/hints-transact-sql-table?view=sql-server-ver16
            hints.append(
                self.expression(
                    exp.WithTableHint(
                        expressions=self._parse_csv(
                            lambda: self._parse_function() or self._parse_var(any_token=True)
                        )
                    )
                )
            )
            self._match_r_paren()
        else:
            # https://dev.mysql.com/doc/refman/8.0/en/index-hints.html
            while self._match_set(self.TABLE_INDEX_HINT_TOKENS):
                hint = exp.IndexTableHint(this=self._prev.text.upper())

                self._match_set((TokenType.INDEX, TokenType.KEY))
                if self._match(TokenType.FOR):
                    hint.set("target", self._advance_any() and self._prev.text.upper())

                hint.set("expressions", self._parse_wrapped_id_vars())
                hints.append(hint)

        return hints or None

    def _parse_table_part(self, schema: bool = False) -> exp.Expr | None:
        return (
            (not schema and self._parse_function(optional_parens=False))
            or self._parse_id_var(any_token=False)
            or self._parse_string_as_identifier()
            or self._parse_placeholder()
        )

    def _parse_table_parts_fast(self) -> exp.Table | None:
        index = self._index
        parts: list[exp.Identifier] | None = None
        all_comments: list[str] | None = None

        while self._match_set(self.IDENTIFIER_TOKENS):
            token = self._prev
            comments = self._prev_comments

            has_dot = self._match(TokenType.DOT)
            curr_tt = self._curr.token_type

            if not has_dot:
                if curr_tt in self.TABLE_POSTFIX_TOKENS:
                    self._retreat(index)
                    return None
            elif curr_tt not in self.IDENTIFIER_TOKENS:
                self._retreat(index)
                return None

            if parts is None:
                parts = []

            if comments:
                if all_comments is None:
                    all_comments = []
                all_comments.extend(comments)
                self._prev_comments = []

            parts.append(
                self.expression(
                    exp.Identifier(
                        this=token.text, quoted=token.token_type == TokenType.IDENTIFIER
                    ),
                    token,
                )
            )

            if not has_dot:
                break

        if parts is None:
            return None

        n = len(parts)

        if n == 1:
            table: exp.Table = exp.Table(this=parts[0])
        elif n == 2:
            table = exp.Table(this=parts[1], db=parts[0])
        elif n >= 3:
            this: exp.Identifier | exp.Dot = parts[2]
            for i in range(3, n):
                this = exp.Dot(this=this, expression=parts[i])

            table = exp.Table(this=this, db=parts[1], catalog=parts[0])

        if table is None:
            self._retreat(index)
        elif all_comments:
            table.add_comments(all_comments)
        return table

    def _parse_table_parts(
        self,
        schema: bool = False,
        is_db_reference: bool = False,
        wildcard: bool = False,
        fast: bool = False,
    ) -> exp.Table | exp.Dot | None:
        if fast:
            return self._parse_table_parts_fast()

        catalog: exp.Expr | str | None = None
        db: exp.Expr | str | None = None
        table: exp.Expr | str | None = self._parse_table_part(schema=schema)

        while self._match(TokenType.DOT):
            if catalog:
                # This allows nesting the table in arbitrarily many dot expressions if needed
                table = self.expression(
                    exp.Dot(this=table, expression=self._parse_table_part(schema=schema))
                )
            else:
                catalog = db
                db = table
                # "" used for tsql FROM a..b case
                table = self._parse_table_part(schema=schema) or ""

        if (
            wildcard
            and self._is_connected()
            and (isinstance(table, exp.Identifier) or not table)
            and self._match(TokenType.STAR)
        ):
            if isinstance(table, exp.Identifier):
                table.args["this"] += "*"
            else:
                table = exp.Identifier(this="*")

        if is_db_reference:
            catalog = db
            db = table
            table = None

        if not table and not is_db_reference:
            self.raise_error(f"Expected table name but got {self._curr}")
        if not db and is_db_reference:
            self.raise_error(f"Expected database name but got {self._curr}")

        table = self.expression(exp.Table(this=table, db=db, catalog=catalog))

        # Bubble up comments from identifier parts to the Table
        comments = []
        for part in table.parts:
            if part_comments := part.pop_comments():
                comments.extend(part_comments)
        if comments:
            table.add_comments(comments)

        changes = self._parse_changes()
        if changes:
            table.set("changes", changes)

        at_before = self._parse_historical_data()
        if at_before:
            table.set("when", at_before)

        pivots = self._parse_pivots()
        if pivots:
            table.set("pivots", pivots)

        return table

    def _parse_table(
        self,
        schema: bool = False,
        joins: bool = False,
        alias_tokens: t.Collection[TokenType] | None = None,
        parse_bracket: bool = False,
        is_db_reference: bool = False,
        parse_partition: bool = False,
        consume_pipe: bool = False,
    ) -> exp.Expr | None:
        if not schema and not is_db_reference and not consume_pipe and not joins:
            index = self._index
            table = self._parse_table_parts(fast=True)

            if table is not None:
                curr_tt = self._curr.token_type
                next_tt = self._next.token_type

                fast_terminators = self.TABLE_TERMINATORS

                # only return the table if we're sure there are no other operators
                # MATCH_CONDITION is a special case because it accepts any alias before it like LIMIT
                if curr_tt in fast_terminators and next_tt != TokenType.MATCH_CONDITION:
                    return table

                postfix_tokens = self.TABLE_POSTFIX_TOKENS

                if curr_tt not in postfix_tokens and next_tt not in postfix_tokens:
                    if alias := self._parse_table_alias(
                        alias_tokens=alias_tokens or self.TABLE_ALIAS_TOKENS
                    ):
                        table.set("alias", alias)

                    if self._curr.token_type in fast_terminators:
                        return table

                self._retreat(index)

        if stream := self._parse_stream():
            return stream

        if lateral := self._parse_lateral():
            return lateral

        if unnest := self._parse_unnest():
            return unnest

        if values := self._parse_derived_table_values():
            return values

        if subquery := self._parse_select(table=True, consume_pipe=consume_pipe):
            if not subquery.args.get("pivots"):
                subquery.set("pivots", self._parse_pivots())
            if joins:
                for join in self._parse_joins():
                    subquery.append("joins", join)
            return subquery

        bracket = parse_bracket and self._parse_bracket(None)
        bracket = self.expression(exp.Table(this=bracket)) if bracket else None

        rows_from_tables = (
            self._parse_wrapped_csv(self._parse_table)
            if self._match_text_seq("ROWS", "FROM")
            else None
        )
        rows_from = (
            self.expression(exp.Table(rows_from=rows_from_tables)) if rows_from_tables else None
        )

        only = self._match(TokenType.ONLY)

        this = t.cast(
            exp.Expr,
            bracket
            or rows_from
            or self._parse_bracket(
                self._parse_table_parts(schema=schema, is_db_reference=is_db_reference)
            ),
        )

        if only:
            this.set("only", only)

        # Postgres supports a wildcard (table) suffix operator, which is a no-op in this context
        self._match(TokenType.STAR)

        parse_partition = parse_partition or self.SUPPORTS_PARTITION_SELECTION
        if parse_partition and self._match(TokenType.PARTITION, advance=False):
            this.set("partition", self._parse_partition())

        if schema:
            return self._parse_schema(this=this)

        if self.dialect.ALIAS_POST_VERSION:
            this.set("version", self._parse_version())

        if self.dialect.ALIAS_POST_TABLESAMPLE:
            this.set("sample", self._parse_table_sample())

        alias = self._parse_table_alias(alias_tokens=alias_tokens or self.TABLE_ALIAS_TOKENS)
        if alias:
            this.set("alias", alias)

            # DuckDB requires the time-travel clause to come after the alias, e.g.
            # SELECT * FROM t AS a AT (VERSION => 1)
            if isinstance(this, exp.Table) and not this.args.get("when"):
                this.set("when", self._parse_historical_data())

        if self._match(TokenType.INDEXED_BY):
            this.set("indexed", self._parse_table_parts())
        elif self._match_text_seq("NOT", "INDEXED"):
            this.set("indexed", False)

        if isinstance(this, exp.Table) and self._match_text_seq("AT"):
            return self.expression(
                exp.AtIndex(this=this.to_column(copy=False), expression=self._parse_id_var())
            )

        this.set("hints", self._parse_table_hints())

        if not this.args.get("pivots"):
            this.set("pivots", self._parse_pivots())

        if not self.dialect.ALIAS_POST_TABLESAMPLE:
            this.set("sample", self._parse_table_sample())

        if not self.dialect.ALIAS_POST_VERSION:
            this.set("version", self._parse_version())

        if joins:
            for join in self._parse_joins(alias_tokens=alias_tokens):
                this.append("joins", join)

        if self._match_pair(TokenType.WITH, TokenType.ORDINALITY):
            this.set("ordinality", True)
            this.set("alias", self._parse_table_alias())

        return this

    def _parse_version(self) -> exp.Version | None:
        for phrase, this in self.VERSION_PHRASES.items():
            if self._match_text_seq(*phrase):
                break
        else:
            return None

        if self._match_set((TokenType.FROM, TokenType.BETWEEN)):
            kind = self._prev.text.upper()
            start = self._parse_bitwise()
            self._match_texts(("TO", "AND"))
            end = self._parse_bitwise()
            expression: exp.Expr | None = self.expression(exp.Tuple(expressions=[start, end]))
        elif self._match_text_seq("CONTAINED", "IN"):
            kind = "CONTAINED IN"
            expression = self.expression(
                exp.Tuple(expressions=self._parse_wrapped_csv(self._parse_bitwise))
            )
        elif self._match(TokenType.ALL):
            kind = "ALL"
            expression = None
        else:
            self._match_text_seq("AS", "OF")
            kind = "AS OF"
            expression = self._parse_type()

        return self.expression(exp.Version(this=this, expression=expression, kind=kind))

    def _parse_historical_data(self) -> exp.HistoricalData | None:
        # https://docs.snowflake.com/en/sql-reference/constructs/at-before
        index = self._index
        historical_data = None
        if self._match_texts(self.HISTORICAL_DATA_PREFIX):
            this = self._prev.text.upper()
            kind = (
                self._match(TokenType.L_PAREN)
                and self._match_texts(self.HISTORICAL_DATA_KIND)
                and self._prev.text.upper()
            )
            expression = self._match(TokenType.FARROW) and self._parse_bitwise()

            if expression:
                self._match_r_paren()
                historical_data = self.expression(
                    exp.HistoricalData(this=this, kind=kind, expression=expression)
                )
            else:
                self._retreat(index)

        return historical_data

    def _parse_changes(self) -> exp.Changes | None:
        if not self._match_text_seq("CHANGES", "(", "INFORMATION", "=>"):
            return None

        information = self._parse_var(any_token=True)
        self._match_r_paren()

        return self.expression(
            exp.Changes(
                information=information,
                at_before=self._parse_historical_data(),
                end=self._parse_historical_data(),
            )
        )

    def _parse_unnest(self, with_alias: bool = True) -> exp.Unnest | None:
        if not self._match_pair(TokenType.UNNEST, TokenType.L_PAREN, advance=False):
            return None

        self._advance()

        expressions = self._parse_wrapped_csv(self._parse_equality)
        offset: bool | exp.Expr = self._match_pair(TokenType.WITH, TokenType.ORDINALITY)

        alias = self._parse_table_alias() if with_alias else None

        if alias:
            if self.dialect.UNNEST_COLUMN_ONLY:
                if alias.args.get("columns"):
                    self.raise_error("Unexpected extra column alias in unnest.")

                alias.set("columns", [alias.this])
                alias.set("this", None)

            columns = alias.args.get("columns") or []
            if offset and len(expressions) < len(columns):
                offset = columns.pop()

        if not offset and self._match_pair(TokenType.WITH, TokenType.OFFSET):
            self._match(TokenType.ALIAS)
            offset = self._parse_id_var(
                any_token=False, tokens=self.UNNEST_OFFSET_ALIAS_TOKENS
            ) or exp.to_identifier("offset")

        return self.expression(exp.Unnest(expressions=expressions, alias=alias, offset=offset))

    def _parse_derived_table_values(self) -> exp.Values | None:
        is_derived = self._match_pair(TokenType.L_PAREN, TokenType.VALUES)
        if not is_derived and not (
            # ClickHouse's `FORMAT Values` is equivalent to `VALUES`
            self._match_text_seq("VALUES") or self._match_text_seq("FORMAT", "VALUES")
        ):
            return None

        expressions = self._parse_csv(self._parse_value)
        alias = self._parse_table_alias()

        if is_derived:
            self._match_r_paren()

        return self.expression(
            exp.Values(expressions=expressions, alias=alias or self._parse_table_alias())
        )

    def _parse_table_sample(self, as_modifier: bool = False) -> exp.TableSample | None:
        if not self._match(TokenType.TABLE_SAMPLE) and not (
            as_modifier and self._match_text_seq("USING", "SAMPLE")
        ):
            return None

        bucket_numerator = None
        bucket_denominator = None
        bucket_field = None
        percent = None
        size = None
        seed = None

        method = self._parse_var(tokens=(TokenType.ROW,), upper=True)
        matched_l_paren = self._match(TokenType.L_PAREN)

        if self.TABLESAMPLE_CSV:
            num = None
            expressions = self._parse_csv(self._parse_primary)
        else:
            expressions = None
            num = (
                self._parse_factor()
                if self._match(TokenType.NUMBER, advance=False)
                else self._parse_primary() or self._parse_placeholder()
            )

        if self._match_text_seq("BUCKET"):
            bucket_numerator = self._parse_number()
            self._match_text_seq("OUT", "OF")
            bucket_denominator = bucket_denominator = self._parse_number()
            self._match(TokenType.ON)
            bucket_field = self._parse_field()
        elif self._match_set((TokenType.PERCENT, TokenType.MOD)):
            percent = num
        elif self._match(TokenType.ROWS) or not self.dialect.TABLESAMPLE_SIZE_IS_PERCENT:
            size = num
        else:
            percent = num

        if matched_l_paren:
            self._match_r_paren()

        if self._match(TokenType.L_PAREN):
            method = self._parse_var(upper=True)
            seed = self._match(TokenType.COMMA) and self._parse_number()
            self._match_r_paren()
        elif self._match_texts(("SEED", "REPEATABLE")):
            seed = self._parse_wrapped(self._parse_number)

        if not method and self.DEFAULT_SAMPLING_METHOD:
            method = exp.var(self.DEFAULT_SAMPLING_METHOD)

        return self.expression(
            exp.TableSample(
                expressions=expressions,
                method=method,
                bucket_numerator=bucket_numerator,
                bucket_denominator=bucket_denominator,
                bucket_field=bucket_field,
                percent=percent,
                size=size,
                seed=seed,
            )
        )

    def _parse_pivots(self) -> list[exp.Pivot] | None:
        if self._curr.token_type not in (TokenType.PIVOT, TokenType.UNPIVOT):
            return None
        return list(iter(self._parse_pivot, None)) or None

    def _parse_joins(
        self, alias_tokens: t.Collection[TokenType] | None = None
    ) -> t.Iterator[exp.Join]:
        return iter(lambda: self._parse_join(alias_tokens=alias_tokens), None)

    def _parse_unpivot_columns(self) -> exp.UnpivotColumns | None:
        if not self._match(TokenType.INTO):
            return None

        return self.expression(
            exp.UnpivotColumns(
                this=self._match_text_seq("NAME") and self._parse_column(),
                expressions=self._match_text_seq("VALUE") and self._parse_csv(self._parse_column),
            )
        )

    # https://duckdb.org/docs/sql/statements/pivot
    def _parse_simplified_pivot(self, is_unpivot: bool | None = None) -> exp.Pivot:
        def _parse_on() -> exp.Expr | None:
            this = self._parse_bitwise()

            if self._match(TokenType.IN):
                # PIVOT ... ON col IN (row_val1, row_val2)
                return self._parse_in(this)
            if self._match(TokenType.ALIAS, advance=False):
                # UNPIVOT ... ON (col1, col2, col3) AS row_val
                return self._parse_alias(this)

            return this

        this = self._parse_table()
        expressions = self._match(TokenType.ON) and self._parse_csv(_parse_on)
        into = self._parse_unpivot_columns()
        using = self._match(TokenType.USING) and self._parse_csv(
            lambda: self._parse_alias(self._parse_column())
        )
        group = self._parse_group()

        return self.expression(
            exp.Pivot(
                this=this,
                expressions=expressions,
                using=using,
                group=group,
                unpivot=is_unpivot,
                into=into,
            )
        )

    def _parse_pivot_in(self) -> exp.In:
        def _parse_aliased_expression() -> exp.Expr | None:
            this = self._parse_select_or_expression()

            self._match(TokenType.ALIAS)
            alias = self._parse_bitwise()
            if alias:
                if isinstance(alias, exp.Column) and not alias.db:
                    alias = alias.this
                return self.expression(exp.PivotAlias(this=this, alias=alias))

            return this

        value = self._parse_column()

        if not self._match(TokenType.IN):
            self.raise_error("Expecting IN")

        if self._match(TokenType.L_PAREN):
            if self._match(TokenType.ANY):
                exprs: list[exp.Expr] = ensure_list(exp.PivotAny(this=self._parse_order()))
            else:
                exprs = self._parse_csv(_parse_aliased_expression)
            self._match_r_paren()
            return self.expression(exp.In(this=value, expressions=exprs))

        return self.expression(exp.In(this=value, field=self._parse_id_var()))

    def _parse_pivot_aggregation(self) -> exp.Expr | None:
        func = self._parse_function()
        if not func:
            if self._prev.token_type == TokenType.COMMA:
                return None
            self.raise_error("Expecting an aggregation function in PIVOT")

        return self._parse_alias(func)

    def _parse_pivot(self) -> exp.Pivot | None:
        index = self._index
        include_nulls = None

        if self._match(TokenType.PIVOT):
            unpivot = False
        elif self._match(TokenType.UNPIVOT):
            unpivot = True

            # https://docs.databricks.com/en/sql/language-manual/sql-ref-syntax-qry-select-unpivot.html#syntax
            if self._match_text_seq("INCLUDE", "NULLS"):
                include_nulls = True
            elif self._match_text_seq("EXCLUDE", "NULLS"):
                include_nulls = False
        else:
            return None

        expressions = []

        if not self._match(TokenType.L_PAREN):
            self._retreat(index)
            return None

        if unpivot:
            expressions = self._parse_csv(self._parse_column)
        else:
            expressions = self._parse_csv(self._parse_pivot_aggregation)

        if not expressions:
            self.raise_error("Failed to parse PIVOT's aggregation list")

        if not self._match(TokenType.FOR):
            self.raise_error("Expecting FOR")

        fields = []
        while True:
            field = self._try_parse(self._parse_pivot_in)
            if not field:
                break
            fields.append(field)

        default_on_null = self._match_text_seq("DEFAULT", "ON", "NULL") and self._parse_wrapped(
            self._parse_bitwise
        )

        group = self._parse_group()

        self._match_r_paren()

        pivot = self.expression(
            exp.Pivot(
                expressions=expressions,
                fields=fields,
                unpivot=unpivot,
                include_nulls=include_nulls,
                default_on_null=default_on_null,
                group=group,
            )
        )

        if unpivot:
            pivot.set("expressions", [_unpivot_target(e) for e in pivot.expressions])
            for pivot_field in pivot.fields:
                if isinstance(pivot_field, exp.In):
                    pivot_field.set("this", _unpivot_target(pivot_field.this))

            pivot.set("value_columns_first", self.UNPIVOT_VALUE_COLUMNS_FIRST)

        if not self._match_set((TokenType.PIVOT, TokenType.UNPIVOT), advance=False):
            pivot.set("alias", self._parse_table_alias())

        if not unpivot:
            names = self._pivot_column_names(t.cast(list[exp.Expr], expressions))

            columns: list[exp.Expr] = []
            all_fields = []
            for pivot_field in pivot.fields:
                pivot_field_expressions = pivot_field.expressions

                # The `PivotAny` expression corresponds to `ANY ORDER BY <column>`; we can't infer in this case.
                if isinstance(seq_get(pivot_field_expressions, 0), exp.PivotAny):
                    continue

                all_fields.append(
                    [
                        # An explicit `<field> AS <alias>` names the output column directly,
                        # so it wins over the dialect's string-identifying convention
                        fld.sql()
                        if self.IDENTIFY_PIVOT_STRINGS and not isinstance(fld, exp.PivotAlias)
                        else fld.alias_or_name
                        for fld in pivot_field_expressions
                    ]
                )

            if all_fields:
                if names:
                    all_fields.append(names)

                # Generate all possible combinations of the pivot columns
                # e.g PIVOT(sum(...) as total FOR year IN (2000, 2010) FOR country IN ('NL', 'US'))
                # generates the product between [[2000, 2010], ['NL', 'US'], ['total']]
                for fld_parts_tuple in itertools.product(*all_fields):
                    fld_parts = list(fld_parts_tuple)

                    if names and self.PREFIXED_PIVOT_COLUMNS:
                        # Move the "name" to the front of the list
                        fld_parts.insert(0, fld_parts.pop(-1))

                    columns.append(exp.to_identifier("_".join(fld_parts)))

            pivot.set("columns", columns)
            pivot.set("identify_pivot_strings", self.IDENTIFY_PIVOT_STRINGS)
            pivot.set("prefixed_pivot_columns", self.PREFIXED_PIVOT_COLUMNS)
            pivot.set("pivot_column_naming", self.PIVOT_COLUMN_NAMING)

        return pivot

    def _pivot_column_names(self, aggregations: list[exp.Expr]) -> list[str]:
        return [agg.alias for agg in aggregations if agg.alias]

    def _parse_prewhere(self, skip_where_token: bool = False) -> exp.PreWhere | None:
        if not skip_where_token and not self._match(TokenType.PREWHERE):
            return None

        comments = self._prev_comments
        return self.expression(
            exp.PreWhere(this=self._parse_disjunction()),
            comments=comments,
        )

    def _parse_where(self, skip_where_token: bool = False) -> exp.Where | None:
        if not skip_where_token and not self._match(TokenType.WHERE):
            return None

        comments = self._prev_comments
        return self.expression(
            exp.Where(this=self._parse_disjunction()),
            comments=comments,
        )

    def _parse_group(self, skip_group_by_token: bool = False) -> exp.Group | None:
        if not skip_group_by_token and not self._match(TokenType.GROUP_BY):
            return None
        comments = self._prev_comments

        elements: dict[str, t.Any] = defaultdict(list)

        if self._match(TokenType.ALL):
            elements["all"] = True
        elif self._match(TokenType.DISTINCT):
            elements["all"] = False

        if self._match_set(self.QUERY_MODIFIER_TOKENS, advance=False):
            return self.expression(exp.Group(**elements), comments=comments)  # type: ignore

        while True:
            index = self._index

            elements["expressions"].extend(
                self._parse_csv(
                    lambda: (
                        None
                        if self._match_set((TokenType.CUBE, TokenType.ROLLUP), advance=False)
                        else self._parse_disjunction()
                    )
                )
            )

            before_with_index = self._index
            with_prefix = self._match(TokenType.WITH)

            if cube_or_rollup := self._parse_cube_or_rollup(with_prefix=with_prefix):
                key = "rollup" if isinstance(cube_or_rollup, exp.Rollup) else "cube"
                elements[key].append(cube_or_rollup)
            elif grouping_sets := self._parse_grouping_sets():
                elements["grouping_sets"].append(grouping_sets)
            elif self._match_text_seq("TOTALS"):
                elements["totals"] = True  # type: ignore

            if before_with_index <= self._index <= before_with_index + 1:
                self._retreat(before_with_index)
                break

            if index == self._index:
                break

        return self.expression(exp.Group(**elements), comments=comments)  # type: ignore

    def _parse_cube_or_rollup(self, with_prefix: bool = False) -> exp.Cube | exp.Rollup | None:
        if self._match(TokenType.CUBE):
            kind: type[exp.Cube | exp.Rollup] = exp.Cube
        elif self._match(TokenType.ROLLUP):
            kind = exp.Rollup
        else:
            return None

        return self.expression(
            kind(expressions=[] if with_prefix else self._parse_wrapped_csv(self._parse_bitwise))
        )

    def _parse_grouping_sets(self) -> exp.GroupingSets | None:
        if self._match(TokenType.GROUPING_SETS):
            return self.expression(
                exp.GroupingSets(expressions=self._parse_wrapped_csv(self._parse_grouping_set))
            )
        return None

    def _parse_grouping_set(self) -> exp.Expr | None:
        return self._parse_grouping_sets() or self._parse_cube_or_rollup() or self._parse_bitwise()

    def _parse_having(self, skip_having_token: bool = False) -> exp.Having | None:
        if not skip_having_token and not self._match(TokenType.HAVING):
            return None
        comments = self._prev_comments
        return self.expression(
            exp.Having(this=self._parse_disjunction()),
            comments=comments,
        )

    def _parse_qualify(self) -> exp.Qualify | None:
        if not self._match(TokenType.QUALIFY):
            return None
        return self.expression(exp.Qualify(this=self._parse_disjunction()))

    def _parse_connect_with_prior(self) -> exp.Expr | None:
        self.NO_PAREN_FUNCTION_PARSERS["PRIOR"] = lambda self: self.expression(
            exp.Prior(this=self._parse_bitwise())
        )
        connect = self._parse_disjunction()
        self.NO_PAREN_FUNCTION_PARSERS.pop("PRIOR")
        return connect

    def _parse_connect(self, skip_start_token: bool = False) -> exp.Connect | None:
        if skip_start_token:
            start = None
        elif self._match_text_seq("START", "WITH"):
            start = self._parse_disjunction()
        else:
            return None

        self._match(TokenType.CONNECT_BY)
        nocycle = self._match_text_seq("NOCYCLE")
        connect = self._parse_connect_with_prior()

        if not start and self._match_text_seq("START", "WITH"):
            start = self._parse_disjunction()

        return self.expression(exp.Connect(start=start, connect=connect, nocycle=nocycle))

    def _parse_name_as_expression(self) -> exp.Expr | None:
        this = self._parse_id_var(any_token=True)
        if self._match(TokenType.ALIAS):
            this = self.expression(exp.Alias(alias=this, this=self._parse_disjunction()))
        return this

    def _parse_interpolate(self) -> list[exp.Expr] | None:
        if self._match_text_seq("INTERPOLATE"):
            return self._parse_wrapped_csv(self._parse_name_as_expression)
        return None

    def _parse_order(
        self, this: exp.Expr | None = None, skip_order_token: bool = False
    ) -> exp.Expr | None:
        siblings = None
        if not skip_order_token and not self._match(TokenType.ORDER_BY):
            if not self._match(TokenType.ORDER_SIBLINGS_BY):
                return this

            siblings = True

        comments = self._prev_comments
        return self.expression(
            exp.Order(
                this=this,
                expressions=self._parse_csv(self._parse_ordered),
                siblings=siblings,
            ),
            comments=comments,
        )

    def _parse_sort(self, exp_class: type[E], token: TokenType) -> E | None:
        if not self._match(token):
            return None
        return self.expression(exp_class(expressions=self._parse_csv(self._parse_ordered)))

    def _parse_ordered(
        self, parse_method: t.Callable[[], exp.Expr | None] | None = None
    ) -> exp.Ordered | None:
        this = parse_method() if parse_method else self._parse_disjunction()
        if not this:
            return None

        if this.name.upper() == "ALL" and self.dialect.SUPPORTS_ORDER_BY_ALL:
            this = exp.var("ALL")

        asc = self._match(TokenType.ASC)
        desc: bool | None = True if self._match(TokenType.DESC) else (False if asc else None)

        is_nulls_first = self._match_text_seq("NULLS", "FIRST")
        is_nulls_last = self._match_text_seq("NULLS", "LAST")

        nulls_first = is_nulls_first or False
        explicitly_null_ordered = is_nulls_first or is_nulls_last

        if (
            not explicitly_null_ordered
            and (
                (not desc and self.dialect.NULL_ORDERING == "nulls_are_small")
                or (desc and self.dialect.NULL_ORDERING != "nulls_are_small")
            )
            and self.dialect.NULL_ORDERING != "nulls_are_last"
        ):
            nulls_first = True

        if self._match_text_seq("WITH", "FILL"):
            with_fill = self.expression(
                exp.WithFill(
                    from_=self._match(TokenType.FROM) and self._parse_bitwise(),
                    to=self._match_text_seq("TO") and self._parse_bitwise(),
                    step=self._match_text_seq("STEP") and self._parse_bitwise(),
                    interpolate=self._parse_interpolate(),
                )
            )
        else:
            with_fill = None

        return self.expression(
            exp.Ordered(this=this, desc=desc, nulls_first=nulls_first, with_fill=with_fill)
        )

    def _parse_limit_options(self) -> exp.LimitOptions | None:
        percent = self._match_set((TokenType.PERCENT, TokenType.MOD))
        rows = self._match_set((TokenType.ROW, TokenType.ROWS))
        self._match_text_seq("ONLY")
        with_ties = self._match_text_seq("WITH", "TIES")

        if not (percent or rows or with_ties):
            return None

        return self.expression(exp.LimitOptions(percent=percent, rows=rows, with_ties=with_ties))

    def _parse_limit(
        self,
        this: exp.Expr | None = None,
        top: bool = False,
        skip_limit_token: bool = False,
    ) -> exp.Expr | None:
        if skip_limit_token or self._match(TokenType.TOP if top else TokenType.LIMIT):
            comments = self._prev_comments
            if top:
                limit_paren = self._match(TokenType.L_PAREN)
                expression = (
                    self._parse_term() or self._parse_select()
                    if limit_paren
                    else self._parse_number()
                )

                if limit_paren:
                    self._match_r_paren()

            else:
                if self.dialect.SUPPORTS_LIMIT_ALL and self._match(TokenType.ALL):
                    return this

                # Parsing LIMIT x% (i.e x PERCENT) as a term leads to an error, since
                # we try to build an exp.Mod expr. For that matter, we backtrack and instead
                # consume the factor plus parse the percentage separately
                index = self._index
                expression = self._try_parse(self._parse_term)
                if isinstance(expression, exp.Mod):
                    self._retreat(index)
                    expression = self._parse_factor()
                elif not expression:
                    expression = self._parse_factor()
            limit_options = self._parse_limit_options()

            if self._match(TokenType.COMMA):
                offset = expression
                expression = self._parse_term()
            else:
                offset = None

            limit_exp = self.expression(
                exp.Limit(
                    this=this,
                    expression=expression,
                    offset=offset,
                    limit_options=limit_options,
                    expressions=self._parse_limit_by(),
                ),
                comments=comments,
            )

            return limit_exp

        if self._match(TokenType.FETCH):
            direction = (
                self._prev.text.upper()
                if self._match_set((TokenType.FIRST, TokenType.NEXT))
                else "FIRST"
            )

            count = self._parse_field(tokens=self.FETCH_TOKENS)

            return self.expression(
                exp.Fetch(
                    direction=direction, count=count, limit_options=self._parse_limit_options()
                )
            )

        return this

    def _parse_offset(self, this: exp.Expr | None = None) -> exp.Expr | None:
        if not self._match(TokenType.OFFSET):
            return this

        count = self._parse_term()
        self._match_set((TokenType.ROW, TokenType.ROWS))

        return self.expression(
            exp.Offset(this=this, expression=count, expressions=self._parse_limit_by())
        )

    def _can_parse_limit_or_offset(self) -> bool:
        if not self._match_set(self.AMBIGUOUS_ALIAS_TOKENS, advance=False):
            return False

        index = self._index
        result = bool(
            self._try_parse(self._parse_limit, retreat=True)
            or self._try_parse(self._parse_offset, retreat=True)
        )
        self._retreat(index)

        # MATCH_CONDITION (...) is a special construct that should not be consumed by limit/offset
        if self._next.token_type == TokenType.MATCH_CONDITION:
            result = False

        return result

    def _can_parse_named_window(self) -> bool:
        # `WINDOW` is in ID_VAR_TOKENS so it could be mistakenly consumed as an implicit alias.
        # Refuse only when the following tokens look like a named-window clause: `WINDOW <id> AS (`.
        if not self._match(TokenType.WINDOW, advance=False):
            return False

        name = self._tokens[self._index + 1] if self._index + 1 < len(self._tokens) else None
        if name is None or name.token_type not in self.ID_VAR_TOKENS:
            return False

        alias_tok = self._tokens[self._index + 2] if self._index + 2 < len(self._tokens) else None
        if alias_tok is None or alias_tok.token_type != TokenType.ALIAS:
            return False

        body = self._tokens[self._index + 3] if self._index + 3 < len(self._tokens) else None
        return body is not None and body.token_type == TokenType.L_PAREN

    def _parse_limit_by(self) -> list[exp.Expr] | None:
        return self._parse_csv(self._parse_bitwise) if self._match_text_seq("BY") else None

    def _parse_locks(self) -> list[exp.Lock]:
        locks = []
        while True:
            update, key = None, None
            if self._match_text_seq("FOR", "UPDATE"):
                update = True
            elif self._match_text_seq("FOR", "SHARE") or self._match_text_seq(
                "LOCK", "IN", "SHARE", "MODE"
            ):
                update = False
            elif self._match_text_seq("FOR", "KEY", "SHARE"):
                update, key = False, True
            elif self._match_text_seq("FOR", "NO", "KEY", "UPDATE"):
                update, key = True, True
            else:
                break

            expressions = None
            if self._match_text_seq("OF"):
                expressions = self._parse_csv(lambda: self._parse_table(schema=True))

            wait: bool | exp.Expr | None = None
            if self._match_text_seq("NOWAIT"):
                wait = True
            elif self._match_text_seq("WAIT"):
                wait = self._parse_primary()
            elif self._match_text_seq("SKIP", "LOCKED"):
                wait = False

            locks.append(
                self.expression(
                    exp.Lock(update=update, expressions=expressions, wait=wait, key=key)
                )
            )

        return locks

    def parse_set_operation(
        self, this: exp.Expr | None, consume_pipe: bool = False
    ) -> exp.Expr | None:
        start = self._index
        _, side_token, kind_token = self._parse_join_parts()

        side = side_token.text if side_token else None
        kind = kind_token.text if kind_token else None

        if not self._match_set(self.SET_OPERATIONS):
            self._retreat(start)
            return None

        token_type = self._prev.token_type

        if token_type == TokenType.UNION:
            operation: type[exp.SetOperation] = exp.Union
        elif token_type == TokenType.EXCEPT:
            operation = exp.Except
        else:
            operation = exp.Intersect

        comments = self._prev.comments

        if self._match(TokenType.DISTINCT):
            distinct: bool | None = True
        elif self._match(TokenType.ALL):
            distinct = False
        else:
            distinct = self.dialect.SET_OP_DISTINCT_BY_DEFAULT[operation]
            if distinct is None:
                self.raise_error(f"Expected DISTINCT or ALL for {operation.__name__}")

        by_name = (
            self._match_text_seq("BY", "NAME")
            or self._match_text_seq("STRICT", "CORRESPONDING")
            or None
        )
        if self._match_text_seq("CORRESPONDING"):
            by_name = True
            if not side and not kind:
                kind = "INNER"

        on_column_list = None
        if by_name and self._match_texts(("ON", "BY")):
            on_column_list = self._parse_wrapped_csv(self._parse_column)

        expression = self._parse_select(
            nested=True, parse_set_operation=False, consume_pipe=consume_pipe
        )

        # Wrap VALUES operands in selects, both for consistency with the CTE canonicalization
        # in _parse_cte and so that alias pushdown can reach into set operation branches
        if isinstance(this, exp.Values):
            this = self._values_to_select(this)
        if isinstance(expression, exp.Values):
            expression = self._values_to_select(expression)

        return self.expression(
            operation(
                this=this,
                distinct=distinct,
                by_name=by_name,
                expression=expression,
                side=side,
                kind=kind,
                on=on_column_list,
            ),
            comments=comments,
        )

    def _parse_set_operations(self, this: exp.Expr | None) -> exp.Expr | None:
        while this:
            setop = self.parse_set_operation(this)
            if not setop:
                break
            this = setop

        if isinstance(this, exp.SetOperation) and self.MODIFIERS_ATTACHED_TO_SET_OP:
            expression = this.expression

            if expression:
                for arg in self.SET_OP_MODIFIERS:
                    expr = expression.args.get(arg)
                    if expr:
                        this.set(arg, expr.pop())

        return this

    def _parse_expression(self) -> exp.Expr | None:
        return self._parse_alias(self._parse_assignment())

    def _parse_assignment(self) -> exp.Expr | None:
        this = self._parse_disjunction()
        if not this and self._next.token_type in self.ASSIGNMENT:
            # This allows us to parse <non-identifier token> := <expr>
            this = exp.column(
                t.cast(str, self._advance_any(ignore_reserved=True) and self._prev.text)
            )

        while self._match_set(self.ASSIGNMENT):
            if isinstance(this, exp.Column) and len(this.parts) == 1:
                this = this.this

            comments = self._prev_comments
            this = self.expression(
                self.ASSIGNMENT[self._prev.token_type](
                    this=this, expression=self._parse_assignment()
                ),
                comments=comments,
            )

        return this

    def _parse_disjunction(self) -> exp.Expr | None:
        this = self._parse_conjunction()
        while self._match_set(self.DISJUNCTION):
            comments = self._prev_comments
            this = self.expression(
                self.DISJUNCTION[self._prev.token_type](
                    this=this, expression=self._parse_conjunction()
                ),
                comments=comments,
            )
        return this

    def _parse_conjunction(self) -> exp.Expr | None:
        this = self._parse_equality()
        while self._match_set(self.CONJUNCTION):
            comments = self._prev_comments
            this = self.expression(
                self.CONJUNCTION[self._prev.token_type](
                    this=this, expression=self._parse_equality()
                ),
                comments=comments,
            )
        return this

    def _parse_equality(self) -> exp.Expr | None:
        this = self._parse_comparison()
        while self._match_set(self.EQUALITY):
            comments = self._prev_comments
            this = self.expression(
                self.EQUALITY[self._prev.token_type](
                    this=this, expression=self._parse_comparison()
                ),
                comments=comments,
            )
        return this

    def _parse_comparison(self) -> exp.Expr | None:
        this = self._parse_range()
        while self._match_set(self.COMPARISON):
            comments = self._prev_comments
            this = self.expression(
                self.COMPARISON[self._prev.token_type](this=this, expression=self._parse_range()),
                comments=comments,
            )
        return this

    def _parse_range(self, this: exp.Expr | None = None) -> exp.Expr | None:
        this = this or self._parse_bitwise()

        while True:
            negate = self._match(TokenType.NOT)
            if self._match_set(self.RANGE_PARSERS):
                expression = self.RANGE_PARSERS[self._prev.token_type](self, this)
                if not expression:
                    return this

                this = expression
            elif self._match(TokenType.ISNULL) or (negate and self._match(TokenType.NULL)):
                this = self.expression(exp.Is(this=this, expression=exp.Null()))
            elif self._match(TokenType.NOTNULL):
                # Postgres supports ISNULL and NOTNULL for conditions.
                # https://blog.andreiavram.ro/postgresql-null-composite-type/
                if self.dialect.NORMALIZE_NOT_NULL:
                    this = self.expression(exp.Is(this=this, expression=exp.Null()))
                    this = self.expression(exp.Not(this=this))
                else:
                    this = self.expression(exp.Is(this=this, expression=exp.Null(), negate=True))
            else:
                if negate:
                    self._retreat(self._index - 1)
                break

            if negate:
                this = self._negate_range(this)
                if self._curr and (
                    self._curr.token_type == TokenType.NOT
                    or self._curr.token_type in self.RANGE_PARSERS
                ):
                    this = self.expression(exp.Paren(this=this))

        return this

    def _negate_range(self, this: exp.Expr | None = None) -> exp.Expr | None:
        if not this:
            return this

        expression = this.this if isinstance(this, exp.Escape) else this
        if isinstance(expression, (exp.Like, exp.ILike)):
            expression.set("negate", True)
            return this

        return self.expression(exp.Not(this=this))

    def _parse_is(self, this: exp.Expr | None) -> exp.Expr | None:
        index = self._index - 1
        negate = self._match(TokenType.NOT)

        if self._match_text_seq("DISTINCT", "FROM"):
            klass = exp.NullSafeEQ if negate else exp.NullSafeNEQ
            return self.expression(klass(this=this, expression=self._parse_bitwise()))

        if self._match(TokenType.JSON):
            kind = self._match_texts(self.IS_JSON_PREDICATE_KIND) and self._prev.text.upper()

            if self._match_text_seq("WITH"):
                _with = True
            elif self._match_text_seq("WITHOUT"):
                _with = False
            else:
                _with = None

            unique = self._match(TokenType.UNIQUE)
            self._match_text_seq("KEYS")
            expression: exp.Expr | None = self.expression(
                exp.JSON(this=kind, with_=_with, unique=unique)
            )
        else:
            expression = self._parse_null() or self._parse_bitwise()
            if not expression:
                self._retreat(index)
                return None

        if negate and isinstance(expression, exp.Null) and not self.dialect.NORMALIZE_NOT_NULL:
            this = self.expression(exp.Is(this=this, expression=expression, negate=True))
        else:
            this = self.expression(exp.Is(this=this, expression=expression))
            this = self.expression(exp.Not(this=this)) if negate else this

        return self._parse_column_ops(this)

    def _parse_in(self, this: exp.Expr | None, alias: bool = False) -> exp.In:
        unnest = self._parse_unnest(with_alias=False)
        if unnest:
            this = self.expression(exp.In(this=this, unnest=unnest))
        elif self._match_set((TokenType.L_PAREN, TokenType.L_BRACKET)):
            matched_l_paren = self._prev.token_type == TokenType.L_PAREN
            expressions = self._parse_csv(lambda: self._parse_select_or_expression(alias=alias))

            if len(expressions) == 1 and isinstance(query := expressions[0], exp.Query):
                this = self.expression(
                    exp.In(this=this, query=self._parse_query_modifiers(query).subquery(copy=False))
                )
            else:
                this = self.expression(exp.In(this=this, expressions=expressions))

            if matched_l_paren:
                self._match_r_paren(this)
            elif not self._match(TokenType.R_BRACKET, expression=this):
                self.raise_error("Expecting ]")
        else:
            this = self.expression(exp.In(this=this, field=self._parse_column()))

        return this

    def _parse_between(self, this: exp.Expr | None) -> exp.Between:
        symmetric = None
        if self._match_text_seq("SYMMETRIC"):
            symmetric = True
        elif self._match_text_seq("ASYMMETRIC"):
            symmetric = False

        low = self._parse_bitwise()
        self._match(TokenType.AND)
        high = self._parse_bitwise()

        return self.expression(exp.Between(this=this, low=low, high=high, symmetric=symmetric))

    def _parse_escape(self, this: exp.Expr | None) -> exp.Expr | None:
        if not self._match(TokenType.ESCAPE):
            return this
        return self.expression(
            exp.Escape(this=this, expression=self._parse_string() or self._parse_null())
        )

    def _parse_interval_span(
        self, this: exp.Expr, parse_function_unit: bool = True
    ) -> exp.Interval:
        # handle day-time format interval span with omitted units:
        #   INTERVAL '<number days> hh[:][mm[:ss[.ff]]]' <maybe `unit TO unit`>
        interval_span_units_omitted = None
        if (
            this
            and this.is_string
            and self.SUPPORTS_OMITTED_INTERVAL_SPAN_UNIT
            and exp.INTERVAL_DAY_TIME_RE.match(this.name)
        ):
            index = self._index

            # Var "TO" Var
            first_unit = self._parse_var(any_token=True, upper=True)
            second_unit = None
            if first_unit and self._match_text_seq("TO"):
                second_unit = self._parse_var(any_token=True, upper=True)

            interval_span_units_omitted = not (first_unit and second_unit)

            self._retreat(index)

        if interval_span_units_omitted:
            unit = None
        else:
            unit = self._parse_function() if parse_function_unit else None
            if not unit and (
                self._curr.token_type == TokenType.VAR
                or self._curr.text.upper() in self.dialect.VALID_INTERVAL_UNITS
            ):
                unit = self._parse_var(any_token=True, upper=True)

        # Most dialects support, e.g., the form INTERVAL '5' day, thus we try to parse
        # each INTERVAL expression into this canonical form so it's easy to transpile
        if this and this.is_number:
            this = exp.Literal.string(this.to_py())
        elif this and this.is_string:
            parts = exp.INTERVAL_STRING_RE.findall(this.name)
            if parts and unit:
                # Unconsume the eagerly-parsed unit, since the real unit was part of the string
                unit = None
                self._retreat(self._index - 1)

            if len(parts) == 1:
                this = exp.Literal.string(parts[0][0])
                unit = self.expression(exp.Var(this=parts[0][1].upper()))

        if self.INTERVAL_SPANS and self._match_text_seq("TO"):
            unit = self.expression(
                exp.IntervalSpan(
                    this=unit,
                    expression=self._parse_function()
                    or self._parse_var(any_token=True, upper=True),
                )
            )

        return self.expression(exp.Interval(this=this, unit=unit))

    def _parse_interval(
        self, require_interval: bool = True, parse_function_unit: bool = True
    ) -> exp.Add | exp.Interval | None:
        index = self._index

        if not self._match(TokenType.INTERVAL) and require_interval:
            return None

        if self._match(TokenType.STRING, advance=False):
            this = self._parse_primary()
        else:
            this = self._parse_term()

        if not this or (
            isinstance(this, exp.Column)
            and not this.table
            and not this.this.quoted
            and self._curr
            and self._curr.text.upper() not in self.dialect.VALID_INTERVAL_UNITS
        ):
            self._retreat(index)
            return None

        interval = self._parse_interval_span(this, parse_function_unit=parse_function_unit)

        index = self._index
        self._match(TokenType.PLUS)

        # Convert INTERVAL 'val_1' unit_1 [+] ... [+] 'val_n' unit_n into a sum of intervals
        if self._match_set((TokenType.STRING, TokenType.NUMBER), advance=False):
            return self.expression(
                exp.Add(
                    this=interval,
                    expression=self._parse_interval(False, parse_function_unit=parse_function_unit),
                )
            )

        self._retreat(index)
        return interval

    def _parse_bitwise(self) -> exp.Expr | None:
        this = self._parse_term()

        while True:
            if self._match_set(self.BITWISE):
                this = self.expression(
                    self.BITWISE[self._prev.token_type](this=this, expression=self._parse_term())
                )
            elif self.dialect.DPIPE_IS_STRING_CONCAT and self._match(TokenType.DPIPE):
                this = self.expression(
                    exp.DPipe(
                        this=this,
                        expression=self._parse_term(),
                        safe=not self.dialect.STRICT_STRING_CONCAT,
                    )
                )
            elif self._match(TokenType.DQMARK):
                this = self.expression(
                    exp.Coalesce(this=this, expressions=ensure_list(self._parse_term()))
                )
            elif self._match_pair(TokenType.LT, TokenType.LT):
                this = self.expression(
                    exp.BitwiseLeftShift(this=this, expression=self._parse_term())
                )
            elif self._match_pair(TokenType.GT, TokenType.GT):
                this = self.expression(
                    exp.BitwiseRightShift(this=this, expression=self._parse_term())
                )
            else:
                break

        return this

    def _parse_term(self) -> exp.Expr | None:
        this = self._parse_factor()

        while self._match_set(self.TERM):
            klass = self.TERM[self._prev.token_type]
            comments = self._prev_comments
            expression = self._parse_factor()

            this = self.expression(klass(this=this, expression=expression), comments=comments)

            if isinstance(this, exp.Collate):
                expr = this.expression

                # Preserve collations such as pg_catalog."default" (Postgres) as columns, otherwise
                # fallback to Identifier / Var
                if isinstance(expr, exp.Column) and len(expr.parts) == 1:
                    ident = expr.this
                    if isinstance(ident, exp.Identifier):
                        this.set("expression", ident if ident.quoted else exp.var(ident.name))

        return this

    def _parse_factor(self) -> exp.Expr | None:
        parse_method = self._parse_exponent if self.EXPONENT else self._parse_unary
        this = self._parse_at_time_zone(parse_method())

        while self._match_set(self.FACTOR):
            klass = self.FACTOR[self._prev.token_type]
            comments = self._prev_comments
            expression = parse_method()

            if not expression and klass is exp.IntDiv and self._prev.text.isalpha():
                self._retreat(self._index - 1)
                return this

            this = self.expression(klass(this=this, expression=expression), comments=comments)

            if isinstance(this, exp.Div):
                this.set("typed", self.dialect.TYPED_DIVISION)
                this.set("safe", self.dialect.SAFE_DIVISION)

        return this

    def _parse_exponent(self) -> exp.Expr | None:
        this = self._parse_unary()
        while self._match_set(self.EXPONENT):
            comments = self._prev_comments
            this = self.expression(
                self.EXPONENT[self._prev.token_type](this=this, expression=self._parse_unary()),
                comments=comments,
            )
        return this

    def _parse_unary(self) -> exp.Expr | None:
        if self._match_set(self.UNARY_PARSERS):
            return self.UNARY_PARSERS[self._prev.token_type](self)
        return self._parse_type()

    def _parse_type(
        self, parse_interval: bool = True, fallback_to_identifier: bool = False
    ) -> exp.Expr | None:
        if not fallback_to_identifier and (atom := self._parse_atom()) is not None:
            return atom

        if interval := parse_interval and self._parse_interval():
            return self._parse_column_ops(interval)

        index = self._index
        data_type = self._parse_types(check_func=True, allow_identifiers=False)

        # parse_types() returns a Cast if we parsed BQ's inline constructor <type>(<values>) e.g.
        # STRUCT<a INT, b STRING>(1, 'foo'), which is canonicalized to CAST(<values> AS <type>)
        if isinstance(data_type, exp.Cast):
            # This constructor can contain ops directly after it, for instance struct unnesting:
            # STRUCT<a INT, b STRING>(1, 'foo').* --> CAST(STRUCT(1, 'foo') AS STRUCT<a iNT, b STRING).*
            return self._parse_column_ops(data_type)

        if data_type:
            index2 = self._index
            this = self._parse_primary()

            if isinstance(this, exp.Literal):
                literal = this.name
                this = self._parse_column_ops(this)

                parser = self.TYPE_LITERAL_PARSERS.get(data_type.this)
                if parser:
                    return parser(self, this, data_type)

                if (
                    self.ZONE_AWARE_TIMESTAMP_CONSTRUCTOR
                    and data_type.is_type(exp.DType.TIMESTAMP)
                    and TIME_ZONE_RE.search(literal)
                ):
                    data_type = exp.DType.TIMESTAMPTZ.into_expr()

                return self.expression(exp.Cast(this=this, to=data_type))

            # The expressions arg gets set by the parser when we have something like DECIMAL(38, 0)
            # in the input SQL. In that case, we'll produce these tokens: DECIMAL ( 38 , 0 )
            #
            # If the index difference here is greater than 1, that means the parser itself must have
            # consumed additional tokens such as the DECIMAL scale and precision in the above example.
            #
            # If it's not greater than 1, then it must be 1, because we've consumed at least the type
            # keyword, meaning that the expressions arg of the DataType must have gotten set by a
            # callable in the TYPE_CONVERTERS mapping. For example, Snowflake converts DECIMAL to
            # DECIMAL(38, 0)) in order to facilitate the data type's transpilation.
            #
            # In these cases, we don't really want to return the converted type, but instead retreat
            # and try to parse a Column or Identifier in the section below.
            if data_type.expressions and index2 - index > 1:
                self._retreat(index2)
                return self._parse_column_ops(data_type)

            self._retreat(index)

        if fallback_to_identifier:
            return self._parse_id_var()

        return self._parse_column()

    def _parse_type_size(self) -> exp.DataTypeParam | None:
        this = self._parse_type()
        if not this:
            return None

        if isinstance(this, exp.Column) and not this.table:
            this = exp.var(this.name.upper())

        return self.expression(
            exp.DataTypeParam(this=this, expression=self._parse_var(any_token=True))
        )

    def _parse_user_defined_type(self, identifier: exp.Identifier) -> exp.Expr | None:
        type_name = identifier.name

        while self._match(TokenType.DOT):
            type_name = f"{type_name}.{self._advance_any() and self._prev.text}"

        return exp.DataType.from_str(type_name, dialect=self.dialect, udt=True)

    def _parse_types(
        self,
        check_func: bool = False,
        schema: bool = False,
        allow_identifiers: bool = True,
        with_collation: bool = False,
    ) -> exp.Expr | None:
        index = self._index
        this: exp.Expr | None = None

        if self._match_set(self.TYPE_TOKENS):
            type_token = self._prev.token_type
        else:
            type_token = None
            identifier = allow_identifiers and self._parse_id_var(
                any_token=False, tokens=(TokenType.VAR,)
            )
            if isinstance(identifier, exp.Identifier):
                try:
                    tokens = self.dialect.tokenize(identifier.name)
                except TokenError:
                    tokens = None

                if tokens and (type_token := tokens[0].token_type) in self.TYPE_TOKENS:
                    if len(tokens) > 1:
                        return exp.DataType.from_str(identifier.name, dialect=self.dialect)
                elif self.dialect.SUPPORTS_USER_DEFINED_TYPES:
                    this = self._parse_user_defined_type(identifier)
                else:
                    self._retreat(self._index - 1)
                    return None
            else:
                return None

        if type_token == TokenType.PSEUDO_TYPE:
            return self.expression(exp.PseudoType(this=self._prev.text.upper()))

        if type_token == TokenType.OBJECT_IDENTIFIER:
            return self.expression(exp.ObjectIdentifier(this=self._prev.text.upper()))

        # https://materialize.com/docs/sql/types/map/
        if type_token == TokenType.MAP and self._match(TokenType.L_BRACKET):
            key_type = self._parse_types(
                check_func=check_func, schema=schema, allow_identifiers=allow_identifiers
            )
            if not self._match(TokenType.FARROW):
                self._retreat(index)
                return None

            value_type = self._parse_types(
                check_func=check_func, schema=schema, allow_identifiers=allow_identifiers
            )
            if not self._match(TokenType.R_BRACKET):
                self._retreat(index)
                return None

            return exp.DataType(
                this=exp.DType.MAP,
                expressions=[key_type, value_type],
                nested=True,
            )

        nested = type_token in self.NESTED_TYPE_TOKENS
        is_struct = type_token in self.STRUCT_TYPE_TOKENS
        is_aggregate = type_token in self.AGGREGATE_TYPE_TOKENS
        expressions = None
        maybe_func = False

        if self._match(TokenType.L_PAREN):
            if is_struct:
                expressions = self._parse_csv(lambda: self._parse_struct_types(type_required=True))
            elif nested:
                expressions = self._parse_csv(
                    lambda: self._parse_types(
                        check_func=check_func, schema=schema, allow_identifiers=allow_identifiers
                    )
                )
                if type_token == TokenType.NULLABLE and len(expressions) == 1:
                    this = expressions[0]
                    this.set("nullable", True)
                    self._match_r_paren()
                    return this
            elif type_token in self.ENUM_TYPE_TOKENS:
                expressions = self._parse_csv(self._parse_equality)
            elif type_token == TokenType.JSON:
                # ClickHouse JSON type supports arguments: JSON(col Type, SKIP col, param=value)
                # https://clickhouse.com/docs/sql-reference/data-types/newjson
                expressions = self._parse_csv(self._parse_json_type_arg)
            elif is_aggregate:
                func_or_ident = self._parse_function(anonymous=True) or self._parse_id_var(
                    any_token=False, tokens=(TokenType.VAR, TokenType.ANY)
                )
                if not func_or_ident:
                    return None
                expressions = [func_or_ident]
                if self._match(TokenType.COMMA):
                    expressions.extend(
                        self._parse_csv(
                            lambda: self._parse_types(
                                check_func=check_func,
                                schema=schema,
                                allow_identifiers=allow_identifiers,
                            )
                        )
                    )
            else:
                expressions = self._parse_csv(self._parse_type_size)

                # https://docs.snowflake.com/en/sql-reference/data-types-vector
                if type_token == TokenType.VECTOR and len(expressions) == 2:
                    expressions = self._parse_vector_expressions(expressions)

            if not self._match(TokenType.R_PAREN):
                self._retreat(index)
                return None

            maybe_func = True

        values: list[exp.Expr] | None = None

        if nested and self._match(TokenType.LT):
            if is_struct:
                expressions = self._parse_csv(lambda: self._parse_struct_types(type_required=True))
            else:
                expressions = self._parse_csv(
                    lambda: self._parse_types(
                        check_func=check_func,
                        schema=schema,
                        allow_identifiers=allow_identifiers,
                        with_collation=True,
                    )
                )

            if not self._match(TokenType.GT):
                self.raise_error("Expecting >")

            if self._match_set((TokenType.L_BRACKET, TokenType.L_PAREN)):
                values = self._parse_csv(self._parse_disjunction)
                if not values and is_struct:
                    values = None
                    self._retreat(self._index - 1)
                else:
                    self._match_set((TokenType.R_BRACKET, TokenType.R_PAREN))

        if type_token in self.TIMESTAMPS:
            if self._match_text_seq("WITH", "TIME", "ZONE"):
                maybe_func = False
                tz_type = exp.DType.TIMETZ if type_token in self.TIMES else exp.DType.TIMESTAMPTZ
                this = exp.DataType(this=tz_type, expressions=expressions)
            elif self._match_text_seq("WITH", "LOCAL", "TIME", "ZONE"):
                maybe_func = False
                this = exp.DataType(this=exp.DType.TIMESTAMPLTZ, expressions=expressions)
            elif self._match_text_seq("WITHOUT", "TIME", "ZONE"):
                maybe_func = False
        elif type_token == TokenType.INTERVAL:
            if self._curr.text.upper() in self.dialect.VALID_INTERVAL_UNITS:
                unit = self._parse_var(upper=True)
                if self._match_text_seq("TO"):
                    unit = exp.IntervalSpan(this=unit, expression=self._parse_var(upper=True))

                this = self.expression(exp.DataType(this=self.expression(exp.Interval(unit=unit))))
            else:
                this = self.expression(exp.DataType(this=exp.DType.INTERVAL))
        elif type_token == TokenType.VOID:
            this = exp.DataType(this=exp.DType.NULL)

        if maybe_func and check_func:
            index2 = self._index
            peek = self._parse_string()

            if not peek:
                self._retreat(index)
                return None

            self._retreat(index2)

        if not this:
            assert type_token is not None
            if self._match_text_seq("UNSIGNED"):
                unsigned_type_token = self.SIGNED_TO_UNSIGNED_TYPE_TOKEN.get(type_token)
                if not unsigned_type_token:
                    self.raise_error(f"Cannot convert {type_token.name} to unsigned.")

                type_token = unsigned_type_token or type_token

            # NULLABLE without parentheses can be a column (Presto/Trino)
            if type_token == TokenType.NULLABLE and not expressions:
                self._retreat(index)
                return None

            this = exp.DataType(
                this=exp.DType[type_token.name],
                expressions=expressions,
                nested=nested,
            )

            # Empty arrays/structs are allowed
            if values is not None:
                cls = exp.Struct if is_struct else exp.Array
                this = exp.cast(cls(expressions=values), this, copy=False)

        elif expressions:
            this.set("expressions", expressions)

        # https://materialize.com/docs/sql/types/list/#type-name
        while self._match(TokenType.LIST):
            this = exp.DataType(this=exp.DType.LIST, expressions=[this], nested=True)

        index = self._index

        # Postgres supports the INT ARRAY[3] syntax as a synonym for INT[3]
        matched_array = self._match(TokenType.ARRAY)

        while self._curr:
            datatype_token = self._prev.token_type
            matched_l_bracket = self._match(TokenType.L_BRACKET)

            if (not matched_l_bracket and not matched_array) or (
                datatype_token == TokenType.ARRAY and self._match(TokenType.R_BRACKET)
            ):
                # Postgres allows casting empty arrays such as ARRAY[]::INT[],
                # not to be confused with the fixed size array parsing
                break

            matched_array = False
            values = self._parse_csv(self._parse_disjunction) or None
            if (
                values
                and not schema
                and (
                    not self.dialect.SUPPORTS_FIXED_SIZE_ARRAYS
                    or datatype_token == TokenType.ARRAY
                    or not self._match(TokenType.R_BRACKET, advance=False)
                )
            ):
                # Retreating here means that we should not parse the following values as part of the data type, e.g. in DuckDB
                # ARRAY[1] should retreat and instead be parsed into exp.Array in contrast to INT[x][y] which denotes a fixed-size array data type
                self._retreat(index)
                break

            this = exp.DataType(
                this=exp.DType.ARRAY, expressions=[this], values=values, nested=True
            )
            self._match(TokenType.R_BRACKET)

        if self.TYPE_CONVERTERS and isinstance(this.this, exp.DType):
            converter = self.TYPE_CONVERTERS.get(this.this)
            if converter:
                this = converter(t.cast(exp.DataType, this))

        if with_collation and isinstance(this, exp.DataType) and self._match(TokenType.COLLATE):
            this.set("collate", self._parse_identifier() or self._parse_column())

        return this

    def _parse_json_type_arg(self) -> exp.Expr | None:
        """Parse a single argument to ClickHouse's JSON type."""

        # SKIP col or SKIP REGEXP 'pattern'
        if self._match_text_seq("SKIP"):
            regexp = self._match(TokenType.RLIKE)
            arg = self._parse_column()
            if isinstance(arg, exp.Column):
                arg = arg.to_dot()
            return self.expression(exp.SkipJSONColumn(regexp=regexp, expression=arg))

        param_or_col = self._parse_column()
        if not isinstance(param_or_col, exp.Column):
            return None

        # Parameter: name=value (e.g., max_dynamic_paths=2)
        if len(param_or_col.parts) == 1 and self._match(TokenType.EQ):
            param = param_or_col.name
            value = self._parse_primary()
            return self.expression(exp.EQ(this=exp.var(param), expression=value))

        # Column type hint: col_name Type
        col = param_or_col.to_dot()
        kind = self._parse_types(check_func=False, allow_identifiers=False)
        return self.expression(exp.ColumnDef(this=col, kind=kind))

    def _parse_vector_expressions(self, expressions: list[exp.Expr]) -> list[exp.Expr]:
        return [exp.DataType.from_str(expressions[0].name, dialect=self.dialect), *expressions[1:]]

    def _parse_struct_types(self, type_required: bool = False) -> exp.Expr | None:
        index = self._index

        if (
            self._curr
            and self._next
            and self._curr.token_type in self.TYPE_TOKENS
            and self._next.token_type in self.TYPE_TOKENS
        ):
            # Takes care of special cases like `STRUCT<list ARRAY<...>>` where the identifier is also a
            # type token. Without this, the list will be parsed as a type and we'll eventually crash
            this = self._parse_id_var()
        else:
            this = (
                self._parse_type(parse_interval=False, fallback_to_identifier=True)
                or self._parse_id_var()
            )

        self._match(TokenType.COLON)

        if (
            type_required
            and not isinstance(this, exp.DataType)
            and not self._match_set(self.TYPE_TOKENS, advance=False)
        ):
            self._retreat(index)
            return self._parse_types()

        return self._parse_column_def(this)

    def _parse_at_time_zone(self, this: exp.Expr | None) -> exp.Expr | None:
        if not self._match_text_seq("AT", "TIME", "ZONE"):
            return this
        return self._parse_at_time_zone(
            self.expression(exp.AtTimeZone(this=this, zone=self._parse_unary()))
        )

    def _parse_atom(self) -> exp.Expr | None:
        if (
            self._curr.token_type in self.IDENTIFIER_TOKENS
            and (column := self._parse_column()) is not None
        ):
            return column

        token = self._curr
        token_type = token.token_type

        if not (primary_parser := self.PRIMARY_PARSERS.get(token_type)):
            return None

        next_type = self._next.token_type

        if (
            next_type in self.COLUMN_OPERATORS
            or next_type in self.COLUMN_POSTFIX_TOKENS
            or (token_type == TokenType.STRING and next_type == TokenType.STRING)
        ):
            return None

        self._advance()
        return primary_parser(self, token)

    def _parse_column(self) -> exp.Expr | None:
        column: exp.Expr | None = self._parse_column_parts_fast()
        if column is None:
            this = self._parse_column_reference()
            if not this:
                this = self._parse_bracket(this)
            column = self._parse_column_ops(this) if this else this

        if column:
            if self.dialect.SUPPORTS_COLUMN_JOIN_MARKS:
                column.set("join_mark", self._match(TokenType.JOIN_MARKER))
            if self.COLON_IS_VARIANT_EXTRACT:
                column = self._parse_colon_as_variant_extract(column)

        return column

    def _parse_column_parts_fast(self) -> exp.Column | exp.Dot | None:
        """Fast path for simple column and dot references (a, a.b, ...).

        Greedily consumes VAR/IDENTIFIER tokens separated by DOTs, then checks
        that nothing complex follows. If it does, retreats and returns None so
        the slow path can handle it. For >4 parts, wraps in exp.Dot nodes.
        """
        index = self._index
        parts: list[exp.Identifier] | None = None
        all_comments: list[str] | None = None

        while self._match_set(self.IDENTIFIER_TOKENS):
            token = self._prev
            comments = self._prev_comments

            if parts is None and token.text.upper() in self.NO_PAREN_FUNCTION_PARSERS:
                self._retreat(index)
                return None

            has_dot = self._match(TokenType.DOT)
            curr_tt = self._curr.token_type

            if not has_dot:
                if curr_tt in self.COLUMN_OPERATORS or curr_tt in self.COLUMN_POSTFIX_TOKENS:
                    self._retreat(index)
                    return None
            elif curr_tt not in self.IDENTIFIER_TOKENS:
                self._retreat(index)
                return None

            if parts is None:
                parts = []

            if comments:
                if all_comments is None:
                    all_comments = []
                all_comments.extend(comments)
                self._prev_comments = []

            parts.append(
                self.expression(
                    exp.Identifier(
                        this=token.text, quoted=token.token_type == TokenType.IDENTIFIER
                    ),
                    token,
                )
            )

            if not has_dot:
                break

        if parts is None:
            return None

        n = len(parts)

        if n == 1:
            column: exp.Column | exp.Dot = exp.Column(this=parts[0])
        elif n == 2:
            column = exp.Column(this=parts[1], table=parts[0])
        elif n == 3:
            column = exp.Column(this=parts[2], table=parts[1], db=parts[0])
        else:
            column = exp.Column(this=parts[3], table=parts[2], db=parts[1], catalog=parts[0])

            for i in range(4, n):
                column = exp.Dot(this=column, expression=parts[i])

        if all_comments:
            column.add_comments(all_comments)

        return column

    def _parse_column_reference(self) -> exp.Expr | None:
        this = self._parse_field()
        if (
            not this
            and self._match(TokenType.VALUES, advance=False)
            and self.VALUES_FOLLOWED_BY_PAREN
            and (not self._next or self._next.token_type != TokenType.L_PAREN)
        ):
            this = self._parse_id_var()

        if isinstance(this, exp.Identifier):
            # We bubble up comments from the Identifier to the Column
            this = self.expression(exp.Column(this=this), comments=this.pop_comments())

        return this

    def _build_json_extract(
        self,
        this: exp.Expr | None,
        path_parts: list[exp.JSONPathPart],
    ) -> tuple[exp.Expr | None, list[exp.JSONPathPart]]:
        if len(path_parts) > 1:
            this = self.expression(
                exp.JSONExtract(
                    this=this,
                    expression=exp.JSONPath(expressions=path_parts),
                    variant_extract=True,
                    requires_json=self.JSON_EXTRACT_REQUIRES_JSON_EXPRESSION,
                )
            )
            path_parts = [exp.JSONPathRoot()]

        return this, path_parts

    def _parse_colon_as_variant_extract(self, this: exp.Expr | None) -> exp.Expr | None:
        path_parts: list[exp.JSONPathPart] = [exp.JSONPathRoot()]

        while self._match(TokenType.COLON):
            if not self.COLON_CHAIN_IS_SINGLE_EXTRACT:
                this, path_parts = self._build_json_extract(this, path_parts)

            key = self._parse_id_var(any_token=True, tokens=(TokenType.SELECT,))

            if key:
                quoted = isinstance(key, exp.Identifier) and key.quoted
                path_parts.append(exp.JSONPathKey(this=key.name, quoted=quoted))

            while True:
                if self._match(TokenType.DOT):
                    next_key = self._parse_id_var(any_token=True, tokens=(TokenType.SELECT,))

                    if next_key:
                        quoted = isinstance(next_key, exp.Identifier) and next_key.quoted
                        path_parts.append(exp.JSONPathKey(this=next_key.name, quoted=quoted))
                elif self._match(TokenType.L_BRACKET):
                    bracket_expr = self._parse_bracket_key_value()

                    if not self._match(TokenType.R_BRACKET):
                        self.raise_error("Expected ]")

                    if bracket_expr:
                        if bracket_expr.is_string:
                            path_parts.append(exp.JSONPathKey(this=bracket_expr.name, quoted=True))
                        elif bracket_expr.is_star:
                            path_parts.append(exp.JSONPathSubscript(this=exp.JSONPathWildcard()))
                        elif bracket_expr.is_number:
                            path_parts.append(exp.JSONPathSubscript(this=bracket_expr.to_py()))
                        else:
                            this, path_parts = self._build_json_extract(this, path_parts)

                            this = self.expression(
                                exp.Bracket(
                                    this=this, expressions=[bracket_expr], json_access=True
                                ),
                            )

                elif self._match(TokenType.DCOLON):
                    this, path_parts = self._build_json_extract(this, path_parts)

                    cast_type = self._parse_types()
                    if cast_type:
                        this = self.expression(exp.Cast(this=this, to=cast_type))
                    else:
                        self.raise_error("Expected type after '::'")
                else:
                    break

        this, _ = self._build_json_extract(this, path_parts)

        return this

    def _parse_dcolon(self) -> exp.Expr | None:
        return self._parse_types()

    def _parse_column_ops(self, this: exp.Expr | None) -> exp.Expr | None:
        while self._curr.token_type in self.BRACKETS:
            this = self._parse_bracket(this)

        column_operators = self.COLUMN_OPERATORS
        cast_column_operators = self.CAST_COLUMN_OPERATORS
        while self._curr:
            op_token = self._curr.token_type

            if op_token not in column_operators:
                break
            op = column_operators[op_token]
            self._advance()

            if op_token in cast_column_operators:
                field = self._parse_dcolon()
                if not field:
                    self.raise_error("Expected type")
            elif op and self._curr:
                field = self._parse_column_reference() or self._parse_bitwise()
                if isinstance(field, exp.Column) and self._match(TokenType.DOT, advance=False):
                    field = self._parse_column_ops(field)
            else:
                dot = self._is_connected() and self._prev.token_type == TokenType.DOT
                field = self._parse_field(any_token=True, anonymous_func=True)

                # In t.true, t.null we should produce an Identifier node
                if dot and isinstance(field, (exp.Null, exp.Boolean)):
                    field = self.expression(
                        exp.Identifier(this=self._prev.text),
                        comments=field.comments,
                    )

            # Function calls can be qualified, e.g., x.y.FOO()
            # This converts the final AST to a series of Dots leading to the function call
            # https://cloud.google.com/bigquery/docs/reference/standard-sql/functions-reference#function_call_rules
            if isinstance(field, (exp.Func, exp.Window)) and this:
                this = this.transform(
                    lambda n: n.to_dot(include_dots=False) if isinstance(n, exp.Column) else n
                )

            if op:
                this = op(self, this, field)
            elif isinstance(this, exp.Column) and not this.args.get("catalog"):
                this = self.expression(
                    exp.Column(
                        this=field,
                        table=this.this,
                        db=this.args.get("table"),
                        catalog=this.args.get("db"),
                    ),
                    comments=this.comments,
                )
            elif isinstance(field, exp.Window):
                # Move the exp.Dot's to the window's function
                window_func = self.expression(exp.Dot(this=this, expression=field.this))
                field.set("this", window_func)
                this = field
            else:
                this = self.expression(exp.Dot(this=this, expression=field))

            if field and field.comments:
                t.cast(exp.Expr, this).add_comments(field.pop_comments())

            this = self._parse_bracket(this)

        return this

    def _parse_paren(self) -> exp.Expr | None:
        if not self._match(TokenType.L_PAREN):
            return None

        comments = self._prev_comments
        query = self._parse_select()

        if query:
            expressions = [query]
        else:
            expressions = self._parse_expressions()

        this = seq_get(expressions, 0)

        if not this and self._match(TokenType.R_PAREN, advance=False):
            this = self.expression(exp.Tuple())
        elif len(expressions) > 1 or self._prev.token_type == TokenType.COMMA:
            this = self.expression(exp.Tuple(expressions=expressions))
        elif isinstance(this, exp.UNWRAPPED_QUERIES):
            this = self._parse_subquery(this=this, parse_alias=False)
        elif isinstance(this, (exp.Subquery, exp.Values)):
            this = self._parse_subquery(
                this=self._parse_query_modifiers(self._parse_set_operations(this)),
                parse_alias=False,
            )
        else:
            this = self.expression(exp.Paren(this=this))

        if this:
            this.add_comments(comments)

        self._match_r_paren(expression=this)

        if isinstance(this, exp.Paren) and isinstance(this.this, exp.AggFunc):
            return self._parse_window(this)

        return this

    def _parse_primary(self) -> exp.Expr | None:
        if self._match_set(self.PRIMARY_PARSERS):
            token_type = self._prev.token_type
            primary = self.PRIMARY_PARSERS[token_type](self, self._prev)

            if token_type == TokenType.STRING:
                expressions = [primary]
                while self._match(TokenType.STRING, advance=False):
                    if self._is_connected() and self.ADJACENT_STRINGS_CANNOT_BE_CONNECTED:
                        self.raise_error(
                            "Adjacent string literals need to be separated by whitespace or comments"
                        )

                    self._advance()
                    expressions.append(exp.Literal.string(self._prev.text))

                if len(expressions) > 1:
                    return self.expression(
                        exp.Concat(expressions=expressions, coalesce=self.dialect.CONCAT_COALESCE)
                    )

            return primary

        if self._match_pair(TokenType.DOT, TokenType.NUMBER):
            return exp.Literal.number(f"0.{self._prev.text}")

        return self._parse_paren()

    def _parse_field(
        self,
        any_token: bool = False,
        tokens: t.Collection[TokenType] | None = None,
        anonymous_func: bool = False,
    ) -> exp.Expr | None:
        if anonymous_func:
            field = (
                self._parse_function(anonymous=anonymous_func, any_token=any_token)
                or self._parse_primary()
            )
        else:
            field = self._parse_primary() or self._parse_function(
                anonymous=anonymous_func, any_token=any_token
            )
        return field or self._parse_id_var(any_token=any_token, tokens=tokens)

    def _parse_function(
        self,
        functions: dict[str, t.Callable] | None = None,
        anonymous: bool = False,
        optional_parens: bool = True,
        any_token: bool = False,
    ) -> exp.Expr | None:
        # This allows us to also parse {fn <function>} syntax (Snowflake, MySQL support this)
        # See: https://community.snowflake.com/s/article/SQL-Escape-Sequences
        fn_syntax = False
        if (
            self._match(TokenType.L_BRACE, advance=False)
            and self._next
            and self._next.text.upper() == "FN"
        ):
            self._advance(2)
            fn_syntax = True

        func = self._parse_function_call(
            functions=functions,
            anonymous=anonymous,
            optional_parens=optional_parens,
            any_token=any_token,
        )

        if fn_syntax:
            self._match(TokenType.R_BRACE)

        return func

    def _parse_function_args(self, alias: bool = False) -> list[exp.Expr]:
        return self._parse_csv(lambda: self._parse_lambda(alias=alias))

    def _parse_function_call(
        self,
        functions: dict[str, t.Callable] | None = None,
        anonymous: bool = False,
        optional_parens: bool = True,
        any_token: bool = False,
    ) -> exp.Expr | None:
        if not self._curr:
            return None

        comments = self._curr.comments
        prev = self._prev
        token = self._curr
        token_type = self._curr.token_type
        this: str | exp.Expr = self._curr.text
        upper = self._curr.text.upper()

        after_dot = prev.token_type == TokenType.DOT
        parser = self.NO_PAREN_FUNCTION_PARSERS.get(upper)
        if (
            optional_parens
            and parser
            and token_type not in self.INVALID_FUNC_NAME_TOKENS
            and not after_dot
        ):
            self._advance()
            return self._parse_window(parser(self))

        if self._next.token_type != TokenType.L_PAREN:
            if optional_parens and token_type in self.NO_PAREN_FUNCTIONS and not after_dot:
                self._advance()
                return self.expression(self.NO_PAREN_FUNCTIONS[token_type]())

            return None

        if any_token:
            if token_type in self.RESERVED_TOKENS:
                return None
        elif token_type not in self.FUNC_TOKENS:
            return None

        self._advance(2)

        parser = self.FUNCTION_PARSERS.get(upper)
        if parser and not anonymous:
            result = parser(self)
        else:
            subquery_predicate = self.SUBQUERY_PREDICATES.get(token_type)

            if subquery_predicate:
                expr = None
                if self._curr.token_type in self.SUBQUERY_TOKENS:
                    expr = self._parse_select()
                    self._match_r_paren()
                elif prev and prev.token_type in (TokenType.LIKE, TokenType.ILIKE):
                    # Backtrack one token since we've consumed the L_PAREN here. Instead, we'd like
                    # to parse "LIKE [ANY | ALL] (...)" as a whole into an exp.Tuple or exp.Paren
                    self._advance(-1)
                    expr = self._parse_bitwise()

                if expr:
                    return self.expression(subquery_predicate(this=expr), comments=comments)

            if functions is None:
                functions = self.FUNCTIONS

            function = functions.get(upper)
            known_function = function and not anonymous

            alias = not known_function or upper in self.FUNCTIONS_WITH_ALIASED_ARGS
            args = self._parse_function_args(alias)

            post_func_comments = self._curr.comments if self._curr else None
            if known_function and post_func_comments:
                # If the user-inputted comment "/* sqlglot.anonymous */" is following the function
                # call we'll construct it as exp.Anonymous, even if it's "known"
                if any(
                    comment.lstrip().startswith(exp.SQLGLOT_ANONYMOUS)
                    for comment in post_func_comments
                ):
                    known_function = False

            if alias and known_function:
                args = self._kv_to_prop_eq(args)

            if known_function:
                func_builder = t.cast(t.Callable, function)

                # mypyc compiled functions don't have __code__, so we use
                # try/except to check if func_builder accepts 'dialect'.
                try:
                    func = func_builder(args)
                except TypeError:
                    func = func_builder(args, dialect=self.dialect)

                func = self.validate_expression(func, args)
                if self.dialect.PRESERVE_ORIGINAL_NAMES:
                    func.meta["name"] = this

                result = func
            else:
                if token_type == TokenType.IDENTIFIER:
                    this = exp.Identifier(this=this, quoted=True).update_positions(token)

                result = self.expression(exp.Anonymous(this=this, expressions=args))

            result = result.update_positions(token)

        if isinstance(result, exp.Expr):
            result.add_comments(comments)

        if parser:
            self._match(TokenType.R_PAREN, expression=result)
        else:
            self._match_r_paren(result)
        return self._parse_window(result)

    def _to_prop_eq(self, expression: exp.Expr, index: int) -> exp.Expr:
        return expression

    def _kv_to_prop_eq(
        self, expressions: list[exp.Expr], parse_map: bool = False
    ) -> list[exp.Expr]:
        transformed = []

        for index, e in enumerate(expressions):
            if isinstance(e, self.KEY_VALUE_DEFINITIONS):
                if isinstance(e, exp.Alias):
                    e = self.expression(exp.PropertyEQ(this=e.args.get("alias"), expression=e.this))

                if not isinstance(e, exp.PropertyEQ):
                    e = self.expression(
                        exp.PropertyEQ(
                            this=e.this if parse_map else exp.to_identifier(e.this.name),
                            expression=e.expression,
                        )
                    )

                if isinstance(e.this, exp.Column):
                    e.this.replace(e.this.this)
            else:
                e = self._to_prop_eq(e, index)

            transformed.append(e)

        return transformed

    def _parse_function_properties(self) -> exp.Properties | None:
        # Skip the generic `key = value` fallback in _parse_property since this
        # runs post-AS where a function body like `name = expr` can be misread
        # as a property.
        properties = []
        while True:
            if self._match_texts(self.PROPERTY_PARSERS):
                prop = self.PROPERTY_PARSERS[self._prev.text.upper()](self)
            elif self._match(TokenType.DEFAULT) and self._match_texts(self.PROPERTY_PARSERS):
                prop = self.PROPERTY_PARSERS[self._prev.text.upper()](self, default=True)
            else:
                break
            for p in ensure_list(prop):
                properties.append(p)

        return self.expression(exp.Properties(expressions=properties)) if properties else None

    def _parse_user_defined_function_expression(self) -> exp.Expr | None:
        return self._parse_statement()

    def _parse_function_parameter(self) -> exp.Expr | None:
        return self._parse_column_def(this=self._parse_id_var(), computed_column=False)

    def _parse_user_defined_function(self, kind: TokenType | None = None) -> exp.Expr | None:
        this = self._parse_table_parts(schema=True)

        if not self._match(TokenType.L_PAREN):
            return this

        expressions = self._parse_csv(self._parse_function_parameter)
        self._match_r_paren()
        return self.expression(
            exp.UserDefinedFunction(this=this, expressions=expressions, wrapped=True)
        )

    def _parse_macro_overloads(
        self,
        this: exp.UserDefinedFunction,
        first_body: exp.Expr,
        first_is_table: bool = False,
    ) -> exp.MacroOverloads:
        overloads = [
            self.expression(
                exp.MacroOverload(
                    this=first_body,
                    expressions=this.expressions or None,
                    is_table=first_is_table,
                )
            )
        ]
        this.set("expressions", None)
        this.set("wrapped", False)

        while self._match(TokenType.COMMA):
            if not self._match(TokenType.L_PAREN):
                break

            params = self._parse_csv(self._parse_function_parameter)
            self._match_r_paren()

            if not self._match(TokenType.ALIAS):
                break

            is_table = self._match(TokenType.TABLE)
            body = self._parse_expression()
            macro = exp.MacroOverload(this=body, expressions=params, is_table=is_table)
            overloads.append(self.expression(macro))

        return self.expression(exp.MacroOverloads(expressions=overloads))

    def _parse_introducer(self, token: Token) -> exp.Introducer | exp.Identifier:
        literal = self._parse_primary()
        if literal:
            return self.expression(exp.Introducer(this=token.text, expression=literal), token)

        return self._identifier_expression(token)

    def _parse_session_parameter(self) -> exp.SessionParameter:
        kind = None
        this = self._parse_id_var() or self._parse_primary()

        if this and self._match(TokenType.DOT):
            kind = this.name
            this = self._parse_var() or self._parse_primary()

        return self.expression(exp.SessionParameter(this=this, kind=kind))

    def _parse_lambda_arg(self) -> exp.Expr | None:
        return self._parse_id_var()

    def _parse_lambda(self, alias: bool = False) -> exp.Expr | None:
        next_token_type = self._next.token_type

        # Fast path: simple atom (column, literal, null, bool) followed by , or )
        if (
            next_token_type in self.LAMBDA_ARG_TERMINATORS
            and (atom := self._parse_atom()) is not None
        ):
            return atom

        index = self._index

        if self._match(TokenType.L_PAREN):
            expressions = t.cast(
                list[t.Optional[exp.Expr]], self._parse_csv(self._parse_lambda_arg)
            )

            if not self._match(TokenType.R_PAREN):
                self._retreat(index)
            elif self._match_set(self.LAMBDAS):
                return self.LAMBDAS[self._prev.token_type](self, expressions)
            else:
                self._retreat(index)
        elif self.TYPED_LAMBDA_ARGS or next_token_type in self.LAMBDAS:
            expressions = [self._parse_lambda_arg()]

            if self._match_set(self.LAMBDAS):
                return self.LAMBDAS[self._prev.token_type](self, expressions)

            self._retreat(index)

        this: exp.Expr | None

        if self._match(TokenType.DISTINCT):
            this = self.expression(
                exp.Distinct(expressions=self._parse_csv(self._parse_disjunction))
            )
        else:
            self._match(TokenType.ALL)  # ALL is the default/no-op aggregate modifier (SQL-92)
            this = self._parse_select_or_expression(alias=alias)

        return self._parse_limit(
            self._parse_respect_or_ignore_nulls(
                self._parse_order(self._parse_having_max(self._parse_respect_or_ignore_nulls(this)))
            )
        )

    def _parse_schema(self, this: exp.Expr | None = None) -> exp.Expr | None:
        index = self._index
        if not self._match(TokenType.L_PAREN):
            return this

        # Disambiguate between schema and subquery/CTE, e.g. in INSERT INTO table (<expr>),
        # expr can be of both types
        if self._match_set(self.SELECT_START_TOKENS):
            self._retreat(index)
            return this
        args = self._parse_csv(lambda: self._parse_constraint() or self._parse_field_def())
        self._match_r_paren()
        return self.expression(exp.Schema(this=this, expressions=args))

    def _parse_field_def(self) -> exp.Expr | None:
        return self._parse_column_def(self._parse_field(any_token=True))

    def _parse_column_def(
        self, this: exp.Expr | None, computed_column: bool = True
    ) -> exp.Expr | None:
        # column defs are not really columns, they're identifiers
        if isinstance(this, exp.Column):
            this = this.this

        if not computed_column:
            self._match(TokenType.ALIAS)

        kind = self._parse_types(schema=True)

        if self._match_text_seq("FOR", "ORDINALITY"):
            return self.expression(exp.ColumnDef(this=this, ordinality=True))

        constraints: list[exp.Expr] = []

        if (not kind and self._match(TokenType.ALIAS)) or self._match_texts(
            ("ALIAS", "MATERIALIZED")
        ):
            persisted = self._prev.text.upper() == "MATERIALIZED"
            constraint_kind = exp.ComputedColumnConstraint(
                this=self._parse_disjunction(),
                persisted=persisted or self._match_text_seq("PERSISTED"),
                data_type=exp.Var(this="AUTO")
                if self._match_text_seq("AUTO")
                else self._parse_types(),
                not_null=self._match_pair(TokenType.NOT, TokenType.NULL),
            )
            constraints.append(self.expression(exp.ColumnConstraint(kind=constraint_kind)))
        elif not kind and self._match_set({TokenType.IN, TokenType.OUT}, advance=False):
            in_out_constraint = self.expression(
                exp.InOutColumnConstraint(
                    input_=self._match(TokenType.IN), output=self._match(TokenType.OUT)
                )
            )
            constraints.append(in_out_constraint)
            kind = self._parse_types()
        elif (
            kind
            and self._match(TokenType.ALIAS, advance=False)
            and (
                not self.WRAPPED_TRANSFORM_COLUMN_CONSTRAINT
                or self._next.token_type == TokenType.L_PAREN
            )
        ):
            self._advance()
            constraints.append(
                self.expression(
                    exp.ColumnConstraint(
                        kind=exp.ComputedColumnConstraint(
                            this=self._parse_disjunction(),
                            persisted=self._match_texts(("STORED", "VIRTUAL"))
                            and self._prev.text.upper() == "STORED",
                        )
                    )
                )
            )

        while True:
            constraint = self._parse_column_constraint()
            if not constraint:
                break
            constraints.append(constraint)

        if not kind and not constraints:
            return this

        position = None
        if self._match_texts(("FIRST", "AFTER")):
            pos = self._prev.text
            position = self.expression(exp.ColumnPosition(this=self._parse_column(), position=pos))

        return self.expression(
            exp.ColumnDef(this=this, kind=kind, constraints=constraints, position=position)
        )

    def _parse_auto_increment(
        self,
    ) -> exp.GeneratedAsIdentityColumnConstraint | exp.AutoIncrementColumnConstraint:
        start = None
        increment = None
        order = None

        if self._match(TokenType.L_PAREN, advance=False):
            args = self._parse_wrapped_csv(self._parse_bitwise)
            start = seq_get(args, 0)
            increment = seq_get(args, 1)

        # The remaining parts form an unordered bag and any of them can be omitted, in which
        # case the engine falls back to its own default, so they're parsed independently.
        while True:
            if self._match_text_seq("START"):
                start = self._parse_bitwise()
            elif self._match_text_seq("INCREMENT"):
                increment = self._parse_bitwise()
            elif self._match_text_seq("ORDER"):
                order = True
            elif self._match_text_seq("NOORDER"):
                order = False
            else:
                break

        if start or increment or order is not None:
            return exp.GeneratedAsIdentityColumnConstraint(
                start=start, increment=increment, this=False, order=order
            )

        return exp.AutoIncrementColumnConstraint()

    def _parse_check_constraint(self) -> exp.CheckColumnConstraint | None:
        if not self._match(TokenType.L_PAREN, advance=False):
            return None

        return self.expression(
            exp.CheckColumnConstraint(
                this=self._parse_wrapped(self._parse_assignment),
                enforced=self._match_text_seq("ENFORCED"),
            )
        )

    def _parse_auto_property(self) -> exp.AutoRefreshProperty | None:
        if not self._match_text_seq("REFRESH"):
            self._retreat(self._index - 1)
            return None
        return self.expression(exp.AutoRefreshProperty(this=self._parse_var(upper=True)))

    def _parse_compress(self) -> exp.CompressColumnConstraint:
        if self._match(TokenType.L_PAREN, advance=False):
            return self.expression(
                exp.CompressColumnConstraint(this=self._parse_wrapped_csv(self._parse_bitwise))
            )

        return self.expression(exp.CompressColumnConstraint(this=self._parse_bitwise()))

    def _parse_generated_as_identity(
        self,
    ) -> (
        exp.GeneratedAsIdentityColumnConstraint
        | exp.ComputedColumnConstraint
        | exp.GeneratedAsRowColumnConstraint
    ):
        if self._match_text_seq("BY", "DEFAULT"):
            on_null = self._match_pair(TokenType.ON, TokenType.NULL)
            this = self.expression(
                exp.GeneratedAsIdentityColumnConstraint(this=False, on_null=on_null)
            )
        else:
            self._match_text_seq("ALWAYS")
            this = self.expression(exp.GeneratedAsIdentityColumnConstraint(this=True))

        self._match(TokenType.ALIAS)

        if self._match_text_seq("ROW"):
            start = self._match_text_seq("START")
            if not start:
                self._match(TokenType.END)
            hidden = self._match_text_seq("HIDDEN")
            return self.expression(exp.GeneratedAsRowColumnConstraint(start=start, hidden=hidden))

        identity = self._match_text_seq("IDENTITY")

        if self._match(TokenType.L_PAREN):
            if self._match_text_seq("START", "WITH"):
                this.set("start", self._parse_bitwise())
            if self._match_text_seq("INCREMENT", "BY"):
                this.set("increment", self._parse_bitwise())
            if self._match_text_seq("MINVALUE"):
                this.set("minvalue", self._parse_bitwise())
            if self._match_text_seq("MAXVALUE"):
                this.set("maxvalue", self._parse_bitwise())

            if self._match_text_seq("CYCLE"):
                this.set("cycle", True)
            elif self._match_text_seq("NO", "CYCLE"):
                this.set("cycle", False)

            if not identity:
                this.set("expression", self._parse_range())
            elif not this.args.get("start") and self._match(TokenType.NUMBER, advance=False):
                args = self._parse_csv(self._parse_bitwise)
                this.set("start", seq_get(args, 0))
                this.set("increment", seq_get(args, 1))

            self._match_r_paren()

        return this

    def _parse_inline(self) -> exp.InlineLengthColumnConstraint:
        self._match_text_seq("LENGTH")
        return self.expression(exp.InlineLengthColumnConstraint(this=self._parse_bitwise()))

    def _parse_not_constraint(self) -> exp.Expr | None:
        if self._match_text_seq("NULL"):
            return self.expression(exp.NotNullColumnConstraint())
        if self._match_text_seq("CASESPECIFIC"):
            return self.expression(exp.CaseSpecificColumnConstraint(not_=True))
        if self._match_text_seq("FOR", "REPLICATION"):
            return self.expression(exp.NotForReplicationColumnConstraint())

        # Unconsume the `NOT` token
        self._retreat(self._index - 1)
        return None

    def _parse_column_constraint(self) -> exp.Expr | None:
        this = self._parse_id_var() if self._match(TokenType.CONSTRAINT) else None

        procedure_option_follows = (
            self._match(TokenType.WITH, advance=False)
            and self._next
            and self._next.text.upper() in self.PROCEDURE_OPTIONS
        )

        if not procedure_option_follows and self._match_texts(self.CONSTRAINT_PARSERS):
            constraint = self.CONSTRAINT_PARSERS[self._prev.text.upper()](self)
            if not constraint:
                self._retreat(self._index - 1)
                return None

            return self.expression(exp.ColumnConstraint(this=this, kind=constraint))

        if self._match_text_seq("CHARACTER", "SET"):
            return self.expression(
                exp.ColumnConstraint(
                    this=this,
                    kind=self.expression(
                        exp.CharacterSetColumnConstraint(this=self._parse_var_or_string())
                    ),
                )
            )

        return this

    def _parse_constraint(self) -> exp.Expr | None:
        if not self._match(TokenType.CONSTRAINT):
            return self._parse_unnamed_constraint(constraints=self.SCHEMA_UNNAMED_CONSTRAINTS)

        return self.expression(
            exp.Constraint(this=self._parse_id_var(), expressions=self._parse_unnamed_constraints())
        )

    def _parse_unnamed_constraints(self) -> list[exp.Expr]:
        constraints = []
        while True:
            constraint = self._parse_unnamed_constraint() or self._parse_function()
            if not constraint:
                break
            constraints.append(constraint)

        return constraints

    def _parse_unnamed_constraint(self, constraints: TEXTS_TYPE | None = None) -> exp.Expr | None:
        index = self._index

        if self._match(TokenType.IDENTIFIER, advance=False) or not self._match_texts(
            constraints or self.CONSTRAINT_PARSERS
        ):
            return None

        constraint_key = self._prev.text.upper()
        if constraint_key not in self.CONSTRAINT_PARSERS:
            self.raise_error(f"No parser found for schema constraint {constraint_key}.")

        result = self.CONSTRAINT_PARSERS[constraint_key](self)
        if not result:
            self._retreat(index)

        return result

    def _parse_unique_key(self) -> exp.Expr | None:
        if (
            self._curr
            and self._curr.token_type != TokenType.IDENTIFIER
            and self._curr.text.upper() in self.CONSTRAINT_PARSERS
        ):
            return None
        return self._parse_id_var(any_token=False)

    def _parse_unique(self) -> exp.UniqueColumnConstraint:
        self._match_texts(("KEY", "INDEX"))
        return self.expression(
            exp.UniqueColumnConstraint(
                nulls=self._match_text_seq("NULLS", "NOT", "DISTINCT"),
                this=self._parse_schema(self._parse_unique_key()),
                index_type=self._match(TokenType.USING) and self._advance_any() and self._prev.text,
                on_conflict=self._parse_on_conflict(),
                options=self._parse_key_constraint_options(),
            )
        )

    def _parse_key_constraint_options(self) -> list[str]:
        options = []
        while True:
            if not self._curr:
                break

            if self._match(TokenType.ON):
                action = None
                on = self._advance_any() and self._prev.text

                if self._match_text_seq("NO", "ACTION"):
                    action = "NO ACTION"
                elif self._match_text_seq("CASCADE"):
                    action = "CASCADE"
                elif self._match_text_seq("RESTRICT"):
                    action = "RESTRICT"
                elif self._match_pair(TokenType.SET, TokenType.NULL):
                    action = "SET NULL"
                elif self._match_pair(TokenType.SET, TokenType.DEFAULT):
                    action = "SET DEFAULT"
                else:
                    self.raise_error("Invalid key constraint")

                options.append(f"ON {on} {action}")
            else:
                var = self._parse_var_from_options(
                    self.KEY_CONSTRAINT_OPTIONS, raise_unmatched=False
                )
                if not var:
                    break
                options.append(var.name)

        return options

    def _parse_references(self, match: bool = True) -> exp.Reference | None:
        if match and not self._match(TokenType.REFERENCES):
            return None

        expressions: list | None = None
        this = self._parse_table(schema=True)
        options = self._parse_key_constraint_options()
        return self.expression(exp.Reference(this=this, expressions=expressions, options=options))

    def _parse_foreign_key(self) -> exp.ForeignKey:
        expressions = (
            self._parse_wrapped_id_vars()
            if not self._match(TokenType.REFERENCES, advance=False)
            else None
        )
        reference = self._parse_references()
        on_options = {}

        while self._match(TokenType.ON):
            if not self._match_set((TokenType.DELETE, TokenType.UPDATE)):
                self.raise_error("Expected DELETE or UPDATE")

            kind = self._prev.text.lower()

            if self._match_text_seq("NO", "ACTION"):
                action = "NO ACTION"
            elif self._match(TokenType.SET):
                self._match_set((TokenType.NULL, TokenType.DEFAULT))
                action = "SET " + self._prev.text.upper()
            else:
                self._advance()
                action = self._prev.text.upper()

            on_options[kind] = action

        return self.expression(
            exp.ForeignKey(
                expressions=expressions,
                reference=reference,
                options=self._parse_key_constraint_options(),
                **on_options,
            )
        )

    def _parse_primary_key_part(self) -> exp.Expr | None:
        return self._parse_field()

    def _parse_period_for_system_time(self) -> exp.PeriodForSystemTimeConstraint | None:
        if not self._match_text_seq("FOR", "SYSTEM_TIME"):
            self._retreat(self._index - 1)
            return None

        id_vars = self._parse_wrapped_id_vars()
        return self.expression(
            exp.PeriodForSystemTimeConstraint(
                this=seq_get(id_vars, 0), expression=seq_get(id_vars, 1)
            )
        )

    def _parse_primary_key(
        self,
        wrapped_optional: bool = False,
        in_props: bool = False,
        named_primary_key: bool = False,
    ) -> exp.PrimaryKeyColumnConstraint | exp.PrimaryKey:
        desc = (
            self._prev.token_type == TokenType.DESC
            if self._match_set((TokenType.ASC, TokenType.DESC))
            else None
        )

        this = None
        if (
            named_primary_key
            and self._curr.text.upper() not in self.CONSTRAINT_PARSERS
            and self._next
            and self._next.token_type == TokenType.L_PAREN
        ):
            this = self._parse_id_var()

        if not in_props and not self._match(TokenType.L_PAREN, advance=False):
            return self.expression(
                exp.PrimaryKeyColumnConstraint(
                    desc=desc, options=self._parse_key_constraint_options()
                )
            )

        expressions = self._parse_wrapped_csv(
            self._parse_primary_key_part, optional=wrapped_optional
        )

        return self.expression(
            exp.PrimaryKey(
                this=this,
                expressions=expressions,
                include=self._parse_index_params(),
                options=self._parse_key_constraint_options(),
            )
        )

    def _parse_bracket_key_value(self, is_map: bool = False) -> exp.Expr | None:
        return self._parse_slice(self._parse_alias(self._parse_disjunction(), explicit=True))

    def _parse_odbc_datetime_literal(self) -> exp.Expr:
        """
        Parses a datetime column in ODBC format. We parse the column into the corresponding
        types, for example `{d'yyyy-mm-dd'}` will be parsed as a `Date` column, exactly the
        same as we did for `DATE('yyyy-mm-dd')`.

        Reference:
        https://learn.microsoft.com/en-us/sql/odbc/reference/develop-app/date-time-and-timestamp-literals
        """
        self._match(TokenType.VAR)
        exp_class = self.ODBC_DATETIME_LITERALS[self._prev.text.lower()]
        expression = self.expression(exp_class(this=self._parse_string()))
        if not self._match(TokenType.R_BRACE):
            self.raise_error("Expected }")
        return expression

    def _parse_bracket(self, this: exp.Expr | None = None) -> exp.Expr | None:
        if not self._match_set(self.BRACKETS):
            return this

        if self.MAP_KEYS_ARE_ARBITRARY_EXPRESSIONS:
            map_token = seq_get(self._tokens, self._index - 2)
            parse_map = map_token is not None and map_token.text.upper() == "MAP"
        else:
            parse_map = False

        bracket_kind = self._prev.token_type
        if (
            bracket_kind == TokenType.L_BRACE
            and self._curr
            and self._curr.token_type == TokenType.VAR
            and self._curr.text.lower() in self.ODBC_DATETIME_LITERALS
        ):
            return self._parse_odbc_datetime_literal()

        expressions = self._parse_csv(
            lambda: self._parse_bracket_key_value(is_map=bracket_kind == TokenType.L_BRACE)
        )

        if bracket_kind == TokenType.L_BRACKET and not self._match(TokenType.R_BRACKET):
            self.raise_error("Expected ]")
        elif bracket_kind == TokenType.L_BRACE and not self._match(TokenType.R_BRACE):
            self.raise_error("Expected }")

        # https://duckdb.org/docs/sql/data_types/struct.html#creating-structs
        if bracket_kind == TokenType.L_BRACE:
            this = self.expression(
                exp.Struct(
                    expressions=self._kv_to_prop_eq(expressions=expressions, parse_map=parse_map)
                )
            )
        elif not this:
            this = build_array_constructor(
                exp.Array, args=expressions, bracket_kind=bracket_kind, dialect=self.dialect
            )
        else:
            constructor_type = self.ARRAY_CONSTRUCTORS.get(this.name.upper())
            if constructor_type:
                return build_array_constructor(
                    constructor_type,
                    args=expressions,
                    bracket_kind=bracket_kind,
                    dialect=self.dialect,
                )

            expressions = apply_index_offset(
                this, expressions, -self.dialect.INDEX_OFFSET, dialect=self.dialect
            )
            this = self.expression(
                exp.Bracket(this=this, expressions=expressions), comments=this.pop_comments()
            )

        self._add_comments(this)
        return self._parse_bracket(this)

    def _parse_slice(self, this: exp.Expr | None) -> exp.Expr | None:
        if not self._match(TokenType.COLON):
            return this

        if self._match_pair(TokenType.DASH, TokenType.COLON, advance=False):
            self._advance()
            end: exp.Expr | None = -exp.Literal.number("1")
        else:
            end = self._parse_assignment()
        step = self._parse_unary() if self._match(TokenType.COLON) else None
        return self.expression(exp.Slice(this=this, expression=end, step=step))

    def _parse_case(self) -> exp.Expr | None:
        if self._match(TokenType.DOT, advance=False):
            # Avoid raising on valid expressions like case.*, supported by, e.g., spark & snowflake
            self._retreat(self._index - 1)
            return None

        ifs = []
        default = None

        comments = self._prev_comments
        expression = self._parse_disjunction()

        while self._match(TokenType.WHEN):
            this = self._parse_disjunction()
            self._match(TokenType.THEN)
            then = self._parse_disjunction()
            ifs.append(self.expression(exp.If(this=this, true=then)))

        if self._match(TokenType.ELSE):
            default = self._parse_disjunction()

        if not self._match(TokenType.END):
            if isinstance(default, exp.Interval) and default.this.sql().upper() == "END":
                default = exp.column("interval")
            else:
                self.raise_error("Expected END after CASE", self._prev)

        return self.expression(
            exp.Case(this=expression, ifs=ifs, default=default), comments=comments
        )

    def _parse_if(self) -> exp.Expr | None:
        if self._match(TokenType.L_PAREN):
            args = self._parse_csv(
                lambda: self._parse_alias(self._parse_assignment(), explicit=True)
            )
            this = self.validate_expression(exp.If.from_arg_list(args), args)
            self._match_r_paren()
        else:
            index = self._index - 1

            if self.NO_PAREN_IF_COMMANDS and index == 0:
                return self._parse_as_command(self._prev)

            condition = self._parse_disjunction()

            if not condition:
                self._retreat(index)
                return None

            self._match(TokenType.THEN)
            true = self._parse_disjunction()
            false = self._parse_disjunction() if self._match(TokenType.ELSE) else None
            self._match(TokenType.END)
            this = self.expression(exp.If(this=condition, true=true, false=false))

        return this

    def _parse_next_value_for(self) -> exp.Expr | None:
        if not self._match_text_seq("VALUE", "FOR"):
            self._retreat(self._index - 1)
            return None

        return self.expression(
            exp.NextValueFor(
                this=self._parse_column(),
                order=self._match(TokenType.OVER) and self._parse_wrapped(self._parse_order),
            )
        )

    def _parse_extract(self) -> exp.Extract | exp.Anonymous:
        this = self._parse_function() or self._parse_var_or_string(upper=True)

        if self._match(TokenType.FROM):
            return self.expression(exp.Extract(this=this, expression=self._parse_bitwise()))

        if not self._match(TokenType.COMMA):
            self.raise_error("Expected FROM or comma after EXTRACT", self._prev)

        return self.expression(exp.Extract(this=this, expression=self._parse_bitwise()))

    def _parse_gap_fill(self) -> exp.GapFill:
        self._match(TokenType.TABLE)
        this = self._parse_table()

        self._match(TokenType.COMMA)
        args = [this, *self._parse_csv(self._parse_lambda)]

        gap_fill = exp.GapFill.from_arg_list(args)
        return self.validate_expression(gap_fill, args)

    def _parse_char(self) -> exp.Chr:
        return self.expression(
            exp.Chr(
                expressions=self._parse_csv(self._parse_assignment),
                charset=self._match(TokenType.USING) and self._parse_charset_name(),
            )
        )

    def _parse_charset_name(self) -> exp.Expr | None:
        """
        Parse a charset name after USING or CHARACTER SET. Dialects that need to preserve quoting
        for specific name shapes override this.
        """
        return self._parse_var(
            tokens={TokenType.BINARY, TokenType.IDENTIFIER},
        )

    def _parse_cast(self, strict: bool, safe: bool | None = None) -> exp.Expr:
        this = self._parse_assignment()

        if not self._match(TokenType.ALIAS):
            if self._match(TokenType.COMMA):
                return self.expression(exp.CastToStrType(this=this, to=self._parse_string()))

            self.raise_error("Expected AS after CAST")

        fmt = None
        to = self._parse_types(with_collation=True)

        default = None
        if self._match(TokenType.DEFAULT):
            default = self._parse_bitwise()
            self._match_text_seq("ON", "CONVERSION", "ERROR")

        if self._match_set((TokenType.FORMAT, TokenType.COMMA)):
            fmt_string = self._parse_wrapped(self._parse_string, optional=True)
            fmt = self._parse_at_time_zone(fmt_string)

            if not to:
                to = exp.DType.UNKNOWN.into_expr()
            if to.this in exp.DataType.TEMPORAL_TYPES:
                this = self.expression(
                    (exp.StrToDate if to.this == exp.DType.DATE else exp.StrToTime)(
                        this=this,
                        format=exp.Literal.string(
                            format_time(
                                fmt_string.this if fmt_string else "",
                                self.dialect.FORMAT_MAPPING or self.dialect.TIME_MAPPING,
                                self.dialect.FORMAT_TRIE or self.dialect.TIME_TRIE,
                            )
                        ),
                        safe=safe,
                    )
                )

                if isinstance(fmt, exp.AtTimeZone) and isinstance(this, exp.StrToTime):
                    this.set("zone", fmt.args["zone"])
                return this
        elif not to:
            self.raise_error("Expected TYPE after CAST")
        elif isinstance(to, exp.Identifier):
            to = exp.DataType.from_str(to.name, dialect=self.dialect, udt=True)
        elif to.this == exp.DType.CHAR and (
            self._match(TokenType.CHARACTER_SET) or self._match_text_seq("CHARACTER", "SET")
        ):
            to = exp.DType.CHARACTER_SET.into_expr(kind=self._parse_var_or_string())

        return self.build_cast(
            strict=strict,
            this=this,
            to=to,
            format=fmt,
            safe=safe,
            action=self._parse_var_from_options(self.CAST_ACTIONS, raise_unmatched=False),
            default=default,
        )

    def _parse_string_agg(self) -> exp.GroupConcat:
        if self._match(TokenType.DISTINCT):
            args: list[exp.Expr | None] = [
                self.expression(exp.Distinct(expressions=[self._parse_disjunction()]))
            ]
            if self._match(TokenType.COMMA):
                args.extend(self._parse_csv(self._parse_disjunction))
        else:
            args = self._parse_csv(self._parse_disjunction)  # type: ignore

        if self._match_text_seq("ON", "OVERFLOW"):
            # trino: LISTAGG(expression [, separator] [ON OVERFLOW overflow_behavior])
            if self._match_text_seq("ERROR"):
                on_overflow: exp.Expr | None = exp.var("ERROR")
            else:
                self._match_text_seq("TRUNCATE")
                on_overflow = self.expression(
                    exp.OverflowTruncateBehavior(
                        this=self._parse_string(),
                        with_count=(
                            self._match_text_seq("WITH", "COUNT")
                            or not self._match_text_seq("WITHOUT", "COUNT")
                        ),
                    )
                )
        else:
            on_overflow = None

        index = self._index
        if not self._match(TokenType.R_PAREN) and args:
            # postgres: STRING_AGG([DISTINCT] expression, separator [ORDER BY expression1 {ASC | DESC} [, ...]])
            # bigquery: STRING_AGG([DISTINCT] expression [, separator] [ORDER BY key [{ASC | DESC}] [, ... ]] [LIMIT n])
            # The order is parsed through `this` as a canonicalization for WITHIN GROUPs
            args[0] = self._parse_limit(this=self._parse_order(this=args[0]))
            return self.expression(exp.GroupConcat(this=args[0], separator=seq_get(args, 1)))

        # Checks if we can parse an order clause: WITHIN GROUP (ORDER BY <order_by_expression_list> [ASC | DESC]).
        # This is done "manually", instead of letting _parse_window parse it into an exp.WithinGroup node, so that
        # the STRING_AGG call is parsed like in MySQL / SQLite and can thus be transpiled more easily to them.
        if not self._match_text_seq("WITHIN", "GROUP"):
            self._retreat(index)
            return self.validate_expression(exp.GroupConcat.from_arg_list(args), args)

        # The corresponding match_r_paren will be called in parse_function (caller)
        self._match_l_paren()

        return self.expression(
            exp.GroupConcat(
                this=self._parse_order(this=seq_get(args, 0)),
                separator=seq_get(args, 1),
                on_overflow=on_overflow,
            )
        )

    def _parse_convert(self, strict: bool, safe: bool | None = None) -> exp.Expr | None:
        this = self._parse_bitwise()

        if self._match(TokenType.USING):
            to: exp.Expr | None = exp.DType.CHARACTER_SET.into_expr(kind=self._parse_charset_name())
        elif self._match(TokenType.COMMA):
            to = self._parse_types()
        else:
            to = None

        return self.build_cast(strict=strict, this=this, to=to, safe=safe)

    def _parse_xml_element(self) -> exp.XMLElement:
        if self._match_text_seq("EVALNAME"):
            evalname = True
            this = self._parse_bitwise()
        else:
            evalname = None
            self._match_text_seq("NAME")
            this = self._parse_id_var()

        return self.expression(
            exp.XMLElement(
                this=this,
                expressions=self._match(TokenType.COMMA) and self._parse_csv(self._parse_bitwise),
                evalname=evalname,
            )
        )

    def _parse_xml_table(self) -> exp.XMLTable:
        namespaces = None
        passing = None
        columns = None

        if self._match_text_seq("XMLNAMESPACES", "("):
            namespaces = self._parse_xml_namespace()
            self._match_text_seq(")", ",")

        this = self._parse_string()

        if self._match_text_seq("PASSING"):
            # The BY VALUE keywords are optional and are provided for semantic clarity
            self._match_text_seq("BY", "VALUE")
            passing = self._parse_csv(self._parse_column)

        by_ref = self._match_text_seq("RETURNING", "SEQUENCE", "BY", "REF")

        if self._match_text_seq("COLUMNS"):
            columns = self._parse_csv(self._parse_field_def)

        return self.expression(
            exp.XMLTable(
                this=this, namespaces=namespaces, passing=passing, columns=columns, by_ref=by_ref
            )
        )

    def _parse_xml_namespace(self) -> list[exp.XMLNamespace]:
        namespaces = []

        while True:
            if self._match(TokenType.DEFAULT):
                uri = self._parse_string()
            else:
                uri = self._parse_alias(self._parse_string())
            namespaces.append(self.expression(exp.XMLNamespace(this=uri)))
            if not self._match(TokenType.COMMA):
                break

        return namespaces

    def _parse_decode(self) -> exp.Decode | exp.DecodeCase | None:
        args = self._parse_csv(self._parse_disjunction)

        if len(args) < 3:
            return self.expression(exp.Decode(this=seq_get(args, 0), charset=seq_get(args, 1)))

        return self.expression(exp.DecodeCase(expressions=args))

    def _parse_json_key_value(self) -> exp.JSONKeyValue | None:
        self._match_text_seq("KEY")
        key = self._parse_column()
        self._match_set(self.JSON_KEY_VALUE_SEPARATOR_TOKENS)
        self._match_text_seq("VALUE")
        value = self._parse_bitwise()

        if not key and not value:
            return None
        return self.expression(exp.JSONKeyValue(this=key, expression=value))

    def _parse_format_json(self, this: exp.Expr | None) -> exp.Expr | None:
        if not this or not self._match_text_seq("FORMAT", "JSON"):
            return this

        return self.expression(exp.FormatJson(this=this))

    def _parse_on_condition(self) -> exp.OnCondition | None:
        # MySQL uses "X ON EMPTY Y ON ERROR" (e.g. JSON_VALUE) while Oracle uses the opposite (e.g. JSON_EXISTS)
        if self.dialect.ON_CONDITION_EMPTY_BEFORE_ERROR:
            empty = self._parse_on_handling("EMPTY", *self.ON_CONDITION_TOKENS)
            error = self._parse_on_handling("ERROR", *self.ON_CONDITION_TOKENS)
        else:
            error = self._parse_on_handling("ERROR", *self.ON_CONDITION_TOKENS)
            empty = self._parse_on_handling("EMPTY", *self.ON_CONDITION_TOKENS)

        null = self._parse_on_handling("NULL", *self.ON_CONDITION_TOKENS)

        if not empty and not error and not null:
            return None

        return self.expression(exp.OnCondition(empty=empty, error=error, null=null))

    def _parse_on_handling(self, on: str, *values: str) -> str | None | exp.Expr | None:
        # Parses the "X ON Y" or "DEFAULT <expr> ON Y syntax, e.g. NULL ON NULL (Oracle, T-SQL, MySQL)
        for value in values:
            if self._match_text_seq(value, "ON", on):
                return f"{value} ON {on}"

        index = self._index
        if self._match(TokenType.DEFAULT):
            default_value = self._parse_bitwise()
            if self._match_text_seq("ON", on):
                return default_value

            self._retreat(index)

        return None

    @t.overload
    def _parse_json_object(self, agg: t.Literal[False]) -> exp.JSONObject: ...

    @t.overload
    def _parse_json_object(self, agg: t.Literal[True]) -> exp.JSONObjectAgg: ...

    def _parse_json_object(self, agg=False):
        star = self._parse_star()
        expressions = (
            [star]
            if star
            else self._parse_csv(lambda: self._parse_format_json(self._parse_json_key_value()))
        )
        null_handling = self._parse_on_handling("NULL", "NULL", "ABSENT")

        unique_keys = None
        if self._match_text_seq("WITH", "UNIQUE"):
            unique_keys = True
        elif self._match_text_seq("WITHOUT", "UNIQUE"):
            unique_keys = False

        self._match_text_seq("KEYS")

        return_type = self._match_text_seq("RETURNING") and self._parse_format_json(
            self._parse_type()
        )
        encoding = self._match_text_seq("ENCODING") and self._parse_var()

        return self.expression(
            (exp.JSONObjectAgg if agg else exp.JSONObject)(
                expressions=expressions,
                null_handling=null_handling,
                unique_keys=unique_keys,
                return_type=return_type,
                encoding=encoding,
            )
        )

    # Note: this is currently incomplete; it only implements the "JSON_value_column" part
    def _parse_json_column_def(self) -> exp.JSONColumnDef:
        if not self._match_text_seq("NESTED"):
            this = self._parse_id_var()
            ordinality = self._match_pair(TokenType.FOR, TokenType.ORDINALITY)
            kind = self._parse_types(allow_identifiers=False)
            nested = None
        else:
            this = None
            ordinality = None
            kind = None
            nested = True

        format_json = self._match_text_seq("FORMAT", "JSON")
        path = self._match_text_seq("PATH") and self._parse_string()
        nested_schema = nested and self._parse_json_schema()

        return self.expression(
            exp.JSONColumnDef(
                this=this,
                kind=kind,
                path=path,
                nested_schema=nested_schema,
                ordinality=ordinality,
                format_json=format_json,
            )
        )

    def _parse_json_schema(self) -> exp.JSONSchema:
        self._match_text_seq("COLUMNS")
        return self.expression(
            exp.JSONSchema(
                expressions=self._parse_wrapped_csv(self._parse_json_column_def, optional=True)
            )
        )

    def _parse_json_table(self) -> exp.JSONTable:
        this = self._parse_format_json(self._parse_bitwise())
        path = self._match(TokenType.COMMA) and self._parse_string()
        error_handling = self._parse_on_handling("ERROR", "ERROR", "NULL")
        empty_handling = self._parse_on_handling("EMPTY", "ERROR", "NULL")
        schema = self._parse_json_schema()

        return exp.JSONTable(
            this=this,
            schema=schema,
            path=path,
            error_handling=error_handling,
            empty_handling=empty_handling,
        )

    def _parse_match_against(self) -> exp.MatchAgainst:
        if self._match_text_seq("TABLE"):
            # parse SingleStore MATCH(TABLE ...) syntax
            # https://docs.singlestore.com/cloud/reference/sql-reference/full-text-search-functions/match/
            expressions = []
            table = self._parse_table()
            if table:
                expressions = [table]
        else:
            expressions = self._parse_csv(self._parse_column)

        self._match_text_seq(")", "AGAINST", "(")

        this = self._parse_string()

        if self._match_text_seq("IN", "NATURAL", "LANGUAGE", "MODE"):
            modifier = "IN NATURAL LANGUAGE MODE"
            if self._match_text_seq("WITH", "QUERY", "EXPANSION"):
                modifier = f"{modifier} WITH QUERY EXPANSION"
        elif self._match_text_seq("IN", "BOOLEAN", "MODE"):
            modifier = "IN BOOLEAN MODE"
        elif self._match_text_seq("WITH", "QUERY", "EXPANSION"):
            modifier = "WITH QUERY EXPANSION"
        else:
            modifier = None

        return self.expression(
            exp.MatchAgainst(this=this, expressions=expressions, modifier=modifier)
        )

    # https://learn.microsoft.com/en-us/sql/t-sql/functions/openjson-transact-sql?view=sql-server-ver16
    def _parse_open_json(self) -> exp.OpenJSON:
        this = self._parse_bitwise()
        path = self._match(TokenType.COMMA) and self._parse_string()

        def _parse_open_json_column_def() -> exp.OpenJSONColumnDef:
            this = self._parse_field(any_token=True)
            kind = self._parse_types()
            path = self._parse_string()
            as_json = self._match_pair(TokenType.ALIAS, TokenType.JSON)

            return self.expression(
                exp.OpenJSONColumnDef(this=this, kind=kind, path=path, as_json=as_json)
            )

        expressions = None
        if self._match_pair(TokenType.R_PAREN, TokenType.WITH):
            self._match_l_paren()
            expressions = self._parse_csv(_parse_open_json_column_def)

        return self.expression(exp.OpenJSON(this=this, path=path, expressions=expressions))

    def _parse_position(self, haystack_first: bool = False) -> exp.StrPosition:
        args = self._parse_csv(self._parse_bitwise)

        if self._match(TokenType.IN):
            return self.expression(
                exp.StrPosition(this=self._parse_bitwise(), substr=seq_get(args, 0))
            )

        if haystack_first:
            haystack = seq_get(args, 0)
            needle = seq_get(args, 1)
        else:
            haystack = seq_get(args, 1)
            needle = seq_get(args, 0)

        return self.expression(
            exp.StrPosition(this=haystack, substr=needle, position=seq_get(args, 2))
        )

    def _parse_join_hint(self, func_name: str) -> exp.JoinHint:
        args = self._parse_csv(self._parse_table)
        return exp.JoinHint(this=func_name.upper(), expressions=args)

    def _parse_substring(self) -> exp.Substring:
        # Postgres supports the form: substring(string [from int] [for int])
        # (despite being undocumented, the reverse order also works)
        # https://www.postgresql.org/docs/9.1/functions-string.html @ Table 9-6

        args = t.cast(list[t.Optional[exp.Expr]], self._parse_csv(self._parse_bitwise))

        start, length = None, None

        while self._curr:
            if self._match(TokenType.FROM):
                start = self._parse_bitwise()
            elif self._match(TokenType.FOR):
                if not start:
                    start = exp.Literal.number(1)
                length = self._parse_bitwise()
            else:
                break

        if start:
            args.append(start)
        if length:
            args.append(length)

        return self.validate_expression(exp.Substring.from_arg_list(args), args)

    def _parse_trim(self) -> exp.Trim:
        # https://www.w3resource.com/sql/character-functions/trim.php
        # https://docs.oracle.com/javadb/10.8.3.0/ref/rreftrimfunc.html

        position = None
        collation = None
        expression = None

        if self._match_texts(self.TRIM_TYPES):
            position = self._prev.text.upper()

        this = self._parse_bitwise()
        if self._match_set((TokenType.FROM, TokenType.COMMA)):
            invert_order = self._prev.token_type == TokenType.FROM or self.TRIM_PATTERN_FIRST
            expression = self._parse_bitwise()

            if invert_order:
                this, expression = expression, this

        if self._match(TokenType.COLLATE):
            collation = self._parse_bitwise()

        return self.expression(
            exp.Trim(this=this, position=position, expression=expression, collation=collation)
        )

    def _parse_window_clause(self) -> list[exp.Expr] | None:
        return self._parse_csv(self._parse_named_window) if self._match(TokenType.WINDOW) else None

    def _parse_named_window(self) -> exp.Expr | None:
        return self._parse_window(self._parse_id_var(), alias=True)

    def _parse_respect_or_ignore_nulls(self, this: exp.Expr | None) -> exp.Expr | None:
        if self._curr.token_type == TokenType.VAR:
            if self._match_text_seq("IGNORE", "NULLS"):
                return self.expression(exp.IgnoreNulls(this=this))
            if self._match_text_seq("RESPECT", "NULLS"):
                return self.expression(exp.RespectNulls(this=this))
        return this

    def _parse_having_max(self, this: exp.Expr | None) -> exp.Expr | None:
        if self._match(TokenType.HAVING):
            self._match_texts(("MAX", "MIN"))
            max = self._prev.text.upper() != "MIN"
            return self.expression(
                exp.HavingMax(this=this, expression=self._parse_column(), max=max)
            )

        return this

    def _parse_window(self, this: exp.Expr | None, alias: bool = False) -> exp.Expr | None:
        func = this
        comments = func.comments if isinstance(func, exp.Expr) else None

        # T-SQL allows the OVER (...) syntax after WITHIN GROUP.
        # https://learn.microsoft.com/en-us/sql/t-sql/functions/percentile-disc-transact-sql?view=sql-server-ver16
        if self._match_text_seq("WITHIN", "GROUP"):
            order = self._parse_wrapped(self._parse_order)
            this = self.expression(exp.WithinGroup(this=this, expression=order))

        if self._match_pair(TokenType.FILTER, TokenType.L_PAREN):
            self._match(TokenType.WHERE)
            this = self.expression(
                exp.Filter(this=this, expression=self._parse_where(skip_where_token=True))
            )
            self._match_r_paren()

        # SQL spec defines an optional [ { IGNORE | RESPECT } NULLS ] OVER
        # Some dialects choose to implement and some do not.
        # https://dev.mysql.com/doc/refman/8.0/en/window-function-descriptions.html

        # There is some code above in _parse_lambda that handles
        #   SELECT FIRST_VALUE(TABLE.COLUMN IGNORE|RESPECT NULLS) OVER ...

        # The below changes handle
        #   SELECT FIRST_VALUE(TABLE.COLUMN) IGNORE|RESPECT NULLS OVER ...

        # Oracle allows both formats
        #   (https://docs.oracle.com/en/database/oracle/oracle-database/19/sqlrf/img_text/first_value.html)
        #   and Snowflake chose to do the same for familiarity
        #   https://docs.snowflake.com/en/sql-reference/functions/first_value.html#usage-notes
        if isinstance(this, exp.AggFunc):
            ignore_respect = find_in_scope(this, exp.IgnoreNulls, exp.RespectNulls)

            if ignore_respect and ignore_respect is not this:
                ignore_respect.replace(ignore_respect.this)
                this = self.expression(ignore_respect.__class__(this=this))

        this = self._parse_respect_or_ignore_nulls(this)

        # bigquery select from window x AS (partition by ...)
        if alias:
            over = None
            self._match(TokenType.ALIAS)
        elif not self._match_set(self.WINDOW_BEFORE_PAREN_TOKENS):
            return this
        else:
            over = self._prev.text.upper()

        if comments and isinstance(func, exp.Expr):
            func.pop_comments()

        if not self._match(TokenType.L_PAREN):
            return self.expression(
                exp.Window(this=this, alias=self._parse_id_var(False), over=over), comments=comments
            )

        window_alias = self._parse_id_var(any_token=False, tokens=self.WINDOW_ALIAS_TOKENS)

        first: bool | None = True if self._match(TokenType.FIRST) else None
        if self._match_text_seq("LAST"):
            first = False

        partition, order = self._parse_partition_and_order()
        kind = (
            self._match_set((TokenType.ROWS, TokenType.RANGE)) or self._match_text_seq("GROUPS")
        ) and self._prev.text

        if kind:
            self._match(TokenType.BETWEEN)
            start = self._parse_window_spec()

            end = self._parse_window_spec() if self._match(TokenType.AND) else {}
            exclude = (
                self._parse_var_from_options(self.WINDOW_EXCLUDE_OPTIONS)
                if self._match_text_seq("EXCLUDE")
                else None
            )

            spec = self.expression(
                exp.WindowSpec(
                    kind=kind,
                    start=start["value"],
                    start_side=start["side"],
                    end=end.get("value"),
                    end_side=end.get("side"),
                    exclude=exclude,
                )
            )
        else:
            spec = None

        self._match_r_paren()

        window = self.expression(
            exp.Window(
                this=this,
                partition_by=partition,
                order=order,
                spec=spec,
                alias=window_alias,
                over=over,
                first=first,
            ),
            comments=comments,
        )

        # This covers Oracle's FIRST/LAST syntax: aggregate KEEP (...) OVER (...)
        if self._match_set(self.WINDOW_BEFORE_PAREN_TOKENS, advance=False):
            return self._parse_window(window, alias=alias)

        return window

    def _parse_partition_and_order(
        self,
    ) -> tuple[list[exp.Expr], exp.Expr | None]:
        return self._parse_partition_by(), self._parse_order()

    def _parse_window_spec(self) -> dict[str, str | exp.Expr | None]:
        self._match(TokenType.BETWEEN)

        return {
            "value": (
                (self._match_text_seq("UNBOUNDED") and "UNBOUNDED")
                or (self._match_text_seq("CURRENT", "ROW") and "CURRENT ROW")
                or self._parse_bitwise()
            ),
            "side": self._prev.text if self._match_texts(self.WINDOW_SIDES) else None,
        }

    def _parse_alias(self, this: exp.Expr | None, explicit: bool = False) -> exp.Expr | None:
        # In some dialects, LIMIT and OFFSET can act as both identifiers and keywords (clauses)
        # so this section tries to parse the clause version and if it fails, it treats the token
        # as an identifier (alias)
        if self._can_parse_limit_or_offset():
            return this

        # WINDOW is in ID_VAR_TOKENS, so it can be consumed as an implicit alias. Detect the
        # named-window clause shape (`WINDOW <ident> AS (...)`) and avoid swallowing it.
        if self._can_parse_named_window():
            return this

        any_token = self._match(TokenType.ALIAS)
        comments = self._prev_comments

        if explicit and not any_token:
            return this

        if self._match(TokenType.L_PAREN):
            aliases = self.expression(
                exp.Aliases(
                    this=this, expressions=self._parse_csv(lambda: self._parse_id_var(any_token))
                ),
                comments=comments,
            )
            self._match_r_paren(aliases)
            return aliases

        alias = self._parse_id_var(any_token, tokens=self.ALIAS_TOKENS) or (
            self.STRING_ALIASES and self._parse_string_as_identifier()
        )

        if alias:
            comments.extend(alias.pop_comments())
            this = self.expression(exp.Alias(this=this, alias=alias), comments=comments)
            column = this.this

            # Moves the comment next to the alias in `expr /* comment */ AS alias`
            if not this.comments and column and column.comments:
                this.comments = column.pop_comments()

        return this

    def _parse_id_var(
        self,
        any_token: bool = True,
        tokens: t.Collection[TokenType] | None = None,
    ) -> exp.Expr | None:
        expression = self._parse_identifier()
        if not expression and (
            (any_token and self._advance_any()) or self._match_set(tokens or self.ID_VAR_TOKENS)
        ):
            quoted = self._prev.token_type == TokenType.STRING
            expression = self._identifier_expression(quoted=quoted)

        return expression

    def _parse_string(self) -> exp.Expr | None:
        if self._match_set(self.STRING_PARSERS):
            return self.STRING_PARSERS[self._prev.token_type](self, self._prev)
        return self._parse_placeholder()

    def _parse_string_as_identifier(self) -> exp.Identifier | None:
        if not self._match(TokenType.STRING):
            return None
        output = exp.to_identifier(self._prev.text, quoted=True)
        output.update_positions(self._prev)
        return output

    def _parse_number(self) -> exp.Expr | None:
        if self._match_set(self.NUMERIC_PARSERS):
            return self.NUMERIC_PARSERS[self._prev.token_type](self, self._prev)
        return self._parse_placeholder()

    def _parse_identifier(self) -> exp.Expr | None:
        if self._match(TokenType.IDENTIFIER):
            return self._identifier_expression(quoted=True)
        return self._parse_placeholder()

    def _parse_var(
        self,
        any_token: bool = False,
        tokens: t.Collection[TokenType] | None = None,
        upper: bool = False,
    ) -> exp.Expr | None:
        if (
            (any_token and self._advance_any())
            or self._match(TokenType.VAR)
            or (self._match_set(tokens) if tokens else False)
        ):
            return self.expression(
                exp.Var(this=self._prev.text.upper() if upper else self._prev.text)
            )
        return self._parse_placeholder()

    def _advance_any(self, ignore_reserved: bool = False) -> Token | None:
        if self._curr and (ignore_reserved or self._curr.token_type not in self.RESERVED_TOKENS):
            self._advance()
            return self._prev
        return None

    def _parse_var_or_string(self, upper: bool = False) -> exp.Expr | None:
        return self._parse_string() or self._parse_var(any_token=True, upper=upper)

    def _parse_primary_or_var(self) -> exp.Expr | None:
        return self._parse_primary() or self._parse_var(any_token=True)

    def _parse_null(self) -> exp.Expr | None:
        if self._match_set((TokenType.NULL, TokenType.UNKNOWN)):
            return self.PRIMARY_PARSERS[TokenType.NULL](self, self._prev)
        return self._parse_placeholder()

    def _parse_boolean(self) -> exp.Expr | None:
        if self._match(TokenType.TRUE):
            return self.PRIMARY_PARSERS[TokenType.TRUE](self, self._prev)
        if self._match(TokenType.FALSE):
            return self.PRIMARY_PARSERS[TokenType.FALSE](self, self._prev)
        return self._parse_placeholder()

    def _parse_star(self) -> exp.Expr | None:
        if self._match(TokenType.STAR):
            return self.PRIMARY_PARSERS[TokenType.STAR](self, self._prev)
        return self._parse_placeholder()

    def _parse_parameter(self) -> exp.Parameter:
        this = self._parse_identifier() or self._parse_primary_or_var()
        return self.expression(exp.Parameter(this=this))

    def _parse_placeholder(self) -> exp.Expr | None:
        if self._match_set(self.PLACEHOLDER_PARSERS):
            placeholder = self.PLACEHOLDER_PARSERS[self._prev.token_type](self)
            if placeholder:
                return placeholder
            self._advance(-1)
        return None

    def _parse_star_op(self, *keywords: str) -> list[exp.Expr] | None:
        if not self._match_texts(keywords):
            return None
        if self._match(TokenType.L_PAREN, advance=False):
            return self._parse_wrapped_csv(self._parse_expression)

        expression = self._parse_alias(self._parse_disjunction(), explicit=True)
        return [expression] if expression else None

    def _parse_csv(
        self, parse_method: t.Callable[[], T | None], sep: TokenType = TokenType.COMMA
    ) -> list[T]:
        parse_result = parse_method()
        items = [parse_result] if parse_result is not None else []

        while self._match(sep):
            if isinstance(parse_result, exp.Expr):
                self._add_comments(parse_result)
            parse_result = parse_method()
            if parse_result is not None:
                items.append(parse_result)

        return items

    def _parse_wrapped_id_vars(self, optional: bool = False) -> list[exp.Expr]:
        return self._parse_wrapped_csv(self._parse_id_var, optional=optional)

    def _parse_wrapped_csv(
        self,
        parse_method: t.Callable[[], T | None],
        sep: TokenType = TokenType.COMMA,
        optional: bool = False,
    ) -> list[T]:
        return self._parse_wrapped(
            lambda: self._parse_csv(parse_method, sep=sep), optional=optional
        )

    def _parse_wrapped(self, parse_method: t.Callable[[], T], optional: bool = False) -> T:
        wrapped = self._match(TokenType.L_PAREN)
        if not wrapped and not optional:
            self.raise_error("Expecting (")
        parse_result = parse_method()
        if wrapped:
            self._match_r_paren()
        return parse_result

    def _parse_expressions(self) -> list[exp.Expr]:
        return self._parse_csv(self._parse_expression)

    def _parse_select_or_expression(self, alias: bool = False) -> exp.Expr | None:
        return (
            self._parse_set_operations(
                self._parse_alias(self._parse_assignment(), explicit=True)
                if alias
                else self._parse_assignment()
            )
            or self._parse_select()
        )

    def _parse_ddl_select(self) -> exp.Expr | None:
        return self._parse_query_modifiers(
            self._parse_set_operations(self._parse_select(nested=True, parse_subquery_alias=False))
        )

    def _parse_transaction(self) -> exp.Transaction | exp.Command:
        this = None
        if self._match_texts(self.TRANSACTION_KIND):
            this = self._prev.text

        self._match_texts(("TRANSACTION", "WORK"))

        modes = []
        while True:
            mode = []
            while self._match(TokenType.VAR) or self._match(TokenType.NOT):
                mode.append(self._prev.text)

            if mode:
                modes.append(" ".join(mode))
            if not self._match(TokenType.COMMA):
                break

        return self.expression(exp.Transaction(this=this, modes=modes))

    def _parse_commit_or_rollback(self) -> exp.Commit | exp.Rollback:
        chain = None
        savepoint = None
        is_rollback = self._prev.token_type == TokenType.ROLLBACK

        self._match_texts(("TRANSACTION", "WORK"))

        if self._match_text_seq("TO"):
            self._match_text_seq("SAVEPOINT")
            savepoint = self._parse_id_var()

        if self._match(TokenType.AND):
            chain = not self._match_text_seq("NO")
            self._match_text_seq("CHAIN")

        if is_rollback:
            return self.expression(exp.Rollback(savepoint=savepoint))

        return self.expression(exp.Commit(chain=chain))

    def _parse_refresh(self) -> exp.Refresh | exp.Command:
        if self._match_text_seq("EXTERNAL", "TABLE"):
            kind = "EXTERNAL TABLE"
        elif self._match(TokenType.TABLE):
            kind = "TABLE"
        elif self._match_text_seq("MATERIALIZED", "VIEW"):
            kind = "MATERIALIZED VIEW"
        else:
            kind = ""

        this = self._parse_string() or self._parse_table()
        if not kind and not isinstance(this, exp.Literal):
            return self._parse_as_command(self._prev)

        return self.expression(exp.Refresh(this=this, kind=kind))

    def _parse_column_def_with_exists(self):
        start = self._index
        self._match(TokenType.COLUMN)

        exists_column = self._parse_exists(not_=True)
        expression = self._parse_field_def()

        if not isinstance(expression, exp.ColumnDef):
            self._retreat(start)
            return None

        expression.set("exists", exists_column)

        return expression

    def _parse_add_column(self) -> exp.ColumnDef | None:
        if not self._prev.text.upper() == "ADD":
            return None

        return self._parse_column_def_with_exists()

    def _parse_drop_column(self) -> exp.Drop | exp.Command | None:
        drop = self._parse_drop() if self._match(TokenType.DROP) else None
        if drop and not isinstance(drop, exp.Command):
            drop.set("kind", drop.args.get("kind", "COLUMN"))
        return drop

    def _parse_alter_drop_action(self) -> exp.Expr | None:
        return self._parse_drop_column()

    # https://docs.aws.amazon.com/athena/latest/ug/alter-table-drop-partition.html
    def _parse_drop_partition(self, exists: bool | None = None) -> exp.DropPartition:
        return self.expression(
            exp.DropPartition(expressions=self._parse_csv(self._parse_partition), exists=exists)
        )

    def _parse_alter_table_add(self) -> list[exp.Expr]:
        def _parse_add_alteration() -> exp.Expr | None:
            self._match_text_seq("ADD")
            if self._match_set(self.ADD_CONSTRAINT_TOKENS, advance=False):
                return self.expression(
                    exp.AddConstraint(expressions=self._parse_csv(self._parse_constraint))
                )

            column_def = self._parse_add_column()
            if isinstance(column_def, exp.ColumnDef):
                return column_def

            exists = self._parse_exists(not_=True)
            if self._match_pair(TokenType.PARTITION, TokenType.L_PAREN, advance=False):
                return self.expression(
                    exp.AddPartition(
                        exists=exists,
                        this=self._parse_field(any_token=True),
                        location=self._match_text_seq("LOCATION", advance=False)
                        and self._parse_property(),
                    )
                )

            return None

        if not self._match_set(self.ADD_CONSTRAINT_TOKENS, advance=False) and (
            not self.dialect.ALTER_TABLE_ADD_REQUIRED_FOR_EACH_COLUMN
            or self._match_text_seq("COLUMNS")
        ):
            schema = self._parse_schema()

            return (
                ensure_list(schema)
                if schema
                else self._parse_csv(self._parse_column_def_with_exists)
            )

        return self._parse_csv(_parse_add_alteration)

    def _parse_alter_table_alter(self) -> exp.Expr | None:
        if self._match_texts(self.ALTER_ALTER_PARSERS):
            return self.ALTER_ALTER_PARSERS[self._prev.text.upper()](self)

        # Many dialects support the ALTER [COLUMN] syntax, so if there is no
        # keyword after ALTER we default to parsing this statement
        self._match(TokenType.COLUMN)
        column = self._parse_field(any_token=True)

        if self._match_pair(TokenType.DROP, TokenType.DEFAULT):
            return self.expression(exp.AlterColumn(this=column, drop=True))
        if self._match_pair(TokenType.SET, TokenType.DEFAULT):
            return self.expression(exp.AlterColumn(this=column, default=self._parse_disjunction()))
        if self._match(TokenType.COMMENT):
            return self.expression(exp.AlterColumn(this=column, comment=self._parse_string()))
        if self._match_text_seq("DROP", "NOT", "NULL"):
            return self.expression(exp.AlterColumn(this=column, drop=True, allow_null=True))
        if self._match_text_seq("SET", "NOT", "NULL"):
            return self.expression(exp.AlterColumn(this=column, allow_null=False))

        if self._match_text_seq("SET", "VISIBLE"):
            return self.expression(exp.AlterColumn(this=column, visible="VISIBLE"))
        if self._match_text_seq("SET", "INVISIBLE"):
            return self.expression(exp.AlterColumn(this=column, visible="INVISIBLE"))

        self._match_text_seq("SET", "DATA")
        self._match_text_seq("TYPE")
        return self.expression(
            exp.AlterColumn(
                this=column,
                dtype=self._parse_types(),
                collate=self._match(TokenType.COLLATE) and self._parse_term(),
                using=self._match(TokenType.USING) and self._parse_disjunction(),
            )
        )

    def _parse_alter_diststyle(self) -> exp.AlterDistStyle:
        if self._match_texts(("ALL", "EVEN", "AUTO")):
            return self.expression(exp.AlterDistStyle(this=exp.var(self._prev.text.upper())))

        self._match_text_seq("KEY", "DISTKEY")
        return self.expression(exp.AlterDistStyle(this=self._parse_column()))

    def _parse_alter_sortkey(self, compound: bool | None = None) -> exp.AlterSortKey:
        if compound:
            self._match_text_seq("SORTKEY")

        if self._match(TokenType.L_PAREN, advance=False):
            return self.expression(
                exp.AlterSortKey(expressions=self._parse_wrapped_id_vars(), compound=compound)
            )

        self._match_texts(("AUTO", "NONE"))
        return self.expression(
            exp.AlterSortKey(this=exp.var(self._prev.text.upper()), compound=compound)
        )

    def _parse_alter_table_drop(self) -> list[exp.Expr]:
        index = self._index - 1

        partition_exists = self._parse_exists()
        if self._match(TokenType.PARTITION, advance=False):
            return self._parse_csv(lambda: self._parse_drop_partition(exists=partition_exists))

        self._retreat(index)
        return self._parse_csv(self._parse_alter_drop_action)

    def _parse_alter_table_rename(self) -> exp.AlterRename | exp.RenameColumn | None:
        if self._match(TokenType.COLUMN) or (
            not self.ALTER_RENAME_REQUIRES_COLUMN and not self._match_text_seq("TO", advance=False)
        ):
            exists = self._parse_exists()
            old_column = self._parse_column()
            to = self._match_text_seq("TO")
            new_column = self._parse_column()

            if old_column is None or not to or new_column is None:
                return None

            return self.expression(exp.RenameColumn(this=old_column, to=new_column, exists=exists))

        self._match_text_seq("TO")
        return self.expression(exp.AlterRename(this=self._parse_table(schema=True)))

    def _parse_alter_table_set(self) -> exp.AlterSet:
        alter_set = self.expression(exp.AlterSet())

        if self._match(TokenType.L_PAREN, advance=False) or self._match_text_seq(
            "TABLE", "PROPERTIES"
        ):
            alter_set.set("expressions", self._parse_wrapped_csv(self._parse_assignment))
        elif self._match_text_seq("FILESTREAM_ON", advance=False):
            alter_set.set("expressions", [self._parse_assignment()])
        elif self._match_texts(("LOGGED", "UNLOGGED")):
            alter_set.set("option", exp.var(self._prev.text.upper()))
        elif self._match_text_seq("WITHOUT") and self._match_texts(("CLUSTER", "OIDS")):
            alter_set.set("option", exp.var(f"WITHOUT {self._prev.text.upper()}"))
        elif self._match_text_seq("LOCATION"):
            alter_set.set("location", self._parse_field())
        elif self._match_text_seq("ACCESS", "METHOD"):
            alter_set.set("access_method", self._parse_field())
        elif self._match_text_seq("TABLESPACE"):
            alter_set.set("tablespace", self._parse_field())
        elif self._match_text_seq("FILE", "FORMAT") or self._match_text_seq("FILEFORMAT"):
            alter_set.set("file_format", [self._parse_field()])
        elif self._match_text_seq("STAGE_FILE_FORMAT"):
            alter_set.set("file_format", self._parse_wrapped_options())
        elif self._match_text_seq("STAGE_COPY_OPTIONS"):
            alter_set.set("copy_options", self._parse_wrapped_options())
        elif self._match_text_seq("TAG") or self._match_text_seq("TAGS"):
            alter_set.set("tag", self._parse_csv(self._parse_assignment))
        else:
            if self._match_text_seq("SERDE"):
                alter_set.set("serde", self._parse_field())

            properties = self._parse_wrapped(self._parse_properties, optional=True)
            alter_set.set("expressions", [properties])

        return alter_set

    def _parse_alter_session(self) -> exp.AlterSession:
        """Parse ALTER SESSION SET/UNSET statements."""
        if self._match(TokenType.SET):
            expressions = self._parse_csv(lambda: self._parse_set_item_assignment())
            return self.expression(exp.AlterSession(expressions=expressions, unset=False))

        self._match_text_seq("UNSET")
        expressions = self._parse_csv(
            lambda: self.expression(exp.SetItem(this=self._parse_id_var(any_token=True)))
        )
        return self.expression(exp.AlterSession(expressions=expressions, unset=True))

    def _parse_alter(self) -> exp.Alter | exp.Command:
        start = self._prev

        iceberg = self._match_text_seq("ICEBERG")

        alter_token = self._match_set(self.ALTERABLES) and self._prev
        if not alter_token:
            return self._parse_as_command(start)
        if iceberg and alter_token.token_type != TokenType.TABLE:
            return self._parse_as_command(start)

        exists = self._parse_exists()
        only = self._match_text_seq("ONLY")

        if alter_token.token_type == TokenType.SESSION:
            this = None
            check = None
            cluster = None
        else:
            this = self._parse_table(schema=True, parse_partition=self.ALTER_TABLE_PARTITIONS)
            check = self._match_text_seq("WITH", "CHECK")
            cluster = self._parse_on_property() if self._match(TokenType.ON) else None

            if self._next:
                self._advance()

        parser = self.ALTER_PARSERS.get(self._prev.text.upper()) if self._prev else None
        if parser:
            actions = ensure_list(parser(self))
            not_valid = self._match_text_seq("NOT", "VALID")
            options = self._parse_csv(self._parse_property)
            cascade = self.dialect.ALTER_TABLE_SUPPORTS_CASCADE and self._match_text_seq("CASCADE")

            if not self._curr and actions:
                return self.expression(
                    exp.Alter(
                        this=this,
                        kind=alter_token.text.upper(),
                        exists=exists,
                        actions=actions,
                        only=only,
                        options=options,
                        cluster=cluster,
                        not_valid=not_valid,
                        check=check,
                        cascade=cascade,
                        iceberg=iceberg,
                    )
                )

        return self._parse_as_command(start)

    def _parse_analyze(self) -> exp.Analyze | exp.Command:
        start = self._prev
        # https://duckdb.org/docs/sql/statements/analyze
        if not self._curr:
            return self.expression(exp.Analyze())

        options = []
        while self._match_texts(self.ANALYZE_STYLES):
            if self._prev.text.upper() == "BUFFER_USAGE_LIMIT":
                options.append(f"BUFFER_USAGE_LIMIT {self._parse_number()}")
            else:
                options.append(self._prev.text.upper())

        this: exp.Expr | None = None
        inner_expression: exp.Expr | None = None

        kind = self._curr.text.upper() if self._curr else None

        if self._match(TokenType.TABLE) or self._match(TokenType.INDEX):
            this = self._parse_table_parts()
        elif self._match_text_seq("TABLES"):
            if self._match_set((TokenType.FROM, TokenType.IN)):
                kind = f"{kind} {self._prev.text.upper()}"
                this = self._parse_table(schema=True, is_db_reference=True)
        elif self._match_text_seq("DATABASE"):
            this = self._parse_table(schema=True, is_db_reference=True)
        elif self._match_text_seq("CLUSTER"):
            this = self._parse_table()
        # Try matching inner expr keywords before fallback to parse table.
        elif self._match_texts(self.ANALYZE_EXPRESSION_PARSERS):
            kind = None
            inner_expression = self.ANALYZE_EXPRESSION_PARSERS[self._prev.text.upper()](self)
        else:
            # Empty kind  https://prestodb.io/docs/current/sql/analyze.html
            kind = None
            this = self._parse_table_parts()

        partition = self._try_parse(self._parse_partition)
        if not partition and self._match_texts(self.PARTITION_KEYWORDS):
            return self._parse_as_command(start)

        # https://docs.starrocks.io/docs/sql-reference/sql-statements/cbo_stats/ANALYZE_TABLE/
        if self._match_text_seq("WITH", "SYNC", "MODE") or self._match_text_seq(
            "WITH", "ASYNC", "MODE"
        ):
            mode = f"WITH {self._tokens[self._index - 2].text.upper()} MODE"
        else:
            mode = None

        if self._match_texts(self.ANALYZE_EXPRESSION_PARSERS):
            inner_expression = self.ANALYZE_EXPRESSION_PARSERS[self._prev.text.upper()](self)

        properties = self._parse_properties()
        return self.expression(
            exp.Analyze(
                kind=kind,
                this=this,
                mode=mode,
                partition=partition,
                properties=properties,
                expression=inner_expression,
                options=options,
            )
        )

    # https://spark.apache.org/docs/3.5.1/sql-ref-syntax-aux-analyze-table.html
    def _parse_analyze_statistics(self) -> exp.AnalyzeStatistics:
        this = None
        kind = self._prev.text.upper()
        option = self._prev.text.upper() if self._match_text_seq("DELTA") else None
        expressions = []

        if not self._match_text_seq("STATISTICS"):
            self.raise_error("Expecting token STATISTICS")

        if self._match_text_seq("NOSCAN"):
            this = "NOSCAN"
        elif self._match(TokenType.FOR):
            if self._match_text_seq("ALL", "COLUMNS"):
                this = "FOR ALL COLUMNS"
            if self._match_text_seq("COLUMNS"):
                this = "FOR COLUMNS"
                expressions = self._parse_csv(self._parse_column_reference)
        elif self._match_text_seq("SAMPLE"):
            sample = self._parse_number()
            expressions = [
                self.expression(
                    exp.AnalyzeSample(
                        sample=sample,
                        kind=self._prev.text.upper() if self._match(TokenType.PERCENT) else None,
                    )
                )
            ]

        return self.expression(
            exp.AnalyzeStatistics(kind=kind, option=option, this=this, expressions=expressions)
        )

    # https://docs.oracle.com/en/database/oracle/oracle-database/21/sqlrf/ANALYZE.html
    def _parse_analyze_validate(self) -> exp.AnalyzeValidate:
        kind = None
        this = None
        expression: exp.Expr | None = None
        if self._match_text_seq("REF", "UPDATE"):
            kind = "REF"
            this = "UPDATE"
            if self._match_text_seq("SET", "DANGLING", "TO", "NULL"):
                this = "UPDATE SET DANGLING TO NULL"
        elif self._match_text_seq("STRUCTURE"):
            kind = "STRUCTURE"
            if self._match_text_seq("CASCADE", "FAST"):
                this = "CASCADE FAST"
            elif self._match_text_seq("CASCADE", "COMPLETE") and self._match_texts(
                ("ONLINE", "OFFLINE")
            ):
                this = f"CASCADE COMPLETE {self._prev.text.upper()}"
                expression = self._parse_into()

        return self.expression(exp.AnalyzeValidate(kind=kind, this=this, expression=expression))

    def _parse_analyze_columns(self) -> exp.AnalyzeColumns | None:
        this = self._prev.text.upper()
        if self._match_text_seq("COLUMNS"):
            return self.expression(exp.AnalyzeColumns(this=f"{this} {self._prev.text.upper()}"))
        return None

    def _parse_analyze_delete(self) -> exp.AnalyzeDelete | None:
        kind = self._prev.text.upper() if self._match_text_seq("SYSTEM") else None
        if self._match_text_seq("STATISTICS"):
            return self.expression(exp.AnalyzeDelete(kind=kind))
        return None

    def _parse_analyze_list(self) -> exp.AnalyzeListChainedRows | None:
        if self._match_text_seq("CHAINED", "ROWS"):
            return self.expression(exp.AnalyzeListChainedRows(expression=self._parse_into()))
        return None

    # https://dev.mysql.com/doc/refman/8.4/en/analyze-table.html
    def _parse_analyze_histogram(self) -> exp.AnalyzeHistogram:
        this = self._prev.text.upper()
        expression: exp.Expr | None = None
        expressions = []
        update_options = None

        if self._match_text_seq("HISTOGRAM", "ON"):
            expressions = self._parse_csv(self._parse_column_reference)
            with_expressions = []
            while self._match(TokenType.WITH):
                # https://docs.starrocks.io/docs/sql-reference/sql-statements/cbo_stats/ANALYZE_TABLE/
                if self._match_texts(("SYNC", "ASYNC")):
                    if self._match_text_seq("MODE", advance=False):
                        with_expressions.append(f"{self._prev.text.upper()} MODE")
                        self._advance()
                else:
                    buckets = self._parse_number()
                    if self._match_text_seq("BUCKETS"):
                        with_expressions.append(f"{buckets} BUCKETS")
            if with_expressions:
                expression = self.expression(exp.AnalyzeWith(expressions=with_expressions))

            if self._match_texts(("MANUAL", "AUTO")) and self._match(
                TokenType.UPDATE, advance=False
            ):
                update_options = self._prev.text.upper()
                self._advance()
            elif self._match_text_seq("USING", "DATA"):
                expression = self.expression(exp.UsingData(this=self._parse_string()))

        return self.expression(
            exp.AnalyzeHistogram(
                this=this,
                expressions=expressions,
                expression=expression,
                update_options=update_options,
            )
        )

    def _parse_merge(self) -> exp.Merge:
        self._match(TokenType.INTO)
        target = self._parse_table()

        if target and self._match(TokenType.ALIAS, advance=False):
            target.set("alias", self._parse_table_alias())

        self._match(TokenType.USING)
        using = self._parse_table()

        return self.expression(
            exp.Merge(
                this=target,
                using=using,
                on=self._match(TokenType.ON) and self._parse_disjunction(),
                using_cond=self._match(TokenType.USING) and self._parse_using_identifiers(),
                whens=self._parse_when_matched(),
                returning=self._parse_returning(),
            )
        )

    def _parse_when_matched(self) -> exp.Whens:
        whens = []

        while self._match(TokenType.WHEN):
            matched = not self._match(TokenType.NOT)
            self._match_text_seq("MATCHED")
            source = (
                False
                if self._match_text_seq("BY", "TARGET")
                else self._match_text_seq("BY", "SOURCE")
            )
            condition = self._parse_disjunction() if self._match(TokenType.AND) else None

            self._match(TokenType.THEN)

            if self._match(TokenType.INSERT):
                this = self._parse_star()
                if this:
                    then: exp.Expr | None = self.expression(exp.Insert(this=this))
                else:
                    then = self.expression(
                        exp.Insert(
                            this=exp.var("ROW")
                            if self._match_text_seq("ROW")
                            else self._parse_value(values=False),
                            expression=self._match_text_seq("VALUES") and self._parse_value(),
                            where=self._parse_where(),
                        )
                    )
            elif self._match(TokenType.UPDATE):
                expressions = self._parse_star()
                if expressions:
                    then = self.expression(exp.Update(expressions=expressions))
                else:
                    then = self.expression(
                        exp.Update(
                            expressions=self._match(TokenType.SET)
                            and self._parse_csv(self._parse_equality),
                            where=self._parse_where(),
                        )
                    )
            elif self._match(TokenType.DELETE):
                then = self.expression(exp.Var(this=self._prev.text))
            else:
                then = self._parse_var_from_options(self.CONFLICT_ACTIONS)

            whens.append(
                self.expression(
                    exp.When(matched=matched, source=source, condition=condition, then=then)
                )
            )
        return self.expression(exp.Whens(expressions=whens))

    def _parse_show(self) -> exp.Expr | None:
        parser = self._find_parser(self.SHOW_PARSERS, self.SHOW_TRIE)
        if parser:
            return parser(self)
        return self._parse_as_command(self._prev)

    def _parse_set_item_assignment(self, kind: str | None = None) -> exp.Expr | None:
        index = self._index

        if kind in ("GLOBAL", "SESSION") and self._match_text_seq("TRANSACTION"):
            return self._parse_set_transaction(global_=kind == "GLOBAL")

        left = self._parse_primary() or self._parse_column()
        assignment_delimiter = self._match_texts(self.SET_ASSIGNMENT_DELIMITERS)

        if not left or (self.SET_REQUIRES_ASSIGNMENT_DELIMITER and not assignment_delimiter):
            self._retreat(index)
            return None

        right = self._parse_statement() or self._parse_id_var()
        if isinstance(right, (exp.Column, exp.Identifier)):
            right = exp.var(right.name)

        this = self.expression(exp.EQ(this=left, expression=right))
        return self.expression(exp.SetItem(this=this, kind=kind))

    def _parse_set_transaction(self, global_: bool = False) -> exp.Expr:
        self._match_text_seq("TRANSACTION")
        characteristics = self._parse_csv(
            lambda: self._parse_var_from_options(self.TRANSACTION_CHARACTERISTICS)
        )
        return self.expression(
            exp.SetItem(expressions=characteristics, kind="TRANSACTION", global_=global_)
        )

    def _parse_set_item(self) -> exp.Expr | None:
        parser = self._find_parser(self.SET_PARSERS, self.SET_TRIE)
        return parser(self) if parser else self._parse_set_item_assignment(kind=None)

    def _parse_set(self, unset: bool = False, tag: bool = False) -> exp.Set | exp.Command:
        index = self._index
        set_ = self.expression(
            exp.Set(expressions=self._parse_csv(self._parse_set_item), unset=unset, tag=tag)
        )

        if self._curr:
            self._retreat(index)
            return self._parse_as_command(self._prev)

        return set_

    def _parse_var_from_options(
        self, options: OPTIONS_TYPE, raise_unmatched: bool = True
    ) -> exp.Var | None:
        start = self._curr
        if not start:
            return None

        option = start.text.upper()
        continuations = (
            None if start.token_type in self.TEXT_MATCH_EXCLUDED_TOKENS else options.get(option)
        )

        index = self._index
        self._advance()
        for keywords in continuations or []:
            if isinstance(keywords, str):
                keywords = (keywords,)

            if self._match_text_seq(*keywords):
                option = f"{option} {' '.join(keywords)}"
                break
        else:
            if continuations or continuations is None:
                if raise_unmatched:
                    self.raise_error(f"Unknown option {option}")

                self._retreat(index)
                return None

        return exp.var(option)

    def _parse_as_command(self, start: Token) -> exp.Command:
        while self._curr:
            self._advance()
        text = self._find_sql(start, self._prev)
        size = len(start.text)
        self._warn_unsupported()
        return exp.Command(this=text[:size], expression=text[size:])

    def _parse_dict_property(self, this: str) -> exp.DictProperty:
        settings = []

        self._match_l_paren()
        kind = self._parse_id_var()

        if self._match(TokenType.L_PAREN):
            while True:
                key = self._parse_id_var()
                value = self._parse_function() or self._parse_primary_or_var()
                if not key and value is None:
                    break
                settings.append(self.expression(exp.DictSubProperty(this=key, value=value)))
            self._match(TokenType.R_PAREN)

        self._match_r_paren()

        return self.expression(
            exp.DictProperty(this=this, kind=kind.this if kind else None, settings=settings)
        )

    def _parse_dict_range(self, this: str) -> exp.DictRange:
        self._match_l_paren()
        has_min = self._match_text_seq("MIN")
        if has_min:
            min = self._parse_var() or self._parse_primary()
            self._match_text_seq("MAX")
            max = self._parse_var() or self._parse_primary()
        else:
            max = self._parse_var() or self._parse_primary()
            min = exp.Literal.number(0)
        self._match_r_paren()
        return self.expression(exp.DictRange(this=this, min=min, max=max))

    def _parse_comprehension(self, this: exp.Expr | None) -> exp.Comprehension | None:
        index = self._index
        expression = self._parse_column()
        position = self._match(TokenType.COMMA) and self._parse_column()

        if not self._match(TokenType.IN):
            self._retreat(index - 1)
            return None
        iterator = self._parse_column()
        condition = self._parse_disjunction() if self._match_text_seq("IF") else None
        return self.expression(
            exp.Comprehension(
                this=this,
                expression=expression,
                position=position,
                iterator=iterator,
                condition=condition,
            )
        )

    def _parse_heredoc(self) -> exp.Heredoc | None:
        if self._match(TokenType.HEREDOC_STRING):
            return self.expression(exp.Heredoc(this=self._prev.text))

        if not self._match_text_seq("$"):
            return None

        tags = ["$"]
        tag_text = None

        if self._is_connected():
            self._advance()
            tags.append(self._prev.text.upper())
        else:
            self.raise_error("No closing $ found")

        if tags[-1] != "$":
            if self._is_connected() and self._match_text_seq("$"):
                tag_text = tags[-1]
                tags.append("$")
            else:
                self.raise_error("No closing $ found")

        heredoc_start = self._curr

        while self._curr:
            if self._match_text_seq(*tags, advance=False):
                this = self._find_sql(heredoc_start, self._prev)
                self._advance(len(tags))
                return self.expression(exp.Heredoc(this=this, tag=tag_text))

            self._advance()

        self.raise_error(f"No closing {''.join(tags)} found")
        return None

    def _find_parser(self, parsers: dict[str, t.Callable], trie: dict) -> t.Callable | None:
        if not self._curr:
            return None

        index = self._index
        this = []
        while True:
            # The current token might be multiple words
            curr = self._curr.text.upper()
            key = curr.split(" ")
            this.append(curr)

            self._advance()
            result, trie = in_trie(trie, key)
            if result == TrieResult.FAILED:
                break

            if result == TrieResult.EXISTS:
                subparser = parsers[" ".join(this)]
                return subparser

        self._retreat(index)
        return None

    def _match_l_paren(self, expression: exp.Expr | None = None) -> None:
        if not self._match(TokenType.L_PAREN, expression=expression):
            self.raise_error("Expecting (")

    def _match_r_paren(self, expression: exp.Expr | None = None) -> None:
        if not self._match(TokenType.R_PAREN, expression=expression):
            self.raise_error("Expecting )")

    def _replace_lambda(
        self, node: exp.Expr | None, expressions: list[exp.Expr]
    ) -> exp.Expr | None:
        if not node:
            return node

        lambda_types = {e.name: e.args.get("to") or False for e in expressions}

        for column in node.find_all(exp.Column):
            typ = lambda_types.get(column.parts[0].name)
            if typ is not None:
                dot_or_id = column.to_dot() if column.table else column.this

                if typ:
                    dot_or_id = self.expression(exp.Cast(this=dot_or_id, to=typ))

                parent = column.parent

                while isinstance(parent, exp.Dot):
                    if not isinstance(parent.parent, exp.Dot):
                        parent.replace(dot_or_id)
                        break
                    parent = parent.parent
                else:
                    if column is node:
                        node = dot_or_id
                    else:
                        column.replace(dot_or_id)
        return node

    def _parse_truncate_table(self) -> exp.TruncateTable | None | exp.Expr:
        start = self._prev

        # Not to be confused with TRUNCATE(number, decimals) function call
        if self._match(TokenType.L_PAREN):
            self._retreat(self._index - 2)
            return self._parse_function()

        # Clickhouse supports TRUNCATE DATABASE as well
        is_database = self._match(TokenType.DATABASE)

        self._match(TokenType.TABLE)

        exists = self._parse_exists(not_=False)

        expressions = self._parse_csv(
            lambda: self._parse_table(schema=True, is_db_reference=is_database)
        )

        cluster = self._parse_on_property() if self._match(TokenType.ON) else None

        if self._match_text_seq("RESTART", "IDENTITY"):
            identity = "RESTART"
        elif self._match_text_seq("CONTINUE", "IDENTITY"):
            identity = "CONTINUE"
        else:
            identity = None

        if self._match_text_seq("CASCADE") or self._match_text_seq("RESTRICT"):
            option = self._prev.text
        else:
            option = None

        partition = self._parse_partition()

        # Fallback case
        if self._curr:
            return self._parse_as_command(start)

        return self.expression(
            exp.TruncateTable(
                expressions=expressions,
                is_database=is_database,
                exists=exists,
                cluster=cluster,
                identity=identity,
                option=option,
                partition=partition,
            )
        )

    def _parse_indexed_column(self) -> exp.Expr | None:
        return self._parse_ordered(self._parse_opclass)

    def _parse_with_operator(self) -> exp.Expr | None:
        this = self._parse_indexed_column()

        if not self._match(TokenType.WITH):
            return this

        op = self._parse_var(any_token=True, tokens=self.RESERVED_TOKENS)

        return self.expression(exp.WithOperator(this=this, op=op))

    def _parse_wrapped_options(self) -> list[exp.Expr]:
        self._match(TokenType.EQ)
        self._match(TokenType.L_PAREN)

        opts: list[exp.Expr] = []
        option: exp.Expr | list[exp.Expr] | None
        while self._curr and not self._match(TokenType.R_PAREN):
            if self._match_text_seq("FORMAT_NAME", "="):
                # The FORMAT_NAME can be set to an identifier for Snowflake and T-SQL
                option = self._parse_format_name()
            else:
                option = self._parse_property()

            if option is None:
                self.raise_error("Unable to parse option")
                break

            opts.extend(ensure_list(option))

        return opts

    def _parse_copy_parameters(self) -> list[exp.CopyParameter]:
        sep = TokenType.COMMA if self.dialect.COPY_PARAMS_ARE_CSV else None

        options = []
        while self._curr and not self._match(TokenType.R_PAREN, advance=False):
            option = self._parse_var(any_token=True)
            prev = self._prev.text.upper()

            # Different dialects might separate options and values by white space, "=" and "AS"
            self._match(TokenType.EQ)
            self._match(TokenType.ALIAS)

            param = self.expression(exp.CopyParameter(this=option))

            if prev in self.COPY_INTO_VARLEN_OPTIONS and self._match(
                TokenType.L_PAREN, advance=False
            ):
                # Snowflake FILE_FORMAT case, Databricks COPY & FORMAT options
                param.set("expressions", self._parse_wrapped_options())
            elif prev == "FILE_FORMAT":
                # T-SQL's external file format case
                param.set("expression", self._parse_field())
            elif (
                prev == "FORMAT"
                and self._prev.token_type == TokenType.ALIAS
                and self._match_texts(("AVRO", "JSON"))
            ):
                param.set("this", exp.var(f"FORMAT AS {self._prev.text.upper()}"))
                param.set("expression", self._parse_field())
            else:
                param.set("expression", self._parse_unquoted_field() or self._parse_bracket())

            options.append(param)

            if sep:
                self._match(sep)

        return options

    def _parse_credentials(self) -> exp.Credentials | None:
        expr = self.expression(exp.Credentials())

        if self._match_text_seq("STORAGE_INTEGRATION", "="):
            expr.set("storage", self._parse_field())
        if self._match_text_seq("CREDENTIALS"):
            # Snowflake case: CREDENTIALS = (...), Redshift case: CREDENTIALS <string>
            creds = (
                self._parse_wrapped_options() if self._match(TokenType.EQ) else self._parse_field()
            )
            expr.set("credentials", creds)
        if self._match_text_seq("ENCRYPTION"):
            expr.set("encryption", self._parse_wrapped_options())
        if self._match_text_seq("IAM_ROLE"):
            expr.set(
                "iam_role",
                exp.var(self._prev.text) if self._match(TokenType.DEFAULT) else self._parse_field(),
            )
        if self._match_text_seq("REGION"):
            expr.set("region", self._parse_field())

        return expr

    def _parse_file_location(self) -> exp.Expr | None:
        return self._parse_field()

    def _parse_copy(self) -> exp.Copy | exp.Command:
        start = self._prev

        self._match(TokenType.INTO)

        this = (
            self._parse_select(nested=True, parse_subquery_alias=False)
            if self._match(TokenType.L_PAREN, advance=False)
            else self._parse_table(schema=True)
        )

        kind = self._match(TokenType.FROM) or not self._match_text_seq("TO")

        files = self._parse_csv(self._parse_file_location)
        if self._match(TokenType.EQ, advance=False):
            # Backtrack one token since we've consumed the lhs of a parameter assignment here.
            # This can happen for Snowflake dialect. Instead, we'd like to parse the parameter
            # list via `_parse_wrapped(..)` below.
            self._advance(-1)
            files = []

        credentials = self._parse_credentials()

        self._match_text_seq("WITH")

        params = self._parse_wrapped(self._parse_copy_parameters, optional=True)

        # Fallback case
        if self._curr:
            return self._parse_as_command(start)

        return self.expression(
            exp.Copy(this=this, kind=kind, credentials=credentials, files=files, params=params)
        )

    def _parse_normalize(self) -> exp.Normalize:
        return self.expression(
            exp.Normalize(
                this=self._parse_bitwise(), form=self._match(TokenType.COMMA) and self._parse_var()
            )
        )

    def _parse_ceil_floor(self, expr_type: type[TCeilFloor]) -> TCeilFloor:
        args = self._parse_csv(lambda: self._parse_lambda())

        this = seq_get(args, 0)
        decimals = seq_get(args, 1)

        return expr_type(
            this=this,
            decimals=decimals,
            to=self._parse_var() if self._match_text_seq("TO") else None,
        )

    def _parse_star_ops(self) -> exp.Expr | None:
        star_token = self._prev

        if self._match_text_seq("COLUMNS", "(", advance=False):
            this = self._parse_function()
            if isinstance(this, exp.Columns):
                this.set("unpack", True)
            return this

        index = self._index
        ilike = self._parse_string() if self._match(TokenType.ILIKE) else None
        if not ilike:
            # ILIKE without a string pattern is not a star filter, e.g. `* ILIKE (foo)`
            self._retreat(index)

        return self.expression(
            exp.Star(
                ilike=ilike,
                except_=self._parse_star_op("EXCEPT", "EXCLUDE"),
                replace=self._parse_star_op("REPLACE"),
                rename=self._parse_star_op("RENAME"),
            )
        ).update_positions(star_token)

    def _parse_grant_privilege(self) -> exp.GrantPrivilege | None:
        privilege_parts = []

        # Keep consuming consecutive keywords until comma (end of this privilege) or ON
        # (end of privilege list) or L_PAREN (start of column list) are met
        while self._curr and not self._match_set(self.PRIVILEGE_FOLLOW_TOKENS, advance=False):
            privilege_parts.append(self._curr.text.upper())
            self._advance()

        this = exp.var(" ".join(privilege_parts))
        expressions = (
            self._parse_wrapped_csv(self._parse_column)
            if self._match(TokenType.L_PAREN, advance=False)
            else None
        )

        return self.expression(exp.GrantPrivilege(this=this, expressions=expressions))

    def _parse_grant_principal(self) -> exp.GrantPrincipal | None:
        kind = self._match_texts(("ROLE", "GROUP")) and self._prev.text.upper()
        principal = self._parse_id_var()

        if not principal:
            return None

        return self.expression(exp.GrantPrincipal(this=principal, kind=kind))

    def _parse_grant_revoke_common(
        self,
    ) -> tuple[list | None, str | None, exp.Expr | None]:
        privileges = self._parse_csv(self._parse_grant_privilege)

        self._match(TokenType.ON)
        kind = self._prev.text.upper() if self._match_set(self.CREATABLES) else None

        # Attempt to parse the securable e.g. MySQL allows names
        # such as "foo.*", "*.*" which are not easily parseable yet
        securable = self._try_parse(self._parse_table_parts)

        return privileges, kind, securable

    def _parse_grant(self) -> exp.Grant | exp.Command:
        start = self._prev

        privileges, kind, securable = self._parse_grant_revoke_common()

        if not securable or not self._match_text_seq("TO"):
            return self._parse_as_command(start)

        principals = self._parse_csv(self._parse_grant_principal)

        grant_option = self._match_text_seq("WITH", "GRANT", "OPTION")

        if self._curr:
            return self._parse_as_command(start)

        return self.expression(
            exp.Grant(
                privileges=privileges,
                kind=kind,
                securable=securable,
                principals=principals,
                grant_option=grant_option,
            )
        )

    def _parse_revoke(self) -> exp.Revoke | exp.Command:
        start = self._prev

        grant_option = self._match_text_seq("GRANT", "OPTION", "FOR")

        privileges, kind, securable = self._parse_grant_revoke_common()

        if not securable or not self._match_text_seq("FROM"):
            return self._parse_as_command(start)

        principals = self._parse_csv(self._parse_grant_principal)

        cascade = None
        if self._match_texts(("CASCADE", "RESTRICT")):
            cascade = self._prev.text.upper()

        if self._curr:
            return self._parse_as_command(start)

        return self.expression(
            exp.Revoke(
                privileges=privileges,
                kind=kind,
                securable=securable,
                principals=principals,
                grant_option=grant_option,
                cascade=cascade,
            )
        )

    def _parse_overlay(self) -> exp.Overlay:
        def _parse_overlay_arg(text: str) -> exp.Expr | None:
            return (
                self._parse_bitwise()
                if self._match(TokenType.COMMA) or self._match_text_seq(text)
                else None
            )

        return self.expression(
            exp.Overlay(
                this=self._parse_bitwise(),
                expression=_parse_overlay_arg("PLACING"),
                from_=_parse_overlay_arg("FROM"),
                for_=_parse_overlay_arg("FOR"),
            )
        )

    def _parse_format_name(self) -> exp.Property:
        # Note: Although not specified in the docs, Snowflake does accept a string/identifier
        # for FILE_FORMAT = <format_name>
        return self.expression(
            exp.Property(
                this=exp.var("FORMAT_NAME"), value=self._parse_string() or self._parse_table_parts()
            )
        )

    def _parse_distinct_arg_function(self, func: type[F], distinct_index: int = 0) -> F:
        is_distinct = self._match(TokenType.DISTINCT)
        if not is_distinct:
            self._match(TokenType.ALL)

        args = [self._parse_lambda()]
        if self._match(TokenType.COMMA):
            args.extend(self._parse_function_args())

        target = seq_get(args, distinct_index)
        if is_distinct and target:
            args[distinct_index] = self.expression(exp.Distinct(expressions=[target]))

        return func.from_arg_list(args)

    def _identifier_expression(
        self, token: Token | None = None, quoted: bool | None = None
    ) -> exp.Identifier:
        token = token or self._prev
        return self.expression(exp.Identifier(this=token.text, quoted=quoted), token)

    def _build_pipe_cte(
        self,
        query: exp.Query,
        expressions: list[exp.Expr],
        alias_cte: exp.TableAlias | None = None,
    ) -> exp.Select:
        new_cte: str | exp.TableAlias | None
        if alias_cte:
            new_cte = alias_cte
        else:
            self._pipe_cte_counter += 1
            new_cte = f"__tmp{self._pipe_cte_counter}"

        with_ = query.args.get("with_")
        ctes = with_.pop() if with_ else None

        new_select = exp.select(*expressions, copy=False).from_(new_cte, copy=False)
        if ctes:
            new_select.set("with_", ctes)

        return new_select.with_(new_cte, as_=query, copy=False)

    def _parse_pipe_syntax_select(self, query: exp.Select) -> exp.Select:
        select = self._parse_select(consume_pipe=False)
        if not select:
            return query

        return self._build_pipe_cte(
            query=query.select(*select.expressions, append=False), expressions=[exp.Star()]
        )

    def _parse_pipe_syntax_limit(self, query: exp.Select) -> exp.Select:
        limit = self._parse_limit()
        offset = self._parse_offset()
        if limit:
            curr_limit = query.args.get("limit", limit)
            if curr_limit.expression.to_py() >= limit.expression.to_py():
                query.limit(limit, copy=False)
        if offset:
            curr_offset = query.args.get("offset")
            curr_offset = curr_offset.expression.to_py() if curr_offset else 0
            query.offset(exp.Literal.number(curr_offset + offset.expression.to_py()), copy=False)

        return query

    def _parse_pipe_syntax_aggregate_fields(self) -> exp.Expr | None:
        this = self._parse_disjunction()
        if self._match_text_seq("GROUP", "AND", advance=False):
            return this

        this = self._parse_alias(this)

        if self._match_set((TokenType.ASC, TokenType.DESC), advance=False):
            return self._parse_ordered(lambda: this)

        return this

    def _parse_pipe_syntax_aggregate_group_order_by(
        self, query: exp.Select, group_by_exists: bool = True
    ) -> exp.Select:
        expr = self._parse_csv(self._parse_pipe_syntax_aggregate_fields)
        aggregates_or_groups, orders = [], []
        for element in expr:
            if isinstance(element, exp.Ordered):
                this = element.this
                if isinstance(this, exp.Alias):
                    element.set("this", this.args["alias"])
                orders.append(element)
            else:
                this = element
            aggregates_or_groups.append(this)

        if group_by_exists:
            query.select(
                *aggregates_or_groups, *query.expressions, append=False, copy=False
            ).group_by(
                *[projection.args.get("alias", projection) for projection in aggregates_or_groups],
                copy=False,
            )
        else:
            query.select(*aggregates_or_groups, append=False, copy=False)

        if orders:
            return query.order_by(*orders, append=False, copy=False)

        return query

    def _parse_pipe_syntax_aggregate(self, query: exp.Select) -> exp.Select:
        self._match_text_seq("AGGREGATE")
        query = self._parse_pipe_syntax_aggregate_group_order_by(query, group_by_exists=False)

        if self._match(TokenType.GROUP_BY) or (
            self._match_text_seq("GROUP", "AND") and self._match(TokenType.ORDER_BY)
        ):
            query = self._parse_pipe_syntax_aggregate_group_order_by(query)

        return self._build_pipe_cte(query=query, expressions=[exp.Star()])

    def _parse_pipe_syntax_set_operator(self, query: exp.Query) -> exp.Query | None:
        first_setop = self.parse_set_operation(this=query)
        if not first_setop:
            return None

        def _parse_and_unwrap_query() -> exp.Expr | None:
            expr = self._parse_paren()
            return expr.assert_is(exp.Subquery).unnest() if expr else None

        first_setop.this.pop()

        setops = [
            first_setop.expression.pop().assert_is(exp.Subquery).unnest(),
            *self._parse_csv(_parse_and_unwrap_query),
        ]

        query = self._build_pipe_cte(query=query, expressions=[exp.Star()])
        with_ = query.args.get("with_")
        ctes = with_.pop() if with_ else None

        if isinstance(first_setop, exp.Union):
            query = query.union(*setops, copy=False, **first_setop.args)
        elif isinstance(first_setop, exp.Except):
            query = query.except_(*setops, copy=False, **first_setop.args)
        else:
            query = query.intersect(*setops, copy=False, **first_setop.args)

        query.set("with_", ctes)

        return self._build_pipe_cte(query=query, expressions=[exp.Star()])

    def _parse_pipe_syntax_join(self, query: exp.Query) -> exp.Query | None:
        join = self._parse_join()
        if not join:
            return None

        if isinstance(query, exp.Select):
            return query.join(join, copy=False)

        return query

    def _parse_pipe_syntax_pivot(self, query: exp.Select) -> exp.Select:
        pivots = self._parse_pivots()
        if not pivots:
            return query

        from_ = query.args.get("from_")
        if from_:
            from_.this.set("pivots", pivots)

        return self._build_pipe_cte(query=query, expressions=[exp.Star()])

    def _parse_pipe_syntax_extend(self, query: exp.Select) -> exp.Select:
        self._match_text_seq("EXTEND")
        query.select(*[exp.Star(), *self._parse_expressions()], append=False, copy=False)
        return self._build_pipe_cte(query=query, expressions=[exp.Star()])

    def _parse_pipe_syntax_tablesample(self, query: exp.Select) -> exp.Select:
        sample = self._parse_table_sample()

        with_ = query.args.get("with_")
        if with_:
            with_.expressions[-1].this.set("sample", sample)
        else:
            query.set("sample", sample)

        return query

    def _parse_pipe_syntax_query(self, query: exp.Query) -> exp.Query | None:
        if isinstance(query, exp.Subquery):
            query = exp.select("*").from_(query, copy=False)

        if not query.args.get("from_"):
            query = exp.select("*").from_(query.subquery(copy=False), copy=False)

        while self._match(TokenType.PIPE_GT):
            start_index = self._index
            start_text = self._curr.text.upper()
            parser = self.PIPE_SYNTAX_TRANSFORM_PARSERS.get(start_text)
            if not parser:
                # The set operators (UNION, etc) and the JOIN operator have a few common starting
                # keywords, making it tricky to disambiguate them without lookahead. The approach
                # here is to try and parse a set operation and if that fails, then try to parse a
                # join operator. If that fails as well, then the operator is not supported.
                parsed_query = self._parse_pipe_syntax_set_operator(query)
                parsed_query = parsed_query or self._parse_pipe_syntax_join(query)
                if not parsed_query:
                    self._retreat(start_index)
                    self.raise_error(f"Unsupported pipe syntax operator: '{start_text}'.")
                    break
                query = parsed_query
            else:
                query = parser(self, query)

        return query

    def _parse_declareitem(self) -> exp.DeclareItem | None:
        self._match_texts(("VAR", "VARIABLE"))

        vars = self._parse_csv(self._parse_id_var)
        if not vars:
            return None

        self._match(TokenType.ALIAS)
        kind = self._parse_schema() if self._match(TokenType.TABLE) else self._parse_types()
        default = (
            self._match(TokenType.DEFAULT) or self._match(TokenType.EQ)
        ) and self._parse_bitwise()

        return self.expression(exp.DeclareItem(this=vars, kind=kind, default=default))

    def _parse_declare(self) -> exp.Declare | exp.Command:
        start = self._prev
        replace = self._match_text_seq("OR", "REPLACE")
        expressions = self._try_parse(lambda: self._parse_csv(self._parse_declareitem))

        if not expressions or self._curr:
            return self._parse_as_command(start)

        return self.expression(exp.Declare(expressions=expressions, replace=replace))

    def build_cast(self, strict: bool, **kwargs) -> exp.Expr:
        exp_class = exp.Cast if strict else exp.TryCast

        if exp_class == exp.TryCast:
            kwargs["requires_string"] = self.dialect.TRY_CAST_REQUIRES_STRING

        return self.expression(exp_class(**kwargs))

    def _parse_json_value(self) -> exp.JSONValue:
        this = self._parse_bitwise()
        self._match(TokenType.COMMA)
        path = self._parse_bitwise()

        returning = self._match(TokenType.RETURNING) and self._parse_type()

        return self.expression(
            exp.JSONValue(
                this=this,
                path=self.dialect.to_json_path(path),
                returning=returning,
                on_condition=self._parse_on_condition(),
            )
        )

    def _parse_group_concat(self) -> exp.Expr | None:
        def concat_exprs(node: exp.Expr | None, exprs: list[exp.Expr]) -> exp.Expr:
            if isinstance(node, exp.Distinct) and len(node.expressions) > 1:
                concat_exprs = [
                    self.expression(
                        exp.Concat(
                            expressions=node.expressions,
                            safe=True,
                            coalesce=self.dialect.CONCAT_COALESCE,
                        )
                    )
                ]
                node.set("expressions", concat_exprs)
                return node
            if len(exprs) == 1:
                return exprs[0]
            return self.expression(
                exp.Concat(expressions=args, safe=True, coalesce=self.dialect.CONCAT_COALESCE)
            )

        args = self._parse_csv(self._parse_lambda)

        if args:
            order = args[-1] if isinstance(args[-1], exp.Order) else None

            if order:
                # Order By is the last (or only) expression in the list and has consumed the 'expr' before it,
                # remove 'expr' from exp.Order and add it back to args
                args[-1] = order.this
                order.set("this", concat_exprs(order.this, args))

            this = order or concat_exprs(args[0], args)
        else:
            this = None

        separator = self._parse_field() if self._match(TokenType.SEPARATOR) else None

        return self.expression(exp.GroupConcat(this=this, separator=separator))

    def _parse_initcap(self) -> exp.Initcap:
        expr = exp.Initcap.from_arg_list(self._parse_function_args())

        # attach dialect's default delimiters
        if expr.args.get("expression") is None:
            expr.set("expression", exp.Literal.string(self.dialect.INITCAP_DEFAULT_DELIMITER_CHARS))

        return expr

    def _parse_operator(self, this: exp.Expr | None) -> exp.Expr | None:
        while True:
            if not self._match(TokenType.L_PAREN):
                break

            op = ""
            while self._curr and not self._match(TokenType.R_PAREN):
                op += self._curr.text
                self._advance()

            comments = self._prev_comments
            this = self.expression(
                exp.Operator(this=this, operator=op, expression=self._parse_bitwise()),
                comments=comments,
            )

            if not self._match(TokenType.OPERATOR):
                break

        return this
