Edit on GitHub

sqlmesh.core.engine_adapter.starrocks

   1from __future__ import annotations
   2
   3import logging
   4import re
   5import sqlglot
   6from sqlglot import exp
   7import typing as t
   8
   9from sqlmesh.core.engine_adapter.base import (
  10    InsertOverwriteStrategy,
  11    get_source_columns_to_types,
  12)
  13from sqlmesh.core.engine_adapter.mixins import (
  14    ClusteredByMixin,
  15    LogicalMergeMixin,
  16    PandasNativeFetchDFSupportMixin,
  17)
  18from sqlmesh.core.engine_adapter.shared import (
  19    CommentCreationTable,
  20    CommentCreationView,
  21    DataObject,
  22    DataObjectType,
  23    set_catalog,
  24    to_schema,
  25)
  26from sqlmesh.core.node import IntervalUnit
  27from sqlmesh.utils.errors import SQLMeshError
  28
  29if t.TYPE_CHECKING:
  30    from sqlmesh.core._typing import SchemaName, TableName
  31    from sqlmesh.core.engine_adapter._typing import QueryOrDF
  32
  33logger = logging.getLogger(__name__)
  34
  35
  36###############################################################################
  37# Declarative Type System for Property Validation and Normalization
  38###############################################################################
  39"""
  40Declarative type system for property validation and normalization.
  41
  42This module provides a declarative way to define property types with clear separation
  43between validation (type checking) and normalization (type conversion).
  44"""
  45Validated = t.Any  # validated intermediate value (AST nodes, string, list...)
  46Normalized = t.Any  # final normalized output
  47
  48# Allowed outputs for EnumType normalize / or general property outputs.
  49PROPERTY_OUTPUT_TYPES = {
  50    "str",  # "HASH"
  51    "var",  # exp.Var("ASYNC")
  52    "identifier",  # exp.Identifier
  53    "literal",  # exp.Literal.string("HASH")
  54    "column",  # exp.Column(this="HASH")
  55    "ast_expr",  # generic exp.Expr
  56}
  57
  58
  59# ============================================================
  60# Fragment parser (robust-ish)
  61# ============================================================
  62def parse_fragment(text: str) -> t.Union[exp.Expr, t.List[exp.Expr]]:
  63    """
  64    Try to parse a DSL fragment into SQLGlot AST(s).
  65
  66    Behavior:
  67    1. If parse_one succeeds, return the exp.Expr.
  68    2. If fails but text contains comma, split by commas and parse each part.
  69    3. If it's parenthesized like "(a, b)", parse and return exp.Tuple or list.
  70    4. If it's a simple token like "IDENT", return exp.Identifier.
  71    """
  72    if isinstance(text, exp.Expr):
  73        return text
  74
  75    if not isinstance(text, str):
  76        raise TypeError("parse_fragment expects a string")
  77
  78    s = text.strip()
  79    try:
  80        parsed = sqlglot.parse_one(s)
  81        return parsed
  82    except Exception:
  83        raise ValueError(f"Unable to parse fragment: {s}")
  84
  85
  86# ============================================================
  87# Base Type
  88# ============================================================
  89class DeclarativeType:
  90    """
  91    Base class for declarative type system.
  92
  93    Design Philosophy:
  94    -----------------
  95    - validate(value): Type checking only - returns validated intermediate value or None
  96    - normalize(validated): Type conversion only - transforms to target output format
  97
  98    Methods:
  99    --------
 100    validate(value) -> Optional[Validated]
 101        Check if value conforms to this type, maybe include some tiny different types
 102        Returns: Validated intermediate value if valid, None otherwise.
 103
 104    normalize(validated) -> Normalized
 105        Convert validated intermediate value to final output format.
 106        Returns: Normalized value in target format.
 107
 108    __call__(value) -> Normalized
 109        Convenience method: validate + normalize in one step.
 110    """
 111
 112    def validate(self, value: t.Any) -> t.Optional[Validated]:
 113        """Check if value conforms to this type. Return validated value or None.
 114        String that can be parsed as literal
 115        """
 116        raise NotImplementedError(f"{self.__class__.__name__}.validate() must be implemented")
 117
 118    def normalize(self, validated: Validated) -> Normalized:
 119        """Convert validated intermediate value to final output format."""
 120        # Default: identity transformation
 121        return validated
 122
 123    def __call__(self, value: t.Any) -> Normalized:
 124        """Validate and normalize in one step."""
 125        validated = self.validate(value)
 126        if validated is None:
 127            raise ValueError(f"Value {value!r} does not conform to type {self.__class__.__name__}")
 128        return self.normalize(validated)
 129
 130
 131# ============================================================
 132# Primitive Types
 133# ============================================================
 134class StringType(DeclarativeType):
 135    """
 136    String type validator.
 137
 138    Accepts:
 139    - Python str only
 140
 141    Validation: Returns the string if valid, None otherwise.
 142    Normalization: Returns the string as-is (identity).
 143    """
 144
 145    def __init__(self, normalized_type: str = "str"):
 146        """
 147        Args:
 148            normalized_type: Target type for normalization.
 149                - "literal": Convert to exp.Literal.string()
 150                - "str": Keep as string (default)
 151                - "identifier": Convert to exp.Identifier
 152        """
 153        self.normalized_type = normalized_type
 154
 155    def validate(self, value: t.Any) -> t.Optional[str]:
 156        """Check if value is a Python string. Returns string or None."""
 157        return value if isinstance(value, str) else None
 158
 159    def normalize(self, validated: str) -> str:
 160        """Return string as-is (identity normalization)."""
 161        return validated
 162
 163
 164class LiteralType(DeclarativeType):
 165    """
 166    Literal type validator.
 167
 168    Accepts:
 169    - exp.Literal only (from AST)
 170    - String that can be parsed as literal
 171
 172    Validation: Returns exp.Literal if valid, None otherwise.
 173    Normalization: Converts to target type based on normalized_type parameter.
 174    """
 175
 176    def __init__(self, normalized_type: t.Optional[str] = None):
 177        """
 178        Args:
 179            normalized_type: Target type for normalization.
 180                - None: Keep as exp.Literal (default)
 181                - "literal": Keep as exp.Literal
 182                - "str": Convert to Python string
 183        """
 184        self.normalized_type = normalized_type
 185
 186    def validate(self, value: t.Any) -> t.Optional[exp.Literal]:
 187        """Check if value is a literal type. Returns exp.Literal or None."""
 188        # Try parsing string first
 189        if isinstance(value, str):
 190            try:
 191                value = parse_fragment(value)
 192            except Exception:
 193                return None
 194
 195        # Check if it's a Literal
 196        if isinstance(value, exp.Literal):
 197            return value
 198
 199        return None
 200
 201    def normalize(self, validated: exp.Literal) -> t.Union[exp.Literal, str]:
 202        """Convert to target type based on normalized_type."""
 203        if self.normalized_type == "str":
 204            return validated.this
 205        # None or "literal" - keep as-is
 206        return validated
 207
 208
 209class IdentifierType(DeclarativeType):
 210    """
 211    Identifier type validator.
 212
 213    Accepts:
 214    - exp.Identifier only
 215    - String that can be parsed as identifier
 216
 217    Validation: Returns exp.Identifier if valid, None otherwise.
 218    Normalization: Converts to target type based on normalized_type parameter.
 219    """
 220
 221    def __init__(self, normalized_type: t.Optional[str] = None):
 222        """
 223        Args:
 224            normalized_type: Target type for normalization.
 225                - None: Keep as exp.Identifier (default)
 226                - "literal": Convert to exp.Literal.string()
 227                - "str": Convert to Python string
 228                - "identifier": Keep as exp.Identifier
 229                - "column": Convert to exp.Column
 230        """
 231        self.normalized_type = normalized_type
 232
 233    def validate(self, value: t.Any) -> t.Optional[exp.Identifier]:
 234        """Check if value is an identifier type. Returns exp.Identifier or None."""
 235        # Try parsing string first
 236        if isinstance(value, str):
 237            try:
 238                value = parse_fragment(value)
 239            except Exception:
 240                return None
 241
 242        # Check if it's an Identifier
 243        if isinstance(value, exp.Identifier):
 244            return value
 245
 246        return None
 247
 248    def normalize(
 249        self, validated: exp.Identifier
 250    ) -> t.Union[exp.Identifier, exp.Column, exp.Literal, str]:
 251        """Convert to target type based on normalized_type."""
 252        if self.normalized_type == "column":
 253            return exp.column(validated.this)
 254        if self.normalized_type == "literal":
 255            return exp.Literal.string(validated.this)
 256        if self.normalized_type == "str":
 257            return validated.this
 258        # None or "identifier" - keep as-is
 259        return validated
 260
 261
 262class ColumnType(DeclarativeType):
 263    """
 264    Column type validator.
 265
 266    Accepts:
 267    - exp.Column only
 268    - String that can be parsed as column
 269
 270    Validation: Returns exp.Column if valid, None otherwise.
 271    Normalization: Converts to target type based on normalized_type parameter.
 272    """
 273
 274    def __init__(self, normalized_type: t.Optional[str] = None):
 275        """
 276        Args:
 277            normalized_type: Target type for normalization.
 278                - None: Keep as exp.Column (default)
 279                - "literal": Convert to exp.Literal.string()
 280                - "str": Convert to Python string
 281                - "identifier": Convert to exp.Identifier
 282                - "column": Keep as exp.Column
 283        """
 284        self.normalized_type = normalized_type
 285
 286    def validate(self, value: t.Any) -> t.Optional[exp.Column]:
 287        """Check if value is a column type. Returns exp.Column or None."""
 288        # Try parsing string first
 289        if isinstance(value, str):
 290            try:
 291                value = parse_fragment(value)
 292            except Exception:
 293                return None
 294
 295        # Check if it's a Column
 296        if isinstance(value, exp.Column):
 297            return value
 298
 299        return None
 300
 301    def normalize(
 302        self, validated: exp.Column
 303    ) -> t.Union[exp.Column, exp.Identifier, exp.Literal, str]:
 304        """Convert to target type based on normalized_type."""
 305        if self.normalized_type == "identifier":
 306            return exp.Identifier(this=validated.this)
 307        if self.normalized_type == "literal":
 308            return exp.Literal.string(validated.this)
 309        if self.normalized_type == "str":
 310            return str(validated.this)
 311        # None or "column" - keep as-is
 312        return validated
 313
 314
 315class EqType(DeclarativeType):
 316    """
 317    EQ expression type validator (key=value pairs).
 318
 319    Accepts:
 320    - exp.EQ(left, right)
 321    - String that can be parsed as key=value
 322
 323    Validation: Returns (key_name, value_expr) tuple if valid, None otherwise.
 324    Normalization: Returns the (key, value) tuple as-is.
 325    """
 326
 327    def validate(self, value: t.Any) -> t.Optional[t.Tuple[str, t.Any]]:
 328        """Check if value is an EQ expression. Returns (key, value) tuple or None."""
 329        # Try parsing string first
 330        if isinstance(value, str):
 331            try:
 332                value = parse_fragment(value)
 333            except Exception:
 334                return None
 335
 336        # Check if it's an EQ expression
 337        if isinstance(value, exp.EQ):
 338            # Extract key name from left side
 339            left = value.this
 340            # Extract value from right side
 341            right = value.expression
 342
 343            key_name = None
 344            if isinstance(left, exp.Column):
 345                key_name = left.this.name if hasattr(left.this, "name") else str(left.this)
 346            elif isinstance(left, exp.Identifier):
 347                key_name = left.this
 348            elif isinstance(left, str):
 349                key_name = left
 350            else:
 351                key_name = str(left)
 352
 353            return (key_name, right)
 354
 355        return None
 356
 357    def normalize(self, validated: t.Tuple[str, t.Any]) -> t.Tuple[str, t.Any]:
 358        """Return (key, value) tuple as-is (identity normalization)."""
 359        return validated
 360
 361
 362class EnumType(DeclarativeType):
 363    """
 364    Enumerated value type validator.
 365
 366    Accepts values from a predefined set of allowed values.
 367    Following input types are allowed:
 368    - str
 369    - exp.Literal
 370    - exp.Var
 371    - exp.Identifier
 372    - exp.Column
 373
 374    Parameters:
 375    -----------
 376    valid_values : t.Sequence[str]
 377        List of allowed values (e.g., ["HASH", "RANDOM"])
 378    normalized_type : t.Optional[str]
 379        Target type for normalization:
 380        - "str": Python string (default)
 381        - "identifier": exp.Identifier
 382        - "literal": exp.Literal.string()
 383        - "column": exp.Column
 384        - "ast_expr": generic exp.Expr (defaults to Identifier)
 385    case_sensitive : bool
 386        Whether to perform case-sensitive matching (default: False)
 387
 388    Validation: Checks if value is in allowed set, returns canonical string.
 389    Normalization: Converts to specified target type.
 390    """
 391
 392    def __init__(
 393        self,
 394        valid_values: t.Sequence[str],
 395        normalized_type: str = "str",
 396        case_sensitive: bool = False,
 397    ):
 398        self.valid_values = list(valid_values)
 399        self.case_sensitive = bool(case_sensitive)
 400        self.normalized_type = normalized_type
 401
 402        if self.normalized_type is not None and self.normalized_type not in PROPERTY_OUTPUT_TYPES:
 403            raise ValueError(
 404                f"normalized_type must be one of {PROPERTY_OUTPUT_TYPES}, got {self.normalized_type!r}"
 405            )
 406
 407        # Pre-compute normalized values for efficient lookup
 408        self._values_normalized = [v if case_sensitive else v.upper() for v in self.valid_values]
 409
 410    def _extract_text(self, value: t.Any) -> t.Optional[str]:
 411        """Extract text from various value types."""
 412        if isinstance(value, str):
 413            return value
 414        if isinstance(value, (exp.Literal, exp.Var)):
 415            return str(value.this)
 416        if isinstance(value, (exp.Identifier, exp.Column)):
 417            # For Identifier/Column, this might be another Expression
 418            if isinstance(value.this, str):
 419                return value.this
 420            elif hasattr(value.this, "name"):  # noqa: RET505
 421                return str(value.this.name)
 422            else:
 423                return str(value.this)
 424        return None
 425
 426    def _normalize_text(self, text: str) -> str:
 427        """Normalize text for comparison based on case sensitivity."""
 428        return text if self.case_sensitive else text.upper()
 429
 430    def validate(self, value: t.Any) -> t.Optional[str]:
 431        """Check if value is in the allowed enum set. Returns canonical string or None."""
 432        # Try parsing string first
 433        if isinstance(value, str):
 434            try:
 435                parsed = parse_fragment(value)
 436                # If parsed successfully, extract text from AST node
 437                if isinstance(parsed, (exp.Identifier, exp.Literal, exp.Column)):
 438                    value = parsed
 439            except Exception:
 440                # If parsing fails, treat as plain string
 441                pass
 442
 443        # Extract text from value
 444        text = self._extract_text(value)
 445
 446        if text is None:
 447            return None
 448
 449        # Normalize and check against allowed values
 450        normalized_text = self._normalize_text(text)
 451        if normalized_text in self._values_normalized:
 452            return normalized_text
 453
 454        return None
 455
 456    def normalize(self, validated: str) -> Normalized:
 457        """Convert validated enum string to target type."""
 458        # validated is already canonical (e.g., "HASH")
 459        if self.normalized_type is None or self.normalized_type == "str":
 460            return validated
 461        if self.normalized_type == "var":
 462            return exp.Var(this=validated)
 463        if self.normalized_type == "literal":
 464            return exp.Literal.string(validated)
 465        if self.normalized_type == "identifier":
 466            return exp.Identifier(this=validated)
 467        if self.normalized_type == "column":
 468            return exp.Column(this=validated)
 469        if self.normalized_type == "ast_expr":
 470            return exp.Identifier(this=validated)
 471
 472        # Fallback to string
 473        return validated
 474
 475
 476class FuncType(DeclarativeType):
 477    """
 478    Function type validator.
 479
 480    Accepts:
 481    - exp.Func (built-in functions like date_trunc, CAST, etc.)
 482    - exp.Anonymous (custom/dialect functions like RANGE, LIST)
 483    - String that can be parsed as function call
 484
 485    Validation: Returns exp.Func or exp.Anonymous if valid, None otherwise.
 486    Normalization: Returns the function expression as-is (identity).
 487
 488    Examples:
 489        date_trunc('day', col1)     → exp.Func
 490        RANGE(col1, col2)           → exp.Anonymous
 491        LIST(region, status)        → exp.Anonymous
 492    """
 493
 494    def validate(self, value: t.Any) -> t.Optional[t.Union[exp.Func, exp.Anonymous]]:
 495        """Check if value is a function type. Returns exp.Func/exp.Anonymous or None."""
 496        # Try parsing string first
 497        if isinstance(value, str):
 498            try:
 499                value = parse_fragment(value)
 500            except Exception:
 501                return None
 502
 503        # Check if it's a Func or Anonymous function
 504        if isinstance(value, (exp.Func, exp.Anonymous)):
 505            return value
 506
 507        return None
 508
 509    def normalize(
 510        self, validated: t.Union[exp.Func, exp.Anonymous]
 511    ) -> t.Union[exp.Func, exp.Anonymous]:
 512        """Return function expression as-is (identity normalization)."""
 513        return validated
 514
 515
 516# ============================================================
 517# AnyOf (combinator)
 518# ============================================================
 519class AnyOf(DeclarativeType):
 520    """
 521    Union type - accepts first matching subtype.
 522
 523    This is a combinator type that tries each subtype in order and accepts
 524    the first one that validates successfully.
 525
 526    Validation: Tries each subtype, returns (matched_type, validated_value) tuple.
 527    Normalization: Uses the matched subtype's normalize method.
 528    """
 529
 530    def __init__(self, *types: DeclarativeType):
 531        if not types:
 532            raise ValueError("AnyOf requires at least one type")
 533
 534        # Validate all types are DeclarativeType instances
 535        for type_ in types:
 536            if not isinstance(type_, DeclarativeType):
 537                raise TypeError(f"AnyOf expects DeclarativeType instances, got {type_!r}")
 538
 539        self.types: t.List[DeclarativeType] = list(types)
 540
 541    def validate(self, value: t.Any) -> t.Optional[t.Tuple[DeclarativeType, Validated]]:
 542        """Try each subtype in order, return (matched_type, validated_value) or None."""
 543        for sub_type in self.types:
 544            validated = sub_type.validate(value)
 545            if validated is not None:
 546                # Return both the matched type and validated value
 547                return (sub_type, validated)
 548
 549        # No type matched
 550        return None
 551
 552    def normalize(self, validated: t.Tuple[DeclarativeType, Validated]) -> Normalized:
 553        """Normalize using the matched subtype's normalize method."""
 554        matched_type, validated_value = validated
 555        return matched_type.normalize(validated_value)
 556
 557
 558# ============================================================
 559# SequenceOf (Tuple/List/Paren/Single -> normalized list/tuple)
 560# ============================================================
 561class SequenceOf(DeclarativeType):
 562    """
 563    Sequence/List type validator with built-in union type support.
 564
 565    Accepts various sequence representations and validates each element against
 566    one or more possible types (similar to AnyOf for each element).
 567    Optionally accepts single elements (promoted to single-item lists).
 568
 569    Accepts:
 570    - exp.Tuple: (a, b, c)
 571    - exp.Array: [a, b, c]
 572    - exp.Paren: (a) or ((a, b))
 573    - Python list/tuple: [a, b] or (a, b)
 574    - String: "a, b, c" (parsed)
 575    - Single element: a (if allow_single=True, promoted to [a])
 576
 577    Validation: Returns list of (matched_type, validated_value) tuples or None.
 578    Normalization: Returns list of normalized elements using matched type's normalize.
 579
 580    Examples:
 581        # Single type
 582        SequenceOf(ColumnType())
 583
 584        # Multiple types (union) - each element tries types in order
 585        SequenceOf(ColumnType(), IdentifierType(), LiteralType())
 586
 587        # Allow single element
 588        SequenceOf(ColumnType(), allow_single=True)
 589
 590        # Multiple types + allow single
 591        SequenceOf(ColumnType(), IdentifierType(), allow_single=True)
 592    """
 593
 594    def __init__(
 595        self,
 596        *elem_types: DeclarativeType,
 597        allow_single: bool = False,
 598        output_as: str = "list",
 599    ):
 600        """
 601        Args:
 602            *elem_types: One or more type validators for elements.
 603                        If multiple types provided, each element tries types in order (AnyOf behavior).
 604            allow_single: Whether to accept single elements (promoted to list). Default: False.
 605            output_as: Output format - "list" or "tuple". Default: "list".
 606        """
 607        if not elem_types:
 608            raise ValueError("SequenceOf requires at least one element type")
 609
 610        self.elem_types: t.List[DeclarativeType] = list(elem_types)
 611        self.allow_single = allow_single
 612        self.output_as = output_as
 613
 614    def validate(self, value: t.Any) -> t.Optional[t.List[t.Tuple[DeclarativeType, Validated]]]:
 615        """Validate each element in the sequence. Returns list of (matched_type, validated_value) tuples or None."""
 616        # Extract elements from various container types
 617        elems = self._extract_elements(value)
 618        if elems is None:
 619            return None
 620
 621        # Validate each element against all possible types (AnyOf behavior)
 622        validated_items: t.List[t.Tuple[DeclarativeType, Validated]] = []
 623        for elem in elems:
 624            # Try each type until one matches
 625            matched = False
 626            for elem_type in self.elem_types:
 627                validated = elem_type.validate(elem)
 628                if validated is not None:
 629                    validated_items.append((elem_type, validated))
 630                    matched = True
 631                    break
 632
 633            # If no type matched, the whole sequence fails if any element fails
 634            if not matched:
 635                return None
 636
 637        return validated_items
 638
 639    def normalize(
 640        self, validated: t.List[t.Tuple[DeclarativeType, Validated]]
 641    ) -> t.Union[t.List[Normalized], t.Tuple[Normalized, ...]]:
 642        """Normalize each validated element using its matched type's normalize method."""
 643        normalized_items = [elem_type.normalize(value) for elem_type, value in validated]
 644
 645        # Convert to desired output format
 646        if self.output_as == "tuple":
 647            return tuple(normalized_items)
 648        return normalized_items  # default: list
 649
 650    def _extract_elements(self, value: t.Any) -> t.Optional[t.List[t.Any]]:
 651        """
 652        Extract elements from various container representations.
 653        Returns list of raw elements or None if extraction fails.
 654        """
 655        # Python list/tuple - process first before string parsing
 656        if isinstance(value, (list, tuple)):
 657            return list(value)
 658
 659        # Try parsing string for AST types
 660        if isinstance(value, str):
 661            try:
 662                value = parse_fragment(value)
 663            except Exception:
 664                # If parsing fails and we accept single strings, promote to list
 665                if self.allow_single and any(isinstance(t, StringType) for t in self.elem_types):
 666                    return [value]
 667                return None
 668
 669        # SQL Tuple: (a, b, c)
 670        if isinstance(value, exp.Tuple):
 671            return list(value.expressions)
 672
 673        # SQL Array: [a, b, c]
 674        if isinstance(value, exp.Array):
 675            return list(value.expressions)
 676
 677        # SQL Paren: (a) or ((a, b))
 678        if isinstance(value, exp.Paren):
 679            inner = value.this
 680            if isinstance(inner, exp.Tuple):
 681                return list(inner.expressions)
 682            return [inner]
 683
 684        # Single AST element: promote to list (if allow_single)
 685        if self.allow_single and isinstance(value, exp.Expr):
 686            return [value]
 687
 688        return None
 689
 690
 691# ============================================================
 692# Field Definition for Structured Types
 693# ============================================================
 694class Field:
 695    """
 696    Field specification for StructuredTupleType.
 697
 698    Defines validation rules, types, and metadata for a single field.
 699
 700    Args:
 701        type: DeclarativeType instance for validating field value
 702        required: Whether this field is required (default: False)
 703        aliases: List of alternative field names (default: [])
 704        doc: Documentation string for this field
 705
 706    Example:
 707        Field(
 708            type=EnumType(["HASH", "RANDOM"]),
 709            required=True,
 710            aliases=["distribution_type"],
 711            doc="Distribution kind: HASH or RANDOM"
 712        )
 713    """
 714
 715    def __init__(
 716        self,
 717        type: DeclarativeType,
 718        required: bool = False,
 719        aliases: t.Optional[t.List[str]] = None,
 720        doc: t.Optional[str] = None,
 721    ):
 722        self.type = type
 723        self.required = required
 724        self.aliases = aliases or []
 725        self.doc = doc
 726
 727
 728# ============================================================
 729# StructuredTupleType - Base class for typed tuples
 730# ============================================================
 731class StructuredTupleType(DeclarativeType):
 732    """
 733    Base class for validating tuples with typed fields.
 734
 735    Subclasses define FIELDS dict to specify structure:
 736
 737    FIELDS = {
 738        "field_name": Field(
 739            type=SomeType(),
 740            required=True,
 741            aliases=["alt_name1", "alt_name2"]
 742        ),
 743        ...
 744    }
 745
 746    Validation Process:
 747    1. Parse tuple into key=value pairs (exp.EQ)
 748    2. Match keys against FIELDS (including aliases)
 749    3. Validate each field value with specified type
 750    4. Check required fields are present
 751    5. Handle unknown/invalid fields based on error flags
 752
 753    Returns: Dict[str, Any] with canonical field names as keys
 754
 755    Example:
 756        class DistributionTupleInputType(StructuredTupleType):
 757            FIELDS = {
 758                "kind": Field(type=EnumType(["HASH", "RANDOM"]), required=True),
 759                "columns": Field(type=SequenceOf(ColumnType())),
 760            }
 761
 762    Args:
 763        error_on_unknown_field: If True, raise error when encountering unknown fields.
 764                                If False, silently skip unknown fields (default: False)
 765        error_on_invalid_field: If True, raise error when field value validation fails.
 766                                If False, return None for entire validation (default: True)
 767    """
 768
 769    FIELDS: t.Dict[str, Field] = {}  # Subclasses override this
 770
 771    def __init__(self, error_on_unknown_field: bool = True, error_on_invalid_field: bool = True):
 772        self.error_on_unknown_field = error_on_unknown_field
 773        self.error_on_invalid_field = error_on_invalid_field
 774
 775        # Build alias mapping: alias -> canonical_name
 776        self._alias_map: t.Dict[str, str] = {}
 777        for field_name, field_spec in self.FIELDS.items():
 778            # Map canonical name to itself
 779            self._alias_map[field_name] = field_name
 780            # Map aliases to canonical name
 781            for alias in field_spec.aliases:
 782                self._alias_map[alias] = field_name
 783
 784    def validate(
 785        self, value: t.Any
 786    ) -> t.Optional[t.Dict[str, t.Tuple[DeclarativeType, Validated]]]:
 787        """
 788        Validate structured tuple.
 789
 790        Returns: Dict mapping canonical field names to (matched_type, validated_value) tuples,
 791                 or None if validation fails.
 792
 793        Raises:
 794            ValueError: If error_on_unknown_field=True and unknown field encountered
 795            ValueError: If error_on_invalid_field=True and field validation fails
 796        """
 797        # Try parsing string first
 798        if isinstance(value, str):
 799            try:
 800                value = parse_fragment(value)
 801            except Exception:
 802                return None
 803
 804        # Extract key=value pairs from tuple/paren
 805        pairs = self._extract_pairs(value)
 806        if pairs is None:
 807            return None
 808
 809        # Validate each pair and build result dict
 810        result: t.Dict[str, t.Tuple[DeclarativeType, Validated]] = {}
 811        eq_type = EqType()
 812
 813        for pair_expr in pairs:
 814            # Validate as EQ expression
 815            eq_validated = eq_type.validate(pair_expr)
 816            if eq_validated is None:
 817                continue  # Skip non-EQ expressions
 818
 819            key, value_expr = eq_validated
 820
 821            # Resolve alias to canonical name
 822            canonical_name = self._alias_map.get(key)
 823            if canonical_name is None:
 824                # Unknown field
 825                if self.error_on_unknown_field:
 826                    raise ValueError(
 827                        f"Unknown field '{key}' in {self.__class__.__name__}. "
 828                        f"Valid fields: {list(self.FIELDS.keys())}"
 829                    )
 830                # Skip unknown field
 831                continue
 832
 833            # Get field spec
 834            field_spec = self.FIELDS[canonical_name]
 835
 836            # Validate field value with specified type
 837            validated_value = field_spec.type.validate(value_expr)
 838            if validated_value is None:
 839                # Field validation failed
 840                if self.error_on_invalid_field:
 841                    raise ValueError(
 842                        f"Invalid value for field '{canonical_name}': {value_expr}. "
 843                        f"Expected type: {field_spec.type.__class__.__name__}, "
 844                        f"Actual type: {type(value_expr).__name__}"
 845                    )
 846                # Return None for entire validation
 847                return None
 848
 849            # Store with canonical name
 850            result[canonical_name] = (field_spec.type, validated_value)
 851
 852        # Check required fields
 853        for field_name, field_spec in self.FIELDS.items():
 854            if field_spec.required and field_name not in result:
 855                # Required field missing
 856                if self.error_on_invalid_field:
 857                    raise ValueError(
 858                        f"Required field '{field_name}' is missing in {self.__class__.__name__}"
 859                    )
 860                return None
 861
 862        return result
 863
 864    def normalize(
 865        self, validated: t.Dict[str, t.Tuple[DeclarativeType, Validated]]
 866    ) -> t.Dict[str, Normalized]:
 867        """
 868        Normalize validated fields.
 869
 870        Returns: Dict mapping canonical field names to normalized values.
 871        """
 872        return {
 873            field_name: field_type.normalize(value)
 874            for field_name, (field_type, value) in validated.items()
 875        }
 876
 877    def _extract_pairs(self, value: t.Any) -> t.Optional[t.List[t.Any]]:
 878        """
 879        Extract list of expressions from tuple/paren.
 880        Each expression should be an exp.EQ (key=value).
 881        """
 882        # exp.Tuple: (a=1, b=2)
 883        if isinstance(value, list):
 884            return value
 885        if isinstance(value, exp.Tuple):
 886            return list(value.expressions)
 887
 888        # exp.Paren: (a=1) or ((a=1, b=2))
 889        if isinstance(value, exp.Paren):
 890            inner = value.this
 891            if isinstance(inner, exp.Tuple):
 892                return list(inner.expressions)
 893            return [inner]
 894
 895        return None
 896
 897
 898class DistributionTupleInputType(StructuredTupleType):
 899    """
 900    StarRocks distribution tuple validator.
 901
 902    Accepts:
 903    - (kind='HASH', columns=(id, dt), buckets=10)
 904    - (kind='HASH', expressions=(id, dt), bucket_num=10)
 905    - (kind='RANDOM')
 906
 907    Returns: Dict with fields:
 908        - kind: "HASH" or "RANDOM" (string)
 909        - columns: List[exp.Column] (optional, for HASH)
 910        - buckets: exp.Literal (optional)
 911
 912    Field Aliases:
 913        - columns: expressions
 914        - buckets: bucket, bucket_num
 915
 916    Examples:
 917        Input:  (kind='HASH', columns=(id, dt), buckets=10)
 918        Output: {
 919            'kind': 'HASH',
 920            'columns': [exp.Column('id'), exp.Column('dt')],
 921            'buckets': exp.Literal.number(10)
 922        }
 923
 924        Input:  (kind='RANDOM')
 925        Output: {'kind': 'RANDOM'}
 926
 927    Conversion:
 928        Use factory methods to convert normalized values to unified dict format:
 929        - from_enum(): Convert EnumType normalized value (str) → dict
 930        - from_func(): Convert FuncType normalized value (exp.Func) → dict
 931        - to_unified_dict(): Convert any normalized value → dict
 932    """
 933
 934    FIELDS = {
 935        "kind": Field(
 936            type=EnumType(["HASH", "RANDOM"], normalized_type="str"),
 937            required=True,
 938            doc="Distribution type: HASH or RANDOM",
 939        ),
 940        "columns": Field(
 941            type=SequenceOf(
 942                ColumnType(),
 943                IdentifierType(normalized_type="column"),
 944                allow_single=True,
 945            ),
 946            required=False,
 947            aliases=["expressions"],
 948            doc="Columns for HASH distribution",
 949        ),
 950        "buckets": Field(
 951            type=AnyOf(LiteralType(), StringType(normalized_type="literal")),
 952            required=False,
 953            aliases=["bucket", "bucket_num"],
 954            doc="Number of buckets",
 955        ),
 956    }
 957
 958
 959class DistributionTupleOutputType(StructuredTupleType):
 960    """
 961    Output validator for distribution tuple.
 962
 963    Used to validate normalized distribution values which are already dicts.
 964    Overrides validate() to handle dict input directly (for output validation),
 965    while parent class handles tuple/string input (for input validation).
 966    """
 967
 968    FIELDS = {
 969        "kind": Field(
 970            type=EnumType(["HASH", "RANDOM"]),
 971            required=True,
 972        ),
 973        "columns": Field(
 974            type=SequenceOf(ColumnType(), allow_single=False),
 975            required=False,
 976        ),
 977        "buckets": Field(
 978            type=LiteralType(),
 979            required=False,
 980        ),
 981    }
 982
 983    def validate(self, value: t.Any) -> t.Optional[t.Dict[str, t.Any]]:
 984        """
 985        Validate a distribution value for OUTPUT validation.
 986
 987        For output validation, accepts:
 988        - dict: Validate structure directly (normalized output)
 989        - tuple/string: Delegate to parent class (for completeness)
 990
 991        Returns: The dict if valid, None otherwise
 992        """
 993        # For output validation, handle dict directly
 994        if isinstance(value, dict):
 995            # Validate required 'kind' field
 996            kind = value.get("kind")
 997            if kind is None:
 998                return None
 999
1000            # Validate 'kind' is a valid enum value
1001            kind_spec = self.FIELDS["kind"].type
1002            if kind_spec.validate(kind) is None:
1003                return None
1004
1005            # Validate 'columns' if present
1006            columns = value.get("columns")
1007            if columns is not None:
1008                columns_spec = self.FIELDS["columns"].type
1009                if columns_spec.validate(columns) is None:
1010                    return None
1011
1012            # Validate 'buckets' if present
1013            buckets = value.get("buckets")
1014            if buckets is not None:
1015                buckets_spec = self.FIELDS["buckets"].type
1016                if buckets_spec.validate(buckets) is None:
1017                    return None
1018
1019            return value
1020
1021        # For tuple/string, delegate to parent class
1022        return super().validate(value)
1023
1024    # ============================================================
1025    # Factory methods for conversion from other normalized types
1026    # ============================================================
1027
1028    @staticmethod
1029    def from_enum(enum_value: str, buckets: t.Optional[int] = None) -> t.Dict[str, t.Any]:
1030        """
1031        Create distribution dict from EnumType normalized value.
1032
1033        Args:
1034            enum_value: "RANDOM" (from EnumType)
1035            buckets: Optional bucket count
1036
1037        Returns:
1038            Dict with kind/columns/buckets fields
1039
1040        Example:
1041            >>> DistributionTupleOutputType.from_enum("RANDOM")
1042            {'kind': 'RANDOM', 'columns': [], 'buckets': None}
1043        """
1044        return {"kind": enum_value, "columns": [], "buckets": buckets}
1045
1046    @staticmethod
1047    def from_func(
1048        func: t.Union[exp.Func, exp.Anonymous], buckets: t.Optional[int] = None
1049    ) -> t.Dict[str, t.Any]:
1050        """
1051        Create distribution dict from FuncType normalized value.
1052
1053        Args:
1054            func: HASH(id, dt) or RANDOM() (from FuncType)
1055            buckets: Optional bucket count
1056
1057        Returns:
1058            Dict with kind/columns/buckets fields
1059
1060        Example:
1061            >> func = sqlglot.parse_one("HASH(id, dt)")
1062            >> DistributionTupleOutputType.from_func(func)
1063            {"kind": "HASH", "columns": [exp.Column("id"), exp.Column("dt")], "buckets": None}
1064        """
1065        func_name = func.name.upper() if hasattr(func, "name") else str(func.this).upper()
1066
1067        if func_name == "HASH":
1068            # Extract columns from HASH(col1, col2, ...)
1069            columns: list[exp.Column] = [func.this] if isinstance(func.this, exp.Column) else []
1070            columns.extend(func.expressions)
1071            return {"kind": "HASH", "columns": columns, "buckets": buckets}
1072        elif func_name == "RANDOM":  # noqa: RET505
1073            return {"kind": "RANDOM", "columns": [], "buckets": buckets}
1074        else:
1075            raise ValueError(f"Unknown distribution function: {func_name}")
1076
1077    @staticmethod
1078    def to_unified_dict(
1079        normalized_value: t.Any, buckets: t.Optional[int] = None
1080    ) -> t.Dict[str, t.Any]:
1081        """
1082        Convert any normalized distribution value to unified dict format.
1083
1084        This is a convenience method that dispatches to appropriate factory method.
1085
1086        Args:
1087            normalized_value: Result from DistributedByInputSpec normalization
1088                             (dict | str | exp.Func)
1089            buckets: Optional bucket count override
1090
1091        Returns:
1092            Unified dict with kind/columns/buckets fields
1093
1094        Raises:
1095            TypeError: If value type is not supported
1096
1097        Example:
1098            >>> # From DistributionTupleOutputType
1099            >>> DistributionTupleOutputType.to_unified_dict({"kind": "HASH", "columns": [...]})
1100            {'kind': 'HASH', 'columns': [Ellipsis]}
1101
1102            >>> # From EnumType
1103            >>> DistributionTupleOutputType.to_unified_dict("RANDOM")
1104            {'kind': 'RANDOM', 'columns': [], 'buckets': None}
1105
1106            >> # From FuncType
1107            >> DistributionTupleOutputType.to_unified_dict(sqlglot.parse_one("HASH(id)"))
1108            {'kind': 'HASH', 'columns': [exp.Column('id')], 'buckets': None}
1109        """
1110        if isinstance(normalized_value, dict):
1111            # Already in DistributionTupleInputType format
1112            return normalized_value
1113        elif isinstance(normalized_value, str):  # noqa: RET505
1114            # From EnumType: "RANDOM"
1115            return DistributionTupleOutputType.from_enum(normalized_value, buckets)
1116        elif isinstance(normalized_value, (exp.Func, exp.Anonymous)):
1117            # From FuncType: HASH(id, dt)
1118            return DistributionTupleOutputType.from_func(normalized_value, buckets)
1119        else:
1120            raise TypeError(
1121                f"Cannot convert {type(normalized_value).__name__} to distribution dict. "
1122                f"Expected dict, str, or exp.Func/exp.Anonymous."
1123            )
1124
1125
1126# ============================================================
1127# Type Specifications for StarRocks Properties (INPUT and OUTPUT)
1128# ============================================================
1129class PropertySpecs:
1130    # Accepts:
1131    # - Single column: id
1132    # - Multiple columns: (id, dt)
1133    # - String for string input: "id, dt" (will be auto-wrapped and parsed by preprocess_parentheses)
1134    GeneralColumnListInputSpec = SequenceOf(
1135        ColumnType(),
1136        StringType(normalized_type="column"),
1137        IdentifierType(normalized_type="column"),
1138        allow_single=True,
1139    )
1140
1141    # TableKey: Simple key specification (primary_key, duplicate_key, unique_key, aggregate_key)
1142    # Accepts:
1143    # - Single column: id
1144    # - Multiple columns: (id, dt)
1145    TableKeyInputSpec = GeneralColumnListInputSpec
1146
1147    # Partitioned By: Flexible partition specification
1148    # Accepts:
1149    # - Single column: col1
1150    # - Multiple columns: (col1, col2)
1151    # - Mixed: (col1, "col2") - string will be parsed
1152    # - RANGE(col1) or RANGE(col1, col2)
1153    # - LIST(col1) or LIST(col1, col2)
1154    # - Expression: (date_trunc('day', col1), col2)
1155    PartitionedByInputSpec = SequenceOf(
1156        ColumnType(),
1157        StringType(normalized_type="column"),
1158        IdentifierType(normalized_type="column"),
1159        FuncType(),  # RANGE(), LIST(), date_trunc(), etc.
1160        allow_single=True,
1161    )
1162
1163    # Partitions: List of partition definitions (strings)
1164    # Accepts:
1165    # - Single partition: 'PARTITION p1 VALUES LESS THAN ("2024-01-01")'
1166    # - Multiple partitions: ('PARTITION p1 ...', 'PARTITION p2 ...')
1167    # Note: Single string is auto-promoted to list
1168    PartitionsInputSpec = SequenceOf(
1169        StringType(), LiteralType(normalized_type="str"), allow_single=True
1170    )
1171
1172    # Distribution: StarRocks distribution specification
1173    # Accepts:
1174    # - Structured tuple1: (kind='HASH', columns=(id, dt), buckets=10)
1175    # - Structured tuple2: (kind='RANDOM')
1176    # - String format: "HASH(id)", "RANDOM", or "(kind='HASH', columns=(id), buckets=10)"
1177    # Note: Does NOT accept simple columns like id or (id, dt)
1178    #    And it can't directly accept "HASH(id) BUCKETS 10", you need to split it with "BUCKETS" to two parts.
1179    DistributedByInputSpec = AnyOf(
1180        DistributionTupleInputType(),  # Try structured tuple first (most specific)
1181        EnumType(["RANDOM"], normalized_type="str"),  # "RANDOM"
1182        FuncType(),  # "HASH(id)",
1183    )
1184
1185    # OrderBy: Simple ordering specification
1186    # Accepts:
1187    # - Single column: dt
1188    # - Multiple columns: (dt, id, status)
1189    OrderByInputSpec = GeneralColumnListInputSpec
1190
1191    # Refresh scheme: Accepts various types, normalizes to string
1192    # For properties like refresh_scheme, it can be a string, identifier, or column
1193    RefreshSchemeInputSpec = AnyOf(
1194        EnumType(["ASYNC", "MANUAL"], normalized_type="var"),
1195        ColumnType(normalized_type="str"),  # Columns → will be converted to string
1196        IdentifierType(normalized_type="str"),  # Identifiers → will be converted to string
1197        LiteralType(normalized_type="str"),  # Numbers and string → to string
1198        StringType(),  # Plain strings
1199    )
1200
1201    # Generic property value: Accepts various types, normalizes to string
1202    # For properties like replication_num, storage_medium, etc.
1203    # StarRocks PROPERTIES syntax requires all values to be strings: "value"
1204    # So we normalize everything to string for consistent SQL generation
1205    GenericPropertyInputSpec = AnyOf(
1206        StringType(),  # Plain strings
1207        LiteralType(normalized_type="str"),  # Numbers and string → will be converted to string
1208        IdentifierType(normalized_type="str"),  # Identifiers → will be converted to string
1209        ColumnType(normalized_type="str"),  # Columns → will be converted to string
1210    )
1211
1212    """
1213    Input Property Specification for StarRocks
1214
1215    This specification defines the validation and normalization rules for StarRocks properties.
1216    Properties are specified in the physical_properties block of a SQLMesh model.
1217
1218    Supported properties:
1219    - partitioned_by / partition_by: Partition specification
1220    - partitions: List of partition definitions
1221    - distributed_by: Distribution specification (HASH/RANDOM with structured tuple or string)
1222    - order_by: Ordering specification (simple column list)
1223    - table key:
1224        - primary_key: Primary key columns
1225        - duplicate_key: Duplicate key columns (for DUPLICATE KEY table)
1226        - unique_key: Unique key columns (for UNIQUE KEY table)
1227        - aggregate_key: Aggregate key columns (for AGGREGATE KEY table)
1228    - other properties: Any other properties not listed above will be treated as generic
1229                        string properties (e.g., replication_num, storage_medium, etc.)
1230
1231    Examples:
1232        duplicate_key = dt                             # Single key
1233        primary_key = (id, customer_id)                # Multiple keys
1234
1235        partitioned_by = col1                          # Single column
1236        partitioned_by = (col1, col2)                  # Multiple columns
1237        partitioned_by = (col1, "col2")                # Mixed (string will be parsed)
1238        partitioned_by = date_trunc('day', col1)       # Expression partition with single func
1239        partitioned_by = (date_trunc('day', col1), col2)  # Expression partition with multiple exprs
1240        partitioned_by = RANGE(col1, col2)             # RANGE partition
1241        partitioned_by = LIST(region, status)          # LIST partition
1242
1243        distributed_by = (kind='HASH', columns=(id, dt), buckets=10)  # Structured
1244        distributed_by = (kind='RANDOM')               # RANDOM distribution
1245        distributed_by = "HASH(id)"                    # String format
1246        distributed_by = "RANDOM"                      # String format
1247
1248        order_by = dt                                  # Single column
1249        order_by = (dt, id, status)                    # Multiple columns
1250
1251        replication_num = 3                            # Generic property (auto-handled)
1252        storage_medium = "SSD"                         # Generic property (auto-handled)
1253    """
1254    PROPERTY_INPUT_SPECS: t.Dict[str, DeclarativeType] = {
1255        # Table key properties
1256        "primary_key": TableKeyInputSpec,
1257        "duplicate_key": TableKeyInputSpec,
1258        "unique_key": TableKeyInputSpec,
1259        "aggregate_key": TableKeyInputSpec,
1260        # Partition-related properties
1261        "partitioned_by": PartitionedByInputSpec,
1262        "partitions": PartitionsInputSpec,
1263        # Distribution property
1264        "distributed_by": DistributedByInputSpec,
1265        # Ordering property
1266        "clustered_by": OrderByInputSpec,
1267        # View properties
1268        # StarRocks syntax: SECURITY {NONE | INVOKER | DEFINER}
1269        "security": EnumType(["NONE", "INVOKER", "DEFINER"], normalized_type="str"),
1270        # Materialized view refresh properties (StarRocks uses REFRESH ...)
1271        # - refresh_moment: IMMEDIATE | DEFERRED
1272        "refresh_moment": EnumType(["IMMEDIATE", "DEFERRED"], normalized_type="str"),
1273        # - refresh_scheme: ASYNC | ASYNC [START (...) EVERY (INTERVAL ...)] | MANUAL
1274        #     it should be a string/literal if START/EVERY is present, other than ASYNC
1275        "refresh_scheme": RefreshSchemeInputSpec,
1276        # Note: All other properties not listed here will be handled, an example here
1277        "replication_num": GenericPropertyInputSpec,
1278    }
1279
1280    # Default output spec for properties not in PROPERTY_OUTPUT_SPECS
1281    GenericPropertyOutputSpec = StringType()
1282
1283    """
1284    Output Property Specification for StarRocks after validation+normalization
1285
1286    This specification describes the expected types after normalization.
1287    For most properties, OUTPUT spec is the same as INPUT spec since normalization
1288    preserves the diverse types (dict | str | exp.Func for distribution).
1289
1290    Conversion to unified formats (e.g., all distributions → dict) happens separately
1291    in the usage layer via factory methods like DistributionTupleInputType.to_unified_dict().
1292
1293    Expected Output Types (after normalization):
1294    - table keys: List[exp.Expr] - columns
1295    - partitioned_by: List[exp.Expr] - columns, functions
1296    - partitions: List[str] - partition definition strings
1297    - distributed_by: Dict | str | exp.Func - DistributionTupleInputType, EnumType, or FuncType output
1298    - order_by: List[exp.Expr] - columns
1299    - generic properties: str - normalized string values
1300    """
1301    GeneralColumnListOutputSpec: DeclarativeType = SequenceOf(ColumnType(), allow_single=False)
1302
1303    PROPERTY_OUTPUT_SPECS: t.Dict[str, DeclarativeType] = {
1304        "primary_key": GeneralColumnListOutputSpec,
1305        "duplicate_key": GeneralColumnListOutputSpec,
1306        "unique_key": GeneralColumnListOutputSpec,
1307        "aggregate_key": GeneralColumnListOutputSpec,
1308        "partitioned_by": SequenceOf(ColumnType(), FuncType(), allow_single=False),
1309        "partitions": SequenceOf(StringType(), allow_single=False),
1310        "distributed_by": AnyOf(
1311            DistributionTupleOutputType(),  # Try structured tuple first (most specific)
1312            EnumType(["RANDOM"], normalized_type="str"),  # "RANDOM"
1313            FuncType(),  # "HASH(id)",
1314        ),  # Still dict | str | exp.Func after normalize
1315        "clustered_by": GeneralColumnListOutputSpec,
1316        "security": EnumType(["NONE", "INVOKER", "DEFINER"], normalized_type="str"),
1317        "refresh_moment": EnumType(["IMMEDIATE", "DEFERRED"], normalized_type="str"),
1318        "refresh_scheme": AnyOf(
1319            EnumType(["ASYNC", "MANUAL"], normalized_type="var"),
1320            StringType(),
1321        ),
1322        # Generic properties use GenericPropertyOutputSpec, an example here
1323        "replication_num": GenericPropertyOutputSpec,
1324    }
1325
1326    # ============================================================
1327    # Helper functions
1328    # ============================================================
1329
1330    @staticmethod
1331    def get_property_input_spec(property_name: str) -> DeclarativeType:
1332        """
1333        Get the INPUT type validator for a property.
1334
1335        Returns the specific type from PROPERTY_INPUT_SPECS if defined,
1336        otherwise returns GenericPropertyInputSpec for unknown properties.
1337
1338        This allows any property not explicitly defined to be treated
1339        as a generic string property.
1340        """
1341        return PropertySpecs.PROPERTY_INPUT_SPECS.get(
1342            property_name, PropertySpecs.GenericPropertyInputSpec
1343        )
1344
1345    @staticmethod
1346    def get_property_output_spec(property_name: str) -> DeclarativeType:
1347        """
1348        Get the OUTPUT type validator for a property.
1349
1350        Returns the specific type from PROPERTY_OUTPUT_SPECS if defined,
1351        otherwise returns GenericPropertyOutputSpec for unknown properties.
1352
1353        This allows validating that normalized values conform to expected output types.
1354        """
1355        return PropertySpecs.PROPERTY_OUTPUT_SPECS.get(
1356            property_name, PropertySpecs.GenericPropertyOutputSpec
1357        )
1358
1359
1360# ============================================================
1361# Property Validation Helpers
1362# ============================================================
1363class PropertyValidator:
1364    """
1365    Centralized property validation helpers for table properties.
1366
1367    Provides reusable validation functions to avoid code duplication
1368    and ensure consistent error messages across different property handlers.
1369    """
1370
1371    TABLE_KEY_TYPES = {"primary_key", "duplicate_key", "unique_key", "aggregate_key"}
1372
1373    # All important properties except generic properties
1374    IMPORTANT_PROPERTY_NAMES = {
1375        *TABLE_KEY_TYPES,
1376        "partitioned_by",
1377        "partitions",
1378        "distributed_by",
1379        "clustered_by",
1380    }
1381
1382    # Centralized property alias configuration
1383    # Maps canonical name -> list of valid aliases
1384    PROPERTY_ALIASES: t.Dict[str, t.Set[str]] = {
1385        "partitioned_by": {"partition_by"},
1386        "clustered_by": {"order_by"},
1387    }
1388
1389    EXCLUSIVE_PROPERTY_NAME_MAP: t.Dict[str, t.Set[str]] = {
1390        "key_type": set(TABLE_KEY_TYPES),
1391        **PROPERTY_ALIASES,
1392    }
1393
1394    # Centralized invalid property name configuration
1395    # Maps canonical name -> list of invalid/deprecated names
1396    INVALID_PROPERTY_NAME_MAP: t.Dict[str, t.List[str]] = {
1397        "partitioned_by": ["partition"],
1398        "distributed_by": ["distribution", "distribute"],
1399        "clustered_by": ["order", "ordering"],
1400    }
1401
1402    @staticmethod
1403    def ensure_parenthesized(value: t.Any) -> t.Any:
1404        """
1405        Ensure string value is wrapped in parentheses for parse_fragment compatibility.
1406
1407        For string inputs like 'id1, id2', wraps to '(id1, id2)' so that
1408        parse_fragment can parse it correctly.
1409
1410        Args:
1411            value: Input value (string, expression, or other)
1412
1413        Returns:
1414            - For strings/Literal/Column(quoted): wrapped in parentheses if not already
1415            - For other types: returned unchanged
1416
1417        Example:
1418            >>> PropertyValidator.ensure_parenthesized('id1, id2')
1419            '(id1, id2)'
1420            >>> PropertyValidator.ensure_parenthesized('(id1, id2)')
1421            '(id1, id2)'
1422            >>> PropertyValidator.ensure_parenthesized(exp.Literal.string('id1, id2'))
1423            '(id1, id2)'
1424            >>> PropertyValidator.ensure_parenthesized(exp.Column(quoted=True, name='id1, id2'))
1425            Column(quoted=True, name=id1, id2)
1426        """
1427        # logger.debug("ensure_parenthesized. value: %s, type: %s", value, type(value))
1428
1429        # Extract string content from Literal
1430        if isinstance(value, exp.Literal) and value.is_string:
1431            value = value.this
1432        # Extract string content from Column (quoted)
1433        elif isinstance(value, exp.Column) and hasattr(value.this, "quoted") and value.this.quoted:
1434            value = value.name  # Column.name returns the string
1435        elif not isinstance(value, str):
1436            return value
1437
1438        stripped = value.strip()
1439        if not stripped:
1440            return value
1441
1442        # Check if already wrapped in parentheses
1443        if stripped.startswith("(") and stripped.endswith(")"):
1444            return value
1445
1446        return f"({stripped})"
1447
1448    @staticmethod
1449    def validate_and_normalize_property(
1450        property_name: str, value: t.Any, preprocess_parentheses: bool = False
1451    ) -> t.Any:
1452        """
1453        Complete property processing pipeline using SPEC:
1454        1. Optionally preprocess string with parentheses
1455        2. Get INPUT type validator
1456        3. Validate and normalize input value
1457        4. Get OUTPUT type validator
1458        5. Verify normalized output conforms to expected type
1459        6. Return verified output
1460
1461        After validation, the output type is guaranteed by SPEC.
1462        Unexpected types indicate SPEC configuration errors.
1463
1464        Args:
1465            property_name: Name of the property
1466            value: The property value to validate
1467            preprocess_parentheses: If True, wrap string values in parentheses
1468
1469        Returns:
1470            The normalized value
1471
1472        Raises:
1473            SQLMeshError: If validation fails
1474
1475        Example:
1476            >>> validated = PropertyValidator.validate_and_normalize_property("distributed_by", "RANDOM")
1477            >>> # Result: "RANDOM" (string from EnumType)
1478        """
1479        # logger.debug("validate_and_normalize_property. value: %s, type: %s", value, type(value))
1480
1481        # Step 1: Optionally preprocess string with parentheses
1482        if preprocess_parentheses:
1483            value = PropertyValidator.ensure_parenthesized(value)
1484
1485        # Step 2: Get INPUT type validator
1486        input_spec = PropertySpecs.get_property_input_spec(property_name)
1487        if input_spec is None:
1488            raise SQLMeshError(f"Unknown property '{property_name}'.")
1489
1490        # Step 3: Validate
1491        validated = input_spec.validate(value)
1492        if validated is None:
1493            raise SQLMeshError(f"Invalid value type for property '{property_name}': {value!r}.")
1494
1495        # Step 4: Normalize
1496        normalized = input_spec.normalize(validated)
1497
1498        # Step 5: Check by using output spec
1499        output_spec = PropertySpecs.get_property_output_spec(property_name)
1500        if output_spec is not None:
1501            if output_spec.validate(normalized) is None:
1502                raise SQLMeshError(
1503                    f"Normalized value for property '{property_name}' doesn't match output spec: {normalized!r}."
1504                )
1505
1506        # Step 6: Return
1507        return normalized
1508
1509    @staticmethod
1510    def check_invalid_names(
1511        valid_name: str,
1512        invalid_names: t.List[str],
1513        table_properties: t.Dict[str, t.Any],
1514        suggestion: t.Optional[str] = None,
1515    ) -> None:
1516        """
1517        Check for invalid/deprecated property names and raise error with suggestion.
1518
1519        Args:
1520            valid_name: The correct property name
1521            invalid_names: List of invalid/deprecated names to check for
1522            table_properties: Table properties dictionary to check
1523            suggestion: Optional custom error message suggestion
1524
1525        Raises:
1526            SQLMeshError: If any invalid name is found
1527
1528        Example:
1529            >> PropertyValidator.check_invalid_names(
1530            ...     valid_name="partitioned_by",
1531            ...     invalid_names=["partition_by", "partition"],
1532            ...     table_properties={"partition_by": "dt"}
1533            ... )
1534            SQLMeshError: Invalid property 'partition_by'. Use 'partitioned_by' instead.
1535        """
1536        for invalid_name in invalid_names:
1537            if invalid_name in table_properties:
1538                msg = suggestion or f"Use '{valid_name}' instead"
1539                raise SQLMeshError(f"Invalid property '{invalid_name}'. {msg}.")
1540
1541    @classmethod
1542    def check_all_invalid_names(cls, table_properties: t.Dict[str, t.Any]) -> None:
1543        """
1544        Check all invalid property names at once using INVALID_PROPERTY_NAME_MAP config.
1545
1546        Args:
1547            table_properties: Table properties dictionary to check
1548
1549        Raises:
1550            SQLMeshError: If any invalid name is found
1551        """
1552        for valid_name, invalid_names in cls.INVALID_PROPERTY_NAME_MAP.items():
1553            cls.check_invalid_names(valid_name, invalid_names, table_properties)
1554
1555    @staticmethod
1556    def check_at_most_one(
1557        property_name: str,
1558        property_description: str,
1559        table_properties: t.Dict[str, t.Any],
1560        exclusive_property_names: t.Optional[t.Set[str]] = None,
1561        parameter_value: t.Optional[t.Any] = None,
1562    ) -> t.Optional[str]:
1563        """
1564        Ensure at most one property from a mutually exclusive group is defined.
1565
1566        Args:
1567            property_name: the canonical name
1568            property_description: description of the property group (for error messages)
1569            exclusive_property_names: List of mutually exclusive property names.
1570                Defaults to canonical name and aliases if not provided.
1571            table_properties: Table properties dictionary to check
1572            parameter_value: Optional parameter value (takes priority over table_properties)
1573
1574        Returns:
1575            Name of the active property, or None if none found
1576            NOTE: If the parameter value is provided, it returns None
1577
1578        Raises:
1579            SQLMeshError: If multiple properties from the group are defined
1580
1581        Example:
1582            >> PropertyValidator.check_at_most_one(
1583            ...     property_name="primary_key",
1584            ...     property_description="key type",
1585            ...     exclusive_property_names=["primary_key", "duplicate_key", "unique_key", "aggregate_key"],
1586            ...     table_properties={"primary_key": "(id)", "duplicate_key": "(id)"}
1587            ... )
1588            SQLMeshError: Multiple key type properties defined: ['primary_key', 'duplicate_key'].
1589                         Only one is allowed.
1590        """
1591        if not exclusive_property_names:
1592            exclusive_property_names = PropertyValidator.EXCLUSIVE_PROPERTY_NAME_MAP.get(
1593                property_name, set()
1594            ) | {property_name}
1595        # logger.debug("Checking at most one property for '%s': %s", property_name, exclusive_property_names)
1596        # Check parameter first (highest priority)
1597        if parameter_value is not None:
1598            # Check if any conflicting properties exist in table_properties
1599            conflicts = [name for name in exclusive_property_names if name in table_properties]
1600            if conflicts:
1601                param_display = f"{property_name} (parameter)"
1602                raise SQLMeshError(
1603                    f"Conflicting {property_description} definitions: "
1604                    f"{param_display} provided along with table_properties {conflicts}. "
1605                    f"Only one {property_description} is allowed."
1606                )
1607            return None
1608
1609        # Check table_properties for multiple definitions
1610        present = [name for name in exclusive_property_names if name in table_properties]
1611        # logger.debug("Get table key names for %s from table_properties: %s", property_name, present)
1612
1613        if len(present) > 1:
1614            raise SQLMeshError(
1615                f"Multiple {property_description} properties defined: {present}. "
1616                f"Only one is allowed."
1617            )
1618
1619        return present[0] if present else None
1620
1621
1622###############################################################################
1623# StarRocks Engine Adapter
1624###############################################################################
1625@set_catalog()
1626class StarRocksEngineAdapter(
1627    LogicalMergeMixin,
1628    PandasNativeFetchDFSupportMixin,
1629    ClusteredByMixin,
1630):
1631    """
1632    StarRocks Engine Adapter for SQLMesh.
1633
1634    StarRocks is a high-performance analytical database with its own dialect-specific
1635    behavior. This adapter highlights a few key characteristics:
1636
1637    1. PRIMARY KEY support is native and must be emitted in the post-schema section.
1638    2. DELETE with subqueries is supported on PRIMARY KEY tables, but other key types still
1639       need guard rails (no boolean literals, TRUNCATE for WHERE TRUE, etc.).
1640    3. Partitioning supports RANGE, LIST, and expression-based syntaxes.
1641
1642    Implementation strategy:
1643    - Override only where StarRocks syntax/behavior diverges from the base adapter.
1644    - Keep the rest of the functionality delegated to the shared base implementation.
1645    """
1646
1647    # ==================== Class Attributes (Declarative Configuration) ====================
1648
1649    DIALECT = "starrocks"
1650    """SQLGlot dialect name for SQL generation"""
1651
1652    DEFAULT_BATCH_SIZE = 10000
1653    """Default batch size for bulk operations"""
1654
1655    SUPPORTS_TRANSACTIONS = False
1656    """
1657    StarRocks does not support transactions for multiple DML statements.
1658    - No BEGIN/COMMIT/ROLLBACK (only txn for multiple INSERT statements from v3.5)
1659    - Operations are auto-committed
1660    - Backfill uses partition-level atomicity
1661    """
1662
1663    INSERT_OVERWRITE_STRATEGY = InsertOverwriteStrategy.DELETE_INSERT
1664    """
1665    StarRocks does support INSERT OVERWRITE syntax (and dynamic overwrite from v3.5).
1666    Use DELETE + INSERT pattern:
1667    1. DELETE FROM table WHERE condition
1668    2. INSERT INTO table SELECT ...
1669
1670    Base class automatically handles this strategy without overriding insert methods.
1671
1672    TODO: later, we can add support for INSERT OVERWRITE, even use Primary Key for beter performance
1673    """
1674
1675    COMMENT_CREATION_TABLE = CommentCreationTable.IN_SCHEMA_DEF_NO_CTAS
1676    """Column comments are added inline in a plain CREATE TABLE, but StarRocks CTAS only accepts a
1677    bare column-name list (no types or per-column COMMENT) before AS SELECT. So for CTAS we emit
1678    `CREATE TABLE t COMMENT '...' AS SELECT ...` (table comment only) and register column comments
1679    afterward via ALTER TABLE ... MODIFY COLUMN ... COMMENT (see _build_create_comment_column_exp)."""
1680
1681    COMMENT_CREATION_VIEW = CommentCreationView.IN_SCHEMA_DEF_NO_COMMANDS
1682    """View comments are added in CREATE VIEW statement"""
1683
1684    SUPPORTS_MATERIALIZED_VIEWS = True
1685    """StarRocks supports materialized views with refresh strategies"""
1686
1687    SUPPORTS_MATERIALIZED_VIEW_SCHEMA = True
1688    """
1689    StarRocks materialized views support specifying a column list, but the column definition is
1690    limited (e.g. column name + comment, not full type definitions). We set this to True and
1691    implement custom MV schema rendering in create_view/_create_materialized_view.
1692    """
1693
1694    RECREATE_MATERIALIZED_VIEW_ON_EVALUATION = False
1695    """
1696    StarRocks async materialized views maintain themselves: they revalidate automatically even if the
1697    underlying data is dropped, and the data is kept current either by StarRocks' automatic refresh or
1698    by an explicit `REFRESH MATERIALIZED VIEW` (which also enables partition-level incremental refresh).
1699    """
1700
1701    SUPPORTS_REPLACE_TABLE = False
1702    """No REPLACE TABLE syntax; use DROP + CREATE instead"""
1703
1704    SUPPORTS_CREATE_DROP_CATALOG = False
1705    """StarRocks supports DROPing external catalogs.
1706    TODO: whether it's external catalogs, or includes the internal catalog
1707    """
1708
1709    SUPPORTS_INDEXES = True
1710    """
1711    StarRocks supports PRIMARY KEY in CREATE TABLE, but NOT standalone CREATE INDEX.
1712
1713    We set this to True to enable PRIMARY KEY generation in CREATE TABLE statements.
1714    The create_index() method is overridden to prevent actual CREATE INDEX execution.
1715
1716    Supported (defined in CREATE TABLE):
1717    - PRIMARY KEY: Automatically creates sorted index
1718    - INDEX clause: For bloom filter, bitmap, inverted indexes
1719    NOT supported:
1720        CREATE INDEX idx_name ON t (name);  -- Will be skipped by create_index()
1721    """
1722
1723    SUPPORTS_TUPLE_IN = False
1724    """
1725    StarRocks does NOT support tuple IN syntax: (col1, col2) IN ((val1, val2), (val3, val4))
1726
1727    Instead, use OR with AND conditions:
1728    (col1 = val1 AND col2 = val2) OR (col1 = val3 AND col2 = val4)
1729
1730    This is automatically handled by snapshot_id_filter and snapshot_name_version_filter
1731    in sqlmesh/core/state_sync/db/utils.py when SUPPORTS_TUPLE_IN = False.
1732    """
1733
1734    MAX_TABLE_COMMENT_LENGTH = 2048
1735    """Maximum length for table comments"""
1736
1737    MAX_COLUMN_COMMENT_LENGTH = 255
1738    """Maximum length for column comments"""
1739
1740    MAX_IDENTIFIER_LENGTH = 64
1741    """Maximum length for table/column names"""
1742
1743    RESOLVE_TABLE_REFS_IN_PHYSICAL_PROPERTIES: t.FrozenSet[str] = frozenset(
1744        {"excluded_trigger_tables", "excluded_refresh_tables"}
1745    )
1746    """StarRocks async materialized views accept these properties to exclude certain tables from
1747    triggering or participating in refreshes.  When the value references a managed SQLMesh model,
1748    StarRocks needs the physical table name (db.table), not the logical view name.  Managed-model
1749    physical names carry no catalog prefix (catalog support is UNSUPPORTED), so they are already in
1750    the warehouse-local db.table form StarRocks expects; unmanaged references (e.g. an external
1751    catalog's ext_catalog.db.table) pass through unchanged."""
1752
1753    # ==================== Schema Operations ====================
1754    # StarRocks supports CREATE/DROP SCHEMA the same as CREATE/DROP DATABSE.
1755    # So, no need to implement create_schema / drop_schema
1756
1757    # ==================== Data Object Query ====================
1758    def _get_data_objects(
1759        self, schema_name: SchemaName, object_names: t.Optional[t.Set[str]] = None
1760    ) -> t.List[DataObject]:
1761        """
1762        Returns all the data objects that exist in the given schema.
1763        Uses information_schema tables which are compatible with MySQL protocol.
1764
1765        StarRocks uses the MySQL-compatible information_schema layout, so the same query
1766        works here.
1767        Note: Materialized View is not reliably distinguished from View (both may appear as `VIEW`)
1768        in information_schema.tables. We therefore best-effort detect MVs via
1769        information_schema.materialized_views and upgrade matching objects to `materialized_view`.
1770
1771        Args:
1772            schema_name: The schema (database) to query
1773            object_names: Optional set of specific table names to filter
1774
1775        Returns:
1776            List of DataObject instances representing tables and views
1777        """
1778        schema_db = to_schema(schema_name).db
1779        query = (
1780            exp.select(
1781                exp.column("table_schema").as_("schema_name"),
1782                exp.column("table_name").as_("name"),
1783                exp.case(exp.column("table_type"))
1784                .when(
1785                    exp.Literal.string("BASE TABLE"),
1786                    exp.Literal.string("table"),
1787                )
1788                .when(
1789                    exp.Literal.string("VIEW"),
1790                    exp.Literal.string("view"),
1791                )
1792                .else_("table_type")
1793                .as_("type"),
1794            )
1795            .from_(exp.table_("tables", db="information_schema"))
1796            .where(exp.column("table_schema").eq(schema_db))
1797        )
1798        if object_names:
1799            # StarRocks may treat information_schema table_name comparisons as case-sensitive.
1800            # Use LOWER(table_name) to match case-insensitively.
1801            lowered_names = [name.lower() for name in object_names]
1802            query = query.where(exp.func("LOWER", exp.column("table_name")).isin(*lowered_names))
1803
1804        df = self.fetchdf(query)
1805        objects = [
1806            DataObject(
1807                schema=row.schema_name,
1808                name=row.name,
1809                type=DataObjectType.from_str(str(row.type)),
1810            )
1811            for row in df.itertuples()
1812        ]
1813
1814        # Best-effort upgrade of MV types using information_schema.materialized_views.
1815        # If this fails (unsupported / permissions / version), fall back to information_schema.tables.
1816        try:
1817            mv_query = (
1818                exp.select(
1819                    exp.column("table_schema").as_("schema_name"),
1820                    exp.column("table_name").as_("name"),
1821                )
1822                .from_(exp.table_("materialized_views", db="information_schema"))
1823                .where(exp.column("table_schema").eq(schema_db))
1824            )
1825            if object_names:
1826                lowered_names = [name.lower() for name in object_names]
1827                mv_query = mv_query.where(
1828                    exp.func("LOWER", exp.column("table_name")).isin(*lowered_names)
1829                )
1830
1831            mv_df = self.fetchdf(mv_query)
1832            mv_names: t.Set[str] = {
1833                t.cast(str, r.name).lower() for r in mv_df.itertuples() if r.name
1834            }
1835
1836            if mv_names:
1837                for obj in objects:
1838                    if obj.name.lower() in mv_names:
1839                        obj.type = DataObjectType.MATERIALIZED_VIEW
1840        except Exception:
1841            logger.warning(
1842                f"[StarRocks] Failed to get materialized views from information_schema.materialized_views"
1843            )
1844
1845        return objects
1846
1847    def create_index(
1848        self,
1849        table_name: TableName,
1850        index_name: str,
1851        columns: t.Tuple[str, ...],
1852        exists: bool = True,
1853    ) -> None:
1854        """
1855        Override to prevent CREATE INDEX statements (not supported in StarRocks).
1856
1857        StarRocks does not support standalone CREATE INDEX statements.
1858        Indexes must be defined during CREATE TABLE using INDEX clause.
1859
1860        Since SQLMesh state tables use PRIMARY KEY (which provides efficient indexing),
1861        we simply log and skip additional index creation requests.
1862
1863        This matches upstream StarRocks limitations and prevents accidental CREATE INDEX calls.
1864        """
1865        logger.warning(
1866            f"[StarRocks] Skipping CREATE INDEX {index_name} on {table_name} - "
1867            f"StarRocks does not support standalone CREATE INDEX statements. "
1868            f"PRIMARY KEY provides equivalent indexing for columns: {columns}"
1869        )
1870        return
1871
1872    def _create_table_like(
1873        self,
1874        target_table_name: TableName,
1875        source_table_name: TableName,
1876        exists: bool,
1877        **kwargs: t.Any,
1878    ) -> None:
1879        """Create a new table using StarRocks' native `CREATE TABLE ... LIKE ...` syntax.
1880
1881        The base implementation re-creates the target table from `columns(source)` which can
1882        lose non-column metadata. Using LIKE lets the engine preserve more of the original
1883        table definition (engine-defined behavior).
1884        """
1885        self.execute(
1886            exp.Create(
1887                this=exp.to_table(target_table_name),
1888                kind="TABLE",
1889                exists=exists,
1890                properties=exp.Properties(
1891                    expressions=[
1892                        exp.LikeProperty(
1893                            this=exp.to_table(source_table_name),
1894                        ),
1895                    ],
1896                ),
1897            )
1898        )
1899
1900    def delete_from(
1901        self,
1902        table_name: TableName,
1903        where: t.Optional[t.Union[str, exp.Expr]] = None,
1904    ) -> None:
1905        """
1906        Delete from a table.
1907
1908        StarRocks DELETE limitations by table type:
1909
1910        PRIMARY KEY tables:
1911        - Support complex WHERE conditions (subqueries, BETWEEN, etc.)
1912        - No special handling needed
1913
1914        Other table types (DUPLICATE/UNIQUE/AGGREGATE KEY):
1915        - WHERE TRUE not supported → use TRUNCATE TABLE
1916        - Boolean literals (TRUE/FALSE) not supported
1917        - BETWEEN not supported → convert to >= AND <=
1918        - Others not supported:
1919            - CAST() not supported in WHERE
1920            - Subqueries not supported
1921            - ...
1922
1923        But, I don't know what the table type is.
1924
1925        Args:
1926            table_name: The table to delete from
1927            where: The where clause to filter rows to delete
1928        """
1929        # Parse where clause if it's a string
1930        where_expr: t.Optional[exp.Expr]
1931        if isinstance(where, str):
1932            from sqlglot import parse_one
1933
1934            where_expr = parse_one(where, dialect=self.dialect)
1935        else:
1936            where_expr = where
1937
1938        # If no where clause or WHERE TRUE, use TRUNCATE TABLE (for all table types)
1939        if not where_expr or where_expr == exp.true():
1940            table_expr = exp.to_table(table_name) if isinstance(table_name, str) else table_name
1941            logger.info(
1942                f"Converting DELETE FROM {table_name} WHERE TRUE to TRUNCATE TABLE "
1943                "(StarRocks does not support WHERE TRUE in DELETE)"
1944            )
1945            self.execute(f"TRUNCATE TABLE {table_expr.sql(dialect=self.dialect, identify=True)}")
1946            return
1947
1948        # For non-PRIMARY KEY tables, apply WHERE clause restrictions
1949        # Note: We conservatively apply restrictions to all tables since we can't easily
1950        # determine table type at DELETE time. PRIMARY KEY tables will still work with
1951        # simplified conditions, while non-PRIMARY KEY tables require them.
1952        if isinstance(where_expr, exp.Expr):
1953            original_where = where_expr
1954            # Remove boolean literals (not supported in any table type)
1955            where_expr = self._where_clause_remove_boolean_literals(where_expr)
1956            # Convert BETWEEN to >= AND <= (required for DUPLICATE/UNIQUE/AGGREGATE KEY tables)
1957            where_expr = self._where_clause_convert_between_to_comparison(where_expr)
1958
1959            if where_expr != original_where:
1960                logger.debug(
1961                    f"Converted WHERE clause for StarRocks compatibility, table: {table_name}.\n"
1962                    f"  Original: {original_where.sql(dialect=self.dialect)}\n"
1963                    f"  Converted: {where_expr.sql(dialect=self.dialect)}"
1964                )
1965
1966        # Use parent implementation
1967        super().delete_from(table_name, where_expr)
1968
1969    def _where_clause_remove_boolean_literals(self, expression: exp.Expr) -> exp.Expr:
1970        """
1971        Remove TRUE/FALSE boolean literals from WHERE expressions.
1972
1973        StarRocks Limitation (except PRIMARY KEY tables):
1974        Boolean literals (TRUE/FALSE) are not supported in WHERE clauses.
1975
1976        This method simplifies expressions:
1977        - (condition) AND TRUE / TRUE AND (condition) → condition
1978        - (condition) OR FALSE / FALSE OR (condition) → condition
1979        - WHERE TRUE → 1=1 (though TRUNCATE is used instead)
1980        - WHERE FALSE → 1=0
1981
1982        Args:
1983            expression: The expression to clean
1984
1985        Returns:
1986            Cleaned expression without boolean literals
1987        """
1988
1989        def transform(node: exp.Expr) -> exp.Expr:
1990            # Handle standalone TRUE/FALSE at the top level
1991            if node == exp.true():
1992                # Convert TRUE to 1=1
1993                return exp.EQ(this=exp.Literal.number(1), expression=exp.Literal.number(1))
1994            elif node == exp.false():  # noqa: RET505
1995                # Convert FALSE to 1=0
1996                return exp.EQ(this=exp.Literal.number(1), expression=exp.Literal.number(0))
1997
1998            # Handle AND expressions
1999            elif isinstance(node, exp.And):
2000                left = node.this
2001                right = node.expression
2002
2003                # Remove TRUE from AND
2004                if left == exp.true():
2005                    return right
2006                if right == exp.true():
2007                    return left
2008
2009            # Handle OR expressions
2010            elif isinstance(node, exp.Or):
2011                left = node.this
2012                right = node.expression
2013
2014                # Remove FALSE from OR
2015                if left == exp.false():
2016                    return right
2017                if right == exp.false():
2018                    return left
2019
2020            return node
2021
2022        # Transform the expression tree
2023        return expression.transform(transform, copy=True)
2024
2025    def _where_clause_convert_between_to_comparison(self, expression: exp.Expr) -> exp.Expr:
2026        """
2027        Convert BETWEEN expressions to >= AND <= comparisons.
2028
2029        StarRocks Limitation (DUPLICATE/UNIQUE/AGGREGATE KEY Tables):
2030        BETWEEN is not supported in DELETE WHERE clauses for non-PRIMARY KEY tables.
2031
2032        PRIMARY KEY tables support BETWEEN, but this conversion is safe for all table types
2033        since the converted form (>= AND <=) is semantically equivalent.
2034
2035        This method converts:
2036        - col BETWEEN a AND b  →  col >= a AND col <= b
2037
2038        Args:
2039            expression: The expression potentially containing BETWEEN
2040
2041        Returns:
2042            Expression with BETWEEN converted to comparisons
2043        """
2044
2045        def transform(node: exp.Expr) -> exp.Expr:
2046            if isinstance(node, exp.Between):
2047                # Extract components: col BETWEEN low AND high
2048                column = node.this  # The column being tested
2049                low = node.args.get("low")  # Lower bound
2050                high = node.args.get("high")  # Upper bound
2051
2052                if column and low and high:
2053                    # Build: column >= low AND column <= high
2054                    gte = exp.GTE(this=column.copy(), expression=low.copy())
2055                    lte = exp.LTE(this=column.copy(), expression=high.copy())
2056                    return exp.And(this=gte, expression=lte)
2057
2058            return node
2059
2060        # Transform the expression tree
2061        return expression.transform(transform, copy=True)
2062
2063    def execute(
2064        self,
2065        expressions: t.Union[str, exp.Expr, t.Sequence[exp.Expr]],
2066        ignore_unsupported_errors: bool = False,
2067        quote_identifiers: bool = True,
2068        track_rows_processed: bool = False,
2069        **kwargs: t.Any,
2070    ) -> None:
2071        """
2072        Override execute to strip FOR UPDATE from queries (not supported in StarRocks).
2073
2074        StarRocks is an OLAP database and does not support row-level locking via
2075        SELECT ... FOR UPDATE. This method removes lock expressions before execution.
2076
2077        Args:
2078            expressions: SQL expression(s) to execute
2079            ignore_unsupported_errors: Whether to ignore unsupported errors
2080            quote_identifiers: Whether to quote identifiers
2081            track_rows_processed: Whether to track rows processed
2082            **kwargs: Additional arguments
2083        """
2084        from sqlglot.helper import ensure_list
2085
2086        if isinstance(expressions, str):
2087            super().execute(
2088                expressions,
2089                ignore_unsupported_errors=ignore_unsupported_errors,
2090                quote_identifiers=quote_identifiers,
2091                track_rows_processed=track_rows_processed,
2092                **kwargs,
2093            )
2094            return
2095
2096        # Process expressions to remove FOR UPDATE
2097        processed_expressions: t.List[exp.Expr] = []
2098        for e in ensure_list(expressions):
2099            if not isinstance(e, exp.Expr):
2100                super().execute(
2101                    expressions,
2102                    ignore_unsupported_errors=ignore_unsupported_errors,
2103                    quote_identifiers=quote_identifiers,
2104                    track_rows_processed=track_rows_processed,
2105                    **kwargs,
2106                )
2107                return
2108
2109            # Remove lock (FOR UPDATE) from SELECT statements
2110            if isinstance(e, exp.Select) and e.args.get("locks"):
2111                e = e.copy()
2112                e.set("locks", None)
2113                logger.warning(
2114                    f"[StarRocks] Removed FOR UPDATE from SELECT statement: "
2115                    f"{e.sql(dialect=self.dialect, identify=quote_identifiers)}"
2116                )
2117            processed_expressions.append(e)
2118
2119        # Call parent execute with processed expressions
2120        super().execute(
2121            processed_expressions,
2122            ignore_unsupported_errors=ignore_unsupported_errors,
2123            quote_identifiers=quote_identifiers,
2124            track_rows_processed=track_rows_processed,
2125            **kwargs,
2126        )
2127
2128    def adjust_physical_properties_for_incremental(
2129        self,
2130        physical_properties: t.Dict[str, t.Any],
2131        *,
2132        requires_delete_capable_table: bool,
2133        unique_key: t.Optional[t.List[exp.Expr]],
2134        model_name: str,
2135    ) -> t.Dict[str, t.Any]:
2136        """Enforce that StarRocks incremental models use a PRIMARY KEY table.
2137
2138        Incremental kinds rely on DELETE/MERGE statements that StarRocks only supports on PRIMARY
2139        KEY tables; DUPLICATE/UNIQUE/AGGREGATE KEY tables reject the predicates SQLMesh generates
2140        (e.g. a time-range DELETE with a CAST bound, or any non-key-column predicate). When a
2141        unique_key is available (INCREMENTAL_BY_UNIQUE_KEY) we promote it to a PRIMARY KEY;
2142        otherwise a PRIMARY KEY must be specified explicitly via physical_properties, and we raise
2143        so the failure is clear at creation time rather than producing a broken table.
2144
2145        The caller owns ``physical_properties`` (it is already a defensive copy), so we mutate and
2146        return it in place.
2147        """
2148        if not requires_delete_capable_table or "primary_key" in physical_properties:
2149            return physical_properties
2150
2151        # Promote the model's unique_key to a PRIMARY KEY table so that complex DELETE/MERGE
2152        # statements remain supported.
2153        if unique_key:
2154            physical_properties["primary_key"] = (
2155                unique_key[0] if len(unique_key) == 1 else exp.Tuple(expressions=unique_key)
2156            )
2157            logger.info(
2158                "Model '%s' promoted to PRIMARY KEY table on StarRocks to support rich DELETE operations.",
2159                model_name,
2160            )
2161            return physical_properties
2162
2163        raise SQLMeshError(
2164            f"StarRocks incremental model '{model_name}' requires a PRIMARY KEY table. "
2165            "Incremental kinds use DELETE/MERGE operations that StarRocks only supports on PRIMARY KEY "
2166            "tables; DUPLICATE/UNIQUE/AGGREGATE KEY tables are not sufficient. "
2167            "Specify `physical_properties (primary_key = (...))`, or set `unique_key` on the model."
2168        )
2169
2170    # ==================== Table Creation (CORE IMPLEMENTATION) ====================
2171    def _create_table_from_columns(
2172        self,
2173        table_name: TableName,
2174        target_columns_to_types: t.Dict[str, exp.DataType],
2175        primary_key: t.Optional[t.Tuple[str, ...]] = None,
2176        exists: bool = True,
2177        table_description: t.Optional[str] = None,
2178        column_descriptions: t.Optional[t.Dict[str, str]] = None,
2179        **kwargs: t.Any,
2180    ) -> None:
2181        """
2182        Create a table using column definitions.
2183
2184        Unified Model Parameter vs Physical Properties Handling:
2185        For properties that can be defined both as model parameters and in physical_properties,
2186        this method implements a unified priority strategy:
2187        1. Model parameter takes priority if present
2188        2. Otherwise, use value from physical_properties
2189        3. Ensure at most one definition exists
2190
2191        Supported unified properties:
2192        - primary_key: Model parameter OR physical_properties.primary_key
2193        - partitioned_by: Model parameter OR physical_properties.partitioned_by/partition_by
2194        - clustered_by: Model parameter OR physical_properties.clustered_by/order_by
2195
2196        Other key types (duplicate_key, aggregate_key, unique_key) only support physical_properties.
2197
2198        StarRocks Key Column Ordering Constraint:
2199        ALL key types (PRIMARY KEY, UNIQUE KEY, DUPLICATE KEY, AGGREGATE KEY) require:
2200        - Key columns MUST be the first N columns in CREATE TABLE
2201        - Column order MUST match the KEY clause order
2202
2203        Implementation Strategy:
2204        1. Normalize model parameters into table_properties with priority handling
2205        2. Extract and validate key columns from unified table_properties
2206        3. Validate no conflicts between different key types
2207        4. Reorder columns to place key columns first
2208        5. For PRIMARY KEY: Pass to base class (sets SUPPORTS_INDEXES=True)
2209        6. For other keys: Handle in _build_table_key_property
2210
2211        Args:
2212            table_name: Fully qualified table name
2213            target_columns_to_types: Column definitions {name: DataType}
2214            primary_key: Primary key column names (model parameter, takes priority)
2215            exists: Add IF NOT EXISTS clause
2216            table_description: Table comment
2217            column_descriptions: Column comments {column_name: comment}
2218            kwargs: Additional properties including:
2219                - partitioned_by: Partition columns (model parameter)
2220                - clustered_by: Clustering columns (model parameter)
2221                - table_properties: Physical properties dict
2222
2223        Example:
2224            # Model parameter (priority):
2225            partitioned_by=dt,
2226            clustered_by=(dt, id))
2227            physical_properties(
2228                primary_key=(id, dt)
2229            )
2230
2231            # Or physical_properties only:
2232            physical_properties(
2233                duplicate_key=(id, dt),
2234                partitioned_by=dt,
2235                order_by=(dt, id)
2236            )
2237        """
2238        # Use setdefault to simplify table_properties access
2239        table_properties = kwargs.setdefault("table_properties", {})
2240
2241        # Extract and validate key columns from table_properties
2242        # Priority: parameter primary_key > table_properties (already handled above)
2243        key_type, key_columns = self._extract_and_validate_key_columns(
2244            table_properties, primary_key
2245        )
2246        # logger.debug(
2247        #     "_create_table_from_columns: extracted key_type=%s, key_columns=%s",
2248        #     key_type,
2249        #     key_columns,
2250        # )
2251
2252        # IMPORTANT: Normalize parameter primary_key into table_properties for unified handling
2253        # This ensures _build_table_properties_exp() can access primary_key even when
2254        # it's passed as a model parameter rather than in physical_properties
2255        if primary_key:
2256            table_properties["primary_key"] = primary_key
2257            logger.debug("_create_table_from_columns: unified primary_key into table_properties")
2258        elif key_type:
2259            # logger.debug(
2260            #     "table key type '%s' may be handled in _build_table_key_property", key_type
2261            # )
2262            pass
2263
2264        # StarRocks key column ordering constraint: All key types need reordering
2265        if key_columns:
2266            target_columns_to_types = self._reorder_columns_for_key(
2267                target_columns_to_types, key_columns, key_type or "key"
2268            )
2269
2270        # IMPORTANT: Do NOT pass primary_key to base class!
2271        # Unlike other databases, StarRocks requires PRIMARY KEY to be in POST_SCHEMA location
2272        # (in properties section after columns), not inside schema (inside column definitions).
2273        # We handle ALL key types (including PRIMARY KEY) in _build_table_key_property.
2274        # logger.debug(
2275        #     "_create_table_from_columns: NOT passing primary_key to base class (handled in _build_table_key_property)"
2276        # )
2277        super()._create_table_from_columns(
2278            table_name=table_name,
2279            target_columns_to_types=target_columns_to_types,
2280            primary_key=None,  # StarRocks handles PRIMARY KEY in properties, not schema
2281            exists=exists,
2282            table_description=table_description,
2283            column_descriptions=column_descriptions,
2284            **kwargs,
2285        )
2286
2287    # ==================== View / Materialized View ====================
2288    def create_view(
2289        self,
2290        view_name: TableName,
2291        query_or_df: QueryOrDF,
2292        target_columns_to_types: t.Optional[t.Dict[str, exp.DataType]] = None,
2293        replace: bool = True,
2294        materialized: bool = False,
2295        materialized_properties: t.Optional[t.Dict[str, t.Any]] = None,
2296        table_description: t.Optional[str] = None,
2297        column_descriptions: t.Optional[t.Dict[str, str]] = None,
2298        view_properties: t.Optional[t.Dict[str, exp.Expr]] = None,
2299        source_columns: t.Optional[t.List[str]] = None,
2300        **create_kwargs: t.Any,
2301    ) -> None:
2302        """
2303        StarRocks behavior:
2304        - Regular VIEW: supports CREATE OR REPLACE (base behavior)
2305        - MATERIALIZED VIEW: does NOT support CREATE OR REPLACE, so replace=True => DROP + CREATE
2306        """
2307        if not materialized:
2308            return super().create_view(
2309                view_name=view_name,
2310                query_or_df=query_or_df,
2311                target_columns_to_types=target_columns_to_types,
2312                replace=replace,
2313                materialized=False,
2314                materialized_properties=materialized_properties,
2315                table_description=table_description,
2316                column_descriptions=column_descriptions,
2317                view_properties=view_properties,
2318                source_columns=source_columns,
2319                **create_kwargs,
2320            )
2321
2322        # MATERIALIZED VIEW path
2323        # MVs with audits get a synchronous refresh after creation (see _create_materialized_view),
2324        # which requires REFRESH DEFERRED. Validate before the drop so we fail without destroying
2325        # an existing MV.
2326        has_audits = bool((materialized_properties or {}).get("has_audits"))
2327        if has_audits:
2328            self._validate_deferred_refresh_for_audits(view_name, view_properties)
2329
2330        if replace:
2331            # Avoid DROP MATERIALIZED VIEW failure when an object with the same name exists but is not an MV.
2332            self.drop_data_object_on_type_mismatch(
2333                self.get_data_object(view_name), DataObjectType.MATERIALIZED_VIEW
2334            )
2335            self.drop_view(view_name, ignore_if_not_exists=True, materialized=True)
2336        # logger.debug(
2337        #     f"Creating materialized view: {view_name}, materialized: {materialized}, "
2338        #     f"materialized_properties: {materialized_properties}, "
2339        #     f"view_properties: {view_properties}, create_kwargs: {create_kwargs}, "
2340        # )
2341
2342        return self._create_materialized_view(
2343            view_name=view_name,
2344            query_or_df=query_or_df,
2345            target_columns_to_types=target_columns_to_types,
2346            materialized_properties=materialized_properties,
2347            table_description=table_description,
2348            column_descriptions=column_descriptions,
2349            view_properties=view_properties,
2350            source_columns=source_columns,
2351            **create_kwargs,
2352        )
2353
2354    def _create_materialized_view(
2355        self,
2356        view_name: TableName,
2357        query_or_df: QueryOrDF,
2358        target_columns_to_types: t.Optional[t.Dict[str, exp.DataType]] = None,
2359        materialized_properties: t.Optional[t.Dict[str, t.Any]] = None,
2360        table_description: t.Optional[str] = None,
2361        column_descriptions: t.Optional[t.Dict[str, str]] = None,
2362        view_properties: t.Optional[t.Dict[str, exp.Expr]] = None,
2363        source_columns: t.Optional[t.List[str]] = None,
2364        **create_kwargs: t.Any,
2365    ) -> None:
2366        """
2367        Create a StarRocks materialized view.
2368
2369        StarRocks MV schema supports a column list but does NOT support explicit data types in that list.
2370        We therefore build a schema with column names + optional COMMENT only.
2371        """
2372        import pandas as pd
2373
2374        query_or_df = self._native_df_to_pandas_df(query_or_df)
2375
2376        if isinstance(query_or_df, pd.DataFrame):
2377            values: t.List[t.Tuple[t.Any, ...]] = list(
2378                query_or_df.itertuples(index=False, name=None)
2379            )
2380            target_columns_to_types, source_columns = self._columns_to_types(
2381                query_or_df, target_columns_to_types, source_columns
2382            )
2383            if not target_columns_to_types:
2384                raise SQLMeshError("columns_to_types must be provided for dataframes")
2385            source_columns_to_types = get_source_columns_to_types(
2386                target_columns_to_types, source_columns
2387            )
2388            query_or_df = self._values_to_sql(
2389                values,
2390                source_columns_to_types,
2391                batch_start=0,
2392                batch_end=len(values),
2393            )
2394
2395        source_queries, target_columns_to_types = self._get_source_queries_and_columns_to_types(
2396            query_or_df,
2397            target_columns_to_types,
2398            batch_size=0,
2399            target_table=view_name,
2400            source_columns=source_columns,
2401        )
2402        if len(source_queries) != 1:
2403            raise SQLMeshError("Only one source query is supported for creating materialized views")
2404
2405        target_table = exp.to_table(view_name)
2406        schema: t.Union[exp.Table, exp.Schema] = self._build_materialized_view_schema_exp(
2407            target_table,
2408            target_columns_to_types=target_columns_to_types,
2409            column_descriptions=column_descriptions,
2410        )
2411
2412        # Pass model materialized properties through the existing properties builder
2413        partitioned_by = None
2414        clustered_by = None
2415        partition_interval_unit = None
2416        if materialized_properties:
2417            partitioned_by = materialized_properties.get("partitioned_by")
2418            clustered_by = materialized_properties.get("clustered_by")
2419            partition_interval_unit = materialized_properties.get("partition_interval_unit")
2420            # logger.debug(
2421            #     f"Get info from materialized_properties: {materialized_properties}, "
2422            #     f"partitioned_by: {partitioned_by}, "
2423            #     f"clustered_by: {clustered_by}, "
2424            #     f"partition_interval_unit: {partition_interval_unit}"
2425            # )
2426
2427        properties_exp = self._build_table_properties_exp(
2428            catalog_name=target_table.catalog,
2429            table_properties=view_properties,
2430            target_columns_to_types=target_columns_to_types,
2431            table_description=table_description,
2432            partitioned_by=partitioned_by,
2433            clustered_by=clustered_by,
2434            partition_interval_unit=partition_interval_unit,
2435            table_kind="MATERIALIZED_VIEW",
2436        )
2437
2438        with source_queries[0] as query:
2439            self.execute(
2440                exp.Create(
2441                    this=schema,
2442                    kind="VIEW",
2443                    replace=False,
2444                    expression=query,
2445                    properties=properties_exp,
2446                    **create_kwargs,
2447                ),
2448                quote_identifiers=self.QUOTE_IDENTIFIERS_IN_VIEWS,
2449            )
2450
2451        # MVs with audits are created with REFRESH DEFERRED (enforced in create_view), so StarRocks
2452        # does not populate them on creation. Audits need data, so block on a synchronous refresh.
2453        if bool((materialized_properties or {}).get("has_audits")):
2454            refresh_sql = (
2455                f"REFRESH MATERIALIZED VIEW "
2456                f"{exp.to_table(view_name).sql(dialect=self.dialect, identify=True)} "
2457                f"WITH SYNC MODE"
2458            )
2459            self.execute(refresh_sql)
2460
2461        self._clear_data_object_cache(view_name)
2462
2463    def _build_materialized_view_schema_exp(
2464        self,
2465        table: exp.Table,
2466        *,
2467        target_columns_to_types: t.Optional[t.Dict[str, exp.DataType]] = None,
2468        column_descriptions: t.Optional[t.Dict[str, str]] = None,
2469    ) -> t.Union[exp.Table, exp.Schema]:
2470        """
2471        Build a StarRocks MV schema with column names + optional COMMENT only (no types).
2472        """
2473        columns: t.List[str] = []
2474        if target_columns_to_types:
2475            columns = list(target_columns_to_types)
2476        elif column_descriptions:
2477            columns = list(column_descriptions)
2478
2479        if not columns:
2480            return table
2481
2482        column_descriptions = column_descriptions or {}
2483        expressions: t.List[exp.Expr] = []
2484        for col in columns:
2485            constraints: t.List[exp.ColumnConstraint] = []
2486            comment = column_descriptions.get(col)
2487            if comment:
2488                constraints.append(
2489                    exp.ColumnConstraint(
2490                        kind=exp.CommentColumnConstraint(
2491                            this=exp.Literal.string(self._truncate_column_comment(comment))
2492                        )
2493                    )
2494                )
2495            expressions.append(
2496                exp.ColumnDef(
2497                    this=exp.to_identifier(col),
2498                    constraints=constraints,
2499                )
2500            )
2501
2502        return exp.Schema(this=table, expressions=expressions)
2503
2504    # ==================== Table Properties Builder (for Table and MV/VIew) ====================
2505    def _build_table_properties_exp(
2506        self,
2507        catalog_name: t.Optional[str] = None,
2508        table_format: t.Optional[str] = None,
2509        storage_format: t.Optional[str] = None,
2510        partitioned_by: t.Optional[t.List[exp.Expr]] = None,
2511        partition_interval_unit: t.Optional[IntervalUnit] = None,
2512        clustered_by: t.Optional[t.List[exp.Expr]] = None,
2513        table_properties: t.Optional[t.Dict[str, exp.Expr]] = None,
2514        target_columns_to_types: t.Optional[t.Dict[str, exp.DataType]] = None,
2515        table_description: t.Optional[str] = None,
2516        table_kind: t.Optional[str] = None,
2517        **kwargs: t.Any,
2518    ) -> t.Optional[exp.Properties]:
2519        """
2520        Build table properties for StarRocks CREATE TABLE statement.
2521
2522        Unified Model Parameter vs Physical Properties Handling:
2523        This method receives both model parameters (partitioned_by, clustered_by) and
2524        physical_properties (table_properties dict). Priority is handled as follows:
2525
2526        1. primary_key / partitioned_by / clustered_by (ORDER BY)
2527           - Model parameter takes priority
2528           - Falls back to physical_properties.xxx
2529           - Handled in _build_partition_property
2530
2531        2. special for primary_key:
2532           - Still need to be processed in _build_table_key_property
2533
2534        3. Other key types (duplicate_key, unique_key, aggregate_key):
2535           - Only available via physical_properties
2536           - Handled in _build_table_key_property
2537
2538        Handles:
2539        - Key constraints (PRIMARY KEY, DUPLICATE KEY, UNIQUE KEY)
2540        - Partition expressions (RANGE/LIST/EXPRESSION)
2541        - Distribution (HASH/RANDOM)
2542        - Order by (clustering)
2543        - Table comment
2544        - Other properties (replication_num, storage_medium, etc.)
2545
2546        Args:
2547            partitioned_by: Partition columns/expression from model parameter (takes priority)
2548            clustered_by: Clustering columns from model parameter (takes priority)
2549            table_properties: Dictionary containing physical_properties:
2550                - primary_key/duplicate_key/unique_key/aggregate_key: Tuple/list of column names
2551                - partitioned_by(partition_by): Partition definition (fallback)
2552                - distributed_by: Tuple of EQ expressions (kind, expressions, buckets) or string
2553                - clustered_by(order_by): Clustering definition (fallback)
2554                - replication_num, storage_medium, etc.: Literal values
2555            table_description: Table comment
2556        """
2557        properties: t.List[exp.Expr] = []
2558        table_properties_copy = dict(table_properties) if table_properties else {}
2559        # logger.debug(
2560        #     "_build_table_properties_exp: table_properties=%s",
2561        #     table_properties.keys() if table_properties else [],
2562        # )
2563
2564        is_mv = table_kind == "MATERIALIZED_VIEW"
2565        if is_mv:
2566            # Required for CREATE MATERIALIZED VIEW (SQLGlot uses this property to switch the keyword)
2567            properties.append(exp.MaterializedProperty())
2568
2569        # Validate all property names at once
2570        PropertyValidator.check_all_invalid_names(table_properties_copy)
2571
2572        # Check for mutually exclusive key types
2573        # Note: primary_key is already set into table_properties if model param is set
2574        active_key_type = PropertyValidator.check_at_most_one(
2575            property_name="key_type",
2576            property_description="key type",
2577            table_properties=table_properties_copy,
2578        )
2579        if is_mv and active_key_type:
2580            raise SQLMeshError(
2581                f"You can't specify the table type when the table is a materialized view. "
2582                f"Current specified key type '{active_key_type}'."
2583            )
2584
2585        # 0. Extract key columns for partition/distribution validation (read-only, don't pop yet)
2586        key_type, key_columns = None, None
2587        if active_key_type:
2588            key_type = active_key_type
2589            key_expr = table_properties_copy[key_type]
2590            # Use validate_and_normalize_property to get List[exp.Column], then extract names
2591            normalized = PropertyValidator.validate_and_normalize_property(
2592                key_type, key_expr, preprocess_parentheses=True
2593            )
2594            key_columns = tuple(col.name for col in normalized)
2595
2596        # 1. Handle key constraints (ALL types including PRIMARY KEY)
2597        key_prop = self._build_table_key_property(table_properties_copy, active_key_type)
2598        if key_prop:
2599            properties.append(key_prop)
2600
2601        # 2. Add table comment (it must be ahead of other properties except the talbe key/type)
2602        if table_description:
2603            properties.append(
2604                exp.SchemaCommentProperty(
2605                    this=exp.Literal.string(self._truncate_table_comment(table_description))
2606                )
2607            )
2608
2609        # 3. Handle partitioned_by (PARTITION BY RANGE/LIST/EXPRESSION)
2610        partition_prop = self._build_partition_property(
2611            partitioned_by,
2612            partition_interval_unit,
2613            target_columns_to_types,
2614            catalog_name,
2615            table_properties_copy,
2616            key_type,
2617            key_columns,
2618        )
2619        if partition_prop:
2620            properties.append(partition_prop)
2621
2622        # 4. Handle distributed_by (DISTRIBUTED BY HASH/RANDOM)
2623        distributed_prop = self._build_distributed_by_property(table_properties_copy, key_columns)
2624        if distributed_prop:
2625            properties.append(distributed_prop)
2626
2627        # 5. Handle refresh_property (REFRESH ...)
2628        # StarRocks only supports ASYNC materialized views, which require a REFRESH clause.
2629        # Synchronous MVs are not supported, so a missing refresh is a hard error rather than
2630        # a silent fallback (which would create an undetectable sync MV).
2631        if is_mv:
2632            refresh_prop = self._build_refresh_property(table_properties_copy)
2633            if refresh_prop is None:
2634                raise SQLMeshError(
2635                    "StarRocks materialized views require a REFRESH clause. "
2636                    "Specify at least one of 'refresh_moment' or 'refresh_scheme' in the model's "
2637                    "physical_properties (e.g. refresh_scheme = 'ASYNC')."
2638                )
2639            properties.append(refresh_prop)
2640
2641        # 6. Handle order_by/clustered_by (ORDER BY ...)
2642        order_prop = self._build_order_by_property(table_properties_copy, clustered_by or None)
2643        if order_prop:
2644            properties.append(order_prop)
2645
2646        # 5. Handle other properties (replication_num, storage_medium, etc.)
2647        other_props = self._build_other_properties(table_properties_copy)
2648        properties.extend(other_props)
2649
2650        return exp.Properties(expressions=properties) if properties else None
2651
2652    def _build_view_properties_exp(
2653        self,
2654        view_properties: t.Optional[t.Dict[str, exp.Expr]] = None,
2655        table_description: t.Optional[str] = None,
2656        **kwargs: t.Any,
2657    ) -> t.Optional[exp.Properties]:
2658        """
2659        Build CREATE VIEW properties for StarRocks.
2660
2661        Supports StarRocks view SECURITY syntax: SECURITY {NONE | INVOKER}
2662        via exp.SqlSecurityProperty (renders as `SECURITY <value>`).
2663        """
2664        properties: t.List[exp.Expr] = []
2665
2666        if table_description:
2667            properties.append(
2668                exp.SchemaCommentProperty(
2669                    this=exp.Literal.string(self._truncate_table_comment(table_description))
2670                )
2671            )
2672
2673        if view_properties:
2674            view_properties_copy = dict(view_properties)
2675            security = view_properties_copy.pop("security", None)
2676            if security is not None:
2677                security_text = PropertyValidator.validate_and_normalize_property(
2678                    "security", security
2679                )
2680                # exp.SqlSecurityProperty renders as `SECURITY <value>` (no '=')
2681                properties.append(exp.SqlSecurityProperty(this=exp.Var(this=security_text)))
2682
2683            properties.extend(self._table_or_view_properties_to_expressions(view_properties_copy))
2684
2685        if properties:
2686            return exp.Properties(expressions=properties)
2687        return None
2688
2689    def _build_table_key_property(
2690        self, table_properties: t.Dict[str, t.Any], active_key_type: t.Optional[str]
2691    ) -> t.Optional[exp.Expr]:
2692        """
2693        Build key constraint property for ALL key types including PRIMARY KEY.
2694
2695        Unlike other databases where PRIMARY KEY is handled by base class in schema,
2696        StarRocks requires ALL key types (PRIMARY KEY, DUPLICATE KEY, UNIQUE KEY, AGGREGATE KEY)
2697        to be in POST_SCHEMA location (properties section after columns).
2698
2699        Handles:
2700        - PRIMARY KEY
2701        - DUPLICATE KEY
2702        - UNIQUE KEY
2703        - AGGREGATE KEY (when implemented)
2704
2705        Args:
2706            table_properties: Dictionary containing key definitions (will be modified)
2707            active_key_type: The active key type or None
2708
2709        Returns:
2710            Key property expression for the active key type, or None
2711        """
2712        if not active_key_type:
2713            return None
2714
2715        # Configuration: key_name -> Property class (excluding primary_key)
2716        KEY_PROPERTY_CLASSES: t.Dict[str, t.Type[exp.Expr]] = {
2717            "primary_key": exp.PrimaryKey,
2718            "duplicate_key": exp.DuplicateKeyProperty,
2719            "unique_key": exp.UniqueKeyProperty,
2720            # "aggregate_key": exp.AggregateKeyProperty,  # Not implemented yet
2721        }
2722
2723        property_class = KEY_PROPERTY_CLASSES.get(active_key_type)
2724        key_value = table_properties.pop(active_key_type, None)
2725        if not property_class:
2726            # Aggregate key requires special handling
2727            if active_key_type == "aggregate_key":
2728                raise SQLMeshError(
2729                    "AGGREGATE KEY tables are not currently supported. "
2730                    "AGGREGATE KEY requires specifying aggregation functions (SUM/MAX/MIN/REPLACE) "
2731                    "for value columns, which is not supported in the current model configuration syntax. "
2732                    "Please use PRIMARY KEY, UNIQUE KEY, or DUPLICATE KEY instead."
2733                )
2734            # Unknown key type
2735            logger.warning(f"[StarRocks] Unknown key type: {active_key_type}")
2736            return None
2737        if key_value is None:
2738            logger.error(f"Failed to get the parameter value for {active_key_type!r}")
2739            return None
2740
2741        logger.debug(
2742            "_build_table_key_property: input key=%s value=%s",
2743            active_key_type,
2744            key_value,
2745        )
2746
2747        # Validate and normalize
2748        # preprocess_parentheses=True handles string preprocessing like 'id, dt' -> '(id, dt)'
2749        normalized = PropertyValidator.validate_and_normalize_property(
2750            active_key_type, key_value, preprocess_parentheses=True
2751        )
2752        # normalized is List[exp.Column] as defined in TableKeyInputSpec
2753        result = property_class(expressions=list(normalized))
2754        return result
2755
2756    def _build_partition_property(
2757        self,
2758        partitioned_by: t.Optional[t.List[exp.Expr]],
2759        partition_interval_unit: t.Optional["IntervalUnit"],
2760        target_columns_to_types: t.Optional[t.Dict[str, exp.DataType]],
2761        catalog_name: t.Optional[str],
2762        table_properties: t.Dict[str, t.Any],
2763        key_type: t.Optional[str],
2764        key_columns: t.Optional[t.Tuple[str, ...]],
2765    ) -> t.Optional[exp.Expr]:
2766        """
2767        Build partition property expression.
2768
2769        StarRocks supports:
2770        - PARTITION BY RANGE (cols) - for time-based partitions
2771        - PARTITION BY LIST (cols) - for categorical partitions
2772        - PARTITION BY (exprs) - for expression partitions, can also be `exprs` (without `(`, and `)`)
2773
2774        Args:
2775            partitioned_by: Partition column expressions from parameter
2776            partition_interval_unit: Optional time unit for automatic partitioning
2777            target_columns_to_types: Column definitions
2778            catalog_name: Catalog name (if applicable)
2779            table_properties: Dictionary containing partitioned_by/partitions (will be modified)
2780            key_type: Table key type (for validation)
2781            key_columns: Table key columns (partition columns must be subset)
2782
2783        Returns:
2784            Partition property expression or None
2785        """
2786        # Priority: parameter > partition_by (alias) > partitioned_by
2787        # Use PropertyValidator to check mutual exclusion between parameter and properties
2788        partition_param_name = PropertyValidator.check_at_most_one(
2789            property_name="partitioned_by",
2790            property_description="partition definition",
2791            table_properties=table_properties,
2792            parameter_value=partitioned_by or None,
2793        )
2794
2795        # If parameter was provided, it takes priority
2796        if not partitioned_by and partition_param_name:
2797            # Get from table_properties
2798            partitioned_by = table_properties.pop(partition_param_name, None)
2799        if not partitioned_by:
2800            return None
2801
2802        # Parse partition expressions to extract columns and kind (RANGE/LIST)
2803        partition_kind, partition_cols = self._parse_partition_expressions(partitioned_by)
2804        logger.debug(
2805            "_build_partition_property: partition_kind=%s, partition_cols=%s",
2806            partition_kind,
2807            partition_cols,
2808        )
2809
2810        def extract_column_name(expr: exp.Expr) -> t.Optional[str]:
2811            if isinstance(expr, exp.Column):
2812                return str(expr.name)
2813            elif isinstance(expr, (exp.Anonymous, exp.Func)):  # noqa: RET505
2814                return None  # not implemented
2815            else:
2816                return str(expr)
2817
2818        # Validate partition columns are in key columns (StarRocks requirement)
2819        if key_columns:
2820            partition_col_names = set(extract_column_name(expr) for expr in partition_cols) - {None}
2821            key_cols_set = set(key_columns)
2822            not_in_key = partition_col_names - key_cols_set
2823            if not_in_key:
2824                logger.warning(
2825                    f"[StarRocks] Partition columns {not_in_key} not in {key_type} columns {key_cols_set}. "
2826                    "StarRocks requires partition columns to be part of the table key."
2827                )
2828
2829        # Get partition definitions (RANGE/LIST partitions)
2830        # Note: Expression-based partitioning (partition_kind=None) does not support pre-created partitions
2831        if partitions := table_properties.pop("partitions", None):
2832            if partition_kind is None:
2833                logger.warning(
2834                    "[StarRocks] 'partitions' parameter is ignored for expression-based partitioning. "
2835                    "Expression partitioning creates partitions automatically and does not support "
2836                    "pre-created partition definitions."
2837                )
2838                partitions = None  # Ignore partitions for expression-based partitioning
2839            else:
2840                partitions = PropertyValidator.validate_and_normalize_property(
2841                    "partitions", partitions
2842                )
2843
2844        # Build partition expression using base class method
2845        result = self._build_partitioned_by_exp(
2846            partition_cols,
2847            partition_interval_unit=partition_interval_unit,
2848            target_columns_to_types=target_columns_to_types,
2849            catalog_name=catalog_name,
2850            partitions=partitions,
2851            partition_kind=partition_kind,
2852        )
2853        return result
2854
2855    def _parse_partition_expressions(
2856        self, partitioned_by: t.List[exp.Expr]
2857    ) -> t.Tuple[t.Optional[str], t.List[exp.Expr]]:
2858        """
2859        Parse partition expressions and extract partition kind (RANGE/LIST).
2860
2861        Uses PartitionedByInputSpec to validate and normalize the entire list,
2862        then extracts RANGE/LIST kind from function expressions.
2863
2864        The SPEC output is List[exp.Column | exp.Anonymous | exp.Func], where:
2865        - exp.Column: Regular column reference
2866        - exp.Anonymous: Function call like RANGE(col), LIST(col), and other datetime related functions
2867        - exp.Func: date_trunc(), and other built-in functions
2868
2869        Args:
2870            partitioned_by: List of partition expressions
2871
2872        Returns:
2873            Tuple of (partition_kind, normalized_columns)
2874            - partition_kind: "RANGE", "LIST", or None
2875            - normalized_columns: List of Column expressions, or function expressions
2876        """
2877        parsed_cols: t.List[exp.Expr] = []
2878        partition_kind: t.Optional[str] = None
2879
2880        normalized = PropertyValidator.validate_and_normalize_property(
2881            "partitioned_by", partitioned_by, preprocess_parentheses=True
2882        )
2883        # Process each normalized expression
2884        for norm_expr in normalized:
2885            # Check if it's a RANGE function (exp.Anonymous)
2886            if isinstance(norm_expr, exp.Anonymous) and norm_expr.this:
2887                func_name = str(norm_expr.this).upper()
2888                if func_name in ("RANGE", "LIST"):
2889                    partition_kind = func_name
2890                    # Extract column expressions from function arguments
2891                    for arg in norm_expr.expressions:
2892                        if isinstance(arg, exp.Column):
2893                            parsed_cols.append(arg)
2894                        else:
2895                            parsed_cols.append(exp.to_column(str(arg)))
2896                    continue
2897
2898            # Check if it's a LIST expression (SQLGlot parses LIST(...) as exp.List)
2899            if isinstance(norm_expr, exp.List):
2900                partition_kind = "LIST"
2901                # Extract column expressions from list items
2902                for item in norm_expr.expressions:
2903                    if isinstance(item, exp.Column):
2904                        parsed_cols.append(item)
2905                    else:
2906                        parsed_cols.append(exp.to_column(str(item)))
2907                continue
2908
2909            # Regular column or other function (date_trunc, etc.)
2910            parsed_cols.append(norm_expr)
2911
2912        return partition_kind, parsed_cols
2913
2914    def _build_partitioned_by_exp(
2915        self,
2916        partitioned_by: t.List[exp.Expr],
2917        *,
2918        partition_interval_unit: t.Optional["IntervalUnit"] = None,
2919        target_columns_to_types: t.Optional[t.Dict[str, exp.DataType]] = None,
2920        catalog_name: t.Optional[str] = None,
2921        **kwargs: t.Any,
2922    ) -> t.Optional[
2923        t.Union[
2924            exp.PartitionedByProperty,
2925            exp.PartitionByRangeProperty,
2926            exp.PartitionByListProperty,
2927            exp.Property,
2928        ]
2929    ]:
2930        """
2931        Build StarRocks partitioning expression.
2932
2933        - partition_kind: RANGE/LIST/None (passed via kwargs, None as expression partitioning)
2934        - partitioned_by: normalized partition column/func/anonymous expressions
2935        - partitions: partition definitions as List[str] (passed via kwargs)
2936
2937        Supports both RANGE and LIST partition syntaxes, and expression partition syntax.
2938
2939        Args:
2940            partitioned_by: List of partition column expressions
2941            partition_interval_unit: Optional time unit (unused for now)
2942            target_columns_to_types: Column definitions (unused for now)
2943            catalog_name: Catalog name (unused for now)
2944            **kwargs: Must contain 'partition_kind' and optionally 'partitions'
2945
2946        Returns:
2947            PartitionByRangeProperty, PartitionByListProperty, or None
2948        """
2949        partition_kind = kwargs.get("partition_kind")
2950        partitions: t.Optional[t.List[str]] = kwargs.get("partitions")
2951
2952        # Process partitions to create_expressions
2953        # partitions is already List[str] after SPEC normalization
2954        create_expressions: t.Optional[t.List[exp.Var]] = None
2955        if partitions:
2956            create_expressions = [exp.Var(this=p, quoted=False) for p in partitions]
2957
2958        # Build partition expression
2959        if partition_kind == "LIST":
2960            return exp.PartitionByListProperty(
2961                partition_expressions=partitioned_by,
2962                create_expressions=create_expressions,
2963            )
2964        elif partition_kind == "RANGE":  # noqa: RET505
2965            return exp.PartitionByRangeProperty(
2966                partition_expressions=partitioned_by,
2967                create_expressions=create_expressions,
2968            )
2969        elif partition_kind is None:
2970            return exp.PartitionedByProperty(this=exp.Schema(expressions=partitioned_by))
2971
2972        return None
2973
2974    def _build_distributed_by_property(
2975        self,
2976        table_properties: t.Dict[str, t.Any],
2977        key_columns: t.Optional[t.Tuple[str, ...]],
2978    ) -> t.Optional[exp.DistributedByProperty]:
2979        """
2980        Build DISTRIBUTED BY property from table_properties.
2981
2982        Supports:
2983        1. Structured tuple: (kind='HASH', columns=(id, dt), buckets=10)
2984        2. String format: "HASH(id)", "RANDOM", "HASH(id) BUCKETS 10"
2985        3. None: Returns None (no default distribution)
2986
2987        For complex string like "HASH(id) BUCKETS 10", uses split-and-combine:
2988        - Split on 'BUCKETS' to separate HASH part and bucket count
2989        - Parse HASH part via DistributedByInputSpec
2990        - Parse bucket count as number
2991        - Combine into unified dict
2992
2993        Args:
2994            table_properties: Dictionary containing distributed_by (will be modified)
2995            key_columns: Table key columns (used for default distribution)
2996
2997        Returns:
2998            DistributedByProperty or None
2999        """
3000        distributed_by = table_properties.pop("distributed_by", None)
3001
3002        # No default - if not set, return None
3003        if distributed_by is None:
3004            return None
3005
3006        # Try to parse complex string with BUCKETS first
3007        unified = self._parse_distribution_with_buckets(distributed_by)
3008        if unified is None:
3009            # Fall back to SPEC-based parsing
3010            normalized = PropertyValidator.validate_and_normalize_property(
3011                "distributed_by", distributed_by
3012            )
3013            # Convert to unified dict format
3014            unified = DistributionTupleOutputType.to_unified_dict(normalized)
3015
3016        logger.debug(
3017            "_build_distributed_by_property: normalized to kind=%s, columns=%s, buckets=%s",
3018            unified.get("kind"),
3019            unified.get("columns"),
3020            unified.get("buckets"),
3021        )
3022
3023        # Build expression
3024        kind_expr = exp.Var(this=unified["kind"])
3025        # Convert columns to expressions
3026        columns: t.List[exp.Column] = unified.get("columns", [])
3027        expressions_list: t.List[exp.Expr] = []
3028        for col in columns:
3029            if isinstance(col, exp.Expr):
3030                expressions_list.append(col)
3031            else:
3032                expressions_list.append(exp.to_column(str(col)))
3033        # Build buckets expression
3034        buckets: t.Optional[t.Any] = unified.get("buckets")
3035        buckets_expr: t.Optional[exp.Expr] = None
3036        if buckets is not None:
3037            if isinstance(buckets, exp.Literal):
3038                buckets_expr = buckets
3039            else:
3040                buckets_expr = exp.Literal.number(int(buckets))
3041
3042        result = exp.DistributedByProperty(
3043            kind=kind_expr,
3044            expressions=expressions_list,
3045            buckets=buckets_expr,
3046            order=None,
3047        )
3048        return result
3049
3050    def _validate_deferred_refresh_for_audits(
3051        self,
3052        view_name: TableName,
3053        view_properties: t.Optional[t.Dict[str, exp.Expr]],
3054    ) -> None:
3055        """
3056        Ensure a materialized view with audits uses REFRESH DEFERRED.
3057
3058        StarRocks audits require data to exist in the MV, so SQLMesh issues an explicit synchronous
3059        `REFRESH MATERIALIZED VIEW ... WITH SYNC MODE` right after creating the MV. For that to be
3060        deterministic, the MV must use `refresh_moment = 'DEFERRED'`; otherwise StarRocks' automatic
3061        (IMMEDIATE) refresh would run concurrently and race with the explicit one. A missing
3062        refresh_moment defaults to IMMEDIATE in StarRocks, so it is rejected as well.
3063        """
3064        refresh_moment = (view_properties or {}).get("refresh_moment")
3065        normalized = (
3066            PropertyValidator.validate_and_normalize_property("refresh_moment", refresh_moment)
3067            if refresh_moment is not None
3068            else None
3069        )
3070        if normalized != "DEFERRED":
3071            raise SQLMeshError(
3072                f"[StarRocks] Materialized view '{exp.to_table(view_name).sql(dialect=self.dialect)}' "
3073                "has audits, which require a synchronous refresh after creation. This is only "
3074                "supported with deferred refresh, so the model must set "
3075                "`refresh_moment = 'DEFERRED'` in its physical_properties "
3076                f"(got {normalized or 'no refresh_moment; StarRocks defaults to IMMEDIATE'}). "
3077                "DEFERRED prevents StarRocks' "
3078                "automatic refresh from racing with the synchronous refresh SQLMesh issues."
3079            )
3080
3081    def _build_refresh_property(
3082        self, table_properties: t.Dict[str, t.Any]
3083    ) -> t.Optional[exp.RefreshTriggerProperty]:
3084        """
3085        Build StarRocks MV REFRESH clause as exp.RefreshTriggerProperty.
3086
3087        Input (from physical_properties):
3088        - refresh_moment: IMMEDIATE | DEFERRED (optional)
3089        - refresh_scheme: MANUAL | ASYNC [START (<start_time>)] EVERY (INTERVAL <n> <unit>) (optional)
3090
3091        Output mapping (to match sqlglot StarRocks generator refreshtriggerproperty_sql):
3092        - method: refresh_moment when provided; otherwise a sentinel that won't render
3093        - kind: ASYNC | MANUAL
3094        - starts/every/unit: parsed from refresh_scheme if present
3095        """
3096        refresh_moment = table_properties.pop("refresh_moment", None)
3097        refresh_scheme = table_properties.pop("refresh_scheme", None)
3098        if refresh_moment is None and refresh_scheme is None:
3099            return None
3100
3101        # method is required by exp.RefreshTriggerProperty, but StarRocks syntax does NOT support AUTO.
3102        # We use a sentinel value that the StarRocks generator will not render (it only renders
3103        # IMMEDIATE/DEFERRED).
3104        method_expr = None
3105        if refresh_moment is not None:
3106            refresh_moment_text = PropertyValidator.validate_and_normalize_property(
3107                "refresh_moment", refresh_moment
3108            )
3109            method_expr = exp.Var(this=refresh_moment_text)
3110
3111        kind_expr: t.Optional[exp.Expr] = None
3112        starts_expr: t.Optional[exp.Expr] = None
3113        every_expr: t.Optional[exp.Expr] = None
3114        unit_expr: t.Optional[exp.Expr] = None
3115
3116        if refresh_scheme is not None:
3117            scheme_text = PropertyValidator.validate_and_normalize_property(
3118                "refresh_scheme", refresh_scheme
3119            )
3120            if isinstance(scheme_text, exp.Var):
3121                kind_expr = scheme_text
3122            else:
3123                kind_expr, starts_expr, every_expr, unit_expr = self._parse_refresh_scheme(
3124                    scheme_text
3125                )
3126
3127        return exp.RefreshTriggerProperty(
3128            method=method_expr,
3129            kind=kind_expr,
3130            starts=starts_expr,
3131            every=every_expr,
3132            unit=unit_expr,
3133        )
3134
3135    def _parse_refresh_scheme(
3136        self, refresh_scheme: str
3137    ) -> t.Tuple[
3138        t.Optional[exp.Expr],
3139        t.Optional[exp.Expr],
3140        t.Optional[exp.Expr],
3141        t.Optional[exp.Expr],
3142    ]:
3143        """
3144        Parse StarRocks refresh_scheme text into (kind, starts, every, unit).
3145
3146        parsing simple and robust. We only extract:
3147        - kind: ASYNC | MANUAL (must appear at the beginning), None if not provided
3148        - starts: START (<start_time>) where <start_time> is treated as a raw string
3149        - every/unit: EVERY (INTERVAL <n> <unit>)
3150        """
3151        text = (refresh_scheme or "").strip()
3152        if not text:
3153            return None, None, None, None
3154
3155        m_kind = re.match(r"^(MANUAL|ASYNC)\b", text, flags=re.IGNORECASE)
3156        if not m_kind:
3157            raise SQLMeshError(
3158                f"[StarRocks] Invalid refresh_scheme {refresh_scheme!r}. Expected to start with MANUAL or ASYNC."
3159            )
3160        kind = m_kind.group(1).upper()
3161        kind_expr: t.Optional[exp.Expr] = exp.Var(this=kind)
3162
3163        starts_expr: t.Optional[exp.Expr] = None
3164        every_expr: t.Optional[exp.Expr] = None
3165        unit_expr: t.Optional[exp.Expr] = None
3166        m_start = re.search(
3167            r"\bSTART\s*\(\s*(?:'([^']*)'|\"([^\"]*)\"|([^)]*))\s*\)", text, flags=re.IGNORECASE
3168        )
3169        if m_start:
3170            start_inner = (m_start.group(1) or m_start.group(2) or m_start.group(3) or "").strip()
3171            starts_expr = exp.Literal.string(start_inner)
3172        m_every = re.search(
3173            r"\bEVERY\s*\(\s*INTERVAL\s+(\d+)\s+(\w+)\s*\)", text, flags=re.IGNORECASE
3174        )
3175        if m_every:
3176            every_expr = exp.Literal.number(int(m_every.group(1)))
3177            unit_expr = exp.Var(this=m_every.group(2).upper())
3178        return kind_expr, starts_expr, every_expr, unit_expr
3179
3180    def _parse_distribution_with_buckets(
3181        self, distributed_by: t.Any
3182    ) -> t.Optional[t.Dict[str, t.Any]]:
3183        """
3184        Parse complex distribution expressions like 'HASH(id) BUCKETS 10'.
3185
3186        Since SQLGlot cannot parse 'HASH(id) BUCKETS 10' directly, we:
3187        1. Detect if input is a string containing 'BUCKETS'
3188        2. Split into HASH part and BUCKETS part
3189        3. Parse HASH part via DistributedByInputSpec
3190        4. Extract bucket count as number
3191        5. Combine into unified dict
3192
3193        Args:
3194            distributed_by: The distribution value (may be string, expression, etc.)
3195
3196        Returns:
3197            Unified dict with keys: kind, columns, buckets
3198            Returns None if not a complex BUCKETS expression
3199            (The output function will still handle "HASH(id)" without BUCKETS)
3200        """
3201        # Only handle string or Literal string values
3202        if isinstance(distributed_by, str):
3203            text = distributed_by
3204        elif isinstance(distributed_by, exp.Literal) and distributed_by.is_string:
3205            text = str(distributed_by.this)
3206        else:
3207            return None
3208
3209        # Check if contains BUCKETS keyword (case-insensitive)
3210        if "BUCKETS" not in text.upper():
3211            return None
3212
3213        # Split on BUCKETS (case-insensitive)
3214        match = re.match(r"^(.+?)\s+BUCKETS\s+(\d+)\s*$", text.strip(), flags=re.IGNORECASE)
3215        if not match:
3216            return None
3217
3218        hash_part = match.group(1).strip()
3219        buckets_str = match.group(2)
3220
3221        # Parse the HASH/RANDOM part via SPEC
3222        normalized = PropertyValidator.validate_and_normalize_property("distributed_by", hash_part)
3223
3224        return DistributionTupleOutputType.to_unified_dict(normalized, int(buckets_str))
3225
3226    def _build_order_by_property(
3227        self,
3228        table_properties: t.Dict[str, t.Any],
3229        clustered_by: t.Optional[t.List[exp.Expr]],
3230    ) -> t.Optional[exp.Cluster]:
3231        """
3232        Build ORDER BY (clustering) property.
3233
3234        Supports both:
3235        - clustered_by parameter (from create_table call)
3236        - order_by in table_properties (backward compatibility alias)
3237
3238        Priority: clustered_by parameter > order_by in table_properties
3239
3240        Args:
3241            table_properties: Dictionary containing optional order_by (will be modified)
3242            clustered_by: Clustering columns from parameter
3243
3244        Returns:
3245            Cluster expression (generates ORDER BY) or None
3246        """
3247        # Priority: clustered_by parameter > order_by in table_properties
3248        # Use PropertyValidator to check mutual exclusion between parameter and property
3249        order_by_param_name = PropertyValidator.check_at_most_one(
3250            property_name="clustered_by",
3251            property_description="clustering definition",
3252            table_properties=table_properties,
3253            parameter_value=clustered_by,
3254        )
3255
3256        # If parameter was provided, it takes priority
3257        if clustered_by is None and order_by_param_name:
3258            # Get order_by from table_properties (already validated by check_at_most_one)
3259            order_by = table_properties.pop(order_by_param_name, None)
3260            if order_by is not None:
3261                normalized = PropertyValidator.validate_and_normalize_property(
3262                    "clustered_by", order_by, preprocess_parentheses=True
3263                )
3264                clustered_by = list(normalized)
3265
3266        if clustered_by:
3267            result = exp.Cluster(expressions=clustered_by)
3268            return result
3269        else:  # noqa: RET505
3270            return None
3271
3272    def _build_other_properties(self, table_properties: t.Dict[str, t.Any]) -> t.List[exp.Property]:
3273        """
3274        Build other literal properties (replication_num, storage_medium, etc.).
3275
3276        Uses validate_and_normalize_property for validation and ensures output is string,
3277        as StarRocks PROPERTIES syntax requires all values to be strings.
3278
3279        Args:
3280            table_properties: Dictionary containing properties (will be modified)
3281
3282        Returns:
3283            List of Property expressions
3284        """
3285        other_props = []
3286
3287        for key, value in list(table_properties.items()):
3288            # Skip special keys handled elsewhere
3289            if key in PropertyValidator.IMPORTANT_PROPERTY_NAMES:
3290                logger.warning(f"[StarRocks] {key!r} should have been processed already, skipping")
3291                continue
3292
3293            # Remove from properties
3294            table_properties.pop(key)
3295
3296            # Validate and normalize to string
3297            # All other properties are treated as generic string properties
3298            try:
3299                normalized = PropertyValidator.validate_and_normalize_property(key, value)
3300                other_props.append(
3301                    exp.Property(
3302                        this=exp.to_identifier(key),
3303                        value=exp.Literal.string(str(normalized)),
3304                    )
3305                )
3306            except SQLMeshError as e:
3307                logger.warning("[StarRocks] skipping property %s due to error: %s", key, e)
3308
3309        return other_props
3310
3311    def _extract_and_validate_key_columns(
3312        self,
3313        table_properties: t.Dict[str, t.Any],
3314        primary_key: t.Optional[t.Tuple[str, ...]] = None,
3315    ) -> t.Tuple[t.Optional[str], t.Optional[t.Tuple[str, ...]]]:
3316        """
3317        Extract and validate key columns from table_properties.
3318
3319        All key types require:
3320        - Key columns must be the first N columns in CREATE TABLE
3321        - Column order must match the KEY clause order
3322
3323        Priority:
3324        - Parameter primary_key > table_properties primary_key
3325        - Only one key type allowed per table
3326
3327        Args:
3328            table_properties: Table properties dictionary (lowercase keys expected)
3329            primary_key: Primary key from method parameter (highest priority)
3330
3331        Returns:
3332            Tuple of (key_type, key_columns)
3333            - key_type: One of 'primary_key', 'unique_key', 'duplicate_key', 'aggregate_key', None
3334            - key_columns: Tuple of column names, or None
3335
3336        Raises:
3337            SQLMeshError: If multiple key types are defined or column extraction fails
3338        """
3339        # Use PropertyValidator to check mutual exclusion
3340        active_key_type = PropertyValidator.check_at_most_one(
3341            property_name="key_type",  # dummy
3342            property_description="table key type",
3343            table_properties=table_properties,
3344            parameter_value=primary_key,
3345        )
3346
3347        # If parameter primary_key was provided, return it
3348        if primary_key:
3349            return ("primary_key", primary_key)
3350
3351        # Extract from table_properties
3352        if not active_key_type:
3353            return (None, None)
3354
3355        # Get the key expression and normalize via SPEC
3356        key_expr = table_properties[active_key_type]  # Read without popping
3357        # Use validate_and_normalize_property to get List[exp.Column], then extract names
3358        normalized = PropertyValidator.validate_and_normalize_property(
3359            active_key_type, key_expr, preprocess_parentheses=True
3360        )
3361        key_columns = tuple(col.name for col in normalized)
3362
3363        return (active_key_type, key_columns)
3364
3365    def _reorder_columns_for_key(
3366        self,
3367        target_columns_to_types: t.Dict[str, exp.DataType],
3368        key_columns: t.Tuple[str, ...],
3369        key_type: str = "key",
3370    ) -> t.Dict[str, exp.DataType]:
3371        """
3372        Reorder columns to place key columns first.
3373
3374        StarRocks Constraint (ALL Table Types):
3375        Key columns (PRIMARY/UNIQUE/DUPLICATE/AGGREGATE) MUST be the first N columns
3376        in the CREATE TABLE statement, in the same order as defined in the KEY clause.
3377
3378        Example:
3379            Input:
3380                columns = {"customer_id": INT, "order_id": BIGINT, "event_date": DATE}
3381                key_columns = ("order_id", "event_date")
3382                key_type = "primary_key"
3383
3384            Output:
3385                {"order_id": BIGINT, "event_date": DATE, "customer_id": INT}
3386
3387        Args:
3388            target_columns_to_types: Original column order (from SELECT)
3389            key_columns: Key column names in desired order
3390            key_type: Type of key for logging (primary_key, unique_key, etc.)
3391
3392        Returns:
3393            Reordered columns with key columns first
3394
3395        Raises:
3396            SQLMeshError: If a key column is not found in target_columns_to_types
3397        """
3398        # Validate that all key columns exist
3399        missing_key_cols = set(key_columns) - set(target_columns_to_types.keys())
3400        if missing_key_cols:
3401            raise SQLMeshError(
3402                f"{key_type} columns {missing_key_cols} not found in table columns. "
3403                f"Available columns: {list(target_columns_to_types.keys())}"
3404            )
3405
3406        # Build new ordered dict: key columns first, then remaining columns
3407        reordered = {}
3408
3409        # 1. Add key columns in key order
3410        for key_col in key_columns:
3411            reordered[key_col] = target_columns_to_types[key_col]
3412
3413        # 2. Add remaining columns (preserve original order)
3414        for col_name, col_type in target_columns_to_types.items():
3415            if col_name not in key_columns:
3416                reordered[col_name] = col_type
3417
3418        logger.info(
3419            f"Reordered columns for {key_type.upper()}: "
3420            f"Original order: {list(target_columns_to_types.keys())}, "
3421            f"New order: {list(reordered.keys())}"
3422        )
3423
3424        return reordered
3425
3426    def _build_create_comment_table_exp(
3427        self, table: exp.Table, table_comment: str, table_kind: str = "TABLE"
3428    ) -> str:
3429        """
3430        Build ALTER TABLE COMMENT SQL for table comment modification.
3431
3432        StarRocks uses non-standard syntax for table comments:
3433            ALTER TABLE {table} COMMENT = '{comment}'
3434
3435        Note: This method is typically NOT called for StarRocks because the table comment is
3436        included directly in CREATE TABLE (and CTAS) via SchemaCommentProperty, which StarRocks
3437        accepts even for `CREATE TABLE ... COMMENT '...' AS SELECT`.
3438
3439        However, this override is provided for potential future use cases:
3440        - Modifying comments on existing tables via ALTER TABLE
3441        - View comments (if COMMENT_CREATION_VIEW changes)
3442
3443        Args:
3444            table: Table expression
3445            table_comment: The comment to add
3446            table_kind: Type of object (TABLE, VIEW, etc.)
3447
3448        Returns:
3449            SQL string for ALTER TABLE COMMENT
3450        """
3451        table_sql = table.sql(dialect=self.dialect, identify=True)
3452        comment_sql = exp.Literal.string(self._truncate_table_comment(table_comment)).sql(
3453            dialect=self.dialect
3454        )
3455        return f"ALTER TABLE {table_sql} COMMENT = {comment_sql}"
3456
3457    def _build_create_comment_column_exp(
3458        self,
3459        table: exp.Table,
3460        column_name: str,
3461        column_comment: str,
3462        table_kind: str = "TABLE",
3463    ) -> str:
3464        """
3465        Build ALTER TABLE MODIFY COLUMN SQL for column comment modification.
3466
3467        StarRocks accepts the comment without re-stating the column type:
3468            ALTER TABLE {table} MODIFY COLUMN {column} COMMENT '{comment}'
3469
3470        Because COMMENT_CREATION_TABLE = IN_SCHEMA_DEF_NO_CTAS, column comments are inlined for a
3471        plain CREATE TABLE but NOT for CTAS (StarRocks rejects types/comments in a CTAS column
3472        list). This method is therefore the fallback used to register column comments after a CTAS,
3473        and to modify column comments on existing tables.
3474
3475        Args:
3476            table: Table expression
3477            column_name: Name of the column
3478            column_comment: The comment to add
3479            table_kind: Type of object (TABLE, VIEW, etc.)
3480
3481        Returns:
3482            SQL string for ALTER TABLE MODIFY COLUMN with COMMENT
3483        """
3484        table_sql = table.sql(dialect=self.dialect, identify=True)
3485        column_sql = exp.to_identifier(column_name).sql(dialect=self.dialect, identify=True)
3486
3487        comment_sql = exp.Literal.string(self._truncate_column_comment(column_comment)).sql(
3488            dialect=self.dialect
3489        )
3490
3491        return f"ALTER TABLE {table_sql} MODIFY COLUMN {column_sql} COMMENT {comment_sql}"
3492
3493    # ==================== Methods NOT Needing Override (Base Class Works) ====================
3494    # The following methods work correctly with base class implementation:
3495    # - columns(): Query column definitions via DESCRIBE TABLE
3496    # - table_exists(): Check if table exists via information_schema
3497    # - insert_append(): Standard INSERT INTO ... SELECT
3498    # - insert_overwrite_by_time_partition(): Uses DELETE_INSERT strategy (handled by base)
3499    # - fetchall() / fetchone(): Standard query execution
3500    # - execute(): Base SQL execution. (Modifyed for `FOR UPDATE` lock operation only)
3501    # - create_table_properties(): Delegate to _build_table_properties_exp()
logger = <Logger sqlmesh.core.engine_adapter.starrocks (WARNING)>

Declarative type system for property validation and normalization.

This module provides a declarative way to define property types with clear separation between validation (type checking) and normalization (type conversion).

Validated = typing.Any
Normalized = typing.Any
PROPERTY_OUTPUT_TYPES = {'column', 'ast_expr', 'str', 'literal', 'identifier', 'var'}
def parse_fragment( text: str) -> Union[sqlglot.expressions.core.Expr, List[sqlglot.expressions.core.Expr]]:
63def parse_fragment(text: str) -> t.Union[exp.Expr, t.List[exp.Expr]]:
64    """
65    Try to parse a DSL fragment into SQLGlot AST(s).
66
67    Behavior:
68    1. If parse_one succeeds, return the exp.Expr.
69    2. If fails but text contains comma, split by commas and parse each part.
70    3. If it's parenthesized like "(a, b)", parse and return exp.Tuple or list.
71    4. If it's a simple token like "IDENT", return exp.Identifier.
72    """
73    if isinstance(text, exp.Expr):
74        return text
75
76    if not isinstance(text, str):
77        raise TypeError("parse_fragment expects a string")
78
79    s = text.strip()
80    try:
81        parsed = sqlglot.parse_one(s)
82        return parsed
83    except Exception:
84        raise ValueError(f"Unable to parse fragment: {s}")

Try to parse a DSL fragment into SQLGlot AST(s).

Behavior:

  1. If parse_one succeeds, return the exp.Expr.
  2. If fails but text contains comma, split by commas and parse each part.
  3. If it's parenthesized like "(a, b)", parse and return exp.Tuple or list.
  4. If it's a simple token like "IDENT", return exp.Identifier.
class DeclarativeType:
 90class DeclarativeType:
 91    """
 92    Base class for declarative type system.
 93
 94    Design Philosophy:
 95    -----------------
 96    - validate(value): Type checking only - returns validated intermediate value or None
 97    - normalize(validated): Type conversion only - transforms to target output format
 98
 99    Methods:
100    --------
101    validate(value) -> Optional[Validated]
102        Check if value conforms to this type, maybe include some tiny different types
103        Returns: Validated intermediate value if valid, None otherwise.
104
105    normalize(validated) -> Normalized
106        Convert validated intermediate value to final output format.
107        Returns: Normalized value in target format.
108
109    __call__(value) -> Normalized
110        Convenience method: validate + normalize in one step.
111    """
112
113    def validate(self, value: t.Any) -> t.Optional[Validated]:
114        """Check if value conforms to this type. Return validated value or None.
115        String that can be parsed as literal
116        """
117        raise NotImplementedError(f"{self.__class__.__name__}.validate() must be implemented")
118
119    def normalize(self, validated: Validated) -> Normalized:
120        """Convert validated intermediate value to final output format."""
121        # Default: identity transformation
122        return validated
123
124    def __call__(self, value: t.Any) -> Normalized:
125        """Validate and normalize in one step."""
126        validated = self.validate(value)
127        if validated is None:
128            raise ValueError(f"Value {value!r} does not conform to type {self.__class__.__name__}")
129        return self.normalize(validated)

Base class for declarative type system.

Design Philosophy:

  • validate(value): Type checking only - returns validated intermediate value or None
  • normalize(validated): Type conversion only - transforms to target output format

Methods:

validate(value) -> Optional[Validated] Check if value conforms to this type, maybe include some tiny different types Returns: Validated intermediate value if valid, None otherwise.

normalize(validated) -> Normalized Convert validated intermediate value to final output format. Returns: Normalized value in target format.

__call__(value) -> Normalized Convenience method: validate + normalize in one step.

def validate(self, value: Any) -> Optional[Any]:
113    def validate(self, value: t.Any) -> t.Optional[Validated]:
114        """Check if value conforms to this type. Return validated value or None.
115        String that can be parsed as literal
116        """
117        raise NotImplementedError(f"{self.__class__.__name__}.validate() must be implemented")

Check if value conforms to this type. Return validated value or None. String that can be parsed as literal

def normalize(self, validated: Any) -> Any:
119    def normalize(self, validated: Validated) -> Normalized:
120        """Convert validated intermediate value to final output format."""
121        # Default: identity transformation
122        return validated

Convert validated intermediate value to final output format.

class StringType(DeclarativeType):
135class StringType(DeclarativeType):
136    """
137    String type validator.
138
139    Accepts:
140    - Python str only
141
142    Validation: Returns the string if valid, None otherwise.
143    Normalization: Returns the string as-is (identity).
144    """
145
146    def __init__(self, normalized_type: str = "str"):
147        """
148        Args:
149            normalized_type: Target type for normalization.
150                - "literal": Convert to exp.Literal.string()
151                - "str": Keep as string (default)
152                - "identifier": Convert to exp.Identifier
153        """
154        self.normalized_type = normalized_type
155
156    def validate(self, value: t.Any) -> t.Optional[str]:
157        """Check if value is a Python string. Returns string or None."""
158        return value if isinstance(value, str) else None
159
160    def normalize(self, validated: str) -> str:
161        """Return string as-is (identity normalization)."""
162        return validated

String type validator.

Accepts:

  • Python str only

Validation: Returns the string if valid, None otherwise. Normalization: Returns the string as-is (identity).

StringType(normalized_type: str = 'str')
146    def __init__(self, normalized_type: str = "str"):
147        """
148        Args:
149            normalized_type: Target type for normalization.
150                - "literal": Convert to exp.Literal.string()
151                - "str": Keep as string (default)
152                - "identifier": Convert to exp.Identifier
153        """
154        self.normalized_type = normalized_type
Arguments:
  • normalized_type: Target type for normalization.
    • "literal": Convert to exp.Literal.string()
    • "str": Keep as string (default)
    • "identifier": Convert to exp.Identifier
normalized_type
def validate(self, value: Any) -> Optional[str]:
156    def validate(self, value: t.Any) -> t.Optional[str]:
157        """Check if value is a Python string. Returns string or None."""
158        return value if isinstance(value, str) else None

Check if value is a Python string. Returns string or None.

def normalize(self, validated: str) -> str:
160    def normalize(self, validated: str) -> str:
161        """Return string as-is (identity normalization)."""
162        return validated

Return string as-is (identity normalization).

class LiteralType(DeclarativeType):
165class LiteralType(DeclarativeType):
166    """
167    Literal type validator.
168
169    Accepts:
170    - exp.Literal only (from AST)
171    - String that can be parsed as literal
172
173    Validation: Returns exp.Literal if valid, None otherwise.
174    Normalization: Converts to target type based on normalized_type parameter.
175    """
176
177    def __init__(self, normalized_type: t.Optional[str] = None):
178        """
179        Args:
180            normalized_type: Target type for normalization.
181                - None: Keep as exp.Literal (default)
182                - "literal": Keep as exp.Literal
183                - "str": Convert to Python string
184        """
185        self.normalized_type = normalized_type
186
187    def validate(self, value: t.Any) -> t.Optional[exp.Literal]:
188        """Check if value is a literal type. Returns exp.Literal or None."""
189        # Try parsing string first
190        if isinstance(value, str):
191            try:
192                value = parse_fragment(value)
193            except Exception:
194                return None
195
196        # Check if it's a Literal
197        if isinstance(value, exp.Literal):
198            return value
199
200        return None
201
202    def normalize(self, validated: exp.Literal) -> t.Union[exp.Literal, str]:
203        """Convert to target type based on normalized_type."""
204        if self.normalized_type == "str":
205            return validated.this
206        # None or "literal" - keep as-is
207        return validated

Literal type validator.

Accepts:

  • exp.Literal only (from AST)
  • String that can be parsed as literal

Validation: Returns exp.Literal if valid, None otherwise. Normalization: Converts to target type based on normalized_type parameter.

LiteralType(normalized_type: Optional[str] = None)
177    def __init__(self, normalized_type: t.Optional[str] = None):
178        """
179        Args:
180            normalized_type: Target type for normalization.
181                - None: Keep as exp.Literal (default)
182                - "literal": Keep as exp.Literal
183                - "str": Convert to Python string
184        """
185        self.normalized_type = normalized_type
Arguments:
  • normalized_type: Target type for normalization.
    • None: Keep as exp.Literal (default)
    • "literal": Keep as exp.Literal
    • "str": Convert to Python string
normalized_type
def validate(self, value: Any) -> Optional[sqlglot.expressions.core.Literal]:
187    def validate(self, value: t.Any) -> t.Optional[exp.Literal]:
188        """Check if value is a literal type. Returns exp.Literal or None."""
189        # Try parsing string first
190        if isinstance(value, str):
191            try:
192                value = parse_fragment(value)
193            except Exception:
194                return None
195
196        # Check if it's a Literal
197        if isinstance(value, exp.Literal):
198            return value
199
200        return None

Check if value is a literal type. Returns exp.Literal or None.

def normalize( self, validated: sqlglot.expressions.core.Literal) -> Union[sqlglot.expressions.core.Literal, str]:
202    def normalize(self, validated: exp.Literal) -> t.Union[exp.Literal, str]:
203        """Convert to target type based on normalized_type."""
204        if self.normalized_type == "str":
205            return validated.this
206        # None or "literal" - keep as-is
207        return validated

Convert to target type based on normalized_type.

class IdentifierType(DeclarativeType):
210class IdentifierType(DeclarativeType):
211    """
212    Identifier type validator.
213
214    Accepts:
215    - exp.Identifier only
216    - String that can be parsed as identifier
217
218    Validation: Returns exp.Identifier if valid, None otherwise.
219    Normalization: Converts to target type based on normalized_type parameter.
220    """
221
222    def __init__(self, normalized_type: t.Optional[str] = None):
223        """
224        Args:
225            normalized_type: Target type for normalization.
226                - None: Keep as exp.Identifier (default)
227                - "literal": Convert to exp.Literal.string()
228                - "str": Convert to Python string
229                - "identifier": Keep as exp.Identifier
230                - "column": Convert to exp.Column
231        """
232        self.normalized_type = normalized_type
233
234    def validate(self, value: t.Any) -> t.Optional[exp.Identifier]:
235        """Check if value is an identifier type. Returns exp.Identifier or None."""
236        # Try parsing string first
237        if isinstance(value, str):
238            try:
239                value = parse_fragment(value)
240            except Exception:
241                return None
242
243        # Check if it's an Identifier
244        if isinstance(value, exp.Identifier):
245            return value
246
247        return None
248
249    def normalize(
250        self, validated: exp.Identifier
251    ) -> t.Union[exp.Identifier, exp.Column, exp.Literal, str]:
252        """Convert to target type based on normalized_type."""
253        if self.normalized_type == "column":
254            return exp.column(validated.this)
255        if self.normalized_type == "literal":
256            return exp.Literal.string(validated.this)
257        if self.normalized_type == "str":
258            return validated.this
259        # None or "identifier" - keep as-is
260        return validated

Identifier type validator.

Accepts:

  • exp.Identifier only
  • String that can be parsed as identifier

Validation: Returns exp.Identifier if valid, None otherwise. Normalization: Converts to target type based on normalized_type parameter.

IdentifierType(normalized_type: Optional[str] = None)
222    def __init__(self, normalized_type: t.Optional[str] = None):
223        """
224        Args:
225            normalized_type: Target type for normalization.
226                - None: Keep as exp.Identifier (default)
227                - "literal": Convert to exp.Literal.string()
228                - "str": Convert to Python string
229                - "identifier": Keep as exp.Identifier
230                - "column": Convert to exp.Column
231        """
232        self.normalized_type = normalized_type
Arguments:
  • normalized_type: Target type for normalization.
    • None: Keep as exp.Identifier (default)
    • "literal": Convert to exp.Literal.string()
    • "str": Convert to Python string
    • "identifier": Keep as exp.Identifier
    • "column": Convert to exp.Column
normalized_type
def validate(self, value: Any) -> Optional[sqlglot.expressions.core.Identifier]:
234    def validate(self, value: t.Any) -> t.Optional[exp.Identifier]:
235        """Check if value is an identifier type. Returns exp.Identifier or None."""
236        # Try parsing string first
237        if isinstance(value, str):
238            try:
239                value = parse_fragment(value)
240            except Exception:
241                return None
242
243        # Check if it's an Identifier
244        if isinstance(value, exp.Identifier):
245            return value
246
247        return None

Check if value is an identifier type. Returns exp.Identifier or None.

def normalize( self, validated: sqlglot.expressions.core.Identifier) -> Union[sqlglot.expressions.core.Identifier, sqlglot.expressions.core.Column, sqlglot.expressions.core.Literal, str]:
249    def normalize(
250        self, validated: exp.Identifier
251    ) -> t.Union[exp.Identifier, exp.Column, exp.Literal, str]:
252        """Convert to target type based on normalized_type."""
253        if self.normalized_type == "column":
254            return exp.column(validated.this)
255        if self.normalized_type == "literal":
256            return exp.Literal.string(validated.this)
257        if self.normalized_type == "str":
258            return validated.this
259        # None or "identifier" - keep as-is
260        return validated

Convert to target type based on normalized_type.

class ColumnType(DeclarativeType):
263class ColumnType(DeclarativeType):
264    """
265    Column type validator.
266
267    Accepts:
268    - exp.Column only
269    - String that can be parsed as column
270
271    Validation: Returns exp.Column if valid, None otherwise.
272    Normalization: Converts to target type based on normalized_type parameter.
273    """
274
275    def __init__(self, normalized_type: t.Optional[str] = None):
276        """
277        Args:
278            normalized_type: Target type for normalization.
279                - None: Keep as exp.Column (default)
280                - "literal": Convert to exp.Literal.string()
281                - "str": Convert to Python string
282                - "identifier": Convert to exp.Identifier
283                - "column": Keep as exp.Column
284        """
285        self.normalized_type = normalized_type
286
287    def validate(self, value: t.Any) -> t.Optional[exp.Column]:
288        """Check if value is a column type. Returns exp.Column or None."""
289        # Try parsing string first
290        if isinstance(value, str):
291            try:
292                value = parse_fragment(value)
293            except Exception:
294                return None
295
296        # Check if it's a Column
297        if isinstance(value, exp.Column):
298            return value
299
300        return None
301
302    def normalize(
303        self, validated: exp.Column
304    ) -> t.Union[exp.Column, exp.Identifier, exp.Literal, str]:
305        """Convert to target type based on normalized_type."""
306        if self.normalized_type == "identifier":
307            return exp.Identifier(this=validated.this)
308        if self.normalized_type == "literal":
309            return exp.Literal.string(validated.this)
310        if self.normalized_type == "str":
311            return str(validated.this)
312        # None or "column" - keep as-is
313        return validated

Column type validator.

Accepts:

  • exp.Column only
  • String that can be parsed as column

Validation: Returns exp.Column if valid, None otherwise. Normalization: Converts to target type based on normalized_type parameter.

ColumnType(normalized_type: Optional[str] = None)
275    def __init__(self, normalized_type: t.Optional[str] = None):
276        """
277        Args:
278            normalized_type: Target type for normalization.
279                - None: Keep as exp.Column (default)
280                - "literal": Convert to exp.Literal.string()
281                - "str": Convert to Python string
282                - "identifier": Convert to exp.Identifier
283                - "column": Keep as exp.Column
284        """
285        self.normalized_type = normalized_type
Arguments:
  • normalized_type: Target type for normalization.
    • None: Keep as exp.Column (default)
    • "literal": Convert to exp.Literal.string()
    • "str": Convert to Python string
    • "identifier": Convert to exp.Identifier
    • "column": Keep as exp.Column
normalized_type
def validate(self, value: Any) -> Optional[sqlglot.expressions.core.Column]:
287    def validate(self, value: t.Any) -> t.Optional[exp.Column]:
288        """Check if value is a column type. Returns exp.Column or None."""
289        # Try parsing string first
290        if isinstance(value, str):
291            try:
292                value = parse_fragment(value)
293            except Exception:
294                return None
295
296        # Check if it's a Column
297        if isinstance(value, exp.Column):
298            return value
299
300        return None

Check if value is a column type. Returns exp.Column or None.

def normalize( self, validated: sqlglot.expressions.core.Column) -> Union[sqlglot.expressions.core.Column, sqlglot.expressions.core.Identifier, sqlglot.expressions.core.Literal, str]:
302    def normalize(
303        self, validated: exp.Column
304    ) -> t.Union[exp.Column, exp.Identifier, exp.Literal, str]:
305        """Convert to target type based on normalized_type."""
306        if self.normalized_type == "identifier":
307            return exp.Identifier(this=validated.this)
308        if self.normalized_type == "literal":
309            return exp.Literal.string(validated.this)
310        if self.normalized_type == "str":
311            return str(validated.this)
312        # None or "column" - keep as-is
313        return validated

Convert to target type based on normalized_type.

class EqType(DeclarativeType):
316class EqType(DeclarativeType):
317    """
318    EQ expression type validator (key=value pairs).
319
320    Accepts:
321    - exp.EQ(left, right)
322    - String that can be parsed as key=value
323
324    Validation: Returns (key_name, value_expr) tuple if valid, None otherwise.
325    Normalization: Returns the (key, value) tuple as-is.
326    """
327
328    def validate(self, value: t.Any) -> t.Optional[t.Tuple[str, t.Any]]:
329        """Check if value is an EQ expression. Returns (key, value) tuple or None."""
330        # Try parsing string first
331        if isinstance(value, str):
332            try:
333                value = parse_fragment(value)
334            except Exception:
335                return None
336
337        # Check if it's an EQ expression
338        if isinstance(value, exp.EQ):
339            # Extract key name from left side
340            left = value.this
341            # Extract value from right side
342            right = value.expression
343
344            key_name = None
345            if isinstance(left, exp.Column):
346                key_name = left.this.name if hasattr(left.this, "name") else str(left.this)
347            elif isinstance(left, exp.Identifier):
348                key_name = left.this
349            elif isinstance(left, str):
350                key_name = left
351            else:
352                key_name = str(left)
353
354            return (key_name, right)
355
356        return None
357
358    def normalize(self, validated: t.Tuple[str, t.Any]) -> t.Tuple[str, t.Any]:
359        """Return (key, value) tuple as-is (identity normalization)."""
360        return validated

EQ expression type validator (key=value pairs).

Accepts:

  • exp.EQ(left, right)
  • String that can be parsed as key=value

Validation: Returns (key_name, value_expr) tuple if valid, None otherwise. Normalization: Returns the (key, value) tuple as-is.

def validate(self, value: Any) -> Optional[Tuple[str, Any]]:
328    def validate(self, value: t.Any) -> t.Optional[t.Tuple[str, t.Any]]:
329        """Check if value is an EQ expression. Returns (key, value) tuple or None."""
330        # Try parsing string first
331        if isinstance(value, str):
332            try:
333                value = parse_fragment(value)
334            except Exception:
335                return None
336
337        # Check if it's an EQ expression
338        if isinstance(value, exp.EQ):
339            # Extract key name from left side
340            left = value.this
341            # Extract value from right side
342            right = value.expression
343
344            key_name = None
345            if isinstance(left, exp.Column):
346                key_name = left.this.name if hasattr(left.this, "name") else str(left.this)
347            elif isinstance(left, exp.Identifier):
348                key_name = left.this
349            elif isinstance(left, str):
350                key_name = left
351            else:
352                key_name = str(left)
353
354            return (key_name, right)
355
356        return None

Check if value is an EQ expression. Returns (key, value) tuple or None.

def normalize(self, validated: Tuple[str, Any]) -> Tuple[str, Any]:
358    def normalize(self, validated: t.Tuple[str, t.Any]) -> t.Tuple[str, t.Any]:
359        """Return (key, value) tuple as-is (identity normalization)."""
360        return validated

Return (key, value) tuple as-is (identity normalization).

class EnumType(DeclarativeType):
363class EnumType(DeclarativeType):
364    """
365    Enumerated value type validator.
366
367    Accepts values from a predefined set of allowed values.
368    Following input types are allowed:
369    - str
370    - exp.Literal
371    - exp.Var
372    - exp.Identifier
373    - exp.Column
374
375    Parameters:
376    -----------
377    valid_values : t.Sequence[str]
378        List of allowed values (e.g., ["HASH", "RANDOM"])
379    normalized_type : t.Optional[str]
380        Target type for normalization:
381        - "str": Python string (default)
382        - "identifier": exp.Identifier
383        - "literal": exp.Literal.string()
384        - "column": exp.Column
385        - "ast_expr": generic exp.Expr (defaults to Identifier)
386    case_sensitive : bool
387        Whether to perform case-sensitive matching (default: False)
388
389    Validation: Checks if value is in allowed set, returns canonical string.
390    Normalization: Converts to specified target type.
391    """
392
393    def __init__(
394        self,
395        valid_values: t.Sequence[str],
396        normalized_type: str = "str",
397        case_sensitive: bool = False,
398    ):
399        self.valid_values = list(valid_values)
400        self.case_sensitive = bool(case_sensitive)
401        self.normalized_type = normalized_type
402
403        if self.normalized_type is not None and self.normalized_type not in PROPERTY_OUTPUT_TYPES:
404            raise ValueError(
405                f"normalized_type must be one of {PROPERTY_OUTPUT_TYPES}, got {self.normalized_type!r}"
406            )
407
408        # Pre-compute normalized values for efficient lookup
409        self._values_normalized = [v if case_sensitive else v.upper() for v in self.valid_values]
410
411    def _extract_text(self, value: t.Any) -> t.Optional[str]:
412        """Extract text from various value types."""
413        if isinstance(value, str):
414            return value
415        if isinstance(value, (exp.Literal, exp.Var)):
416            return str(value.this)
417        if isinstance(value, (exp.Identifier, exp.Column)):
418            # For Identifier/Column, this might be another Expression
419            if isinstance(value.this, str):
420                return value.this
421            elif hasattr(value.this, "name"):  # noqa: RET505
422                return str(value.this.name)
423            else:
424                return str(value.this)
425        return None
426
427    def _normalize_text(self, text: str) -> str:
428        """Normalize text for comparison based on case sensitivity."""
429        return text if self.case_sensitive else text.upper()
430
431    def validate(self, value: t.Any) -> t.Optional[str]:
432        """Check if value is in the allowed enum set. Returns canonical string or None."""
433        # Try parsing string first
434        if isinstance(value, str):
435            try:
436                parsed = parse_fragment(value)
437                # If parsed successfully, extract text from AST node
438                if isinstance(parsed, (exp.Identifier, exp.Literal, exp.Column)):
439                    value = parsed
440            except Exception:
441                # If parsing fails, treat as plain string
442                pass
443
444        # Extract text from value
445        text = self._extract_text(value)
446
447        if text is None:
448            return None
449
450        # Normalize and check against allowed values
451        normalized_text = self._normalize_text(text)
452        if normalized_text in self._values_normalized:
453            return normalized_text
454
455        return None
456
457    def normalize(self, validated: str) -> Normalized:
458        """Convert validated enum string to target type."""
459        # validated is already canonical (e.g., "HASH")
460        if self.normalized_type is None or self.normalized_type == "str":
461            return validated
462        if self.normalized_type == "var":
463            return exp.Var(this=validated)
464        if self.normalized_type == "literal":
465            return exp.Literal.string(validated)
466        if self.normalized_type == "identifier":
467            return exp.Identifier(this=validated)
468        if self.normalized_type == "column":
469            return exp.Column(this=validated)
470        if self.normalized_type == "ast_expr":
471            return exp.Identifier(this=validated)
472
473        # Fallback to string
474        return validated

Enumerated value type validator.

Accepts values from a predefined set of allowed values. Following input types are allowed:

  • str
  • exp.Literal
  • exp.Var
  • exp.Identifier
  • exp.Column

Parameters:

valid_values : t.Sequence[str] List of allowed values (e.g., ["HASH", "RANDOM"]) normalized_type : t.Optional[str] Target type for normalization: - "str": Python string (default) - "identifier": exp.Identifier - "literal": exp.Literal.string() - "column": exp.Column - "ast_expr": generic exp.Expr (defaults to Identifier) case_sensitive : bool Whether to perform case-sensitive matching (default: False)

Validation: Checks if value is in allowed set, returns canonical string. Normalization: Converts to specified target type.

EnumType( valid_values: Sequence[str], normalized_type: str = 'str', case_sensitive: bool = False)
393    def __init__(
394        self,
395        valid_values: t.Sequence[str],
396        normalized_type: str = "str",
397        case_sensitive: bool = False,
398    ):
399        self.valid_values = list(valid_values)
400        self.case_sensitive = bool(case_sensitive)
401        self.normalized_type = normalized_type
402
403        if self.normalized_type is not None and self.normalized_type not in PROPERTY_OUTPUT_TYPES:
404            raise ValueError(
405                f"normalized_type must be one of {PROPERTY_OUTPUT_TYPES}, got {self.normalized_type!r}"
406            )
407
408        # Pre-compute normalized values for efficient lookup
409        self._values_normalized = [v if case_sensitive else v.upper() for v in self.valid_values]
valid_values
case_sensitive
normalized_type
def validate(self, value: Any) -> Optional[str]:
431    def validate(self, value: t.Any) -> t.Optional[str]:
432        """Check if value is in the allowed enum set. Returns canonical string or None."""
433        # Try parsing string first
434        if isinstance(value, str):
435            try:
436                parsed = parse_fragment(value)
437                # If parsed successfully, extract text from AST node
438                if isinstance(parsed, (exp.Identifier, exp.Literal, exp.Column)):
439                    value = parsed
440            except Exception:
441                # If parsing fails, treat as plain string
442                pass
443
444        # Extract text from value
445        text = self._extract_text(value)
446
447        if text is None:
448            return None
449
450        # Normalize and check against allowed values
451        normalized_text = self._normalize_text(text)
452        if normalized_text in self._values_normalized:
453            return normalized_text
454
455        return None

Check if value is in the allowed enum set. Returns canonical string or None.

def normalize(self, validated: str) -> Any:
457    def normalize(self, validated: str) -> Normalized:
458        """Convert validated enum string to target type."""
459        # validated is already canonical (e.g., "HASH")
460        if self.normalized_type is None or self.normalized_type == "str":
461            return validated
462        if self.normalized_type == "var":
463            return exp.Var(this=validated)
464        if self.normalized_type == "literal":
465            return exp.Literal.string(validated)
466        if self.normalized_type == "identifier":
467            return exp.Identifier(this=validated)
468        if self.normalized_type == "column":
469            return exp.Column(this=validated)
470        if self.normalized_type == "ast_expr":
471            return exp.Identifier(this=validated)
472
473        # Fallback to string
474        return validated

Convert validated enum string to target type.

class FuncType(DeclarativeType):
477class FuncType(DeclarativeType):
478    """
479    Function type validator.
480
481    Accepts:
482    - exp.Func (built-in functions like date_trunc, CAST, etc.)
483    - exp.Anonymous (custom/dialect functions like RANGE, LIST)
484    - String that can be parsed as function call
485
486    Validation: Returns exp.Func or exp.Anonymous if valid, None otherwise.
487    Normalization: Returns the function expression as-is (identity).
488
489    Examples:
490        date_trunc('day', col1)     → exp.Func
491        RANGE(col1, col2)           → exp.Anonymous
492        LIST(region, status)        → exp.Anonymous
493    """
494
495    def validate(self, value: t.Any) -> t.Optional[t.Union[exp.Func, exp.Anonymous]]:
496        """Check if value is a function type. Returns exp.Func/exp.Anonymous or None."""
497        # Try parsing string first
498        if isinstance(value, str):
499            try:
500                value = parse_fragment(value)
501            except Exception:
502                return None
503
504        # Check if it's a Func or Anonymous function
505        if isinstance(value, (exp.Func, exp.Anonymous)):
506            return value
507
508        return None
509
510    def normalize(
511        self, validated: t.Union[exp.Func, exp.Anonymous]
512    ) -> t.Union[exp.Func, exp.Anonymous]:
513        """Return function expression as-is (identity normalization)."""
514        return validated

Function type validator.

Accepts:

  • exp.Func (built-in functions like date_trunc, CAST, etc.)
  • exp.Anonymous (custom/dialect functions like RANGE, LIST)
  • String that can be parsed as function call

Validation: Returns exp.Func or exp.Anonymous if valid, None otherwise. Normalization: Returns the function expression as-is (identity).

Examples:

date_trunc('day', col1) → exp.Func RANGE(col1, col2) → exp.Anonymous LIST(region, status) → exp.Anonymous

def validate( self, value: Any) -> Union[sqlglot.expressions.core.Func, sqlglot.expressions.core.Anonymous, NoneType]:
495    def validate(self, value: t.Any) -> t.Optional[t.Union[exp.Func, exp.Anonymous]]:
496        """Check if value is a function type. Returns exp.Func/exp.Anonymous or None."""
497        # Try parsing string first
498        if isinstance(value, str):
499            try:
500                value = parse_fragment(value)
501            except Exception:
502                return None
503
504        # Check if it's a Func or Anonymous function
505        if isinstance(value, (exp.Func, exp.Anonymous)):
506            return value
507
508        return None

Check if value is a function type. Returns exp.Func/exp.Anonymous or None.

def normalize( self, validated: Union[sqlglot.expressions.core.Func, sqlglot.expressions.core.Anonymous]) -> Union[sqlglot.expressions.core.Func, sqlglot.expressions.core.Anonymous]:
510    def normalize(
511        self, validated: t.Union[exp.Func, exp.Anonymous]
512    ) -> t.Union[exp.Func, exp.Anonymous]:
513        """Return function expression as-is (identity normalization)."""
514        return validated

Return function expression as-is (identity normalization).

class AnyOf(DeclarativeType):
520class AnyOf(DeclarativeType):
521    """
522    Union type - accepts first matching subtype.
523
524    This is a combinator type that tries each subtype in order and accepts
525    the first one that validates successfully.
526
527    Validation: Tries each subtype, returns (matched_type, validated_value) tuple.
528    Normalization: Uses the matched subtype's normalize method.
529    """
530
531    def __init__(self, *types: DeclarativeType):
532        if not types:
533            raise ValueError("AnyOf requires at least one type")
534
535        # Validate all types are DeclarativeType instances
536        for type_ in types:
537            if not isinstance(type_, DeclarativeType):
538                raise TypeError(f"AnyOf expects DeclarativeType instances, got {type_!r}")
539
540        self.types: t.List[DeclarativeType] = list(types)
541
542    def validate(self, value: t.Any) -> t.Optional[t.Tuple[DeclarativeType, Validated]]:
543        """Try each subtype in order, return (matched_type, validated_value) or None."""
544        for sub_type in self.types:
545            validated = sub_type.validate(value)
546            if validated is not None:
547                # Return both the matched type and validated value
548                return (sub_type, validated)
549
550        # No type matched
551        return None
552
553    def normalize(self, validated: t.Tuple[DeclarativeType, Validated]) -> Normalized:
554        """Normalize using the matched subtype's normalize method."""
555        matched_type, validated_value = validated
556        return matched_type.normalize(validated_value)

Union type - accepts first matching subtype.

This is a combinator type that tries each subtype in order and accepts the first one that validates successfully.

Validation: Tries each subtype, returns (matched_type, validated_value) tuple. Normalization: Uses the matched subtype's normalize method.

AnyOf(*types: DeclarativeType)
531    def __init__(self, *types: DeclarativeType):
532        if not types:
533            raise ValueError("AnyOf requires at least one type")
534
535        # Validate all types are DeclarativeType instances
536        for type_ in types:
537            if not isinstance(type_, DeclarativeType):
538                raise TypeError(f"AnyOf expects DeclarativeType instances, got {type_!r}")
539
540        self.types: t.List[DeclarativeType] = list(types)
types: List[DeclarativeType]
def validate( self, value: Any) -> Optional[Tuple[DeclarativeType, Any]]:
542    def validate(self, value: t.Any) -> t.Optional[t.Tuple[DeclarativeType, Validated]]:
543        """Try each subtype in order, return (matched_type, validated_value) or None."""
544        for sub_type in self.types:
545            validated = sub_type.validate(value)
546            if validated is not None:
547                # Return both the matched type and validated value
548                return (sub_type, validated)
549
550        # No type matched
551        return None

Try each subtype in order, return (matched_type, validated_value) or None.

def normalize( self, validated: Tuple[DeclarativeType, Any]) -> Any:
553    def normalize(self, validated: t.Tuple[DeclarativeType, Validated]) -> Normalized:
554        """Normalize using the matched subtype's normalize method."""
555        matched_type, validated_value = validated
556        return matched_type.normalize(validated_value)

Normalize using the matched subtype's normalize method.

class SequenceOf(DeclarativeType):
562class SequenceOf(DeclarativeType):
563    """
564    Sequence/List type validator with built-in union type support.
565
566    Accepts various sequence representations and validates each element against
567    one or more possible types (similar to AnyOf for each element).
568    Optionally accepts single elements (promoted to single-item lists).
569
570    Accepts:
571    - exp.Tuple: (a, b, c)
572    - exp.Array: [a, b, c]
573    - exp.Paren: (a) or ((a, b))
574    - Python list/tuple: [a, b] or (a, b)
575    - String: "a, b, c" (parsed)
576    - Single element: a (if allow_single=True, promoted to [a])
577
578    Validation: Returns list of (matched_type, validated_value) tuples or None.
579    Normalization: Returns list of normalized elements using matched type's normalize.
580
581    Examples:
582        # Single type
583        SequenceOf(ColumnType())
584
585        # Multiple types (union) - each element tries types in order
586        SequenceOf(ColumnType(), IdentifierType(), LiteralType())
587
588        # Allow single element
589        SequenceOf(ColumnType(), allow_single=True)
590
591        # Multiple types + allow single
592        SequenceOf(ColumnType(), IdentifierType(), allow_single=True)
593    """
594
595    def __init__(
596        self,
597        *elem_types: DeclarativeType,
598        allow_single: bool = False,
599        output_as: str = "list",
600    ):
601        """
602        Args:
603            *elem_types: One or more type validators for elements.
604                        If multiple types provided, each element tries types in order (AnyOf behavior).
605            allow_single: Whether to accept single elements (promoted to list). Default: False.
606            output_as: Output format - "list" or "tuple". Default: "list".
607        """
608        if not elem_types:
609            raise ValueError("SequenceOf requires at least one element type")
610
611        self.elem_types: t.List[DeclarativeType] = list(elem_types)
612        self.allow_single = allow_single
613        self.output_as = output_as
614
615    def validate(self, value: t.Any) -> t.Optional[t.List[t.Tuple[DeclarativeType, Validated]]]:
616        """Validate each element in the sequence. Returns list of (matched_type, validated_value) tuples or None."""
617        # Extract elements from various container types
618        elems = self._extract_elements(value)
619        if elems is None:
620            return None
621
622        # Validate each element against all possible types (AnyOf behavior)
623        validated_items: t.List[t.Tuple[DeclarativeType, Validated]] = []
624        for elem in elems:
625            # Try each type until one matches
626            matched = False
627            for elem_type in self.elem_types:
628                validated = elem_type.validate(elem)
629                if validated is not None:
630                    validated_items.append((elem_type, validated))
631                    matched = True
632                    break
633
634            # If no type matched, the whole sequence fails if any element fails
635            if not matched:
636                return None
637
638        return validated_items
639
640    def normalize(
641        self, validated: t.List[t.Tuple[DeclarativeType, Validated]]
642    ) -> t.Union[t.List[Normalized], t.Tuple[Normalized, ...]]:
643        """Normalize each validated element using its matched type's normalize method."""
644        normalized_items = [elem_type.normalize(value) for elem_type, value in validated]
645
646        # Convert to desired output format
647        if self.output_as == "tuple":
648            return tuple(normalized_items)
649        return normalized_items  # default: list
650
651    def _extract_elements(self, value: t.Any) -> t.Optional[t.List[t.Any]]:
652        """
653        Extract elements from various container representations.
654        Returns list of raw elements or None if extraction fails.
655        """
656        # Python list/tuple - process first before string parsing
657        if isinstance(value, (list, tuple)):
658            return list(value)
659
660        # Try parsing string for AST types
661        if isinstance(value, str):
662            try:
663                value = parse_fragment(value)
664            except Exception:
665                # If parsing fails and we accept single strings, promote to list
666                if self.allow_single and any(isinstance(t, StringType) for t in self.elem_types):
667                    return [value]
668                return None
669
670        # SQL Tuple: (a, b, c)
671        if isinstance(value, exp.Tuple):
672            return list(value.expressions)
673
674        # SQL Array: [a, b, c]
675        if isinstance(value, exp.Array):
676            return list(value.expressions)
677
678        # SQL Paren: (a) or ((a, b))
679        if isinstance(value, exp.Paren):
680            inner = value.this
681            if isinstance(inner, exp.Tuple):
682                return list(inner.expressions)
683            return [inner]
684
685        # Single AST element: promote to list (if allow_single)
686        if self.allow_single and isinstance(value, exp.Expr):
687            return [value]
688
689        return None

Sequence/List type validator with built-in union type support.

Accepts various sequence representations and validates each element against one or more possible types (similar to AnyOf for each element). Optionally accepts single elements (promoted to single-item lists).

Accepts:

  • exp.Tuple: (a, b, c)
  • exp.Array: [a, b, c]
  • exp.Paren: (a) or ((a, b))
  • Python list/tuple: [a, b] or (a, b)
  • String: "a, b, c" (parsed)
  • Single element: a (if allow_single=True, promoted to [a])

Validation: Returns list of (matched_type, validated_value) tuples or None. Normalization: Returns list of normalized elements using matched type's normalize.

Examples:

Single type

SequenceOf(ColumnType())

Multiple types (union) - each element tries types in order

SequenceOf(ColumnType(), IdentifierType(), LiteralType())

Allow single element

SequenceOf(ColumnType(), allow_single=True)

Multiple types + allow single

SequenceOf(ColumnType(), IdentifierType(), allow_single=True)

SequenceOf( *elem_types: DeclarativeType, allow_single: bool = False, output_as: str = 'list')
595    def __init__(
596        self,
597        *elem_types: DeclarativeType,
598        allow_single: bool = False,
599        output_as: str = "list",
600    ):
601        """
602        Args:
603            *elem_types: One or more type validators for elements.
604                        If multiple types provided, each element tries types in order (AnyOf behavior).
605            allow_single: Whether to accept single elements (promoted to list). Default: False.
606            output_as: Output format - "list" or "tuple". Default: "list".
607        """
608        if not elem_types:
609            raise ValueError("SequenceOf requires at least one element type")
610
611        self.elem_types: t.List[DeclarativeType] = list(elem_types)
612        self.allow_single = allow_single
613        self.output_as = output_as
Arguments:
  • *elem_types: One or more type validators for elements. If multiple types provided, each element tries types in order (AnyOf behavior).
  • allow_single: Whether to accept single elements (promoted to list). Default: False.
  • output_as: Output format - "list" or "tuple". Default: "list".
elem_types: List[DeclarativeType]
allow_single
output_as
def validate( self, value: Any) -> Optional[List[Tuple[DeclarativeType, Any]]]:
615    def validate(self, value: t.Any) -> t.Optional[t.List[t.Tuple[DeclarativeType, Validated]]]:
616        """Validate each element in the sequence. Returns list of (matched_type, validated_value) tuples or None."""
617        # Extract elements from various container types
618        elems = self._extract_elements(value)
619        if elems is None:
620            return None
621
622        # Validate each element against all possible types (AnyOf behavior)
623        validated_items: t.List[t.Tuple[DeclarativeType, Validated]] = []
624        for elem in elems:
625            # Try each type until one matches
626            matched = False
627            for elem_type in self.elem_types:
628                validated = elem_type.validate(elem)
629                if validated is not None:
630                    validated_items.append((elem_type, validated))
631                    matched = True
632                    break
633
634            # If no type matched, the whole sequence fails if any element fails
635            if not matched:
636                return None
637
638        return validated_items

Validate each element in the sequence. Returns list of (matched_type, validated_value) tuples or None.

def normalize( self, validated: List[Tuple[DeclarativeType, Any]]) -> Union[List[Any], Tuple[Any, ...]]:
640    def normalize(
641        self, validated: t.List[t.Tuple[DeclarativeType, Validated]]
642    ) -> t.Union[t.List[Normalized], t.Tuple[Normalized, ...]]:
643        """Normalize each validated element using its matched type's normalize method."""
644        normalized_items = [elem_type.normalize(value) for elem_type, value in validated]
645
646        # Convert to desired output format
647        if self.output_as == "tuple":
648            return tuple(normalized_items)
649        return normalized_items  # default: list

Normalize each validated element using its matched type's normalize method.

class Field:
695class Field:
696    """
697    Field specification for StructuredTupleType.
698
699    Defines validation rules, types, and metadata for a single field.
700
701    Args:
702        type: DeclarativeType instance for validating field value
703        required: Whether this field is required (default: False)
704        aliases: List of alternative field names (default: [])
705        doc: Documentation string for this field
706
707    Example:
708        Field(
709            type=EnumType(["HASH", "RANDOM"]),
710            required=True,
711            aliases=["distribution_type"],
712            doc="Distribution kind: HASH or RANDOM"
713        )
714    """
715
716    def __init__(
717        self,
718        type: DeclarativeType,
719        required: bool = False,
720        aliases: t.Optional[t.List[str]] = None,
721        doc: t.Optional[str] = None,
722    ):
723        self.type = type
724        self.required = required
725        self.aliases = aliases or []
726        self.doc = doc

Field specification for StructuredTupleType.

Defines validation rules, types, and metadata for a single field.

Arguments:
  • type: DeclarativeType instance for validating field value
  • required: Whether this field is required (default: False)
  • aliases: List of alternative field names (default: [])
  • doc: Documentation string for this field
Example:

Field( type=EnumType(["HASH", "RANDOM"]), required=True, aliases=["distribution_type"], doc="Distribution kind: HASH or RANDOM" )

Field( type: DeclarativeType, required: bool = False, aliases: Optional[List[str]] = None, doc: Optional[str] = None)
716    def __init__(
717        self,
718        type: DeclarativeType,
719        required: bool = False,
720        aliases: t.Optional[t.List[str]] = None,
721        doc: t.Optional[str] = None,
722    ):
723        self.type = type
724        self.required = required
725        self.aliases = aliases or []
726        self.doc = doc
type
required
aliases
doc
class StructuredTupleType(DeclarativeType):
732class StructuredTupleType(DeclarativeType):
733    """
734    Base class for validating tuples with typed fields.
735
736    Subclasses define FIELDS dict to specify structure:
737
738    FIELDS = {
739        "field_name": Field(
740            type=SomeType(),
741            required=True,
742            aliases=["alt_name1", "alt_name2"]
743        ),
744        ...
745    }
746
747    Validation Process:
748    1. Parse tuple into key=value pairs (exp.EQ)
749    2. Match keys against FIELDS (including aliases)
750    3. Validate each field value with specified type
751    4. Check required fields are present
752    5. Handle unknown/invalid fields based on error flags
753
754    Returns: Dict[str, Any] with canonical field names as keys
755
756    Example:
757        class DistributionTupleInputType(StructuredTupleType):
758            FIELDS = {
759                "kind": Field(type=EnumType(["HASH", "RANDOM"]), required=True),
760                "columns": Field(type=SequenceOf(ColumnType())),
761            }
762
763    Args:
764        error_on_unknown_field: If True, raise error when encountering unknown fields.
765                                If False, silently skip unknown fields (default: False)
766        error_on_invalid_field: If True, raise error when field value validation fails.
767                                If False, return None for entire validation (default: True)
768    """
769
770    FIELDS: t.Dict[str, Field] = {}  # Subclasses override this
771
772    def __init__(self, error_on_unknown_field: bool = True, error_on_invalid_field: bool = True):
773        self.error_on_unknown_field = error_on_unknown_field
774        self.error_on_invalid_field = error_on_invalid_field
775
776        # Build alias mapping: alias -> canonical_name
777        self._alias_map: t.Dict[str, str] = {}
778        for field_name, field_spec in self.FIELDS.items():
779            # Map canonical name to itself
780            self._alias_map[field_name] = field_name
781            # Map aliases to canonical name
782            for alias in field_spec.aliases:
783                self._alias_map[alias] = field_name
784
785    def validate(
786        self, value: t.Any
787    ) -> t.Optional[t.Dict[str, t.Tuple[DeclarativeType, Validated]]]:
788        """
789        Validate structured tuple.
790
791        Returns: Dict mapping canonical field names to (matched_type, validated_value) tuples,
792                 or None if validation fails.
793
794        Raises:
795            ValueError: If error_on_unknown_field=True and unknown field encountered
796            ValueError: If error_on_invalid_field=True and field validation fails
797        """
798        # Try parsing string first
799        if isinstance(value, str):
800            try:
801                value = parse_fragment(value)
802            except Exception:
803                return None
804
805        # Extract key=value pairs from tuple/paren
806        pairs = self._extract_pairs(value)
807        if pairs is None:
808            return None
809
810        # Validate each pair and build result dict
811        result: t.Dict[str, t.Tuple[DeclarativeType, Validated]] = {}
812        eq_type = EqType()
813
814        for pair_expr in pairs:
815            # Validate as EQ expression
816            eq_validated = eq_type.validate(pair_expr)
817            if eq_validated is None:
818                continue  # Skip non-EQ expressions
819
820            key, value_expr = eq_validated
821
822            # Resolve alias to canonical name
823            canonical_name = self._alias_map.get(key)
824            if canonical_name is None:
825                # Unknown field
826                if self.error_on_unknown_field:
827                    raise ValueError(
828                        f"Unknown field '{key}' in {self.__class__.__name__}. "
829                        f"Valid fields: {list(self.FIELDS.keys())}"
830                    )
831                # Skip unknown field
832                continue
833
834            # Get field spec
835            field_spec = self.FIELDS[canonical_name]
836
837            # Validate field value with specified type
838            validated_value = field_spec.type.validate(value_expr)
839            if validated_value is None:
840                # Field validation failed
841                if self.error_on_invalid_field:
842                    raise ValueError(
843                        f"Invalid value for field '{canonical_name}': {value_expr}. "
844                        f"Expected type: {field_spec.type.__class__.__name__}, "
845                        f"Actual type: {type(value_expr).__name__}"
846                    )
847                # Return None for entire validation
848                return None
849
850            # Store with canonical name
851            result[canonical_name] = (field_spec.type, validated_value)
852
853        # Check required fields
854        for field_name, field_spec in self.FIELDS.items():
855            if field_spec.required and field_name not in result:
856                # Required field missing
857                if self.error_on_invalid_field:
858                    raise ValueError(
859                        f"Required field '{field_name}' is missing in {self.__class__.__name__}"
860                    )
861                return None
862
863        return result
864
865    def normalize(
866        self, validated: t.Dict[str, t.Tuple[DeclarativeType, Validated]]
867    ) -> t.Dict[str, Normalized]:
868        """
869        Normalize validated fields.
870
871        Returns: Dict mapping canonical field names to normalized values.
872        """
873        return {
874            field_name: field_type.normalize(value)
875            for field_name, (field_type, value) in validated.items()
876        }
877
878    def _extract_pairs(self, value: t.Any) -> t.Optional[t.List[t.Any]]:
879        """
880        Extract list of expressions from tuple/paren.
881        Each expression should be an exp.EQ (key=value).
882        """
883        # exp.Tuple: (a=1, b=2)
884        if isinstance(value, list):
885            return value
886        if isinstance(value, exp.Tuple):
887            return list(value.expressions)
888
889        # exp.Paren: (a=1) or ((a=1, b=2))
890        if isinstance(value, exp.Paren):
891            inner = value.this
892            if isinstance(inner, exp.Tuple):
893                return list(inner.expressions)
894            return [inner]
895
896        return None

Base class for validating tuples with typed fields.

Subclasses define FIELDS dict to specify structure:

FIELDS = { "field_name": Field( type=SomeType(), required=True, aliases=["alt_name1", "alt_name2"] ), ... }

Validation Process:

  1. Parse tuple into key=value pairs (exp.EQ)
  2. Match keys against FIELDS (including aliases)
  3. Validate each field value with specified type
  4. Check required fields are present
  5. Handle unknown/invalid fields based on error flags

Returns: Dict[str, Any] with canonical field names as keys

Example:

class DistributionTupleInputType(StructuredTupleType): FIELDS = { "kind": Field(type=EnumType(["HASH", "RANDOM"]), required=True), "columns": Field(type=SequenceOf(ColumnType())), }

Arguments:
  • error_on_unknown_field: If True, raise error when encountering unknown fields. If False, silently skip unknown fields (default: False)
  • error_on_invalid_field: If True, raise error when field value validation fails. If False, return None for entire validation (default: True)
StructuredTupleType( error_on_unknown_field: bool = True, error_on_invalid_field: bool = True)
772    def __init__(self, error_on_unknown_field: bool = True, error_on_invalid_field: bool = True):
773        self.error_on_unknown_field = error_on_unknown_field
774        self.error_on_invalid_field = error_on_invalid_field
775
776        # Build alias mapping: alias -> canonical_name
777        self._alias_map: t.Dict[str, str] = {}
778        for field_name, field_spec in self.FIELDS.items():
779            # Map canonical name to itself
780            self._alias_map[field_name] = field_name
781            # Map aliases to canonical name
782            for alias in field_spec.aliases:
783                self._alias_map[alias] = field_name
FIELDS: Dict[str, Field] = {}
error_on_unknown_field
error_on_invalid_field
def validate( self, value: Any) -> Optional[Dict[str, Tuple[DeclarativeType, Any]]]:
785    def validate(
786        self, value: t.Any
787    ) -> t.Optional[t.Dict[str, t.Tuple[DeclarativeType, Validated]]]:
788        """
789        Validate structured tuple.
790
791        Returns: Dict mapping canonical field names to (matched_type, validated_value) tuples,
792                 or None if validation fails.
793
794        Raises:
795            ValueError: If error_on_unknown_field=True and unknown field encountered
796            ValueError: If error_on_invalid_field=True and field validation fails
797        """
798        # Try parsing string first
799        if isinstance(value, str):
800            try:
801                value = parse_fragment(value)
802            except Exception:
803                return None
804
805        # Extract key=value pairs from tuple/paren
806        pairs = self._extract_pairs(value)
807        if pairs is None:
808            return None
809
810        # Validate each pair and build result dict
811        result: t.Dict[str, t.Tuple[DeclarativeType, Validated]] = {}
812        eq_type = EqType()
813
814        for pair_expr in pairs:
815            # Validate as EQ expression
816            eq_validated = eq_type.validate(pair_expr)
817            if eq_validated is None:
818                continue  # Skip non-EQ expressions
819
820            key, value_expr = eq_validated
821
822            # Resolve alias to canonical name
823            canonical_name = self._alias_map.get(key)
824            if canonical_name is None:
825                # Unknown field
826                if self.error_on_unknown_field:
827                    raise ValueError(
828                        f"Unknown field '{key}' in {self.__class__.__name__}. "
829                        f"Valid fields: {list(self.FIELDS.keys())}"
830                    )
831                # Skip unknown field
832                continue
833
834            # Get field spec
835            field_spec = self.FIELDS[canonical_name]
836
837            # Validate field value with specified type
838            validated_value = field_spec.type.validate(value_expr)
839            if validated_value is None:
840                # Field validation failed
841                if self.error_on_invalid_field:
842                    raise ValueError(
843                        f"Invalid value for field '{canonical_name}': {value_expr}. "
844                        f"Expected type: {field_spec.type.__class__.__name__}, "
845                        f"Actual type: {type(value_expr).__name__}"
846                    )
847                # Return None for entire validation
848                return None
849
850            # Store with canonical name
851            result[canonical_name] = (field_spec.type, validated_value)
852
853        # Check required fields
854        for field_name, field_spec in self.FIELDS.items():
855            if field_spec.required and field_name not in result:
856                # Required field missing
857                if self.error_on_invalid_field:
858                    raise ValueError(
859                        f"Required field '{field_name}' is missing in {self.__class__.__name__}"
860                    )
861                return None
862
863        return result

Validate structured tuple.

Returns: Dict mapping canonical field names to (matched_type, validated_value) tuples, or None if validation fails.

Raises:
  • ValueError: If error_on_unknown_field=True and unknown field encountered
  • ValueError: If error_on_invalid_field=True and field validation fails
def normalize( self, validated: Dict[str, Tuple[DeclarativeType, Any]]) -> Dict[str, Any]:
865    def normalize(
866        self, validated: t.Dict[str, t.Tuple[DeclarativeType, Validated]]
867    ) -> t.Dict[str, Normalized]:
868        """
869        Normalize validated fields.
870
871        Returns: Dict mapping canonical field names to normalized values.
872        """
873        return {
874            field_name: field_type.normalize(value)
875            for field_name, (field_type, value) in validated.items()
876        }

Normalize validated fields.

Returns: Dict mapping canonical field names to normalized values.

class DistributionTupleInputType(StructuredTupleType):
899class DistributionTupleInputType(StructuredTupleType):
900    """
901    StarRocks distribution tuple validator.
902
903    Accepts:
904    - (kind='HASH', columns=(id, dt), buckets=10)
905    - (kind='HASH', expressions=(id, dt), bucket_num=10)
906    - (kind='RANDOM')
907
908    Returns: Dict with fields:
909        - kind: "HASH" or "RANDOM" (string)
910        - columns: List[exp.Column] (optional, for HASH)
911        - buckets: exp.Literal (optional)
912
913    Field Aliases:
914        - columns: expressions
915        - buckets: bucket, bucket_num
916
917    Examples:
918        Input:  (kind='HASH', columns=(id, dt), buckets=10)
919        Output: {
920            'kind': 'HASH',
921            'columns': [exp.Column('id'), exp.Column('dt')],
922            'buckets': exp.Literal.number(10)
923        }
924
925        Input:  (kind='RANDOM')
926        Output: {'kind': 'RANDOM'}
927
928    Conversion:
929        Use factory methods to convert normalized values to unified dict format:
930        - from_enum(): Convert EnumType normalized value (str) → dict
931        - from_func(): Convert FuncType normalized value (exp.Func) → dict
932        - to_unified_dict(): Convert any normalized value → dict
933    """
934
935    FIELDS = {
936        "kind": Field(
937            type=EnumType(["HASH", "RANDOM"], normalized_type="str"),
938            required=True,
939            doc="Distribution type: HASH or RANDOM",
940        ),
941        "columns": Field(
942            type=SequenceOf(
943                ColumnType(),
944                IdentifierType(normalized_type="column"),
945                allow_single=True,
946            ),
947            required=False,
948            aliases=["expressions"],
949            doc="Columns for HASH distribution",
950        ),
951        "buckets": Field(
952            type=AnyOf(LiteralType(), StringType(normalized_type="literal")),
953            required=False,
954            aliases=["bucket", "bucket_num"],
955            doc="Number of buckets",
956        ),
957    }

StarRocks distribution tuple validator.

Accepts:

  • (kind='HASH', columns=(id, dt), buckets=10)
  • (kind='HASH', expressions=(id, dt), bucket_num=10)
  • (kind='RANDOM')

Returns: Dict with fields: - kind: "HASH" or "RANDOM" (string) - columns: List[exp.Column] (optional, for HASH) - buckets: exp.Literal (optional)

Field Aliases:
  • columns: expressions
  • buckets: bucket, bucket_num
Examples:

Input: (kind='HASH', columns=(id, dt), buckets=10) Output: { 'kind': 'HASH', 'columns': [exp.Column('id'), exp.Column('dt')], 'buckets': exp.Literal.number(10) }

Input: (kind='RANDOM') Output: {'kind': 'RANDOM'}

Conversion:

Use factory methods to convert normalized values to unified dict format:

  • from_enum(): Convert EnumType normalized value (str) → dict
  • from_func(): Convert FuncType normalized value (exp.Func) → dict
  • to_unified_dict(): Convert any normalized value → dict
FIELDS = {'kind': <Field object>, 'columns': <Field object>, 'buckets': <Field object>}
class DistributionTupleOutputType(StructuredTupleType):
 960class DistributionTupleOutputType(StructuredTupleType):
 961    """
 962    Output validator for distribution tuple.
 963
 964    Used to validate normalized distribution values which are already dicts.
 965    Overrides validate() to handle dict input directly (for output validation),
 966    while parent class handles tuple/string input (for input validation).
 967    """
 968
 969    FIELDS = {
 970        "kind": Field(
 971            type=EnumType(["HASH", "RANDOM"]),
 972            required=True,
 973        ),
 974        "columns": Field(
 975            type=SequenceOf(ColumnType(), allow_single=False),
 976            required=False,
 977        ),
 978        "buckets": Field(
 979            type=LiteralType(),
 980            required=False,
 981        ),
 982    }
 983
 984    def validate(self, value: t.Any) -> t.Optional[t.Dict[str, t.Any]]:
 985        """
 986        Validate a distribution value for OUTPUT validation.
 987
 988        For output validation, accepts:
 989        - dict: Validate structure directly (normalized output)
 990        - tuple/string: Delegate to parent class (for completeness)
 991
 992        Returns: The dict if valid, None otherwise
 993        """
 994        # For output validation, handle dict directly
 995        if isinstance(value, dict):
 996            # Validate required 'kind' field
 997            kind = value.get("kind")
 998            if kind is None:
 999                return None
1000
1001            # Validate 'kind' is a valid enum value
1002            kind_spec = self.FIELDS["kind"].type
1003            if kind_spec.validate(kind) is None:
1004                return None
1005
1006            # Validate 'columns' if present
1007            columns = value.get("columns")
1008            if columns is not None:
1009                columns_spec = self.FIELDS["columns"].type
1010                if columns_spec.validate(columns) is None:
1011                    return None
1012
1013            # Validate 'buckets' if present
1014            buckets = value.get("buckets")
1015            if buckets is not None:
1016                buckets_spec = self.FIELDS["buckets"].type
1017                if buckets_spec.validate(buckets) is None:
1018                    return None
1019
1020            return value
1021
1022        # For tuple/string, delegate to parent class
1023        return super().validate(value)
1024
1025    # ============================================================
1026    # Factory methods for conversion from other normalized types
1027    # ============================================================
1028
1029    @staticmethod
1030    def from_enum(enum_value: str, buckets: t.Optional[int] = None) -> t.Dict[str, t.Any]:
1031        """
1032        Create distribution dict from EnumType normalized value.
1033
1034        Args:
1035            enum_value: "RANDOM" (from EnumType)
1036            buckets: Optional bucket count
1037
1038        Returns:
1039            Dict with kind/columns/buckets fields
1040
1041        Example:
1042            >>> DistributionTupleOutputType.from_enum("RANDOM")
1043            {'kind': 'RANDOM', 'columns': [], 'buckets': None}
1044        """
1045        return {"kind": enum_value, "columns": [], "buckets": buckets}
1046
1047    @staticmethod
1048    def from_func(
1049        func: t.Union[exp.Func, exp.Anonymous], buckets: t.Optional[int] = None
1050    ) -> t.Dict[str, t.Any]:
1051        """
1052        Create distribution dict from FuncType normalized value.
1053
1054        Args:
1055            func: HASH(id, dt) or RANDOM() (from FuncType)
1056            buckets: Optional bucket count
1057
1058        Returns:
1059            Dict with kind/columns/buckets fields
1060
1061        Example:
1062            >> func = sqlglot.parse_one("HASH(id, dt)")
1063            >> DistributionTupleOutputType.from_func(func)
1064            {"kind": "HASH", "columns": [exp.Column("id"), exp.Column("dt")], "buckets": None}
1065        """
1066        func_name = func.name.upper() if hasattr(func, "name") else str(func.this).upper()
1067
1068        if func_name == "HASH":
1069            # Extract columns from HASH(col1, col2, ...)
1070            columns: list[exp.Column] = [func.this] if isinstance(func.this, exp.Column) else []
1071            columns.extend(func.expressions)
1072            return {"kind": "HASH", "columns": columns, "buckets": buckets}
1073        elif func_name == "RANDOM":  # noqa: RET505
1074            return {"kind": "RANDOM", "columns": [], "buckets": buckets}
1075        else:
1076            raise ValueError(f"Unknown distribution function: {func_name}")
1077
1078    @staticmethod
1079    def to_unified_dict(
1080        normalized_value: t.Any, buckets: t.Optional[int] = None
1081    ) -> t.Dict[str, t.Any]:
1082        """
1083        Convert any normalized distribution value to unified dict format.
1084
1085        This is a convenience method that dispatches to appropriate factory method.
1086
1087        Args:
1088            normalized_value: Result from DistributedByInputSpec normalization
1089                             (dict | str | exp.Func)
1090            buckets: Optional bucket count override
1091
1092        Returns:
1093            Unified dict with kind/columns/buckets fields
1094
1095        Raises:
1096            TypeError: If value type is not supported
1097
1098        Example:
1099            >>> # From DistributionTupleOutputType
1100            >>> DistributionTupleOutputType.to_unified_dict({"kind": "HASH", "columns": [...]})
1101            {'kind': 'HASH', 'columns': [Ellipsis]}
1102
1103            >>> # From EnumType
1104            >>> DistributionTupleOutputType.to_unified_dict("RANDOM")
1105            {'kind': 'RANDOM', 'columns': [], 'buckets': None}
1106
1107            >> # From FuncType
1108            >> DistributionTupleOutputType.to_unified_dict(sqlglot.parse_one("HASH(id)"))
1109            {'kind': 'HASH', 'columns': [exp.Column('id')], 'buckets': None}
1110        """
1111        if isinstance(normalized_value, dict):
1112            # Already in DistributionTupleInputType format
1113            return normalized_value
1114        elif isinstance(normalized_value, str):  # noqa: RET505
1115            # From EnumType: "RANDOM"
1116            return DistributionTupleOutputType.from_enum(normalized_value, buckets)
1117        elif isinstance(normalized_value, (exp.Func, exp.Anonymous)):
1118            # From FuncType: HASH(id, dt)
1119            return DistributionTupleOutputType.from_func(normalized_value, buckets)
1120        else:
1121            raise TypeError(
1122                f"Cannot convert {type(normalized_value).__name__} to distribution dict. "
1123                f"Expected dict, str, or exp.Func/exp.Anonymous."
1124            )

Output validator for distribution tuple.

Used to validate normalized distribution values which are already dicts. Overrides validate() to handle dict input directly (for output validation), while parent class handles tuple/string input (for input validation).

FIELDS = {'kind': <Field object>, 'columns': <Field object>, 'buckets': <Field object>}
def validate(self, value: Any) -> Optional[Dict[str, Any]]:
 984    def validate(self, value: t.Any) -> t.Optional[t.Dict[str, t.Any]]:
 985        """
 986        Validate a distribution value for OUTPUT validation.
 987
 988        For output validation, accepts:
 989        - dict: Validate structure directly (normalized output)
 990        - tuple/string: Delegate to parent class (for completeness)
 991
 992        Returns: The dict if valid, None otherwise
 993        """
 994        # For output validation, handle dict directly
 995        if isinstance(value, dict):
 996            # Validate required 'kind' field
 997            kind = value.get("kind")
 998            if kind is None:
 999                return None
1000
1001            # Validate 'kind' is a valid enum value
1002            kind_spec = self.FIELDS["kind"].type
1003            if kind_spec.validate(kind) is None:
1004                return None
1005
1006            # Validate 'columns' if present
1007            columns = value.get("columns")
1008            if columns is not None:
1009                columns_spec = self.FIELDS["columns"].type
1010                if columns_spec.validate(columns) is None:
1011                    return None
1012
1013            # Validate 'buckets' if present
1014            buckets = value.get("buckets")
1015            if buckets is not None:
1016                buckets_spec = self.FIELDS["buckets"].type
1017                if buckets_spec.validate(buckets) is None:
1018                    return None
1019
1020            return value
1021
1022        # For tuple/string, delegate to parent class
1023        return super().validate(value)

Validate a distribution value for OUTPUT validation.

For output validation, accepts:

  • dict: Validate structure directly (normalized output)
  • tuple/string: Delegate to parent class (for completeness)

Returns: The dict if valid, None otherwise

@staticmethod
def from_enum(enum_value: str, buckets: Optional[int] = None) -> Dict[str, Any]:
1029    @staticmethod
1030    def from_enum(enum_value: str, buckets: t.Optional[int] = None) -> t.Dict[str, t.Any]:
1031        """
1032        Create distribution dict from EnumType normalized value.
1033
1034        Args:
1035            enum_value: "RANDOM" (from EnumType)
1036            buckets: Optional bucket count
1037
1038        Returns:
1039            Dict with kind/columns/buckets fields
1040
1041        Example:
1042            >>> DistributionTupleOutputType.from_enum("RANDOM")
1043            {'kind': 'RANDOM', 'columns': [], 'buckets': None}
1044        """
1045        return {"kind": enum_value, "columns": [], "buckets": buckets}

Create distribution dict from EnumType normalized value.

Arguments:
  • enum_value: "RANDOM" (from EnumType)
  • buckets: Optional bucket count
Returns:

Dict with kind/columns/buckets fields

Example:
>>> DistributionTupleOutputType.from_enum("RANDOM")
{'kind': 'RANDOM', 'columns': [], 'buckets': None}
@staticmethod
def from_func( func: Union[sqlglot.expressions.core.Func, sqlglot.expressions.core.Anonymous], buckets: Optional[int] = None) -> Dict[str, Any]:
1047    @staticmethod
1048    def from_func(
1049        func: t.Union[exp.Func, exp.Anonymous], buckets: t.Optional[int] = None
1050    ) -> t.Dict[str, t.Any]:
1051        """
1052        Create distribution dict from FuncType normalized value.
1053
1054        Args:
1055            func: HASH(id, dt) or RANDOM() (from FuncType)
1056            buckets: Optional bucket count
1057
1058        Returns:
1059            Dict with kind/columns/buckets fields
1060
1061        Example:
1062            >> func = sqlglot.parse_one("HASH(id, dt)")
1063            >> DistributionTupleOutputType.from_func(func)
1064            {"kind": "HASH", "columns": [exp.Column("id"), exp.Column("dt")], "buckets": None}
1065        """
1066        func_name = func.name.upper() if hasattr(func, "name") else str(func.this).upper()
1067
1068        if func_name == "HASH":
1069            # Extract columns from HASH(col1, col2, ...)
1070            columns: list[exp.Column] = [func.this] if isinstance(func.this, exp.Column) else []
1071            columns.extend(func.expressions)
1072            return {"kind": "HASH", "columns": columns, "buckets": buckets}
1073        elif func_name == "RANDOM":  # noqa: RET505
1074            return {"kind": "RANDOM", "columns": [], "buckets": buckets}
1075        else:
1076            raise ValueError(f"Unknown distribution function: {func_name}")

Create distribution dict from FuncType normalized value.

Arguments:
  • func: HASH(id, dt) or RANDOM() (from FuncType)
  • buckets: Optional bucket count
Returns:

Dict with kind/columns/buckets fields

Example:

func = sqlglot.parse_one("HASH(id, dt)") DistributionTupleOutputType.from_func(func) {"kind": "HASH", "columns": [exp.Column("id"), exp.Column("dt")], "buckets": None}

@staticmethod
def to_unified_dict(normalized_value: Any, buckets: Optional[int] = None) -> Dict[str, Any]:
1078    @staticmethod
1079    def to_unified_dict(
1080        normalized_value: t.Any, buckets: t.Optional[int] = None
1081    ) -> t.Dict[str, t.Any]:
1082        """
1083        Convert any normalized distribution value to unified dict format.
1084
1085        This is a convenience method that dispatches to appropriate factory method.
1086
1087        Args:
1088            normalized_value: Result from DistributedByInputSpec normalization
1089                             (dict | str | exp.Func)
1090            buckets: Optional bucket count override
1091
1092        Returns:
1093            Unified dict with kind/columns/buckets fields
1094
1095        Raises:
1096            TypeError: If value type is not supported
1097
1098        Example:
1099            >>> # From DistributionTupleOutputType
1100            >>> DistributionTupleOutputType.to_unified_dict({"kind": "HASH", "columns": [...]})
1101            {'kind': 'HASH', 'columns': [Ellipsis]}
1102
1103            >>> # From EnumType
1104            >>> DistributionTupleOutputType.to_unified_dict("RANDOM")
1105            {'kind': 'RANDOM', 'columns': [], 'buckets': None}
1106
1107            >> # From FuncType
1108            >> DistributionTupleOutputType.to_unified_dict(sqlglot.parse_one("HASH(id)"))
1109            {'kind': 'HASH', 'columns': [exp.Column('id')], 'buckets': None}
1110        """
1111        if isinstance(normalized_value, dict):
1112            # Already in DistributionTupleInputType format
1113            return normalized_value
1114        elif isinstance(normalized_value, str):  # noqa: RET505
1115            # From EnumType: "RANDOM"
1116            return DistributionTupleOutputType.from_enum(normalized_value, buckets)
1117        elif isinstance(normalized_value, (exp.Func, exp.Anonymous)):
1118            # From FuncType: HASH(id, dt)
1119            return DistributionTupleOutputType.from_func(normalized_value, buckets)
1120        else:
1121            raise TypeError(
1122                f"Cannot convert {type(normalized_value).__name__} to distribution dict. "
1123                f"Expected dict, str, or exp.Func/exp.Anonymous."
1124            )

Convert any normalized distribution value to unified dict format.

This is a convenience method that dispatches to appropriate factory method.

Arguments:
  • normalized_value: Result from DistributedByInputSpec normalization (dict | str | exp.Func)
  • buckets: Optional bucket count override
Returns:

Unified dict with kind/columns/buckets fields

Raises:
  • TypeError: If value type is not supported
Example:
>>> # From DistributionTupleOutputType
>>> DistributionTupleOutputType.to_unified_dict({"kind": "HASH", "columns": [...]})
{'kind': 'HASH', 'columns': [Ellipsis]}
>>> # From EnumType
>>> DistributionTupleOutputType.to_unified_dict("RANDOM")
{'kind': 'RANDOM', 'columns': [], 'buckets': None}

From FuncType

DistributionTupleOutputType.to_unified_dict(sqlglot.parse_one("HASH(id)")) {'kind': 'HASH', 'columns': [exp.Column('id')], 'buckets': None}

class PropertySpecs:
1130class PropertySpecs:
1131    # Accepts:
1132    # - Single column: id
1133    # - Multiple columns: (id, dt)
1134    # - String for string input: "id, dt" (will be auto-wrapped and parsed by preprocess_parentheses)
1135    GeneralColumnListInputSpec = SequenceOf(
1136        ColumnType(),
1137        StringType(normalized_type="column"),
1138        IdentifierType(normalized_type="column"),
1139        allow_single=True,
1140    )
1141
1142    # TableKey: Simple key specification (primary_key, duplicate_key, unique_key, aggregate_key)
1143    # Accepts:
1144    # - Single column: id
1145    # - Multiple columns: (id, dt)
1146    TableKeyInputSpec = GeneralColumnListInputSpec
1147
1148    # Partitioned By: Flexible partition specification
1149    # Accepts:
1150    # - Single column: col1
1151    # - Multiple columns: (col1, col2)
1152    # - Mixed: (col1, "col2") - string will be parsed
1153    # - RANGE(col1) or RANGE(col1, col2)
1154    # - LIST(col1) or LIST(col1, col2)
1155    # - Expression: (date_trunc('day', col1), col2)
1156    PartitionedByInputSpec = SequenceOf(
1157        ColumnType(),
1158        StringType(normalized_type="column"),
1159        IdentifierType(normalized_type="column"),
1160        FuncType(),  # RANGE(), LIST(), date_trunc(), etc.
1161        allow_single=True,
1162    )
1163
1164    # Partitions: List of partition definitions (strings)
1165    # Accepts:
1166    # - Single partition: 'PARTITION p1 VALUES LESS THAN ("2024-01-01")'
1167    # - Multiple partitions: ('PARTITION p1 ...', 'PARTITION p2 ...')
1168    # Note: Single string is auto-promoted to list
1169    PartitionsInputSpec = SequenceOf(
1170        StringType(), LiteralType(normalized_type="str"), allow_single=True
1171    )
1172
1173    # Distribution: StarRocks distribution specification
1174    # Accepts:
1175    # - Structured tuple1: (kind='HASH', columns=(id, dt), buckets=10)
1176    # - Structured tuple2: (kind='RANDOM')
1177    # - String format: "HASH(id)", "RANDOM", or "(kind='HASH', columns=(id), buckets=10)"
1178    # Note: Does NOT accept simple columns like id or (id, dt)
1179    #    And it can't directly accept "HASH(id) BUCKETS 10", you need to split it with "BUCKETS" to two parts.
1180    DistributedByInputSpec = AnyOf(
1181        DistributionTupleInputType(),  # Try structured tuple first (most specific)
1182        EnumType(["RANDOM"], normalized_type="str"),  # "RANDOM"
1183        FuncType(),  # "HASH(id)",
1184    )
1185
1186    # OrderBy: Simple ordering specification
1187    # Accepts:
1188    # - Single column: dt
1189    # - Multiple columns: (dt, id, status)
1190    OrderByInputSpec = GeneralColumnListInputSpec
1191
1192    # Refresh scheme: Accepts various types, normalizes to string
1193    # For properties like refresh_scheme, it can be a string, identifier, or column
1194    RefreshSchemeInputSpec = AnyOf(
1195        EnumType(["ASYNC", "MANUAL"], normalized_type="var"),
1196        ColumnType(normalized_type="str"),  # Columns → will be converted to string
1197        IdentifierType(normalized_type="str"),  # Identifiers → will be converted to string
1198        LiteralType(normalized_type="str"),  # Numbers and string → to string
1199        StringType(),  # Plain strings
1200    )
1201
1202    # Generic property value: Accepts various types, normalizes to string
1203    # For properties like replication_num, storage_medium, etc.
1204    # StarRocks PROPERTIES syntax requires all values to be strings: "value"
1205    # So we normalize everything to string for consistent SQL generation
1206    GenericPropertyInputSpec = AnyOf(
1207        StringType(),  # Plain strings
1208        LiteralType(normalized_type="str"),  # Numbers and string → will be converted to string
1209        IdentifierType(normalized_type="str"),  # Identifiers → will be converted to string
1210        ColumnType(normalized_type="str"),  # Columns → will be converted to string
1211    )
1212
1213    """
1214    Input Property Specification for StarRocks
1215
1216    This specification defines the validation and normalization rules for StarRocks properties.
1217    Properties are specified in the physical_properties block of a SQLMesh model.
1218
1219    Supported properties:
1220    - partitioned_by / partition_by: Partition specification
1221    - partitions: List of partition definitions
1222    - distributed_by: Distribution specification (HASH/RANDOM with structured tuple or string)
1223    - order_by: Ordering specification (simple column list)
1224    - table key:
1225        - primary_key: Primary key columns
1226        - duplicate_key: Duplicate key columns (for DUPLICATE KEY table)
1227        - unique_key: Unique key columns (for UNIQUE KEY table)
1228        - aggregate_key: Aggregate key columns (for AGGREGATE KEY table)
1229    - other properties: Any other properties not listed above will be treated as generic
1230                        string properties (e.g., replication_num, storage_medium, etc.)
1231
1232    Examples:
1233        duplicate_key = dt                             # Single key
1234        primary_key = (id, customer_id)                # Multiple keys
1235
1236        partitioned_by = col1                          # Single column
1237        partitioned_by = (col1, col2)                  # Multiple columns
1238        partitioned_by = (col1, "col2")                # Mixed (string will be parsed)
1239        partitioned_by = date_trunc('day', col1)       # Expression partition with single func
1240        partitioned_by = (date_trunc('day', col1), col2)  # Expression partition with multiple exprs
1241        partitioned_by = RANGE(col1, col2)             # RANGE partition
1242        partitioned_by = LIST(region, status)          # LIST partition
1243
1244        distributed_by = (kind='HASH', columns=(id, dt), buckets=10)  # Structured
1245        distributed_by = (kind='RANDOM')               # RANDOM distribution
1246        distributed_by = "HASH(id)"                    # String format
1247        distributed_by = "RANDOM"                      # String format
1248
1249        order_by = dt                                  # Single column
1250        order_by = (dt, id, status)                    # Multiple columns
1251
1252        replication_num = 3                            # Generic property (auto-handled)
1253        storage_medium = "SSD"                         # Generic property (auto-handled)
1254    """
1255    PROPERTY_INPUT_SPECS: t.Dict[str, DeclarativeType] = {
1256        # Table key properties
1257        "primary_key": TableKeyInputSpec,
1258        "duplicate_key": TableKeyInputSpec,
1259        "unique_key": TableKeyInputSpec,
1260        "aggregate_key": TableKeyInputSpec,
1261        # Partition-related properties
1262        "partitioned_by": PartitionedByInputSpec,
1263        "partitions": PartitionsInputSpec,
1264        # Distribution property
1265        "distributed_by": DistributedByInputSpec,
1266        # Ordering property
1267        "clustered_by": OrderByInputSpec,
1268        # View properties
1269        # StarRocks syntax: SECURITY {NONE | INVOKER | DEFINER}
1270        "security": EnumType(["NONE", "INVOKER", "DEFINER"], normalized_type="str"),
1271        # Materialized view refresh properties (StarRocks uses REFRESH ...)
1272        # - refresh_moment: IMMEDIATE | DEFERRED
1273        "refresh_moment": EnumType(["IMMEDIATE", "DEFERRED"], normalized_type="str"),
1274        # - refresh_scheme: ASYNC | ASYNC [START (...) EVERY (INTERVAL ...)] | MANUAL
1275        #     it should be a string/literal if START/EVERY is present, other than ASYNC
1276        "refresh_scheme": RefreshSchemeInputSpec,
1277        # Note: All other properties not listed here will be handled, an example here
1278        "replication_num": GenericPropertyInputSpec,
1279    }
1280
1281    # Default output spec for properties not in PROPERTY_OUTPUT_SPECS
1282    GenericPropertyOutputSpec = StringType()
1283
1284    """
1285    Output Property Specification for StarRocks after validation+normalization
1286
1287    This specification describes the expected types after normalization.
1288    For most properties, OUTPUT spec is the same as INPUT spec since normalization
1289    preserves the diverse types (dict | str | exp.Func for distribution).
1290
1291    Conversion to unified formats (e.g., all distributions → dict) happens separately
1292    in the usage layer via factory methods like DistributionTupleInputType.to_unified_dict().
1293
1294    Expected Output Types (after normalization):
1295    - table keys: List[exp.Expr] - columns
1296    - partitioned_by: List[exp.Expr] - columns, functions
1297    - partitions: List[str] - partition definition strings
1298    - distributed_by: Dict | str | exp.Func - DistributionTupleInputType, EnumType, or FuncType output
1299    - order_by: List[exp.Expr] - columns
1300    - generic properties: str - normalized string values
1301    """
1302    GeneralColumnListOutputSpec: DeclarativeType = SequenceOf(ColumnType(), allow_single=False)
1303
1304    PROPERTY_OUTPUT_SPECS: t.Dict[str, DeclarativeType] = {
1305        "primary_key": GeneralColumnListOutputSpec,
1306        "duplicate_key": GeneralColumnListOutputSpec,
1307        "unique_key": GeneralColumnListOutputSpec,
1308        "aggregate_key": GeneralColumnListOutputSpec,
1309        "partitioned_by": SequenceOf(ColumnType(), FuncType(), allow_single=False),
1310        "partitions": SequenceOf(StringType(), allow_single=False),
1311        "distributed_by": AnyOf(
1312            DistributionTupleOutputType(),  # Try structured tuple first (most specific)
1313            EnumType(["RANDOM"], normalized_type="str"),  # "RANDOM"
1314            FuncType(),  # "HASH(id)",
1315        ),  # Still dict | str | exp.Func after normalize
1316        "clustered_by": GeneralColumnListOutputSpec,
1317        "security": EnumType(["NONE", "INVOKER", "DEFINER"], normalized_type="str"),
1318        "refresh_moment": EnumType(["IMMEDIATE", "DEFERRED"], normalized_type="str"),
1319        "refresh_scheme": AnyOf(
1320            EnumType(["ASYNC", "MANUAL"], normalized_type="var"),
1321            StringType(),
1322        ),
1323        # Generic properties use GenericPropertyOutputSpec, an example here
1324        "replication_num": GenericPropertyOutputSpec,
1325    }
1326
1327    # ============================================================
1328    # Helper functions
1329    # ============================================================
1330
1331    @staticmethod
1332    def get_property_input_spec(property_name: str) -> DeclarativeType:
1333        """
1334        Get the INPUT type validator for a property.
1335
1336        Returns the specific type from PROPERTY_INPUT_SPECS if defined,
1337        otherwise returns GenericPropertyInputSpec for unknown properties.
1338
1339        This allows any property not explicitly defined to be treated
1340        as a generic string property.
1341        """
1342        return PropertySpecs.PROPERTY_INPUT_SPECS.get(
1343            property_name, PropertySpecs.GenericPropertyInputSpec
1344        )
1345
1346    @staticmethod
1347    def get_property_output_spec(property_name: str) -> DeclarativeType:
1348        """
1349        Get the OUTPUT type validator for a property.
1350
1351        Returns the specific type from PROPERTY_OUTPUT_SPECS if defined,
1352        otherwise returns GenericPropertyOutputSpec for unknown properties.
1353
1354        This allows validating that normalized values conform to expected output types.
1355        """
1356        return PropertySpecs.PROPERTY_OUTPUT_SPECS.get(
1357            property_name, PropertySpecs.GenericPropertyOutputSpec
1358        )
GeneralColumnListInputSpec = <SequenceOf object>
TableKeyInputSpec = <SequenceOf object>
PartitionedByInputSpec = <SequenceOf object>
PartitionsInputSpec = <SequenceOf object>
DistributedByInputSpec = <AnyOf object>
OrderByInputSpec = <SequenceOf object>
RefreshSchemeInputSpec = <AnyOf object>
GenericPropertyInputSpec = <AnyOf object>

Input Property Specification for StarRocks

This specification defines the validation and normalization rules for StarRocks properties. Properties are specified in the physical_properties block of a SQLMesh model.

Supported properties:

  • partitioned_by / partition_by: Partition specification
  • partitions: List of partition definitions
  • distributed_by: Distribution specification (HASH/RANDOM with structured tuple or string)
  • order_by: Ordering specification (simple column list)
  • table key:
    • primary_key: Primary key columns
    • duplicate_key: Duplicate key columns (for DUPLICATE KEY table)
    • unique_key: Unique key columns (for UNIQUE KEY table)
    • aggregate_key: Aggregate key columns (for AGGREGATE KEY table)
  • other properties: Any other properties not listed above will be treated as generic string properties (e.g., replication_num, storage_medium, etc.)
Examples:

duplicate_key = dt # Single key primary_key = (id, customer_id) # Multiple keys

partitioned_by = col1 # Single column partitioned_by = (col1, col2) # Multiple columns partitioned_by = (col1, "col2") # Mixed (string will be parsed) partitioned_by = date_trunc('day', col1) # Expression partition with single func partitioned_by = (date_trunc('day', col1), col2) # Expression partition with multiple exprs partitioned_by = RANGE(col1, col2) # RANGE partition partitioned_by = LIST(region, status) # LIST partition

distributed_by = (kind='HASH', columns=(id, dt), buckets=10) # Structured distributed_by = (kind='RANDOM') # RANDOM distribution distributed_by = "HASH(id)" # String format distributed_by = "RANDOM" # String format

order_by = dt # Single column order_by = (dt, id, status) # Multiple columns

replication_num = 3 # Generic property (auto-handled) storage_medium = "SSD" # Generic property (auto-handled)

PROPERTY_INPUT_SPECS: Dict[str, DeclarativeType] = {'primary_key': <SequenceOf object>, 'duplicate_key': <SequenceOf object>, 'unique_key': <SequenceOf object>, 'aggregate_key': <SequenceOf object>, 'partitioned_by': <SequenceOf object>, 'partitions': <SequenceOf object>, 'distributed_by': <AnyOf object>, 'clustered_by': <SequenceOf object>, 'security': <EnumType object>, 'refresh_moment': <EnumType object>, 'refresh_scheme': <AnyOf object>, 'replication_num': <AnyOf object>}
GenericPropertyOutputSpec = <StringType object>

Output Property Specification for StarRocks after validation+normalization

This specification describes the expected types after normalization. For most properties, OUTPUT spec is the same as INPUT spec since normalization preserves the diverse types (dict | str | exp.Func for distribution).

Conversion to unified formats (e.g., all distributions → dict) happens separately in the usage layer via factory methods like DistributionTupleInputType.to_unified_dict().

Expected Output Types (after normalization):

  • table keys: List[exp.Expr] - columns
  • partitioned_by: List[exp.Expr] - columns, functions
  • partitions: List[str] - partition definition strings
  • distributed_by: Dict | str | exp.Func - DistributionTupleInputType, EnumType, or FuncType output
  • order_by: List[exp.Expr] - columns
  • generic properties: str - normalized string values
GeneralColumnListOutputSpec: DeclarativeType = <SequenceOf object>
PROPERTY_OUTPUT_SPECS: Dict[str, DeclarativeType] = {'primary_key': <SequenceOf object>, 'duplicate_key': <SequenceOf object>, 'unique_key': <SequenceOf object>, 'aggregate_key': <SequenceOf object>, 'partitioned_by': <SequenceOf object>, 'partitions': <SequenceOf object>, 'distributed_by': <AnyOf object>, 'clustered_by': <SequenceOf object>, 'security': <EnumType object>, 'refresh_moment': <EnumType object>, 'refresh_scheme': <AnyOf object>, 'replication_num': <StringType object>}
@staticmethod
def get_property_input_spec( property_name: str) -> DeclarativeType:
1331    @staticmethod
1332    def get_property_input_spec(property_name: str) -> DeclarativeType:
1333        """
1334        Get the INPUT type validator for a property.
1335
1336        Returns the specific type from PROPERTY_INPUT_SPECS if defined,
1337        otherwise returns GenericPropertyInputSpec for unknown properties.
1338
1339        This allows any property not explicitly defined to be treated
1340        as a generic string property.
1341        """
1342        return PropertySpecs.PROPERTY_INPUT_SPECS.get(
1343            property_name, PropertySpecs.GenericPropertyInputSpec
1344        )

Get the INPUT type validator for a property.

Returns the specific type from PROPERTY_INPUT_SPECS if defined, otherwise returns GenericPropertyInputSpec for unknown properties.

This allows any property not explicitly defined to be treated as a generic string property.

@staticmethod
def get_property_output_spec( property_name: str) -> DeclarativeType:
1346    @staticmethod
1347    def get_property_output_spec(property_name: str) -> DeclarativeType:
1348        """
1349        Get the OUTPUT type validator for a property.
1350
1351        Returns the specific type from PROPERTY_OUTPUT_SPECS if defined,
1352        otherwise returns GenericPropertyOutputSpec for unknown properties.
1353
1354        This allows validating that normalized values conform to expected output types.
1355        """
1356        return PropertySpecs.PROPERTY_OUTPUT_SPECS.get(
1357            property_name, PropertySpecs.GenericPropertyOutputSpec
1358        )

Get the OUTPUT type validator for a property.

Returns the specific type from PROPERTY_OUTPUT_SPECS if defined, otherwise returns GenericPropertyOutputSpec for unknown properties.

This allows validating that normalized values conform to expected output types.

class PropertyValidator:
1364class PropertyValidator:
1365    """
1366    Centralized property validation helpers for table properties.
1367
1368    Provides reusable validation functions to avoid code duplication
1369    and ensure consistent error messages across different property handlers.
1370    """
1371
1372    TABLE_KEY_TYPES = {"primary_key", "duplicate_key", "unique_key", "aggregate_key"}
1373
1374    # All important properties except generic properties
1375    IMPORTANT_PROPERTY_NAMES = {
1376        *TABLE_KEY_TYPES,
1377        "partitioned_by",
1378        "partitions",
1379        "distributed_by",
1380        "clustered_by",
1381    }
1382
1383    # Centralized property alias configuration
1384    # Maps canonical name -> list of valid aliases
1385    PROPERTY_ALIASES: t.Dict[str, t.Set[str]] = {
1386        "partitioned_by": {"partition_by"},
1387        "clustered_by": {"order_by"},
1388    }
1389
1390    EXCLUSIVE_PROPERTY_NAME_MAP: t.Dict[str, t.Set[str]] = {
1391        "key_type": set(TABLE_KEY_TYPES),
1392        **PROPERTY_ALIASES,
1393    }
1394
1395    # Centralized invalid property name configuration
1396    # Maps canonical name -> list of invalid/deprecated names
1397    INVALID_PROPERTY_NAME_MAP: t.Dict[str, t.List[str]] = {
1398        "partitioned_by": ["partition"],
1399        "distributed_by": ["distribution", "distribute"],
1400        "clustered_by": ["order", "ordering"],
1401    }
1402
1403    @staticmethod
1404    def ensure_parenthesized(value: t.Any) -> t.Any:
1405        """
1406        Ensure string value is wrapped in parentheses for parse_fragment compatibility.
1407
1408        For string inputs like 'id1, id2', wraps to '(id1, id2)' so that
1409        parse_fragment can parse it correctly.
1410
1411        Args:
1412            value: Input value (string, expression, or other)
1413
1414        Returns:
1415            - For strings/Literal/Column(quoted): wrapped in parentheses if not already
1416            - For other types: returned unchanged
1417
1418        Example:
1419            >>> PropertyValidator.ensure_parenthesized('id1, id2')
1420            '(id1, id2)'
1421            >>> PropertyValidator.ensure_parenthesized('(id1, id2)')
1422            '(id1, id2)'
1423            >>> PropertyValidator.ensure_parenthesized(exp.Literal.string('id1, id2'))
1424            '(id1, id2)'
1425            >>> PropertyValidator.ensure_parenthesized(exp.Column(quoted=True, name='id1, id2'))
1426            Column(quoted=True, name=id1, id2)
1427        """
1428        # logger.debug("ensure_parenthesized. value: %s, type: %s", value, type(value))
1429
1430        # Extract string content from Literal
1431        if isinstance(value, exp.Literal) and value.is_string:
1432            value = value.this
1433        # Extract string content from Column (quoted)
1434        elif isinstance(value, exp.Column) and hasattr(value.this, "quoted") and value.this.quoted:
1435            value = value.name  # Column.name returns the string
1436        elif not isinstance(value, str):
1437            return value
1438
1439        stripped = value.strip()
1440        if not stripped:
1441            return value
1442
1443        # Check if already wrapped in parentheses
1444        if stripped.startswith("(") and stripped.endswith(")"):
1445            return value
1446
1447        return f"({stripped})"
1448
1449    @staticmethod
1450    def validate_and_normalize_property(
1451        property_name: str, value: t.Any, preprocess_parentheses: bool = False
1452    ) -> t.Any:
1453        """
1454        Complete property processing pipeline using SPEC:
1455        1. Optionally preprocess string with parentheses
1456        2. Get INPUT type validator
1457        3. Validate and normalize input value
1458        4. Get OUTPUT type validator
1459        5. Verify normalized output conforms to expected type
1460        6. Return verified output
1461
1462        After validation, the output type is guaranteed by SPEC.
1463        Unexpected types indicate SPEC configuration errors.
1464
1465        Args:
1466            property_name: Name of the property
1467            value: The property value to validate
1468            preprocess_parentheses: If True, wrap string values in parentheses
1469
1470        Returns:
1471            The normalized value
1472
1473        Raises:
1474            SQLMeshError: If validation fails
1475
1476        Example:
1477            >>> validated = PropertyValidator.validate_and_normalize_property("distributed_by", "RANDOM")
1478            >>> # Result: "RANDOM" (string from EnumType)
1479        """
1480        # logger.debug("validate_and_normalize_property. value: %s, type: %s", value, type(value))
1481
1482        # Step 1: Optionally preprocess string with parentheses
1483        if preprocess_parentheses:
1484            value = PropertyValidator.ensure_parenthesized(value)
1485
1486        # Step 2: Get INPUT type validator
1487        input_spec = PropertySpecs.get_property_input_spec(property_name)
1488        if input_spec is None:
1489            raise SQLMeshError(f"Unknown property '{property_name}'.")
1490
1491        # Step 3: Validate
1492        validated = input_spec.validate(value)
1493        if validated is None:
1494            raise SQLMeshError(f"Invalid value type for property '{property_name}': {value!r}.")
1495
1496        # Step 4: Normalize
1497        normalized = input_spec.normalize(validated)
1498
1499        # Step 5: Check by using output spec
1500        output_spec = PropertySpecs.get_property_output_spec(property_name)
1501        if output_spec is not None:
1502            if output_spec.validate(normalized) is None:
1503                raise SQLMeshError(
1504                    f"Normalized value for property '{property_name}' doesn't match output spec: {normalized!r}."
1505                )
1506
1507        # Step 6: Return
1508        return normalized
1509
1510    @staticmethod
1511    def check_invalid_names(
1512        valid_name: str,
1513        invalid_names: t.List[str],
1514        table_properties: t.Dict[str, t.Any],
1515        suggestion: t.Optional[str] = None,
1516    ) -> None:
1517        """
1518        Check for invalid/deprecated property names and raise error with suggestion.
1519
1520        Args:
1521            valid_name: The correct property name
1522            invalid_names: List of invalid/deprecated names to check for
1523            table_properties: Table properties dictionary to check
1524            suggestion: Optional custom error message suggestion
1525
1526        Raises:
1527            SQLMeshError: If any invalid name is found
1528
1529        Example:
1530            >> PropertyValidator.check_invalid_names(
1531            ...     valid_name="partitioned_by",
1532            ...     invalid_names=["partition_by", "partition"],
1533            ...     table_properties={"partition_by": "dt"}
1534            ... )
1535            SQLMeshError: Invalid property 'partition_by'. Use 'partitioned_by' instead.
1536        """
1537        for invalid_name in invalid_names:
1538            if invalid_name in table_properties:
1539                msg = suggestion or f"Use '{valid_name}' instead"
1540                raise SQLMeshError(f"Invalid property '{invalid_name}'. {msg}.")
1541
1542    @classmethod
1543    def check_all_invalid_names(cls, table_properties: t.Dict[str, t.Any]) -> None:
1544        """
1545        Check all invalid property names at once using INVALID_PROPERTY_NAME_MAP config.
1546
1547        Args:
1548            table_properties: Table properties dictionary to check
1549
1550        Raises:
1551            SQLMeshError: If any invalid name is found
1552        """
1553        for valid_name, invalid_names in cls.INVALID_PROPERTY_NAME_MAP.items():
1554            cls.check_invalid_names(valid_name, invalid_names, table_properties)
1555
1556    @staticmethod
1557    def check_at_most_one(
1558        property_name: str,
1559        property_description: str,
1560        table_properties: t.Dict[str, t.Any],
1561        exclusive_property_names: t.Optional[t.Set[str]] = None,
1562        parameter_value: t.Optional[t.Any] = None,
1563    ) -> t.Optional[str]:
1564        """
1565        Ensure at most one property from a mutually exclusive group is defined.
1566
1567        Args:
1568            property_name: the canonical name
1569            property_description: description of the property group (for error messages)
1570            exclusive_property_names: List of mutually exclusive property names.
1571                Defaults to canonical name and aliases if not provided.
1572            table_properties: Table properties dictionary to check
1573            parameter_value: Optional parameter value (takes priority over table_properties)
1574
1575        Returns:
1576            Name of the active property, or None if none found
1577            NOTE: If the parameter value is provided, it returns None
1578
1579        Raises:
1580            SQLMeshError: If multiple properties from the group are defined
1581
1582        Example:
1583            >> PropertyValidator.check_at_most_one(
1584            ...     property_name="primary_key",
1585            ...     property_description="key type",
1586            ...     exclusive_property_names=["primary_key", "duplicate_key", "unique_key", "aggregate_key"],
1587            ...     table_properties={"primary_key": "(id)", "duplicate_key": "(id)"}
1588            ... )
1589            SQLMeshError: Multiple key type properties defined: ['primary_key', 'duplicate_key'].
1590                         Only one is allowed.
1591        """
1592        if not exclusive_property_names:
1593            exclusive_property_names = PropertyValidator.EXCLUSIVE_PROPERTY_NAME_MAP.get(
1594                property_name, set()
1595            ) | {property_name}
1596        # logger.debug("Checking at most one property for '%s': %s", property_name, exclusive_property_names)
1597        # Check parameter first (highest priority)
1598        if parameter_value is not None:
1599            # Check if any conflicting properties exist in table_properties
1600            conflicts = [name for name in exclusive_property_names if name in table_properties]
1601            if conflicts:
1602                param_display = f"{property_name} (parameter)"
1603                raise SQLMeshError(
1604                    f"Conflicting {property_description} definitions: "
1605                    f"{param_display} provided along with table_properties {conflicts}. "
1606                    f"Only one {property_description} is allowed."
1607                )
1608            return None
1609
1610        # Check table_properties for multiple definitions
1611        present = [name for name in exclusive_property_names if name in table_properties]
1612        # logger.debug("Get table key names for %s from table_properties: %s", property_name, present)
1613
1614        if len(present) > 1:
1615            raise SQLMeshError(
1616                f"Multiple {property_description} properties defined: {present}. "
1617                f"Only one is allowed."
1618            )
1619
1620        return present[0] if present else None

Centralized property validation helpers for table properties.

Provides reusable validation functions to avoid code duplication and ensure consistent error messages across different property handlers.

TABLE_KEY_TYPES = {'unique_key', 'duplicate_key', 'aggregate_key', 'primary_key'}
IMPORTANT_PROPERTY_NAMES = {'partitions', 'aggregate_key', 'primary_key', 'distributed_by', 'duplicate_key', 'unique_key', 'partitioned_by', 'clustered_by'}
PROPERTY_ALIASES: Dict[str, Set[str]] = {'partitioned_by': {'partition_by'}, 'clustered_by': {'order_by'}}
EXCLUSIVE_PROPERTY_NAME_MAP: Dict[str, Set[str]] = {'key_type': {'unique_key', 'duplicate_key', 'aggregate_key', 'primary_key'}, 'partitioned_by': {'partition_by'}, 'clustered_by': {'order_by'}}
INVALID_PROPERTY_NAME_MAP: Dict[str, List[str]] = {'partitioned_by': ['partition'], 'distributed_by': ['distribution', 'distribute'], 'clustered_by': ['order', 'ordering']}
@staticmethod
def ensure_parenthesized(value: Any) -> Any:
1403    @staticmethod
1404    def ensure_parenthesized(value: t.Any) -> t.Any:
1405        """
1406        Ensure string value is wrapped in parentheses for parse_fragment compatibility.
1407
1408        For string inputs like 'id1, id2', wraps to '(id1, id2)' so that
1409        parse_fragment can parse it correctly.
1410
1411        Args:
1412            value: Input value (string, expression, or other)
1413
1414        Returns:
1415            - For strings/Literal/Column(quoted): wrapped in parentheses if not already
1416            - For other types: returned unchanged
1417
1418        Example:
1419            >>> PropertyValidator.ensure_parenthesized('id1, id2')
1420            '(id1, id2)'
1421            >>> PropertyValidator.ensure_parenthesized('(id1, id2)')
1422            '(id1, id2)'
1423            >>> PropertyValidator.ensure_parenthesized(exp.Literal.string('id1, id2'))
1424            '(id1, id2)'
1425            >>> PropertyValidator.ensure_parenthesized(exp.Column(quoted=True, name='id1, id2'))
1426            Column(quoted=True, name=id1, id2)
1427        """
1428        # logger.debug("ensure_parenthesized. value: %s, type: %s", value, type(value))
1429
1430        # Extract string content from Literal
1431        if isinstance(value, exp.Literal) and value.is_string:
1432            value = value.this
1433        # Extract string content from Column (quoted)
1434        elif isinstance(value, exp.Column) and hasattr(value.this, "quoted") and value.this.quoted:
1435            value = value.name  # Column.name returns the string
1436        elif not isinstance(value, str):
1437            return value
1438
1439        stripped = value.strip()
1440        if not stripped:
1441            return value
1442
1443        # Check if already wrapped in parentheses
1444        if stripped.startswith("(") and stripped.endswith(")"):
1445            return value
1446
1447        return f"({stripped})"

Ensure string value is wrapped in parentheses for parse_fragment compatibility.

For string inputs like 'id1, id2', wraps to '(id1, id2)' so that parse_fragment can parse it correctly.

Arguments:
  • value: Input value (string, expression, or other)
Returns:
  • For strings/Literal/Column(quoted): wrapped in parentheses if not already
  • For other types: returned unchanged
Example:
>>> PropertyValidator.ensure_parenthesized('id1, id2')
'(id1, id2)'
>>> PropertyValidator.ensure_parenthesized('(id1, id2)')
'(id1, id2)'
>>> PropertyValidator.ensure_parenthesized(exp.Literal.string('id1, id2'))
'(id1, id2)'
>>> PropertyValidator.ensure_parenthesized(exp.Column(quoted=True, name='id1, id2'))
Column(quoted=True, name=id1, id2)
@staticmethod
def validate_and_normalize_property( property_name: str, value: Any, preprocess_parentheses: bool = False) -> Any:
1449    @staticmethod
1450    def validate_and_normalize_property(
1451        property_name: str, value: t.Any, preprocess_parentheses: bool = False
1452    ) -> t.Any:
1453        """
1454        Complete property processing pipeline using SPEC:
1455        1. Optionally preprocess string with parentheses
1456        2. Get INPUT type validator
1457        3. Validate and normalize input value
1458        4. Get OUTPUT type validator
1459        5. Verify normalized output conforms to expected type
1460        6. Return verified output
1461
1462        After validation, the output type is guaranteed by SPEC.
1463        Unexpected types indicate SPEC configuration errors.
1464
1465        Args:
1466            property_name: Name of the property
1467            value: The property value to validate
1468            preprocess_parentheses: If True, wrap string values in parentheses
1469
1470        Returns:
1471            The normalized value
1472
1473        Raises:
1474            SQLMeshError: If validation fails
1475
1476        Example:
1477            >>> validated = PropertyValidator.validate_and_normalize_property("distributed_by", "RANDOM")
1478            >>> # Result: "RANDOM" (string from EnumType)
1479        """
1480        # logger.debug("validate_and_normalize_property. value: %s, type: %s", value, type(value))
1481
1482        # Step 1: Optionally preprocess string with parentheses
1483        if preprocess_parentheses:
1484            value = PropertyValidator.ensure_parenthesized(value)
1485
1486        # Step 2: Get INPUT type validator
1487        input_spec = PropertySpecs.get_property_input_spec(property_name)
1488        if input_spec is None:
1489            raise SQLMeshError(f"Unknown property '{property_name}'.")
1490
1491        # Step 3: Validate
1492        validated = input_spec.validate(value)
1493        if validated is None:
1494            raise SQLMeshError(f"Invalid value type for property '{property_name}': {value!r}.")
1495
1496        # Step 4: Normalize
1497        normalized = input_spec.normalize(validated)
1498
1499        # Step 5: Check by using output spec
1500        output_spec = PropertySpecs.get_property_output_spec(property_name)
1501        if output_spec is not None:
1502            if output_spec.validate(normalized) is None:
1503                raise SQLMeshError(
1504                    f"Normalized value for property '{property_name}' doesn't match output spec: {normalized!r}."
1505                )
1506
1507        # Step 6: Return
1508        return normalized

Complete property processing pipeline using SPEC:

  1. Optionally preprocess string with parentheses
  2. Get INPUT type validator
  3. Validate and normalize input value
  4. Get OUTPUT type validator
  5. Verify normalized output conforms to expected type
  6. Return verified output

After validation, the output type is guaranteed by SPEC. Unexpected types indicate SPEC configuration errors.

Arguments:
  • property_name: Name of the property
  • value: The property value to validate
  • preprocess_parentheses: If True, wrap string values in parentheses
Returns:

The normalized value

Raises:
  • SQLMeshError: If validation fails
Example:
>>> validated = PropertyValidator.validate_and_normalize_property("distributed_by", "RANDOM")
>>> # Result: "RANDOM" (string from EnumType)
@staticmethod
def check_invalid_names( valid_name: str, invalid_names: List[str], table_properties: Dict[str, Any], suggestion: Optional[str] = None) -> None:
1510    @staticmethod
1511    def check_invalid_names(
1512        valid_name: str,
1513        invalid_names: t.List[str],
1514        table_properties: t.Dict[str, t.Any],
1515        suggestion: t.Optional[str] = None,
1516    ) -> None:
1517        """
1518        Check for invalid/deprecated property names and raise error with suggestion.
1519
1520        Args:
1521            valid_name: The correct property name
1522            invalid_names: List of invalid/deprecated names to check for
1523            table_properties: Table properties dictionary to check
1524            suggestion: Optional custom error message suggestion
1525
1526        Raises:
1527            SQLMeshError: If any invalid name is found
1528
1529        Example:
1530            >> PropertyValidator.check_invalid_names(
1531            ...     valid_name="partitioned_by",
1532            ...     invalid_names=["partition_by", "partition"],
1533            ...     table_properties={"partition_by": "dt"}
1534            ... )
1535            SQLMeshError: Invalid property 'partition_by'. Use 'partitioned_by' instead.
1536        """
1537        for invalid_name in invalid_names:
1538            if invalid_name in table_properties:
1539                msg = suggestion or f"Use '{valid_name}' instead"
1540                raise SQLMeshError(f"Invalid property '{invalid_name}'. {msg}.")

Check for invalid/deprecated property names and raise error with suggestion.

Arguments:
  • valid_name: The correct property name
  • invalid_names: List of invalid/deprecated names to check for
  • table_properties: Table properties dictionary to check
  • suggestion: Optional custom error message suggestion
Raises:
  • SQLMeshError: If any invalid name is found
Example:

PropertyValidator.check_invalid_names( ... valid_name="partitioned_by", ... invalid_names=["partition_by", "partition"], ... table_properties={"partition_by": "dt"} ... ) SQLMeshError: Invalid property 'partition_by'. Use 'partitioned_by' instead.

@classmethod
def check_all_invalid_names(cls, table_properties: Dict[str, Any]) -> None:
1542    @classmethod
1543    def check_all_invalid_names(cls, table_properties: t.Dict[str, t.Any]) -> None:
1544        """
1545        Check all invalid property names at once using INVALID_PROPERTY_NAME_MAP config.
1546
1547        Args:
1548            table_properties: Table properties dictionary to check
1549
1550        Raises:
1551            SQLMeshError: If any invalid name is found
1552        """
1553        for valid_name, invalid_names in cls.INVALID_PROPERTY_NAME_MAP.items():
1554            cls.check_invalid_names(valid_name, invalid_names, table_properties)

Check all invalid property names at once using INVALID_PROPERTY_NAME_MAP config.

Arguments:
  • table_properties: Table properties dictionary to check
Raises:
  • SQLMeshError: If any invalid name is found
@staticmethod
def check_at_most_one( property_name: str, property_description: str, table_properties: Dict[str, Any], exclusive_property_names: Optional[Set[str]] = None, parameter_value: Optional[Any] = None) -> Optional[str]:
1556    @staticmethod
1557    def check_at_most_one(
1558        property_name: str,
1559        property_description: str,
1560        table_properties: t.Dict[str, t.Any],
1561        exclusive_property_names: t.Optional[t.Set[str]] = None,
1562        parameter_value: t.Optional[t.Any] = None,
1563    ) -> t.Optional[str]:
1564        """
1565        Ensure at most one property from a mutually exclusive group is defined.
1566
1567        Args:
1568            property_name: the canonical name
1569            property_description: description of the property group (for error messages)
1570            exclusive_property_names: List of mutually exclusive property names.
1571                Defaults to canonical name and aliases if not provided.
1572            table_properties: Table properties dictionary to check
1573            parameter_value: Optional parameter value (takes priority over table_properties)
1574
1575        Returns:
1576            Name of the active property, or None if none found
1577            NOTE: If the parameter value is provided, it returns None
1578
1579        Raises:
1580            SQLMeshError: If multiple properties from the group are defined
1581
1582        Example:
1583            >> PropertyValidator.check_at_most_one(
1584            ...     property_name="primary_key",
1585            ...     property_description="key type",
1586            ...     exclusive_property_names=["primary_key", "duplicate_key", "unique_key", "aggregate_key"],
1587            ...     table_properties={"primary_key": "(id)", "duplicate_key": "(id)"}
1588            ... )
1589            SQLMeshError: Multiple key type properties defined: ['primary_key', 'duplicate_key'].
1590                         Only one is allowed.
1591        """
1592        if not exclusive_property_names:
1593            exclusive_property_names = PropertyValidator.EXCLUSIVE_PROPERTY_NAME_MAP.get(
1594                property_name, set()
1595            ) | {property_name}
1596        # logger.debug("Checking at most one property for '%s': %s", property_name, exclusive_property_names)
1597        # Check parameter first (highest priority)
1598        if parameter_value is not None:
1599            # Check if any conflicting properties exist in table_properties
1600            conflicts = [name for name in exclusive_property_names if name in table_properties]
1601            if conflicts:
1602                param_display = f"{property_name} (parameter)"
1603                raise SQLMeshError(
1604                    f"Conflicting {property_description} definitions: "
1605                    f"{param_display} provided along with table_properties {conflicts}. "
1606                    f"Only one {property_description} is allowed."
1607                )
1608            return None
1609
1610        # Check table_properties for multiple definitions
1611        present = [name for name in exclusive_property_names if name in table_properties]
1612        # logger.debug("Get table key names for %s from table_properties: %s", property_name, present)
1613
1614        if len(present) > 1:
1615            raise SQLMeshError(
1616                f"Multiple {property_description} properties defined: {present}. "
1617                f"Only one is allowed."
1618            )
1619
1620        return present[0] if present else None

Ensure at most one property from a mutually exclusive group is defined.

Arguments:
  • property_name: the canonical name
  • property_description: description of the property group (for error messages)
  • exclusive_property_names: List of mutually exclusive property names. Defaults to canonical name and aliases if not provided.
  • table_properties: Table properties dictionary to check
  • parameter_value: Optional parameter value (takes priority over table_properties)
Returns:

Name of the active property, or None if none found NOTE: If the parameter value is provided, it returns None

Raises:
  • SQLMeshError: If multiple properties from the group are defined
Example:

PropertyValidator.check_at_most_one( ... property_name="primary_key", ... property_description="key type", ... exclusive_property_names=["primary_key", "duplicate_key", "unique_key", "aggregate_key"], ... table_properties={"primary_key": "(id)", "duplicate_key": "(id)"} ... ) SQLMeshError: Multiple key type properties defined: ['primary_key', 'duplicate_key']. Only one is allowed.

1626@set_catalog()
1627class StarRocksEngineAdapter(
1628    LogicalMergeMixin,
1629    PandasNativeFetchDFSupportMixin,
1630    ClusteredByMixin,
1631):
1632    """
1633    StarRocks Engine Adapter for SQLMesh.
1634
1635    StarRocks is a high-performance analytical database with its own dialect-specific
1636    behavior. This adapter highlights a few key characteristics:
1637
1638    1. PRIMARY KEY support is native and must be emitted in the post-schema section.
1639    2. DELETE with subqueries is supported on PRIMARY KEY tables, but other key types still
1640       need guard rails (no boolean literals, TRUNCATE for WHERE TRUE, etc.).
1641    3. Partitioning supports RANGE, LIST, and expression-based syntaxes.
1642
1643    Implementation strategy:
1644    - Override only where StarRocks syntax/behavior diverges from the base adapter.
1645    - Keep the rest of the functionality delegated to the shared base implementation.
1646    """
1647
1648    # ==================== Class Attributes (Declarative Configuration) ====================
1649
1650    DIALECT = "starrocks"
1651    """SQLGlot dialect name for SQL generation"""
1652
1653    DEFAULT_BATCH_SIZE = 10000
1654    """Default batch size for bulk operations"""
1655
1656    SUPPORTS_TRANSACTIONS = False
1657    """
1658    StarRocks does not support transactions for multiple DML statements.
1659    - No BEGIN/COMMIT/ROLLBACK (only txn for multiple INSERT statements from v3.5)
1660    - Operations are auto-committed
1661    - Backfill uses partition-level atomicity
1662    """
1663
1664    INSERT_OVERWRITE_STRATEGY = InsertOverwriteStrategy.DELETE_INSERT
1665    """
1666    StarRocks does support INSERT OVERWRITE syntax (and dynamic overwrite from v3.5).
1667    Use DELETE + INSERT pattern:
1668    1. DELETE FROM table WHERE condition
1669    2. INSERT INTO table SELECT ...
1670
1671    Base class automatically handles this strategy without overriding insert methods.
1672
1673    TODO: later, we can add support for INSERT OVERWRITE, even use Primary Key for beter performance
1674    """
1675
1676    COMMENT_CREATION_TABLE = CommentCreationTable.IN_SCHEMA_DEF_NO_CTAS
1677    """Column comments are added inline in a plain CREATE TABLE, but StarRocks CTAS only accepts a
1678    bare column-name list (no types or per-column COMMENT) before AS SELECT. So for CTAS we emit
1679    `CREATE TABLE t COMMENT '...' AS SELECT ...` (table comment only) and register column comments
1680    afterward via ALTER TABLE ... MODIFY COLUMN ... COMMENT (see _build_create_comment_column_exp)."""
1681
1682    COMMENT_CREATION_VIEW = CommentCreationView.IN_SCHEMA_DEF_NO_COMMANDS
1683    """View comments are added in CREATE VIEW statement"""
1684
1685    SUPPORTS_MATERIALIZED_VIEWS = True
1686    """StarRocks supports materialized views with refresh strategies"""
1687
1688    SUPPORTS_MATERIALIZED_VIEW_SCHEMA = True
1689    """
1690    StarRocks materialized views support specifying a column list, but the column definition is
1691    limited (e.g. column name + comment, not full type definitions). We set this to True and
1692    implement custom MV schema rendering in create_view/_create_materialized_view.
1693    """
1694
1695    RECREATE_MATERIALIZED_VIEW_ON_EVALUATION = False
1696    """
1697    StarRocks async materialized views maintain themselves: they revalidate automatically even if the
1698    underlying data is dropped, and the data is kept current either by StarRocks' automatic refresh or
1699    by an explicit `REFRESH MATERIALIZED VIEW` (which also enables partition-level incremental refresh).
1700    """
1701
1702    SUPPORTS_REPLACE_TABLE = False
1703    """No REPLACE TABLE syntax; use DROP + CREATE instead"""
1704
1705    SUPPORTS_CREATE_DROP_CATALOG = False
1706    """StarRocks supports DROPing external catalogs.
1707    TODO: whether it's external catalogs, or includes the internal catalog
1708    """
1709
1710    SUPPORTS_INDEXES = True
1711    """
1712    StarRocks supports PRIMARY KEY in CREATE TABLE, but NOT standalone CREATE INDEX.
1713
1714    We set this to True to enable PRIMARY KEY generation in CREATE TABLE statements.
1715    The create_index() method is overridden to prevent actual CREATE INDEX execution.
1716
1717    Supported (defined in CREATE TABLE):
1718    - PRIMARY KEY: Automatically creates sorted index
1719    - INDEX clause: For bloom filter, bitmap, inverted indexes
1720    NOT supported:
1721        CREATE INDEX idx_name ON t (name);  -- Will be skipped by create_index()
1722    """
1723
1724    SUPPORTS_TUPLE_IN = False
1725    """
1726    StarRocks does NOT support tuple IN syntax: (col1, col2) IN ((val1, val2), (val3, val4))
1727
1728    Instead, use OR with AND conditions:
1729    (col1 = val1 AND col2 = val2) OR (col1 = val3 AND col2 = val4)
1730
1731    This is automatically handled by snapshot_id_filter and snapshot_name_version_filter
1732    in sqlmesh/core/state_sync/db/utils.py when SUPPORTS_TUPLE_IN = False.
1733    """
1734
1735    MAX_TABLE_COMMENT_LENGTH = 2048
1736    """Maximum length for table comments"""
1737
1738    MAX_COLUMN_COMMENT_LENGTH = 255
1739    """Maximum length for column comments"""
1740
1741    MAX_IDENTIFIER_LENGTH = 64
1742    """Maximum length for table/column names"""
1743
1744    RESOLVE_TABLE_REFS_IN_PHYSICAL_PROPERTIES: t.FrozenSet[str] = frozenset(
1745        {"excluded_trigger_tables", "excluded_refresh_tables"}
1746    )
1747    """StarRocks async materialized views accept these properties to exclude certain tables from
1748    triggering or participating in refreshes.  When the value references a managed SQLMesh model,
1749    StarRocks needs the physical table name (db.table), not the logical view name.  Managed-model
1750    physical names carry no catalog prefix (catalog support is UNSUPPORTED), so they are already in
1751    the warehouse-local db.table form StarRocks expects; unmanaged references (e.g. an external
1752    catalog's ext_catalog.db.table) pass through unchanged."""
1753
1754    # ==================== Schema Operations ====================
1755    # StarRocks supports CREATE/DROP SCHEMA the same as CREATE/DROP DATABSE.
1756    # So, no need to implement create_schema / drop_schema
1757
1758    # ==================== Data Object Query ====================
1759    def _get_data_objects(
1760        self, schema_name: SchemaName, object_names: t.Optional[t.Set[str]] = None
1761    ) -> t.List[DataObject]:
1762        """
1763        Returns all the data objects that exist in the given schema.
1764        Uses information_schema tables which are compatible with MySQL protocol.
1765
1766        StarRocks uses the MySQL-compatible information_schema layout, so the same query
1767        works here.
1768        Note: Materialized View is not reliably distinguished from View (both may appear as `VIEW`)
1769        in information_schema.tables. We therefore best-effort detect MVs via
1770        information_schema.materialized_views and upgrade matching objects to `materialized_view`.
1771
1772        Args:
1773            schema_name: The schema (database) to query
1774            object_names: Optional set of specific table names to filter
1775
1776        Returns:
1777            List of DataObject instances representing tables and views
1778        """
1779        schema_db = to_schema(schema_name).db
1780        query = (
1781            exp.select(
1782                exp.column("table_schema").as_("schema_name"),
1783                exp.column("table_name").as_("name"),
1784                exp.case(exp.column("table_type"))
1785                .when(
1786                    exp.Literal.string("BASE TABLE"),
1787                    exp.Literal.string("table"),
1788                )
1789                .when(
1790                    exp.Literal.string("VIEW"),
1791                    exp.Literal.string("view"),
1792                )
1793                .else_("table_type")
1794                .as_("type"),
1795            )
1796            .from_(exp.table_("tables", db="information_schema"))
1797            .where(exp.column("table_schema").eq(schema_db))
1798        )
1799        if object_names:
1800            # StarRocks may treat information_schema table_name comparisons as case-sensitive.
1801            # Use LOWER(table_name) to match case-insensitively.
1802            lowered_names = [name.lower() for name in object_names]
1803            query = query.where(exp.func("LOWER", exp.column("table_name")).isin(*lowered_names))
1804
1805        df = self.fetchdf(query)
1806        objects = [
1807            DataObject(
1808                schema=row.schema_name,
1809                name=row.name,
1810                type=DataObjectType.from_str(str(row.type)),
1811            )
1812            for row in df.itertuples()
1813        ]
1814
1815        # Best-effort upgrade of MV types using information_schema.materialized_views.
1816        # If this fails (unsupported / permissions / version), fall back to information_schema.tables.
1817        try:
1818            mv_query = (
1819                exp.select(
1820                    exp.column("table_schema").as_("schema_name"),
1821                    exp.column("table_name").as_("name"),
1822                )
1823                .from_(exp.table_("materialized_views", db="information_schema"))
1824                .where(exp.column("table_schema").eq(schema_db))
1825            )
1826            if object_names:
1827                lowered_names = [name.lower() for name in object_names]
1828                mv_query = mv_query.where(
1829                    exp.func("LOWER", exp.column("table_name")).isin(*lowered_names)
1830                )
1831
1832            mv_df = self.fetchdf(mv_query)
1833            mv_names: t.Set[str] = {
1834                t.cast(str, r.name).lower() for r in mv_df.itertuples() if r.name
1835            }
1836
1837            if mv_names:
1838                for obj in objects:
1839                    if obj.name.lower() in mv_names:
1840                        obj.type = DataObjectType.MATERIALIZED_VIEW
1841        except Exception:
1842            logger.warning(
1843                f"[StarRocks] Failed to get materialized views from information_schema.materialized_views"
1844            )
1845
1846        return objects
1847
1848    def create_index(
1849        self,
1850        table_name: TableName,
1851        index_name: str,
1852        columns: t.Tuple[str, ...],
1853        exists: bool = True,
1854    ) -> None:
1855        """
1856        Override to prevent CREATE INDEX statements (not supported in StarRocks).
1857
1858        StarRocks does not support standalone CREATE INDEX statements.
1859        Indexes must be defined during CREATE TABLE using INDEX clause.
1860
1861        Since SQLMesh state tables use PRIMARY KEY (which provides efficient indexing),
1862        we simply log and skip additional index creation requests.
1863
1864        This matches upstream StarRocks limitations and prevents accidental CREATE INDEX calls.
1865        """
1866        logger.warning(
1867            f"[StarRocks] Skipping CREATE INDEX {index_name} on {table_name} - "
1868            f"StarRocks does not support standalone CREATE INDEX statements. "
1869            f"PRIMARY KEY provides equivalent indexing for columns: {columns}"
1870        )
1871        return
1872
1873    def _create_table_like(
1874        self,
1875        target_table_name: TableName,
1876        source_table_name: TableName,
1877        exists: bool,
1878        **kwargs: t.Any,
1879    ) -> None:
1880        """Create a new table using StarRocks' native `CREATE TABLE ... LIKE ...` syntax.
1881
1882        The base implementation re-creates the target table from `columns(source)` which can
1883        lose non-column metadata. Using LIKE lets the engine preserve more of the original
1884        table definition (engine-defined behavior).
1885        """
1886        self.execute(
1887            exp.Create(
1888                this=exp.to_table(target_table_name),
1889                kind="TABLE",
1890                exists=exists,
1891                properties=exp.Properties(
1892                    expressions=[
1893                        exp.LikeProperty(
1894                            this=exp.to_table(source_table_name),
1895                        ),
1896                    ],
1897                ),
1898            )
1899        )
1900
1901    def delete_from(
1902        self,
1903        table_name: TableName,
1904        where: t.Optional[t.Union[str, exp.Expr]] = None,
1905    ) -> None:
1906        """
1907        Delete from a table.
1908
1909        StarRocks DELETE limitations by table type:
1910
1911        PRIMARY KEY tables:
1912        - Support complex WHERE conditions (subqueries, BETWEEN, etc.)
1913        - No special handling needed
1914
1915        Other table types (DUPLICATE/UNIQUE/AGGREGATE KEY):
1916        - WHERE TRUE not supported → use TRUNCATE TABLE
1917        - Boolean literals (TRUE/FALSE) not supported
1918        - BETWEEN not supported → convert to >= AND <=
1919        - Others not supported:
1920            - CAST() not supported in WHERE
1921            - Subqueries not supported
1922            - ...
1923
1924        But, I don't know what the table type is.
1925
1926        Args:
1927            table_name: The table to delete from
1928            where: The where clause to filter rows to delete
1929        """
1930        # Parse where clause if it's a string
1931        where_expr: t.Optional[exp.Expr]
1932        if isinstance(where, str):
1933            from sqlglot import parse_one
1934
1935            where_expr = parse_one(where, dialect=self.dialect)
1936        else:
1937            where_expr = where
1938
1939        # If no where clause or WHERE TRUE, use TRUNCATE TABLE (for all table types)
1940        if not where_expr or where_expr == exp.true():
1941            table_expr = exp.to_table(table_name) if isinstance(table_name, str) else table_name
1942            logger.info(
1943                f"Converting DELETE FROM {table_name} WHERE TRUE to TRUNCATE TABLE "
1944                "(StarRocks does not support WHERE TRUE in DELETE)"
1945            )
1946            self.execute(f"TRUNCATE TABLE {table_expr.sql(dialect=self.dialect, identify=True)}")
1947            return
1948
1949        # For non-PRIMARY KEY tables, apply WHERE clause restrictions
1950        # Note: We conservatively apply restrictions to all tables since we can't easily
1951        # determine table type at DELETE time. PRIMARY KEY tables will still work with
1952        # simplified conditions, while non-PRIMARY KEY tables require them.
1953        if isinstance(where_expr, exp.Expr):
1954            original_where = where_expr
1955            # Remove boolean literals (not supported in any table type)
1956            where_expr = self._where_clause_remove_boolean_literals(where_expr)
1957            # Convert BETWEEN to >= AND <= (required for DUPLICATE/UNIQUE/AGGREGATE KEY tables)
1958            where_expr = self._where_clause_convert_between_to_comparison(where_expr)
1959
1960            if where_expr != original_where:
1961                logger.debug(
1962                    f"Converted WHERE clause for StarRocks compatibility, table: {table_name}.\n"
1963                    f"  Original: {original_where.sql(dialect=self.dialect)}\n"
1964                    f"  Converted: {where_expr.sql(dialect=self.dialect)}"
1965                )
1966
1967        # Use parent implementation
1968        super().delete_from(table_name, where_expr)
1969
1970    def _where_clause_remove_boolean_literals(self, expression: exp.Expr) -> exp.Expr:
1971        """
1972        Remove TRUE/FALSE boolean literals from WHERE expressions.
1973
1974        StarRocks Limitation (except PRIMARY KEY tables):
1975        Boolean literals (TRUE/FALSE) are not supported in WHERE clauses.
1976
1977        This method simplifies expressions:
1978        - (condition) AND TRUE / TRUE AND (condition) → condition
1979        - (condition) OR FALSE / FALSE OR (condition) → condition
1980        - WHERE TRUE → 1=1 (though TRUNCATE is used instead)
1981        - WHERE FALSE → 1=0
1982
1983        Args:
1984            expression: The expression to clean
1985
1986        Returns:
1987            Cleaned expression without boolean literals
1988        """
1989
1990        def transform(node: exp.Expr) -> exp.Expr:
1991            # Handle standalone TRUE/FALSE at the top level
1992            if node == exp.true():
1993                # Convert TRUE to 1=1
1994                return exp.EQ(this=exp.Literal.number(1), expression=exp.Literal.number(1))
1995            elif node == exp.false():  # noqa: RET505
1996                # Convert FALSE to 1=0
1997                return exp.EQ(this=exp.Literal.number(1), expression=exp.Literal.number(0))
1998
1999            # Handle AND expressions
2000            elif isinstance(node, exp.And):
2001                left = node.this
2002                right = node.expression
2003
2004                # Remove TRUE from AND
2005                if left == exp.true():
2006                    return right
2007                if right == exp.true():
2008                    return left
2009
2010            # Handle OR expressions
2011            elif isinstance(node, exp.Or):
2012                left = node.this
2013                right = node.expression
2014
2015                # Remove FALSE from OR
2016                if left == exp.false():
2017                    return right
2018                if right == exp.false():
2019                    return left
2020
2021            return node
2022
2023        # Transform the expression tree
2024        return expression.transform(transform, copy=True)
2025
2026    def _where_clause_convert_between_to_comparison(self, expression: exp.Expr) -> exp.Expr:
2027        """
2028        Convert BETWEEN expressions to >= AND <= comparisons.
2029
2030        StarRocks Limitation (DUPLICATE/UNIQUE/AGGREGATE KEY Tables):
2031        BETWEEN is not supported in DELETE WHERE clauses for non-PRIMARY KEY tables.
2032
2033        PRIMARY KEY tables support BETWEEN, but this conversion is safe for all table types
2034        since the converted form (>= AND <=) is semantically equivalent.
2035
2036        This method converts:
2037        - col BETWEEN a AND b  →  col >= a AND col <= b
2038
2039        Args:
2040            expression: The expression potentially containing BETWEEN
2041
2042        Returns:
2043            Expression with BETWEEN converted to comparisons
2044        """
2045
2046        def transform(node: exp.Expr) -> exp.Expr:
2047            if isinstance(node, exp.Between):
2048                # Extract components: col BETWEEN low AND high
2049                column = node.this  # The column being tested
2050                low = node.args.get("low")  # Lower bound
2051                high = node.args.get("high")  # Upper bound
2052
2053                if column and low and high:
2054                    # Build: column >= low AND column <= high
2055                    gte = exp.GTE(this=column.copy(), expression=low.copy())
2056                    lte = exp.LTE(this=column.copy(), expression=high.copy())
2057                    return exp.And(this=gte, expression=lte)
2058
2059            return node
2060
2061        # Transform the expression tree
2062        return expression.transform(transform, copy=True)
2063
2064    def execute(
2065        self,
2066        expressions: t.Union[str, exp.Expr, t.Sequence[exp.Expr]],
2067        ignore_unsupported_errors: bool = False,
2068        quote_identifiers: bool = True,
2069        track_rows_processed: bool = False,
2070        **kwargs: t.Any,
2071    ) -> None:
2072        """
2073        Override execute to strip FOR UPDATE from queries (not supported in StarRocks).
2074
2075        StarRocks is an OLAP database and does not support row-level locking via
2076        SELECT ... FOR UPDATE. This method removes lock expressions before execution.
2077
2078        Args:
2079            expressions: SQL expression(s) to execute
2080            ignore_unsupported_errors: Whether to ignore unsupported errors
2081            quote_identifiers: Whether to quote identifiers
2082            track_rows_processed: Whether to track rows processed
2083            **kwargs: Additional arguments
2084        """
2085        from sqlglot.helper import ensure_list
2086
2087        if isinstance(expressions, str):
2088            super().execute(
2089                expressions,
2090                ignore_unsupported_errors=ignore_unsupported_errors,
2091                quote_identifiers=quote_identifiers,
2092                track_rows_processed=track_rows_processed,
2093                **kwargs,
2094            )
2095            return
2096
2097        # Process expressions to remove FOR UPDATE
2098        processed_expressions: t.List[exp.Expr] = []
2099        for e in ensure_list(expressions):
2100            if not isinstance(e, exp.Expr):
2101                super().execute(
2102                    expressions,
2103                    ignore_unsupported_errors=ignore_unsupported_errors,
2104                    quote_identifiers=quote_identifiers,
2105                    track_rows_processed=track_rows_processed,
2106                    **kwargs,
2107                )
2108                return
2109
2110            # Remove lock (FOR UPDATE) from SELECT statements
2111            if isinstance(e, exp.Select) and e.args.get("locks"):
2112                e = e.copy()
2113                e.set("locks", None)
2114                logger.warning(
2115                    f"[StarRocks] Removed FOR UPDATE from SELECT statement: "
2116                    f"{e.sql(dialect=self.dialect, identify=quote_identifiers)}"
2117                )
2118            processed_expressions.append(e)
2119
2120        # Call parent execute with processed expressions
2121        super().execute(
2122            processed_expressions,
2123            ignore_unsupported_errors=ignore_unsupported_errors,
2124            quote_identifiers=quote_identifiers,
2125            track_rows_processed=track_rows_processed,
2126            **kwargs,
2127        )
2128
2129    def adjust_physical_properties_for_incremental(
2130        self,
2131        physical_properties: t.Dict[str, t.Any],
2132        *,
2133        requires_delete_capable_table: bool,
2134        unique_key: t.Optional[t.List[exp.Expr]],
2135        model_name: str,
2136    ) -> t.Dict[str, t.Any]:
2137        """Enforce that StarRocks incremental models use a PRIMARY KEY table.
2138
2139        Incremental kinds rely on DELETE/MERGE statements that StarRocks only supports on PRIMARY
2140        KEY tables; DUPLICATE/UNIQUE/AGGREGATE KEY tables reject the predicates SQLMesh generates
2141        (e.g. a time-range DELETE with a CAST bound, or any non-key-column predicate). When a
2142        unique_key is available (INCREMENTAL_BY_UNIQUE_KEY) we promote it to a PRIMARY KEY;
2143        otherwise a PRIMARY KEY must be specified explicitly via physical_properties, and we raise
2144        so the failure is clear at creation time rather than producing a broken table.
2145
2146        The caller owns ``physical_properties`` (it is already a defensive copy), so we mutate and
2147        return it in place.
2148        """
2149        if not requires_delete_capable_table or "primary_key" in physical_properties:
2150            return physical_properties
2151
2152        # Promote the model's unique_key to a PRIMARY KEY table so that complex DELETE/MERGE
2153        # statements remain supported.
2154        if unique_key:
2155            physical_properties["primary_key"] = (
2156                unique_key[0] if len(unique_key) == 1 else exp.Tuple(expressions=unique_key)
2157            )
2158            logger.info(
2159                "Model '%s' promoted to PRIMARY KEY table on StarRocks to support rich DELETE operations.",
2160                model_name,
2161            )
2162            return physical_properties
2163
2164        raise SQLMeshError(
2165            f"StarRocks incremental model '{model_name}' requires a PRIMARY KEY table. "
2166            "Incremental kinds use DELETE/MERGE operations that StarRocks only supports on PRIMARY KEY "
2167            "tables; DUPLICATE/UNIQUE/AGGREGATE KEY tables are not sufficient. "
2168            "Specify `physical_properties (primary_key = (...))`, or set `unique_key` on the model."
2169        )
2170
2171    # ==================== Table Creation (CORE IMPLEMENTATION) ====================
2172    def _create_table_from_columns(
2173        self,
2174        table_name: TableName,
2175        target_columns_to_types: t.Dict[str, exp.DataType],
2176        primary_key: t.Optional[t.Tuple[str, ...]] = None,
2177        exists: bool = True,
2178        table_description: t.Optional[str] = None,
2179        column_descriptions: t.Optional[t.Dict[str, str]] = None,
2180        **kwargs: t.Any,
2181    ) -> None:
2182        """
2183        Create a table using column definitions.
2184
2185        Unified Model Parameter vs Physical Properties Handling:
2186        For properties that can be defined both as model parameters and in physical_properties,
2187        this method implements a unified priority strategy:
2188        1. Model parameter takes priority if present
2189        2. Otherwise, use value from physical_properties
2190        3. Ensure at most one definition exists
2191
2192        Supported unified properties:
2193        - primary_key: Model parameter OR physical_properties.primary_key
2194        - partitioned_by: Model parameter OR physical_properties.partitioned_by/partition_by
2195        - clustered_by: Model parameter OR physical_properties.clustered_by/order_by
2196
2197        Other key types (duplicate_key, aggregate_key, unique_key) only support physical_properties.
2198
2199        StarRocks Key Column Ordering Constraint:
2200        ALL key types (PRIMARY KEY, UNIQUE KEY, DUPLICATE KEY, AGGREGATE KEY) require:
2201        - Key columns MUST be the first N columns in CREATE TABLE
2202        - Column order MUST match the KEY clause order
2203
2204        Implementation Strategy:
2205        1. Normalize model parameters into table_properties with priority handling
2206        2. Extract and validate key columns from unified table_properties
2207        3. Validate no conflicts between different key types
2208        4. Reorder columns to place key columns first
2209        5. For PRIMARY KEY: Pass to base class (sets SUPPORTS_INDEXES=True)
2210        6. For other keys: Handle in _build_table_key_property
2211
2212        Args:
2213            table_name: Fully qualified table name
2214            target_columns_to_types: Column definitions {name: DataType}
2215            primary_key: Primary key column names (model parameter, takes priority)
2216            exists: Add IF NOT EXISTS clause
2217            table_description: Table comment
2218            column_descriptions: Column comments {column_name: comment}
2219            kwargs: Additional properties including:
2220                - partitioned_by: Partition columns (model parameter)
2221                - clustered_by: Clustering columns (model parameter)
2222                - table_properties: Physical properties dict
2223
2224        Example:
2225            # Model parameter (priority):
2226            partitioned_by=dt,
2227            clustered_by=(dt, id))
2228            physical_properties(
2229                primary_key=(id, dt)
2230            )
2231
2232            # Or physical_properties only:
2233            physical_properties(
2234                duplicate_key=(id, dt),
2235                partitioned_by=dt,
2236                order_by=(dt, id)
2237            )
2238        """
2239        # Use setdefault to simplify table_properties access
2240        table_properties = kwargs.setdefault("table_properties", {})
2241
2242        # Extract and validate key columns from table_properties
2243        # Priority: parameter primary_key > table_properties (already handled above)
2244        key_type, key_columns = self._extract_and_validate_key_columns(
2245            table_properties, primary_key
2246        )
2247        # logger.debug(
2248        #     "_create_table_from_columns: extracted key_type=%s, key_columns=%s",
2249        #     key_type,
2250        #     key_columns,
2251        # )
2252
2253        # IMPORTANT: Normalize parameter primary_key into table_properties for unified handling
2254        # This ensures _build_table_properties_exp() can access primary_key even when
2255        # it's passed as a model parameter rather than in physical_properties
2256        if primary_key:
2257            table_properties["primary_key"] = primary_key
2258            logger.debug("_create_table_from_columns: unified primary_key into table_properties")
2259        elif key_type:
2260            # logger.debug(
2261            #     "table key type '%s' may be handled in _build_table_key_property", key_type
2262            # )
2263            pass
2264
2265        # StarRocks key column ordering constraint: All key types need reordering
2266        if key_columns:
2267            target_columns_to_types = self._reorder_columns_for_key(
2268                target_columns_to_types, key_columns, key_type or "key"
2269            )
2270
2271        # IMPORTANT: Do NOT pass primary_key to base class!
2272        # Unlike other databases, StarRocks requires PRIMARY KEY to be in POST_SCHEMA location
2273        # (in properties section after columns), not inside schema (inside column definitions).
2274        # We handle ALL key types (including PRIMARY KEY) in _build_table_key_property.
2275        # logger.debug(
2276        #     "_create_table_from_columns: NOT passing primary_key to base class (handled in _build_table_key_property)"
2277        # )
2278        super()._create_table_from_columns(
2279            table_name=table_name,
2280            target_columns_to_types=target_columns_to_types,
2281            primary_key=None,  # StarRocks handles PRIMARY KEY in properties, not schema
2282            exists=exists,
2283            table_description=table_description,
2284            column_descriptions=column_descriptions,
2285            **kwargs,
2286        )
2287
2288    # ==================== View / Materialized View ====================
2289    def create_view(
2290        self,
2291        view_name: TableName,
2292        query_or_df: QueryOrDF,
2293        target_columns_to_types: t.Optional[t.Dict[str, exp.DataType]] = None,
2294        replace: bool = True,
2295        materialized: bool = False,
2296        materialized_properties: t.Optional[t.Dict[str, t.Any]] = None,
2297        table_description: t.Optional[str] = None,
2298        column_descriptions: t.Optional[t.Dict[str, str]] = None,
2299        view_properties: t.Optional[t.Dict[str, exp.Expr]] = None,
2300        source_columns: t.Optional[t.List[str]] = None,
2301        **create_kwargs: t.Any,
2302    ) -> None:
2303        """
2304        StarRocks behavior:
2305        - Regular VIEW: supports CREATE OR REPLACE (base behavior)
2306        - MATERIALIZED VIEW: does NOT support CREATE OR REPLACE, so replace=True => DROP + CREATE
2307        """
2308        if not materialized:
2309            return super().create_view(
2310                view_name=view_name,
2311                query_or_df=query_or_df,
2312                target_columns_to_types=target_columns_to_types,
2313                replace=replace,
2314                materialized=False,
2315                materialized_properties=materialized_properties,
2316                table_description=table_description,
2317                column_descriptions=column_descriptions,
2318                view_properties=view_properties,
2319                source_columns=source_columns,
2320                **create_kwargs,
2321            )
2322
2323        # MATERIALIZED VIEW path
2324        # MVs with audits get a synchronous refresh after creation (see _create_materialized_view),
2325        # which requires REFRESH DEFERRED. Validate before the drop so we fail without destroying
2326        # an existing MV.
2327        has_audits = bool((materialized_properties or {}).get("has_audits"))
2328        if has_audits:
2329            self._validate_deferred_refresh_for_audits(view_name, view_properties)
2330
2331        if replace:
2332            # Avoid DROP MATERIALIZED VIEW failure when an object with the same name exists but is not an MV.
2333            self.drop_data_object_on_type_mismatch(
2334                self.get_data_object(view_name), DataObjectType.MATERIALIZED_VIEW
2335            )
2336            self.drop_view(view_name, ignore_if_not_exists=True, materialized=True)
2337        # logger.debug(
2338        #     f"Creating materialized view: {view_name}, materialized: {materialized}, "
2339        #     f"materialized_properties: {materialized_properties}, "
2340        #     f"view_properties: {view_properties}, create_kwargs: {create_kwargs}, "
2341        # )
2342
2343        return self._create_materialized_view(
2344            view_name=view_name,
2345            query_or_df=query_or_df,
2346            target_columns_to_types=target_columns_to_types,
2347            materialized_properties=materialized_properties,
2348            table_description=table_description,
2349            column_descriptions=column_descriptions,
2350            view_properties=view_properties,
2351            source_columns=source_columns,
2352            **create_kwargs,
2353        )
2354
2355    def _create_materialized_view(
2356        self,
2357        view_name: TableName,
2358        query_or_df: QueryOrDF,
2359        target_columns_to_types: t.Optional[t.Dict[str, exp.DataType]] = None,
2360        materialized_properties: t.Optional[t.Dict[str, t.Any]] = None,
2361        table_description: t.Optional[str] = None,
2362        column_descriptions: t.Optional[t.Dict[str, str]] = None,
2363        view_properties: t.Optional[t.Dict[str, exp.Expr]] = None,
2364        source_columns: t.Optional[t.List[str]] = None,
2365        **create_kwargs: t.Any,
2366    ) -> None:
2367        """
2368        Create a StarRocks materialized view.
2369
2370        StarRocks MV schema supports a column list but does NOT support explicit data types in that list.
2371        We therefore build a schema with column names + optional COMMENT only.
2372        """
2373        import pandas as pd
2374
2375        query_or_df = self._native_df_to_pandas_df(query_or_df)
2376
2377        if isinstance(query_or_df, pd.DataFrame):
2378            values: t.List[t.Tuple[t.Any, ...]] = list(
2379                query_or_df.itertuples(index=False, name=None)
2380            )
2381            target_columns_to_types, source_columns = self._columns_to_types(
2382                query_or_df, target_columns_to_types, source_columns
2383            )
2384            if not target_columns_to_types:
2385                raise SQLMeshError("columns_to_types must be provided for dataframes")
2386            source_columns_to_types = get_source_columns_to_types(
2387                target_columns_to_types, source_columns
2388            )
2389            query_or_df = self._values_to_sql(
2390                values,
2391                source_columns_to_types,
2392                batch_start=0,
2393                batch_end=len(values),
2394            )
2395
2396        source_queries, target_columns_to_types = self._get_source_queries_and_columns_to_types(
2397            query_or_df,
2398            target_columns_to_types,
2399            batch_size=0,
2400            target_table=view_name,
2401            source_columns=source_columns,
2402        )
2403        if len(source_queries) != 1:
2404            raise SQLMeshError("Only one source query is supported for creating materialized views")
2405
2406        target_table = exp.to_table(view_name)
2407        schema: t.Union[exp.Table, exp.Schema] = self._build_materialized_view_schema_exp(
2408            target_table,
2409            target_columns_to_types=target_columns_to_types,
2410            column_descriptions=column_descriptions,
2411        )
2412
2413        # Pass model materialized properties through the existing properties builder
2414        partitioned_by = None
2415        clustered_by = None
2416        partition_interval_unit = None
2417        if materialized_properties:
2418            partitioned_by = materialized_properties.get("partitioned_by")
2419            clustered_by = materialized_properties.get("clustered_by")
2420            partition_interval_unit = materialized_properties.get("partition_interval_unit")
2421            # logger.debug(
2422            #     f"Get info from materialized_properties: {materialized_properties}, "
2423            #     f"partitioned_by: {partitioned_by}, "
2424            #     f"clustered_by: {clustered_by}, "
2425            #     f"partition_interval_unit: {partition_interval_unit}"
2426            # )
2427
2428        properties_exp = self._build_table_properties_exp(
2429            catalog_name=target_table.catalog,
2430            table_properties=view_properties,
2431            target_columns_to_types=target_columns_to_types,
2432            table_description=table_description,
2433            partitioned_by=partitioned_by,
2434            clustered_by=clustered_by,
2435            partition_interval_unit=partition_interval_unit,
2436            table_kind="MATERIALIZED_VIEW",
2437        )
2438
2439        with source_queries[0] as query:
2440            self.execute(
2441                exp.Create(
2442                    this=schema,
2443                    kind="VIEW",
2444                    replace=False,
2445                    expression=query,
2446                    properties=properties_exp,
2447                    **create_kwargs,
2448                ),
2449                quote_identifiers=self.QUOTE_IDENTIFIERS_IN_VIEWS,
2450            )
2451
2452        # MVs with audits are created with REFRESH DEFERRED (enforced in create_view), so StarRocks
2453        # does not populate them on creation. Audits need data, so block on a synchronous refresh.
2454        if bool((materialized_properties or {}).get("has_audits")):
2455            refresh_sql = (
2456                f"REFRESH MATERIALIZED VIEW "
2457                f"{exp.to_table(view_name).sql(dialect=self.dialect, identify=True)} "
2458                f"WITH SYNC MODE"
2459            )
2460            self.execute(refresh_sql)
2461
2462        self._clear_data_object_cache(view_name)
2463
2464    def _build_materialized_view_schema_exp(
2465        self,
2466        table: exp.Table,
2467        *,
2468        target_columns_to_types: t.Optional[t.Dict[str, exp.DataType]] = None,
2469        column_descriptions: t.Optional[t.Dict[str, str]] = None,
2470    ) -> t.Union[exp.Table, exp.Schema]:
2471        """
2472        Build a StarRocks MV schema with column names + optional COMMENT only (no types).
2473        """
2474        columns: t.List[str] = []
2475        if target_columns_to_types:
2476            columns = list(target_columns_to_types)
2477        elif column_descriptions:
2478            columns = list(column_descriptions)
2479
2480        if not columns:
2481            return table
2482
2483        column_descriptions = column_descriptions or {}
2484        expressions: t.List[exp.Expr] = []
2485        for col in columns:
2486            constraints: t.List[exp.ColumnConstraint] = []
2487            comment = column_descriptions.get(col)
2488            if comment:
2489                constraints.append(
2490                    exp.ColumnConstraint(
2491                        kind=exp.CommentColumnConstraint(
2492                            this=exp.Literal.string(self._truncate_column_comment(comment))
2493                        )
2494                    )
2495                )
2496            expressions.append(
2497                exp.ColumnDef(
2498                    this=exp.to_identifier(col),
2499                    constraints=constraints,
2500                )
2501            )
2502
2503        return exp.Schema(this=table, expressions=expressions)
2504
2505    # ==================== Table Properties Builder (for Table and MV/VIew) ====================
2506    def _build_table_properties_exp(
2507        self,
2508        catalog_name: t.Optional[str] = None,
2509        table_format: t.Optional[str] = None,
2510        storage_format: t.Optional[str] = None,
2511        partitioned_by: t.Optional[t.List[exp.Expr]] = None,
2512        partition_interval_unit: t.Optional[IntervalUnit] = None,
2513        clustered_by: t.Optional[t.List[exp.Expr]] = None,
2514        table_properties: t.Optional[t.Dict[str, exp.Expr]] = None,
2515        target_columns_to_types: t.Optional[t.Dict[str, exp.DataType]] = None,
2516        table_description: t.Optional[str] = None,
2517        table_kind: t.Optional[str] = None,
2518        **kwargs: t.Any,
2519    ) -> t.Optional[exp.Properties]:
2520        """
2521        Build table properties for StarRocks CREATE TABLE statement.
2522
2523        Unified Model Parameter vs Physical Properties Handling:
2524        This method receives both model parameters (partitioned_by, clustered_by) and
2525        physical_properties (table_properties dict). Priority is handled as follows:
2526
2527        1. primary_key / partitioned_by / clustered_by (ORDER BY)
2528           - Model parameter takes priority
2529           - Falls back to physical_properties.xxx
2530           - Handled in _build_partition_property
2531
2532        2. special for primary_key:
2533           - Still need to be processed in _build_table_key_property
2534
2535        3. Other key types (duplicate_key, unique_key, aggregate_key):
2536           - Only available via physical_properties
2537           - Handled in _build_table_key_property
2538
2539        Handles:
2540        - Key constraints (PRIMARY KEY, DUPLICATE KEY, UNIQUE KEY)
2541        - Partition expressions (RANGE/LIST/EXPRESSION)
2542        - Distribution (HASH/RANDOM)
2543        - Order by (clustering)
2544        - Table comment
2545        - Other properties (replication_num, storage_medium, etc.)
2546
2547        Args:
2548            partitioned_by: Partition columns/expression from model parameter (takes priority)
2549            clustered_by: Clustering columns from model parameter (takes priority)
2550            table_properties: Dictionary containing physical_properties:
2551                - primary_key/duplicate_key/unique_key/aggregate_key: Tuple/list of column names
2552                - partitioned_by(partition_by): Partition definition (fallback)
2553                - distributed_by: Tuple of EQ expressions (kind, expressions, buckets) or string
2554                - clustered_by(order_by): Clustering definition (fallback)
2555                - replication_num, storage_medium, etc.: Literal values
2556            table_description: Table comment
2557        """
2558        properties: t.List[exp.Expr] = []
2559        table_properties_copy = dict(table_properties) if table_properties else {}
2560        # logger.debug(
2561        #     "_build_table_properties_exp: table_properties=%s",
2562        #     table_properties.keys() if table_properties else [],
2563        # )
2564
2565        is_mv = table_kind == "MATERIALIZED_VIEW"
2566        if is_mv:
2567            # Required for CREATE MATERIALIZED VIEW (SQLGlot uses this property to switch the keyword)
2568            properties.append(exp.MaterializedProperty())
2569
2570        # Validate all property names at once
2571        PropertyValidator.check_all_invalid_names(table_properties_copy)
2572
2573        # Check for mutually exclusive key types
2574        # Note: primary_key is already set into table_properties if model param is set
2575        active_key_type = PropertyValidator.check_at_most_one(
2576            property_name="key_type",
2577            property_description="key type",
2578            table_properties=table_properties_copy,
2579        )
2580        if is_mv and active_key_type:
2581            raise SQLMeshError(
2582                f"You can't specify the table type when the table is a materialized view. "
2583                f"Current specified key type '{active_key_type}'."
2584            )
2585
2586        # 0. Extract key columns for partition/distribution validation (read-only, don't pop yet)
2587        key_type, key_columns = None, None
2588        if active_key_type:
2589            key_type = active_key_type
2590            key_expr = table_properties_copy[key_type]
2591            # Use validate_and_normalize_property to get List[exp.Column], then extract names
2592            normalized = PropertyValidator.validate_and_normalize_property(
2593                key_type, key_expr, preprocess_parentheses=True
2594            )
2595            key_columns = tuple(col.name for col in normalized)
2596
2597        # 1. Handle key constraints (ALL types including PRIMARY KEY)
2598        key_prop = self._build_table_key_property(table_properties_copy, active_key_type)
2599        if key_prop:
2600            properties.append(key_prop)
2601
2602        # 2. Add table comment (it must be ahead of other properties except the talbe key/type)
2603        if table_description:
2604            properties.append(
2605                exp.SchemaCommentProperty(
2606                    this=exp.Literal.string(self._truncate_table_comment(table_description))
2607                )
2608            )
2609
2610        # 3. Handle partitioned_by (PARTITION BY RANGE/LIST/EXPRESSION)
2611        partition_prop = self._build_partition_property(
2612            partitioned_by,
2613            partition_interval_unit,
2614            target_columns_to_types,
2615            catalog_name,
2616            table_properties_copy,
2617            key_type,
2618            key_columns,
2619        )
2620        if partition_prop:
2621            properties.append(partition_prop)
2622
2623        # 4. Handle distributed_by (DISTRIBUTED BY HASH/RANDOM)
2624        distributed_prop = self._build_distributed_by_property(table_properties_copy, key_columns)
2625        if distributed_prop:
2626            properties.append(distributed_prop)
2627
2628        # 5. Handle refresh_property (REFRESH ...)
2629        # StarRocks only supports ASYNC materialized views, which require a REFRESH clause.
2630        # Synchronous MVs are not supported, so a missing refresh is a hard error rather than
2631        # a silent fallback (which would create an undetectable sync MV).
2632        if is_mv:
2633            refresh_prop = self._build_refresh_property(table_properties_copy)
2634            if refresh_prop is None:
2635                raise SQLMeshError(
2636                    "StarRocks materialized views require a REFRESH clause. "
2637                    "Specify at least one of 'refresh_moment' or 'refresh_scheme' in the model's "
2638                    "physical_properties (e.g. refresh_scheme = 'ASYNC')."
2639                )
2640            properties.append(refresh_prop)
2641
2642        # 6. Handle order_by/clustered_by (ORDER BY ...)
2643        order_prop = self._build_order_by_property(table_properties_copy, clustered_by or None)
2644        if order_prop:
2645            properties.append(order_prop)
2646
2647        # 5. Handle other properties (replication_num, storage_medium, etc.)
2648        other_props = self._build_other_properties(table_properties_copy)
2649        properties.extend(other_props)
2650
2651        return exp.Properties(expressions=properties) if properties else None
2652
2653    def _build_view_properties_exp(
2654        self,
2655        view_properties: t.Optional[t.Dict[str, exp.Expr]] = None,
2656        table_description: t.Optional[str] = None,
2657        **kwargs: t.Any,
2658    ) -> t.Optional[exp.Properties]:
2659        """
2660        Build CREATE VIEW properties for StarRocks.
2661
2662        Supports StarRocks view SECURITY syntax: SECURITY {NONE | INVOKER}
2663        via exp.SqlSecurityProperty (renders as `SECURITY <value>`).
2664        """
2665        properties: t.List[exp.Expr] = []
2666
2667        if table_description:
2668            properties.append(
2669                exp.SchemaCommentProperty(
2670                    this=exp.Literal.string(self._truncate_table_comment(table_description))
2671                )
2672            )
2673
2674        if view_properties:
2675            view_properties_copy = dict(view_properties)
2676            security = view_properties_copy.pop("security", None)
2677            if security is not None:
2678                security_text = PropertyValidator.validate_and_normalize_property(
2679                    "security", security
2680                )
2681                # exp.SqlSecurityProperty renders as `SECURITY <value>` (no '=')
2682                properties.append(exp.SqlSecurityProperty(this=exp.Var(this=security_text)))
2683
2684            properties.extend(self._table_or_view_properties_to_expressions(view_properties_copy))
2685
2686        if properties:
2687            return exp.Properties(expressions=properties)
2688        return None
2689
2690    def _build_table_key_property(
2691        self, table_properties: t.Dict[str, t.Any], active_key_type: t.Optional[str]
2692    ) -> t.Optional[exp.Expr]:
2693        """
2694        Build key constraint property for ALL key types including PRIMARY KEY.
2695
2696        Unlike other databases where PRIMARY KEY is handled by base class in schema,
2697        StarRocks requires ALL key types (PRIMARY KEY, DUPLICATE KEY, UNIQUE KEY, AGGREGATE KEY)
2698        to be in POST_SCHEMA location (properties section after columns).
2699
2700        Handles:
2701        - PRIMARY KEY
2702        - DUPLICATE KEY
2703        - UNIQUE KEY
2704        - AGGREGATE KEY (when implemented)
2705
2706        Args:
2707            table_properties: Dictionary containing key definitions (will be modified)
2708            active_key_type: The active key type or None
2709
2710        Returns:
2711            Key property expression for the active key type, or None
2712        """
2713        if not active_key_type:
2714            return None
2715
2716        # Configuration: key_name -> Property class (excluding primary_key)
2717        KEY_PROPERTY_CLASSES: t.Dict[str, t.Type[exp.Expr]] = {
2718            "primary_key": exp.PrimaryKey,
2719            "duplicate_key": exp.DuplicateKeyProperty,
2720            "unique_key": exp.UniqueKeyProperty,
2721            # "aggregate_key": exp.AggregateKeyProperty,  # Not implemented yet
2722        }
2723
2724        property_class = KEY_PROPERTY_CLASSES.get(active_key_type)
2725        key_value = table_properties.pop(active_key_type, None)
2726        if not property_class:
2727            # Aggregate key requires special handling
2728            if active_key_type == "aggregate_key":
2729                raise SQLMeshError(
2730                    "AGGREGATE KEY tables are not currently supported. "
2731                    "AGGREGATE KEY requires specifying aggregation functions (SUM/MAX/MIN/REPLACE) "
2732                    "for value columns, which is not supported in the current model configuration syntax. "
2733                    "Please use PRIMARY KEY, UNIQUE KEY, or DUPLICATE KEY instead."
2734                )
2735            # Unknown key type
2736            logger.warning(f"[StarRocks] Unknown key type: {active_key_type}")
2737            return None
2738        if key_value is None:
2739            logger.error(f"Failed to get the parameter value for {active_key_type!r}")
2740            return None
2741
2742        logger.debug(
2743            "_build_table_key_property: input key=%s value=%s",
2744            active_key_type,
2745            key_value,
2746        )
2747
2748        # Validate and normalize
2749        # preprocess_parentheses=True handles string preprocessing like 'id, dt' -> '(id, dt)'
2750        normalized = PropertyValidator.validate_and_normalize_property(
2751            active_key_type, key_value, preprocess_parentheses=True
2752        )
2753        # normalized is List[exp.Column] as defined in TableKeyInputSpec
2754        result = property_class(expressions=list(normalized))
2755        return result
2756
2757    def _build_partition_property(
2758        self,
2759        partitioned_by: t.Optional[t.List[exp.Expr]],
2760        partition_interval_unit: t.Optional["IntervalUnit"],
2761        target_columns_to_types: t.Optional[t.Dict[str, exp.DataType]],
2762        catalog_name: t.Optional[str],
2763        table_properties: t.Dict[str, t.Any],
2764        key_type: t.Optional[str],
2765        key_columns: t.Optional[t.Tuple[str, ...]],
2766    ) -> t.Optional[exp.Expr]:
2767        """
2768        Build partition property expression.
2769
2770        StarRocks supports:
2771        - PARTITION BY RANGE (cols) - for time-based partitions
2772        - PARTITION BY LIST (cols) - for categorical partitions
2773        - PARTITION BY (exprs) - for expression partitions, can also be `exprs` (without `(`, and `)`)
2774
2775        Args:
2776            partitioned_by: Partition column expressions from parameter
2777            partition_interval_unit: Optional time unit for automatic partitioning
2778            target_columns_to_types: Column definitions
2779            catalog_name: Catalog name (if applicable)
2780            table_properties: Dictionary containing partitioned_by/partitions (will be modified)
2781            key_type: Table key type (for validation)
2782            key_columns: Table key columns (partition columns must be subset)
2783
2784        Returns:
2785            Partition property expression or None
2786        """
2787        # Priority: parameter > partition_by (alias) > partitioned_by
2788        # Use PropertyValidator to check mutual exclusion between parameter and properties
2789        partition_param_name = PropertyValidator.check_at_most_one(
2790            property_name="partitioned_by",
2791            property_description="partition definition",
2792            table_properties=table_properties,
2793            parameter_value=partitioned_by or None,
2794        )
2795
2796        # If parameter was provided, it takes priority
2797        if not partitioned_by and partition_param_name:
2798            # Get from table_properties
2799            partitioned_by = table_properties.pop(partition_param_name, None)
2800        if not partitioned_by:
2801            return None
2802
2803        # Parse partition expressions to extract columns and kind (RANGE/LIST)
2804        partition_kind, partition_cols = self._parse_partition_expressions(partitioned_by)
2805        logger.debug(
2806            "_build_partition_property: partition_kind=%s, partition_cols=%s",
2807            partition_kind,
2808            partition_cols,
2809        )
2810
2811        def extract_column_name(expr: exp.Expr) -> t.Optional[str]:
2812            if isinstance(expr, exp.Column):
2813                return str(expr.name)
2814            elif isinstance(expr, (exp.Anonymous, exp.Func)):  # noqa: RET505
2815                return None  # not implemented
2816            else:
2817                return str(expr)
2818
2819        # Validate partition columns are in key columns (StarRocks requirement)
2820        if key_columns:
2821            partition_col_names = set(extract_column_name(expr) for expr in partition_cols) - {None}
2822            key_cols_set = set(key_columns)
2823            not_in_key = partition_col_names - key_cols_set
2824            if not_in_key:
2825                logger.warning(
2826                    f"[StarRocks] Partition columns {not_in_key} not in {key_type} columns {key_cols_set}. "
2827                    "StarRocks requires partition columns to be part of the table key."
2828                )
2829
2830        # Get partition definitions (RANGE/LIST partitions)
2831        # Note: Expression-based partitioning (partition_kind=None) does not support pre-created partitions
2832        if partitions := table_properties.pop("partitions", None):
2833            if partition_kind is None:
2834                logger.warning(
2835                    "[StarRocks] 'partitions' parameter is ignored for expression-based partitioning. "
2836                    "Expression partitioning creates partitions automatically and does not support "
2837                    "pre-created partition definitions."
2838                )
2839                partitions = None  # Ignore partitions for expression-based partitioning
2840            else:
2841                partitions = PropertyValidator.validate_and_normalize_property(
2842                    "partitions", partitions
2843                )
2844
2845        # Build partition expression using base class method
2846        result = self._build_partitioned_by_exp(
2847            partition_cols,
2848            partition_interval_unit=partition_interval_unit,
2849            target_columns_to_types=target_columns_to_types,
2850            catalog_name=catalog_name,
2851            partitions=partitions,
2852            partition_kind=partition_kind,
2853        )
2854        return result
2855
2856    def _parse_partition_expressions(
2857        self, partitioned_by: t.List[exp.Expr]
2858    ) -> t.Tuple[t.Optional[str], t.List[exp.Expr]]:
2859        """
2860        Parse partition expressions and extract partition kind (RANGE/LIST).
2861
2862        Uses PartitionedByInputSpec to validate and normalize the entire list,
2863        then extracts RANGE/LIST kind from function expressions.
2864
2865        The SPEC output is List[exp.Column | exp.Anonymous | exp.Func], where:
2866        - exp.Column: Regular column reference
2867        - exp.Anonymous: Function call like RANGE(col), LIST(col), and other datetime related functions
2868        - exp.Func: date_trunc(), and other built-in functions
2869
2870        Args:
2871            partitioned_by: List of partition expressions
2872
2873        Returns:
2874            Tuple of (partition_kind, normalized_columns)
2875            - partition_kind: "RANGE", "LIST", or None
2876            - normalized_columns: List of Column expressions, or function expressions
2877        """
2878        parsed_cols: t.List[exp.Expr] = []
2879        partition_kind: t.Optional[str] = None
2880
2881        normalized = PropertyValidator.validate_and_normalize_property(
2882            "partitioned_by", partitioned_by, preprocess_parentheses=True
2883        )
2884        # Process each normalized expression
2885        for norm_expr in normalized:
2886            # Check if it's a RANGE function (exp.Anonymous)
2887            if isinstance(norm_expr, exp.Anonymous) and norm_expr.this:
2888                func_name = str(norm_expr.this).upper()
2889                if func_name in ("RANGE", "LIST"):
2890                    partition_kind = func_name
2891                    # Extract column expressions from function arguments
2892                    for arg in norm_expr.expressions:
2893                        if isinstance(arg, exp.Column):
2894                            parsed_cols.append(arg)
2895                        else:
2896                            parsed_cols.append(exp.to_column(str(arg)))
2897                    continue
2898
2899            # Check if it's a LIST expression (SQLGlot parses LIST(...) as exp.List)
2900            if isinstance(norm_expr, exp.List):
2901                partition_kind = "LIST"
2902                # Extract column expressions from list items
2903                for item in norm_expr.expressions:
2904                    if isinstance(item, exp.Column):
2905                        parsed_cols.append(item)
2906                    else:
2907                        parsed_cols.append(exp.to_column(str(item)))
2908                continue
2909
2910            # Regular column or other function (date_trunc, etc.)
2911            parsed_cols.append(norm_expr)
2912
2913        return partition_kind, parsed_cols
2914
2915    def _build_partitioned_by_exp(
2916        self,
2917        partitioned_by: t.List[exp.Expr],
2918        *,
2919        partition_interval_unit: t.Optional["IntervalUnit"] = None,
2920        target_columns_to_types: t.Optional[t.Dict[str, exp.DataType]] = None,
2921        catalog_name: t.Optional[str] = None,
2922        **kwargs: t.Any,
2923    ) -> t.Optional[
2924        t.Union[
2925            exp.PartitionedByProperty,
2926            exp.PartitionByRangeProperty,
2927            exp.PartitionByListProperty,
2928            exp.Property,
2929        ]
2930    ]:
2931        """
2932        Build StarRocks partitioning expression.
2933
2934        - partition_kind: RANGE/LIST/None (passed via kwargs, None as expression partitioning)
2935        - partitioned_by: normalized partition column/func/anonymous expressions
2936        - partitions: partition definitions as List[str] (passed via kwargs)
2937
2938        Supports both RANGE and LIST partition syntaxes, and expression partition syntax.
2939
2940        Args:
2941            partitioned_by: List of partition column expressions
2942            partition_interval_unit: Optional time unit (unused for now)
2943            target_columns_to_types: Column definitions (unused for now)
2944            catalog_name: Catalog name (unused for now)
2945            **kwargs: Must contain 'partition_kind' and optionally 'partitions'
2946
2947        Returns:
2948            PartitionByRangeProperty, PartitionByListProperty, or None
2949        """
2950        partition_kind = kwargs.get("partition_kind")
2951        partitions: t.Optional[t.List[str]] = kwargs.get("partitions")
2952
2953        # Process partitions to create_expressions
2954        # partitions is already List[str] after SPEC normalization
2955        create_expressions: t.Optional[t.List[exp.Var]] = None
2956        if partitions:
2957            create_expressions = [exp.Var(this=p, quoted=False) for p in partitions]
2958
2959        # Build partition expression
2960        if partition_kind == "LIST":
2961            return exp.PartitionByListProperty(
2962                partition_expressions=partitioned_by,
2963                create_expressions=create_expressions,
2964            )
2965        elif partition_kind == "RANGE":  # noqa: RET505
2966            return exp.PartitionByRangeProperty(
2967                partition_expressions=partitioned_by,
2968                create_expressions=create_expressions,
2969            )
2970        elif partition_kind is None:
2971            return exp.PartitionedByProperty(this=exp.Schema(expressions=partitioned_by))
2972
2973        return None
2974
2975    def _build_distributed_by_property(
2976        self,
2977        table_properties: t.Dict[str, t.Any],
2978        key_columns: t.Optional[t.Tuple[str, ...]],
2979    ) -> t.Optional[exp.DistributedByProperty]:
2980        """
2981        Build DISTRIBUTED BY property from table_properties.
2982
2983        Supports:
2984        1. Structured tuple: (kind='HASH', columns=(id, dt), buckets=10)
2985        2. String format: "HASH(id)", "RANDOM", "HASH(id) BUCKETS 10"
2986        3. None: Returns None (no default distribution)
2987
2988        For complex string like "HASH(id) BUCKETS 10", uses split-and-combine:
2989        - Split on 'BUCKETS' to separate HASH part and bucket count
2990        - Parse HASH part via DistributedByInputSpec
2991        - Parse bucket count as number
2992        - Combine into unified dict
2993
2994        Args:
2995            table_properties: Dictionary containing distributed_by (will be modified)
2996            key_columns: Table key columns (used for default distribution)
2997
2998        Returns:
2999            DistributedByProperty or None
3000        """
3001        distributed_by = table_properties.pop("distributed_by", None)
3002
3003        # No default - if not set, return None
3004        if distributed_by is None:
3005            return None
3006
3007        # Try to parse complex string with BUCKETS first
3008        unified = self._parse_distribution_with_buckets(distributed_by)
3009        if unified is None:
3010            # Fall back to SPEC-based parsing
3011            normalized = PropertyValidator.validate_and_normalize_property(
3012                "distributed_by", distributed_by
3013            )
3014            # Convert to unified dict format
3015            unified = DistributionTupleOutputType.to_unified_dict(normalized)
3016
3017        logger.debug(
3018            "_build_distributed_by_property: normalized to kind=%s, columns=%s, buckets=%s",
3019            unified.get("kind"),
3020            unified.get("columns"),
3021            unified.get("buckets"),
3022        )
3023
3024        # Build expression
3025        kind_expr = exp.Var(this=unified["kind"])
3026        # Convert columns to expressions
3027        columns: t.List[exp.Column] = unified.get("columns", [])
3028        expressions_list: t.List[exp.Expr] = []
3029        for col in columns:
3030            if isinstance(col, exp.Expr):
3031                expressions_list.append(col)
3032            else:
3033                expressions_list.append(exp.to_column(str(col)))
3034        # Build buckets expression
3035        buckets: t.Optional[t.Any] = unified.get("buckets")
3036        buckets_expr: t.Optional[exp.Expr] = None
3037        if buckets is not None:
3038            if isinstance(buckets, exp.Literal):
3039                buckets_expr = buckets
3040            else:
3041                buckets_expr = exp.Literal.number(int(buckets))
3042
3043        result = exp.DistributedByProperty(
3044            kind=kind_expr,
3045            expressions=expressions_list,
3046            buckets=buckets_expr,
3047            order=None,
3048        )
3049        return result
3050
3051    def _validate_deferred_refresh_for_audits(
3052        self,
3053        view_name: TableName,
3054        view_properties: t.Optional[t.Dict[str, exp.Expr]],
3055    ) -> None:
3056        """
3057        Ensure a materialized view with audits uses REFRESH DEFERRED.
3058
3059        StarRocks audits require data to exist in the MV, so SQLMesh issues an explicit synchronous
3060        `REFRESH MATERIALIZED VIEW ... WITH SYNC MODE` right after creating the MV. For that to be
3061        deterministic, the MV must use `refresh_moment = 'DEFERRED'`; otherwise StarRocks' automatic
3062        (IMMEDIATE) refresh would run concurrently and race with the explicit one. A missing
3063        refresh_moment defaults to IMMEDIATE in StarRocks, so it is rejected as well.
3064        """
3065        refresh_moment = (view_properties or {}).get("refresh_moment")
3066        normalized = (
3067            PropertyValidator.validate_and_normalize_property("refresh_moment", refresh_moment)
3068            if refresh_moment is not None
3069            else None
3070        )
3071        if normalized != "DEFERRED":
3072            raise SQLMeshError(
3073                f"[StarRocks] Materialized view '{exp.to_table(view_name).sql(dialect=self.dialect)}' "
3074                "has audits, which require a synchronous refresh after creation. This is only "
3075                "supported with deferred refresh, so the model must set "
3076                "`refresh_moment = 'DEFERRED'` in its physical_properties "
3077                f"(got {normalized or 'no refresh_moment; StarRocks defaults to IMMEDIATE'}). "
3078                "DEFERRED prevents StarRocks' "
3079                "automatic refresh from racing with the synchronous refresh SQLMesh issues."
3080            )
3081
3082    def _build_refresh_property(
3083        self, table_properties: t.Dict[str, t.Any]
3084    ) -> t.Optional[exp.RefreshTriggerProperty]:
3085        """
3086        Build StarRocks MV REFRESH clause as exp.RefreshTriggerProperty.
3087
3088        Input (from physical_properties):
3089        - refresh_moment: IMMEDIATE | DEFERRED (optional)
3090        - refresh_scheme: MANUAL | ASYNC [START (<start_time>)] EVERY (INTERVAL <n> <unit>) (optional)
3091
3092        Output mapping (to match sqlglot StarRocks generator refreshtriggerproperty_sql):
3093        - method: refresh_moment when provided; otherwise a sentinel that won't render
3094        - kind: ASYNC | MANUAL
3095        - starts/every/unit: parsed from refresh_scheme if present
3096        """
3097        refresh_moment = table_properties.pop("refresh_moment", None)
3098        refresh_scheme = table_properties.pop("refresh_scheme", None)
3099        if refresh_moment is None and refresh_scheme is None:
3100            return None
3101
3102        # method is required by exp.RefreshTriggerProperty, but StarRocks syntax does NOT support AUTO.
3103        # We use a sentinel value that the StarRocks generator will not render (it only renders
3104        # IMMEDIATE/DEFERRED).
3105        method_expr = None
3106        if refresh_moment is not None:
3107            refresh_moment_text = PropertyValidator.validate_and_normalize_property(
3108                "refresh_moment", refresh_moment
3109            )
3110            method_expr = exp.Var(this=refresh_moment_text)
3111
3112        kind_expr: t.Optional[exp.Expr] = None
3113        starts_expr: t.Optional[exp.Expr] = None
3114        every_expr: t.Optional[exp.Expr] = None
3115        unit_expr: t.Optional[exp.Expr] = None
3116
3117        if refresh_scheme is not None:
3118            scheme_text = PropertyValidator.validate_and_normalize_property(
3119                "refresh_scheme", refresh_scheme
3120            )
3121            if isinstance(scheme_text, exp.Var):
3122                kind_expr = scheme_text
3123            else:
3124                kind_expr, starts_expr, every_expr, unit_expr = self._parse_refresh_scheme(
3125                    scheme_text
3126                )
3127
3128        return exp.RefreshTriggerProperty(
3129            method=method_expr,
3130            kind=kind_expr,
3131            starts=starts_expr,
3132            every=every_expr,
3133            unit=unit_expr,
3134        )
3135
3136    def _parse_refresh_scheme(
3137        self, refresh_scheme: str
3138    ) -> t.Tuple[
3139        t.Optional[exp.Expr],
3140        t.Optional[exp.Expr],
3141        t.Optional[exp.Expr],
3142        t.Optional[exp.Expr],
3143    ]:
3144        """
3145        Parse StarRocks refresh_scheme text into (kind, starts, every, unit).
3146
3147        parsing simple and robust. We only extract:
3148        - kind: ASYNC | MANUAL (must appear at the beginning), None if not provided
3149        - starts: START (<start_time>) where <start_time> is treated as a raw string
3150        - every/unit: EVERY (INTERVAL <n> <unit>)
3151        """
3152        text = (refresh_scheme or "").strip()
3153        if not text:
3154            return None, None, None, None
3155
3156        m_kind = re.match(r"^(MANUAL|ASYNC)\b", text, flags=re.IGNORECASE)
3157        if not m_kind:
3158            raise SQLMeshError(
3159                f"[StarRocks] Invalid refresh_scheme {refresh_scheme!r}. Expected to start with MANUAL or ASYNC."
3160            )
3161        kind = m_kind.group(1).upper()
3162        kind_expr: t.Optional[exp.Expr] = exp.Var(this=kind)
3163
3164        starts_expr: t.Optional[exp.Expr] = None
3165        every_expr: t.Optional[exp.Expr] = None
3166        unit_expr: t.Optional[exp.Expr] = None
3167        m_start = re.search(
3168            r"\bSTART\s*\(\s*(?:'([^']*)'|\"([^\"]*)\"|([^)]*))\s*\)", text, flags=re.IGNORECASE
3169        )
3170        if m_start:
3171            start_inner = (m_start.group(1) or m_start.group(2) or m_start.group(3) or "").strip()
3172            starts_expr = exp.Literal.string(start_inner)
3173        m_every = re.search(
3174            r"\bEVERY\s*\(\s*INTERVAL\s+(\d+)\s+(\w+)\s*\)", text, flags=re.IGNORECASE
3175        )
3176        if m_every:
3177            every_expr = exp.Literal.number(int(m_every.group(1)))
3178            unit_expr = exp.Var(this=m_every.group(2).upper())
3179        return kind_expr, starts_expr, every_expr, unit_expr
3180
3181    def _parse_distribution_with_buckets(
3182        self, distributed_by: t.Any
3183    ) -> t.Optional[t.Dict[str, t.Any]]:
3184        """
3185        Parse complex distribution expressions like 'HASH(id) BUCKETS 10'.
3186
3187        Since SQLGlot cannot parse 'HASH(id) BUCKETS 10' directly, we:
3188        1. Detect if input is a string containing 'BUCKETS'
3189        2. Split into HASH part and BUCKETS part
3190        3. Parse HASH part via DistributedByInputSpec
3191        4. Extract bucket count as number
3192        5. Combine into unified dict
3193
3194        Args:
3195            distributed_by: The distribution value (may be string, expression, etc.)
3196
3197        Returns:
3198            Unified dict with keys: kind, columns, buckets
3199            Returns None if not a complex BUCKETS expression
3200            (The output function will still handle "HASH(id)" without BUCKETS)
3201        """
3202        # Only handle string or Literal string values
3203        if isinstance(distributed_by, str):
3204            text = distributed_by
3205        elif isinstance(distributed_by, exp.Literal) and distributed_by.is_string:
3206            text = str(distributed_by.this)
3207        else:
3208            return None
3209
3210        # Check if contains BUCKETS keyword (case-insensitive)
3211        if "BUCKETS" not in text.upper():
3212            return None
3213
3214        # Split on BUCKETS (case-insensitive)
3215        match = re.match(r"^(.+?)\s+BUCKETS\s+(\d+)\s*$", text.strip(), flags=re.IGNORECASE)
3216        if not match:
3217            return None
3218
3219        hash_part = match.group(1).strip()
3220        buckets_str = match.group(2)
3221
3222        # Parse the HASH/RANDOM part via SPEC
3223        normalized = PropertyValidator.validate_and_normalize_property("distributed_by", hash_part)
3224
3225        return DistributionTupleOutputType.to_unified_dict(normalized, int(buckets_str))
3226
3227    def _build_order_by_property(
3228        self,
3229        table_properties: t.Dict[str, t.Any],
3230        clustered_by: t.Optional[t.List[exp.Expr]],
3231    ) -> t.Optional[exp.Cluster]:
3232        """
3233        Build ORDER BY (clustering) property.
3234
3235        Supports both:
3236        - clustered_by parameter (from create_table call)
3237        - order_by in table_properties (backward compatibility alias)
3238
3239        Priority: clustered_by parameter > order_by in table_properties
3240
3241        Args:
3242            table_properties: Dictionary containing optional order_by (will be modified)
3243            clustered_by: Clustering columns from parameter
3244
3245        Returns:
3246            Cluster expression (generates ORDER BY) or None
3247        """
3248        # Priority: clustered_by parameter > order_by in table_properties
3249        # Use PropertyValidator to check mutual exclusion between parameter and property
3250        order_by_param_name = PropertyValidator.check_at_most_one(
3251            property_name="clustered_by",
3252            property_description="clustering definition",
3253            table_properties=table_properties,
3254            parameter_value=clustered_by,
3255        )
3256
3257        # If parameter was provided, it takes priority
3258        if clustered_by is None and order_by_param_name:
3259            # Get order_by from table_properties (already validated by check_at_most_one)
3260            order_by = table_properties.pop(order_by_param_name, None)
3261            if order_by is not None:
3262                normalized = PropertyValidator.validate_and_normalize_property(
3263                    "clustered_by", order_by, preprocess_parentheses=True
3264                )
3265                clustered_by = list(normalized)
3266
3267        if clustered_by:
3268            result = exp.Cluster(expressions=clustered_by)
3269            return result
3270        else:  # noqa: RET505
3271            return None
3272
3273    def _build_other_properties(self, table_properties: t.Dict[str, t.Any]) -> t.List[exp.Property]:
3274        """
3275        Build other literal properties (replication_num, storage_medium, etc.).
3276
3277        Uses validate_and_normalize_property for validation and ensures output is string,
3278        as StarRocks PROPERTIES syntax requires all values to be strings.
3279
3280        Args:
3281            table_properties: Dictionary containing properties (will be modified)
3282
3283        Returns:
3284            List of Property expressions
3285        """
3286        other_props = []
3287
3288        for key, value in list(table_properties.items()):
3289            # Skip special keys handled elsewhere
3290            if key in PropertyValidator.IMPORTANT_PROPERTY_NAMES:
3291                logger.warning(f"[StarRocks] {key!r} should have been processed already, skipping")
3292                continue
3293
3294            # Remove from properties
3295            table_properties.pop(key)
3296
3297            # Validate and normalize to string
3298            # All other properties are treated as generic string properties
3299            try:
3300                normalized = PropertyValidator.validate_and_normalize_property(key, value)
3301                other_props.append(
3302                    exp.Property(
3303                        this=exp.to_identifier(key),
3304                        value=exp.Literal.string(str(normalized)),
3305                    )
3306                )
3307            except SQLMeshError as e:
3308                logger.warning("[StarRocks] skipping property %s due to error: %s", key, e)
3309
3310        return other_props
3311
3312    def _extract_and_validate_key_columns(
3313        self,
3314        table_properties: t.Dict[str, t.Any],
3315        primary_key: t.Optional[t.Tuple[str, ...]] = None,
3316    ) -> t.Tuple[t.Optional[str], t.Optional[t.Tuple[str, ...]]]:
3317        """
3318        Extract and validate key columns from table_properties.
3319
3320        All key types require:
3321        - Key columns must be the first N columns in CREATE TABLE
3322        - Column order must match the KEY clause order
3323
3324        Priority:
3325        - Parameter primary_key > table_properties primary_key
3326        - Only one key type allowed per table
3327
3328        Args:
3329            table_properties: Table properties dictionary (lowercase keys expected)
3330            primary_key: Primary key from method parameter (highest priority)
3331
3332        Returns:
3333            Tuple of (key_type, key_columns)
3334            - key_type: One of 'primary_key', 'unique_key', 'duplicate_key', 'aggregate_key', None
3335            - key_columns: Tuple of column names, or None
3336
3337        Raises:
3338            SQLMeshError: If multiple key types are defined or column extraction fails
3339        """
3340        # Use PropertyValidator to check mutual exclusion
3341        active_key_type = PropertyValidator.check_at_most_one(
3342            property_name="key_type",  # dummy
3343            property_description="table key type",
3344            table_properties=table_properties,
3345            parameter_value=primary_key,
3346        )
3347
3348        # If parameter primary_key was provided, return it
3349        if primary_key:
3350            return ("primary_key", primary_key)
3351
3352        # Extract from table_properties
3353        if not active_key_type:
3354            return (None, None)
3355
3356        # Get the key expression and normalize via SPEC
3357        key_expr = table_properties[active_key_type]  # Read without popping
3358        # Use validate_and_normalize_property to get List[exp.Column], then extract names
3359        normalized = PropertyValidator.validate_and_normalize_property(
3360            active_key_type, key_expr, preprocess_parentheses=True
3361        )
3362        key_columns = tuple(col.name for col in normalized)
3363
3364        return (active_key_type, key_columns)
3365
3366    def _reorder_columns_for_key(
3367        self,
3368        target_columns_to_types: t.Dict[str, exp.DataType],
3369        key_columns: t.Tuple[str, ...],
3370        key_type: str = "key",
3371    ) -> t.Dict[str, exp.DataType]:
3372        """
3373        Reorder columns to place key columns first.
3374
3375        StarRocks Constraint (ALL Table Types):
3376        Key columns (PRIMARY/UNIQUE/DUPLICATE/AGGREGATE) MUST be the first N columns
3377        in the CREATE TABLE statement, in the same order as defined in the KEY clause.
3378
3379        Example:
3380            Input:
3381                columns = {"customer_id": INT, "order_id": BIGINT, "event_date": DATE}
3382                key_columns = ("order_id", "event_date")
3383                key_type = "primary_key"
3384
3385            Output:
3386                {"order_id": BIGINT, "event_date": DATE, "customer_id": INT}
3387
3388        Args:
3389            target_columns_to_types: Original column order (from SELECT)
3390            key_columns: Key column names in desired order
3391            key_type: Type of key for logging (primary_key, unique_key, etc.)
3392
3393        Returns:
3394            Reordered columns with key columns first
3395
3396        Raises:
3397            SQLMeshError: If a key column is not found in target_columns_to_types
3398        """
3399        # Validate that all key columns exist
3400        missing_key_cols = set(key_columns) - set(target_columns_to_types.keys())
3401        if missing_key_cols:
3402            raise SQLMeshError(
3403                f"{key_type} columns {missing_key_cols} not found in table columns. "
3404                f"Available columns: {list(target_columns_to_types.keys())}"
3405            )
3406
3407        # Build new ordered dict: key columns first, then remaining columns
3408        reordered = {}
3409
3410        # 1. Add key columns in key order
3411        for key_col in key_columns:
3412            reordered[key_col] = target_columns_to_types[key_col]
3413
3414        # 2. Add remaining columns (preserve original order)
3415        for col_name, col_type in target_columns_to_types.items():
3416            if col_name not in key_columns:
3417                reordered[col_name] = col_type
3418
3419        logger.info(
3420            f"Reordered columns for {key_type.upper()}: "
3421            f"Original order: {list(target_columns_to_types.keys())}, "
3422            f"New order: {list(reordered.keys())}"
3423        )
3424
3425        return reordered
3426
3427    def _build_create_comment_table_exp(
3428        self, table: exp.Table, table_comment: str, table_kind: str = "TABLE"
3429    ) -> str:
3430        """
3431        Build ALTER TABLE COMMENT SQL for table comment modification.
3432
3433        StarRocks uses non-standard syntax for table comments:
3434            ALTER TABLE {table} COMMENT = '{comment}'
3435
3436        Note: This method is typically NOT called for StarRocks because the table comment is
3437        included directly in CREATE TABLE (and CTAS) via SchemaCommentProperty, which StarRocks
3438        accepts even for `CREATE TABLE ... COMMENT '...' AS SELECT`.
3439
3440        However, this override is provided for potential future use cases:
3441        - Modifying comments on existing tables via ALTER TABLE
3442        - View comments (if COMMENT_CREATION_VIEW changes)
3443
3444        Args:
3445            table: Table expression
3446            table_comment: The comment to add
3447            table_kind: Type of object (TABLE, VIEW, etc.)
3448
3449        Returns:
3450            SQL string for ALTER TABLE COMMENT
3451        """
3452        table_sql = table.sql(dialect=self.dialect, identify=True)
3453        comment_sql = exp.Literal.string(self._truncate_table_comment(table_comment)).sql(
3454            dialect=self.dialect
3455        )
3456        return f"ALTER TABLE {table_sql} COMMENT = {comment_sql}"
3457
3458    def _build_create_comment_column_exp(
3459        self,
3460        table: exp.Table,
3461        column_name: str,
3462        column_comment: str,
3463        table_kind: str = "TABLE",
3464    ) -> str:
3465        """
3466        Build ALTER TABLE MODIFY COLUMN SQL for column comment modification.
3467
3468        StarRocks accepts the comment without re-stating the column type:
3469            ALTER TABLE {table} MODIFY COLUMN {column} COMMENT '{comment}'
3470
3471        Because COMMENT_CREATION_TABLE = IN_SCHEMA_DEF_NO_CTAS, column comments are inlined for a
3472        plain CREATE TABLE but NOT for CTAS (StarRocks rejects types/comments in a CTAS column
3473        list). This method is therefore the fallback used to register column comments after a CTAS,
3474        and to modify column comments on existing tables.
3475
3476        Args:
3477            table: Table expression
3478            column_name: Name of the column
3479            column_comment: The comment to add
3480            table_kind: Type of object (TABLE, VIEW, etc.)
3481
3482        Returns:
3483            SQL string for ALTER TABLE MODIFY COLUMN with COMMENT
3484        """
3485        table_sql = table.sql(dialect=self.dialect, identify=True)
3486        column_sql = exp.to_identifier(column_name).sql(dialect=self.dialect, identify=True)
3487
3488        comment_sql = exp.Literal.string(self._truncate_column_comment(column_comment)).sql(
3489            dialect=self.dialect
3490        )
3491
3492        return f"ALTER TABLE {table_sql} MODIFY COLUMN {column_sql} COMMENT {comment_sql}"
3493
3494    # ==================== Methods NOT Needing Override (Base Class Works) ====================
3495    # The following methods work correctly with base class implementation:
3496    # - columns(): Query column definitions via DESCRIBE TABLE
3497    # - table_exists(): Check if table exists via information_schema
3498    # - insert_append(): Standard INSERT INTO ... SELECT
3499    # - insert_overwrite_by_time_partition(): Uses DELETE_INSERT strategy (handled by base)
3500    # - fetchall() / fetchone(): Standard query execution
3501    # - execute(): Base SQL execution. (Modifyed for `FOR UPDATE` lock operation only)
3502    # - create_table_properties(): Delegate to _build_table_properties_exp()

StarRocks Engine Adapter for SQLMesh.

StarRocks is a high-performance analytical database with its own dialect-specific behavior. This adapter highlights a few key characteristics:

  1. PRIMARY KEY support is native and must be emitted in the post-schema section.
  2. DELETE with subqueries is supported on PRIMARY KEY tables, but other key types still need guard rails (no boolean literals, TRUNCATE for WHERE TRUE, etc.).
  3. Partitioning supports RANGE, LIST, and expression-based syntaxes.

Implementation strategy:

  • Override only where StarRocks syntax/behavior diverges from the base adapter.
  • Keep the rest of the functionality delegated to the shared base implementation.
DIALECT = 'starrocks'

SQLGlot dialect name for SQL generation

DEFAULT_BATCH_SIZE = 10000

Default batch size for bulk operations

SUPPORTS_TRANSACTIONS = False

StarRocks does not support transactions for multiple DML statements.

  • No BEGIN/COMMIT/ROLLBACK (only txn for multiple INSERT statements from v3.5)
  • Operations are auto-committed
  • Backfill uses partition-level atomicity
INSERT_OVERWRITE_STRATEGY = <InsertOverwriteStrategy.DELETE_INSERT: 1>

StarRocks does support INSERT OVERWRITE syntax (and dynamic overwrite from v3.5). Use DELETE + INSERT pattern:

  1. DELETE FROM table WHERE condition
  2. INSERT INTO table SELECT ...

Base class automatically handles this strategy without overriding insert methods.

TODO: later, we can add support for INSERT OVERWRITE, even use Primary Key for beter performance

COMMENT_CREATION_TABLE = <CommentCreationTable.IN_SCHEMA_DEF_NO_CTAS: 3>

Column comments are added inline in a plain CREATE TABLE, but StarRocks CTAS only accepts a bare column-name list (no types or per-column COMMENT) before AS SELECT. So for CTAS we emit CREATE TABLE t COMMENT '...' AS SELECT ... (table comment only) and register column comments afterward via ALTER TABLE ... MODIFY COLUMN ... COMMENT (see _build_create_comment_column_exp).

COMMENT_CREATION_VIEW = <CommentCreationView.IN_SCHEMA_DEF_NO_COMMANDS: 3>

View comments are added in CREATE VIEW statement

SUPPORTS_MATERIALIZED_VIEWS = True

StarRocks supports materialized views with refresh strategies

SUPPORTS_MATERIALIZED_VIEW_SCHEMA = True

StarRocks materialized views support specifying a column list, but the column definition is limited (e.g. column name + comment, not full type definitions). We set this to True and implement custom MV schema rendering in create_view/_create_materialized_view.

RECREATE_MATERIALIZED_VIEW_ON_EVALUATION = False

StarRocks async materialized views maintain themselves: they revalidate automatically even if the underlying data is dropped, and the data is kept current either by StarRocks' automatic refresh or by an explicit REFRESH MATERIALIZED VIEW (which also enables partition-level incremental refresh).

SUPPORTS_REPLACE_TABLE = False

No REPLACE TABLE syntax; use DROP + CREATE instead

SUPPORTS_CREATE_DROP_CATALOG = False

StarRocks supports DROPing external catalogs. TODO: whether it's external catalogs, or includes the internal catalog

SUPPORTS_INDEXES = True

StarRocks supports PRIMARY KEY in CREATE TABLE, but NOT standalone CREATE INDEX.

We set this to True to enable PRIMARY KEY generation in CREATE TABLE statements. The create_index() method is overridden to prevent actual CREATE INDEX execution.

Supported (defined in CREATE TABLE):

  • PRIMARY KEY: Automatically creates sorted index
  • INDEX clause: For bloom filter, bitmap, inverted indexes
NOT supported:

CREATE INDEX idx_name ON t (name); -- Will be skipped by create_index()

SUPPORTS_TUPLE_IN = False

StarRocks does NOT support tuple IN syntax: (col1, col2) IN ((val1, val2), (val3, val4))

Instead, use OR with AND conditions: (col1 = val1 AND col2 = val2) OR (col1 = val3 AND col2 = val4)

This is automatically handled by snapshot_id_filter and snapshot_name_version_filter in sqlmesh/core/state_sync/db/utils.py when SUPPORTS_TUPLE_IN = False.

MAX_TABLE_COMMENT_LENGTH = 2048

Maximum length for table comments

MAX_COLUMN_COMMENT_LENGTH = 255

Maximum length for column comments

MAX_IDENTIFIER_LENGTH = 64

Maximum length for table/column names

RESOLVE_TABLE_REFS_IN_PHYSICAL_PROPERTIES: FrozenSet[str] = frozenset({'excluded_refresh_tables', 'excluded_trigger_tables'})

StarRocks async materialized views accept these properties to exclude certain tables from triggering or participating in refreshes. When the value references a managed SQLMesh model, StarRocks needs the physical table name (db.table), not the logical view name. Managed-model physical names carry no catalog prefix (catalog support is UNSUPPORTED), so they are already in the warehouse-local db.table form StarRocks expects; unmanaged references (e.g. an external catalog's ext_catalog.db.table) pass through unchanged.

def create_index( self, table_name: Union[str, sqlglot.expressions.query.Table], index_name: str, columns: Tuple[str, ...], exists: bool = True) -> None:
1848    def create_index(
1849        self,
1850        table_name: TableName,
1851        index_name: str,
1852        columns: t.Tuple[str, ...],
1853        exists: bool = True,
1854    ) -> None:
1855        """
1856        Override to prevent CREATE INDEX statements (not supported in StarRocks).
1857
1858        StarRocks does not support standalone CREATE INDEX statements.
1859        Indexes must be defined during CREATE TABLE using INDEX clause.
1860
1861        Since SQLMesh state tables use PRIMARY KEY (which provides efficient indexing),
1862        we simply log and skip additional index creation requests.
1863
1864        This matches upstream StarRocks limitations and prevents accidental CREATE INDEX calls.
1865        """
1866        logger.warning(
1867            f"[StarRocks] Skipping CREATE INDEX {index_name} on {table_name} - "
1868            f"StarRocks does not support standalone CREATE INDEX statements. "
1869            f"PRIMARY KEY provides equivalent indexing for columns: {columns}"
1870        )
1871        return

Override to prevent CREATE INDEX statements (not supported in StarRocks).

StarRocks does not support standalone CREATE INDEX statements. Indexes must be defined during CREATE TABLE using INDEX clause.

Since SQLMesh state tables use PRIMARY KEY (which provides efficient indexing), we simply log and skip additional index creation requests.

This matches upstream StarRocks limitations and prevents accidental CREATE INDEX calls.

def delete_from( self, table_name: Union[str, sqlglot.expressions.query.Table], where: Union[str, sqlglot.expressions.core.Expr, NoneType] = None) -> None:
1901    def delete_from(
1902        self,
1903        table_name: TableName,
1904        where: t.Optional[t.Union[str, exp.Expr]] = None,
1905    ) -> None:
1906        """
1907        Delete from a table.
1908
1909        StarRocks DELETE limitations by table type:
1910
1911        PRIMARY KEY tables:
1912        - Support complex WHERE conditions (subqueries, BETWEEN, etc.)
1913        - No special handling needed
1914
1915        Other table types (DUPLICATE/UNIQUE/AGGREGATE KEY):
1916        - WHERE TRUE not supported → use TRUNCATE TABLE
1917        - Boolean literals (TRUE/FALSE) not supported
1918        - BETWEEN not supported → convert to >= AND <=
1919        - Others not supported:
1920            - CAST() not supported in WHERE
1921            - Subqueries not supported
1922            - ...
1923
1924        But, I don't know what the table type is.
1925
1926        Args:
1927            table_name: The table to delete from
1928            where: The where clause to filter rows to delete
1929        """
1930        # Parse where clause if it's a string
1931        where_expr: t.Optional[exp.Expr]
1932        if isinstance(where, str):
1933            from sqlglot import parse_one
1934
1935            where_expr = parse_one(where, dialect=self.dialect)
1936        else:
1937            where_expr = where
1938
1939        # If no where clause or WHERE TRUE, use TRUNCATE TABLE (for all table types)
1940        if not where_expr or where_expr == exp.true():
1941            table_expr = exp.to_table(table_name) if isinstance(table_name, str) else table_name
1942            logger.info(
1943                f"Converting DELETE FROM {table_name} WHERE TRUE to TRUNCATE TABLE "
1944                "(StarRocks does not support WHERE TRUE in DELETE)"
1945            )
1946            self.execute(f"TRUNCATE TABLE {table_expr.sql(dialect=self.dialect, identify=True)}")
1947            return
1948
1949        # For non-PRIMARY KEY tables, apply WHERE clause restrictions
1950        # Note: We conservatively apply restrictions to all tables since we can't easily
1951        # determine table type at DELETE time. PRIMARY KEY tables will still work with
1952        # simplified conditions, while non-PRIMARY KEY tables require them.
1953        if isinstance(where_expr, exp.Expr):
1954            original_where = where_expr
1955            # Remove boolean literals (not supported in any table type)
1956            where_expr = self._where_clause_remove_boolean_literals(where_expr)
1957            # Convert BETWEEN to >= AND <= (required for DUPLICATE/UNIQUE/AGGREGATE KEY tables)
1958            where_expr = self._where_clause_convert_between_to_comparison(where_expr)
1959
1960            if where_expr != original_where:
1961                logger.debug(
1962                    f"Converted WHERE clause for StarRocks compatibility, table: {table_name}.\n"
1963                    f"  Original: {original_where.sql(dialect=self.dialect)}\n"
1964                    f"  Converted: {where_expr.sql(dialect=self.dialect)}"
1965                )
1966
1967        # Use parent implementation
1968        super().delete_from(table_name, where_expr)

Delete from a table.

StarRocks DELETE limitations by table type:

PRIMARY KEY tables:

  • Support complex WHERE conditions (subqueries, BETWEEN, etc.)
  • No special handling needed

Other table types (DUPLICATE/UNIQUE/AGGREGATE KEY):

  • WHERE TRUE not supported → use TRUNCATE TABLE
  • Boolean literals (TRUE/FALSE) not supported
  • BETWEEN not supported → convert to >= AND <=
  • Others not supported:
    • CAST() not supported in WHERE
    • Subqueries not supported
    • ...

But, I don't know what the table type is.

Arguments:
  • table_name: The table to delete from
  • where: The where clause to filter rows to delete
def execute( self, expressions: Union[str, sqlglot.expressions.core.Expr, Sequence[sqlglot.expressions.core.Expr]], ignore_unsupported_errors: bool = False, quote_identifiers: bool = True, track_rows_processed: bool = False, **kwargs: Any) -> None:
2064    def execute(
2065        self,
2066        expressions: t.Union[str, exp.Expr, t.Sequence[exp.Expr]],
2067        ignore_unsupported_errors: bool = False,
2068        quote_identifiers: bool = True,
2069        track_rows_processed: bool = False,
2070        **kwargs: t.Any,
2071    ) -> None:
2072        """
2073        Override execute to strip FOR UPDATE from queries (not supported in StarRocks).
2074
2075        StarRocks is an OLAP database and does not support row-level locking via
2076        SELECT ... FOR UPDATE. This method removes lock expressions before execution.
2077
2078        Args:
2079            expressions: SQL expression(s) to execute
2080            ignore_unsupported_errors: Whether to ignore unsupported errors
2081            quote_identifiers: Whether to quote identifiers
2082            track_rows_processed: Whether to track rows processed
2083            **kwargs: Additional arguments
2084        """
2085        from sqlglot.helper import ensure_list
2086
2087        if isinstance(expressions, str):
2088            super().execute(
2089                expressions,
2090                ignore_unsupported_errors=ignore_unsupported_errors,
2091                quote_identifiers=quote_identifiers,
2092                track_rows_processed=track_rows_processed,
2093                **kwargs,
2094            )
2095            return
2096
2097        # Process expressions to remove FOR UPDATE
2098        processed_expressions: t.List[exp.Expr] = []
2099        for e in ensure_list(expressions):
2100            if not isinstance(e, exp.Expr):
2101                super().execute(
2102                    expressions,
2103                    ignore_unsupported_errors=ignore_unsupported_errors,
2104                    quote_identifiers=quote_identifiers,
2105                    track_rows_processed=track_rows_processed,
2106                    **kwargs,
2107                )
2108                return
2109
2110            # Remove lock (FOR UPDATE) from SELECT statements
2111            if isinstance(e, exp.Select) and e.args.get("locks"):
2112                e = e.copy()
2113                e.set("locks", None)
2114                logger.warning(
2115                    f"[StarRocks] Removed FOR UPDATE from SELECT statement: "
2116                    f"{e.sql(dialect=self.dialect, identify=quote_identifiers)}"
2117                )
2118            processed_expressions.append(e)
2119
2120        # Call parent execute with processed expressions
2121        super().execute(
2122            processed_expressions,
2123            ignore_unsupported_errors=ignore_unsupported_errors,
2124            quote_identifiers=quote_identifiers,
2125            track_rows_processed=track_rows_processed,
2126            **kwargs,
2127        )

Override execute to strip FOR UPDATE from queries (not supported in StarRocks).

StarRocks is an OLAP database and does not support row-level locking via SELECT ... FOR UPDATE. This method removes lock expressions before execution.

Arguments:
  • expressions: SQL expression(s) to execute
  • ignore_unsupported_errors: Whether to ignore unsupported errors
  • quote_identifiers: Whether to quote identifiers
  • track_rows_processed: Whether to track rows processed
  • **kwargs: Additional arguments
def adjust_physical_properties_for_incremental( self, physical_properties: Dict[str, Any], *, requires_delete_capable_table: bool, unique_key: Optional[List[sqlglot.expressions.core.Expr]], model_name: str) -> Dict[str, Any]:
2129    def adjust_physical_properties_for_incremental(
2130        self,
2131        physical_properties: t.Dict[str, t.Any],
2132        *,
2133        requires_delete_capable_table: bool,
2134        unique_key: t.Optional[t.List[exp.Expr]],
2135        model_name: str,
2136    ) -> t.Dict[str, t.Any]:
2137        """Enforce that StarRocks incremental models use a PRIMARY KEY table.
2138
2139        Incremental kinds rely on DELETE/MERGE statements that StarRocks only supports on PRIMARY
2140        KEY tables; DUPLICATE/UNIQUE/AGGREGATE KEY tables reject the predicates SQLMesh generates
2141        (e.g. a time-range DELETE with a CAST bound, or any non-key-column predicate). When a
2142        unique_key is available (INCREMENTAL_BY_UNIQUE_KEY) we promote it to a PRIMARY KEY;
2143        otherwise a PRIMARY KEY must be specified explicitly via physical_properties, and we raise
2144        so the failure is clear at creation time rather than producing a broken table.
2145
2146        The caller owns ``physical_properties`` (it is already a defensive copy), so we mutate and
2147        return it in place.
2148        """
2149        if not requires_delete_capable_table or "primary_key" in physical_properties:
2150            return physical_properties
2151
2152        # Promote the model's unique_key to a PRIMARY KEY table so that complex DELETE/MERGE
2153        # statements remain supported.
2154        if unique_key:
2155            physical_properties["primary_key"] = (
2156                unique_key[0] if len(unique_key) == 1 else exp.Tuple(expressions=unique_key)
2157            )
2158            logger.info(
2159                "Model '%s' promoted to PRIMARY KEY table on StarRocks to support rich DELETE operations.",
2160                model_name,
2161            )
2162            return physical_properties
2163
2164        raise SQLMeshError(
2165            f"StarRocks incremental model '{model_name}' requires a PRIMARY KEY table. "
2166            "Incremental kinds use DELETE/MERGE operations that StarRocks only supports on PRIMARY KEY "
2167            "tables; DUPLICATE/UNIQUE/AGGREGATE KEY tables are not sufficient. "
2168            "Specify `physical_properties (primary_key = (...))`, or set `unique_key` on the model."
2169        )

Enforce that StarRocks incremental models use a PRIMARY KEY table.

Incremental kinds rely on DELETE/MERGE statements that StarRocks only supports on PRIMARY KEY tables; DUPLICATE/UNIQUE/AGGREGATE KEY tables reject the predicates SQLMesh generates (e.g. a time-range DELETE with a CAST bound, or any non-key-column predicate). When a unique_key is available (INCREMENTAL_BY_UNIQUE_KEY) we promote it to a PRIMARY KEY; otherwise a PRIMARY KEY must be specified explicitly via physical_properties, and we raise so the failure is clear at creation time rather than producing a broken table.

The caller owns physical_properties (it is already a defensive copy), so we mutate and return it in place.

def create_view( self, view_name: Union[str, sqlglot.expressions.query.Table], query_or_df: <MagicMock id='130969790289648'>, target_columns_to_types: Optional[Dict[str, sqlglot.expressions.datatypes.DataType]] = None, replace: bool = True, materialized: bool = False, materialized_properties: Optional[Dict[str, Any]] = None, table_description: Optional[str] = None, column_descriptions: Optional[Dict[str, str]] = None, view_properties: Optional[Dict[str, sqlglot.expressions.core.Expr]] = None, source_columns: Optional[List[str]] = None, **create_kwargs: Any) -> None:
2289    def create_view(
2290        self,
2291        view_name: TableName,
2292        query_or_df: QueryOrDF,
2293        target_columns_to_types: t.Optional[t.Dict[str, exp.DataType]] = None,
2294        replace: bool = True,
2295        materialized: bool = False,
2296        materialized_properties: t.Optional[t.Dict[str, t.Any]] = None,
2297        table_description: t.Optional[str] = None,
2298        column_descriptions: t.Optional[t.Dict[str, str]] = None,
2299        view_properties: t.Optional[t.Dict[str, exp.Expr]] = None,
2300        source_columns: t.Optional[t.List[str]] = None,
2301        **create_kwargs: t.Any,
2302    ) -> None:
2303        """
2304        StarRocks behavior:
2305        - Regular VIEW: supports CREATE OR REPLACE (base behavior)
2306        - MATERIALIZED VIEW: does NOT support CREATE OR REPLACE, so replace=True => DROP + CREATE
2307        """
2308        if not materialized:
2309            return super().create_view(
2310                view_name=view_name,
2311                query_or_df=query_or_df,
2312                target_columns_to_types=target_columns_to_types,
2313                replace=replace,
2314                materialized=False,
2315                materialized_properties=materialized_properties,
2316                table_description=table_description,
2317                column_descriptions=column_descriptions,
2318                view_properties=view_properties,
2319                source_columns=source_columns,
2320                **create_kwargs,
2321            )
2322
2323        # MATERIALIZED VIEW path
2324        # MVs with audits get a synchronous refresh after creation (see _create_materialized_view),
2325        # which requires REFRESH DEFERRED. Validate before the drop so we fail without destroying
2326        # an existing MV.
2327        has_audits = bool((materialized_properties or {}).get("has_audits"))
2328        if has_audits:
2329            self._validate_deferred_refresh_for_audits(view_name, view_properties)
2330
2331        if replace:
2332            # Avoid DROP MATERIALIZED VIEW failure when an object with the same name exists but is not an MV.
2333            self.drop_data_object_on_type_mismatch(
2334                self.get_data_object(view_name), DataObjectType.MATERIALIZED_VIEW
2335            )
2336            self.drop_view(view_name, ignore_if_not_exists=True, materialized=True)
2337        # logger.debug(
2338        #     f"Creating materialized view: {view_name}, materialized: {materialized}, "
2339        #     f"materialized_properties: {materialized_properties}, "
2340        #     f"view_properties: {view_properties}, create_kwargs: {create_kwargs}, "
2341        # )
2342
2343        return self._create_materialized_view(
2344            view_name=view_name,
2345            query_or_df=query_or_df,
2346            target_columns_to_types=target_columns_to_types,
2347            materialized_properties=materialized_properties,
2348            table_description=table_description,
2349            column_descriptions=column_descriptions,
2350            view_properties=view_properties,
2351            source_columns=source_columns,
2352            **create_kwargs,
2353        )

StarRocks behavior:

  • Regular VIEW: supports CREATE OR REPLACE (base behavior)
  • MATERIALIZED VIEW: does NOT support CREATE OR REPLACE, so replace=True => DROP + CREATE
def merge( self, target_table: Union[str, sqlglot.expressions.query.Table], source_table: <MagicMock id='130969800269328'>, target_columns_to_types: Optional[Dict[str, sqlglot.expressions.datatypes.DataType]], unique_key: Sequence[sqlglot.expressions.core.Expr], when_matched: Optional[sqlglot.expressions.dml.Whens] = None, merge_filter: Optional[sqlglot.expressions.core.Expr] = None, source_columns: Optional[List[str]] = None, **kwargs: Any) -> None:
37    def merge(
38        self,
39        target_table: TableName,
40        source_table: QueryOrDF,
41        target_columns_to_types: t.Optional[t.Dict[str, exp.DataType]],
42        unique_key: t.Sequence[exp.Expr],
43        when_matched: t.Optional[exp.Whens] = None,
44        merge_filter: t.Optional[exp.Expr] = None,
45        source_columns: t.Optional[t.List[str]] = None,
46        **kwargs: t.Any,
47    ) -> None:
48        logical_merge(
49            self,
50            target_table,
51            source_table,
52            target_columns_to_types,
53            unique_key,
54            when_matched=when_matched,
55            merge_filter=merge_filter,
56            source_columns=source_columns,
57        )
def get_alter_operations( self, current_table_name: Union[str, sqlglot.expressions.query.Table], target_table_name: Union[str, sqlglot.expressions.query.Table], *, ignore_destructive: bool = False, ignore_additive: bool = False) -> List[sqlmesh.core.schema_diff.TableAlterOperation]:
358    def get_alter_operations(
359        self,
360        current_table_name: TableName,
361        target_table_name: TableName,
362        *,
363        ignore_destructive: bool = False,
364        ignore_additive: bool = False,
365    ) -> t.List[TableAlterOperation]:
366        operations = super().get_alter_operations(
367            current_table_name,
368            target_table_name,
369            ignore_destructive=ignore_destructive,
370            ignore_additive=ignore_additive,
371        )
372
373        # check for a change in clustering
374        current_table = exp.to_table(current_table_name)
375        target_table = exp.to_table(target_table_name)
376
377        current_table_schema = schema_(current_table.db, catalog=current_table.catalog)
378        target_table_schema = schema_(target_table.db, catalog=target_table.catalog)
379
380        current_table_info = seq_get(
381            self.get_data_objects(current_table_schema, {current_table.name}), 0
382        )
383        target_table_info = seq_get(
384            self.get_data_objects(target_table_schema, {target_table.name}), 0
385        )
386
387        if current_table_info and target_table_info:
388            if target_table_info.is_clustered:
389                if target_table_info.clustering_key and (
390                    current_table_info.clustering_key != target_table_info.clustering_key
391                ):
392                    operations.append(
393                        TableAlterChangeClusterKeyOperation(
394                            target_table=current_table,
395                            clustering_key=target_table_info.clustering_key,
396                            dialect=self.dialect,
397                        )
398                    )
399            elif current_table_info.is_clustered:
400                operations.append(TableAlterDropClusterKeyOperation(target_table=current_table))
401
402        return operations

Determines the alter statements needed to change the current table into the structure of the target table.