Edit on GitHub

sqlglot.generators.duckdb

   1from __future__ import annotations
   2
   3from decimal import Decimal
   4from itertools import groupby
   5import re
   6import typing as t
   7
   8from sqlglot import exp, generator, transforms
   9from sqlglot.dialects.dialect import (
  10    DATETIME_DELTA,
  11    JSON_EXTRACT_TYPE,
  12    approx_count_distinct_sql,
  13    arrow_json_extract_sql,
  14    array_append_sql,
  15    array_compact_sql,
  16    array_concat_sql,
  17    count_if_to_sum,
  18    date_delta_to_binary_interval_op,
  19    datestrtodate_sql,
  20    encode_decode_sql,
  21    explode_to_unnest_sql,
  22    generate_series_sql,
  23    getbit_sql,
  24    groupconcat_sql,
  25    inline_array_unless_query,
  26    months_between_sql,
  27    no_datetime_sql,
  28    no_comment_column_constraint_sql,
  29    no_make_interval_sql,
  30    no_time_sql,
  31    no_timestamp_sql,
  32    rename_func,
  33    remove_from_array_using_filter,
  34    strposition_sql,
  35    timestrtotime_sql,
  36    unit_to_str,
  37    week_unit_to_dow,
  38    weekstart_unit_to_str,
  39    WEEK_START_DAY_TO_DOW,
  40)
  41from sqlglot.generator import unsupported_args
  42from sqlglot.helper import find_new_name, is_date_unit, seq_get
  43from sqlglot.optimizer.scope import find_all_in_scope
  44from builtins import type as Type
  45
  46_CONNECT_BY_ARGS_TO_SKIP = frozenset({"connect", "where", "from_", "with_", "expressions"})
  47
  48# Regex to detect time zones in timestamps of the form [+|-]TT[:tt]
  49# The pattern matches timezone offsets that appear after the time portion
  50TIMEZONE_PATTERN = re.compile(r":\d{2}.*?[+\-]\d{2}(?::\d{2})?")
  51
  52# Characters that must be escaped when building regex expressions in INITCAP
  53REGEX_ESCAPE_REPLACEMENTS = {
  54    "\\": "\\\\",
  55    "-": r"\-",
  56    "^": r"\^",
  57    "[": r"\[",
  58    "]": r"\]",
  59}
  60
  61# Used to in RANDSTR transpilation
  62RANDSTR_CHAR_POOL = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
  63RANDSTR_SEED = 123456
  64
  65# Whitespace control characters that DuckDB must process with `CHR({val})` calls
  66WS_CONTROL_CHARS_TO_DUCK = {
  67    "\u000b": 11,
  68    "\u001c": 28,
  69    "\u001d": 29,
  70    "\u001e": 30,
  71    "\u001f": 31,
  72}
  73
  74MAX_BIT_POSITION = exp.Literal.number(32768)
  75
  76# cs/as/ps are Snowflake defaults; DuckDB already behaves the same way, so they are safe to drop.
  77# Note: "as" is also a reserved keyword in DuckDB, making it impossible to pass through.
  78_SNOWFLAKE_COLLATION_DEFAULTS = frozenset({"cs", "as", "ps"})
  79_SNOWFLAKE_COLLATION_UNSUPPORTED = frozenset(
  80    {"ci", "ai", "upper", "lower", "utf8", "bin", "pi", "fl", "fu", "trim", "ltrim", "rtrim"}
  81)
  82
  83# Window functions that support IGNORE/RESPECT NULLS in DuckDB
  84_IGNORE_RESPECT_NULLS_WINDOW_FUNCTIONS = (
  85    exp.FirstValue,
  86    exp.Lag,
  87    exp.LastValue,
  88    exp.Lead,
  89    exp.NthValue,
  90)
  91
  92# SEQ function constants
  93_SEQ_BASE: exp.Expr = exp.maybe_parse("(ROW_NUMBER() OVER (ORDER BY 1) - 1)")
  94_SEQ_RESTRICTED = (exp.Where, exp.Having, exp.AggFunc, exp.Order, exp.Select)
  95# Maps SEQ expression types to their byte width (suffix indicates bytes: SEQ1=1, SEQ2=2, etc.)
  96_SEQ_BYTE_WIDTH = {exp.Seq1: 1, exp.Seq2: 2, exp.Seq4: 4, exp.Seq8: 8}
  97
  98# Template for generating signed and unsigned SEQ values within a specified range
  99_SEQ_UNSIGNED: exp.Expr = exp.maybe_parse(":base % :max_val")
 100_SEQ_SIGNED: exp.Expr = exp.maybe_parse(
 101    "(CASE WHEN :base % :max_val >= :half "
 102    "THEN :base % :max_val - :max_val "
 103    "ELSE :base % :max_val END)"
 104)
 105
 106
 107def _apply_base64_alphabet_replacements(
 108    result: exp.Expr,
 109    alphabet: exp.Expr | None,
 110    reverse: bool = False,
 111) -> exp.Expr:
 112    """
 113    Apply base64 alphabet character replacements.
 114
 115    Base64 alphabet can be 1-3 chars: 1st = index 62 ('+'), 2nd = index 63 ('/'), 3rd = padding ('=').
 116    zip truncates to the shorter string, so 1-char alphabet only replaces '+', 2-char replaces '+/', etc.
 117
 118    Args:
 119        result: The expression to apply replacements to
 120        alphabet: Custom alphabet literal (expected chars for +/=)
 121        reverse: If False, replace default with custom (encode)
 122                 If True, replace custom with default (decode)
 123    """
 124    if isinstance(alphabet, exp.Literal) and alphabet.is_string:
 125        for default_char, new_char in zip("+/=", alphabet.this):
 126            if new_char != default_char:
 127                find, replace = (new_char, default_char) if reverse else (default_char, new_char)
 128                result = exp.Replace(
 129                    this=result,
 130                    expression=exp.Literal.string(find),
 131                    replacement=exp.Literal.string(replace),
 132                )
 133    return result
 134
 135
 136def _base64_decode_sql(self: DuckDBGenerator, expression: exp.Expr, to_string: bool) -> str:
 137    """
 138    Transpile Snowflake BASE64_DECODE_STRING/BINARY to DuckDB.
 139
 140    DuckDB uses FROM_BASE64() which returns BLOB. For string output, wrap with DECODE().
 141    Custom alphabets require REPLACE() calls to convert to standard base64.
 142    """
 143    input_expr = expression.this
 144    alphabet = expression.args.get("alphabet")
 145
 146    # Handle custom alphabet by replacing non-standard chars with standard ones
 147    input_expr = _apply_base64_alphabet_replacements(input_expr, alphabet, reverse=True)
 148
 149    # FROM_BASE64 returns BLOB
 150    input_expr = exp.FromBase64(this=input_expr)
 151
 152    if to_string:
 153        input_expr = exp.Decode(this=input_expr)
 154
 155    return self.sql(input_expr)
 156
 157
 158def _last_day_sql(self: DuckDBGenerator, expression: exp.LastDay) -> str:
 159    """
 160    DuckDB's LAST_DAY only supports finding the last day of a month.
 161    For other date parts (year, quarter, week), we need to implement equivalent logic.
 162    """
 163    date_expr = expression.this
 164    unit_expr = expression.args.get("unit")
 165
 166    week_start = week_unit_to_dow(unit_expr)
 167    if week_start:
 168        # The week's last day precedes its start day; DuckDB DAYOFWEEK: Sunday=0, ..., Saturday=6
 169        last_dow = week_start - 1
 170        dow = exp.func("EXTRACT", "DAYOFWEEK", date_expr)
 171
 172        # Days to the last day of week: (last_dow + 7 - dayofweek) % 7
 173        days_to_last_expr = exp.Mod(
 174            this=exp.Paren(this=exp.Sub(this=exp.Literal.number(last_dow + 7), expression=dow)),
 175            expression=exp.Literal.number(7),
 176        )
 177        interval_expr = exp.Interval(this=days_to_last_expr, unit=exp.var("DAY"))
 178        add_expr = exp.Add(this=date_expr, expression=interval_expr)
 179
 180        return self.sql(exp.cast(add_expr, exp.DType.DATE))
 181
 182    unit = expression.text("unit")
 183
 184    if not unit or unit.upper() == "MONTH":
 185        # Default behavior - use DuckDB's native LAST_DAY
 186        return self.func("LAST_DAY", date_expr)
 187
 188    if unit.upper() == "YEAR":
 189        # Last day of year: December 31st of the same year
 190        year_expr = exp.func("EXTRACT", "YEAR", date_expr)
 191        make_date_expr = exp.func(
 192            "MAKE_DATE", year_expr, exp.Literal.number(12), exp.Literal.number(31)
 193        )
 194        return self.sql(make_date_expr)
 195
 196    if unit.upper() == "QUARTER":
 197        # Last day of quarter
 198        year_expr = exp.func("EXTRACT", "YEAR", date_expr)
 199        quarter_expr = exp.func("EXTRACT", "QUARTER", date_expr)
 200
 201        # Calculate last month of quarter: quarter * 3. Quarter can be 1 to 4
 202        last_month_expr = exp.Mul(this=quarter_expr, expression=exp.Literal.number(3))
 203        first_day_last_month_expr = exp.func(
 204            "MAKE_DATE", year_expr, last_month_expr, exp.Literal.number(1)
 205        )
 206
 207        # Last day of the last month of the quarter
 208        last_day_expr = exp.func("LAST_DAY", first_day_last_month_expr)
 209        return self.sql(last_day_expr)
 210
 211    self.unsupported(f"Unsupported date part '{unit}' in LAST_DAY function")
 212    return self.function_fallback_sql(expression)
 213
 214
 215def _is_nanosecond_unit(unit: exp.Expr | None) -> bool:
 216    return isinstance(unit, (exp.Var, exp.Literal)) and unit.name.upper() == "NANOSECOND"
 217
 218
 219def _handle_nanosecond_diff(
 220    self: DuckDBGenerator,
 221    end_time: exp.Expr,
 222    start_time: exp.Expr,
 223) -> str:
 224    """Generate NANOSECOND diff using EPOCH_NS since DATE_DIFF doesn't support it."""
 225    end_ns = exp.cast(end_time, exp.DType.TIMESTAMP_NS)
 226    start_ns = exp.cast(start_time, exp.DType.TIMESTAMP_NS)
 227
 228    # Build expression tree: EPOCH_NS(end) - EPOCH_NS(start)
 229    return self.sql(
 230        exp.Sub(this=exp.func("EPOCH_NS", end_ns), expression=exp.func("EPOCH_NS", start_ns))
 231    )
 232
 233
 234def _to_boolean_sql(self: DuckDBGenerator, expression: exp.ToBoolean) -> str:
 235    """
 236    Transpile TO_BOOLEAN and TRY_TO_BOOLEAN functions from Snowflake to DuckDB equivalent.
 237
 238    DuckDB's CAST to BOOLEAN supports most of Snowflake's TO_BOOLEAN strings except 'on'/'off'.
 239    We need to handle the 'on'/'off' cases explicitly.
 240
 241    For TO_BOOLEAN (safe=False): NaN and INF values cause errors. We use DuckDB's native ERROR()
 242    function to replicate this behavior with a clear error message.
 243
 244    For TRY_TO_BOOLEAN (safe=True): Use DuckDB's TRY_CAST for conversion, which returns NULL
 245    for invalid inputs instead of throwing errors.
 246    """
 247    arg = expression.this
 248    is_safe = expression.args.get("safe", False)
 249
 250    base_case_expr = (
 251        exp.case()
 252        .when(
 253            # Handle 'on' -> TRUE (case insensitive)
 254            exp.Upper(this=exp.cast(arg, exp.DType.VARCHAR)).eq(exp.Literal.string("ON")),
 255            exp.true(),
 256        )
 257        .when(
 258            # Handle 'off' -> FALSE (case insensitive)
 259            exp.Upper(this=exp.cast(arg, exp.DType.VARCHAR)).eq(exp.Literal.string("OFF")),
 260            exp.false(),
 261        )
 262    )
 263
 264    if is_safe:
 265        # TRY_TO_BOOLEAN: handle 'on'/'off' and use TRY_CAST for everything else
 266        case_expr = base_case_expr.else_(exp.func("TRY_CAST", arg, exp.DType.BOOLEAN.into_expr()))
 267    else:
 268        # TO_BOOLEAN: handle NaN/INF errors, 'on'/'off', and use regular CAST
 269        cast_to_real = exp.func("TRY_CAST", arg, exp.DType.FLOAT.into_expr())
 270
 271        # Check for NaN and INF values
 272        nan_inf_check = exp.Or(
 273            this=exp.func("ISNAN", cast_to_real), expression=exp.func("ISINF", cast_to_real)
 274        )
 275
 276        case_expr = base_case_expr.when(
 277            nan_inf_check,
 278            exp.func(
 279                "ERROR",
 280                exp.Literal.string("TO_BOOLEAN: Non-numeric values NaN and INF are not supported"),
 281            ),
 282        ).else_(exp.cast(arg, exp.DType.BOOLEAN))
 283
 284    return self.sql(case_expr)
 285
 286
 287# BigQuery -> DuckDB conversion for the DATE function
 288def _date_sql(self: DuckDBGenerator, expression: exp.Date) -> str:
 289    this = expression.this
 290    zone = self.sql(expression, "zone")
 291
 292    if zone:
 293        # BigQuery considers "this" at UTC, converts it to the specified
 294        # time zone and then keeps only the DATE part
 295        # To micmic that, we:
 296        #   (1) Cast to TIMESTAMP to remove DuckDB's local tz
 297        #   (2) Apply consecutive AtTimeZone calls for UTC -> zone conversion
 298        this = exp.cast(this, exp.DType.TIMESTAMP)
 299        at_utc = exp.AtTimeZone(this=this, zone=exp.Literal.string("UTC"))
 300        this = exp.AtTimeZone(this=at_utc, zone=zone)
 301
 302    return self.sql(exp.cast(expression=this, to=exp.DType.DATE))
 303
 304
 305# BigQuery -> DuckDB conversion for the TIME_DIFF function
 306def _timediff_sql(self: DuckDBGenerator, expression: exp.TimeDiff) -> str:
 307    unit = expression.unit
 308
 309    if _is_nanosecond_unit(unit):
 310        return _handle_nanosecond_diff(self, expression.expression, expression.this)
 311
 312    this = exp.cast(expression.this, exp.DType.TIME)
 313    expr = exp.cast(expression.expression, exp.DType.TIME)
 314
 315    # Although the 2 dialects share similar signatures, BQ seems to inverse
 316    # the sign of the result so the start/end time operands are flipped
 317    return self.func("DATE_DIFF", unit_to_str(expression), expr, this)
 318
 319
 320def _date_delta_to_binary_interval_op(
 321    cast: bool = True,
 322) -> t.Callable[[DuckDBGenerator, DATETIME_DELTA], str]:
 323    """
 324    DuckDB override to handle:
 325    1. NANOSECOND operations (DuckDB doesn't support INTERVAL ... NANOSECOND)
 326    2. Float/decimal interval values (DuckDB INTERVAL requires integers)
 327    """
 328    base_impl = date_delta_to_binary_interval_op(cast=cast)
 329
 330    def _duckdb_date_delta_sql(self: DuckDBGenerator, expression: DATETIME_DELTA) -> str:
 331        unit = expression.unit
 332        interval_value = expression.expression
 333
 334        # Handle NANOSECOND unit (DuckDB doesn't support INTERVAL ... NANOSECOND)
 335        if _is_nanosecond_unit(unit):
 336            if isinstance(interval_value, exp.Interval):
 337                interval_value = interval_value.this
 338
 339            timestamp_ns = exp.cast(expression.this, exp.DType.TIMESTAMP_NS)
 340
 341            return self.sql(
 342                exp.func(
 343                    "MAKE_TIMESTAMP_NS",
 344                    exp.Add(this=exp.func("EPOCH_NS", timestamp_ns), expression=interval_value),
 345                )
 346            )
 347
 348        # Handle float/decimal interval values as duckDB INTERVAL requires integer expressions
 349        if not interval_value or isinstance(interval_value, exp.Interval):
 350            return base_impl(self, expression)
 351
 352        if interval_value.is_type(*exp.DataType.REAL_TYPES):
 353            expression.set("expression", exp.cast(exp.func("ROUND", interval_value), "INT"))
 354
 355        return base_impl(self, expression)
 356
 357    return _duckdb_date_delta_sql
 358
 359
 360def _array_insert_sql(self: DuckDBGenerator, expression: exp.ArrayInsert) -> str:
 361    """
 362    Transpile ARRAY_INSERT to DuckDB using LIST_CONCAT and slicing.
 363
 364    Handles:
 365    - 0-based and 1-based indexing (normalizes to 0-based for calculations)
 366    - Negative position conversion (requires array length)
 367    - NULL propagation (source dialects return NULL, DuckDB creates single-element array)
 368    - Assumes position is within bounds per user constraint
 369
 370    Note: All dialects that support ARRAY_INSERT (Snowflake, Spark, Databricks) have
 371    ARRAY_FUNCS_PROPAGATES_NULLS=True, so we always assume source propagates NULLs.
 372
 373    Args:
 374        expression: The ArrayInsert expression to transpile.
 375
 376    Returns:
 377        SQL string implementing ARRAY_INSERT behavior.
 378    """
 379    this = expression.this
 380    position = expression.args.get("position")
 381    element = expression.expression
 382    element_array = exp.Array(expressions=[element])
 383    index_offset = expression.args.get("offset", 0)
 384
 385    if not position or not position.is_int:
 386        self.unsupported("ARRAY_INSERT can only be transpiled with a literal position")
 387        return self.func("ARRAY_INSERT", this, position, element)
 388
 389    pos_value = position.to_py()
 390
 391    # Normalize one-based indexing to zero-based for slice calculations
 392    # Spark (1-based) -> Snowflake (0-based):
 393    #   Positive: pos=1 -> pos=0 (subtract 1)
 394    #   Negative: pos=-2 -> pos=-1 (add 1)
 395    # Example: Spark array_insert([a,b,c], -2, d) -> [a,b,d,c] is same as Snowflake pos=-1
 396    if pos_value > 0:
 397        pos_value = pos_value - index_offset
 398    elif pos_value < 0:
 399        pos_value = pos_value + index_offset
 400
 401    # Build the appropriate list_concat expression based on position
 402    if pos_value == 0:
 403        # insert at beginning
 404        concat_exprs = [element_array, this]
 405    elif pos_value > 0:
 406        # Positive position: LIST_CONCAT(arr[1:pos], [elem], arr[pos+1:])
 407        # 0-based -> DuckDB 1-based slicing
 408
 409        # left slice: arr[1:pos]
 410        slice_start = exp.Bracket(
 411            this=this,
 412            expressions=[
 413                exp.Slice(this=exp.Literal.number(1), expression=exp.Literal.number(pos_value))
 414            ],
 415        )
 416
 417        # right slice: arr[pos+1:]
 418        slice_end = exp.Bracket(
 419            this=this, expressions=[exp.Slice(this=exp.Literal.number(pos_value + 1))]
 420        )
 421
 422        concat_exprs = [slice_start, element_array, slice_end]
 423    else:
 424        # Negative position: arr[1:LEN(arr)+pos], [elem], arr[LEN(arr)+pos+1:]
 425        # pos=-1 means insert before last element
 426        arr_len = exp.Length(this=this)
 427
 428        # Calculate slice position: LEN(arr) + pos (e.g., LEN(arr) + (-1) = LEN(arr) - 1)
 429        slice_end_pos = arr_len + exp.Literal.number(pos_value)
 430        slice_start_pos = slice_end_pos + exp.Literal.number(1)
 431
 432        # left slice: arr[1:LEN(arr)+pos]
 433        slice_start = exp.Bracket(
 434            this=this,
 435            expressions=[exp.Slice(this=exp.Literal.number(1), expression=slice_end_pos)],
 436        )
 437
 438        # right slice: arr[LEN(arr)+pos+1:]
 439        slice_end = exp.Bracket(this=this, expressions=[exp.Slice(this=slice_start_pos)])
 440
 441        concat_exprs = [slice_start, element_array, slice_end]
 442
 443    # All dialects that support ARRAY_INSERT propagate NULLs (Snowflake/Spark/Databricks)
 444    # Wrap in CASE WHEN array IS NULL THEN NULL ELSE func_expr END
 445    return self.sql(
 446        exp.If(
 447            this=exp.Is(this=this, expression=exp.Null()),
 448            true=exp.Null(),
 449            false=self.func("LIST_CONCAT", *concat_exprs),
 450        )
 451    )
 452
 453
 454def _array_remove_at_sql(self: DuckDBGenerator, expression: exp.ArrayRemoveAt) -> str:
 455    """
 456    Transpile ARRAY_REMOVE_AT to DuckDB using LIST_CONCAT and slicing.
 457
 458    Handles:
 459    - Positive positions (0-based indexing)
 460    - Negative positions (from end of array)
 461    - NULL propagation (Snowflake returns NULL for NULL array, DuckDB doesn't auto-propagate)
 462    - Only supports literal integer positions (non-literals remain untranspiled)
 463
 464    Transpilation patterns:
 465    - pos=0 (first): arr[2:]
 466    - pos>0 (middle): LIST_CONCAT(arr[1:p], arr[p+2:])
 467    - pos=-1 (last): arr[1:LEN(arr)-1]
 468    - pos<-1: LIST_CONCAT(arr[1:LEN(arr)+p], arr[LEN(arr)+p+2:])
 469
 470    All wrapped in: CASE WHEN arr IS NULL THEN NULL ELSE ... END
 471
 472    Args:
 473        expression: The ArrayRemoveAt expression to transpile.
 474
 475    Returns:
 476        SQL string implementing ARRAY_REMOVE_AT behavior.
 477    """
 478    this = expression.this
 479    position = expression.args.get("position")
 480
 481    if not position or not position.is_int:
 482        self.unsupported("ARRAY_REMOVE_AT can only be transpiled with a literal position")
 483        return self.func("ARRAY_REMOVE_AT", this, position)
 484
 485    pos_value = position.to_py()
 486
 487    # Build the appropriate expression based on position
 488    if pos_value == 0:
 489        # Remove first element: arr[2:]
 490        result_expr: exp.Expr | str = exp.Bracket(
 491            this=this,
 492            expressions=[exp.Slice(this=exp.Literal.number(2))],
 493        )
 494    elif pos_value > 0:
 495        # Remove at positive position: LIST_CONCAT(arr[1:pos], arr[pos+2:])
 496        # DuckDB uses 1-based slicing
 497        left_slice = exp.Bracket(
 498            this=this,
 499            expressions=[
 500                exp.Slice(this=exp.Literal.number(1), expression=exp.Literal.number(pos_value))
 501            ],
 502        )
 503        right_slice = exp.Bracket(
 504            this=this,
 505            expressions=[exp.Slice(this=exp.Literal.number(pos_value + 2))],
 506        )
 507        result_expr = self.func("LIST_CONCAT", left_slice, right_slice)
 508    elif pos_value == -1:
 509        # Remove last element: arr[1:LEN(arr)-1]
 510        # Optimization: simpler than general negative case
 511        arr_len = exp.Length(this=this)
 512        slice_end = arr_len + exp.Literal.number(-1)
 513        result_expr = exp.Bracket(
 514            this=this,
 515            expressions=[exp.Slice(this=exp.Literal.number(1), expression=slice_end)],
 516        )
 517    else:
 518        # Remove at negative position: LIST_CONCAT(arr[1:LEN(arr)+pos], arr[LEN(arr)+pos+2:])
 519        arr_len = exp.Length(this=this)
 520        slice_end_pos = arr_len + exp.Literal.number(pos_value)
 521        slice_start_pos = slice_end_pos + exp.Literal.number(2)
 522
 523        left_slice = exp.Bracket(
 524            this=this,
 525            expressions=[exp.Slice(this=exp.Literal.number(1), expression=slice_end_pos)],
 526        )
 527        right_slice = exp.Bracket(
 528            this=this,
 529            expressions=[exp.Slice(this=slice_start_pos)],
 530        )
 531        result_expr = self.func("LIST_CONCAT", left_slice, right_slice)
 532
 533    # Snowflake ARRAY_FUNCS_PROPAGATES_NULLS=True, so wrap in NULL check
 534    # CASE WHEN array IS NULL THEN NULL ELSE result_expr END
 535    return self.sql(
 536        exp.If(
 537            this=exp.Is(this=this, expression=exp.Null()),
 538            true=exp.Null(),
 539            false=result_expr,
 540        )
 541    )
 542
 543
 544@unsupported_args(("expression", "DuckDB's ARRAY_SORT does not support a comparator."))
 545def _array_sort_sql(self: DuckDBGenerator, expression: exp.ArraySort) -> str:
 546    return self.func("ARRAY_SORT", expression.this)
 547
 548
 549def _array_contains_sql(self: DuckDBGenerator, expression: exp.ArrayContains) -> str:
 550    this = expression.this
 551    expr = expression.expression
 552
 553    func = self.func("ARRAY_CONTAINS", this, expr)
 554
 555    if expression.args.get("check_null"):
 556        check_null_in_array = exp.Nullif(
 557            this=exp.NEQ(this=exp.ArraySize(this=this), expression=exp.func("LIST_COUNT", this)),
 558            expression=exp.false(),
 559        )
 560        return self.sql(exp.If(this=expr.is_(exp.Null()), true=check_null_in_array, false=func))
 561
 562    return func
 563
 564
 565def _array_overlaps_sql(self: DuckDBGenerator, expression: exp.ArrayOverlaps) -> str:
 566    """
 567    Translates Snowflake's NULL-safe ARRAYS_OVERLAP to DuckDB.
 568
 569    DuckDB's native && operator is not NULL-safe: [1,NULL,3] && [NULL,4,5] returns FALSE.
 570    Snowflake returns TRUE when both arrays contain NULL (NULLs are treated as known values).
 571
 572    Generated SQL: (arr1 && arr2) OR (ARRAY_LENGTH(arr1) <> LIST_COUNT(arr1) AND ARRAY_LENGTH(arr2) <> LIST_COUNT(arr2))
 573
 574    ARRAY_LENGTH counts all elements (including NULLs); LIST_COUNT counts only non-NULLs.
 575    When they differ, the array contains at least one NULL, matching Snowflake's NULL-safe semantics.
 576    """
 577    if not expression.args.get("null_safe"):
 578        return self.binary(expression, "&&")
 579
 580    arr1 = expression.this
 581    arr2 = expression.expression
 582
 583    check_nulls = exp.and_(
 584        exp.NEQ(
 585            this=exp.ArraySize(this=arr1.copy()),
 586            expression=exp.func("LIST_COUNT", arr1.copy()),
 587        ),
 588        exp.NEQ(
 589            this=exp.ArraySize(this=arr2.copy()),
 590            expression=exp.func("LIST_COUNT", arr2.copy()),
 591        ),
 592        copy=False,
 593    )
 594
 595    overlap = exp.ArrayOverlaps(this=arr1.copy(), expression=arr2.copy())
 596
 597    return self.sql(
 598        exp.or_(
 599            exp.paren(overlap, copy=False),
 600            exp.paren(check_nulls, copy=False),
 601            copy=False,
 602            wrap=False,
 603        )
 604    )
 605
 606
 607def _struct_sql(self: DuckDBGenerator, expression: exp.Struct) -> str:
 608    ancestor_cast = expression.find_ancestor(exp.Cast, exp.Select)
 609    ancestor_cast = None if isinstance(ancestor_cast, exp.Select) else ancestor_cast
 610
 611    # Empty struct cast works with MAP() since DuckDB can't parse {}
 612    if not expression.expressions:
 613        if isinstance(ancestor_cast, exp.Cast) and ancestor_cast.to.is_type(exp.DType.MAP):
 614            return "MAP()"
 615
 616    args: list[str] = []
 617
 618    # BigQuery allows inline construction such as "STRUCT<a STRING, b INTEGER>('str', 1)" which is
 619    # canonicalized to "ROW('str', 1) AS STRUCT(a TEXT, b INT)" in DuckDB
 620    # The transformation to ROW will take place if:
 621    #  1. The STRUCT itself does not have proper fields (key := value) as a "proper" STRUCT would
 622    #  2. A cast to STRUCT / ARRAY of STRUCTs is found
 623    is_bq_inline_struct = (
 624        (expression.find(exp.PropertyEQ) is None)
 625        and ancestor_cast
 626        and any(
 627            casted_type.is_type(exp.DType.STRUCT)
 628            for casted_type in ancestor_cast.find_all(exp.DataType)
 629        )
 630    )
 631
 632    for i, expr in enumerate(expression.expressions):
 633        is_property_eq = isinstance(expr, exp.PropertyEQ)
 634        this = expr.this
 635        value = expr.expression if is_property_eq else expr
 636
 637        if is_bq_inline_struct:
 638            args.append(self.sql(value))
 639        else:
 640            if isinstance(this, exp.Identifier):
 641                key = self.sql(exp.Literal.string(expr.name))
 642            elif is_property_eq:
 643                key = self.sql(this)
 644            else:
 645                key = self.sql(exp.Literal.string(f"_{i}"))
 646
 647            args.append(f"{key}: {self.sql(value)}")
 648
 649    csv_args = ", ".join(args)
 650
 651    return f"ROW({csv_args})" if is_bq_inline_struct else f"{{{csv_args}}}"
 652
 653
 654def _json_format_sql(self: DuckDBGenerator, expression: exp.JSONFormat) -> str:
 655    sql = self.func("TO_JSON", expression.this, expression.args.get("options"))
 656    return f"CAST({sql} AS TEXT)"
 657
 658
 659def _build_seq_expression(base: exp.Expr, byte_width: int, signed: bool) -> exp.Expr:
 660    """Build a SEQ expression with the given base, byte width, and signedness."""
 661    bits = byte_width * 8
 662    max_val = exp.Literal.number(2**bits)
 663
 664    if signed:
 665        half = exp.Literal.number(2 ** (bits - 1))
 666        return exp.replace_placeholders(_SEQ_SIGNED.copy(), base=base, max_val=max_val, half=half)
 667    return exp.replace_placeholders(_SEQ_UNSIGNED.copy(), base=base, max_val=max_val)
 668
 669
 670def _seq_to_range_in_generator(expression: exp.Expr) -> exp.Expr:
 671    """
 672    Transform SEQ functions to `range` column references when inside a GENERATOR context.
 673
 674    When GENERATOR(ROWCOUNT => N) becomes RANGE(N) in DuckDB, it produces a column
 675    named `range` with values 0, 1, ..., N-1. SEQ functions produce the same sequence,
 676    so we replace them with `range % max_val` to avoid nested window function issues.
 677    """
 678    if not isinstance(expression, exp.Select):
 679        return expression
 680
 681    from_ = expression.args.get("from_")
 682    if not (
 683        from_
 684        and isinstance(from_.this, exp.TableFromRows)
 685        and isinstance(from_.this.this, exp.Generator)
 686    ):
 687        return expression
 688
 689    def replace_seq(node: exp.Expr) -> exp.Expr:
 690        if isinstance(node, (exp.Seq1, exp.Seq2, exp.Seq4, exp.Seq8)):
 691            byte_width = _SEQ_BYTE_WIDTH[type(node)]
 692            return _build_seq_expression(exp.column("range"), byte_width, signed=node.name == "1")
 693        return node
 694
 695    return expression.transform(replace_seq, copy=False)
 696
 697
 698def connect_by_to_recursive_cte(expression: exp.Expr) -> exp.Expr:
 699    # Rewrites START WITH ... CONNECT BY PRIOR into WITH RECURSIVE
 700    # Falls through unchanged if there are no PRIORs.
 701    if not isinstance(expression, exp.Select) or not expression.args.get("connect"):
 702        return expression
 703
 704    connect = expression.args["connect"]
 705    connect_pred = connect.args["connect"]
 706
 707    priors = list(connect_pred.find_all(exp.Prior))
 708    if not priors:
 709        return expression
 710
 711    from_ = expression.args.get("from_")
 712    if not from_ or expression.args.get("joins"):
 713        return expression
 714
 715    source_table = from_.this
 716    base_select_exprs = expression.expressions
 717    base_where = expression.args.get("where")
 718    base_with = expression.args.get("with_")
 719
 720    # LEVEL is a Snowflake pseudo-column: it's always computed as a depth counter in the CTE.
 721    has_level = any(
 722        isinstance(col, exp.Column) and col.name.upper() == "LEVEL"
 723        for e in base_select_exprs
 724        for col in e.find_all(exp.Column)
 725    )
 726    has_star = expression.is_star
 727
 728    # CONNECT_BY_ROOT col yields the value of `col` from the START WITH row that begins each
 729    # branch. Each one is threaded through the CTE as an extra column: the anchor binds it to the
 730    # row's own value, the recursive arm forwards the parent's value unchanged.
 731    root_col_names: list[str] = []
 732    anchor_root_cols: list[exp.Expr] = []
 733    inner_root_cols: list[exp.Expr] = []
 734    roots = [root for e in base_select_exprs for root in e.find_all(exp.ConnectByRoot)]
 735
 736    for i, root in enumerate(roots):
 737        name = f"_connect_by_root_{i}"
 738        root_col_names.append(name)
 739        anchor_root_cols.append(exp.alias_(root.this, name))
 740        inner_root_cols.append(exp.alias_(exp.column(name, "_parent_row"), name))
 741        root.replace(exp.column(name))
 742
 743    # Build the join condition from the full CONNECT BY predicate:
 744    # PRIOR(col) → _parent_row.col, unqualified cols → _child_row.col.
 745    def _qualify_connect_pred(node: exp.Expression) -> exp.Expression:
 746        for col in find_all_in_scope(node, exp.Column):
 747            col.set(
 748                "table",
 749                exp.to_identifier(
 750                    "_parent_row" if isinstance(col.parent, exp.Prior) else "_child_row"
 751                ),
 752            )
 753        for prior in find_all_in_scope(node, exp.Prior):
 754            prior.replace(prior.this)
 755        return node
 756
 757    # Avoid colliding with any CTE names already on the query.
 758    cte_name = find_new_name(
 759        {cte.alias for cte in (base_with.expressions if base_with else [])}, "_rootcte"
 760    )
 761
 762    # Anchor: project all source columns + seed LEVEL at 1 + bind each root column to its own value.
 763    anchor = exp.select(
 764        exp.Star(), exp.alias_(exp.Literal.number(1), "level"), *anchor_root_cols
 765    ).from_(source_table)
 766    if connect.args.get("start"):
 767        anchor = anchor.where(connect.args["start"])
 768
 769    # Recursive arm: carry all child columns + increment level + forward each root value.
 770    # SELECT * in both arms means WHERE/PRIOR columns are always available without explicit tracking.
 771    inner_query = (
 772        exp.select(
 773            exp.Column(this=exp.Star(), table=exp.to_identifier("_child_row")),
 774            exp.alias_(exp.column("level", "_parent_row") + 1, "level"),
 775            *inner_root_cols,
 776        )
 777        .from_(source_table.as_("_child_row"))
 778        .join(exp.to_table(cte_name).as_("_parent_row"), on=_qualify_connect_pred(connect_pred))
 779    )
 780
 781    # Outer SELECT re-projects from the CTE. Synthetic level/root columns are excluded from any
 782    # star expansion (level only when not referenced) but kept where explicitly projected.
 783    if has_star:
 784        except_cols = [] if has_level else [exp.column("level")]
 785        except_cols.extend(exp.column(name) for name in root_col_names)
 786        star = exp.Star(except_=except_cols) if except_cols else exp.Star()
 787        outer_select_exprs: list[exp.Expr] = [
 788            star,
 789            *(e for e in base_select_exprs if not e.is_star),
 790        ]
 791    else:
 792        outer_select_exprs = base_select_exprs
 793    outer_query = exp.select(*outer_select_exprs).from_(cte_name)
 794    if base_where:
 795        outer_query = outer_query.where(base_where.this)
 796
 797    # Attach the CTE, marking the WITH clause recursive.
 798    if base_with:
 799        outer_query.set("with_", base_with)
 800    outer_query = outer_query.with_(
 801        cte_name, as_=anchor.union(inner_query, distinct=False), recursive=True, copy=False
 802    )
 803
 804    for arg, val in expression.args.items():
 805        if val and arg not in _CONNECT_BY_ARGS_TO_SKIP:
 806            outer_query.set(arg, val)
 807
 808    # Strip stale source table qualifiers in one pass; CTEs are child scopes so
 809    # find_all_in_scope stays within the outer query only.
 810    for col in find_all_in_scope(outer_query, exp.Column):
 811        col.set("table", None)
 812
 813    return outer_query
 814
 815
 816def _seq_sql(self: DuckDBGenerator, expression: exp.Func, byte_width: int) -> str:
 817    """
 818    Transpile Snowflake SEQ1/SEQ2/SEQ4/SEQ8 to DuckDB.
 819
 820    Generates monotonically increasing integers starting from 0.
 821    The signed parameter (0 or 1) affects wrap-around behavior:
 822    - Unsigned (0): wraps at 2^(bits) - 1
 823    - Signed (1): wraps at 2^(bits-1) - 1, then goes negative
 824    """
 825    # Warn if SEQ is in a restricted context (Select stops search at current scope)
 826    ancestor = expression.find_ancestor(*_SEQ_RESTRICTED)
 827    if ancestor and (
 828        (not isinstance(ancestor, (exp.Order, exp.Select)))
 829        or (isinstance(ancestor, exp.Order) and isinstance(ancestor.parent, exp.Window))
 830    ):
 831        self.unsupported("SEQ in restricted context is not supported - use CTE or subquery")
 832
 833    result = _build_seq_expression(_SEQ_BASE.copy(), byte_width, signed=expression.name == "1")
 834    return self.sql(result)
 835
 836
 837def _unix_to_time_sql(self: DuckDBGenerator, expression: exp.UnixToTime) -> str:
 838    scale = expression.args.get("scale")
 839    timestamp = expression.this
 840    target_type = expression.args.get("target_type")
 841
 842    # Check if we need NTZ (naive timestamp in UTC)
 843    is_ntz = target_type and target_type.this in (
 844        exp.DType.TIMESTAMP,
 845        exp.DType.TIMESTAMPNTZ,
 846    )
 847
 848    if scale == exp.UnixToTime.MILLIS:
 849        # EPOCH_MS already returns TIMESTAMP (naive, UTC)
 850        return self.func("EPOCH_MS", timestamp)
 851    if scale == exp.UnixToTime.MICROS:
 852        # MAKE_TIMESTAMP already returns TIMESTAMP (naive, UTC)
 853        return self.func("MAKE_TIMESTAMP", timestamp)
 854
 855    # Other scales: divide and use TO_TIMESTAMP
 856    if scale not in (None, exp.UnixToTime.SECONDS):
 857        timestamp = exp.Div(this=timestamp, expression=exp.func("POW", 10, scale))
 858
 859    to_timestamp: exp.Expr = exp.Anonymous(this="TO_TIMESTAMP", expressions=[timestamp])
 860
 861    if is_ntz:
 862        to_timestamp = exp.AtTimeZone(this=to_timestamp, zone=exp.Literal.string("UTC"))
 863
 864    return self.sql(to_timestamp)
 865
 866
 867WRAPPED_JSON_EXTRACT_EXPRESSIONS = (exp.Binary, exp.Bracket, exp.In, exp.Not)
 868
 869
 870def _arrow_json_extract_sql(self: DuckDBGenerator, expression: JSON_EXTRACT_TYPE) -> str:
 871    arrow_sql = arrow_json_extract_sql(self, expression)
 872    if not expression.same_parent and isinstance(
 873        expression.parent, WRAPPED_JSON_EXTRACT_EXPRESSIONS
 874    ):
 875        arrow_sql = self.wrap(arrow_sql)
 876    return arrow_sql
 877
 878
 879def _implicit_datetime_cast(
 880    arg: exp.Expr | None, type: exp.DType = exp.DType.DATE
 881) -> exp.Expr | None:
 882    if isinstance(arg, exp.Literal) and arg.is_string:
 883        ts = arg.name
 884        if type == exp.DType.DATE and ":" in ts:
 885            type = exp.DType.TIMESTAMPTZ if TIMEZONE_PATTERN.search(ts) else exp.DType.TIMESTAMP
 886
 887        arg = exp.cast(arg, type)
 888
 889    return arg
 890
 891
 892def _week_trunc_start_dow(unit: exp.Expr | None) -> int | None:
 893    # DuckDB's weeks are ISO 8601, so ISOWEEK maps to its plain WEEK unit
 894    if isinstance(unit, exp.Literal) and unit.name.upper() == "ISOWEEK":
 895        return 1
 896    return week_unit_to_dow(unit)
 897
 898
 899def _build_week_trunc_expression(
 900    date_expr: exp.Expr,
 901    start_dow: int,
 902    preserve_start_day: bool = False,
 903    cast_to_date: bool = True,
 904) -> exp.Expr:
 905    """
 906    Build DATE_TRUNC expression for week boundaries with custom start day.
 907
 908    DuckDB's DATE_TRUNC('WEEK', ...) always returns Monday. To align to a different
 909    start day, we shift the date before truncating.
 910
 911    Args:
 912        date_expr: The date expression to truncate.
 913        start_dow: ISO 8601 day-of-week number (Monday=1, ..., Sunday=7).
 914        preserve_start_day: If True, reverse the shift after truncating so the result lands on the
 915            correct week start day. Needed for DATE_TRUNC (absolute result matters) but
 916            not for DATE_DIFF (only relative alignment matters).
 917        cast_to_date: If True, cast the shifted result back to DATE; set to False for
 918            timestamp-valued inputs, where the result must remain a timestamp.
 919
 920    Shift formula: Sunday (7) gets +1, others get (1 - start_dow).
 921    """
 922    shift_days = 1 if start_dow == 7 else 1 - start_dow
 923    truncated = exp.func("DATE_TRUNC", unit=exp.var("WEEK"), this=date_expr)
 924
 925    if shift_days == 0:
 926        return truncated
 927
 928    shift = exp.Interval(this=exp.Literal.string(str(shift_days)), unit=exp.var("DAY"))
 929    shifted_date = exp.DateAdd(this=date_expr, expression=shift)
 930    truncated.set("this", shifted_date)
 931
 932    if preserve_start_day:
 933        interval = exp.Interval(this=exp.Literal.string(str(-shift_days)), unit=exp.var("DAY"))
 934        shifted_back: exp.Expr = exp.DateAdd(this=truncated, expression=interval)
 935        if cast_to_date:
 936            return exp.cast(shifted_back, to=exp.DType.DATE, copy=False)
 937        return shifted_back
 938
 939    return truncated
 940
 941
 942def _date_diff_sql(self: DuckDBGenerator, expression: exp.DateDiff | exp.DatetimeDiff) -> str:
 943    unit = expression.unit
 944
 945    if _is_nanosecond_unit(unit):
 946        return _handle_nanosecond_diff(self, expression.this, expression.expression)
 947
 948    this = _implicit_datetime_cast(expression.this)
 949    expr = _implicit_datetime_cast(expression.expression)
 950
 951    # DuckDB's WEEK diff does not respect Monday crossing (week boundaries), it checks (end_day - start_day) / 7:
 952    #  SELECT DATE_DIFF('WEEK', CAST('2024-12-13' AS DATE), CAST('2024-12-17' AS DATE)) --> 0 (Monday crossed)
 953    #  SELECT DATE_DIFF('WEEK', CAST('2024-12-13' AS DATE), CAST('2024-12-20' AS DATE)) --> 1 (7 days difference)
 954    # Whereas for other units such as MONTH it does respect month boundaries:
 955    #  SELECT DATE_DIFF('MONTH', CAST('2024-11-30' AS DATE), CAST('2024-12-01' AS DATE)) --> 1 (Month crossed)
 956    date_part_boundary = expression.args.get("date_part_boundary")
 957
 958    # Extract week start day; returns None if day is dynamic (column/placeholder)
 959    week_start = week_unit_to_dow(unit)
 960    if date_part_boundary and week_start and this and expr:
 961        expression.set("unit", exp.Literal.string("WEEK"))
 962
 963        # Truncate both dates to week boundaries to respect input dialect semantics
 964        this = _build_week_trunc_expression(this, week_start)
 965        expr = _build_week_trunc_expression(expr, week_start)
 966
 967    return self.func("DATE_DIFF", unit_to_str(expression), expr, this)
 968
 969
 970def _generate_datetime_array_sql(
 971    self: DuckDBGenerator, expression: exp.GenerateDateArray | exp.GenerateTimestampArray
 972) -> str:
 973    is_generate_date_array = isinstance(expression, exp.GenerateDateArray)
 974
 975    type = exp.DType.DATE if is_generate_date_array else exp.DType.TIMESTAMP
 976    start = _implicit_datetime_cast(expression.args.get("start"), type=type)
 977    end = _implicit_datetime_cast(expression.args.get("end"), type=type)
 978
 979    # BQ's GENERATE_DATE_ARRAY & GENERATE_TIMESTAMP_ARRAY are transformed to DuckDB'S GENERATE_SERIES
 980    gen_series: exp.GenerateSeries | exp.Cast = exp.GenerateSeries(
 981        start=start, end=end, step=expression.args.get("step")
 982    )
 983
 984    if is_generate_date_array:
 985        # The GENERATE_SERIES result type is TIMESTAMP array, so to match BQ's semantics for
 986        # GENERATE_DATE_ARRAY we must cast it back to DATE array
 987        gen_series = exp.cast(gen_series, exp.DataType.from_str("ARRAY<DATE>"))
 988
 989    return self.sql(gen_series)
 990
 991
 992def _json_extract_value_array_sql(
 993    self: DuckDBGenerator, expression: exp.JSONValueArray | exp.JSONExtractArray
 994) -> str:
 995    json_extract = exp.JSONExtract(this=expression.this, expression=expression.expression)
 996    data_type = "ARRAY<STRING>" if isinstance(expression, exp.JSONValueArray) else "ARRAY<JSON>"
 997    return self.sql(exp.cast(json_extract, to=exp.DataType.from_str(data_type)))
 998
 999
1000def _cast_to_varchar(arg: exp.Expr | None) -> exp.Expr | None:
1001    if arg and arg.type and not arg.is_type(*exp.DataType.TEXT_TYPES, exp.DType.UNKNOWN):
1002        return exp.cast(arg, exp.DType.VARCHAR)
1003    return arg
1004
1005
1006def _cast_to_boolean(arg: exp.Expr | None) -> exp.Expr | None:
1007    if arg and not arg.is_type(exp.DType.BOOLEAN):
1008        return exp.cast(arg, exp.DType.BOOLEAN)
1009    return arg
1010
1011
1012def _is_binary(arg: exp.Expr) -> bool:
1013    return arg.is_type(
1014        exp.DType.BINARY,
1015        exp.DType.VARBINARY,
1016        exp.DType.BLOB,
1017    )
1018
1019
1020def _gen_with_cast_to_blob(self: DuckDBGenerator, expression: exp.Expr, result_sql: str) -> str:
1021    if _is_binary(expression):
1022        blob = exp.DataType.from_str("BLOB", dialect="duckdb")
1023        result_sql = self.sql(exp.Cast(this=result_sql, to=blob))
1024    return result_sql
1025
1026
1027def _cast_to_bit(arg: exp.Expr) -> exp.Expr:
1028    if not _is_binary(arg):
1029        return arg
1030
1031    if isinstance(arg, exp.HexString):
1032        arg = exp.Unhex(this=exp.Literal.string(arg.this))
1033
1034    return exp.cast(arg, exp.DType.BIT)
1035
1036
1037def _prepare_binary_bitwise_args(expression: exp.Binary) -> None:
1038    if _is_binary(expression.this):
1039        expression.set("this", _cast_to_bit(expression.this))
1040    if _is_binary(expression.expression):
1041        expression.set("expression", _cast_to_bit(expression.expression))
1042
1043
1044def _day_navigation_sql(self: DuckDBGenerator, expression: exp.NextDay | exp.PreviousDay) -> str:
1045    """
1046    Transpile Snowflake's NEXT_DAY / PREVIOUS_DAY to DuckDB using date arithmetic.
1047
1048    Returns the DATE of the next/previous occurrence of the specified weekday.
1049
1050    Formulas:
1051    - NEXT_DAY: (target_dow - current_dow + 6) % 7 + 1
1052    - PREVIOUS_DAY: (current_dow - target_dow + 6) % 7 + 1
1053
1054    Supports both literal and non-literal day names:
1055    - Literal: Direct lookup (e.g., 'Monday' -> 1)
1056    - Non-literal: CASE statement for runtime evaluation
1057
1058    Examples:
1059        NEXT_DAY('2024-01-01' (Monday), 'Monday')
1060          -> (1 - 1 + 6) % 7 + 1 = 6 % 7 + 1 = 7 days -> 2024-01-08
1061
1062        PREVIOUS_DAY('2024-01-15' (Monday), 'Friday')
1063          -> (1 - 5 + 6) % 7 + 1 = 2 % 7 + 1 = 3 days -> 2024-01-12
1064    """
1065    date_expr = expression.this
1066    day_name_expr = expression.expression
1067
1068    # Build ISODOW call for current day of week
1069    isodow_call = exp.func("ISODOW", date_expr)
1070
1071    # Determine target day of week
1072    if isinstance(day_name_expr, exp.Literal):
1073        # Literal day name: lookup target_dow directly
1074        day_name_str = day_name_expr.name.upper()
1075        matching_day = next(
1076            (day for day in WEEK_START_DAY_TO_DOW if day.startswith(day_name_str)), None
1077        )
1078        if matching_day:
1079            target_dow: exp.Expr = exp.Literal.number(WEEK_START_DAY_TO_DOW[matching_day])
1080        else:
1081            # Unrecognized day name, use fallback
1082            return self.function_fallback_sql(expression)
1083    else:
1084        # Non-literal day name: build CASE statement for runtime mapping
1085        upper_day_name = exp.Upper(this=day_name_expr)
1086        target_dow = exp.Case(
1087            ifs=[
1088                exp.If(
1089                    this=exp.func(
1090                        "STARTS_WITH", upper_day_name.copy(), exp.Literal.string(day[:2])
1091                    ),
1092                    true=exp.Literal.number(dow_num),
1093                )
1094                for day, dow_num in WEEK_START_DAY_TO_DOW.items()
1095            ]
1096        )
1097
1098    # Calculate days offset and apply interval based on direction
1099    if isinstance(expression, exp.NextDay):
1100        # NEXT_DAY: (target_dow - current_dow + 6) % 7 + 1
1101        days_offset = exp.paren(target_dow - isodow_call + 6, copy=False) % 7 + 1
1102        date_with_offset = date_expr + exp.Interval(this=days_offset, unit=exp.var("DAY"))
1103    else:  # exp.PreviousDay
1104        # PREVIOUS_DAY: (current_dow - target_dow + 6) % 7 + 1
1105        days_offset = exp.paren(isodow_call - target_dow + 6, copy=False) % 7 + 1
1106        date_with_offset = date_expr - exp.Interval(this=days_offset, unit=exp.var("DAY"))
1107
1108    # Build final: CAST(date_with_offset AS DATE)
1109    return self.sql(exp.cast(date_with_offset, exp.DType.DATE))
1110
1111
1112def _anyvalue_sql(self: DuckDBGenerator, expression: exp.AnyValue) -> str:
1113    # Transform ANY_VALUE(expr HAVING MAX/MIN having_expr) to ARG_MAX_NULL/ARG_MIN_NULL
1114    having = expression.this
1115    if isinstance(having, exp.HavingMax):
1116        func_name = "ARG_MAX_NULL" if having.args.get("max") else "ARG_MIN_NULL"
1117        return self.func(func_name, having.this, having.expression)
1118    return self.function_fallback_sql(expression)
1119
1120
1121def _bitwise_agg_sql(
1122    self: DuckDBGenerator,
1123    expression: exp.BitwiseOrAgg | exp.BitwiseAndAgg | exp.BitwiseXorAgg,
1124) -> str:
1125    """
1126    DuckDB's bitwise aggregate functions only accept integer types. For other types:
1127    - DECIMAL/STRING: Use CAST(arg AS INT) to convert directly, will round to nearest int
1128    - FLOAT/DOUBLE: Use ROUND(arg)::INT to round to nearest integer, required due to float precision loss
1129    """
1130    if isinstance(expression, exp.BitwiseOrAgg):
1131        func_name = "BIT_OR"
1132    elif isinstance(expression, exp.BitwiseAndAgg):
1133        func_name = "BIT_AND"
1134    else:  # exp.BitwiseXorAgg
1135        func_name = "BIT_XOR"
1136
1137    arg = expression.this
1138
1139    if not arg.type:
1140        from sqlglot.optimizer.annotate_types import annotate_types
1141
1142        arg = annotate_types(arg, dialect=self.dialect)
1143
1144    if arg.is_type(*exp.DataType.REAL_TYPES, *exp.DataType.TEXT_TYPES):
1145        if arg.is_type(*exp.DataType.FLOAT_TYPES):
1146            # float types need to be rounded first due to precision loss
1147            arg = exp.func("ROUND", arg)
1148
1149        arg = exp.cast(arg, exp.DType.INT)
1150
1151    return self.func(func_name, arg)
1152
1153
1154def _literal_sql_with_ws_chr(self: DuckDBGenerator, literal: str) -> str:
1155    # DuckDB does not support \uXXXX escapes, so we must use CHR() instead of replacing them directly
1156    if not any(ch in WS_CONTROL_CHARS_TO_DUCK for ch in literal):
1157        return self.sql(exp.Literal.string(literal))
1158
1159    sql_segments: list[str] = []
1160    for is_ws_control, group in groupby(literal, key=lambda ch: ch in WS_CONTROL_CHARS_TO_DUCK):
1161        if is_ws_control:
1162            for ch in group:
1163                duckdb_char_code = WS_CONTROL_CHARS_TO_DUCK[ch]
1164                sql_segments.append(self.func("CHR", exp.Literal.number(str(duckdb_char_code))))
1165        else:
1166            sql_segments.append(self.sql(exp.Literal.string("".join(group))))
1167
1168    sql = " || ".join(sql_segments)
1169    return sql if len(sql_segments) == 1 else f"({sql})"
1170
1171
1172def _escape_regex_metachars(
1173    self: DuckDBGenerator, delimiters: exp.Expr | None, delimiters_sql: str
1174) -> str:
1175    r"""
1176    Escapes regex metacharacters \ - ^ [ ] for use in character classes regex expressions.
1177
1178    Literal strings are escaped at transpile time, expressions handled with REPLACE() calls.
1179    """
1180    if not delimiters:
1181        return delimiters_sql
1182
1183    if delimiters.is_string:
1184        literal_value = delimiters.this
1185        escaped_literal = "".join(REGEX_ESCAPE_REPLACEMENTS.get(ch, ch) for ch in literal_value)
1186        return _literal_sql_with_ws_chr(self, escaped_literal)
1187
1188    escaped_sql = delimiters_sql
1189    for raw, escaped in REGEX_ESCAPE_REPLACEMENTS.items():
1190        escaped_sql = self.func(
1191            "REPLACE",
1192            escaped_sql,
1193            self.sql(exp.Literal.string(raw)),
1194            self.sql(exp.Literal.string(escaped)),
1195        )
1196
1197    return escaped_sql
1198
1199
1200def _build_capitalization_sql(
1201    self: DuckDBGenerator,
1202    value_to_split: str,
1203    delimiters_sql: str,
1204) -> str:
1205    # empty string delimiter --> treat value as one word, no need to split
1206    if delimiters_sql == "''":
1207        return f"UPPER(LEFT({value_to_split}, 1)) || LOWER(SUBSTRING({value_to_split}, 2))"
1208
1209    delim_regex_sql = f"CONCAT('[', {delimiters_sql}, ']')"
1210    split_regex_sql = f"CONCAT('([', {delimiters_sql}, ']+|[^', {delimiters_sql}, ']+)')"
1211
1212    # REGEXP_EXTRACT_ALL produces a list of string segments, alternating between delimiter and non-delimiter segments.
1213    # We do not know whether the first segment is a delimiter or not, so we check the first character of the string
1214    # with REGEXP_MATCHES. If the first char is a delimiter, we capitalize even list indexes, otherwise capitalize odd.
1215    return self.func(
1216        "ARRAY_TO_STRING",
1217        exp.case()
1218        .when(
1219            f"REGEXP_MATCHES(LEFT({value_to_split}, 1), {delim_regex_sql})",
1220            self.func(
1221                "LIST_TRANSFORM",
1222                self.func("REGEXP_EXTRACT_ALL", value_to_split, split_regex_sql),
1223                "(seg, idx) -> CASE WHEN idx % 2 = 0 THEN UPPER(LEFT(seg, 1)) || LOWER(SUBSTRING(seg, 2)) ELSE seg END",
1224            ),
1225        )
1226        .else_(
1227            self.func(
1228                "LIST_TRANSFORM",
1229                self.func("REGEXP_EXTRACT_ALL", value_to_split, split_regex_sql),
1230                "(seg, idx) -> CASE WHEN idx % 2 = 1 THEN UPPER(LEFT(seg, 1)) || LOWER(SUBSTRING(seg, 2)) ELSE seg END",
1231            ),
1232        ),
1233        "''",
1234    )
1235
1236
1237def _initcap_sql(self: DuckDBGenerator, expression: exp.Initcap) -> str:
1238    this_sql = self.sql(expression, "this")
1239    delimiters = expression.args.get("expression")
1240    if delimiters is None:
1241        # fallback for manually created exp.Initcap w/o delimiters arg
1242        delimiters = exp.Literal.string(self.dialect.INITCAP_DEFAULT_DELIMITER_CHARS)
1243    delimiters_sql = self.sql(delimiters)
1244
1245    escaped_delimiters_sql = _escape_regex_metachars(self, delimiters, delimiters_sql)
1246
1247    return _build_capitalization_sql(self, this_sql, escaped_delimiters_sql)
1248
1249
1250def _boolxor_agg_sql(self: DuckDBGenerator, expression: exp.BoolxorAgg) -> str:
1251    """
1252    Snowflake's `BOOLXOR_AGG(col)` returns TRUE if exactly one input in `col` is TRUE, FALSE otherwise;
1253    Since DuckDB does not have a mapping function, we mimic the behavior by generating `COUNT_IF(col) = 1`.
1254
1255    DuckDB's COUNT_IF strictly requires boolean inputs, so cast if not already boolean.
1256    """
1257    return self.sql(
1258        exp.EQ(
1259            this=exp.CountIf(this=_cast_to_boolean(expression.this)),
1260            expression=exp.Literal.number(1),
1261        )
1262    )
1263
1264
1265def _bitshift_sql(
1266    self: DuckDBGenerator, expression: exp.BitwiseLeftShift | exp.BitwiseRightShift
1267) -> str:
1268    """
1269    Transform bitshift expressions for DuckDB by injecting BIT/INT128 casts.
1270
1271    DuckDB's bitwise shift operators don't work with BLOB/BINARY types, so we cast
1272    them to BIT for the operation, then cast the result back to the original type.
1273
1274    Note: Assumes type annotation has been applied with the source dialect.
1275    """
1276    operator = "<<" if isinstance(expression, exp.BitwiseLeftShift) else ">>"
1277    result_is_blob = False
1278    this = expression.this
1279
1280    if _is_binary(this):
1281        result_is_blob = True
1282        expression.set("this", exp.cast(this, exp.DType.BIT))
1283    elif expression.args.get("requires_int128"):
1284        this.replace(exp.cast(this, exp.DType.INT128))
1285
1286    result_sql = self.binary(expression, operator)
1287
1288    # Wrap in parentheses if parent is a bitwise operator to "fix" DuckDB precedence issue
1289    # DuckDB parses: a << b | c << d  as  (a << b | c) << d
1290    if isinstance(expression.parent, exp.Binary):
1291        result_sql = self.sql(exp.Paren(this=result_sql))
1292
1293    if result_is_blob:
1294        result_sql = self.sql(
1295            exp.Cast(this=result_sql, to=exp.DataType.from_str("BLOB", dialect="duckdb"))
1296        )
1297
1298    return result_sql
1299
1300
1301def _scale_rounding_sql(
1302    self: DuckDBGenerator,
1303    expression: exp.Expr,
1304    rounding_func: Type[exp.Expr],
1305) -> str | None:
1306    """
1307    Handle scale parameter transformation for rounding functions.
1308
1309    DuckDB doesn't support the scale parameter for certain functions (e.g., FLOOR, CEIL),
1310    so we transform: FUNC(x, n) to ROUND(FUNC(x * 10^n) / 10^n, n)
1311
1312    Args:
1313        self: The DuckDB generator instance
1314        expression: The expression to transform (must have 'this', 'decimals', and 'to' args)
1315        rounding_func: The rounding function class to use in the transformation
1316
1317    Returns:
1318        The transformed SQL string if decimals parameter exists, None otherwise
1319    """
1320    decimals = expression.args.get("decimals")
1321
1322    if decimals is None or expression.args.get("to") is not None:
1323        return None
1324
1325    this = expression.this
1326    if isinstance(this, exp.Binary):
1327        this = exp.Paren(this=this)
1328
1329    n_int = decimals
1330    if not (decimals.is_int or decimals.is_type(*exp.DataType.INTEGER_TYPES)):
1331        n_int = exp.cast(decimals, exp.DType.INT)
1332
1333    pow_ = exp.Pow(this=exp.Literal.number("10"), expression=n_int)
1334    rounded = rounding_func(this=exp.Mul(this=this, expression=pow_))
1335    result = exp.Div(this=rounded, expression=pow_.copy())
1336
1337    return self.round_sql(
1338        exp.Round(this=result, decimals=decimals, casts_non_integer_decimals=True)
1339    )
1340
1341
1342def _ceil_floor(self: DuckDBGenerator, expression: exp.Floor | exp.Ceil) -> str:
1343    scaled_sql = _scale_rounding_sql(self, expression, type(expression))
1344    if scaled_sql is not None:
1345        return scaled_sql
1346    return self.ceil_floor(expression)
1347
1348
1349def _regr_val_sql(
1350    self: DuckDBGenerator,
1351    expression: exp.RegrValx | exp.RegrValy,
1352) -> str:
1353    """
1354    Transpile Snowflake's REGR_VALX/REGR_VALY to DuckDB equivalent.
1355
1356    REGR_VALX(y, x) returns NULL if y is NULL; otherwise returns x.
1357    REGR_VALY(y, x) returns NULL if x is NULL; otherwise returns y.
1358    """
1359    from sqlglot.optimizer.annotate_types import annotate_types
1360
1361    y = expression.this
1362    x = expression.expression
1363
1364    # Determine which argument to check for NULL and which to return based on expression type
1365    if isinstance(expression, exp.RegrValx):
1366        # REGR_VALX: check y for NULL, return x
1367        check_for_null = y
1368        return_value = x
1369        return_value_attr = "expression"
1370    else:
1371        # REGR_VALY: check x for NULL, return y
1372        check_for_null = x
1373        return_value = y
1374        return_value_attr = "this"
1375
1376    # Get the type from the return argument
1377    result_type = return_value.type
1378
1379    # If no type info, annotate the expression to infer types
1380    if not result_type or result_type.this == exp.DType.UNKNOWN:
1381        try:
1382            annotated = annotate_types(expression.copy(), dialect=self.dialect)
1383            result_type = getattr(annotated, return_value_attr).type
1384        except Exception:
1385            pass
1386
1387    # Default to DOUBLE for regression functions if type still unknown
1388    if not result_type or result_type.this == exp.DType.UNKNOWN:
1389        result_type = exp.DType.DOUBLE.into_expr()
1390
1391    # Cast NULL to the same type as return_value to avoid DuckDB type inference issues
1392    typed_null = exp.Cast(this=exp.Null(), to=result_type)
1393
1394    return self.sql(
1395        exp.If(
1396            this=exp.Is(this=check_for_null.copy(), expression=exp.Null()),
1397            true=typed_null,
1398            false=return_value.copy(),
1399        )
1400    )
1401
1402
1403def _maybe_corr_null_to_false(
1404    expression: exp.Filter | exp.Window | exp.Corr,
1405) -> exp.Filter | exp.Window | exp.Corr | None:
1406    corr = expression
1407    while isinstance(corr, (exp.Window, exp.Filter)):
1408        corr = corr.this
1409
1410    if not isinstance(corr, exp.Corr) or not corr.args.get("null_on_zero_variance"):
1411        return None
1412
1413    corr.set("null_on_zero_variance", False)
1414    return expression
1415
1416
1417def _date_from_parts_sql(self, expression: exp.DateFromParts) -> str:
1418    """
1419    Snowflake's DATE_FROM_PARTS allows out-of-range values for the month and day input.
1420    E.g., larger values (month=13, day=100), zero-values (month=0, day=0), negative values (month=-13, day=-100).
1421
1422    DuckDB's MAKE_DATE does not support out-of-range values, but DuckDB's INTERVAL type does.
1423
1424    We convert to date arithmetic:
1425    DATE_FROM_PARTS(year, month, day)
1426    - MAKE_DATE(year, 1, 1) + INTERVAL (month-1) MONTH + INTERVAL (day-1) DAY
1427    """
1428    year_expr = expression.args.get("year")
1429    month_expr = expression.args.get("month")
1430    day_expr = expression.args.get("day")
1431
1432    if expression.args.get("allow_overflow"):
1433        base_date: exp.Expr = exp.func(
1434            "MAKE_DATE", year_expr, exp.Literal.number(1), exp.Literal.number(1)
1435        )
1436
1437        if month_expr:
1438            base_date = base_date + exp.Interval(this=month_expr - 1, unit=exp.var("MONTH"))
1439
1440        if day_expr:
1441            base_date = base_date + exp.Interval(this=day_expr - 1, unit=exp.var("DAY"))
1442
1443        return self.sql(exp.cast(expression=base_date, to=exp.DType.DATE))
1444
1445    return self.func("MAKE_DATE", year_expr, month_expr, day_expr)
1446
1447
1448def _round_arg(arg: exp.Expr, round_input: bool | None = None) -> exp.Expr:
1449    if round_input:
1450        return exp.func("ROUND", arg, exp.Literal.number(0))
1451    return arg
1452
1453
1454def _boolnot_sql(self: DuckDBGenerator, expression: exp.Boolnot) -> str:
1455    arg = _round_arg(expression.this, expression.args.get("round_input"))
1456    return self.sql(exp.not_(exp.paren(arg)))
1457
1458
1459def _booland_sql(self: DuckDBGenerator, expression: exp.Booland) -> str:
1460    round_input = expression.args.get("round_input")
1461    left = _round_arg(expression.this, round_input)
1462    right = _round_arg(expression.expression, round_input)
1463    return self.sql(exp.paren(exp.and_(exp.paren(left), exp.paren(right), wrap=False)))
1464
1465
1466def _boolor_sql(self: DuckDBGenerator, expression: exp.Boolor) -> str:
1467    round_input = expression.args.get("round_input")
1468    left = _round_arg(expression.this, round_input)
1469    right = _round_arg(expression.expression, round_input)
1470    return self.sql(exp.paren(exp.or_(exp.paren(left), exp.paren(right), wrap=False)))
1471
1472
1473def _xor_sql(self: DuckDBGenerator, expression: exp.Xor) -> str:
1474    round_input = expression.args.get("round_input")
1475    left = _round_arg(expression.this, round_input)
1476    right = _round_arg(expression.expression, round_input)
1477    return self.sql(
1478        exp.or_(
1479            exp.paren(exp.and_(left.copy(), exp.paren(right.not_()), wrap=False)),
1480            exp.paren(exp.and_(exp.paren(left.not_()), right.copy(), wrap=False)),
1481            wrap=False,
1482        )
1483    )
1484
1485
1486def _explode_to_unnest_sql(self: DuckDBGenerator, expression: exp.Lateral) -> str:
1487    """Handle LATERAL VIEW EXPLODE/INLINE conversion to UNNEST for DuckDB."""
1488    explode = expression.this
1489
1490    if isinstance(explode, exp.Inline):
1491        # For INLINE, create CROSS JOIN LATERAL (SELECT UNNEST(..., max_depth => 2))
1492        # Build the UNNEST call with DuckDB-style named parameter
1493        unnest_expr = exp.Unnest(
1494            expressions=[
1495                explode.this,
1496                exp.Kwarg(this=exp.var("max_depth"), expression=exp.Literal.number(2)),
1497            ]
1498        )
1499        select_expr = exp.Select(expressions=[unnest_expr]).subquery()
1500
1501        alias_expr = expression.args.get("alias")
1502        if alias_expr and not alias_expr.this:
1503            # we need to provide a table name if not present
1504            alias_expr.set("this", exp.to_identifier(f"_u_{expression.index}"))
1505
1506        transformed_lateral_expr = exp.Lateral(this=select_expr, alias=alias_expr)
1507        cross_join_lateral_expr = exp.Join(this=transformed_lateral_expr, kind="CROSS")
1508
1509        return self.sql(cross_join_lateral_expr)
1510
1511    # For other cases, use the standard conversion
1512    return explode_to_unnest_sql(self, expression)
1513
1514
1515def _sha_sql(
1516    self: DuckDBGenerator,
1517    expression: exp.Expr,
1518    hash_func: str,
1519    is_binary: bool = False,
1520) -> str:
1521    arg = expression.this
1522
1523    # For SHA2 variants, check digest length (DuckDB only supports SHA256)
1524    if hash_func == "SHA256":
1525        length = expression.text("length") or "256"
1526        if length != "256":
1527            self.unsupported("DuckDB only supports SHA256 hashing algorithm.")
1528
1529    # Cast if type is incompatible with DuckDB
1530    if (
1531        arg.type
1532        and arg.type.this != exp.DType.UNKNOWN
1533        and not arg.is_type(*exp.DataType.TEXT_TYPES)
1534        and not _is_binary(arg)
1535    ):
1536        arg = exp.cast(arg, exp.DType.VARCHAR)
1537
1538    result = self.func(hash_func, arg)
1539    return self.func("UNHEX", result) if is_binary else result
1540
1541
1542class DuckDBGenerator(generator.Generator):
1543    PARAMETER_TOKEN = "$"
1544    NAMED_PLACEHOLDER_TOKEN = "$"
1545    JOIN_HINTS = False
1546    TABLE_HINTS = False
1547    QUERY_HINTS = False
1548    LIMIT_FETCH = "LIMIT"
1549    STRUCT_DELIMITER = ("(", ")")
1550    RENAME_TABLE_WITH_DB = False
1551    NVL2_SUPPORTED = False
1552    SEMI_ANTI_JOIN_WITH_SIDE = False
1553    CAST_TO_INT_ROUNDS = True
1554    SAFE_INT_DIVISION = True
1555    TABLESAMPLE_KEYWORDS = "USING SAMPLE"
1556    TABLESAMPLE_SEED_KEYWORD = "REPEATABLE"
1557    LAST_DAY_SUPPORTS_DATE_PART = False
1558    JSON_KEY_VALUE_PAIR_SEP = ","
1559    IGNORE_NULLS_IN_FUNC = True
1560    IGNORE_NULLS_BEFORE_ORDER = False
1561    JSON_PATH_BRACKETED_KEY_SUPPORTED = False
1562    SUPPORTS_CREATE_TABLE_LIKE = False
1563    MULTI_ARG_DISTINCT = False
1564    CAN_IMPLEMENT_ARRAY_ANY = True
1565    SUPPORTS_TO_NUMBER = False
1566    SELECT_KINDS: tuple[str, ...] = ()
1567    SUPPORTS_DECODE_CASE = False
1568    SUPPORTS_DROP_ALTER_ICEBERG_PROPERTY = False
1569
1570    # USING SAMPLE goes after QUALIFY and before ORDER BY / LIMIT
1571    # https://duckdb.org/docs/preview/sql/query_syntax/sample
1572    AFTER_HAVING_MODIFIER_TRANSFORMS = {
1573        **generator.AFTER_HAVING_MODIFIER_TRANSFORMS,
1574        "sample": lambda self, e: self.sql(e, "sample"),
1575    }
1576    SUPPORTS_WINDOW_EXCLUDE = True
1577    COPY_HAS_INTO_KEYWORD = False
1578    STAR_EXCEPT = "EXCLUDE"
1579    PAD_FILL_PATTERN_IS_REQUIRED = True
1580    ARRAY_SIZE_DIM_REQUIRED: bool | None = False
1581    NORMALIZE_EXTRACT_DATE_PARTS = True
1582    SUPPORTS_LIKE_QUANTIFIERS = False
1583    HISTORICAL_DATA_POST_ALIAS = True
1584    SET_ASSIGNMENT_REQUIRES_VARIABLE_KEYWORD = True
1585
1586    TRANSFORMS = {
1587        **generator.Generator.TRANSFORMS,
1588        exp.AnyValue: _anyvalue_sql,
1589        exp.ApproxDistinct: approx_count_distinct_sql,
1590        exp.Boolnot: _boolnot_sql,
1591        exp.Booland: _booland_sql,
1592        exp.Boolor: _boolor_sql,
1593        exp.Array: transforms.preprocess(
1594            [transforms.inherit_struct_field_names],
1595            generator=inline_array_unless_query,
1596        ),
1597        exp.ArrayAppend: array_append_sql("LIST_APPEND"),
1598        exp.ArrayCompact: array_compact_sql,
1599        exp.ArrayConstructCompact: lambda self, e: self.sql(
1600            exp.ArrayCompact(this=exp.Array(expressions=e.expressions))
1601        ),
1602        exp.ArrayConcat: array_concat_sql("LIST_CONCAT"),
1603        exp.ArrayContains: _array_contains_sql,
1604        exp.ArrayOverlaps: _array_overlaps_sql,
1605        exp.ArrayFilter: rename_func("LIST_FILTER"),
1606        exp.ArrayInsert: _array_insert_sql,
1607        exp.ArrayPosition: lambda self, e: (
1608            self.sql(
1609                exp.Sub(
1610                    this=exp.ArrayPosition(this=e.this, expression=e.expression),
1611                    expression=exp.Literal.number(1),
1612                )
1613            )
1614            if e.args.get("zero_based")
1615            else self.func("ARRAY_POSITION", e.this, e.expression)
1616        ),
1617        exp.ArrayRemoveAt: _array_remove_at_sql,
1618        exp.ArrayRemove: remove_from_array_using_filter,
1619        exp.ArraySort: _array_sort_sql,
1620        exp.ArrayPrepend: array_append_sql("LIST_PREPEND", swap_params=True),
1621        exp.ArraySum: rename_func("LIST_SUM"),
1622        exp.ArrayMax: rename_func("LIST_MAX"),
1623        exp.ArrayMin: rename_func("LIST_MIN"),
1624        exp.Base64DecodeBinary: lambda self, e: _base64_decode_sql(self, e, to_string=False),
1625        exp.Base64DecodeString: lambda self, e: _base64_decode_sql(self, e, to_string=True),
1626        exp.BitwiseAnd: lambda self, e: self._bitwise_op(e, "&"),
1627        exp.BitwiseAndAgg: _bitwise_agg_sql,
1628        exp.BitwiseCount: rename_func("BIT_COUNT"),
1629        exp.BitwiseLeftShift: _bitshift_sql,
1630        exp.BitwiseOr: lambda self, e: self._bitwise_op(e, "|"),
1631        exp.BitwiseOrAgg: _bitwise_agg_sql,
1632        exp.BitwiseRightShift: _bitshift_sql,
1633        exp.BitwiseXorAgg: _bitwise_agg_sql,
1634        exp.CommentColumnConstraint: no_comment_column_constraint_sql,
1635        exp.Corr: lambda self, e: self._corr_sql(e),
1636        exp.CosineDistance: rename_func("LIST_COSINE_DISTANCE"),
1637        exp.CurrentTime: lambda *_: "CURRENT_TIME",
1638        exp.CurrentSchemas: lambda self, e: self.func(
1639            "current_schemas", e.this if e.this else exp.true()
1640        ),
1641        exp.CurrentTimestamp: lambda self, e: (
1642            self.sql(
1643                exp.AtTimeZone(this=exp.var("CURRENT_TIMESTAMP"), zone=exp.Literal.string("UTC"))
1644            )
1645            if e.args.get("sysdate")
1646            else "CURRENT_TIMESTAMP"
1647        ),
1648        exp.CurrentVersion: rename_func("version"),
1649        exp.Localtime: unsupported_args("this")(lambda *_: "LOCALTIME"),
1650        exp.DayOfMonth: rename_func("DAYOFMONTH"),
1651        exp.DayOfWeek: rename_func("DAYOFWEEK"),
1652        exp.DayOfWeekIso: rename_func("ISODOW"),
1653        exp.DayOfYear: rename_func("DAYOFYEAR"),
1654        exp.Dayname: lambda self, e: (
1655            self.func("STRFTIME", e.this, exp.Literal.string("%a"))
1656            if e.args.get("abbreviated")
1657            else self.func("DAYNAME", e.this)
1658        ),
1659        exp.Monthname: lambda self, e: (
1660            self.func("STRFTIME", e.this, exp.Literal.string("%b"))
1661            if e.args.get("abbreviated")
1662            else self.func("MONTHNAME", e.this)
1663        ),
1664        exp.Date: _date_sql,
1665        exp.DateAdd: _date_delta_to_binary_interval_op(),
1666        exp.DateFromParts: _date_from_parts_sql,
1667        exp.DateSub: _date_delta_to_binary_interval_op(),
1668        exp.DateDiff: _date_diff_sql,
1669        exp.DateStrToDate: datestrtodate_sql,
1670        exp.Datetime: no_datetime_sql,
1671        exp.DatetimeDiff: _date_diff_sql,
1672        exp.DatetimeSub: _date_delta_to_binary_interval_op(),
1673        exp.DatetimeAdd: _date_delta_to_binary_interval_op(),
1674        exp.DateToDi: lambda self, e: (
1675            f"CAST(STRFTIME({self.sql(e, 'this')}, {self.dialect.DATEINT_FORMAT}) AS INT)"
1676        ),
1677        exp.Decode: lambda self, e: encode_decode_sql(self, e, "DECODE", replace=False),
1678        exp.HexDecodeString: lambda self, e: self.sql(exp.Decode(this=exp.Unhex(this=e.this))),
1679        exp.DiToDate: lambda self, e: (
1680            f"CAST(STRPTIME(CAST({self.sql(e, 'this')} AS TEXT), {self.dialect.DATEINT_FORMAT}) AS DATE)"
1681        ),
1682        exp.Encode: lambda self, e: encode_decode_sql(self, e, "ENCODE", replace=False),
1683        exp.EqualNull: lambda self, e: self.sql(
1684            exp.NullSafeEQ(this=e.this, expression=e.expression)
1685        ),
1686        exp.EuclideanDistance: rename_func("LIST_DISTANCE"),
1687        exp.GenerateDateArray: _generate_datetime_array_sql,
1688        exp.GenerateSeries: generate_series_sql("GENERATE_SERIES", "RANGE"),
1689        exp.GenerateTimestampArray: _generate_datetime_array_sql,
1690        exp.Getbit: getbit_sql,
1691        exp.GroupConcat: lambda self, e: groupconcat_sql(self, e, within_group=False),
1692        exp.Explode: rename_func("UNNEST"),
1693        exp.IcebergProperty: lambda *_: "",
1694        exp.IntDiv: lambda self, e: self.binary(e, "//"),
1695        exp.IsInf: rename_func("ISINF"),
1696        exp.IsNan: rename_func("ISNAN"),
1697        exp.IsNullValue: lambda self, e: self.sql(
1698            exp.func("JSON_TYPE", e.this).eq(exp.Literal.string("NULL"))
1699        ),
1700        exp.IsArray: lambda self, e: self.sql(
1701            exp.func("JSON_TYPE", e.this).eq(exp.Literal.string("ARRAY"))
1702        ),
1703        exp.Ceil: _ceil_floor,
1704        exp.Floor: _ceil_floor,
1705        exp.JSONBExists: rename_func("JSON_EXISTS"),
1706        exp.JSONExtract: _arrow_json_extract_sql,
1707        exp.JSONExtractArray: _json_extract_value_array_sql,
1708        exp.JSONFormat: _json_format_sql,
1709        exp.JSONValueArray: _json_extract_value_array_sql,
1710        exp.Lateral: _explode_to_unnest_sql,
1711        exp.LogicalOr: lambda self, e: self.func("BOOL_OR", _cast_to_boolean(e.this)),
1712        exp.LogicalAnd: lambda self, e: self.func("BOOL_AND", _cast_to_boolean(e.this)),
1713        exp.Select: transforms.preprocess(
1714            [connect_by_to_recursive_cte, _seq_to_range_in_generator]
1715        ),
1716        exp.Seq1: lambda self, e: _seq_sql(self, e, 1),
1717        exp.Seq2: lambda self, e: _seq_sql(self, e, 2),
1718        exp.Seq4: lambda self, e: _seq_sql(self, e, 4),
1719        exp.Seq8: lambda self, e: _seq_sql(self, e, 8),
1720        exp.BoolxorAgg: _boolxor_agg_sql,
1721        exp.MakeInterval: lambda self, e: no_make_interval_sql(self, e, sep=" "),
1722        exp.Initcap: _initcap_sql,
1723        exp.MD5Digest: lambda self, e: self.func("UNHEX", self.func("MD5", e.this)),
1724        exp.SHA: lambda self, e: _sha_sql(self, e, "SHA1"),
1725        exp.SHA1Digest: lambda self, e: _sha_sql(self, e, "SHA1", is_binary=True),
1726        exp.SHA2: lambda self, e: _sha_sql(self, e, "SHA256"),
1727        exp.SHA2Digest: lambda self, e: _sha_sql(self, e, "SHA256", is_binary=True),
1728        exp.MonthsBetween: months_between_sql,
1729        exp.NextDay: _day_navigation_sql,
1730        exp.PercentileCont: rename_func("QUANTILE_CONT"),
1731        exp.PercentileDisc: rename_func("QUANTILE_DISC"),
1732        # DuckDB doesn't allow qualified columns inside of PIVOT expressions.
1733        # See: https://github.com/duckdb/duckdb/blob/671faf92411182f81dce42ac43de8bfb05d9909e/src/planner/binder/tableref/bind_pivot.cpp#L61-L62
1734        exp.Pivot: transforms.preprocess([transforms.unqualify_columns]),
1735        exp.PreviousDay: _day_navigation_sql,
1736        exp.RegexpILike: lambda self, e: self.func(
1737            "REGEXP_MATCHES", e.this, e.expression, exp.Literal.string("i")
1738        ),
1739        exp.RegexpSplit: rename_func("STR_SPLIT_REGEX"),
1740        exp.RegrValx: _regr_val_sql,
1741        exp.RegrValy: _regr_val_sql,
1742        exp.Return: lambda self, e: self.sql(e, "this"),
1743        exp.ReturnsProperty: lambda self, e: "TABLE" if isinstance(e.this, exp.Schema) else "",
1744        exp.StrToUnix: lambda self, e: self.func(
1745            "EPOCH", self.func("STRPTIME", e.this, self.format_time(e))
1746        ),
1747        exp.Struct: _struct_sql,
1748        exp.Transform: rename_func("LIST_TRANSFORM"),
1749        exp.TimeAdd: _date_delta_to_binary_interval_op(),
1750        exp.TimeSub: _date_delta_to_binary_interval_op(),
1751        exp.Time: no_time_sql,
1752        exp.TimeDiff: _timediff_sql,
1753        exp.Timestamp: no_timestamp_sql,
1754        exp.TimestampAdd: _date_delta_to_binary_interval_op(),
1755        exp.TimestampDiff: lambda self, e: self.func(
1756            "DATE_DIFF", exp.Literal.string(e.unit), e.expression, e.this
1757        ),
1758        exp.TimestampSub: _date_delta_to_binary_interval_op(),
1759        exp.TimeStrToDate: lambda self, e: self.sql(exp.cast(e.this, exp.DType.DATE)),
1760        exp.TimeStrToTime: timestrtotime_sql,
1761        exp.TimeStrToUnix: lambda self, e: self.func(
1762            "EPOCH", exp.cast(e.this, exp.DType.TIMESTAMP)
1763        ),
1764        exp.TimeToStr: lambda self, e: self.func("STRFTIME", e.this, self.format_time(e)),
1765        exp.ToBoolean: _to_boolean_sql,
1766        exp.ToVariant: lambda self, e: self.sql(
1767            exp.cast(e.this, exp.DataType.from_str("VARIANT", dialect="duckdb"))
1768        ),
1769        exp.TimeToUnix: rename_func("EPOCH"),
1770        exp.TsOrDiToDi: lambda self, e: (
1771            f"CAST(SUBSTR(REPLACE(CAST({self.sql(e, 'this')} AS TEXT), '-', ''), 1, 8) AS INT)"
1772        ),
1773        exp.TsOrDsAdd: _date_delta_to_binary_interval_op(),
1774        exp.TsOrDsDiff: lambda self, e: self.func(
1775            "DATE_DIFF",
1776            f"'{e.args.get('unit') or 'DAY'}'",
1777            exp.cast(e.expression, exp.DType.TIMESTAMP),
1778            exp.cast(e.this, exp.DType.TIMESTAMP),
1779        ),
1780        exp.UnixMicros: lambda self, e: self.func("EPOCH_US", _implicit_datetime_cast(e.this)),
1781        exp.UnixMillis: lambda self, e: self.func("EPOCH_MS", _implicit_datetime_cast(e.this)),
1782        exp.UnixSeconds: lambda self, e: self.sql(
1783            exp.cast(self.func("EPOCH", _implicit_datetime_cast(e.this)), exp.DType.BIGINT)
1784        ),
1785        exp.UnixToStr: lambda self, e: self.func(
1786            "STRFTIME", self.func("TO_TIMESTAMP", e.this), self.format_time(e)
1787        ),
1788        exp.UnixToTime: _unix_to_time_sql,
1789        exp.UnixToTimeStr: lambda self, e: f"CAST(TO_TIMESTAMP({self.sql(e, 'this')}) AS TEXT)",
1790        exp.VariancePop: rename_func("VAR_POP"),
1791        exp.WeekOfYear: rename_func("WEEKOFYEAR"),
1792        exp.YearOfWeek: lambda self, e: self.sql(
1793            exp.Extract(
1794                this=exp.Var(this="ISOYEAR"),
1795                expression=e.this,
1796            )
1797        ),
1798        exp.YearOfWeekIso: lambda self, e: self.sql(
1799            exp.Extract(
1800                this=exp.Var(this="ISOYEAR"),
1801                expression=e.this,
1802            )
1803        ),
1804        exp.Xor: _xor_sql,
1805        exp.JSONObjectAgg: rename_func("JSON_GROUP_OBJECT"),
1806        exp.JSONBObjectAgg: rename_func("JSON_GROUP_OBJECT"),
1807        exp.DateBin: rename_func("TIME_BUCKET"),
1808        exp.LastDay: _last_day_sql,
1809    }
1810
1811    SUPPORTED_JSON_PATH_PARTS = {
1812        exp.JSONPathKey,
1813        exp.JSONPathRoot,
1814        exp.JSONPathSubscript,
1815        exp.JSONPathWildcard,
1816    }
1817
1818    TYPE_MAPPING = {
1819        **generator.Generator.TYPE_MAPPING,
1820        exp.DType.BINARY: "BLOB",
1821        exp.DType.BPCHAR: "TEXT",
1822        exp.DType.CHAR: "TEXT",
1823        exp.DType.DATETIME: "TIMESTAMP",
1824        exp.DType.DECFLOAT: "DECIMAL",
1825        exp.DType.FLOAT: "REAL",
1826        exp.DType.JSONB: "JSON",
1827        exp.DType.NCHAR: "TEXT",
1828        exp.DType.NVARCHAR: "TEXT",
1829        exp.DType.UINT: "UINTEGER",
1830        exp.DType.VARBINARY: "BLOB",
1831        exp.DType.ROWVERSION: "BLOB",
1832        exp.DType.VARCHAR: "TEXT",
1833        exp.DType.TIMESTAMPLTZ: "TIMESTAMPTZ",
1834        exp.DType.TIMESTAMPNTZ: "TIMESTAMP",
1835        exp.DType.TIMESTAMP_S: "TIMESTAMP_S",
1836        exp.DType.TIMESTAMP_MS: "TIMESTAMP_MS",
1837        exp.DType.TIMESTAMP_NS: "TIMESTAMP_NS",
1838        exp.DType.BIGDECIMAL: "DECIMAL",
1839    }
1840
1841    TYPE_PARAM_SETTINGS = {
1842        **generator.Generator.TYPE_PARAM_SETTINGS,
1843        exp.DType.BIGDECIMAL: ((38, 5), (38, 38)),
1844        exp.DType.DECFLOAT: ((38, 5), (38, 38)),
1845    }
1846
1847    # https://github.com/duckdb/duckdb/blob/ff7f24fd8e3128d94371827523dae85ebaf58713/third_party/libpg_query/grammar/keywords/reserved_keywords.list#L1-L77
1848    RESERVED_KEYWORDS = {
1849        "array",
1850        "analyse",
1851        "union",
1852        "all",
1853        "when",
1854        "in_p",
1855        "default",
1856        "create_p",
1857        "window",
1858        "asymmetric",
1859        "to",
1860        "else",
1861        "localtime",
1862        "from",
1863        "end_p",
1864        "select",
1865        "current_date",
1866        "foreign",
1867        "with",
1868        "grant",
1869        "session_user",
1870        "or",
1871        "except",
1872        "references",
1873        "fetch",
1874        "limit",
1875        "group_p",
1876        "leading",
1877        "into",
1878        "collate",
1879        "offset",
1880        "do",
1881        "then",
1882        "localtimestamp",
1883        "check_p",
1884        "lateral_p",
1885        "current_role",
1886        "where",
1887        "asc_p",
1888        "placing",
1889        "desc_p",
1890        "user",
1891        "unique",
1892        "initially",
1893        "column",
1894        "both",
1895        "some",
1896        "as",
1897        "any",
1898        "only",
1899        "deferrable",
1900        "null_p",
1901        "current_time",
1902        "true_p",
1903        "table",
1904        "case",
1905        "trailing",
1906        "variadic",
1907        "for",
1908        "on",
1909        "distinct",
1910        "false_p",
1911        "not",
1912        "constraint",
1913        "current_timestamp",
1914        "returning",
1915        "primary",
1916        "intersect",
1917        "having",
1918        "analyze",
1919        "current_user",
1920        "and",
1921        "cast",
1922        "symmetric",
1923        "using",
1924        "order",
1925        "current_catalog",
1926    }
1927
1928    UNWRAPPED_INTERVAL_VALUES = (exp.Literal, exp.Paren)
1929
1930    # DuckDB doesn't generally support CREATE TABLE .. properties
1931    # https://duckdb.org/docs/sql/statements/create_table.html
1932    # There are a few exceptions (e.g. temporary tables) which are supported or
1933    # can be transpiled to DuckDB, so we explicitly override them accordingly
1934    PROPERTIES_LOCATION = {
1935        **{
1936            prop: exp.Properties.Location.UNSUPPORTED
1937            for prop in generator.Generator.PROPERTIES_LOCATION
1938        },
1939        exp.LikeProperty: exp.Properties.Location.POST_SCHEMA,
1940        exp.TemporaryProperty: exp.Properties.Location.POST_CREATE,
1941        exp.ReturnsProperty: exp.Properties.Location.POST_ALIAS,
1942        exp.SequenceProperties: exp.Properties.Location.POST_EXPRESSION,
1943        exp.IcebergProperty: exp.Properties.Location.POST_CREATE,
1944    }
1945
1946    IGNORE_RESPECT_NULLS_WINDOW_FUNCTIONS: t.ClassVar = _IGNORE_RESPECT_NULLS_WINDOW_FUNCTIONS
1947
1948    # Template for ZIPF transpilation - placeholders get replaced with actual parameters
1949    ZIPF_TEMPLATE: t.ClassVar[exp.Expr] = exp.maybe_parse(
1950        """
1951        WITH rand AS (SELECT :random_expr AS r),
1952        weights AS (
1953            SELECT i, 1.0 / POWER(i, :s) AS w
1954            FROM RANGE(1, :n + 1) AS t(i)
1955        ),
1956        cdf AS (
1957            SELECT i, SUM(w) OVER (ORDER BY i) / SUM(w) OVER () AS p
1958            FROM weights
1959        )
1960        SELECT MIN(i)
1961        FROM cdf
1962        WHERE p >= (SELECT r FROM rand)
1963        """
1964    )
1965
1966    # Template for NORMAL transpilation using Box-Muller transform
1967    # mean + (stddev * sqrt(-2 * ln(u1)) * cos(2 * pi * u2))
1968    NORMAL_TEMPLATE: t.ClassVar[exp.Expr] = exp.maybe_parse(
1969        ":mean + (:stddev * SQRT(-2 * LN(GREATEST(:u1, 1e-10))) * COS(2 * PI() * :u2))"
1970    )
1971
1972    # Template for generating a seeded pseudo-random value in [0, 1) from a hash
1973    SEEDED_RANDOM_TEMPLATE: t.ClassVar[exp.Expr] = exp.maybe_parse(
1974        "(ABS(HASH(:seed)) % 1000000) / 1000000.0"
1975    )
1976
1977    # Template for generating signed and unsigned SEQ values within a specified range
1978    SEQ_UNSIGNED: t.ClassVar[exp.Expr] = _SEQ_UNSIGNED
1979    SEQ_SIGNED: t.ClassVar[exp.Expr] = _SEQ_SIGNED
1980
1981    # Template for MAP_CAT transpilation - Snowflake semantics:
1982    # 1. Returns NULL if either input is NULL
1983    # 2. For duplicate keys, prefers non-NULL value (COALESCE(m2[k], m1[k]))
1984    # 3. Filters out entries with NULL values from the result
1985    MAPCAT_TEMPLATE: t.ClassVar[exp.Expr] = exp.maybe_parse(
1986        """
1987        CASE
1988            WHEN :map1 IS NULL OR :map2 IS NULL THEN NULL
1989            ELSE MAP_FROM_ENTRIES(LIST_FILTER(LIST_TRANSFORM(
1990                LIST_DISTINCT(LIST_CONCAT(MAP_KEYS(:map1), MAP_KEYS(:map2))),
1991                __k -> STRUCT_PACK(key := __k, value := COALESCE(:map2[__k], :map1[__k]))
1992            ), __x -> __x.value IS NOT NULL))
1993        END
1994        """
1995    )
1996
1997    # Mappings for EXTRACT/DATE_PART transpilation
1998    # Maps Snowflake specifiers unsupported in DuckDB to strftime format codes
1999    EXTRACT_STRFTIME_MAPPINGS: t.ClassVar[dict[str, tuple[str, str]]] = {
2000        "WEEKISO": ("%V", "INTEGER"),
2001        "YEAROFWEEK": ("%G", "INTEGER"),
2002        "YEAROFWEEKISO": ("%G", "INTEGER"),
2003        "NANOSECOND": ("%n", "BIGINT"),
2004    }
2005
2006    # Maps epoch-based specifiers to DuckDB epoch functions
2007    EXTRACT_EPOCH_MAPPINGS: t.ClassVar[dict[str, str]] = {
2008        "EPOCH_SECOND": "EPOCH",
2009        "EPOCH_MILLISECOND": "EPOCH_MS",
2010        "EPOCH_MICROSECOND": "EPOCH_US",
2011        "EPOCH_NANOSECOND": "EPOCH_NS",
2012    }
2013
2014    # Template for BITMAP_CONSTRUCT_AGG transpilation
2015    #
2016    # BACKGROUND:
2017    # Snowflake's BITMAP_CONSTRUCT_AGG aggregates integers into a compact binary bitmap.
2018    # Supports values in range 0-32767, this version returns NULL if any value is out of range
2019    # See: https://docs.snowflake.com/en/sql-reference/functions/bitmap_construct_agg
2020    # See: https://docs.snowflake.com/en/user-guide/querying-bitmaps-for-distinct-counts
2021    #
2022    # Snowflake uses two different formats based on the number of unique values:
2023    #
2024    # Format 1 - Small bitmap (< 5 unique values): Length of 10 bytes
2025    #   Bytes 0-1: Count of values as 2-byte big-endian integer (e.g., 3 values = 0x0003)
2026    #   Bytes 2-9: Up to 4 values, each as 2-byte little-endian integers, zero-padded to 8 bytes
2027    #   Example: Values [1, 2, 3] -> 0x0003 0100 0200 0300 0000 (hex)
2028    #                                count  v1   v2   v3   pad
2029    #
2030    # Format 2 - Large bitmap (>= 5 unique values): Length of 10 + (2 * count) bytes
2031    #   Bytes 0-9: Fixed header 0x08 followed by 9 zero bytes
2032    #   Bytes 10+: Each value as 2-byte little-endian integer (no padding)
2033    #   Example: Values [1,2,3,4,5] -> 0x08 00000000 00000000 00 0100 0200 0300 0400 0500
2034    #                                  hdr  ----9 zero bytes----  v1   v2   v3   v4   v5
2035    #
2036    # TEMPLATE STRUCTURE
2037    #
2038    # Phase 1 - Innermost subquery: Data preparation
2039    #   SELECT LIST_SORT(...) AS l
2040    #   - Aggregates all input values into a list, remove NULLs, duplicates and sorts
2041    #   Result: Clean, sorted list of unique non-null integers stored as 'l'
2042    #
2043    # Phase 2 - Middle subquery: Hex string construction
2044    #   LIST_TRANSFORM(...)
2045    #   - Converts each integer to 2-byte little-endian hex representation
2046    #   - & 255 extracts low byte, >> 8 extracts high byte
2047    #   - LIST_REDUCE: Concatenates all hex pairs into single string 'h'
2048    #   Result: Hex string of all values
2049    #
2050    # Phase 3 - Outer SELECT: Final bitmap assembly
2051    #   LENGTH(l) < 5:
2052    #   - Small format: 2-byte count (big-endian via %04X) + values + zero padding
2053    #   LENGTH(l) >= 5:
2054    #   - Large format: Fixed 10-byte header + values (no padding needed)
2055    #   Result: Complete binary bitmap as BLOB
2056    #
2057    BITMAP_CONSTRUCT_AGG_TEMPLATE: t.ClassVar[exp.Expr] = exp.maybe_parse(
2058        """
2059        SELECT CASE
2060            WHEN l IS NULL OR LENGTH(l) = 0 THEN NULL
2061            WHEN LENGTH(l) != LENGTH(LIST_FILTER(l, __v -> __v BETWEEN 0 AND 32767)) THEN NULL
2062            WHEN LENGTH(l) < 5 THEN UNHEX(PRINTF('%04X', LENGTH(l)) || h || REPEAT('00', GREATEST(0, 4 - LENGTH(l)) * 2))
2063            ELSE UNHEX('08000000000000000000' || h)
2064        END
2065        FROM (
2066            SELECT l, COALESCE(LIST_REDUCE(
2067                LIST_TRANSFORM(l, __x -> PRINTF('%02X%02X', CAST(__x AS INT) & 255, (CAST(__x AS INT) >> 8) & 255)),
2068                (__a, __b) -> __a || __b, ''
2069            ), '') AS h
2070            FROM (SELECT LIST_SORT(LIST_DISTINCT(LIST(:arg) FILTER(NOT :arg IS NULL))) AS l)
2071        )
2072        """
2073    )
2074
2075    # Template for RANDSTR transpilation - placeholders get replaced with actual parameters
2076    RANDSTR_TEMPLATE: t.ClassVar[exp.Expr] = exp.maybe_parse(
2077        f"""
2078        SELECT LISTAGG(
2079            SUBSTRING(
2080                '{RANDSTR_CHAR_POOL}',
2081                1 + CAST(FLOOR(random_value * 62) AS INT),
2082                1
2083            ),
2084            ''
2085        )
2086        FROM (
2087            SELECT (ABS(HASH(i + :seed)) % 1000) / 1000.0 AS random_value
2088            FROM RANGE(:length) AS t(i)
2089        )
2090        """,
2091    )
2092
2093    # Template for MINHASH transpilation
2094    # Computes k minimum hash values across aggregated data using DuckDB list functions
2095    # Returns JSON matching Snowflake format: {"state": [...], "type": "minhash", "version": 1}
2096    MINHASH_TEMPLATE: t.ClassVar[exp.Expr] = exp.maybe_parse(
2097        """
2098        SELECT JSON_OBJECT('state', LIST(min_h ORDER BY seed), 'type', 'minhash', 'version', 1)
2099        FROM (
2100            SELECT seed, LIST_MIN(LIST_TRANSFORM(vals, __v -> HASH(CAST(__v AS VARCHAR) || CAST(seed AS VARCHAR)))) AS min_h
2101            FROM (SELECT LIST(:expr) AS vals), RANGE(0, :k) AS t(seed)
2102        )
2103        """,
2104    )
2105
2106    # Template for MINHASH_COMBINE transpilation
2107    # Combines multiple minhash signatures by taking element-wise minimum
2108    MINHASH_COMBINE_TEMPLATE: t.ClassVar[exp.Expr] = exp.maybe_parse(
2109        """
2110        SELECT JSON_OBJECT('state', LIST(min_h ORDER BY idx), 'type', 'minhash', 'version', 1)
2111        FROM (
2112            SELECT
2113                pos AS idx,
2114                MIN(val) AS min_h
2115            FROM
2116                UNNEST(LIST(:expr)) AS _(sig),
2117                UNNEST(CAST(sig -> 'state' AS UBIGINT[])) WITH ORDINALITY AS t(val, pos)
2118            GROUP BY pos
2119        )
2120        """,
2121    )
2122
2123    # Template for APPROXIMATE_SIMILARITY transpilation
2124    # Computes multi-way Jaccard similarity: fraction of positions where ALL signatures agree
2125    APPROXIMATE_SIMILARITY_TEMPLATE: t.ClassVar[exp.Expr] = exp.maybe_parse(
2126        """
2127        SELECT CAST(SUM(CASE WHEN num_distinct = 1 THEN 1 ELSE 0 END) AS DOUBLE) / COUNT(*)
2128        FROM (
2129            SELECT pos, COUNT(DISTINCT h) AS num_distinct
2130            FROM (
2131                SELECT h, pos
2132                FROM UNNEST(LIST(:expr)) AS _(sig),
2133                     UNNEST(CAST(sig -> 'state' AS UBIGINT[])) WITH ORDINALITY AS s(h, pos)
2134            )
2135            GROUP BY pos
2136        )
2137        """,
2138    )
2139
2140    # Template for ARRAYS_ZIP transpilation
2141    # Snowflake pads to longest array; DuckDB LIST_ZIP truncates to shortest
2142    # Uses RANGE + indexing to match Snowflake behavior
2143    ARRAYS_ZIP_TEMPLATE: t.ClassVar[exp.Expr] = exp.maybe_parse(
2144        """
2145        CASE WHEN :null_check THEN NULL
2146        WHEN :all_empty_check THEN [:empty_struct]
2147        ELSE LIST_TRANSFORM(RANGE(0, :max_len), __i -> :transform_struct)
2148        END
2149        """,
2150    )
2151
2152    UUID_V5_TEMPLATE: t.ClassVar[exp.Expr] = exp.maybe_parse(
2153        """
2154        (SELECT
2155            LOWER(
2156                SUBSTR(h, 1, 8) || '-' ||
2157                SUBSTR(h, 9, 4) || '-' ||
2158                '5' || SUBSTR(h, 14, 3) || '-' ||
2159                FORMAT('{:02x}', CAST('0x' || SUBSTR(h, 17, 2) AS INT) & 63 | 128) || SUBSTR(h, 19, 2) || '-' ||
2160                SUBSTR(h, 21, 12)
2161            )
2162        FROM (
2163            SELECT SUBSTR(SHA1(UNHEX(REPLACE(:namespace, '-', '')) || ENCODE(:name, 'utf8')), 1, 32) AS h
2164        ))
2165        """
2166    )
2167
2168    # Shared bag semantics outer frame for ARRAY_EXCEPT and ARRAY_INTERSECTION.
2169    # Each element is paired with its 1-based position via LIST_ZIP, then filtered
2170    # by a comparison operator (supplied via :cond) that determines the operation:
2171    #   EXCEPT (>):        keep the N-th occurrence only if N > count in arr2
2172    #                      e.g. [2,2,2] EXCEPT [2,2] -> [2]
2173    #   INTERSECTION (<=): keep the N-th occurrence only if N <= count in arr2
2174    #                      e.g. [2,2,2] INTERSECT [2,2] -> [2,2]
2175    # IS NOT DISTINCT FROM is used for NULL-safe element comparison.
2176    ARRAY_BAG_TEMPLATE: t.ClassVar[exp.Expr] = exp.maybe_parse(
2177        """
2178        CASE
2179            WHEN :arr1 IS NULL OR :arr2 IS NULL THEN NULL
2180            ELSE LIST_TRANSFORM(
2181                LIST_FILTER(
2182                    LIST_ZIP(:arr1, GENERATE_SERIES(1, LEN(:arr1))),
2183                    pair -> :cond
2184                ),
2185                pair -> pair[0]
2186            )
2187        END
2188        """
2189    )
2190
2191    ARRAY_EXCEPT_CONDITION: t.ClassVar[exp.Expr] = exp.maybe_parse(
2192        "LEN(LIST_FILTER(:arr1[1:pair[1]], e -> e IS NOT DISTINCT FROM pair[0]))"
2193        " > LEN(LIST_FILTER(:arr2, e -> e IS NOT DISTINCT FROM pair[0]))"
2194    )
2195
2196    ARRAY_INTERSECTION_CONDITION: t.ClassVar[exp.Expr] = exp.maybe_parse(
2197        "LEN(LIST_FILTER(:arr1[1:pair[1]], e -> e IS NOT DISTINCT FROM pair[0]))"
2198        " <= LEN(LIST_FILTER(:arr2, e -> e IS NOT DISTINCT FROM pair[0]))"
2199    )
2200
2201    # Set semantics for ARRAY_EXCEPT. Deduplicates arr1 via LIST_DISTINCT, then
2202    # filters out any element that appears at least once in arr2.
2203    #   e.g. [1,1,2,3] EXCEPT [1] -> [2,3]
2204    # IS NOT DISTINCT FROM is used for NULL-safe element comparison.
2205    ARRAY_EXCEPT_SET_TEMPLATE: t.ClassVar[exp.Expr] = exp.maybe_parse(
2206        """
2207        CASE
2208            WHEN :arr1 IS NULL OR :arr2 IS NULL THEN NULL
2209            ELSE LIST_FILTER(
2210                LIST_DISTINCT(:arr1),
2211                e -> LEN(LIST_FILTER(:arr2, x -> x IS NOT DISTINCT FROM e)) = 0
2212            )
2213        END
2214        """
2215    )
2216
2217    # BigQuery's `x IN UNNEST(arr)` NULL semantics:
2218    #   NULL IN UNNEST([1, 2])  -> NULL
2219    #   3 IN UNNEST([1, NULL])  -> NULL
2220    #   3 IN UNNEST([1, 2])     -> FALSE
2221    #   1 IN UNNEST(NULL)       -> FALSE (not NULL)
2222    #   1 IN UNNEST([])         -> FALSE
2223    # The default `IN (SELECT UNNEST(...))` rewrite creates a correlated subquery
2224    # that DuckDB rejects inside non-inner joins, so a CASE expression is used instead.
2225    IN_UNNEST_TEMPLATE: t.ClassVar[exp.Expr] = exp.maybe_parse(
2226        """
2227        CASE
2228            WHEN :arr IS NULL OR ARRAY_LENGTH(:arr) = 0 THEN FALSE
2229            WHEN ARRAY_CONTAINS(:arr, :value) THEN TRUE
2230            WHEN :value IS NULL OR ARRAY_LENGTH(:arr) <> LIST_COUNT(:arr) THEN NULL
2231            ELSE FALSE
2232        END
2233        """
2234    )
2235
2236    STRTOK_TO_ARRAY_TEMPLATE: t.ClassVar[exp.Expr] = exp.maybe_parse(
2237        """
2238        CASE WHEN :delimiter IS NULL THEN NULL
2239        ELSE LIST_FILTER(
2240            REGEXP_SPLIT_TO_ARRAY(:string, CASE WHEN :delimiter = '' THEN '.^' ELSE CONCAT('[', :escaped, ']') END),
2241            x -> NOT x = ''
2242        ) END
2243        """
2244    )
2245
2246    # Template for STRTOK function transpilation
2247    #
2248    # DuckDB itself doesn't have a strtok function. This handles the transpilation from Snowflake to DuckDB.
2249    # We may need to adjust this if we want to support transpilation from other dialects
2250    #
2251    # CASE
2252    #     -- Snowflake: empty delimiter + empty input string -> NULL
2253    #     WHEN delimiter = '' AND input_str = '' THEN NULL
2254    #
2255    #     -- Snowflake: empty delimiter + non-empty input string -> treats whole input as 1 token -> return input string if index is 1
2256    #     WHEN delimiter = '' AND index = 1 THEN input_str
2257    #
2258    #     -- Snowflake: empty delimiter + non-empty input string -> treats whole input as 1 token -> return NULL if index is not 1
2259    #     WHEN delimiter = '' THEN NULL
2260    #
2261    #     -- Snowflake: negative indices return NULL
2262    #     WHEN index < 0 THEN NULL
2263    #
2264    #     -- Snowflake: return NULL if any argument is NULL
2265    #     WHEN input_str IS NULL OR delimiter IS NULL OR index IS NULL THEN NULL
2266    #
2267    #
2268    #     ELSE LIST_FILTER(
2269    #         REGEXP_SPLIT_TO_ARRAY(
2270    #             input_str,
2271    #             CASE
2272    #                 -- if delimiter is '', we don't want to surround it with '[' and ']' as '[]' is invalid for DuckDB
2273    #                 WHEN delimiter = '' THEN ''
2274    #
2275    #                 -- handle problematic regex characters in delimiter with REGEXP_REPLACE
2276    #                 -- turn delimiter into a regex char set, otherwise DuckDB will match in order, which we don't want
2277    #                 ELSE '[' || REGEXP_REPLACE(delimiter, problematic_char_set, '\\\1', 'g') || ']'
2278    #             END
2279    #         ),
2280    #
2281    #         -- Snowflake: don't return empty strings
2282    #         x -> NOT x = ''
2283    #     )[index]
2284    # END
2285    STRTOK_TEMPLATE: t.ClassVar[exp.Expr] = exp.maybe_parse(
2286        """
2287        CASE
2288            WHEN :delimiter = '' AND :string = '' THEN NULL
2289            WHEN :delimiter = '' AND :part_index = 1 THEN :string
2290            WHEN :delimiter = '' THEN NULL
2291            WHEN :part_index < 0 THEN NULL
2292            WHEN :string IS NULL OR :delimiter IS NULL OR :part_index IS NULL THEN NULL
2293            ELSE :base_func
2294        END
2295        """
2296    )
2297
2298    # Snowflake AUTO detects 3 DATE formats: YYYY-MM-DD (ISO-8601), MM/DD/YYYY, DD-MON-YYYY.
2299    # DuckDB TRY_CAST handles ISO-8601 natively. For the other two formats we use CONTAINS('/')
2300    # and REGEXP_MATCHES('[A-Za-z]') as heuristics — these correctly handle single-digit months
2301    # and days (e.g. 1/5/2020, 5-JAN-2020) where a positional char check would fail.
2302    # Ref: https://docs.snowflake.com/en/sql-reference/date-time-input-output#date-formats
2303    _TRYCAST_DATE_SLASH_FMT = "%m/%d/%Y"
2304    _TRYCAST_DATE_MON_FMT = "%d-%b-%Y"
2305
2306    def datatype_sql(self, expression: exp.DataType) -> str:
2307        if expression.is_type(exp.DType.ARRAY):
2308            return f"{self.expressions(expression, flat=True)}[{self.expressions(expression, key='values', flat=True)}]"
2309
2310        # Modifiers are not supported for TIME, [TIME | TIMESTAMP] WITH TIME ZONE
2311        if expression.is_type(
2312            exp.DType.TIME, exp.DType.TIMETZ, exp.DType.TIMESTAMPTZ, exp.DType.TIMESTAMPLTZ
2313        ):
2314            expression.set("expressions", None)
2315
2316        return super().datatype_sql(expression)
2317
2318    def _array_bag_sql(self, condition: exp.Expr, arr1: exp.Expr, arr2: exp.Expr) -> str:
2319        cond = exp.Paren(this=exp.replace_placeholders(condition, arr1=arr1, arr2=arr2))
2320        return self.sql(
2321            exp.replace_placeholders(self.ARRAY_BAG_TEMPLATE, arr1=arr1, arr2=arr2, cond=cond)
2322        )
2323
2324    def timeslice_sql(self, expression: exp.TimeSlice) -> str:
2325        """
2326        Transform Snowflake's TIME_SLICE to DuckDB's time_bucket.
2327
2328        Snowflake: TIME_SLICE(date_expr, slice_length, 'UNIT' [, 'START'|'END'])
2329        DuckDB:    time_bucket(INTERVAL 'slice_length' UNIT, date_expr)
2330
2331        For 'END' kind, add the interval to get the end of the slice.
2332        For DATE type with 'END', cast result back to DATE to preserve type.
2333        """
2334        date_expr = expression.this
2335        slice_length = expression.expression
2336        unit = expression.unit
2337        kind = expression.text("kind").upper()
2338
2339        # Create INTERVAL expression: INTERVAL 'N' UNIT
2340        interval_expr = exp.Interval(this=slice_length, unit=unit)
2341
2342        # Create base time_bucket expression
2343        time_bucket_expr = exp.func("time_bucket", interval_expr, date_expr)
2344
2345        # Check if we need the end of the slice (default is start)
2346        if not kind == "END":
2347            # For 'START', return time_bucket directly
2348            return self.sql(time_bucket_expr)
2349
2350        # For 'END', add the interval to get end of slice
2351        add_expr = exp.Add(this=time_bucket_expr, expression=interval_expr.copy())
2352
2353        # If input is DATE type, cast result back to DATE to preserve type
2354        # DuckDB converts DATE to TIMESTAMP when adding intervals
2355        if date_expr.is_type(exp.DType.DATE):
2356            return self.sql(exp.cast(add_expr, exp.DType.DATE))
2357
2358        return self.sql(add_expr)
2359
2360    def bytestring_sql(self, expression: exp.ByteString) -> str:
2361        if not expression.args.get("is_bytes"):
2362            return super().bytestring_sql(expression)
2363
2364        # Each char is a byte. Casting to BLOB decodes \xHH, so everything but printable ASCII
2365        # (including the backslash itself) is written as such
2366        text = "".join(
2367            ch if ch.isascii() and ch.isprintable() and ch != "\\" else f"\\x{ord(ch):02x}"
2368            for ch in expression.this
2369        )
2370        return self.sql(exp.cast(exp.Literal.string(text), exp.DType.BINARY))
2371
2372    def bitmapbucketnumber_sql(self, expression: exp.BitmapBucketNumber) -> str:
2373        """
2374        Transpile BITMAP_BUCKET_NUMBER function from Snowflake to DuckDB equivalent.
2375
2376        Snowflake's BITMAP_BUCKET_NUMBER returns a 1-based bucket identifier where:
2377        - Each bucket covers 32,768 values
2378        - Bucket numbering starts at 1
2379        - Formula: ((value - 1) // 32768) + 1 for positive values
2380
2381        For non-positive values (0 and negative), we use value // 32768 to avoid
2382        producing bucket 0 or positive bucket IDs for negative inputs.
2383        """
2384        value = expression.this
2385
2386        positive_formula = ((value - 1) // 32768) + 1
2387        non_positive_formula = value // 32768
2388
2389        # CASE WHEN value > 0 THEN ((value - 1) // 32768) + 1 ELSE value // 32768 END
2390        case_expr = (
2391            exp.case()
2392            .when(exp.GT(this=value, expression=exp.Literal.number(0)), positive_formula)
2393            .else_(non_positive_formula)
2394        )
2395        return self.sql(case_expr)
2396
2397    def bitmapbitposition_sql(self, expression: exp.BitmapBitPosition) -> str:
2398        """
2399        Transpile Snowflake's BITMAP_BIT_POSITION to DuckDB CASE expression.
2400
2401        Snowflake's BITMAP_BIT_POSITION behavior:
2402        - For n <= 0: returns ABS(n) % 32768
2403        - For n > 0: returns (n - 1) % 32768 (maximum return value is 32767)
2404        """
2405        this = expression.this
2406
2407        return self.sql(
2408            exp.Mod(
2409                this=exp.Paren(
2410                    this=exp.If(
2411                        this=exp.GT(this=this, expression=exp.Literal.number(0)),
2412                        true=this - exp.Literal.number(1),
2413                        false=exp.Abs(this=this),
2414                    )
2415                ),
2416                expression=MAX_BIT_POSITION,
2417            )
2418        )
2419
2420    def bitmapconstructagg_sql(self, expression: exp.BitmapConstructAgg) -> str:
2421        """
2422        Transpile Snowflake's BITMAP_CONSTRUCT_AGG to DuckDB equivalent.
2423        Uses a pre-parsed template with placeholders replaced by expression nodes.
2424
2425        Snowflake bitmap format:
2426        - Small (< 5 unique values): 2-byte count (big-endian) + values (little-endian) + padding to 10 bytes
2427        - Large (>= 5 unique values): 10-byte header (0x08 + 9 zeros) + values (little-endian)
2428        """
2429        arg = expression.this
2430        return (
2431            f"({self.sql(exp.replace_placeholders(self.BITMAP_CONSTRUCT_AGG_TEMPLATE, arg=arg))})"
2432        )
2433
2434    def getignorecase_sql(self, expression: exp.GetIgnoreCase) -> str:
2435        self.unsupported("DuckDB does not support the GET_IGNORE_CASE() function")
2436        return self.function_fallback_sql(expression)
2437
2438    def compress_sql(self, expression: exp.Compress) -> str:
2439        self.unsupported("DuckDB does not support the COMPRESS() function")
2440        return self.function_fallback_sql(expression)
2441
2442    def encrypt_sql(self, expression: exp.Encrypt) -> str:
2443        self.unsupported("ENCRYPT is not supported in DuckDB")
2444        return self.function_fallback_sql(expression)
2445
2446    def decrypt_sql(self, expression: exp.Decrypt) -> str:
2447        func_name = "TRY_DECRYPT" if expression.args.get("safe") else "DECRYPT"
2448        self.unsupported(f"{func_name} is not supported in DuckDB")
2449        return self.function_fallback_sql(expression)
2450
2451    def decryptraw_sql(self, expression: exp.DecryptRaw) -> str:
2452        func_name = "TRY_DECRYPT_RAW" if expression.args.get("safe") else "DECRYPT_RAW"
2453        self.unsupported(f"{func_name} is not supported in DuckDB")
2454        return self.function_fallback_sql(expression)
2455
2456    def encryptraw_sql(self, expression: exp.EncryptRaw) -> str:
2457        self.unsupported("ENCRYPT_RAW is not supported in DuckDB")
2458        return self.function_fallback_sql(expression)
2459
2460    def parseurl_sql(self, expression: exp.ParseUrl) -> str:
2461        self.unsupported("PARSE_URL is not supported in DuckDB")
2462        return self.function_fallback_sql(expression)
2463
2464    def parseip_sql(self, expression: exp.ParseIp) -> str:
2465        self.unsupported("PARSE_IP is not supported in DuckDB")
2466        return self.function_fallback_sql(expression)
2467
2468    def decompressstring_sql(self, expression: exp.DecompressString) -> str:
2469        self.unsupported("DECOMPRESS_STRING is not supported in DuckDB")
2470        return self.function_fallback_sql(expression)
2471
2472    def decompressbinary_sql(self, expression: exp.DecompressBinary) -> str:
2473        self.unsupported("DECOMPRESS_BINARY is not supported in DuckDB")
2474        return self.function_fallback_sql(expression)
2475
2476    def jarowinklersimilarity_sql(self, expression: exp.JarowinklerSimilarity) -> str:
2477        this = expression.this
2478        expr = expression.expression
2479
2480        if expression.args.get("case_insensitive"):
2481            this = exp.Upper(this=this)
2482            expr = exp.Upper(this=expr)
2483
2484        result = exp.func("JARO_WINKLER_SIMILARITY", this, expr)
2485
2486        if expression.args.get("integer_scale"):
2487            result = exp.cast(result * 100, "INTEGER")
2488
2489        return self.sql(result)
2490
2491    def randstr_sql(self, expression: exp.Randstr) -> str:
2492        """
2493        Transpile Snowflake's RANDSTR to DuckDB equivalent using deterministic hash-based random.
2494        Uses a pre-parsed template with placeholders replaced by expression nodes.
2495
2496        RANDSTR(length, generator) generates a random string of specified length.
2497        - With numeric seed: Use HASH(i + seed) for deterministic output (same seed = same result)
2498        - With RANDOM(): Use RANDOM() in the hash for non-deterministic output
2499        - No generator: Use default seed value
2500        """
2501        length = expression.this
2502        generator = expression.args.get("generator")
2503
2504        if generator:
2505            if isinstance(generator, exp.Rand):
2506                # If it's RANDOM(), use its seed if available, otherwise use RANDOM() itself
2507                seed_value = generator.this or generator
2508            else:
2509                # Const/int or other expression - use as seed directly
2510                seed_value = generator
2511        else:
2512            # No generator specified, use default seed (arbitrary but deterministic)
2513            seed_value = exp.Literal.number(RANDSTR_SEED)
2514
2515        replacements = {"seed": seed_value, "length": length}
2516        return f"({self.sql(exp.replace_placeholders(self.RANDSTR_TEMPLATE, **replacements))})"
2517
2518    @unsupported_args("finish")
2519    def reduce_sql(self, expression: exp.Reduce) -> str:
2520        array_arg = expression.this
2521        initial_value = expression.args.get("initial")
2522        merge_lambda = expression.args.get("merge")
2523
2524        if merge_lambda:
2525            merge_lambda.set("colon", True)
2526
2527        return self.func("list_reduce", array_arg, merge_lambda, initial_value)
2528
2529    def zipf_sql(self, expression: exp.Zipf) -> str:
2530        """
2531        Transpile Snowflake's ZIPF to DuckDB using CDF-based inverse sampling.
2532        Uses a pre-parsed template with placeholders replaced by expression nodes.
2533        """
2534        s = expression.this
2535        n = expression.args["elementcount"]
2536        gen = expression.args["gen"]
2537
2538        if not isinstance(gen, exp.Rand):
2539            # (ABS(HASH(seed)) % 1000000) / 1000000.0
2540            random_expr: exp.Expr = exp.Div(
2541                this=exp.Paren(
2542                    this=exp.Mod(
2543                        this=exp.Abs(this=exp.Anonymous(this="HASH", expressions=[gen.copy()])),
2544                        expression=exp.Literal.number(1000000),
2545                    )
2546                ),
2547                expression=exp.Literal.number(1000000.0),
2548            )
2549        else:
2550            # Use RANDOM() for non-deterministic output
2551            random_expr = exp.Rand()
2552
2553        replacements = {"s": s, "n": n, "random_expr": random_expr}
2554        return f"({self.sql(exp.replace_placeholders(self.ZIPF_TEMPLATE, **replacements))})"
2555
2556    def tobinary_sql(self, expression: exp.ToBinary) -> str:
2557        """
2558        TO_BINARY and TRY_TO_BINARY transpilation:
2559        - 'HEX': TO_BINARY('48454C50', 'HEX') -> UNHEX('48454C50')
2560        - 'UTF-8': TO_BINARY('TEST', 'UTF-8') -> ENCODE('TEST')
2561        - 'BASE64': TO_BINARY('SEVMUA==', 'BASE64') -> FROM_BASE64('SEVMUA==')
2562
2563        For TRY_TO_BINARY (safe=True), wrap with TRY():
2564        - 'HEX': TRY_TO_BINARY('invalid', 'HEX') -> TRY(UNHEX('invalid'))
2565        """
2566        value = expression.this
2567        format_arg = expression.args.get("format")
2568        is_safe = expression.args.get("safe")
2569        is_binary = _is_binary(expression)
2570
2571        if not format_arg and not is_binary:
2572            func_name = "TRY_TO_BINARY" if is_safe else "TO_BINARY"
2573            return self.func(func_name, value)
2574
2575        # Snowflake defaults to HEX encoding when no format is specified
2576        fmt = format_arg.name.upper() if format_arg else "HEX"
2577
2578        if fmt in ("UTF-8", "UTF8"):
2579            # DuckDB ENCODE always uses UTF-8, no charset parameter needed
2580            result = self.func("ENCODE", value)
2581        elif fmt == "BASE64":
2582            result = self.func("FROM_BASE64", value)
2583        elif fmt == "HEX":
2584            result = self.func("UNHEX", value)
2585        else:
2586            if is_safe:
2587                return self.sql(exp.null())
2588            else:
2589                self.unsupported(f"format {fmt} is not supported")
2590                result = self.func("TO_BINARY", value)
2591        return f"TRY({result})" if is_safe else result
2592
2593    def tonumber_sql(self, expression: exp.ToNumber) -> str:
2594        fmt = expression.args.get("format")
2595        precision = expression.args.get("precision")
2596        scale = expression.args.get("scale")
2597
2598        if not fmt and precision and scale:
2599            to = exp.DataType.build(f"DECIMAL({precision.name}, {scale.name})", dialect="duckdb")
2600            if expression.args.get("safe"):
2601                return self.sql(exp.TryCast(this=expression.this, to=to))
2602            return self.sql(exp.cast(expression.this, to, dialect="duckdb"))
2603
2604        return super().tonumber_sql(expression)
2605
2606    def _greatest_least_sql(self, expression: exp.Greatest | exp.Least) -> str:
2607        """
2608        Handle GREATEST/LEAST functions with dialect-aware NULL behavior.
2609
2610        - If ignore_nulls=False (BigQuery-style): return NULL if any argument is NULL
2611        - If ignore_nulls=True (DuckDB/PostgreSQL-style): ignore NULLs, return greatest/least non-NULL value
2612        """
2613        # Get all arguments
2614        all_args = [expression.this, *expression.expressions]
2615        fallback_sql = self.function_fallback_sql(expression)
2616
2617        if expression.args.get("ignore_nulls"):
2618            # DuckDB/PostgreSQL behavior: use native GREATEST/LEAST (ignores NULLs)
2619            return self.sql(fallback_sql)
2620
2621        # return NULL if any argument is NULL
2622        case_expr = exp.case().when(
2623            exp.or_(*[arg.is_(exp.null()) for arg in all_args], copy=False),
2624            exp.null(),
2625            copy=False,
2626        )
2627        case_expr.set("default", fallback_sql)
2628        return self.sql(case_expr)
2629
2630    def generator_sql(self, expression: exp.Generator) -> str:
2631        # Transpile Snowflake GENERATOR to DuckDB range()
2632        rowcount = expression.args.get("rowcount")
2633        time_limit = expression.args.get("time_limit")
2634
2635        if time_limit:
2636            self.unsupported("GENERATOR TIMELIMIT parameter is not supported in DuckDB")
2637
2638        if not rowcount:
2639            self.unsupported("GENERATOR without ROWCOUNT is not supported in DuckDB")
2640            return self.func("range", exp.Literal.number(0))
2641
2642        return self.func("range", rowcount)
2643
2644    def greatest_sql(self, expression: exp.Greatest) -> str:
2645        return self._greatest_least_sql(expression)
2646
2647    def least_sql(self, expression: exp.Least) -> str:
2648        return self._greatest_least_sql(expression)
2649
2650    def lambda_sql(self, expression: exp.Lambda, arrow_sep: str = "->", wrap: bool = True) -> str:
2651        if expression.args.get("colon"):
2652            prefix = "LAMBDA "
2653            arrow_sep = ":"
2654            wrap = False
2655        else:
2656            prefix = ""
2657
2658        lambda_sql = super().lambda_sql(expression, arrow_sep=arrow_sep, wrap=wrap)
2659        return f"{prefix}{lambda_sql}"
2660
2661    def show_sql(self, expression: exp.Show) -> str:
2662        from_ = self.sql(expression, "from_")
2663        from_ = f" FROM {from_}" if from_ else ""
2664        return f"SHOW {expression.name}{from_}"
2665
2666    def soundex_sql(self, expression: exp.Soundex) -> str:
2667        self.unsupported("SOUNDEX is not supported in DuckDB")
2668        return self.func("SOUNDEX", expression.this)
2669
2670    def sortarray_sql(self, expression: exp.SortArray) -> str:
2671        arr = expression.this
2672        asc = expression.args.get("asc")
2673        nulls_first = expression.args.get("nulls_first")
2674
2675        if not isinstance(asc, exp.Boolean) and not isinstance(nulls_first, exp.Boolean):
2676            return self.func("LIST_SORT", arr, asc, nulls_first)
2677
2678        nulls_are_first = nulls_first == exp.true()
2679        nulls_first_sql = exp.Literal.string("NULLS FIRST") if nulls_are_first else None
2680
2681        if not isinstance(asc, exp.Boolean):
2682            return self.func("LIST_SORT", arr, asc, nulls_first_sql)
2683
2684        descending = asc == exp.false()
2685
2686        if not descending and not nulls_are_first:
2687            return self.func("LIST_SORT", arr)
2688        if not nulls_are_first:
2689            return self.func("ARRAY_REVERSE_SORT", arr)
2690        return self.func(
2691            "LIST_SORT",
2692            arr,
2693            exp.Literal.string("DESC" if descending else "ASC"),
2694            exp.Literal.string("NULLS FIRST"),
2695        )
2696
2697    def install_sql(self, expression: exp.Install) -> str:
2698        force = "FORCE " if expression.args.get("force") else ""
2699        this = self.sql(expression, "this")
2700        from_clause = expression.args.get("from_")
2701        from_clause = f" FROM {from_clause}" if from_clause else ""
2702        return f"{force}INSTALL {this}{from_clause}"
2703
2704    def approxtopk_sql(self, expression: exp.ApproxTopK) -> str:
2705        self.unsupported(
2706            "APPROX_TOP_K cannot be transpiled to DuckDB due to incompatible return types. "
2707        )
2708        return self.function_fallback_sql(expression)
2709
2710    def strposition_sql(self, expression: exp.StrPosition) -> str:
2711        this = expression.this
2712        substr = expression.args.get("substr")
2713        position = expression.args.get("position")
2714
2715        # For BINARY/BLOB: DuckDB's STRPOS doesn't support BLOB types
2716        # Convert to HEX strings, use STRPOS, then convert hex position to byte position
2717        if _is_binary(this):
2718            # Build expression: STRPOS(HEX(haystack), HEX(needle))
2719            hex_strpos = exp.StrPosition(
2720                this=exp.Hex(this=this),
2721                substr=exp.Hex(this=substr),
2722            )
2723
2724            return self.sql(exp.cast((hex_strpos + 1) / 2, exp.DType.INT))
2725
2726        # For VARCHAR: handle clamp_position
2727        if expression.args.get("clamp_position") and position:
2728            expression = expression.copy()
2729            expression.set(
2730                "position",
2731                exp.If(
2732                    this=exp.LTE(this=position, expression=exp.Literal.number(0)),
2733                    true=exp.Literal.number(1),
2734                    false=position.copy(),
2735                ),
2736            )
2737
2738        return strposition_sql(self, expression)
2739
2740    def substring_sql(self, expression: exp.Substring) -> str:
2741        if expression.args.get("zero_start"):
2742            start = expression.args.get("start")
2743            length = expression.args.get("length")
2744
2745            if start := expression.args.get("start"):
2746                start = exp.If(this=start.eq(0), true=exp.Literal.number(1), false=start)
2747            if length := expression.args.get("length"):
2748                length = exp.If(this=length < 0, true=exp.Literal.number(0), false=length)
2749
2750            return self.func("SUBSTRING", expression.this, start, length)
2751
2752        return self.function_fallback_sql(expression)
2753
2754    def strtotime_sql(self, expression: exp.StrToTime) -> str:
2755        # Check if target_type requires TIMESTAMPTZ (for LTZ/TZ variants)
2756        target_type = expression.args.get("target_type")
2757        needs_tz = target_type and target_type.this in (
2758            exp.DType.TIMESTAMPLTZ,
2759            exp.DType.TIMESTAMPTZ,
2760        )
2761
2762        value, formatted_time = self._strptime_default_year(expression)
2763
2764        if expression.args.get("safe"):
2765            cast_type = exp.DType.TIMESTAMPTZ if needs_tz else exp.DType.TIMESTAMP
2766            return self.sql(exp.cast(self.func("TRY_STRPTIME", value, formatted_time), cast_type))
2767
2768        base_sql = self.func("STRPTIME", value, formatted_time)
2769        if needs_tz:
2770            return self.sql(
2771                exp.cast(
2772                    base_sql,
2773                    exp.DataType(this=exp.DType.TIMESTAMPTZ),
2774                )
2775            )
2776        return base_sql
2777
2778    def strtodate_sql(self, expression: exp.StrToDate) -> str:
2779        value, formatted_time = self._strptime_default_year(expression)
2780        function_name = "STRPTIME" if not expression.args.get("safe") else "TRY_STRPTIME"
2781        return self.sql(
2782            exp.cast(
2783                self.func(function_name, value, formatted_time),
2784                exp.DataType(this=exp.DType.DATE),
2785            )
2786        )
2787
2788    def _strptime_default_year(
2789        self, expression: exp.StrToTime | exp.StrToDate | exp.ParseDatetime
2790    ) -> tuple[exp.ExpOrStr, exp.ExpOrStr | None]:
2791        value: exp.ExpOrStr = expression.this
2792        formatted_time: exp.ExpOrStr | None = self.format_time(expression)
2793
2794        if default_year := expression.args.get("default_year"):
2795            value = exp.DPipe(this=exp.Literal.string(f"{default_year.name} "), expression=value)
2796            formatted_time = exp.DPipe(this=exp.Literal.string("%Y "), expression=formatted_time)
2797
2798        return value, formatted_time
2799
2800    def parsedatetime_sql(self, expression: exp.ParseDatetime) -> str:
2801        value, formatted_time = self._strptime_default_year(expression)
2802        return self.func("STRPTIME", value, formatted_time)
2803
2804    def parsetime_sql(self, expression: exp.ParseTime) -> str:
2805        formatted_time = self.format_time(expression)
2806        return self.sql(
2807            exp.cast(
2808                self.func("STRPTIME", expression.this, formatted_time),
2809                exp.DataType(this=exp.DType.TIME),
2810            )
2811        )
2812
2813    def tsordstotime_sql(self, expression: exp.TsOrDsToTime) -> str:
2814        this = expression.this
2815        time_format = self.format_time(expression)
2816        safe = expression.args.get("safe")
2817        time_type = exp.DataType.from_str("TIME", dialect="duckdb")
2818        cast_expr = exp.TryCast if safe else exp.Cast
2819
2820        if time_format:
2821            func_name = "TRY_STRPTIME" if safe else "STRPTIME"
2822            strptime = exp.Anonymous(this=func_name, expressions=[this, time_format])
2823            return self.sql(cast_expr(this=strptime, to=time_type))
2824
2825        if isinstance(this, exp.TsOrDsToTime) or this.is_type(exp.DType.TIME):
2826            return self.sql(this)
2827
2828        return self.sql(cast_expr(this=this, to=time_type))
2829
2830    def currentdate_sql(self, expression: exp.CurrentDate) -> str:
2831        if not expression.this:
2832            return "CURRENT_DATE"
2833
2834        expr = exp.Cast(
2835            this=exp.AtTimeZone(this=exp.CurrentTimestamp(), zone=expression.this),
2836            to=exp.DataType(this=exp.DType.DATE),
2837        )
2838        return self.sql(expr)
2839
2840    def checkjson_sql(self, expression: exp.CheckJson) -> str:
2841        arg = expression.this
2842        return self.sql(
2843            exp.case()
2844            .when(
2845                exp.or_(arg.is_(exp.Null()), arg.eq(""), exp.func("json_valid", arg)),
2846                exp.null(),
2847            )
2848            .else_(exp.Literal.string("Invalid JSON"))
2849        )
2850
2851    def parsejson_sql(self, expression: exp.ParseJSON) -> str:
2852        arg = expression.this
2853        if expression.args.get("safe"):
2854            return self.sql(
2855                exp.case()
2856                .when(exp.func("json_valid", arg), exp.cast(arg.copy(), "JSON"))
2857                .else_(exp.null())
2858            )
2859        return self.func("JSON", arg)
2860
2861    def unicode_sql(self, expression: exp.Unicode) -> str:
2862        if expression.args.get("empty_is_zero"):
2863            return self.sql(
2864                exp.case()
2865                .when(expression.this.eq(exp.Literal.string("")), exp.Literal.number(0))
2866                .else_(exp.Anonymous(this="UNICODE", expressions=[expression.this]))
2867            )
2868
2869        return self.func("UNICODE", expression.this)
2870
2871    def stripnullvalue_sql(self, expression: exp.StripNullValue) -> str:
2872        return self.sql(
2873            exp.case()
2874            .when(exp.func("json_type", expression.this).eq("NULL"), exp.null())
2875            .else_(expression.this)
2876        )
2877
2878    def trunc_sql(self, expression: exp.Trunc) -> str:
2879        decimals = expression.args.get("decimals")
2880        if (
2881            expression.args.get("fractions_supported")
2882            and decimals
2883            and not decimals.is_type(exp.DType.INT)
2884        ):
2885            decimals = exp.cast(decimals, exp.DType.INT, dialect="duckdb")
2886
2887        return self.func("TRUNC", expression.this, decimals)
2888
2889    def normal_sql(self, expression: exp.Normal) -> str:
2890        """
2891        Transpile Snowflake's NORMAL(mean, stddev, gen) to DuckDB.
2892
2893        Uses the Box-Muller transform via NORMAL_TEMPLATE.
2894        """
2895        mean = expression.this
2896        stddev = expression.args["stddev"]
2897        gen: exp.Expr = expression.args["gen"]
2898
2899        # Build two uniform random values [0, 1) for Box-Muller transform
2900        if isinstance(gen, exp.Rand) and gen.this is None:
2901            u1: exp.Expr = exp.Rand()
2902            u2: exp.Expr = exp.Rand()
2903        else:
2904            # Seeded: derive two values using HASH with different inputs
2905            seed = gen.this if isinstance(gen, exp.Rand) else gen
2906            u1 = exp.replace_placeholders(self.SEEDED_RANDOM_TEMPLATE, seed=seed)
2907            u2 = exp.replace_placeholders(
2908                self.SEEDED_RANDOM_TEMPLATE,
2909                seed=exp.Add(this=seed.copy(), expression=exp.Literal.number(1)),
2910            )
2911
2912        replacements = {"mean": mean, "stddev": stddev, "u1": u1, "u2": u2}
2913        return self.sql(exp.replace_placeholders(self.NORMAL_TEMPLATE, **replacements))
2914
2915    def uniform_sql(self, expression: exp.Uniform) -> str:
2916        """
2917        Transpile Snowflake's UNIFORM(min, max, gen) to DuckDB.
2918
2919        UNIFORM returns a random value in [min, max]:
2920        - Integer result if both min and max are integers
2921        - Float result if either min or max is a float
2922        """
2923        min_val = expression.this
2924        max_val = expression.expression
2925        gen = expression.args.get("gen") or expression.args.get("seed") or exp.Rand()
2926
2927        # Determine if result should be integer (both bounds are integers).
2928        # We do this to emulate Snowflake's behavior, INT -> INT, FLOAT -> FLOAT
2929        is_int_result = min_val.is_int and max_val.is_int
2930
2931        # Build the random value expression [0, 1)
2932        if not isinstance(gen, exp.Rand):
2933            # Seed value: (ABS(HASH(seed)) % 1000000) / 1000000.0
2934            random_expr: exp.Expr = exp.Div(
2935                this=exp.Paren(
2936                    this=exp.Mod(
2937                        this=exp.Abs(this=exp.Anonymous(this="HASH", expressions=[gen])),
2938                        expression=exp.Literal.number(1000000),
2939                    )
2940                ),
2941                expression=exp.Literal.number(1000000.0),
2942            )
2943        else:
2944            random_expr = exp.Rand()
2945
2946        # Build: min + random * (max - min [+ 1 for int])
2947        range_expr: exp.Expr = exp.Sub(this=max_val, expression=min_val)
2948        if is_int_result:
2949            range_expr = exp.Add(this=range_expr, expression=exp.Literal.number(1))
2950
2951        result: exp.Expr = exp.Add(
2952            this=min_val,
2953            expression=exp.Mul(this=random_expr, expression=exp.Paren(this=range_expr)),
2954        )
2955
2956        if is_int_result:
2957            result = exp.Cast(this=exp.Floor(this=result), to=exp.DType.BIGINT.into_expr())
2958
2959        return self.sql(result)
2960
2961    def timefromparts_sql(self, expression: exp.TimeFromParts) -> str:
2962        nano = expression.args.get("nano")
2963        overflow = expression.args.get("overflow")
2964
2965        # Snowflake's TIME_FROM_PARTS supports overflow
2966        if overflow:
2967            hour = expression.args["hour"]
2968            minute = expression.args["min"]
2969            sec = expression.args["sec"]
2970
2971            # Check if values are within normal ranges - use MAKE_TIME for efficiency
2972            if not nano and all(arg.is_int for arg in [hour, minute, sec]):
2973                try:
2974                    h_val = hour.to_py()
2975                    m_val = minute.to_py()
2976                    s_val = sec.to_py()
2977                    if 0 <= h_val <= 23 and 0 <= m_val <= 59 and 0 <= s_val <= 59:
2978                        return rename_func("MAKE_TIME")(self, expression)
2979                except ValueError:
2980                    pass
2981
2982            # Overflow or nanoseconds detected - use INTERVAL arithmetic
2983            if nano:
2984                sec = sec + nano.pop() / exp.Literal.number(1000000000.0)
2985
2986            total_seconds = hour * exp.Literal.number(3600) + minute * exp.Literal.number(60) + sec
2987
2988            return self.sql(
2989                exp.Add(
2990                    this=exp.Cast(
2991                        this=exp.Literal.string("00:00:00"), to=exp.DType.TIME.into_expr()
2992                    ),
2993                    expression=exp.Interval(this=total_seconds, unit=exp.var("SECOND")),
2994                )
2995            )
2996
2997        # Default: MAKE_TIME
2998        if nano:
2999            expression.set(
3000                "sec", expression.args["sec"] + nano.pop() / exp.Literal.number(1000000000.0)
3001            )
3002
3003        return rename_func("MAKE_TIME")(self, expression)
3004
3005    def extract_sql(self, expression: exp.Extract) -> str:
3006        """
3007        Transpile EXTRACT/DATE_PART for DuckDB, handling specifiers not natively supported.
3008
3009        DuckDB doesn't support: WEEKISO, YEAROFWEEK, YEAROFWEEKISO, NANOSECOND,
3010        EPOCH_SECOND (as integer), EPOCH_MILLISECOND, EPOCH_MICROSECOND, EPOCH_NANOSECOND
3011        """
3012        this = expression.this
3013        datetime_expr = expression.expression
3014
3015        # TIMESTAMPTZ extractions may produce different results between Snowflake and DuckDB
3016        # because Snowflake applies server timezone while DuckDB uses local timezone
3017        if datetime_expr.is_type(exp.DType.TIMESTAMPTZ, exp.DType.TIMESTAMPLTZ):
3018            self.unsupported(
3019                "EXTRACT from TIMESTAMPTZ / TIMESTAMPLTZ may produce different results due to timezone handling differences"
3020            )
3021
3022        part_name = this.name.upper()
3023
3024        if part_name in self.EXTRACT_STRFTIME_MAPPINGS:
3025            fmt, cast_type = self.EXTRACT_STRFTIME_MAPPINGS[part_name]
3026
3027            # Problem: strftime doesn't accept TIME and there's no NANOSECOND function
3028            # So, for NANOSECOND with TIME, fallback to MICROSECOND * 1000
3029            is_nano_time = part_name == "NANOSECOND" and datetime_expr.is_type(
3030                exp.DType.TIME, exp.DType.TIMETZ
3031            )
3032
3033            if is_nano_time:
3034                self.unsupported("Parameter NANOSECOND is not supported with TIME type in DuckDB")
3035                return self.sql(
3036                    exp.cast(
3037                        exp.Mul(
3038                            this=exp.Extract(this=exp.var("MICROSECOND"), expression=datetime_expr),
3039                            expression=exp.Literal.number(1000),
3040                        ),
3041                        exp.DataType.from_str(cast_type, dialect="duckdb"),
3042                    )
3043                )
3044
3045            # For NANOSECOND, cast to TIMESTAMP_NS to preserve nanosecond precision
3046            strftime_input = datetime_expr
3047            if part_name == "NANOSECOND":
3048                strftime_input = exp.cast(datetime_expr, exp.DType.TIMESTAMP_NS)
3049
3050            return self.sql(
3051                exp.cast(
3052                    exp.Anonymous(
3053                        this="STRFTIME",
3054                        expressions=[strftime_input, exp.Literal.string(fmt)],
3055                    ),
3056                    exp.DataType.from_str(cast_type, dialect="duckdb"),
3057                )
3058            )
3059
3060        if part_name in self.EXTRACT_EPOCH_MAPPINGS:
3061            func_name = self.EXTRACT_EPOCH_MAPPINGS[part_name]
3062            result: exp.Expr = exp.Anonymous(this=func_name, expressions=[datetime_expr])
3063            # EPOCH returns float, cast to BIGINT for integer result
3064            if part_name == "EPOCH_SECOND":
3065                result = exp.cast(result, exp.DataType.from_str("BIGINT", dialect="duckdb"))
3066            return self.sql(result)
3067
3068        return super().extract_sql(expression)
3069
3070    def timestampfromparts_sql(self, expression: exp.TimestampFromParts) -> str:
3071        # Check if this is the date/time expression form: TIMESTAMP_FROM_PARTS(date_expr, time_expr)
3072        date_expr = expression.this
3073        time_expr = expression.expression
3074
3075        if date_expr is not None and time_expr is not None:
3076            # In DuckDB, DATE + TIME produces TIMESTAMP
3077            return self.sql(exp.Add(this=date_expr, expression=time_expr))
3078
3079        # Component-based form: TIMESTAMP_FROM_PARTS(year, month, day, hour, minute, second, ...)
3080        sec = expression.args.get("sec")
3081        if sec is None:
3082            # This shouldn't happen with valid input, but handle gracefully
3083            return rename_func("MAKE_TIMESTAMP")(self, expression)
3084
3085        milli = expression.args.get("milli")
3086        if milli is not None:
3087            sec += milli.pop() / exp.Literal.number(1000.0)
3088
3089        nano = expression.args.get("nano")
3090        if nano is not None:
3091            sec += nano.pop() / exp.Literal.number(1000000000.0)
3092
3093        if milli or nano:
3094            expression.set("sec", sec)
3095
3096        if zone := expression.args.get("zone"):
3097            return self.sql(exp.AtTimeZone(this=expression, zone=zone.pop()))
3098
3099        return rename_func("MAKE_TIMESTAMP")(self, expression)
3100
3101    @unsupported_args("nano")
3102    def timestampltzfromparts_sql(self, expression: exp.TimestampLtzFromParts) -> str:
3103        # Pop nano so rename_func only passes args that MAKE_TIMESTAMP accepts
3104        if nano := expression.args.get("nano"):
3105            nano.pop()
3106
3107        timestamp = rename_func("MAKE_TIMESTAMP")(self, expression)
3108        return f"CAST({timestamp} AS TIMESTAMPTZ)"
3109
3110    @unsupported_args("nano")
3111    def timestamptzfromparts_sql(self, expression: exp.TimestampTzFromParts) -> str:
3112        # Extract zone before popping
3113        zone = expression.args.get("zone")
3114        # Pop zone and nano so rename_func only passes args that MAKE_TIMESTAMP accepts
3115        if zone:
3116            zone = zone.pop()
3117
3118        if nano := expression.args.get("nano"):
3119            nano.pop()
3120
3121        timestamp = rename_func("MAKE_TIMESTAMP")(self, expression)
3122
3123        if zone:
3124            # Use AT TIME ZONE to apply the explicit timezone
3125            return f"{timestamp} AT TIME ZONE {self.sql(zone)}"
3126
3127        return timestamp
3128
3129    def after_limit_modifiers(self, expression: exp.Expr) -> list[str]:
3130        locks = self.expressions(expression, key="locks", sep=" ")
3131        return [f" {locks}" if locks else ""]
3132
3133    def tablesample_sql(
3134        self,
3135        expression: exp.TableSample,
3136        tablesample_keyword: str | None = None,
3137    ) -> str:
3138        if not isinstance(expression.parent, exp.Select):
3139            # This sample clause only applies to a single source, not the entire resulting relation
3140            tablesample_keyword = "TABLESAMPLE"
3141
3142        if expression.args.get("size"):
3143            method = expression.args.get("method")
3144            if method and method.name.upper() != "RESERVOIR":
3145                self.unsupported(
3146                    f"Sampling method {method} is not supported with a discrete sample count, "
3147                    "defaulting to reservoir sampling"
3148                )
3149                expression.set("method", exp.var("RESERVOIR"))
3150
3151        return super().tablesample_sql(expression, tablesample_keyword=tablesample_keyword)
3152
3153    def in_sql(self, expression: exp.In) -> str:
3154        unnest = expression.args.get("unnest")
3155        if unnest:
3156            return self.sql(
3157                exp.replace_placeholders(
3158                    self.IN_UNNEST_TEMPLATE, arr=unnest.expressions[0], value=expression.this
3159                )
3160            )
3161        return super().in_sql(expression)
3162
3163    def join_sql(self, expression: exp.Join) -> str:
3164        if (
3165            not expression.args.get("using")
3166            and not expression.args.get("on")
3167            and not expression.method
3168            and (expression.kind in ("", "INNER", "OUTER"))
3169        ):
3170            # Some dialects support `LEFT/INNER JOIN UNNEST(...)` without an explicit ON clause
3171            # DuckDB doesn't, but we can just add a dummy ON clause that is always true
3172            if isinstance(expression.this, exp.Unnest):
3173                return super().join_sql(expression.on(exp.true()))
3174
3175            expression.set("side", None)
3176            expression.set("kind", None)
3177
3178        return super().join_sql(expression)
3179
3180    def countif_sql(self, expression: exp.CountIf) -> str:
3181        if self.dialect.version >= (1, 2):
3182            this = expression.this
3183            if expression.args.get("zero_on_all_null") and not isinstance(this, exp.Distinct):
3184                # DuckDB >= 1.2's COUNT_IF returns NULL when the condition is NULL on all rows,
3185                # so we wrap the condition in IS TRUE to preserve count-like semantics
3186                expression = exp.CountIf(this=exp.paren(this).is_(exp.true()))
3187            return self.function_fallback_sql(expression)
3188
3189        # https://github.com/tobymao/sqlglot/pull/4749
3190        return count_if_to_sum(self, expression)
3191
3192    def bracket_sql(self, expression: exp.Bracket) -> str:
3193        if self.dialect.version >= (1, 2):
3194            return super().bracket_sql(expression)
3195
3196        # https://duckdb.org/2025/02/05/announcing-duckdb-120.html#breaking-changes
3197        this = expression.this
3198        if isinstance(this, exp.Array):
3199            this.replace(exp.paren(this))
3200
3201        bracket = super().bracket_sql(expression)
3202
3203        if not expression.args.get("returns_list_for_maps"):
3204            if not this.type:
3205                from sqlglot.optimizer.annotate_types import annotate_types
3206
3207                this = annotate_types(this, dialect=self.dialect)
3208
3209            if this.is_type(exp.DType.MAP):
3210                bracket = f"({bracket})[1]"
3211
3212        return bracket
3213
3214    def withingroup_sql(self, expression: exp.WithinGroup) -> str:
3215        func = expression.this
3216
3217        # For ARRAY_AGG, DuckDB requires ORDER BY inside the function, not in WITHIN GROUP
3218        # Transform: ARRAY_AGG(x) WITHIN GROUP (ORDER BY y) -> ARRAY_AGG(x ORDER BY y)
3219        if isinstance(func, exp.ArrayAgg):
3220            if not isinstance(order := expression.expression, exp.Order):
3221                return self.sql(func)
3222
3223            # Save the original column for FILTER clause (before wrapping with Order)
3224            original_this = func.this
3225
3226            # Move ORDER BY inside ARRAY_AGG by wrapping its argument with Order
3227            # ArrayAgg.this should become Order(this=ArrayAgg.this, expressions=order.expressions)
3228            func.set(
3229                "this",
3230                exp.Order(
3231                    this=func.this.copy(),
3232                    expressions=order.expressions,
3233                ),
3234            )
3235
3236            # Generate the ARRAY_AGG function with ORDER BY and add FILTER clause if needed
3237            # Use original_this (not the Order-wrapped version) for the FILTER condition
3238            array_agg_sql = self.function_fallback_sql(func)
3239            return self._add_arrayagg_null_filter(array_agg_sql, func, original_this)
3240
3241        # For other functions (like PERCENTILES), use existing logic
3242        expression_sql = self.sql(expression, "expression")
3243
3244        if isinstance(func, exp.PERCENTILES):
3245            # Make the order key the first arg and slide the fraction to the right
3246            # https://duckdb.org/docs/sql/aggregates#ordered-set-aggregate-functions
3247            order_col = expression.find(exp.Ordered)
3248            if order_col:
3249                func.set("expression", func.this)
3250                func.set("this", order_col.this)
3251
3252        this = self.sql(expression, "this").rstrip(")")
3253
3254        return f"{this}{expression_sql})"
3255
3256    def length_sql(self, expression: exp.Length) -> str:
3257        arg = expression.this
3258
3259        # Dialects like BQ and Snowflake also accept binary values as args, so
3260        # DDB will attempt to infer the type or resort to case/when resolution
3261        if not expression.args.get("binary") or arg.is_string:
3262            return self.func("LENGTH", arg)
3263
3264        if not arg.type:
3265            from sqlglot.optimizer.annotate_types import annotate_types
3266
3267            arg = annotate_types(arg, dialect=self.dialect)
3268
3269        if arg.is_type(*exp.DataType.TEXT_TYPES):
3270            return self.func("LENGTH", arg)
3271
3272        # We need these casts to make duckdb's static type checker happy
3273        blob = exp.cast(arg, exp.DType.VARBINARY)
3274        varchar = exp.cast(arg, exp.DType.VARCHAR)
3275
3276        case = (
3277            exp.case(exp.Anonymous(this="TYPEOF", expressions=[arg]))
3278            .when(exp.Literal.string("BLOB"), exp.ByteLength(this=blob))
3279            .else_(exp.Anonymous(this="LENGTH", expressions=[varchar]))
3280        )
3281        return self.sql(case)
3282
3283    def bitlength_sql(self, expression: exp.BitLength) -> str:
3284        if not _is_binary(arg := expression.this):
3285            return self.func("BIT_LENGTH", arg)
3286
3287        blob = exp.cast(arg, exp.DataType.Type.VARBINARY)
3288        return self.sql(exp.ByteLength(this=blob) * exp.Literal.number(8))
3289
3290    def chr_sql(self, expression: exp.Chr, name: str = "CHR") -> str:
3291        arg = expression.expressions[0]
3292        if arg.is_type(*exp.DataType.REAL_TYPES):
3293            arg = exp.cast(arg, exp.DType.INT)
3294        return self.func("CHR", arg)
3295
3296    def collation_sql(self, expression: exp.Collation) -> str:
3297        self.unsupported("COLLATION function is not supported by DuckDB")
3298        return self.function_fallback_sql(expression)
3299
3300    def collate_sql(self, expression: exp.Collate) -> str:
3301        if not expression.expression.is_string:
3302            return super().collate_sql(expression)
3303
3304        raw = expression.expression.name
3305        if not raw:
3306            return self.sql(expression.this)
3307
3308        parts = []
3309        for part in raw.split("-"):
3310            lower = part.lower()
3311            if lower not in _SNOWFLAKE_COLLATION_DEFAULTS:
3312                if lower in _SNOWFLAKE_COLLATION_UNSUPPORTED:
3313                    self.unsupported(
3314                        f"Snowflake collation specifier '{part}' has no DuckDB equivalent"
3315                    )
3316                parts.append(lower)
3317
3318        if not parts:
3319            return self.sql(expression.this)
3320        return super().collate_sql(
3321            exp.Collate(this=expression.this, expression=exp.var(".".join(parts)))
3322        )
3323
3324    def _validate_regexp_flags(self, flags: exp.Expr | None, supported_flags: str) -> str | None:
3325        """
3326        Validate and filter regexp flags for DuckDB compatibility.
3327
3328        Args:
3329            flags: The flags expression to validate
3330            supported_flags: String of supported flags (e.g., "ims", "cims").
3331                            Only these flags will be returned.
3332
3333        Returns:
3334            Validated/filtered flag string, or None if no valid flags remain
3335        """
3336        if not isinstance(flags, exp.Expr):
3337            return None
3338
3339        if not flags.is_string:
3340            self.unsupported("Non-literal regexp flags are not fully supported in DuckDB")
3341            return None
3342
3343        flag_str = flags.this
3344        unsupported = set(flag_str) - set(supported_flags)
3345
3346        if unsupported:
3347            self.unsupported(
3348                f"Regexp flags {sorted(unsupported)} are not supported in this context"
3349            )
3350
3351        flag_str = "".join(f for f in flag_str if f in supported_flags)
3352        return flag_str if flag_str else None
3353
3354    def regexpcount_sql(self, expression: exp.RegexpCount) -> str:
3355        this = expression.this
3356        pattern = expression.expression
3357        position = expression.args.get("position")
3358        parameters = expression.args.get("parameters")
3359
3360        # Validate flags - only "ims" flags are supported for embedded patterns
3361        validated_flags = self._validate_regexp_flags(parameters, supported_flags="ims")
3362
3363        if position:
3364            this = exp.Substring(this=this, start=position)
3365
3366        # Handle empty pattern: Snowflake returns 0, DuckDB would match between every character.
3367        # Checked before the flags are embedded, which would make the pattern non-empty
3368        is_empty = exp.EQ(this=pattern.copy(), expression=exp.Literal.string(""))
3369
3370        # Embed flags in pattern (REGEXP_EXTRACT_ALL doesn't support flags argument)
3371        if validated_flags:
3372            pattern = exp.Concat(expressions=[exp.Literal.string(f"(?{validated_flags})"), pattern])
3373
3374        result = (
3375            exp.case()
3376            .when(is_empty, exp.Literal.number(0))
3377            .else_(
3378                exp.Length(
3379                    this=exp.Anonymous(this="REGEXP_EXTRACT_ALL", expressions=[this, pattern])
3380                )
3381            )
3382        )
3383
3384        return self.sql(result)
3385
3386    def regexpreplace_sql(self, expression: exp.RegexpReplace) -> str:
3387        subject = expression.this
3388        pattern = expression.expression
3389        replacement = expression.args.get("replacement") or exp.Literal.string("")
3390        position = expression.args.get("position")
3391        occurrence = expression.args.get("occurrence")
3392        modifiers = expression.args.get("modifiers")
3393
3394        validated_flags = self._validate_regexp_flags(modifiers, supported_flags="cimsg") or ""
3395
3396        # Handle occurrence (only literals supported)
3397        if occurrence and not occurrence.is_int:
3398            self.unsupported("REGEXP_REPLACE with non-literal occurrence")
3399        else:
3400            occurrence = occurrence.to_py() if occurrence and occurrence.is_int else 0
3401            if occurrence > 1:
3402                self.unsupported(f"REGEXP_REPLACE occurrence={occurrence} not supported")
3403            # flag duckdb to do either all or none, single_replace check is for duckdb round trip
3404            elif (
3405                occurrence == 0
3406                and "g" not in validated_flags
3407                and not expression.args.get("single_replace")
3408            ):
3409                validated_flags += "g"
3410
3411        # Handle position (only literals supported)
3412        prefix = None
3413        if position and not position.is_int:
3414            self.unsupported("REGEXP_REPLACE with non-literal position")
3415        elif position and position.is_int and position.to_py() > 1:
3416            pos = position.to_py()
3417            prefix = exp.Substring(
3418                this=subject, start=exp.Literal.number(1), length=exp.Literal.number(pos - 1)
3419            )
3420            subject = exp.Substring(this=subject, start=exp.Literal.number(pos))
3421
3422        result: exp.Expr = exp.Anonymous(
3423            this="REGEXP_REPLACE",
3424            expressions=[
3425                subject,
3426                pattern,
3427                replacement,
3428                exp.Literal.string(validated_flags) if validated_flags else None,
3429            ],
3430        )
3431
3432        if prefix:
3433            result = exp.Concat(expressions=[prefix, result])
3434
3435        return self.sql(result)
3436
3437    def regexplike_sql(self, expression: exp.RegexpLike) -> str:
3438        this = expression.this
3439        pattern = expression.expression
3440        flag = expression.args.get("flag")
3441
3442        if expression.args.get("full_match"):
3443            validated_flags = self._validate_regexp_flags(flag, supported_flags="cims")
3444            flag = exp.Literal.string(validated_flags) if validated_flags else None
3445            return self.func("REGEXP_FULL_MATCH", this, pattern, flag)
3446
3447        return self.func("REGEXP_MATCHES", this, pattern, flag)
3448
3449    @unsupported_args("ins_cost", "del_cost", "sub_cost")
3450    def levenshtein_sql(self, expression: exp.Levenshtein) -> str:
3451        this = expression.this
3452        expr = expression.expression
3453        max_dist = expression.args.get("max_dist")
3454
3455        if max_dist is None:
3456            return self.func("LEVENSHTEIN", this, expr)
3457
3458        # Emulate Snowflake semantics: if distance > max_dist, return max_dist
3459        levenshtein = exp.Levenshtein(this=this, expression=expr)
3460        return self.sql(exp.Least(this=levenshtein, expressions=[max_dist]))
3461
3462    def pad_sql(self, expression: exp.Pad) -> str:
3463        """
3464        Handle RPAD/LPAD for VARCHAR and BINARY types.
3465
3466        For VARCHAR: Delegate to parent class
3467        For BINARY: Lower to: input || REPEAT(pad, GREATEST(0, target_len - OCTET_LENGTH(input)))
3468        """
3469        string_arg = expression.this
3470        fill_arg = expression.args.get("fill_pattern") or exp.Literal.string(" ")
3471
3472        if _is_binary(string_arg) or _is_binary(fill_arg):
3473            length_arg = expression.expression
3474            is_left = expression.args.get("is_left")
3475
3476            input_len = exp.ByteLength(this=string_arg)
3477            chars_needed = length_arg - input_len
3478            pad_count = exp.Greatest(
3479                this=exp.Literal.number(0), expressions=[chars_needed], ignore_nulls=True
3480            )
3481            repeat_expr = exp.Repeat(this=fill_arg, times=pad_count)
3482
3483            left, right = string_arg, repeat_expr
3484            if is_left:
3485                left, right = right, left
3486
3487            result = exp.DPipe(this=left, expression=right)
3488            return self.sql(result)
3489
3490        # For VARCHAR: Delegate to parent class (handles PAD_FILL_PATTERN_IS_REQUIRED)
3491        return super().pad_sql(expression)
3492
3493    def minhash_sql(self, expression: exp.Minhash) -> str:
3494        k = expression.this
3495        exprs = expression.expressions
3496
3497        if len(exprs) != 1 or isinstance(exprs[0], exp.Star):
3498            self.unsupported(
3499                "MINHASH with multiple expressions or * requires manual query restructuring"
3500            )
3501            return self.func("MINHASH", k, *exprs)
3502
3503        expr = exprs[0]
3504        result = exp.replace_placeholders(self.MINHASH_TEMPLATE.copy(), expr=expr, k=k)
3505        return f"({self.sql(result)})"
3506
3507    def minhashcombine_sql(self, expression: exp.MinhashCombine) -> str:
3508        expr = expression.this
3509        result = exp.replace_placeholders(self.MINHASH_COMBINE_TEMPLATE.copy(), expr=expr)
3510        return f"({self.sql(result)})"
3511
3512    def approximatesimilarity_sql(self, expression: exp.ApproximateSimilarity) -> str:
3513        expr = expression.this
3514        result = exp.replace_placeholders(self.APPROXIMATE_SIMILARITY_TEMPLATE.copy(), expr=expr)
3515        return f"({self.sql(result)})"
3516
3517    def arrayuniqueagg_sql(self, expression: exp.ArrayUniqueAgg) -> str:
3518        return self.sql(
3519            exp.Filter(
3520                this=exp.func("LIST", exp.Distinct(expressions=[expression.this])),
3521                expression=exp.Where(this=expression.this.copy().is_(exp.null()).not_()),
3522            )
3523        )
3524
3525    def arrayconcatagg_sql(self, expression: exp.ArrayConcatAgg) -> str:
3526        this = expression.this
3527
3528        if isinstance(this, exp.Limit):
3529            self.unsupported("LIMIT in ARRAY_CONCAT_AGG cannot be transpiled to DuckDB")
3530            this = this.this
3531
3532        inner = this.this if isinstance(this, exp.Order) else this
3533
3534        return self.func(
3535            "FLATTEN",
3536            exp.Filter(
3537                this=exp.ArrayAgg(this=this),
3538                expression=exp.Where(this=inner.copy().is_(exp.null()).not_()),
3539            ),
3540        )
3541
3542    def arrayunionagg_sql(self, expression: exp.ArrayUnionAgg) -> str:
3543        self.unsupported("ARRAY_UNION_AGG is not supported in DuckDB")
3544        return self.function_fallback_sql(expression)
3545
3546    def arraydistinct_sql(self, expression: exp.ArrayDistinct) -> str:
3547        arr = expression.this
3548        func = self.func("LIST_DISTINCT", arr)
3549
3550        if expression.args.get("check_null"):
3551            add_null_to_array = exp.func(
3552                "LIST_APPEND", exp.func("LIST_DISTINCT", exp.ArrayCompact(this=arr)), exp.Null()
3553            )
3554            return self.sql(
3555                exp.If(
3556                    this=exp.NEQ(
3557                        this=exp.ArraySize(this=arr), expression=exp.func("LIST_COUNT", arr)
3558                    ),
3559                    true=add_null_to_array,
3560                    false=func,
3561                )
3562            )
3563
3564        return func
3565
3566    def arrayintersect_sql(self, expression: exp.ArrayIntersect) -> str:
3567        if expression.args.get("is_multiset") and len(expression.expressions) == 2:
3568            return self._array_bag_sql(
3569                self.ARRAY_INTERSECTION_CONDITION,
3570                expression.expressions[0],
3571                expression.expressions[1],
3572            )
3573        return self.function_fallback_sql(expression)
3574
3575    def arrayexcept_sql(self, expression: exp.ArrayExcept) -> str:
3576        arr1, arr2 = expression.this, expression.expression
3577        if expression.args.get("is_multiset"):
3578            return self._array_bag_sql(self.ARRAY_EXCEPT_CONDITION, arr1, arr2)
3579        return self.sql(
3580            exp.replace_placeholders(self.ARRAY_EXCEPT_SET_TEMPLATE, arr1=arr1, arr2=arr2)
3581        )
3582
3583    def arrayslice_sql(self, expression: exp.ArraySlice) -> str:
3584        """
3585        Spark's SLICE uses a length, so normalize negative starts before computing
3586        the inclusive end index. Starts before the array produce an empty slice.
3587
3588        Transpiles Snowflake's ARRAY_SLICE (0-indexed, exclusive end) to DuckDB's
3589        ARRAY_SLICE (1-indexed, inclusive end) by wrapping start and end in CASE
3590        expressions that adjust the index at query time:
3591          - start: CASE WHEN start >= 0 THEN start + 1 ELSE start END
3592          - end:   CASE WHEN end < 0 THEN end - 1 ELSE end END
3593        """
3594        start, end = expression.args.get("start"), expression.args.get("end")
3595
3596        if expression.args.get("zero_based"):
3597            if start is not None:
3598                start = (
3599                    exp.case()
3600                    .when(
3601                        exp.GTE(this=start.copy(), expression=exp.Literal.number(0)),
3602                        exp.Add(this=start.copy(), expression=exp.Literal.number(1)),
3603                    )
3604                    .else_(start)
3605                )
3606            if end is not None and not expression.args.get("is_length"):
3607                end = (
3608                    exp.case()
3609                    .when(
3610                        exp.LT(this=end.copy(), expression=exp.Literal.number(0)),
3611                        exp.Sub(this=end.copy(), expression=exp.Literal.number(1)),
3612                    )
3613                    .else_(end)
3614                )
3615
3616        if expression.args.get("is_length") and start is not None and end is not None:
3617            if not start.is_int or start.to_py() < 0:
3618                size = exp.ArraySize(this=expression.this.copy())
3619                start = (
3620                    exp.case()
3621                    .when(start < -size, size + 1)
3622                    .when(start < 0, size + start + 1)
3623                    .else_(start)
3624                )
3625
3626            if start.is_int and end.is_int:
3627                end = exp.Literal.number(start.to_py() + end.to_py() - 1)
3628            else:
3629                end = start + exp.cast(end, "BIGINT") - 1
3630        return self.func("ARRAY_SLICE", expression.this, start, end, expression.args.get("step"))
3631
3632    def arrayszip_sql(self, expression: exp.ArraysZip) -> str:
3633        args = expression.expressions
3634
3635        if not args:
3636            # Return [{}] - using MAP([], []) since DuckDB can't represent empty structs
3637            return self.sql(exp.array(exp.Map(keys=exp.array(), values=exp.array())))
3638
3639        # Build placeholder values for template
3640        lengths = [exp.Length(this=arg) for arg in args]
3641        max_len = (
3642            lengths[0]
3643            if len(lengths) == 1
3644            else exp.Greatest(this=lengths[0], expressions=lengths[1:])
3645        )
3646
3647        # Empty struct with same schema: {'$1': NULL, '$2': NULL, ...}
3648        empty_struct = exp.func(
3649            "STRUCT",
3650            *[
3651                exp.PropertyEQ(this=exp.Literal.string(f"${i + 1}"), expression=exp.Null())
3652                for i in range(len(args))
3653            ],
3654        )
3655
3656        # Struct for transform: {'$1': COALESCE(arr1, [])[__i + 1], ...}
3657        # COALESCE wrapping handles NULL arrays - prevents invalid NULL[i] syntax
3658        index = exp.column("__i") + 1
3659        transform_struct = exp.func(
3660            "STRUCT",
3661            *[
3662                exp.PropertyEQ(
3663                    this=exp.Literal.string(f"${i + 1}"),
3664                    expression=exp.func("COALESCE", arg, exp.array())[index],
3665                )
3666                for i, arg in enumerate(args)
3667            ],
3668        )
3669
3670        result = exp.replace_placeholders(
3671            self.ARRAYS_ZIP_TEMPLATE.copy(),
3672            null_check=exp.or_(*[arg.is_(exp.Null()) for arg in args]),
3673            all_empty_check=exp.and_(
3674                *[
3675                    exp.EQ(this=exp.Length(this=arg), expression=exp.Literal.number(0))
3676                    for arg in args
3677                ]
3678            ),
3679            empty_struct=empty_struct,
3680            max_len=max_len,
3681            transform_struct=transform_struct,
3682        )
3683        return self.sql(result)
3684
3685    def lower_sql(self, expression: exp.Lower) -> str:
3686        result_sql = self.func("LOWER", _cast_to_varchar(expression.this))
3687        return _gen_with_cast_to_blob(self, expression, result_sql)
3688
3689    def upper_sql(self, expression: exp.Upper) -> str:
3690        result_sql = self.func("UPPER", _cast_to_varchar(expression.this))
3691        return _gen_with_cast_to_blob(self, expression, result_sql)
3692
3693    def reverse_sql(self, expression: exp.Reverse) -> str:
3694        result_sql = self.func("REVERSE", _cast_to_varchar(expression.this))
3695        return _gen_with_cast_to_blob(self, expression, result_sql)
3696
3697    def _left_right_sql(self, expression: exp.Left | exp.Right, func_name: str) -> str:
3698        arg = expression.this
3699        length = expression.expression
3700        is_binary = _is_binary(arg)
3701
3702        if is_binary:
3703            # LEFT/RIGHT(blob, n) becomes UNHEX(LEFT/RIGHT(HEX(blob), n * 2))
3704            # Each byte becomes 2 hex chars, so multiply length by 2
3705            hex_arg = exp.Hex(this=arg)
3706            hex_length = exp.Mul(this=length, expression=exp.Literal.number(2))
3707            result: exp.Expression = exp.Unhex(
3708                this=exp.Anonymous(this=func_name, expressions=[hex_arg, hex_length])
3709            )
3710        else:
3711            result = exp.Anonymous(this=func_name, expressions=[arg, length])
3712
3713        if expression.args.get("negative_length_returns_empty"):
3714            empty: exp.Expression = exp.Literal.string("")
3715            if is_binary:
3716                empty = exp.Unhex(this=empty)
3717            result = exp.case().when(length < exp.Literal.number(0), empty).else_(result)
3718
3719        return self.sql(result)
3720
3721    def left_sql(self, expression: exp.Left) -> str:
3722        return self._left_right_sql(expression, "LEFT")
3723
3724    def right_sql(self, expression: exp.Right) -> str:
3725        return self._left_right_sql(expression, "RIGHT")
3726
3727    def rtrimmedlength_sql(self, expression: exp.RtrimmedLength) -> str:
3728        return self.func("LENGTH", exp.Trim(this=expression.this, position="TRAILING"))
3729
3730    def stuff_sql(self, expression: exp.Stuff) -> str:
3731        base = expression.this
3732        start = expression.args["start"]
3733        length = expression.args["length"]
3734        insertion = expression.expression
3735        is_binary = _is_binary(base)
3736
3737        if is_binary:
3738            # DuckDB's SUBSTRING doesn't accept BLOB; operate on the HEX string instead
3739            # (each byte = 2 hex chars), then UNHEX back to BLOB
3740            base = exp.Hex(this=base)
3741            insertion = exp.Hex(this=insertion)
3742            left = exp.Substring(
3743                this=base.copy(),
3744                start=exp.Literal.number(1),
3745                length=(start.copy() - exp.Literal.number(1)) * exp.Literal.number(2),
3746            )
3747            right = exp.Substring(
3748                this=base.copy(),
3749                start=((start + length) - exp.Literal.number(1)) * exp.Literal.number(2)
3750                + exp.Literal.number(1),
3751            )
3752        else:
3753            left = exp.Substring(
3754                this=base.copy(),
3755                start=exp.Literal.number(1),
3756                length=start.copy() - exp.Literal.number(1),
3757            )
3758            right = exp.Substring(this=base.copy(), start=start + length)
3759        result: exp.Expr = exp.DPipe(
3760            this=exp.DPipe(this=left, expression=insertion), expression=right
3761        )
3762
3763        if is_binary:
3764            result = exp.Unhex(this=result)
3765
3766        return self.sql(result)
3767
3768    def rand_sql(self, expression: exp.Rand) -> str:
3769        seed = expression.this
3770        if seed is not None:
3771            self.unsupported("RANDOM with seed is not supported in DuckDB")
3772
3773        lower = expression.args.get("lower")
3774        upper = expression.args.get("upper")
3775
3776        if lower and upper:
3777            # scale DuckDB's [0,1) to the specified range
3778            range_size = exp.paren(upper - lower)
3779            scaled = exp.Add(this=lower, expression=exp.func("random") * range_size)
3780
3781            # For now we assume that if bounds are set, return type is BIGINT. Snowflake/Teradata
3782            result = exp.cast(scaled, exp.DType.BIGINT)
3783            return self.sql(result)
3784
3785        # Default DuckDB behavior - just return RANDOM() as float
3786        return "RANDOM()"
3787
3788    def bytelength_sql(self, expression: exp.ByteLength) -> str:
3789        arg = expression.this
3790
3791        # Check if it's a text type (handles both literals and annotated expressions)
3792        if arg.is_type(*exp.DataType.TEXT_TYPES):
3793            return self.func("OCTET_LENGTH", exp.Encode(this=arg))
3794
3795        # Default: pass through as-is (conservative for DuckDB, handles binary and unannotated)
3796        return self.func("OCTET_LENGTH", arg)
3797
3798    def base64encode_sql(self, expression: exp.Base64Encode) -> str:
3799        # DuckDB TO_BASE64 requires BLOB input
3800        # Snowflake BASE64_ENCODE accepts both VARCHAR and BINARY - for VARCHAR it implicitly
3801        # encodes UTF-8 bytes. We add ENCODE unless the input is a binary type.
3802        result = expression.this
3803
3804        # Check if input is a string type - ENCODE only accepts VARCHAR
3805        if result.is_type(*exp.DataType.TEXT_TYPES):
3806            result = exp.Encode(this=result)
3807
3808        result = exp.ToBase64(this=result)
3809
3810        max_line_length = expression.args.get("max_line_length")
3811        alphabet = expression.args.get("alphabet")
3812
3813        # Handle custom alphabet by replacing standard chars with custom ones
3814        result = _apply_base64_alphabet_replacements(result, alphabet)
3815
3816        # Handle max_line_length by inserting newlines every N characters
3817        line_length = (
3818            t.cast(int, max_line_length.to_py())
3819            if isinstance(max_line_length, exp.Literal) and max_line_length.is_number
3820            else 0
3821        )
3822        if line_length > 0:
3823            newline = exp.Chr(expressions=[exp.Literal.number(10)])
3824            result = exp.Trim(
3825                this=exp.RegexpReplace(
3826                    this=result,
3827                    expression=exp.Literal.string(f"(.{{{line_length}}})"),
3828                    replacement=exp.Concat(expressions=[exp.Literal.string("\\1"), newline.copy()]),
3829                ),
3830                expression=newline,
3831                position="TRAILING",
3832            )
3833
3834        return self.sql(result)
3835
3836    def hex_sql(self, expression: exp.Hex) -> str:
3837        case = expression.args.get("case")
3838
3839        if not case:
3840            return self.func("HEX", expression.this)
3841
3842        hex_expr = exp.Hex(this=expression.this)
3843        return self.sql(
3844            exp.case()
3845            .when(case.is_(exp.null()), exp.null())
3846            .when(case.copy().eq(0), exp.Lower(this=hex_expr.copy()))
3847            .else_(hex_expr)
3848        )
3849
3850    def replace_sql(self, expression: exp.Replace) -> str:
3851        result_sql = self.func(
3852            "REPLACE",
3853            _cast_to_varchar(expression.this),
3854            _cast_to_varchar(expression.expression),
3855            _cast_to_varchar(expression.args.get("replacement")),
3856        )
3857        return _gen_with_cast_to_blob(self, expression, result_sql)
3858
3859    def _bitwise_op(self, expression: exp.Binary, op: str) -> str:
3860        _prepare_binary_bitwise_args(expression)
3861        result_sql = self.binary(expression, op)
3862        return _gen_with_cast_to_blob(self, expression, result_sql)
3863
3864    def bitwisexor_sql(self, expression: exp.BitwiseXor) -> str:
3865        _prepare_binary_bitwise_args(expression)
3866        result_sql = self.func("XOR", expression.this, expression.expression)
3867        return _gen_with_cast_to_blob(self, expression, result_sql)
3868
3869    def objectinsert_sql(self, expression: exp.ObjectInsert) -> str:
3870        this = expression.this
3871        key = expression.args.get("key")
3872        key_sql = key.name if isinstance(key, exp.Expr) else ""
3873        value_sql = self.sql(expression, "value")
3874
3875        kv_sql = f"{key_sql} := {value_sql}"
3876
3877        # If the input struct is empty e.g. transpiling OBJECT_INSERT(OBJECT_CONSTRUCT(), key, value) from Snowflake
3878        # then we can generate STRUCT_PACK which will build it since STRUCT_INSERT({}, key := value) is not valid DuckDB
3879        if isinstance(this, exp.Struct) and not this.expressions:
3880            return self.func("STRUCT_PACK", kv_sql)
3881
3882        return self.func("STRUCT_INSERT", this, kv_sql)
3883
3884    def mapcat_sql(self, expression: exp.MapCat) -> str:
3885        result = exp.replace_placeholders(
3886            self.MAPCAT_TEMPLATE.copy(),
3887            map1=expression.this,
3888            map2=expression.expression,
3889        )
3890        return self.sql(result)
3891
3892    def mapcontainskey_sql(self, expression: exp.MapContainsKey) -> str:
3893        return self.func(
3894            "ARRAY_CONTAINS", exp.func("MAP_KEYS", expression.args["key"]), expression.this
3895        )
3896
3897    def mapdelete_sql(self, expression: exp.MapDelete) -> str:
3898        map_arg = expression.this
3899        keys_to_delete = expression.expressions
3900
3901        x_dot_key = exp.Dot(this=exp.to_identifier("x"), expression=exp.to_identifier("key"))
3902
3903        lambda_expr = exp.Lambda(
3904            this=exp.In(this=x_dot_key, expressions=keys_to_delete).not_(),
3905            expressions=[exp.to_identifier("x")],
3906        )
3907        result = exp.func(
3908            "MAP_FROM_ENTRIES",
3909            exp.ArrayFilter(this=exp.func("MAP_ENTRIES", map_arg), expression=lambda_expr),
3910        )
3911        return self.sql(result)
3912
3913    def mappick_sql(self, expression: exp.MapPick) -> str:
3914        map_arg = expression.this
3915        keys_to_pick = expression.expressions
3916
3917        x_dot_key = exp.Dot(this=exp.to_identifier("x"), expression=exp.to_identifier("key"))
3918
3919        if len(keys_to_pick) == 1 and keys_to_pick[0].is_type(exp.DType.ARRAY):
3920            lambda_expr = exp.Lambda(
3921                this=exp.func("ARRAY_CONTAINS", keys_to_pick[0], x_dot_key),
3922                expressions=[exp.to_identifier("x")],
3923            )
3924        else:
3925            lambda_expr = exp.Lambda(
3926                this=exp.In(this=x_dot_key, expressions=keys_to_pick),
3927                expressions=[exp.to_identifier("x")],
3928            )
3929
3930        result = exp.func(
3931            "MAP_FROM_ENTRIES",
3932            exp.func("LIST_FILTER", exp.func("MAP_ENTRIES", map_arg), lambda_expr),
3933        )
3934        return self.sql(result)
3935
3936    def mapsize_sql(self, expression: exp.MapSize) -> str:
3937        return self.func("CARDINALITY", expression.this)
3938
3939    @unsupported_args("update_flag")
3940    def mapinsert_sql(self, expression: exp.MapInsert) -> str:
3941        map_arg = expression.this
3942        key = expression.args.get("key")
3943        value = expression.args.get("value")
3944
3945        map_type = map_arg.type
3946
3947        if value is not None:
3948            if map_type and map_type.expressions and len(map_type.expressions) > 1:
3949                # Extract the value type from MAP(key_type, value_type)
3950                value_type = map_type.expressions[1]
3951                # Cast value to match the map's value type to avoid type conflicts
3952                value = exp.cast(value, value_type)
3953            # else: polymorphic MAP case - no type parameters available, use value as-is
3954
3955        # Create a single-entry map for the new key-value pair
3956        new_entry_struct = exp.Struct(expressions=[exp.PropertyEQ(this=key, expression=value)])
3957        new_entry: exp.Expression = exp.ToMap(this=new_entry_struct)
3958
3959        # Use MAP_CONCAT to merge the original map with the new entry
3960        # This automatically handles both insert and update cases
3961        result = exp.func("MAP_CONCAT", map_arg, new_entry)
3962
3963        return self.sql(result)
3964
3965    def startswith_sql(self, expression: exp.StartsWith) -> str:
3966        return self.func(
3967            "STARTS_WITH",
3968            _cast_to_varchar(expression.this),
3969            _cast_to_varchar(expression.expression),
3970        )
3971
3972    def space_sql(self, expression: exp.Space) -> str:
3973        # DuckDB's REPEAT requires BIGINT for the count parameter
3974        return self.sql(
3975            exp.Repeat(
3976                this=exp.Literal.string(" "),
3977                times=exp.cast(expression.this, exp.DType.BIGINT),
3978            )
3979        )
3980
3981    def tablefromrows_sql(self, expression: exp.TableFromRows) -> str:
3982        # For GENERATOR, unwrap TABLE() - just emit the Generator (becomes RANGE)
3983        if isinstance(expression.this, exp.Generator):
3984            # Preserve alias, joins, and other table-level args
3985            table = exp.Table(
3986                this=expression.this,
3987                alias=expression.args.get("alias"),
3988                joins=expression.args.get("joins"),
3989            )
3990            return self.sql(table)
3991
3992        return super().tablefromrows_sql(expression)
3993
3994    def unnest_sql(self, expression: exp.Unnest) -> str:
3995        explode_array = expression.args.get("explode_array")
3996        if explode_array:
3997            # In BigQuery, UNNESTing a nested array leads to explosion of the top-level array & struct
3998            # This is transpiled to DDB by transforming "FROM UNNEST(...)" to "FROM (SELECT UNNEST(..., max_depth => 2))"
3999            expression.expressions.append(
4000                exp.Kwarg(this=exp.var("max_depth"), expression=exp.Literal.number(2))
4001            )
4002
4003            # If BQ's UNNEST is aliased, we transform it from a column alias to a table alias in DDB
4004            alias = expression.args.get("alias")
4005            if isinstance(alias, exp.TableAlias):
4006                expression.set("alias", None)
4007                if alias.columns:
4008                    alias = exp.TableAlias(this=seq_get(alias.columns, 0))
4009
4010            unnest_sql = super().unnest_sql(expression)
4011            select = exp.Select(expressions=[unnest_sql]).subquery(alias)
4012            return self.sql(select)
4013
4014        return super().unnest_sql(expression)
4015
4016    def arrayagg_sql(self, expression: exp.ArrayAgg) -> str:
4017        if isinstance(expression.this, exp.Limit):
4018            self.unsupported("LIMIT inside ARRAY_AGG is not supported in DuckDB")
4019
4020        return super().arrayagg_sql(expression)
4021
4022    def ignorenulls_sql(self, expression: exp.IgnoreNulls) -> str:
4023        this = expression.this
4024
4025        if isinstance(this, self.IGNORE_RESPECT_NULLS_WINDOW_FUNCTIONS):
4026            # DuckDB should render IGNORE NULLS only for the general-purpose
4027            # window functions that accept it e.g. FIRST_VALUE(... IGNORE NULLS) OVER (...)
4028            return super().ignorenulls_sql(expression)
4029
4030        # For ARRAY_AGG(expr IGNORE NULLS ...), convert IGNORE NULLS to a
4031        # FILTER(WHERE expr IS NOT NULL) clause by setting nulls_excluded on
4032        # the ArrayAgg.  The existing _add_arrayagg_null_filter method will
4033        # emit the FILTER clause during arrayagg_sql / withingroup_sql.
4034        if isinstance(this, exp.ArrayAgg):
4035            this.set("nulls_excluded", True)
4036            return self.sql(this)
4037
4038        if isinstance(this, exp.First):
4039            this = exp.AnyValue(this=this.this)
4040
4041        if not isinstance(this, (exp.AnyValue, exp.ApproxQuantiles)):
4042            self.unsupported("IGNORE NULLS is not supported for non-window functions.")
4043
4044        return self.sql(this)
4045
4046    def split_sql(self, expression: exp.Split) -> str:
4047        base_func = exp.func("STR_SPLIT", expression.this, expression.expression)
4048
4049        case_expr = exp.case().else_(base_func)
4050        needs_case = False
4051
4052        if expression.args.get("null_returns_null"):
4053            case_expr = case_expr.when(expression.expression.is_(exp.null()), exp.null())
4054            needs_case = True
4055
4056        if expression.args.get("empty_delimiter_returns_whole"):
4057            # When delimiter is empty string, return input string as single array element
4058            array_with_input = exp.array(expression.this)
4059            case_expr = case_expr.when(
4060                expression.expression.eq(exp.Literal.string("")), array_with_input
4061            )
4062            needs_case = True
4063
4064        return self.sql(case_expr if needs_case else base_func)
4065
4066    def splitpart_sql(self, expression: exp.SplitPart) -> str:
4067        string_arg = expression.this
4068        delimiter_arg = expression.args.get("delimiter")
4069        part_index_arg = expression.args.get("part_index")
4070
4071        if delimiter_arg and part_index_arg:
4072            # Handle Snowflake's "index 0 and 1 both return first element" behavior
4073            if expression.args.get("part_index_zero_as_one"):
4074                # Convert 0 to 1 for compatibility
4075
4076                part_index_arg = exp.Paren(
4077                    this=exp.case()
4078                    .when(part_index_arg.eq(exp.Literal.number("0")), exp.Literal.number("1"))
4079                    .else_(part_index_arg)
4080                )
4081
4082            # Use Anonymous to avoid recursion
4083            base_func_expr: exp.Expr = exp.Anonymous(
4084                this="SPLIT_PART", expressions=[string_arg, delimiter_arg, part_index_arg]
4085            )
4086            needs_case_transform = False
4087            case_expr = exp.case().else_(base_func_expr)
4088
4089            if expression.args.get("empty_delimiter_returns_whole"):
4090                # When delimiter is empty string:
4091                # - Return whole string if part_index is 1 or -1
4092                # - Return empty string otherwise
4093                empty_case = exp.Paren(
4094                    this=exp.case()
4095                    .when(
4096                        exp.or_(
4097                            part_index_arg.eq(exp.Literal.number("1")),
4098                            part_index_arg.eq(exp.Literal.number("-1")),
4099                        ),
4100                        string_arg,
4101                    )
4102                    .else_(exp.Literal.string(""))
4103                )
4104
4105                case_expr = case_expr.when(delimiter_arg.eq(exp.Literal.string("")), empty_case)
4106                needs_case_transform = True
4107
4108            """
4109            Output looks something like this:
4110
4111            CASE
4112            WHEN delimiter is '' THEN
4113                (
4114                    CASE
4115                    WHEN adjusted_part_index = 1 OR adjusted_part_index = -1 THEN input
4116                    ELSE '' END
4117                )
4118            ELSE SPLIT_PART(input, delimiter, adjusted_part_index)
4119            END
4120
4121            """
4122            return self.sql(case_expr if needs_case_transform else base_func_expr)
4123
4124        return self.function_fallback_sql(expression)
4125
4126    def respectnulls_sql(self, expression: exp.RespectNulls) -> str:
4127        if isinstance(expression.this, self.IGNORE_RESPECT_NULLS_WINDOW_FUNCTIONS):
4128            # DuckDB should render RESPECT NULLS only for the general-purpose
4129            # window functions that accept it e.g. FIRST_VALUE(... RESPECT NULLS) OVER (...)
4130            return super().respectnulls_sql(expression)
4131
4132        self.unsupported("RESPECT NULLS is not supported for non-window functions.")
4133        return self.sql(expression, "this")
4134
4135    def arraytostring_sql(self, expression: exp.ArrayToString) -> str:
4136        null = expression.args.get("null")
4137
4138        if expression.args.get("null_is_empty"):
4139            x = exp.to_identifier("x")
4140            list_transform = exp.Transform(
4141                this=expression.this.copy(),
4142                expression=exp.Lambda(
4143                    this=exp.Coalesce(
4144                        this=exp.cast(x, "TEXT"), expressions=[exp.Literal.string("")]
4145                    ),
4146                    expressions=[x],
4147                ),
4148            )
4149            array_to_string = exp.ArrayToString(
4150                this=list_transform, expression=expression.expression
4151            )
4152            if expression.args.get("null_delim_is_null"):
4153                return self.sql(
4154                    exp.case()
4155                    .when(expression.expression.copy().is_(exp.null()), exp.null())
4156                    .else_(array_to_string)
4157                )
4158            return self.sql(array_to_string)
4159
4160        if null:
4161            x = exp.to_identifier("x")
4162            return self.sql(
4163                exp.ArrayToString(
4164                    this=exp.Transform(
4165                        this=expression.this,
4166                        expression=exp.Lambda(
4167                            this=exp.Coalesce(this=x, expressions=[null]),
4168                            expressions=[x],
4169                        ),
4170                    ),
4171                    expression=expression.expression,
4172                )
4173            )
4174
4175        return self.func("ARRAY_TO_STRING", expression.this, expression.expression)
4176
4177    def concatws_sql(self, expression: exp.ConcatWs) -> str:
4178        # DuckDB-specific: handle binary types using DPipe (||) operator
4179        separator = seq_get(expression.expressions, 0)
4180        args = expression.expressions[1:]
4181
4182        if any(_is_binary(arg) for arg in [separator, *args]):
4183            result = args[0]
4184            for arg in args[1:]:
4185                result = exp.DPipe(
4186                    this=exp.DPipe(this=result, expression=separator), expression=arg
4187                )
4188            return self.sql(result)
4189
4190        if expression.args.get("flatten") and any(
4191            isinstance(arg, exp.Array) or arg.is_type(exp.DType.ARRAY) for arg in args
4192        ):
4193            arrays = []
4194            for arg in args:
4195                if isinstance(arg, exp.Array) or arg.is_type(exp.DType.ARRAY):
4196                    arg = exp.cast(arg, "TEXT[]")
4197                else:
4198                    if (not arg.type or arg.is_type(exp.DType.UNKNOWN)) and not isinstance(
4199                        arg, exp.CONSTANTS
4200                    ):
4201                        self.unsupported(
4202                            "Cannot transpile CONCAT_WS with unknown argument types to DuckDB."
4203                        )
4204                    arg = exp.array(exp.cast(arg, exp.DType.TEXT))
4205
4206                arrays.append(arg)
4207
4208            array = (
4209                exp.ArrayConcat(this=arrays[0], expressions=arrays[1:])
4210                if len(arrays) > 1
4211                else arrays[0]
4212            )
4213            return self.func("CONCAT_WS", separator, self.func("ARRAY_TO_STRING", array, separator))
4214
4215        return super().concatws_sql(expression)
4216
4217    def _regexp_extract_sql(self, expression: exp.RegexpExtract | exp.RegexpExtractAll) -> str:
4218        this = expression.this
4219        group = expression.args.get("group")
4220        params = expression.args.get("parameters")
4221        position = expression.args.get("position")
4222        occurrence = expression.args.get("occurrence")
4223        null_if_pos_overflow = expression.args.get("null_if_pos_overflow")
4224
4225        # Handle Snowflake's 'e' flag: it enables capture group extraction
4226        # In DuckDB, this is controlled by the group parameter directly
4227        if params and params.is_string and "e" in params.name:
4228            params = exp.Literal.string(params.name.replace("e", ""))
4229
4230        validated_flags = self._validate_regexp_flags(params, supported_flags="cims")
4231
4232        # Strip default group when no following params (DuckDB default is same as group=0)
4233        if (
4234            not validated_flags
4235            and group
4236            and group.name == str(self.dialect.REGEXP_EXTRACT_DEFAULT_GROUP)
4237        ):
4238            group = None
4239
4240        flags_expr = exp.Literal.string(validated_flags) if validated_flags else None
4241
4242        # use substring to handle position argument
4243        if position and (not position.is_int or position.to_py() > 1):
4244            this = exp.Substring(this=this, start=position)
4245
4246            if null_if_pos_overflow:
4247                this = exp.Nullif(this=this, expression=exp.Literal.string(""))
4248
4249        is_extract_all = isinstance(expression, exp.RegexpExtractAll)
4250        non_single_occurrence = occurrence and (not occurrence.is_int or occurrence.to_py() > 1)
4251
4252        if is_extract_all or non_single_occurrence:
4253            name = "REGEXP_EXTRACT_ALL"
4254        else:
4255            name = "REGEXP_EXTRACT"
4256
4257        result: exp.Expr = exp.Anonymous(
4258            this=name, expressions=[this, expression.expression, group, flags_expr]
4259        )
4260
4261        # Array slicing for REGEXP_EXTRACT_ALL with occurrence
4262        if is_extract_all and non_single_occurrence:
4263            result = exp.Bracket(this=result, expressions=[exp.Slice(this=occurrence)])
4264        # ARRAY_EXTRACT for REGEXP_EXTRACT with occurrence > 1
4265        elif non_single_occurrence:
4266            result = exp.Anonymous(this="ARRAY_EXTRACT", expressions=[result, occurrence])
4267
4268        return self.sql(result)
4269
4270    def regexpextract_sql(self, expression: exp.RegexpExtract) -> str:
4271        return self._regexp_extract_sql(expression)
4272
4273    def regexpextractall_sql(self, expression: exp.RegexpExtractAll) -> str:
4274        return self._regexp_extract_sql(expression)
4275
4276    def regexpinstr_sql(self, expression: exp.RegexpInstr) -> str:
4277        this = expression.this
4278        pattern = expression.expression
4279        position = expression.args.get("position")
4280        orig_occ = expression.args.get("occurrence")
4281        occurrence = orig_occ or exp.Literal.number(1)
4282        option = expression.args.get("option")
4283        parameters = expression.args.get("parameters")
4284
4285        validated_flags = self._validate_regexp_flags(parameters, supported_flags="ims")
4286        # Checked before the flags are embedded, which would make the pattern non-empty
4287        is_empty = pattern.copy().eq(exp.Literal.string(""))
4288        if validated_flags:
4289            pattern = exp.Concat(expressions=[exp.Literal.string(f"(?{validated_flags})"), pattern])
4290
4291        # Handle starting position offset
4292        pos_offset: exp.Expr = exp.Literal.number(0)
4293        if position and (not position.is_int or position.to_py() > 1):
4294            this = exp.Substring(this=this, start=position)
4295            pos_offset = position - exp.Literal.number(1)
4296
4297        # Helper: LIST_SUM(LIST_TRANSFORM(list[1:end], x -> LENGTH(x)))
4298        def sum_lengths(func_name: str, end: exp.Expr) -> exp.Expr:
4299            lst = exp.Bracket(
4300                this=exp.Anonymous(this=func_name, expressions=[this, pattern]),
4301                expressions=[exp.Slice(this=exp.Literal.number(1), expression=end)],
4302                offset=1,
4303            )
4304            transform = exp.Anonymous(
4305                this="LIST_TRANSFORM",
4306                expressions=[
4307                    lst,
4308                    exp.Lambda(
4309                        this=exp.Length(this=exp.to_identifier("x")),
4310                        expressions=[exp.to_identifier("x")],
4311                    ),
4312                ],
4313            )
4314            return exp.Coalesce(
4315                this=exp.Anonymous(this="LIST_SUM", expressions=[transform]),
4316                expressions=[exp.Literal.number(0)],
4317            )
4318
4319        # Position = 1 + sum(split_lengths[1:occ]) + sum(match_lengths[1:occ-1]) + offset
4320        base_pos: exp.Expr = (
4321            exp.Literal.number(1)
4322            + sum_lengths("STRING_SPLIT_REGEX", occurrence)
4323            + sum_lengths("REGEXP_EXTRACT_ALL", occurrence - exp.Literal.number(1))
4324            + pos_offset
4325        )
4326
4327        # option=1: add match length for end position
4328        if option and option.is_int and option.to_py() == 1:
4329            match_at_occ = exp.Bracket(
4330                this=exp.Anonymous(this="REGEXP_EXTRACT_ALL", expressions=[this, pattern]),
4331                expressions=[occurrence],
4332                offset=1,
4333            )
4334            base_pos = base_pos + exp.Coalesce(
4335                this=exp.Length(this=match_at_occ), expressions=[exp.Literal.number(0)]
4336            )
4337
4338        # NULL checks for all provided arguments
4339        # .copy() is used strictly because .is_() alters the node's parent pointer, mutating the parsed AST
4340        null_args = [
4341            expression.this,
4342            expression.expression,
4343            position,
4344            orig_occ,
4345            option,
4346            parameters,
4347        ]
4348        null_checks = [arg.copy().is_(exp.Null()) for arg in null_args if arg]
4349
4350        matches = exp.Anonymous(this="REGEXP_EXTRACT_ALL", expressions=[this, pattern])
4351
4352        return self.sql(
4353            exp.case()
4354            .when(exp.or_(*null_checks), exp.Null())
4355            .when(is_empty, exp.Literal.number(0))
4356            .when(exp.Length(this=matches) < occurrence, exp.Literal.number(0))
4357            .else_(base_pos)
4358        )
4359
4360    @unsupported_args("culture")
4361    def numbertostr_sql(self, expression: exp.NumberToStr) -> str:
4362        fmt = expression.args.get("format")
4363        if fmt and fmt.is_int:
4364            return self.func("FORMAT", f"'{{:,.{fmt.name}f}}'", expression.this)
4365
4366        self.unsupported("Only integer formats are supported by NumberToStr")
4367        return self.function_fallback_sql(expression)
4368
4369    def autoincrementcolumnconstraint_sql(self, _) -> str:
4370        self.unsupported("The AUTOINCREMENT column constraint is not supported by DuckDB")
4371        return ""
4372
4373    def aliases_sql(self, expression: exp.Aliases) -> str:
4374        this = expression.this
4375        if isinstance(this, exp.Posexplode):
4376            return self.posexplode_sql(this)
4377
4378        return super().aliases_sql(expression)
4379
4380    def posexplode_sql(self, expression: exp.Posexplode) -> str:
4381        this = expression.this
4382        parent = expression.parent
4383
4384        # The default Spark aliases are "pos" and "col", unless specified otherwise
4385        pos, col = exp.to_identifier("pos"), exp.to_identifier("col")
4386
4387        if isinstance(parent, exp.Aliases):
4388            # Column case: SELECT POSEXPLODE(col) [AS (a, b)]
4389            pos, col = parent.expressions
4390        elif isinstance(parent, exp.Table):
4391            # Table case: SELECT * FROM POSEXPLODE(col) [AS (a, b)]
4392            alias = parent.args.get("alias")
4393            if alias:
4394                pos, col = alias.columns or [pos, col]
4395                alias.pop()
4396
4397        # Translate POSEXPLODE to UNNEST + GENERATE_SUBSCRIPTS
4398        # Note: In Spark pos is 0-indexed, but in DuckDB it's 1-indexed, so we subtract 1 from GENERATE_SUBSCRIPTS
4399        unnest_sql = self.sql(exp.Unnest(expressions=[this], alias=col))
4400        gen_subscripts = self.sql(
4401            exp.Alias(
4402                this=exp.Anonymous(
4403                    this="GENERATE_SUBSCRIPTS", expressions=[this, exp.Literal.number(1)]
4404                )
4405                - exp.Literal.number(1),
4406                alias=pos,
4407            )
4408        )
4409
4410        posexplode_sql = self.format_args(gen_subscripts, unnest_sql)
4411
4412        if isinstance(parent, exp.From) or (parent and isinstance(parent.parent, exp.From)):
4413            # SELECT * FROM POSEXPLODE(col) -> SELECT * FROM (SELECT GENERATE_SUBSCRIPTS(...), UNNEST(...))
4414            return self.sql(exp.Subquery(this=exp.Select(expressions=[posexplode_sql])))
4415
4416        return posexplode_sql
4417
4418    def addmonths_sql(self, expression: exp.AddMonths) -> str:
4419        """
4420        Handles three key issues:
4421        1. Float/decimal months: e.g., Snowflake rounds, whereas DuckDB INTERVAL requires integers
4422        2. End-of-month preservation: If input is last day of month, result is last day of result month
4423        3. Type preservation: Maintains DATE/TIMESTAMPTZ types (DuckDB defaults to TIMESTAMP)
4424        """
4425        from sqlglot.optimizer.annotate_types import annotate_types
4426
4427        this = expression.this
4428        if not this.type:
4429            this = annotate_types(this, dialect=self.dialect)
4430
4431        if this.is_type(*exp.DataType.TEXT_TYPES):
4432            this = exp.Cast(this=this, to=exp.DataType(this=exp.DType.TIMESTAMP))
4433
4434        # Detect float/decimal months to apply rounding (Snowflake behavior)
4435        # DuckDB INTERVAL syntax doesn't support non-integer expressions, so use TO_MONTHS
4436        months_expr = expression.expression
4437        if not months_expr.type:
4438            months_expr = annotate_types(months_expr, dialect=self.dialect)
4439
4440        # Build interval or to_months expression based on type
4441        # Float/decimal case: Round and use TO_MONTHS(CAST(ROUND(value) AS INT))
4442        interval_or_to_months = (
4443            exp.func("TO_MONTHS", exp.cast(exp.func("ROUND", months_expr), "INT"))
4444            if months_expr.is_type(
4445                exp.DType.FLOAT,
4446                exp.DType.DOUBLE,
4447                exp.DType.DECIMAL,
4448            )
4449            # Integer case: standard INTERVAL N MONTH syntax
4450            else exp.Interval(this=months_expr, unit=exp.var("MONTH"))
4451        )
4452
4453        date_add_expr = exp.Add(this=this, expression=interval_or_to_months)
4454
4455        # Apply end-of-month preservation if Snowflake flag is set
4456        # CASE WHEN LAST_DAY(date) = date THEN LAST_DAY(result) ELSE result END
4457        preserve_eom = expression.args.get("preserve_end_of_month")
4458        result_expr = (
4459            exp.case()
4460            .when(
4461                exp.EQ(this=exp.func("LAST_DAY", this), expression=this),
4462                exp.func("LAST_DAY", date_add_expr),
4463            )
4464            .else_(date_add_expr)
4465            if preserve_eom
4466            else date_add_expr
4467        )
4468
4469        # DuckDB's DATE_ADD function returns TIMESTAMP/DATETIME by default, even when the input is DATE
4470        # To match for example Snowflake's ADD_MONTHS behavior (which preserves the input type)
4471        # We need to cast the result back to the original type when the input is DATE or TIMESTAMPTZ
4472        # Example: ADD_MONTHS('2023-01-31'::date, 1) should return DATE, not TIMESTAMP
4473        if this.is_type(exp.DType.DATE, exp.DType.TIMESTAMPTZ):
4474            return self.sql(exp.Cast(this=result_expr, to=this.type))
4475        return self.sql(result_expr)
4476
4477    def format_sql(self, expression: exp.Format) -> str:
4478        if expression.name.lower() == "%s" and len(expression.expressions) == 1:
4479            return self.func("FORMAT", "'{}'", expression.expressions[0])
4480
4481        return self.function_fallback_sql(expression)
4482
4483    def hexstring_sql(
4484        self, expression: exp.HexString, binary_function_repr: str | None = None
4485    ) -> str:
4486        # UNHEX('FF') correctly produces blob \xFF in DuckDB
4487        return super().hexstring_sql(expression, binary_function_repr="UNHEX")
4488
4489    def datetrunc_sql(self, expression: exp.DateTrunc) -> str:
4490        unit = expression.args.get("unit")
4491        date = expression.this
4492
4493        week_start = _week_trunc_start_dow(unit)
4494        unit = unit_to_str(expression)
4495
4496        if week_start:
4497            result = self.sql(
4498                _build_week_trunc_expression(date, week_start, preserve_start_day=True)
4499            )
4500        else:
4501            result = self.func("DATE_TRUNC", unit, date)
4502
4503        if (
4504            expression.args.get("input_type_preserved")
4505            and date.is_type(*exp.DataType.TEMPORAL_TYPES)
4506            and not (is_date_unit(unit) and date.is_type(exp.DType.DATE))
4507        ):
4508            return self.sql(exp.Cast(this=result, to=date.type))
4509
4510        return result
4511
4512    def datetimetrunc_sql(self, expression: exp.DatetimeTrunc) -> str:
4513        this = exp.cast(expression.this, exp.DType.DATETIME)
4514        week_start = _week_trunc_start_dow(expression.args.get("unit"))
4515        if week_start:
4516            return self.sql(
4517                _build_week_trunc_expression(
4518                    this, week_start, preserve_start_day=True, cast_to_date=False
4519                )
4520            )
4521
4522        return self.func("DATE_TRUNC", unit_to_str(expression), this)
4523
4524    def timestamptrunc_sql(self, expression: exp.TimestampTrunc) -> str:
4525        zone = expression.args.get("zone")
4526        timestamp = expression.this
4527        week_start = _week_trunc_start_dow(expression.args.get("unit"))
4528
4529        # The week start emulation below is exact, so avoid weekstart_unit_to_str's degrade warning
4530        unit = unit_to_str(expression) if week_start else weekstart_unit_to_str(self, expression)
4531        date_unit = is_date_unit(unit) or bool(week_start)
4532
4533        def _trunc_expr(this: exp.Expr) -> exp.Expr:
4534            if week_start:
4535                return _build_week_trunc_expression(
4536                    this, week_start, preserve_start_day=True, cast_to_date=False
4537                )
4538            return exp.func("DATE_TRUNC", unit, this)
4539
4540        if date_unit and zone:
4541            # BigQuery's TIMESTAMP_TRUNC with timezone truncates in the target timezone and returns as UTC.
4542            # Double AT TIME ZONE needed for BigQuery compatibility:
4543            # 1. First AT TIME ZONE: ensures truncation happens in the target timezone
4544            # 2. Second AT TIME ZONE: converts the DATE result back to TIMESTAMPTZ (preserving time component)
4545            timestamp = exp.AtTimeZone(this=timestamp, zone=zone)
4546            trunced = _trunc_expr(timestamp)
4547            if isinstance(trunced, exp.DateAdd):
4548                # Parenthesize so the trailing AT TIME ZONE binds to the whole shifted expression
4549                trunced = exp.Paren(this=trunced)
4550            return self.sql(exp.AtTimeZone(this=trunced, zone=zone))
4551
4552        result = self.sql(_trunc_expr(timestamp))
4553        if expression.args.get("input_type_preserved"):
4554            if timestamp.type and timestamp.is_type(exp.DType.TIME, exp.DType.TIMETZ):
4555                dummy_date = exp.Cast(
4556                    this=exp.Literal.string("1970-01-01"),
4557                    to=exp.DataType(this=exp.DType.DATE),
4558                )
4559                date_time = exp.Add(this=dummy_date, expression=timestamp)
4560                result = self.func("DATE_TRUNC", unit, date_time)
4561                return self.sql(exp.Cast(this=result, to=timestamp.type))
4562
4563            if timestamp.is_type(*exp.DataType.TEMPORAL_TYPES) and not (
4564                date_unit and timestamp.is_type(exp.DType.DATE)
4565            ):
4566                return self.sql(exp.Cast(this=result, to=timestamp.type))
4567
4568        return result
4569
4570    def trim_sql(self, expression: exp.Trim) -> str:
4571        expression.this.replace(_cast_to_varchar(expression.this))
4572        if expression.expression:
4573            expression.expression.replace(_cast_to_varchar(expression.expression))
4574
4575        result_sql = super().trim_sql(expression)
4576        return _gen_with_cast_to_blob(self, expression, result_sql)
4577
4578    def round_sql(self, expression: exp.Round) -> str:
4579        this = expression.this
4580        decimals = expression.args.get("decimals")
4581        truncate = expression.args.get("truncate")
4582
4583        # DuckDB requires the scale (decimals) argument to be an INT
4584        # Some dialects (e.g., Snowflake) allow non-integer scales and cast to an integer internally
4585        if decimals is not None and expression.args.get("casts_non_integer_decimals"):
4586            if not (decimals.is_int or decimals.is_type(*exp.DataType.INTEGER_TYPES)):
4587                decimals = exp.cast(decimals, exp.DType.INT)
4588
4589        func = "ROUND"
4590        if truncate:
4591            # BigQuery uses ROUND_HALF_EVEN; Snowflake uses HALF_TO_EVEN
4592            if truncate.this in ("ROUND_HALF_EVEN", "HALF_TO_EVEN"):
4593                func = "ROUND_EVEN"
4594                truncate = None
4595            # BigQuery uses ROUND_HALF_AWAY_FROM_ZERO; Snowflake uses HALF_AWAY_FROM_ZERO
4596            elif truncate.this in ("ROUND_HALF_AWAY_FROM_ZERO", "HALF_AWAY_FROM_ZERO"):
4597                truncate = None
4598
4599        return self.func(func, this, decimals, truncate)
4600
4601    def trycast_sql(self, expression: exp.TryCast) -> str:
4602        to = expression.to
4603        to_type = to.this
4604        src = expression.this
4605
4606        if (
4607            expression.args.get("null_on_text_overflow")
4608            and to_type in exp.DataType.TEXT_TYPES
4609            and to.expressions
4610        ):
4611            return self.sql(
4612                exp.case()
4613                .when(
4614                    exp.LTE(this=exp.func("LENGTH", src), expression=to.expressions[0].this),
4615                    exp.cast(src, "TEXT"),
4616                )
4617                .else_(exp.Null())
4618            )
4619        elif to_type == exp.DType.DATE and expression.args.get("probe_date_format"):
4620            slash_strptime = exp.cast(
4621                exp.func("TRY_STRPTIME", src, exp.Literal.string(self._TRYCAST_DATE_SLASH_FMT)),
4622                "DATE",
4623            )
4624            mon_strptime = exp.cast(
4625                exp.func("TRY_STRPTIME", src, exp.Literal.string(self._TRYCAST_DATE_MON_FMT)),
4626                "DATE",
4627            )
4628            return self.sql(
4629                exp.case()
4630                .when(exp.func("CONTAINS", src, exp.Literal.string("/")), slash_strptime)
4631                .when(
4632                    exp.RegexpLike(this=src, expression=exp.Literal.string("[A-Za-z]")),
4633                    mon_strptime,
4634                )
4635                .else_(exp.TryCast(this=src, to=to))
4636            )
4637        elif (
4638            isinstance(to_type, exp.Interval)
4639            and (unit := to_type.unit)
4640            and expression.args.get("requires_string")
4641        ):
4642            interval_type = exp.DataType.build("INTERVAL")
4643            if isinstance(unit, exp.IntervalSpan):
4644                self.unsupported(
4645                    "TRY_CAST to INTERVAL with span (e.g. HOUR TO MINUTE) is not supported in DuckDB"
4646                )
4647                return self.sql(exp.TryCast(this=src, to=interval_type))
4648            return self.sql(
4649                exp.TryCast(
4650                    this=exp.DPipe(this=src, expression=exp.Literal.string(f" {unit.name}")),
4651                    to=interval_type,
4652                )
4653            )
4654
4655        return super().trycast_sql(expression)
4656
4657    def strtok_sql(self, expression: exp.Strtok) -> str:
4658        string_arg = expression.this
4659        delimiter_arg = expression.args.get("delimiter")
4660        part_index_arg = expression.args.get("part_index")
4661
4662        if delimiter_arg and part_index_arg:
4663            # Escape regex chars and build character class at runtime using REGEXP_REPLACE
4664            escaped_delimiter = exp.Anonymous(
4665                this="REGEXP_REPLACE",
4666                expressions=[
4667                    delimiter_arg,
4668                    exp.Literal.string(
4669                        r"([\[\]^.\-*+?(){}|$\\])"
4670                    ),  # Escape problematic regex chars
4671                    exp.Literal.string(
4672                        r"\\\1"
4673                    ),  # Replace with escaped version using $1 backreference
4674                    exp.Literal.string("g"),  # Global flag
4675                ],
4676            )
4677            # CASE WHEN delimiter = '' THEN '' ELSE CONCAT('[', escaped_delimiter, ']') END
4678            regex_pattern = (
4679                exp.case()
4680                .when(delimiter_arg.eq(exp.Literal.string("")), exp.Literal.string(""))
4681                .else_(
4682                    exp.func(
4683                        "CONCAT",
4684                        exp.Literal.string("["),
4685                        escaped_delimiter,
4686                        exp.Literal.string("]"),
4687                    )
4688                )
4689            )
4690
4691            # STRTOK skips empty strings, so we need to filter them out
4692            # LIST_FILTER(REGEXP_SPLIT_TO_ARRAY(string, pattern), x -> x != '')[index]
4693            split_array = exp.func("REGEXP_SPLIT_TO_ARRAY", string_arg, regex_pattern)
4694            x = exp.to_identifier("x")
4695            is_empty = x.eq(exp.Literal.string(""))
4696            filtered_array = exp.func(
4697                "LIST_FILTER",
4698                split_array,
4699                exp.Lambda(this=exp.not_(is_empty.copy()), expressions=[x.copy()]),
4700            )
4701            base_func = exp.Bracket(
4702                this=filtered_array,
4703                expressions=[part_index_arg],
4704                offset=1,
4705            )
4706
4707            # Use template with the built regex pattern
4708            result = exp.replace_placeholders(
4709                self.STRTOK_TEMPLATE.copy(),
4710                string=string_arg,
4711                delimiter=delimiter_arg,
4712                part_index=part_index_arg,
4713                base_func=base_func,
4714            )
4715
4716            return self.sql(result)
4717
4718        return self.function_fallback_sql(expression)
4719
4720    def strtoktoarray_sql(self, expression: exp.StrtokToArray) -> str:
4721        string_arg = expression.this
4722        delimiter_arg = expression.args.get("expression") or exp.Literal.string(" ")
4723
4724        escaped = exp.RegexpReplace(
4725            this=delimiter_arg.copy(),
4726            expression=exp.Literal.string(r"([\[\]^.\-*+?(){}|$\\])"),
4727            replacement=exp.Literal.string(r"\\\1"),
4728            modifiers=exp.Literal.string("g"),
4729        )
4730        return self.sql(
4731            exp.replace_placeholders(
4732                self.STRTOK_TO_ARRAY_TEMPLATE.copy(),
4733                string=string_arg,
4734                delimiter=delimiter_arg,
4735                escaped=escaped,
4736            )
4737        )
4738
4739    def approxquantile_sql(self, expression: exp.ApproxQuantile) -> str:
4740        result = self.func("APPROX_QUANTILE", expression.this, expression.args.get("quantile"))
4741
4742        # DuckDB returns integers for APPROX_QUANTILE, cast to DOUBLE if the expected type is a real type
4743        if expression.is_type(*exp.DataType.REAL_TYPES):
4744            result = f"CAST({result} AS DOUBLE)"
4745
4746        return result
4747
4748    def approxquantiles_sql(self, expression: exp.ApproxQuantiles) -> str:
4749        """
4750        BigQuery's APPROX_QUANTILES(expr, n) returns an array of n+1 approximate quantile values
4751        dividing the input distribution into n equal-sized buckets.
4752
4753        Both BigQuery and DuckDB use approximate algorithms for quantile estimation, but BigQuery
4754        does not document the specific algorithm used so results may differ. DuckDB does not
4755        support RESPECT NULLS.
4756        """
4757        this = expression.this
4758        if isinstance(this, exp.Distinct):
4759            # APPROX_QUANTILES requires 2 args and DISTINCT node grabs both
4760            if len(this.expressions) < 2:
4761                self.unsupported("APPROX_QUANTILES requires a bucket count argument")
4762                return self.function_fallback_sql(expression)
4763            num_quantiles_expr = this.expressions[1].pop()
4764        else:
4765            num_quantiles_expr = expression.expression
4766
4767        if not isinstance(num_quantiles_expr, exp.Literal) or not num_quantiles_expr.is_int:
4768            self.unsupported("APPROX_QUANTILES bucket count must be a positive integer")
4769            return self.function_fallback_sql(expression)
4770
4771        num_quantiles = t.cast(int, num_quantiles_expr.to_py())
4772        if num_quantiles <= 0:
4773            self.unsupported("APPROX_QUANTILES bucket count must be a positive integer")
4774            return self.function_fallback_sql(expression)
4775
4776        quantiles = [
4777            exp.Literal.number(Decimal(i) / Decimal(num_quantiles))
4778            for i in range(num_quantiles + 1)
4779        ]
4780
4781        return self.sql(exp.ApproxQuantile(this=this, quantile=exp.Array(expressions=quantiles)))
4782
4783    def jsonextractscalar_sql(self, expression: exp.JSONExtractScalar) -> str:
4784        if expression.args.get("scalar_only"):
4785            json_value = exp.JSONExtractScalar(
4786                this=rename_func("JSON_VALUE")(self, expression), expression="'$'"
4787            )
4788
4789            # `->>` binds looser than most operators, so the wrap logic needs the parent
4790            json_value.parent = expression.parent
4791            expression = json_value
4792
4793        return _arrow_json_extract_sql(self, expression)
4794
4795    def bitwisenot_sql(self, expression: exp.BitwiseNot) -> str:
4796        this = expression.this
4797
4798        if _is_binary(this):
4799            expression.type = exp.DType.BINARY.into_expr()
4800
4801        arg = _cast_to_bit(this)
4802
4803        if isinstance(this, exp.Neg):
4804            arg = exp.Paren(this=arg)
4805
4806        expression.set("this", arg)
4807
4808        result_sql = f"~{self.sql(expression, 'this')}"
4809
4810        return _gen_with_cast_to_blob(self, expression, result_sql)
4811
4812    def window_sql(self, expression: exp.Window) -> str:
4813        this = expression.this
4814        if isinstance(this, exp.Corr) or (
4815            isinstance(this, exp.Filter) and isinstance(this.this, exp.Corr)
4816        ):
4817            return self._corr_sql(expression)
4818
4819        return super().window_sql(expression)
4820
4821    def filter_sql(self, expression: exp.Filter) -> str:
4822        if isinstance(expression.this, exp.Corr):
4823            return self._corr_sql(expression)
4824
4825        return super().filter_sql(expression)
4826
4827    def _corr_sql(
4828        self,
4829        expression: exp.Filter | exp.Window | exp.Corr,
4830    ) -> str:
4831        if isinstance(expression, exp.Corr) and not expression.args.get("null_on_zero_variance"):
4832            return self.func("CORR", expression.this, expression.expression)
4833
4834        corr_expr = _maybe_corr_null_to_false(expression)
4835        if corr_expr is None:
4836            if isinstance(expression, exp.Window):
4837                return super().window_sql(expression)
4838            if isinstance(expression, exp.Filter):
4839                return super().filter_sql(expression)
4840            corr_expr = expression  # make mypy happy
4841
4842        return self.sql(exp.case().when(exp.IsNan(this=corr_expr), exp.null()).else_(corr_expr))
4843
4844    def uuid_sql(self, expression: exp.Uuid) -> str:
4845        namespace = expression.this
4846        name = expression.args.get("name")
4847
4848        # UUID v5 (namespace + name) - Emulate using SHA1
4849        if namespace and name:
4850            result = exp.replace_placeholders(
4851                self.UUID_V5_TEMPLATE.copy(),
4852                namespace=namespace,
4853                name=name,
4854            )
4855            return self.sql(result)
4856
4857        return super().uuid_sql(expression)
TIMEZONE_PATTERN = re.compile(':\\d{2}.*?[+\\-]\\d{2}(?::\\d{2})?')
REGEX_ESCAPE_REPLACEMENTS = {'\\': '\\\\', '-': '\\-', '^': '\\^', '[': '\\[', ']': '\\]'}
RANDSTR_CHAR_POOL = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'
RANDSTR_SEED = 123456
WS_CONTROL_CHARS_TO_DUCK = {'\x0b': 11, '\x1c': 28, '\x1d': 29, '\x1e': 30, '\x1f': 31}
MAX_BIT_POSITION = Literal(this=32768, is_string=False)
def connect_by_to_recursive_cte( expression: sqlglot.expressions.core.Expr) -> sqlglot.expressions.core.Expr:
699def connect_by_to_recursive_cte(expression: exp.Expr) -> exp.Expr:
700    # Rewrites START WITH ... CONNECT BY PRIOR into WITH RECURSIVE
701    # Falls through unchanged if there are no PRIORs.
702    if not isinstance(expression, exp.Select) or not expression.args.get("connect"):
703        return expression
704
705    connect = expression.args["connect"]
706    connect_pred = connect.args["connect"]
707
708    priors = list(connect_pred.find_all(exp.Prior))
709    if not priors:
710        return expression
711
712    from_ = expression.args.get("from_")
713    if not from_ or expression.args.get("joins"):
714        return expression
715
716    source_table = from_.this
717    base_select_exprs = expression.expressions
718    base_where = expression.args.get("where")
719    base_with = expression.args.get("with_")
720
721    # LEVEL is a Snowflake pseudo-column: it's always computed as a depth counter in the CTE.
722    has_level = any(
723        isinstance(col, exp.Column) and col.name.upper() == "LEVEL"
724        for e in base_select_exprs
725        for col in e.find_all(exp.Column)
726    )
727    has_star = expression.is_star
728
729    # CONNECT_BY_ROOT col yields the value of `col` from the START WITH row that begins each
730    # branch. Each one is threaded through the CTE as an extra column: the anchor binds it to the
731    # row's own value, the recursive arm forwards the parent's value unchanged.
732    root_col_names: list[str] = []
733    anchor_root_cols: list[exp.Expr] = []
734    inner_root_cols: list[exp.Expr] = []
735    roots = [root for e in base_select_exprs for root in e.find_all(exp.ConnectByRoot)]
736
737    for i, root in enumerate(roots):
738        name = f"_connect_by_root_{i}"
739        root_col_names.append(name)
740        anchor_root_cols.append(exp.alias_(root.this, name))
741        inner_root_cols.append(exp.alias_(exp.column(name, "_parent_row"), name))
742        root.replace(exp.column(name))
743
744    # Build the join condition from the full CONNECT BY predicate:
745    # PRIOR(col) → _parent_row.col, unqualified cols → _child_row.col.
746    def _qualify_connect_pred(node: exp.Expression) -> exp.Expression:
747        for col in find_all_in_scope(node, exp.Column):
748            col.set(
749                "table",
750                exp.to_identifier(
751                    "_parent_row" if isinstance(col.parent, exp.Prior) else "_child_row"
752                ),
753            )
754        for prior in find_all_in_scope(node, exp.Prior):
755            prior.replace(prior.this)
756        return node
757
758    # Avoid colliding with any CTE names already on the query.
759    cte_name = find_new_name(
760        {cte.alias for cte in (base_with.expressions if base_with else [])}, "_rootcte"
761    )
762
763    # Anchor: project all source columns + seed LEVEL at 1 + bind each root column to its own value.
764    anchor = exp.select(
765        exp.Star(), exp.alias_(exp.Literal.number(1), "level"), *anchor_root_cols
766    ).from_(source_table)
767    if connect.args.get("start"):
768        anchor = anchor.where(connect.args["start"])
769
770    # Recursive arm: carry all child columns + increment level + forward each root value.
771    # SELECT * in both arms means WHERE/PRIOR columns are always available without explicit tracking.
772    inner_query = (
773        exp.select(
774            exp.Column(this=exp.Star(), table=exp.to_identifier("_child_row")),
775            exp.alias_(exp.column("level", "_parent_row") + 1, "level"),
776            *inner_root_cols,
777        )
778        .from_(source_table.as_("_child_row"))
779        .join(exp.to_table(cte_name).as_("_parent_row"), on=_qualify_connect_pred(connect_pred))
780    )
781
782    # Outer SELECT re-projects from the CTE. Synthetic level/root columns are excluded from any
783    # star expansion (level only when not referenced) but kept where explicitly projected.
784    if has_star:
785        except_cols = [] if has_level else [exp.column("level")]
786        except_cols.extend(exp.column(name) for name in root_col_names)
787        star = exp.Star(except_=except_cols) if except_cols else exp.Star()
788        outer_select_exprs: list[exp.Expr] = [
789            star,
790            *(e for e in base_select_exprs if not e.is_star),
791        ]
792    else:
793        outer_select_exprs = base_select_exprs
794    outer_query = exp.select(*outer_select_exprs).from_(cte_name)
795    if base_where:
796        outer_query = outer_query.where(base_where.this)
797
798    # Attach the CTE, marking the WITH clause recursive.
799    if base_with:
800        outer_query.set("with_", base_with)
801    outer_query = outer_query.with_(
802        cte_name, as_=anchor.union(inner_query, distinct=False), recursive=True, copy=False
803    )
804
805    for arg, val in expression.args.items():
806        if val and arg not in _CONNECT_BY_ARGS_TO_SKIP:
807            outer_query.set(arg, val)
808
809    # Strip stale source table qualifiers in one pass; CTEs are child scopes so
810    # find_all_in_scope stays within the outer query only.
811    for col in find_all_in_scope(outer_query, exp.Column):
812        col.set("table", None)
813
814    return outer_query
WRAPPED_JSON_EXTRACT_EXPRESSIONS = (<class 'sqlglot.expressions.core.Binary'>, <class 'sqlglot.expressions.core.Bracket'>, <class 'sqlglot.expressions.core.In'>, <class 'sqlglot.expressions.core.Not'>)
class DuckDBGenerator(sqlglot.generator.Generator):
1543class DuckDBGenerator(generator.Generator):
1544    PARAMETER_TOKEN = "$"
1545    NAMED_PLACEHOLDER_TOKEN = "$"
1546    JOIN_HINTS = False
1547    TABLE_HINTS = False
1548    QUERY_HINTS = False
1549    LIMIT_FETCH = "LIMIT"
1550    STRUCT_DELIMITER = ("(", ")")
1551    RENAME_TABLE_WITH_DB = False
1552    NVL2_SUPPORTED = False
1553    SEMI_ANTI_JOIN_WITH_SIDE = False
1554    CAST_TO_INT_ROUNDS = True
1555    SAFE_INT_DIVISION = True
1556    TABLESAMPLE_KEYWORDS = "USING SAMPLE"
1557    TABLESAMPLE_SEED_KEYWORD = "REPEATABLE"
1558    LAST_DAY_SUPPORTS_DATE_PART = False
1559    JSON_KEY_VALUE_PAIR_SEP = ","
1560    IGNORE_NULLS_IN_FUNC = True
1561    IGNORE_NULLS_BEFORE_ORDER = False
1562    JSON_PATH_BRACKETED_KEY_SUPPORTED = False
1563    SUPPORTS_CREATE_TABLE_LIKE = False
1564    MULTI_ARG_DISTINCT = False
1565    CAN_IMPLEMENT_ARRAY_ANY = True
1566    SUPPORTS_TO_NUMBER = False
1567    SELECT_KINDS: tuple[str, ...] = ()
1568    SUPPORTS_DECODE_CASE = False
1569    SUPPORTS_DROP_ALTER_ICEBERG_PROPERTY = False
1570
1571    # USING SAMPLE goes after QUALIFY and before ORDER BY / LIMIT
1572    # https://duckdb.org/docs/preview/sql/query_syntax/sample
1573    AFTER_HAVING_MODIFIER_TRANSFORMS = {
1574        **generator.AFTER_HAVING_MODIFIER_TRANSFORMS,
1575        "sample": lambda self, e: self.sql(e, "sample"),
1576    }
1577    SUPPORTS_WINDOW_EXCLUDE = True
1578    COPY_HAS_INTO_KEYWORD = False
1579    STAR_EXCEPT = "EXCLUDE"
1580    PAD_FILL_PATTERN_IS_REQUIRED = True
1581    ARRAY_SIZE_DIM_REQUIRED: bool | None = False
1582    NORMALIZE_EXTRACT_DATE_PARTS = True
1583    SUPPORTS_LIKE_QUANTIFIERS = False
1584    HISTORICAL_DATA_POST_ALIAS = True
1585    SET_ASSIGNMENT_REQUIRES_VARIABLE_KEYWORD = True
1586
1587    TRANSFORMS = {
1588        **generator.Generator.TRANSFORMS,
1589        exp.AnyValue: _anyvalue_sql,
1590        exp.ApproxDistinct: approx_count_distinct_sql,
1591        exp.Boolnot: _boolnot_sql,
1592        exp.Booland: _booland_sql,
1593        exp.Boolor: _boolor_sql,
1594        exp.Array: transforms.preprocess(
1595            [transforms.inherit_struct_field_names],
1596            generator=inline_array_unless_query,
1597        ),
1598        exp.ArrayAppend: array_append_sql("LIST_APPEND"),
1599        exp.ArrayCompact: array_compact_sql,
1600        exp.ArrayConstructCompact: lambda self, e: self.sql(
1601            exp.ArrayCompact(this=exp.Array(expressions=e.expressions))
1602        ),
1603        exp.ArrayConcat: array_concat_sql("LIST_CONCAT"),
1604        exp.ArrayContains: _array_contains_sql,
1605        exp.ArrayOverlaps: _array_overlaps_sql,
1606        exp.ArrayFilter: rename_func("LIST_FILTER"),
1607        exp.ArrayInsert: _array_insert_sql,
1608        exp.ArrayPosition: lambda self, e: (
1609            self.sql(
1610                exp.Sub(
1611                    this=exp.ArrayPosition(this=e.this, expression=e.expression),
1612                    expression=exp.Literal.number(1),
1613                )
1614            )
1615            if e.args.get("zero_based")
1616            else self.func("ARRAY_POSITION", e.this, e.expression)
1617        ),
1618        exp.ArrayRemoveAt: _array_remove_at_sql,
1619        exp.ArrayRemove: remove_from_array_using_filter,
1620        exp.ArraySort: _array_sort_sql,
1621        exp.ArrayPrepend: array_append_sql("LIST_PREPEND", swap_params=True),
1622        exp.ArraySum: rename_func("LIST_SUM"),
1623        exp.ArrayMax: rename_func("LIST_MAX"),
1624        exp.ArrayMin: rename_func("LIST_MIN"),
1625        exp.Base64DecodeBinary: lambda self, e: _base64_decode_sql(self, e, to_string=False),
1626        exp.Base64DecodeString: lambda self, e: _base64_decode_sql(self, e, to_string=True),
1627        exp.BitwiseAnd: lambda self, e: self._bitwise_op(e, "&"),
1628        exp.BitwiseAndAgg: _bitwise_agg_sql,
1629        exp.BitwiseCount: rename_func("BIT_COUNT"),
1630        exp.BitwiseLeftShift: _bitshift_sql,
1631        exp.BitwiseOr: lambda self, e: self._bitwise_op(e, "|"),
1632        exp.BitwiseOrAgg: _bitwise_agg_sql,
1633        exp.BitwiseRightShift: _bitshift_sql,
1634        exp.BitwiseXorAgg: _bitwise_agg_sql,
1635        exp.CommentColumnConstraint: no_comment_column_constraint_sql,
1636        exp.Corr: lambda self, e: self._corr_sql(e),
1637        exp.CosineDistance: rename_func("LIST_COSINE_DISTANCE"),
1638        exp.CurrentTime: lambda *_: "CURRENT_TIME",
1639        exp.CurrentSchemas: lambda self, e: self.func(
1640            "current_schemas", e.this if e.this else exp.true()
1641        ),
1642        exp.CurrentTimestamp: lambda self, e: (
1643            self.sql(
1644                exp.AtTimeZone(this=exp.var("CURRENT_TIMESTAMP"), zone=exp.Literal.string("UTC"))
1645            )
1646            if e.args.get("sysdate")
1647            else "CURRENT_TIMESTAMP"
1648        ),
1649        exp.CurrentVersion: rename_func("version"),
1650        exp.Localtime: unsupported_args("this")(lambda *_: "LOCALTIME"),
1651        exp.DayOfMonth: rename_func("DAYOFMONTH"),
1652        exp.DayOfWeek: rename_func("DAYOFWEEK"),
1653        exp.DayOfWeekIso: rename_func("ISODOW"),
1654        exp.DayOfYear: rename_func("DAYOFYEAR"),
1655        exp.Dayname: lambda self, e: (
1656            self.func("STRFTIME", e.this, exp.Literal.string("%a"))
1657            if e.args.get("abbreviated")
1658            else self.func("DAYNAME", e.this)
1659        ),
1660        exp.Monthname: lambda self, e: (
1661            self.func("STRFTIME", e.this, exp.Literal.string("%b"))
1662            if e.args.get("abbreviated")
1663            else self.func("MONTHNAME", e.this)
1664        ),
1665        exp.Date: _date_sql,
1666        exp.DateAdd: _date_delta_to_binary_interval_op(),
1667        exp.DateFromParts: _date_from_parts_sql,
1668        exp.DateSub: _date_delta_to_binary_interval_op(),
1669        exp.DateDiff: _date_diff_sql,
1670        exp.DateStrToDate: datestrtodate_sql,
1671        exp.Datetime: no_datetime_sql,
1672        exp.DatetimeDiff: _date_diff_sql,
1673        exp.DatetimeSub: _date_delta_to_binary_interval_op(),
1674        exp.DatetimeAdd: _date_delta_to_binary_interval_op(),
1675        exp.DateToDi: lambda self, e: (
1676            f"CAST(STRFTIME({self.sql(e, 'this')}, {self.dialect.DATEINT_FORMAT}) AS INT)"
1677        ),
1678        exp.Decode: lambda self, e: encode_decode_sql(self, e, "DECODE", replace=False),
1679        exp.HexDecodeString: lambda self, e: self.sql(exp.Decode(this=exp.Unhex(this=e.this))),
1680        exp.DiToDate: lambda self, e: (
1681            f"CAST(STRPTIME(CAST({self.sql(e, 'this')} AS TEXT), {self.dialect.DATEINT_FORMAT}) AS DATE)"
1682        ),
1683        exp.Encode: lambda self, e: encode_decode_sql(self, e, "ENCODE", replace=False),
1684        exp.EqualNull: lambda self, e: self.sql(
1685            exp.NullSafeEQ(this=e.this, expression=e.expression)
1686        ),
1687        exp.EuclideanDistance: rename_func("LIST_DISTANCE"),
1688        exp.GenerateDateArray: _generate_datetime_array_sql,
1689        exp.GenerateSeries: generate_series_sql("GENERATE_SERIES", "RANGE"),
1690        exp.GenerateTimestampArray: _generate_datetime_array_sql,
1691        exp.Getbit: getbit_sql,
1692        exp.GroupConcat: lambda self, e: groupconcat_sql(self, e, within_group=False),
1693        exp.Explode: rename_func("UNNEST"),
1694        exp.IcebergProperty: lambda *_: "",
1695        exp.IntDiv: lambda self, e: self.binary(e, "//"),
1696        exp.IsInf: rename_func("ISINF"),
1697        exp.IsNan: rename_func("ISNAN"),
1698        exp.IsNullValue: lambda self, e: self.sql(
1699            exp.func("JSON_TYPE", e.this).eq(exp.Literal.string("NULL"))
1700        ),
1701        exp.IsArray: lambda self, e: self.sql(
1702            exp.func("JSON_TYPE", e.this).eq(exp.Literal.string("ARRAY"))
1703        ),
1704        exp.Ceil: _ceil_floor,
1705        exp.Floor: _ceil_floor,
1706        exp.JSONBExists: rename_func("JSON_EXISTS"),
1707        exp.JSONExtract: _arrow_json_extract_sql,
1708        exp.JSONExtractArray: _json_extract_value_array_sql,
1709        exp.JSONFormat: _json_format_sql,
1710        exp.JSONValueArray: _json_extract_value_array_sql,
1711        exp.Lateral: _explode_to_unnest_sql,
1712        exp.LogicalOr: lambda self, e: self.func("BOOL_OR", _cast_to_boolean(e.this)),
1713        exp.LogicalAnd: lambda self, e: self.func("BOOL_AND", _cast_to_boolean(e.this)),
1714        exp.Select: transforms.preprocess(
1715            [connect_by_to_recursive_cte, _seq_to_range_in_generator]
1716        ),
1717        exp.Seq1: lambda self, e: _seq_sql(self, e, 1),
1718        exp.Seq2: lambda self, e: _seq_sql(self, e, 2),
1719        exp.Seq4: lambda self, e: _seq_sql(self, e, 4),
1720        exp.Seq8: lambda self, e: _seq_sql(self, e, 8),
1721        exp.BoolxorAgg: _boolxor_agg_sql,
1722        exp.MakeInterval: lambda self, e: no_make_interval_sql(self, e, sep=" "),
1723        exp.Initcap: _initcap_sql,
1724        exp.MD5Digest: lambda self, e: self.func("UNHEX", self.func("MD5", e.this)),
1725        exp.SHA: lambda self, e: _sha_sql(self, e, "SHA1"),
1726        exp.SHA1Digest: lambda self, e: _sha_sql(self, e, "SHA1", is_binary=True),
1727        exp.SHA2: lambda self, e: _sha_sql(self, e, "SHA256"),
1728        exp.SHA2Digest: lambda self, e: _sha_sql(self, e, "SHA256", is_binary=True),
1729        exp.MonthsBetween: months_between_sql,
1730        exp.NextDay: _day_navigation_sql,
1731        exp.PercentileCont: rename_func("QUANTILE_CONT"),
1732        exp.PercentileDisc: rename_func("QUANTILE_DISC"),
1733        # DuckDB doesn't allow qualified columns inside of PIVOT expressions.
1734        # See: https://github.com/duckdb/duckdb/blob/671faf92411182f81dce42ac43de8bfb05d9909e/src/planner/binder/tableref/bind_pivot.cpp#L61-L62
1735        exp.Pivot: transforms.preprocess([transforms.unqualify_columns]),
1736        exp.PreviousDay: _day_navigation_sql,
1737        exp.RegexpILike: lambda self, e: self.func(
1738            "REGEXP_MATCHES", e.this, e.expression, exp.Literal.string("i")
1739        ),
1740        exp.RegexpSplit: rename_func("STR_SPLIT_REGEX"),
1741        exp.RegrValx: _regr_val_sql,
1742        exp.RegrValy: _regr_val_sql,
1743        exp.Return: lambda self, e: self.sql(e, "this"),
1744        exp.ReturnsProperty: lambda self, e: "TABLE" if isinstance(e.this, exp.Schema) else "",
1745        exp.StrToUnix: lambda self, e: self.func(
1746            "EPOCH", self.func("STRPTIME", e.this, self.format_time(e))
1747        ),
1748        exp.Struct: _struct_sql,
1749        exp.Transform: rename_func("LIST_TRANSFORM"),
1750        exp.TimeAdd: _date_delta_to_binary_interval_op(),
1751        exp.TimeSub: _date_delta_to_binary_interval_op(),
1752        exp.Time: no_time_sql,
1753        exp.TimeDiff: _timediff_sql,
1754        exp.Timestamp: no_timestamp_sql,
1755        exp.TimestampAdd: _date_delta_to_binary_interval_op(),
1756        exp.TimestampDiff: lambda self, e: self.func(
1757            "DATE_DIFF", exp.Literal.string(e.unit), e.expression, e.this
1758        ),
1759        exp.TimestampSub: _date_delta_to_binary_interval_op(),
1760        exp.TimeStrToDate: lambda self, e: self.sql(exp.cast(e.this, exp.DType.DATE)),
1761        exp.TimeStrToTime: timestrtotime_sql,
1762        exp.TimeStrToUnix: lambda self, e: self.func(
1763            "EPOCH", exp.cast(e.this, exp.DType.TIMESTAMP)
1764        ),
1765        exp.TimeToStr: lambda self, e: self.func("STRFTIME", e.this, self.format_time(e)),
1766        exp.ToBoolean: _to_boolean_sql,
1767        exp.ToVariant: lambda self, e: self.sql(
1768            exp.cast(e.this, exp.DataType.from_str("VARIANT", dialect="duckdb"))
1769        ),
1770        exp.TimeToUnix: rename_func("EPOCH"),
1771        exp.TsOrDiToDi: lambda self, e: (
1772            f"CAST(SUBSTR(REPLACE(CAST({self.sql(e, 'this')} AS TEXT), '-', ''), 1, 8) AS INT)"
1773        ),
1774        exp.TsOrDsAdd: _date_delta_to_binary_interval_op(),
1775        exp.TsOrDsDiff: lambda self, e: self.func(
1776            "DATE_DIFF",
1777            f"'{e.args.get('unit') or 'DAY'}'",
1778            exp.cast(e.expression, exp.DType.TIMESTAMP),
1779            exp.cast(e.this, exp.DType.TIMESTAMP),
1780        ),
1781        exp.UnixMicros: lambda self, e: self.func("EPOCH_US", _implicit_datetime_cast(e.this)),
1782        exp.UnixMillis: lambda self, e: self.func("EPOCH_MS", _implicit_datetime_cast(e.this)),
1783        exp.UnixSeconds: lambda self, e: self.sql(
1784            exp.cast(self.func("EPOCH", _implicit_datetime_cast(e.this)), exp.DType.BIGINT)
1785        ),
1786        exp.UnixToStr: lambda self, e: self.func(
1787            "STRFTIME", self.func("TO_TIMESTAMP", e.this), self.format_time(e)
1788        ),
1789        exp.UnixToTime: _unix_to_time_sql,
1790        exp.UnixToTimeStr: lambda self, e: f"CAST(TO_TIMESTAMP({self.sql(e, 'this')}) AS TEXT)",
1791        exp.VariancePop: rename_func("VAR_POP"),
1792        exp.WeekOfYear: rename_func("WEEKOFYEAR"),
1793        exp.YearOfWeek: lambda self, e: self.sql(
1794            exp.Extract(
1795                this=exp.Var(this="ISOYEAR"),
1796                expression=e.this,
1797            )
1798        ),
1799        exp.YearOfWeekIso: lambda self, e: self.sql(
1800            exp.Extract(
1801                this=exp.Var(this="ISOYEAR"),
1802                expression=e.this,
1803            )
1804        ),
1805        exp.Xor: _xor_sql,
1806        exp.JSONObjectAgg: rename_func("JSON_GROUP_OBJECT"),
1807        exp.JSONBObjectAgg: rename_func("JSON_GROUP_OBJECT"),
1808        exp.DateBin: rename_func("TIME_BUCKET"),
1809        exp.LastDay: _last_day_sql,
1810    }
1811
1812    SUPPORTED_JSON_PATH_PARTS = {
1813        exp.JSONPathKey,
1814        exp.JSONPathRoot,
1815        exp.JSONPathSubscript,
1816        exp.JSONPathWildcard,
1817    }
1818
1819    TYPE_MAPPING = {
1820        **generator.Generator.TYPE_MAPPING,
1821        exp.DType.BINARY: "BLOB",
1822        exp.DType.BPCHAR: "TEXT",
1823        exp.DType.CHAR: "TEXT",
1824        exp.DType.DATETIME: "TIMESTAMP",
1825        exp.DType.DECFLOAT: "DECIMAL",
1826        exp.DType.FLOAT: "REAL",
1827        exp.DType.JSONB: "JSON",
1828        exp.DType.NCHAR: "TEXT",
1829        exp.DType.NVARCHAR: "TEXT",
1830        exp.DType.UINT: "UINTEGER",
1831        exp.DType.VARBINARY: "BLOB",
1832        exp.DType.ROWVERSION: "BLOB",
1833        exp.DType.VARCHAR: "TEXT",
1834        exp.DType.TIMESTAMPLTZ: "TIMESTAMPTZ",
1835        exp.DType.TIMESTAMPNTZ: "TIMESTAMP",
1836        exp.DType.TIMESTAMP_S: "TIMESTAMP_S",
1837        exp.DType.TIMESTAMP_MS: "TIMESTAMP_MS",
1838        exp.DType.TIMESTAMP_NS: "TIMESTAMP_NS",
1839        exp.DType.BIGDECIMAL: "DECIMAL",
1840    }
1841
1842    TYPE_PARAM_SETTINGS = {
1843        **generator.Generator.TYPE_PARAM_SETTINGS,
1844        exp.DType.BIGDECIMAL: ((38, 5), (38, 38)),
1845        exp.DType.DECFLOAT: ((38, 5), (38, 38)),
1846    }
1847
1848    # https://github.com/duckdb/duckdb/blob/ff7f24fd8e3128d94371827523dae85ebaf58713/third_party/libpg_query/grammar/keywords/reserved_keywords.list#L1-L77
1849    RESERVED_KEYWORDS = {
1850        "array",
1851        "analyse",
1852        "union",
1853        "all",
1854        "when",
1855        "in_p",
1856        "default",
1857        "create_p",
1858        "window",
1859        "asymmetric",
1860        "to",
1861        "else",
1862        "localtime",
1863        "from",
1864        "end_p",
1865        "select",
1866        "current_date",
1867        "foreign",
1868        "with",
1869        "grant",
1870        "session_user",
1871        "or",
1872        "except",
1873        "references",
1874        "fetch",
1875        "limit",
1876        "group_p",
1877        "leading",
1878        "into",
1879        "collate",
1880        "offset",
1881        "do",
1882        "then",
1883        "localtimestamp",
1884        "check_p",
1885        "lateral_p",
1886        "current_role",
1887        "where",
1888        "asc_p",
1889        "placing",
1890        "desc_p",
1891        "user",
1892        "unique",
1893        "initially",
1894        "column",
1895        "both",
1896        "some",
1897        "as",
1898        "any",
1899        "only",
1900        "deferrable",
1901        "null_p",
1902        "current_time",
1903        "true_p",
1904        "table",
1905        "case",
1906        "trailing",
1907        "variadic",
1908        "for",
1909        "on",
1910        "distinct",
1911        "false_p",
1912        "not",
1913        "constraint",
1914        "current_timestamp",
1915        "returning",
1916        "primary",
1917        "intersect",
1918        "having",
1919        "analyze",
1920        "current_user",
1921        "and",
1922        "cast",
1923        "symmetric",
1924        "using",
1925        "order",
1926        "current_catalog",
1927    }
1928
1929    UNWRAPPED_INTERVAL_VALUES = (exp.Literal, exp.Paren)
1930
1931    # DuckDB doesn't generally support CREATE TABLE .. properties
1932    # https://duckdb.org/docs/sql/statements/create_table.html
1933    # There are a few exceptions (e.g. temporary tables) which are supported or
1934    # can be transpiled to DuckDB, so we explicitly override them accordingly
1935    PROPERTIES_LOCATION = {
1936        **{
1937            prop: exp.Properties.Location.UNSUPPORTED
1938            for prop in generator.Generator.PROPERTIES_LOCATION
1939        },
1940        exp.LikeProperty: exp.Properties.Location.POST_SCHEMA,
1941        exp.TemporaryProperty: exp.Properties.Location.POST_CREATE,
1942        exp.ReturnsProperty: exp.Properties.Location.POST_ALIAS,
1943        exp.SequenceProperties: exp.Properties.Location.POST_EXPRESSION,
1944        exp.IcebergProperty: exp.Properties.Location.POST_CREATE,
1945    }
1946
1947    IGNORE_RESPECT_NULLS_WINDOW_FUNCTIONS: t.ClassVar = _IGNORE_RESPECT_NULLS_WINDOW_FUNCTIONS
1948
1949    # Template for ZIPF transpilation - placeholders get replaced with actual parameters
1950    ZIPF_TEMPLATE: t.ClassVar[exp.Expr] = exp.maybe_parse(
1951        """
1952        WITH rand AS (SELECT :random_expr AS r),
1953        weights AS (
1954            SELECT i, 1.0 / POWER(i, :s) AS w
1955            FROM RANGE(1, :n + 1) AS t(i)
1956        ),
1957        cdf AS (
1958            SELECT i, SUM(w) OVER (ORDER BY i) / SUM(w) OVER () AS p
1959            FROM weights
1960        )
1961        SELECT MIN(i)
1962        FROM cdf
1963        WHERE p >= (SELECT r FROM rand)
1964        """
1965    )
1966
1967    # Template for NORMAL transpilation using Box-Muller transform
1968    # mean + (stddev * sqrt(-2 * ln(u1)) * cos(2 * pi * u2))
1969    NORMAL_TEMPLATE: t.ClassVar[exp.Expr] = exp.maybe_parse(
1970        ":mean + (:stddev * SQRT(-2 * LN(GREATEST(:u1, 1e-10))) * COS(2 * PI() * :u2))"
1971    )
1972
1973    # Template for generating a seeded pseudo-random value in [0, 1) from a hash
1974    SEEDED_RANDOM_TEMPLATE: t.ClassVar[exp.Expr] = exp.maybe_parse(
1975        "(ABS(HASH(:seed)) % 1000000) / 1000000.0"
1976    )
1977
1978    # Template for generating signed and unsigned SEQ values within a specified range
1979    SEQ_UNSIGNED: t.ClassVar[exp.Expr] = _SEQ_UNSIGNED
1980    SEQ_SIGNED: t.ClassVar[exp.Expr] = _SEQ_SIGNED
1981
1982    # Template for MAP_CAT transpilation - Snowflake semantics:
1983    # 1. Returns NULL if either input is NULL
1984    # 2. For duplicate keys, prefers non-NULL value (COALESCE(m2[k], m1[k]))
1985    # 3. Filters out entries with NULL values from the result
1986    MAPCAT_TEMPLATE: t.ClassVar[exp.Expr] = exp.maybe_parse(
1987        """
1988        CASE
1989            WHEN :map1 IS NULL OR :map2 IS NULL THEN NULL
1990            ELSE MAP_FROM_ENTRIES(LIST_FILTER(LIST_TRANSFORM(
1991                LIST_DISTINCT(LIST_CONCAT(MAP_KEYS(:map1), MAP_KEYS(:map2))),
1992                __k -> STRUCT_PACK(key := __k, value := COALESCE(:map2[__k], :map1[__k]))
1993            ), __x -> __x.value IS NOT NULL))
1994        END
1995        """
1996    )
1997
1998    # Mappings for EXTRACT/DATE_PART transpilation
1999    # Maps Snowflake specifiers unsupported in DuckDB to strftime format codes
2000    EXTRACT_STRFTIME_MAPPINGS: t.ClassVar[dict[str, tuple[str, str]]] = {
2001        "WEEKISO": ("%V", "INTEGER"),
2002        "YEAROFWEEK": ("%G", "INTEGER"),
2003        "YEAROFWEEKISO": ("%G", "INTEGER"),
2004        "NANOSECOND": ("%n", "BIGINT"),
2005    }
2006
2007    # Maps epoch-based specifiers to DuckDB epoch functions
2008    EXTRACT_EPOCH_MAPPINGS: t.ClassVar[dict[str, str]] = {
2009        "EPOCH_SECOND": "EPOCH",
2010        "EPOCH_MILLISECOND": "EPOCH_MS",
2011        "EPOCH_MICROSECOND": "EPOCH_US",
2012        "EPOCH_NANOSECOND": "EPOCH_NS",
2013    }
2014
2015    # Template for BITMAP_CONSTRUCT_AGG transpilation
2016    #
2017    # BACKGROUND:
2018    # Snowflake's BITMAP_CONSTRUCT_AGG aggregates integers into a compact binary bitmap.
2019    # Supports values in range 0-32767, this version returns NULL if any value is out of range
2020    # See: https://docs.snowflake.com/en/sql-reference/functions/bitmap_construct_agg
2021    # See: https://docs.snowflake.com/en/user-guide/querying-bitmaps-for-distinct-counts
2022    #
2023    # Snowflake uses two different formats based on the number of unique values:
2024    #
2025    # Format 1 - Small bitmap (< 5 unique values): Length of 10 bytes
2026    #   Bytes 0-1: Count of values as 2-byte big-endian integer (e.g., 3 values = 0x0003)
2027    #   Bytes 2-9: Up to 4 values, each as 2-byte little-endian integers, zero-padded to 8 bytes
2028    #   Example: Values [1, 2, 3] -> 0x0003 0100 0200 0300 0000 (hex)
2029    #                                count  v1   v2   v3   pad
2030    #
2031    # Format 2 - Large bitmap (>= 5 unique values): Length of 10 + (2 * count) bytes
2032    #   Bytes 0-9: Fixed header 0x08 followed by 9 zero bytes
2033    #   Bytes 10+: Each value as 2-byte little-endian integer (no padding)
2034    #   Example: Values [1,2,3,4,5] -> 0x08 00000000 00000000 00 0100 0200 0300 0400 0500
2035    #                                  hdr  ----9 zero bytes----  v1   v2   v3   v4   v5
2036    #
2037    # TEMPLATE STRUCTURE
2038    #
2039    # Phase 1 - Innermost subquery: Data preparation
2040    #   SELECT LIST_SORT(...) AS l
2041    #   - Aggregates all input values into a list, remove NULLs, duplicates and sorts
2042    #   Result: Clean, sorted list of unique non-null integers stored as 'l'
2043    #
2044    # Phase 2 - Middle subquery: Hex string construction
2045    #   LIST_TRANSFORM(...)
2046    #   - Converts each integer to 2-byte little-endian hex representation
2047    #   - & 255 extracts low byte, >> 8 extracts high byte
2048    #   - LIST_REDUCE: Concatenates all hex pairs into single string 'h'
2049    #   Result: Hex string of all values
2050    #
2051    # Phase 3 - Outer SELECT: Final bitmap assembly
2052    #   LENGTH(l) < 5:
2053    #   - Small format: 2-byte count (big-endian via %04X) + values + zero padding
2054    #   LENGTH(l) >= 5:
2055    #   - Large format: Fixed 10-byte header + values (no padding needed)
2056    #   Result: Complete binary bitmap as BLOB
2057    #
2058    BITMAP_CONSTRUCT_AGG_TEMPLATE: t.ClassVar[exp.Expr] = exp.maybe_parse(
2059        """
2060        SELECT CASE
2061            WHEN l IS NULL OR LENGTH(l) = 0 THEN NULL
2062            WHEN LENGTH(l) != LENGTH(LIST_FILTER(l, __v -> __v BETWEEN 0 AND 32767)) THEN NULL
2063            WHEN LENGTH(l) < 5 THEN UNHEX(PRINTF('%04X', LENGTH(l)) || h || REPEAT('00', GREATEST(0, 4 - LENGTH(l)) * 2))
2064            ELSE UNHEX('08000000000000000000' || h)
2065        END
2066        FROM (
2067            SELECT l, COALESCE(LIST_REDUCE(
2068                LIST_TRANSFORM(l, __x -> PRINTF('%02X%02X', CAST(__x AS INT) & 255, (CAST(__x AS INT) >> 8) & 255)),
2069                (__a, __b) -> __a || __b, ''
2070            ), '') AS h
2071            FROM (SELECT LIST_SORT(LIST_DISTINCT(LIST(:arg) FILTER(NOT :arg IS NULL))) AS l)
2072        )
2073        """
2074    )
2075
2076    # Template for RANDSTR transpilation - placeholders get replaced with actual parameters
2077    RANDSTR_TEMPLATE: t.ClassVar[exp.Expr] = exp.maybe_parse(
2078        f"""
2079        SELECT LISTAGG(
2080            SUBSTRING(
2081                '{RANDSTR_CHAR_POOL}',
2082                1 + CAST(FLOOR(random_value * 62) AS INT),
2083                1
2084            ),
2085            ''
2086        )
2087        FROM (
2088            SELECT (ABS(HASH(i + :seed)) % 1000) / 1000.0 AS random_value
2089            FROM RANGE(:length) AS t(i)
2090        )
2091        """,
2092    )
2093
2094    # Template for MINHASH transpilation
2095    # Computes k minimum hash values across aggregated data using DuckDB list functions
2096    # Returns JSON matching Snowflake format: {"state": [...], "type": "minhash", "version": 1}
2097    MINHASH_TEMPLATE: t.ClassVar[exp.Expr] = exp.maybe_parse(
2098        """
2099        SELECT JSON_OBJECT('state', LIST(min_h ORDER BY seed), 'type', 'minhash', 'version', 1)
2100        FROM (
2101            SELECT seed, LIST_MIN(LIST_TRANSFORM(vals, __v -> HASH(CAST(__v AS VARCHAR) || CAST(seed AS VARCHAR)))) AS min_h
2102            FROM (SELECT LIST(:expr) AS vals), RANGE(0, :k) AS t(seed)
2103        )
2104        """,
2105    )
2106
2107    # Template for MINHASH_COMBINE transpilation
2108    # Combines multiple minhash signatures by taking element-wise minimum
2109    MINHASH_COMBINE_TEMPLATE: t.ClassVar[exp.Expr] = exp.maybe_parse(
2110        """
2111        SELECT JSON_OBJECT('state', LIST(min_h ORDER BY idx), 'type', 'minhash', 'version', 1)
2112        FROM (
2113            SELECT
2114                pos AS idx,
2115                MIN(val) AS min_h
2116            FROM
2117                UNNEST(LIST(:expr)) AS _(sig),
2118                UNNEST(CAST(sig -> 'state' AS UBIGINT[])) WITH ORDINALITY AS t(val, pos)
2119            GROUP BY pos
2120        )
2121        """,
2122    )
2123
2124    # Template for APPROXIMATE_SIMILARITY transpilation
2125    # Computes multi-way Jaccard similarity: fraction of positions where ALL signatures agree
2126    APPROXIMATE_SIMILARITY_TEMPLATE: t.ClassVar[exp.Expr] = exp.maybe_parse(
2127        """
2128        SELECT CAST(SUM(CASE WHEN num_distinct = 1 THEN 1 ELSE 0 END) AS DOUBLE) / COUNT(*)
2129        FROM (
2130            SELECT pos, COUNT(DISTINCT h) AS num_distinct
2131            FROM (
2132                SELECT h, pos
2133                FROM UNNEST(LIST(:expr)) AS _(sig),
2134                     UNNEST(CAST(sig -> 'state' AS UBIGINT[])) WITH ORDINALITY AS s(h, pos)
2135            )
2136            GROUP BY pos
2137        )
2138        """,
2139    )
2140
2141    # Template for ARRAYS_ZIP transpilation
2142    # Snowflake pads to longest array; DuckDB LIST_ZIP truncates to shortest
2143    # Uses RANGE + indexing to match Snowflake behavior
2144    ARRAYS_ZIP_TEMPLATE: t.ClassVar[exp.Expr] = exp.maybe_parse(
2145        """
2146        CASE WHEN :null_check THEN NULL
2147        WHEN :all_empty_check THEN [:empty_struct]
2148        ELSE LIST_TRANSFORM(RANGE(0, :max_len), __i -> :transform_struct)
2149        END
2150        """,
2151    )
2152
2153    UUID_V5_TEMPLATE: t.ClassVar[exp.Expr] = exp.maybe_parse(
2154        """
2155        (SELECT
2156            LOWER(
2157                SUBSTR(h, 1, 8) || '-' ||
2158                SUBSTR(h, 9, 4) || '-' ||
2159                '5' || SUBSTR(h, 14, 3) || '-' ||
2160                FORMAT('{:02x}', CAST('0x' || SUBSTR(h, 17, 2) AS INT) & 63 | 128) || SUBSTR(h, 19, 2) || '-' ||
2161                SUBSTR(h, 21, 12)
2162            )
2163        FROM (
2164            SELECT SUBSTR(SHA1(UNHEX(REPLACE(:namespace, '-', '')) || ENCODE(:name, 'utf8')), 1, 32) AS h
2165        ))
2166        """
2167    )
2168
2169    # Shared bag semantics outer frame for ARRAY_EXCEPT and ARRAY_INTERSECTION.
2170    # Each element is paired with its 1-based position via LIST_ZIP, then filtered
2171    # by a comparison operator (supplied via :cond) that determines the operation:
2172    #   EXCEPT (>):        keep the N-th occurrence only if N > count in arr2
2173    #                      e.g. [2,2,2] EXCEPT [2,2] -> [2]
2174    #   INTERSECTION (<=): keep the N-th occurrence only if N <= count in arr2
2175    #                      e.g. [2,2,2] INTERSECT [2,2] -> [2,2]
2176    # IS NOT DISTINCT FROM is used for NULL-safe element comparison.
2177    ARRAY_BAG_TEMPLATE: t.ClassVar[exp.Expr] = exp.maybe_parse(
2178        """
2179        CASE
2180            WHEN :arr1 IS NULL OR :arr2 IS NULL THEN NULL
2181            ELSE LIST_TRANSFORM(
2182                LIST_FILTER(
2183                    LIST_ZIP(:arr1, GENERATE_SERIES(1, LEN(:arr1))),
2184                    pair -> :cond
2185                ),
2186                pair -> pair[0]
2187            )
2188        END
2189        """
2190    )
2191
2192    ARRAY_EXCEPT_CONDITION: t.ClassVar[exp.Expr] = exp.maybe_parse(
2193        "LEN(LIST_FILTER(:arr1[1:pair[1]], e -> e IS NOT DISTINCT FROM pair[0]))"
2194        " > LEN(LIST_FILTER(:arr2, e -> e IS NOT DISTINCT FROM pair[0]))"
2195    )
2196
2197    ARRAY_INTERSECTION_CONDITION: t.ClassVar[exp.Expr] = exp.maybe_parse(
2198        "LEN(LIST_FILTER(:arr1[1:pair[1]], e -> e IS NOT DISTINCT FROM pair[0]))"
2199        " <= LEN(LIST_FILTER(:arr2, e -> e IS NOT DISTINCT FROM pair[0]))"
2200    )
2201
2202    # Set semantics for ARRAY_EXCEPT. Deduplicates arr1 via LIST_DISTINCT, then
2203    # filters out any element that appears at least once in arr2.
2204    #   e.g. [1,1,2,3] EXCEPT [1] -> [2,3]
2205    # IS NOT DISTINCT FROM is used for NULL-safe element comparison.
2206    ARRAY_EXCEPT_SET_TEMPLATE: t.ClassVar[exp.Expr] = exp.maybe_parse(
2207        """
2208        CASE
2209            WHEN :arr1 IS NULL OR :arr2 IS NULL THEN NULL
2210            ELSE LIST_FILTER(
2211                LIST_DISTINCT(:arr1),
2212                e -> LEN(LIST_FILTER(:arr2, x -> x IS NOT DISTINCT FROM e)) = 0
2213            )
2214        END
2215        """
2216    )
2217
2218    # BigQuery's `x IN UNNEST(arr)` NULL semantics:
2219    #   NULL IN UNNEST([1, 2])  -> NULL
2220    #   3 IN UNNEST([1, NULL])  -> NULL
2221    #   3 IN UNNEST([1, 2])     -> FALSE
2222    #   1 IN UNNEST(NULL)       -> FALSE (not NULL)
2223    #   1 IN UNNEST([])         -> FALSE
2224    # The default `IN (SELECT UNNEST(...))` rewrite creates a correlated subquery
2225    # that DuckDB rejects inside non-inner joins, so a CASE expression is used instead.
2226    IN_UNNEST_TEMPLATE: t.ClassVar[exp.Expr] = exp.maybe_parse(
2227        """
2228        CASE
2229            WHEN :arr IS NULL OR ARRAY_LENGTH(:arr) = 0 THEN FALSE
2230            WHEN ARRAY_CONTAINS(:arr, :value) THEN TRUE
2231            WHEN :value IS NULL OR ARRAY_LENGTH(:arr) <> LIST_COUNT(:arr) THEN NULL
2232            ELSE FALSE
2233        END
2234        """
2235    )
2236
2237    STRTOK_TO_ARRAY_TEMPLATE: t.ClassVar[exp.Expr] = exp.maybe_parse(
2238        """
2239        CASE WHEN :delimiter IS NULL THEN NULL
2240        ELSE LIST_FILTER(
2241            REGEXP_SPLIT_TO_ARRAY(:string, CASE WHEN :delimiter = '' THEN '.^' ELSE CONCAT('[', :escaped, ']') END),
2242            x -> NOT x = ''
2243        ) END
2244        """
2245    )
2246
2247    # Template for STRTOK function transpilation
2248    #
2249    # DuckDB itself doesn't have a strtok function. This handles the transpilation from Snowflake to DuckDB.
2250    # We may need to adjust this if we want to support transpilation from other dialects
2251    #
2252    # CASE
2253    #     -- Snowflake: empty delimiter + empty input string -> NULL
2254    #     WHEN delimiter = '' AND input_str = '' THEN NULL
2255    #
2256    #     -- Snowflake: empty delimiter + non-empty input string -> treats whole input as 1 token -> return input string if index is 1
2257    #     WHEN delimiter = '' AND index = 1 THEN input_str
2258    #
2259    #     -- Snowflake: empty delimiter + non-empty input string -> treats whole input as 1 token -> return NULL if index is not 1
2260    #     WHEN delimiter = '' THEN NULL
2261    #
2262    #     -- Snowflake: negative indices return NULL
2263    #     WHEN index < 0 THEN NULL
2264    #
2265    #     -- Snowflake: return NULL if any argument is NULL
2266    #     WHEN input_str IS NULL OR delimiter IS NULL OR index IS NULL THEN NULL
2267    #
2268    #
2269    #     ELSE LIST_FILTER(
2270    #         REGEXP_SPLIT_TO_ARRAY(
2271    #             input_str,
2272    #             CASE
2273    #                 -- if delimiter is '', we don't want to surround it with '[' and ']' as '[]' is invalid for DuckDB
2274    #                 WHEN delimiter = '' THEN ''
2275    #
2276    #                 -- handle problematic regex characters in delimiter with REGEXP_REPLACE
2277    #                 -- turn delimiter into a regex char set, otherwise DuckDB will match in order, which we don't want
2278    #                 ELSE '[' || REGEXP_REPLACE(delimiter, problematic_char_set, '\\\1', 'g') || ']'
2279    #             END
2280    #         ),
2281    #
2282    #         -- Snowflake: don't return empty strings
2283    #         x -> NOT x = ''
2284    #     )[index]
2285    # END
2286    STRTOK_TEMPLATE: t.ClassVar[exp.Expr] = exp.maybe_parse(
2287        """
2288        CASE
2289            WHEN :delimiter = '' AND :string = '' THEN NULL
2290            WHEN :delimiter = '' AND :part_index = 1 THEN :string
2291            WHEN :delimiter = '' THEN NULL
2292            WHEN :part_index < 0 THEN NULL
2293            WHEN :string IS NULL OR :delimiter IS NULL OR :part_index IS NULL THEN NULL
2294            ELSE :base_func
2295        END
2296        """
2297    )
2298
2299    # Snowflake AUTO detects 3 DATE formats: YYYY-MM-DD (ISO-8601), MM/DD/YYYY, DD-MON-YYYY.
2300    # DuckDB TRY_CAST handles ISO-8601 natively. For the other two formats we use CONTAINS('/')
2301    # and REGEXP_MATCHES('[A-Za-z]') as heuristics — these correctly handle single-digit months
2302    # and days (e.g. 1/5/2020, 5-JAN-2020) where a positional char check would fail.
2303    # Ref: https://docs.snowflake.com/en/sql-reference/date-time-input-output#date-formats
2304    _TRYCAST_DATE_SLASH_FMT = "%m/%d/%Y"
2305    _TRYCAST_DATE_MON_FMT = "%d-%b-%Y"
2306
2307    def datatype_sql(self, expression: exp.DataType) -> str:
2308        if expression.is_type(exp.DType.ARRAY):
2309            return f"{self.expressions(expression, flat=True)}[{self.expressions(expression, key='values', flat=True)}]"
2310
2311        # Modifiers are not supported for TIME, [TIME | TIMESTAMP] WITH TIME ZONE
2312        if expression.is_type(
2313            exp.DType.TIME, exp.DType.TIMETZ, exp.DType.TIMESTAMPTZ, exp.DType.TIMESTAMPLTZ
2314        ):
2315            expression.set("expressions", None)
2316
2317        return super().datatype_sql(expression)
2318
2319    def _array_bag_sql(self, condition: exp.Expr, arr1: exp.Expr, arr2: exp.Expr) -> str:
2320        cond = exp.Paren(this=exp.replace_placeholders(condition, arr1=arr1, arr2=arr2))
2321        return self.sql(
2322            exp.replace_placeholders(self.ARRAY_BAG_TEMPLATE, arr1=arr1, arr2=arr2, cond=cond)
2323        )
2324
2325    def timeslice_sql(self, expression: exp.TimeSlice) -> str:
2326        """
2327        Transform Snowflake's TIME_SLICE to DuckDB's time_bucket.
2328
2329        Snowflake: TIME_SLICE(date_expr, slice_length, 'UNIT' [, 'START'|'END'])
2330        DuckDB:    time_bucket(INTERVAL 'slice_length' UNIT, date_expr)
2331
2332        For 'END' kind, add the interval to get the end of the slice.
2333        For DATE type with 'END', cast result back to DATE to preserve type.
2334        """
2335        date_expr = expression.this
2336        slice_length = expression.expression
2337        unit = expression.unit
2338        kind = expression.text("kind").upper()
2339
2340        # Create INTERVAL expression: INTERVAL 'N' UNIT
2341        interval_expr = exp.Interval(this=slice_length, unit=unit)
2342
2343        # Create base time_bucket expression
2344        time_bucket_expr = exp.func("time_bucket", interval_expr, date_expr)
2345
2346        # Check if we need the end of the slice (default is start)
2347        if not kind == "END":
2348            # For 'START', return time_bucket directly
2349            return self.sql(time_bucket_expr)
2350
2351        # For 'END', add the interval to get end of slice
2352        add_expr = exp.Add(this=time_bucket_expr, expression=interval_expr.copy())
2353
2354        # If input is DATE type, cast result back to DATE to preserve type
2355        # DuckDB converts DATE to TIMESTAMP when adding intervals
2356        if date_expr.is_type(exp.DType.DATE):
2357            return self.sql(exp.cast(add_expr, exp.DType.DATE))
2358
2359        return self.sql(add_expr)
2360
2361    def bytestring_sql(self, expression: exp.ByteString) -> str:
2362        if not expression.args.get("is_bytes"):
2363            return super().bytestring_sql(expression)
2364
2365        # Each char is a byte. Casting to BLOB decodes \xHH, so everything but printable ASCII
2366        # (including the backslash itself) is written as such
2367        text = "".join(
2368            ch if ch.isascii() and ch.isprintable() and ch != "\\" else f"\\x{ord(ch):02x}"
2369            for ch in expression.this
2370        )
2371        return self.sql(exp.cast(exp.Literal.string(text), exp.DType.BINARY))
2372
2373    def bitmapbucketnumber_sql(self, expression: exp.BitmapBucketNumber) -> str:
2374        """
2375        Transpile BITMAP_BUCKET_NUMBER function from Snowflake to DuckDB equivalent.
2376
2377        Snowflake's BITMAP_BUCKET_NUMBER returns a 1-based bucket identifier where:
2378        - Each bucket covers 32,768 values
2379        - Bucket numbering starts at 1
2380        - Formula: ((value - 1) // 32768) + 1 for positive values
2381
2382        For non-positive values (0 and negative), we use value // 32768 to avoid
2383        producing bucket 0 or positive bucket IDs for negative inputs.
2384        """
2385        value = expression.this
2386
2387        positive_formula = ((value - 1) // 32768) + 1
2388        non_positive_formula = value // 32768
2389
2390        # CASE WHEN value > 0 THEN ((value - 1) // 32768) + 1 ELSE value // 32768 END
2391        case_expr = (
2392            exp.case()
2393            .when(exp.GT(this=value, expression=exp.Literal.number(0)), positive_formula)
2394            .else_(non_positive_formula)
2395        )
2396        return self.sql(case_expr)
2397
2398    def bitmapbitposition_sql(self, expression: exp.BitmapBitPosition) -> str:
2399        """
2400        Transpile Snowflake's BITMAP_BIT_POSITION to DuckDB CASE expression.
2401
2402        Snowflake's BITMAP_BIT_POSITION behavior:
2403        - For n <= 0: returns ABS(n) % 32768
2404        - For n > 0: returns (n - 1) % 32768 (maximum return value is 32767)
2405        """
2406        this = expression.this
2407
2408        return self.sql(
2409            exp.Mod(
2410                this=exp.Paren(
2411                    this=exp.If(
2412                        this=exp.GT(this=this, expression=exp.Literal.number(0)),
2413                        true=this - exp.Literal.number(1),
2414                        false=exp.Abs(this=this),
2415                    )
2416                ),
2417                expression=MAX_BIT_POSITION,
2418            )
2419        )
2420
2421    def bitmapconstructagg_sql(self, expression: exp.BitmapConstructAgg) -> str:
2422        """
2423        Transpile Snowflake's BITMAP_CONSTRUCT_AGG to DuckDB equivalent.
2424        Uses a pre-parsed template with placeholders replaced by expression nodes.
2425
2426        Snowflake bitmap format:
2427        - Small (< 5 unique values): 2-byte count (big-endian) + values (little-endian) + padding to 10 bytes
2428        - Large (>= 5 unique values): 10-byte header (0x08 + 9 zeros) + values (little-endian)
2429        """
2430        arg = expression.this
2431        return (
2432            f"({self.sql(exp.replace_placeholders(self.BITMAP_CONSTRUCT_AGG_TEMPLATE, arg=arg))})"
2433        )
2434
2435    def getignorecase_sql(self, expression: exp.GetIgnoreCase) -> str:
2436        self.unsupported("DuckDB does not support the GET_IGNORE_CASE() function")
2437        return self.function_fallback_sql(expression)
2438
2439    def compress_sql(self, expression: exp.Compress) -> str:
2440        self.unsupported("DuckDB does not support the COMPRESS() function")
2441        return self.function_fallback_sql(expression)
2442
2443    def encrypt_sql(self, expression: exp.Encrypt) -> str:
2444        self.unsupported("ENCRYPT is not supported in DuckDB")
2445        return self.function_fallback_sql(expression)
2446
2447    def decrypt_sql(self, expression: exp.Decrypt) -> str:
2448        func_name = "TRY_DECRYPT" if expression.args.get("safe") else "DECRYPT"
2449        self.unsupported(f"{func_name} is not supported in DuckDB")
2450        return self.function_fallback_sql(expression)
2451
2452    def decryptraw_sql(self, expression: exp.DecryptRaw) -> str:
2453        func_name = "TRY_DECRYPT_RAW" if expression.args.get("safe") else "DECRYPT_RAW"
2454        self.unsupported(f"{func_name} is not supported in DuckDB")
2455        return self.function_fallback_sql(expression)
2456
2457    def encryptraw_sql(self, expression: exp.EncryptRaw) -> str:
2458        self.unsupported("ENCRYPT_RAW is not supported in DuckDB")
2459        return self.function_fallback_sql(expression)
2460
2461    def parseurl_sql(self, expression: exp.ParseUrl) -> str:
2462        self.unsupported("PARSE_URL is not supported in DuckDB")
2463        return self.function_fallback_sql(expression)
2464
2465    def parseip_sql(self, expression: exp.ParseIp) -> str:
2466        self.unsupported("PARSE_IP is not supported in DuckDB")
2467        return self.function_fallback_sql(expression)
2468
2469    def decompressstring_sql(self, expression: exp.DecompressString) -> str:
2470        self.unsupported("DECOMPRESS_STRING is not supported in DuckDB")
2471        return self.function_fallback_sql(expression)
2472
2473    def decompressbinary_sql(self, expression: exp.DecompressBinary) -> str:
2474        self.unsupported("DECOMPRESS_BINARY is not supported in DuckDB")
2475        return self.function_fallback_sql(expression)
2476
2477    def jarowinklersimilarity_sql(self, expression: exp.JarowinklerSimilarity) -> str:
2478        this = expression.this
2479        expr = expression.expression
2480
2481        if expression.args.get("case_insensitive"):
2482            this = exp.Upper(this=this)
2483            expr = exp.Upper(this=expr)
2484
2485        result = exp.func("JARO_WINKLER_SIMILARITY", this, expr)
2486
2487        if expression.args.get("integer_scale"):
2488            result = exp.cast(result * 100, "INTEGER")
2489
2490        return self.sql(result)
2491
2492    def randstr_sql(self, expression: exp.Randstr) -> str:
2493        """
2494        Transpile Snowflake's RANDSTR to DuckDB equivalent using deterministic hash-based random.
2495        Uses a pre-parsed template with placeholders replaced by expression nodes.
2496
2497        RANDSTR(length, generator) generates a random string of specified length.
2498        - With numeric seed: Use HASH(i + seed) for deterministic output (same seed = same result)
2499        - With RANDOM(): Use RANDOM() in the hash for non-deterministic output
2500        - No generator: Use default seed value
2501        """
2502        length = expression.this
2503        generator = expression.args.get("generator")
2504
2505        if generator:
2506            if isinstance(generator, exp.Rand):
2507                # If it's RANDOM(), use its seed if available, otherwise use RANDOM() itself
2508                seed_value = generator.this or generator
2509            else:
2510                # Const/int or other expression - use as seed directly
2511                seed_value = generator
2512        else:
2513            # No generator specified, use default seed (arbitrary but deterministic)
2514            seed_value = exp.Literal.number(RANDSTR_SEED)
2515
2516        replacements = {"seed": seed_value, "length": length}
2517        return f"({self.sql(exp.replace_placeholders(self.RANDSTR_TEMPLATE, **replacements))})"
2518
2519    @unsupported_args("finish")
2520    def reduce_sql(self, expression: exp.Reduce) -> str:
2521        array_arg = expression.this
2522        initial_value = expression.args.get("initial")
2523        merge_lambda = expression.args.get("merge")
2524
2525        if merge_lambda:
2526            merge_lambda.set("colon", True)
2527
2528        return self.func("list_reduce", array_arg, merge_lambda, initial_value)
2529
2530    def zipf_sql(self, expression: exp.Zipf) -> str:
2531        """
2532        Transpile Snowflake's ZIPF to DuckDB using CDF-based inverse sampling.
2533        Uses a pre-parsed template with placeholders replaced by expression nodes.
2534        """
2535        s = expression.this
2536        n = expression.args["elementcount"]
2537        gen = expression.args["gen"]
2538
2539        if not isinstance(gen, exp.Rand):
2540            # (ABS(HASH(seed)) % 1000000) / 1000000.0
2541            random_expr: exp.Expr = exp.Div(
2542                this=exp.Paren(
2543                    this=exp.Mod(
2544                        this=exp.Abs(this=exp.Anonymous(this="HASH", expressions=[gen.copy()])),
2545                        expression=exp.Literal.number(1000000),
2546                    )
2547                ),
2548                expression=exp.Literal.number(1000000.0),
2549            )
2550        else:
2551            # Use RANDOM() for non-deterministic output
2552            random_expr = exp.Rand()
2553
2554        replacements = {"s": s, "n": n, "random_expr": random_expr}
2555        return f"({self.sql(exp.replace_placeholders(self.ZIPF_TEMPLATE, **replacements))})"
2556
2557    def tobinary_sql(self, expression: exp.ToBinary) -> str:
2558        """
2559        TO_BINARY and TRY_TO_BINARY transpilation:
2560        - 'HEX': TO_BINARY('48454C50', 'HEX') -> UNHEX('48454C50')
2561        - 'UTF-8': TO_BINARY('TEST', 'UTF-8') -> ENCODE('TEST')
2562        - 'BASE64': TO_BINARY('SEVMUA==', 'BASE64') -> FROM_BASE64('SEVMUA==')
2563
2564        For TRY_TO_BINARY (safe=True), wrap with TRY():
2565        - 'HEX': TRY_TO_BINARY('invalid', 'HEX') -> TRY(UNHEX('invalid'))
2566        """
2567        value = expression.this
2568        format_arg = expression.args.get("format")
2569        is_safe = expression.args.get("safe")
2570        is_binary = _is_binary(expression)
2571
2572        if not format_arg and not is_binary:
2573            func_name = "TRY_TO_BINARY" if is_safe else "TO_BINARY"
2574            return self.func(func_name, value)
2575
2576        # Snowflake defaults to HEX encoding when no format is specified
2577        fmt = format_arg.name.upper() if format_arg else "HEX"
2578
2579        if fmt in ("UTF-8", "UTF8"):
2580            # DuckDB ENCODE always uses UTF-8, no charset parameter needed
2581            result = self.func("ENCODE", value)
2582        elif fmt == "BASE64":
2583            result = self.func("FROM_BASE64", value)
2584        elif fmt == "HEX":
2585            result = self.func("UNHEX", value)
2586        else:
2587            if is_safe:
2588                return self.sql(exp.null())
2589            else:
2590                self.unsupported(f"format {fmt} is not supported")
2591                result = self.func("TO_BINARY", value)
2592        return f"TRY({result})" if is_safe else result
2593
2594    def tonumber_sql(self, expression: exp.ToNumber) -> str:
2595        fmt = expression.args.get("format")
2596        precision = expression.args.get("precision")
2597        scale = expression.args.get("scale")
2598
2599        if not fmt and precision and scale:
2600            to = exp.DataType.build(f"DECIMAL({precision.name}, {scale.name})", dialect="duckdb")
2601            if expression.args.get("safe"):
2602                return self.sql(exp.TryCast(this=expression.this, to=to))
2603            return self.sql(exp.cast(expression.this, to, dialect="duckdb"))
2604
2605        return super().tonumber_sql(expression)
2606
2607    def _greatest_least_sql(self, expression: exp.Greatest | exp.Least) -> str:
2608        """
2609        Handle GREATEST/LEAST functions with dialect-aware NULL behavior.
2610
2611        - If ignore_nulls=False (BigQuery-style): return NULL if any argument is NULL
2612        - If ignore_nulls=True (DuckDB/PostgreSQL-style): ignore NULLs, return greatest/least non-NULL value
2613        """
2614        # Get all arguments
2615        all_args = [expression.this, *expression.expressions]
2616        fallback_sql = self.function_fallback_sql(expression)
2617
2618        if expression.args.get("ignore_nulls"):
2619            # DuckDB/PostgreSQL behavior: use native GREATEST/LEAST (ignores NULLs)
2620            return self.sql(fallback_sql)
2621
2622        # return NULL if any argument is NULL
2623        case_expr = exp.case().when(
2624            exp.or_(*[arg.is_(exp.null()) for arg in all_args], copy=False),
2625            exp.null(),
2626            copy=False,
2627        )
2628        case_expr.set("default", fallback_sql)
2629        return self.sql(case_expr)
2630
2631    def generator_sql(self, expression: exp.Generator) -> str:
2632        # Transpile Snowflake GENERATOR to DuckDB range()
2633        rowcount = expression.args.get("rowcount")
2634        time_limit = expression.args.get("time_limit")
2635
2636        if time_limit:
2637            self.unsupported("GENERATOR TIMELIMIT parameter is not supported in DuckDB")
2638
2639        if not rowcount:
2640            self.unsupported("GENERATOR without ROWCOUNT is not supported in DuckDB")
2641            return self.func("range", exp.Literal.number(0))
2642
2643        return self.func("range", rowcount)
2644
2645    def greatest_sql(self, expression: exp.Greatest) -> str:
2646        return self._greatest_least_sql(expression)
2647
2648    def least_sql(self, expression: exp.Least) -> str:
2649        return self._greatest_least_sql(expression)
2650
2651    def lambda_sql(self, expression: exp.Lambda, arrow_sep: str = "->", wrap: bool = True) -> str:
2652        if expression.args.get("colon"):
2653            prefix = "LAMBDA "
2654            arrow_sep = ":"
2655            wrap = False
2656        else:
2657            prefix = ""
2658
2659        lambda_sql = super().lambda_sql(expression, arrow_sep=arrow_sep, wrap=wrap)
2660        return f"{prefix}{lambda_sql}"
2661
2662    def show_sql(self, expression: exp.Show) -> str:
2663        from_ = self.sql(expression, "from_")
2664        from_ = f" FROM {from_}" if from_ else ""
2665        return f"SHOW {expression.name}{from_}"
2666
2667    def soundex_sql(self, expression: exp.Soundex) -> str:
2668        self.unsupported("SOUNDEX is not supported in DuckDB")
2669        return self.func("SOUNDEX", expression.this)
2670
2671    def sortarray_sql(self, expression: exp.SortArray) -> str:
2672        arr = expression.this
2673        asc = expression.args.get("asc")
2674        nulls_first = expression.args.get("nulls_first")
2675
2676        if not isinstance(asc, exp.Boolean) and not isinstance(nulls_first, exp.Boolean):
2677            return self.func("LIST_SORT", arr, asc, nulls_first)
2678
2679        nulls_are_first = nulls_first == exp.true()
2680        nulls_first_sql = exp.Literal.string("NULLS FIRST") if nulls_are_first else None
2681
2682        if not isinstance(asc, exp.Boolean):
2683            return self.func("LIST_SORT", arr, asc, nulls_first_sql)
2684
2685        descending = asc == exp.false()
2686
2687        if not descending and not nulls_are_first:
2688            return self.func("LIST_SORT", arr)
2689        if not nulls_are_first:
2690            return self.func("ARRAY_REVERSE_SORT", arr)
2691        return self.func(
2692            "LIST_SORT",
2693            arr,
2694            exp.Literal.string("DESC" if descending else "ASC"),
2695            exp.Literal.string("NULLS FIRST"),
2696        )
2697
2698    def install_sql(self, expression: exp.Install) -> str:
2699        force = "FORCE " if expression.args.get("force") else ""
2700        this = self.sql(expression, "this")
2701        from_clause = expression.args.get("from_")
2702        from_clause = f" FROM {from_clause}" if from_clause else ""
2703        return f"{force}INSTALL {this}{from_clause}"
2704
2705    def approxtopk_sql(self, expression: exp.ApproxTopK) -> str:
2706        self.unsupported(
2707            "APPROX_TOP_K cannot be transpiled to DuckDB due to incompatible return types. "
2708        )
2709        return self.function_fallback_sql(expression)
2710
2711    def strposition_sql(self, expression: exp.StrPosition) -> str:
2712        this = expression.this
2713        substr = expression.args.get("substr")
2714        position = expression.args.get("position")
2715
2716        # For BINARY/BLOB: DuckDB's STRPOS doesn't support BLOB types
2717        # Convert to HEX strings, use STRPOS, then convert hex position to byte position
2718        if _is_binary(this):
2719            # Build expression: STRPOS(HEX(haystack), HEX(needle))
2720            hex_strpos = exp.StrPosition(
2721                this=exp.Hex(this=this),
2722                substr=exp.Hex(this=substr),
2723            )
2724
2725            return self.sql(exp.cast((hex_strpos + 1) / 2, exp.DType.INT))
2726
2727        # For VARCHAR: handle clamp_position
2728        if expression.args.get("clamp_position") and position:
2729            expression = expression.copy()
2730            expression.set(
2731                "position",
2732                exp.If(
2733                    this=exp.LTE(this=position, expression=exp.Literal.number(0)),
2734                    true=exp.Literal.number(1),
2735                    false=position.copy(),
2736                ),
2737            )
2738
2739        return strposition_sql(self, expression)
2740
2741    def substring_sql(self, expression: exp.Substring) -> str:
2742        if expression.args.get("zero_start"):
2743            start = expression.args.get("start")
2744            length = expression.args.get("length")
2745
2746            if start := expression.args.get("start"):
2747                start = exp.If(this=start.eq(0), true=exp.Literal.number(1), false=start)
2748            if length := expression.args.get("length"):
2749                length = exp.If(this=length < 0, true=exp.Literal.number(0), false=length)
2750
2751            return self.func("SUBSTRING", expression.this, start, length)
2752
2753        return self.function_fallback_sql(expression)
2754
2755    def strtotime_sql(self, expression: exp.StrToTime) -> str:
2756        # Check if target_type requires TIMESTAMPTZ (for LTZ/TZ variants)
2757        target_type = expression.args.get("target_type")
2758        needs_tz = target_type and target_type.this in (
2759            exp.DType.TIMESTAMPLTZ,
2760            exp.DType.TIMESTAMPTZ,
2761        )
2762
2763        value, formatted_time = self._strptime_default_year(expression)
2764
2765        if expression.args.get("safe"):
2766            cast_type = exp.DType.TIMESTAMPTZ if needs_tz else exp.DType.TIMESTAMP
2767            return self.sql(exp.cast(self.func("TRY_STRPTIME", value, formatted_time), cast_type))
2768
2769        base_sql = self.func("STRPTIME", value, formatted_time)
2770        if needs_tz:
2771            return self.sql(
2772                exp.cast(
2773                    base_sql,
2774                    exp.DataType(this=exp.DType.TIMESTAMPTZ),
2775                )
2776            )
2777        return base_sql
2778
2779    def strtodate_sql(self, expression: exp.StrToDate) -> str:
2780        value, formatted_time = self._strptime_default_year(expression)
2781        function_name = "STRPTIME" if not expression.args.get("safe") else "TRY_STRPTIME"
2782        return self.sql(
2783            exp.cast(
2784                self.func(function_name, value, formatted_time),
2785                exp.DataType(this=exp.DType.DATE),
2786            )
2787        )
2788
2789    def _strptime_default_year(
2790        self, expression: exp.StrToTime | exp.StrToDate | exp.ParseDatetime
2791    ) -> tuple[exp.ExpOrStr, exp.ExpOrStr | None]:
2792        value: exp.ExpOrStr = expression.this
2793        formatted_time: exp.ExpOrStr | None = self.format_time(expression)
2794
2795        if default_year := expression.args.get("default_year"):
2796            value = exp.DPipe(this=exp.Literal.string(f"{default_year.name} "), expression=value)
2797            formatted_time = exp.DPipe(this=exp.Literal.string("%Y "), expression=formatted_time)
2798
2799        return value, formatted_time
2800
2801    def parsedatetime_sql(self, expression: exp.ParseDatetime) -> str:
2802        value, formatted_time = self._strptime_default_year(expression)
2803        return self.func("STRPTIME", value, formatted_time)
2804
2805    def parsetime_sql(self, expression: exp.ParseTime) -> str:
2806        formatted_time = self.format_time(expression)
2807        return self.sql(
2808            exp.cast(
2809                self.func("STRPTIME", expression.this, formatted_time),
2810                exp.DataType(this=exp.DType.TIME),
2811            )
2812        )
2813
2814    def tsordstotime_sql(self, expression: exp.TsOrDsToTime) -> str:
2815        this = expression.this
2816        time_format = self.format_time(expression)
2817        safe = expression.args.get("safe")
2818        time_type = exp.DataType.from_str("TIME", dialect="duckdb")
2819        cast_expr = exp.TryCast if safe else exp.Cast
2820
2821        if time_format:
2822            func_name = "TRY_STRPTIME" if safe else "STRPTIME"
2823            strptime = exp.Anonymous(this=func_name, expressions=[this, time_format])
2824            return self.sql(cast_expr(this=strptime, to=time_type))
2825
2826        if isinstance(this, exp.TsOrDsToTime) or this.is_type(exp.DType.TIME):
2827            return self.sql(this)
2828
2829        return self.sql(cast_expr(this=this, to=time_type))
2830
2831    def currentdate_sql(self, expression: exp.CurrentDate) -> str:
2832        if not expression.this:
2833            return "CURRENT_DATE"
2834
2835        expr = exp.Cast(
2836            this=exp.AtTimeZone(this=exp.CurrentTimestamp(), zone=expression.this),
2837            to=exp.DataType(this=exp.DType.DATE),
2838        )
2839        return self.sql(expr)
2840
2841    def checkjson_sql(self, expression: exp.CheckJson) -> str:
2842        arg = expression.this
2843        return self.sql(
2844            exp.case()
2845            .when(
2846                exp.or_(arg.is_(exp.Null()), arg.eq(""), exp.func("json_valid", arg)),
2847                exp.null(),
2848            )
2849            .else_(exp.Literal.string("Invalid JSON"))
2850        )
2851
2852    def parsejson_sql(self, expression: exp.ParseJSON) -> str:
2853        arg = expression.this
2854        if expression.args.get("safe"):
2855            return self.sql(
2856                exp.case()
2857                .when(exp.func("json_valid", arg), exp.cast(arg.copy(), "JSON"))
2858                .else_(exp.null())
2859            )
2860        return self.func("JSON", arg)
2861
2862    def unicode_sql(self, expression: exp.Unicode) -> str:
2863        if expression.args.get("empty_is_zero"):
2864            return self.sql(
2865                exp.case()
2866                .when(expression.this.eq(exp.Literal.string("")), exp.Literal.number(0))
2867                .else_(exp.Anonymous(this="UNICODE", expressions=[expression.this]))
2868            )
2869
2870        return self.func("UNICODE", expression.this)
2871
2872    def stripnullvalue_sql(self, expression: exp.StripNullValue) -> str:
2873        return self.sql(
2874            exp.case()
2875            .when(exp.func("json_type", expression.this).eq("NULL"), exp.null())
2876            .else_(expression.this)
2877        )
2878
2879    def trunc_sql(self, expression: exp.Trunc) -> str:
2880        decimals = expression.args.get("decimals")
2881        if (
2882            expression.args.get("fractions_supported")
2883            and decimals
2884            and not decimals.is_type(exp.DType.INT)
2885        ):
2886            decimals = exp.cast(decimals, exp.DType.INT, dialect="duckdb")
2887
2888        return self.func("TRUNC", expression.this, decimals)
2889
2890    def normal_sql(self, expression: exp.Normal) -> str:
2891        """
2892        Transpile Snowflake's NORMAL(mean, stddev, gen) to DuckDB.
2893
2894        Uses the Box-Muller transform via NORMAL_TEMPLATE.
2895        """
2896        mean = expression.this
2897        stddev = expression.args["stddev"]
2898        gen: exp.Expr = expression.args["gen"]
2899
2900        # Build two uniform random values [0, 1) for Box-Muller transform
2901        if isinstance(gen, exp.Rand) and gen.this is None:
2902            u1: exp.Expr = exp.Rand()
2903            u2: exp.Expr = exp.Rand()
2904        else:
2905            # Seeded: derive two values using HASH with different inputs
2906            seed = gen.this if isinstance(gen, exp.Rand) else gen
2907            u1 = exp.replace_placeholders(self.SEEDED_RANDOM_TEMPLATE, seed=seed)
2908            u2 = exp.replace_placeholders(
2909                self.SEEDED_RANDOM_TEMPLATE,
2910                seed=exp.Add(this=seed.copy(), expression=exp.Literal.number(1)),
2911            )
2912
2913        replacements = {"mean": mean, "stddev": stddev, "u1": u1, "u2": u2}
2914        return self.sql(exp.replace_placeholders(self.NORMAL_TEMPLATE, **replacements))
2915
2916    def uniform_sql(self, expression: exp.Uniform) -> str:
2917        """
2918        Transpile Snowflake's UNIFORM(min, max, gen) to DuckDB.
2919
2920        UNIFORM returns a random value in [min, max]:
2921        - Integer result if both min and max are integers
2922        - Float result if either min or max is a float
2923        """
2924        min_val = expression.this
2925        max_val = expression.expression
2926        gen = expression.args.get("gen") or expression.args.get("seed") or exp.Rand()
2927
2928        # Determine if result should be integer (both bounds are integers).
2929        # We do this to emulate Snowflake's behavior, INT -> INT, FLOAT -> FLOAT
2930        is_int_result = min_val.is_int and max_val.is_int
2931
2932        # Build the random value expression [0, 1)
2933        if not isinstance(gen, exp.Rand):
2934            # Seed value: (ABS(HASH(seed)) % 1000000) / 1000000.0
2935            random_expr: exp.Expr = exp.Div(
2936                this=exp.Paren(
2937                    this=exp.Mod(
2938                        this=exp.Abs(this=exp.Anonymous(this="HASH", expressions=[gen])),
2939                        expression=exp.Literal.number(1000000),
2940                    )
2941                ),
2942                expression=exp.Literal.number(1000000.0),
2943            )
2944        else:
2945            random_expr = exp.Rand()
2946
2947        # Build: min + random * (max - min [+ 1 for int])
2948        range_expr: exp.Expr = exp.Sub(this=max_val, expression=min_val)
2949        if is_int_result:
2950            range_expr = exp.Add(this=range_expr, expression=exp.Literal.number(1))
2951
2952        result: exp.Expr = exp.Add(
2953            this=min_val,
2954            expression=exp.Mul(this=random_expr, expression=exp.Paren(this=range_expr)),
2955        )
2956
2957        if is_int_result:
2958            result = exp.Cast(this=exp.Floor(this=result), to=exp.DType.BIGINT.into_expr())
2959
2960        return self.sql(result)
2961
2962    def timefromparts_sql(self, expression: exp.TimeFromParts) -> str:
2963        nano = expression.args.get("nano")
2964        overflow = expression.args.get("overflow")
2965
2966        # Snowflake's TIME_FROM_PARTS supports overflow
2967        if overflow:
2968            hour = expression.args["hour"]
2969            minute = expression.args["min"]
2970            sec = expression.args["sec"]
2971
2972            # Check if values are within normal ranges - use MAKE_TIME for efficiency
2973            if not nano and all(arg.is_int for arg in [hour, minute, sec]):
2974                try:
2975                    h_val = hour.to_py()
2976                    m_val = minute.to_py()
2977                    s_val = sec.to_py()
2978                    if 0 <= h_val <= 23 and 0 <= m_val <= 59 and 0 <= s_val <= 59:
2979                        return rename_func("MAKE_TIME")(self, expression)
2980                except ValueError:
2981                    pass
2982
2983            # Overflow or nanoseconds detected - use INTERVAL arithmetic
2984            if nano:
2985                sec = sec + nano.pop() / exp.Literal.number(1000000000.0)
2986
2987            total_seconds = hour * exp.Literal.number(3600) + minute * exp.Literal.number(60) + sec
2988
2989            return self.sql(
2990                exp.Add(
2991                    this=exp.Cast(
2992                        this=exp.Literal.string("00:00:00"), to=exp.DType.TIME.into_expr()
2993                    ),
2994                    expression=exp.Interval(this=total_seconds, unit=exp.var("SECOND")),
2995                )
2996            )
2997
2998        # Default: MAKE_TIME
2999        if nano:
3000            expression.set(
3001                "sec", expression.args["sec"] + nano.pop() / exp.Literal.number(1000000000.0)
3002            )
3003
3004        return rename_func("MAKE_TIME")(self, expression)
3005
3006    def extract_sql(self, expression: exp.Extract) -> str:
3007        """
3008        Transpile EXTRACT/DATE_PART for DuckDB, handling specifiers not natively supported.
3009
3010        DuckDB doesn't support: WEEKISO, YEAROFWEEK, YEAROFWEEKISO, NANOSECOND,
3011        EPOCH_SECOND (as integer), EPOCH_MILLISECOND, EPOCH_MICROSECOND, EPOCH_NANOSECOND
3012        """
3013        this = expression.this
3014        datetime_expr = expression.expression
3015
3016        # TIMESTAMPTZ extractions may produce different results between Snowflake and DuckDB
3017        # because Snowflake applies server timezone while DuckDB uses local timezone
3018        if datetime_expr.is_type(exp.DType.TIMESTAMPTZ, exp.DType.TIMESTAMPLTZ):
3019            self.unsupported(
3020                "EXTRACT from TIMESTAMPTZ / TIMESTAMPLTZ may produce different results due to timezone handling differences"
3021            )
3022
3023        part_name = this.name.upper()
3024
3025        if part_name in self.EXTRACT_STRFTIME_MAPPINGS:
3026            fmt, cast_type = self.EXTRACT_STRFTIME_MAPPINGS[part_name]
3027
3028            # Problem: strftime doesn't accept TIME and there's no NANOSECOND function
3029            # So, for NANOSECOND with TIME, fallback to MICROSECOND * 1000
3030            is_nano_time = part_name == "NANOSECOND" and datetime_expr.is_type(
3031                exp.DType.TIME, exp.DType.TIMETZ
3032            )
3033
3034            if is_nano_time:
3035                self.unsupported("Parameter NANOSECOND is not supported with TIME type in DuckDB")
3036                return self.sql(
3037                    exp.cast(
3038                        exp.Mul(
3039                            this=exp.Extract(this=exp.var("MICROSECOND"), expression=datetime_expr),
3040                            expression=exp.Literal.number(1000),
3041                        ),
3042                        exp.DataType.from_str(cast_type, dialect="duckdb"),
3043                    )
3044                )
3045
3046            # For NANOSECOND, cast to TIMESTAMP_NS to preserve nanosecond precision
3047            strftime_input = datetime_expr
3048            if part_name == "NANOSECOND":
3049                strftime_input = exp.cast(datetime_expr, exp.DType.TIMESTAMP_NS)
3050
3051            return self.sql(
3052                exp.cast(
3053                    exp.Anonymous(
3054                        this="STRFTIME",
3055                        expressions=[strftime_input, exp.Literal.string(fmt)],
3056                    ),
3057                    exp.DataType.from_str(cast_type, dialect="duckdb"),
3058                )
3059            )
3060
3061        if part_name in self.EXTRACT_EPOCH_MAPPINGS:
3062            func_name = self.EXTRACT_EPOCH_MAPPINGS[part_name]
3063            result: exp.Expr = exp.Anonymous(this=func_name, expressions=[datetime_expr])
3064            # EPOCH returns float, cast to BIGINT for integer result
3065            if part_name == "EPOCH_SECOND":
3066                result = exp.cast(result, exp.DataType.from_str("BIGINT", dialect="duckdb"))
3067            return self.sql(result)
3068
3069        return super().extract_sql(expression)
3070
3071    def timestampfromparts_sql(self, expression: exp.TimestampFromParts) -> str:
3072        # Check if this is the date/time expression form: TIMESTAMP_FROM_PARTS(date_expr, time_expr)
3073        date_expr = expression.this
3074        time_expr = expression.expression
3075
3076        if date_expr is not None and time_expr is not None:
3077            # In DuckDB, DATE + TIME produces TIMESTAMP
3078            return self.sql(exp.Add(this=date_expr, expression=time_expr))
3079
3080        # Component-based form: TIMESTAMP_FROM_PARTS(year, month, day, hour, minute, second, ...)
3081        sec = expression.args.get("sec")
3082        if sec is None:
3083            # This shouldn't happen with valid input, but handle gracefully
3084            return rename_func("MAKE_TIMESTAMP")(self, expression)
3085
3086        milli = expression.args.get("milli")
3087        if milli is not None:
3088            sec += milli.pop() / exp.Literal.number(1000.0)
3089
3090        nano = expression.args.get("nano")
3091        if nano is not None:
3092            sec += nano.pop() / exp.Literal.number(1000000000.0)
3093
3094        if milli or nano:
3095            expression.set("sec", sec)
3096
3097        if zone := expression.args.get("zone"):
3098            return self.sql(exp.AtTimeZone(this=expression, zone=zone.pop()))
3099
3100        return rename_func("MAKE_TIMESTAMP")(self, expression)
3101
3102    @unsupported_args("nano")
3103    def timestampltzfromparts_sql(self, expression: exp.TimestampLtzFromParts) -> str:
3104        # Pop nano so rename_func only passes args that MAKE_TIMESTAMP accepts
3105        if nano := expression.args.get("nano"):
3106            nano.pop()
3107
3108        timestamp = rename_func("MAKE_TIMESTAMP")(self, expression)
3109        return f"CAST({timestamp} AS TIMESTAMPTZ)"
3110
3111    @unsupported_args("nano")
3112    def timestamptzfromparts_sql(self, expression: exp.TimestampTzFromParts) -> str:
3113        # Extract zone before popping
3114        zone = expression.args.get("zone")
3115        # Pop zone and nano so rename_func only passes args that MAKE_TIMESTAMP accepts
3116        if zone:
3117            zone = zone.pop()
3118
3119        if nano := expression.args.get("nano"):
3120            nano.pop()
3121
3122        timestamp = rename_func("MAKE_TIMESTAMP")(self, expression)
3123
3124        if zone:
3125            # Use AT TIME ZONE to apply the explicit timezone
3126            return f"{timestamp} AT TIME ZONE {self.sql(zone)}"
3127
3128        return timestamp
3129
3130    def after_limit_modifiers(self, expression: exp.Expr) -> list[str]:
3131        locks = self.expressions(expression, key="locks", sep=" ")
3132        return [f" {locks}" if locks else ""]
3133
3134    def tablesample_sql(
3135        self,
3136        expression: exp.TableSample,
3137        tablesample_keyword: str | None = None,
3138    ) -> str:
3139        if not isinstance(expression.parent, exp.Select):
3140            # This sample clause only applies to a single source, not the entire resulting relation
3141            tablesample_keyword = "TABLESAMPLE"
3142
3143        if expression.args.get("size"):
3144            method = expression.args.get("method")
3145            if method and method.name.upper() != "RESERVOIR":
3146                self.unsupported(
3147                    f"Sampling method {method} is not supported with a discrete sample count, "
3148                    "defaulting to reservoir sampling"
3149                )
3150                expression.set("method", exp.var("RESERVOIR"))
3151
3152        return super().tablesample_sql(expression, tablesample_keyword=tablesample_keyword)
3153
3154    def in_sql(self, expression: exp.In) -> str:
3155        unnest = expression.args.get("unnest")
3156        if unnest:
3157            return self.sql(
3158                exp.replace_placeholders(
3159                    self.IN_UNNEST_TEMPLATE, arr=unnest.expressions[0], value=expression.this
3160                )
3161            )
3162        return super().in_sql(expression)
3163
3164    def join_sql(self, expression: exp.Join) -> str:
3165        if (
3166            not expression.args.get("using")
3167            and not expression.args.get("on")
3168            and not expression.method
3169            and (expression.kind in ("", "INNER", "OUTER"))
3170        ):
3171            # Some dialects support `LEFT/INNER JOIN UNNEST(...)` without an explicit ON clause
3172            # DuckDB doesn't, but we can just add a dummy ON clause that is always true
3173            if isinstance(expression.this, exp.Unnest):
3174                return super().join_sql(expression.on(exp.true()))
3175
3176            expression.set("side", None)
3177            expression.set("kind", None)
3178
3179        return super().join_sql(expression)
3180
3181    def countif_sql(self, expression: exp.CountIf) -> str:
3182        if self.dialect.version >= (1, 2):
3183            this = expression.this
3184            if expression.args.get("zero_on_all_null") and not isinstance(this, exp.Distinct):
3185                # DuckDB >= 1.2's COUNT_IF returns NULL when the condition is NULL on all rows,
3186                # so we wrap the condition in IS TRUE to preserve count-like semantics
3187                expression = exp.CountIf(this=exp.paren(this).is_(exp.true()))
3188            return self.function_fallback_sql(expression)
3189
3190        # https://github.com/tobymao/sqlglot/pull/4749
3191        return count_if_to_sum(self, expression)
3192
3193    def bracket_sql(self, expression: exp.Bracket) -> str:
3194        if self.dialect.version >= (1, 2):
3195            return super().bracket_sql(expression)
3196
3197        # https://duckdb.org/2025/02/05/announcing-duckdb-120.html#breaking-changes
3198        this = expression.this
3199        if isinstance(this, exp.Array):
3200            this.replace(exp.paren(this))
3201
3202        bracket = super().bracket_sql(expression)
3203
3204        if not expression.args.get("returns_list_for_maps"):
3205            if not this.type:
3206                from sqlglot.optimizer.annotate_types import annotate_types
3207
3208                this = annotate_types(this, dialect=self.dialect)
3209
3210            if this.is_type(exp.DType.MAP):
3211                bracket = f"({bracket})[1]"
3212
3213        return bracket
3214
3215    def withingroup_sql(self, expression: exp.WithinGroup) -> str:
3216        func = expression.this
3217
3218        # For ARRAY_AGG, DuckDB requires ORDER BY inside the function, not in WITHIN GROUP
3219        # Transform: ARRAY_AGG(x) WITHIN GROUP (ORDER BY y) -> ARRAY_AGG(x ORDER BY y)
3220        if isinstance(func, exp.ArrayAgg):
3221            if not isinstance(order := expression.expression, exp.Order):
3222                return self.sql(func)
3223
3224            # Save the original column for FILTER clause (before wrapping with Order)
3225            original_this = func.this
3226
3227            # Move ORDER BY inside ARRAY_AGG by wrapping its argument with Order
3228            # ArrayAgg.this should become Order(this=ArrayAgg.this, expressions=order.expressions)
3229            func.set(
3230                "this",
3231                exp.Order(
3232                    this=func.this.copy(),
3233                    expressions=order.expressions,
3234                ),
3235            )
3236
3237            # Generate the ARRAY_AGG function with ORDER BY and add FILTER clause if needed
3238            # Use original_this (not the Order-wrapped version) for the FILTER condition
3239            array_agg_sql = self.function_fallback_sql(func)
3240            return self._add_arrayagg_null_filter(array_agg_sql, func, original_this)
3241
3242        # For other functions (like PERCENTILES), use existing logic
3243        expression_sql = self.sql(expression, "expression")
3244
3245        if isinstance(func, exp.PERCENTILES):
3246            # Make the order key the first arg and slide the fraction to the right
3247            # https://duckdb.org/docs/sql/aggregates#ordered-set-aggregate-functions
3248            order_col = expression.find(exp.Ordered)
3249            if order_col:
3250                func.set("expression", func.this)
3251                func.set("this", order_col.this)
3252
3253        this = self.sql(expression, "this").rstrip(")")
3254
3255        return f"{this}{expression_sql})"
3256
3257    def length_sql(self, expression: exp.Length) -> str:
3258        arg = expression.this
3259
3260        # Dialects like BQ and Snowflake also accept binary values as args, so
3261        # DDB will attempt to infer the type or resort to case/when resolution
3262        if not expression.args.get("binary") or arg.is_string:
3263            return self.func("LENGTH", arg)
3264
3265        if not arg.type:
3266            from sqlglot.optimizer.annotate_types import annotate_types
3267
3268            arg = annotate_types(arg, dialect=self.dialect)
3269
3270        if arg.is_type(*exp.DataType.TEXT_TYPES):
3271            return self.func("LENGTH", arg)
3272
3273        # We need these casts to make duckdb's static type checker happy
3274        blob = exp.cast(arg, exp.DType.VARBINARY)
3275        varchar = exp.cast(arg, exp.DType.VARCHAR)
3276
3277        case = (
3278            exp.case(exp.Anonymous(this="TYPEOF", expressions=[arg]))
3279            .when(exp.Literal.string("BLOB"), exp.ByteLength(this=blob))
3280            .else_(exp.Anonymous(this="LENGTH", expressions=[varchar]))
3281        )
3282        return self.sql(case)
3283
3284    def bitlength_sql(self, expression: exp.BitLength) -> str:
3285        if not _is_binary(arg := expression.this):
3286            return self.func("BIT_LENGTH", arg)
3287
3288        blob = exp.cast(arg, exp.DataType.Type.VARBINARY)
3289        return self.sql(exp.ByteLength(this=blob) * exp.Literal.number(8))
3290
3291    def chr_sql(self, expression: exp.Chr, name: str = "CHR") -> str:
3292        arg = expression.expressions[0]
3293        if arg.is_type(*exp.DataType.REAL_TYPES):
3294            arg = exp.cast(arg, exp.DType.INT)
3295        return self.func("CHR", arg)
3296
3297    def collation_sql(self, expression: exp.Collation) -> str:
3298        self.unsupported("COLLATION function is not supported by DuckDB")
3299        return self.function_fallback_sql(expression)
3300
3301    def collate_sql(self, expression: exp.Collate) -> str:
3302        if not expression.expression.is_string:
3303            return super().collate_sql(expression)
3304
3305        raw = expression.expression.name
3306        if not raw:
3307            return self.sql(expression.this)
3308
3309        parts = []
3310        for part in raw.split("-"):
3311            lower = part.lower()
3312            if lower not in _SNOWFLAKE_COLLATION_DEFAULTS:
3313                if lower in _SNOWFLAKE_COLLATION_UNSUPPORTED:
3314                    self.unsupported(
3315                        f"Snowflake collation specifier '{part}' has no DuckDB equivalent"
3316                    )
3317                parts.append(lower)
3318
3319        if not parts:
3320            return self.sql(expression.this)
3321        return super().collate_sql(
3322            exp.Collate(this=expression.this, expression=exp.var(".".join(parts)))
3323        )
3324
3325    def _validate_regexp_flags(self, flags: exp.Expr | None, supported_flags: str) -> str | None:
3326        """
3327        Validate and filter regexp flags for DuckDB compatibility.
3328
3329        Args:
3330            flags: The flags expression to validate
3331            supported_flags: String of supported flags (e.g., "ims", "cims").
3332                            Only these flags will be returned.
3333
3334        Returns:
3335            Validated/filtered flag string, or None if no valid flags remain
3336        """
3337        if not isinstance(flags, exp.Expr):
3338            return None
3339
3340        if not flags.is_string:
3341            self.unsupported("Non-literal regexp flags are not fully supported in DuckDB")
3342            return None
3343
3344        flag_str = flags.this
3345        unsupported = set(flag_str) - set(supported_flags)
3346
3347        if unsupported:
3348            self.unsupported(
3349                f"Regexp flags {sorted(unsupported)} are not supported in this context"
3350            )
3351
3352        flag_str = "".join(f for f in flag_str if f in supported_flags)
3353        return flag_str if flag_str else None
3354
3355    def regexpcount_sql(self, expression: exp.RegexpCount) -> str:
3356        this = expression.this
3357        pattern = expression.expression
3358        position = expression.args.get("position")
3359        parameters = expression.args.get("parameters")
3360
3361        # Validate flags - only "ims" flags are supported for embedded patterns
3362        validated_flags = self._validate_regexp_flags(parameters, supported_flags="ims")
3363
3364        if position:
3365            this = exp.Substring(this=this, start=position)
3366
3367        # Handle empty pattern: Snowflake returns 0, DuckDB would match between every character.
3368        # Checked before the flags are embedded, which would make the pattern non-empty
3369        is_empty = exp.EQ(this=pattern.copy(), expression=exp.Literal.string(""))
3370
3371        # Embed flags in pattern (REGEXP_EXTRACT_ALL doesn't support flags argument)
3372        if validated_flags:
3373            pattern = exp.Concat(expressions=[exp.Literal.string(f"(?{validated_flags})"), pattern])
3374
3375        result = (
3376            exp.case()
3377            .when(is_empty, exp.Literal.number(0))
3378            .else_(
3379                exp.Length(
3380                    this=exp.Anonymous(this="REGEXP_EXTRACT_ALL", expressions=[this, pattern])
3381                )
3382            )
3383        )
3384
3385        return self.sql(result)
3386
3387    def regexpreplace_sql(self, expression: exp.RegexpReplace) -> str:
3388        subject = expression.this
3389        pattern = expression.expression
3390        replacement = expression.args.get("replacement") or exp.Literal.string("")
3391        position = expression.args.get("position")
3392        occurrence = expression.args.get("occurrence")
3393        modifiers = expression.args.get("modifiers")
3394
3395        validated_flags = self._validate_regexp_flags(modifiers, supported_flags="cimsg") or ""
3396
3397        # Handle occurrence (only literals supported)
3398        if occurrence and not occurrence.is_int:
3399            self.unsupported("REGEXP_REPLACE with non-literal occurrence")
3400        else:
3401            occurrence = occurrence.to_py() if occurrence and occurrence.is_int else 0
3402            if occurrence > 1:
3403                self.unsupported(f"REGEXP_REPLACE occurrence={occurrence} not supported")
3404            # flag duckdb to do either all or none, single_replace check is for duckdb round trip
3405            elif (
3406                occurrence == 0
3407                and "g" not in validated_flags
3408                and not expression.args.get("single_replace")
3409            ):
3410                validated_flags += "g"
3411
3412        # Handle position (only literals supported)
3413        prefix = None
3414        if position and not position.is_int:
3415            self.unsupported("REGEXP_REPLACE with non-literal position")
3416        elif position and position.is_int and position.to_py() > 1:
3417            pos = position.to_py()
3418            prefix = exp.Substring(
3419                this=subject, start=exp.Literal.number(1), length=exp.Literal.number(pos - 1)
3420            )
3421            subject = exp.Substring(this=subject, start=exp.Literal.number(pos))
3422
3423        result: exp.Expr = exp.Anonymous(
3424            this="REGEXP_REPLACE",
3425            expressions=[
3426                subject,
3427                pattern,
3428                replacement,
3429                exp.Literal.string(validated_flags) if validated_flags else None,
3430            ],
3431        )
3432
3433        if prefix:
3434            result = exp.Concat(expressions=[prefix, result])
3435
3436        return self.sql(result)
3437
3438    def regexplike_sql(self, expression: exp.RegexpLike) -> str:
3439        this = expression.this
3440        pattern = expression.expression
3441        flag = expression.args.get("flag")
3442
3443        if expression.args.get("full_match"):
3444            validated_flags = self._validate_regexp_flags(flag, supported_flags="cims")
3445            flag = exp.Literal.string(validated_flags) if validated_flags else None
3446            return self.func("REGEXP_FULL_MATCH", this, pattern, flag)
3447
3448        return self.func("REGEXP_MATCHES", this, pattern, flag)
3449
3450    @unsupported_args("ins_cost", "del_cost", "sub_cost")
3451    def levenshtein_sql(self, expression: exp.Levenshtein) -> str:
3452        this = expression.this
3453        expr = expression.expression
3454        max_dist = expression.args.get("max_dist")
3455
3456        if max_dist is None:
3457            return self.func("LEVENSHTEIN", this, expr)
3458
3459        # Emulate Snowflake semantics: if distance > max_dist, return max_dist
3460        levenshtein = exp.Levenshtein(this=this, expression=expr)
3461        return self.sql(exp.Least(this=levenshtein, expressions=[max_dist]))
3462
3463    def pad_sql(self, expression: exp.Pad) -> str:
3464        """
3465        Handle RPAD/LPAD for VARCHAR and BINARY types.
3466
3467        For VARCHAR: Delegate to parent class
3468        For BINARY: Lower to: input || REPEAT(pad, GREATEST(0, target_len - OCTET_LENGTH(input)))
3469        """
3470        string_arg = expression.this
3471        fill_arg = expression.args.get("fill_pattern") or exp.Literal.string(" ")
3472
3473        if _is_binary(string_arg) or _is_binary(fill_arg):
3474            length_arg = expression.expression
3475            is_left = expression.args.get("is_left")
3476
3477            input_len = exp.ByteLength(this=string_arg)
3478            chars_needed = length_arg - input_len
3479            pad_count = exp.Greatest(
3480                this=exp.Literal.number(0), expressions=[chars_needed], ignore_nulls=True
3481            )
3482            repeat_expr = exp.Repeat(this=fill_arg, times=pad_count)
3483
3484            left, right = string_arg, repeat_expr
3485            if is_left:
3486                left, right = right, left
3487
3488            result = exp.DPipe(this=left, expression=right)
3489            return self.sql(result)
3490
3491        # For VARCHAR: Delegate to parent class (handles PAD_FILL_PATTERN_IS_REQUIRED)
3492        return super().pad_sql(expression)
3493
3494    def minhash_sql(self, expression: exp.Minhash) -> str:
3495        k = expression.this
3496        exprs = expression.expressions
3497
3498        if len(exprs) != 1 or isinstance(exprs[0], exp.Star):
3499            self.unsupported(
3500                "MINHASH with multiple expressions or * requires manual query restructuring"
3501            )
3502            return self.func("MINHASH", k, *exprs)
3503
3504        expr = exprs[0]
3505        result = exp.replace_placeholders(self.MINHASH_TEMPLATE.copy(), expr=expr, k=k)
3506        return f"({self.sql(result)})"
3507
3508    def minhashcombine_sql(self, expression: exp.MinhashCombine) -> str:
3509        expr = expression.this
3510        result = exp.replace_placeholders(self.MINHASH_COMBINE_TEMPLATE.copy(), expr=expr)
3511        return f"({self.sql(result)})"
3512
3513    def approximatesimilarity_sql(self, expression: exp.ApproximateSimilarity) -> str:
3514        expr = expression.this
3515        result = exp.replace_placeholders(self.APPROXIMATE_SIMILARITY_TEMPLATE.copy(), expr=expr)
3516        return f"({self.sql(result)})"
3517
3518    def arrayuniqueagg_sql(self, expression: exp.ArrayUniqueAgg) -> str:
3519        return self.sql(
3520            exp.Filter(
3521                this=exp.func("LIST", exp.Distinct(expressions=[expression.this])),
3522                expression=exp.Where(this=expression.this.copy().is_(exp.null()).not_()),
3523            )
3524        )
3525
3526    def arrayconcatagg_sql(self, expression: exp.ArrayConcatAgg) -> str:
3527        this = expression.this
3528
3529        if isinstance(this, exp.Limit):
3530            self.unsupported("LIMIT in ARRAY_CONCAT_AGG cannot be transpiled to DuckDB")
3531            this = this.this
3532
3533        inner = this.this if isinstance(this, exp.Order) else this
3534
3535        return self.func(
3536            "FLATTEN",
3537            exp.Filter(
3538                this=exp.ArrayAgg(this=this),
3539                expression=exp.Where(this=inner.copy().is_(exp.null()).not_()),
3540            ),
3541        )
3542
3543    def arrayunionagg_sql(self, expression: exp.ArrayUnionAgg) -> str:
3544        self.unsupported("ARRAY_UNION_AGG is not supported in DuckDB")
3545        return self.function_fallback_sql(expression)
3546
3547    def arraydistinct_sql(self, expression: exp.ArrayDistinct) -> str:
3548        arr = expression.this
3549        func = self.func("LIST_DISTINCT", arr)
3550
3551        if expression.args.get("check_null"):
3552            add_null_to_array = exp.func(
3553                "LIST_APPEND", exp.func("LIST_DISTINCT", exp.ArrayCompact(this=arr)), exp.Null()
3554            )
3555            return self.sql(
3556                exp.If(
3557                    this=exp.NEQ(
3558                        this=exp.ArraySize(this=arr), expression=exp.func("LIST_COUNT", arr)
3559                    ),
3560                    true=add_null_to_array,
3561                    false=func,
3562                )
3563            )
3564
3565        return func
3566
3567    def arrayintersect_sql(self, expression: exp.ArrayIntersect) -> str:
3568        if expression.args.get("is_multiset") and len(expression.expressions) == 2:
3569            return self._array_bag_sql(
3570                self.ARRAY_INTERSECTION_CONDITION,
3571                expression.expressions[0],
3572                expression.expressions[1],
3573            )
3574        return self.function_fallback_sql(expression)
3575
3576    def arrayexcept_sql(self, expression: exp.ArrayExcept) -> str:
3577        arr1, arr2 = expression.this, expression.expression
3578        if expression.args.get("is_multiset"):
3579            return self._array_bag_sql(self.ARRAY_EXCEPT_CONDITION, arr1, arr2)
3580        return self.sql(
3581            exp.replace_placeholders(self.ARRAY_EXCEPT_SET_TEMPLATE, arr1=arr1, arr2=arr2)
3582        )
3583
3584    def arrayslice_sql(self, expression: exp.ArraySlice) -> str:
3585        """
3586        Spark's SLICE uses a length, so normalize negative starts before computing
3587        the inclusive end index. Starts before the array produce an empty slice.
3588
3589        Transpiles Snowflake's ARRAY_SLICE (0-indexed, exclusive end) to DuckDB's
3590        ARRAY_SLICE (1-indexed, inclusive end) by wrapping start and end in CASE
3591        expressions that adjust the index at query time:
3592          - start: CASE WHEN start >= 0 THEN start + 1 ELSE start END
3593          - end:   CASE WHEN end < 0 THEN end - 1 ELSE end END
3594        """
3595        start, end = expression.args.get("start"), expression.args.get("end")
3596
3597        if expression.args.get("zero_based"):
3598            if start is not None:
3599                start = (
3600                    exp.case()
3601                    .when(
3602                        exp.GTE(this=start.copy(), expression=exp.Literal.number(0)),
3603                        exp.Add(this=start.copy(), expression=exp.Literal.number(1)),
3604                    )
3605                    .else_(start)
3606                )
3607            if end is not None and not expression.args.get("is_length"):
3608                end = (
3609                    exp.case()
3610                    .when(
3611                        exp.LT(this=end.copy(), expression=exp.Literal.number(0)),
3612                        exp.Sub(this=end.copy(), expression=exp.Literal.number(1)),
3613                    )
3614                    .else_(end)
3615                )
3616
3617        if expression.args.get("is_length") and start is not None and end is not None:
3618            if not start.is_int or start.to_py() < 0:
3619                size = exp.ArraySize(this=expression.this.copy())
3620                start = (
3621                    exp.case()
3622                    .when(start < -size, size + 1)
3623                    .when(start < 0, size + start + 1)
3624                    .else_(start)
3625                )
3626
3627            if start.is_int and end.is_int:
3628                end = exp.Literal.number(start.to_py() + end.to_py() - 1)
3629            else:
3630                end = start + exp.cast(end, "BIGINT") - 1
3631        return self.func("ARRAY_SLICE", expression.this, start, end, expression.args.get("step"))
3632
3633    def arrayszip_sql(self, expression: exp.ArraysZip) -> str:
3634        args = expression.expressions
3635
3636        if not args:
3637            # Return [{}] - using MAP([], []) since DuckDB can't represent empty structs
3638            return self.sql(exp.array(exp.Map(keys=exp.array(), values=exp.array())))
3639
3640        # Build placeholder values for template
3641        lengths = [exp.Length(this=arg) for arg in args]
3642        max_len = (
3643            lengths[0]
3644            if len(lengths) == 1
3645            else exp.Greatest(this=lengths[0], expressions=lengths[1:])
3646        )
3647
3648        # Empty struct with same schema: {'$1': NULL, '$2': NULL, ...}
3649        empty_struct = exp.func(
3650            "STRUCT",
3651            *[
3652                exp.PropertyEQ(this=exp.Literal.string(f"${i + 1}"), expression=exp.Null())
3653                for i in range(len(args))
3654            ],
3655        )
3656
3657        # Struct for transform: {'$1': COALESCE(arr1, [])[__i + 1], ...}
3658        # COALESCE wrapping handles NULL arrays - prevents invalid NULL[i] syntax
3659        index = exp.column("__i") + 1
3660        transform_struct = exp.func(
3661            "STRUCT",
3662            *[
3663                exp.PropertyEQ(
3664                    this=exp.Literal.string(f"${i + 1}"),
3665                    expression=exp.func("COALESCE", arg, exp.array())[index],
3666                )
3667                for i, arg in enumerate(args)
3668            ],
3669        )
3670
3671        result = exp.replace_placeholders(
3672            self.ARRAYS_ZIP_TEMPLATE.copy(),
3673            null_check=exp.or_(*[arg.is_(exp.Null()) for arg in args]),
3674            all_empty_check=exp.and_(
3675                *[
3676                    exp.EQ(this=exp.Length(this=arg), expression=exp.Literal.number(0))
3677                    for arg in args
3678                ]
3679            ),
3680            empty_struct=empty_struct,
3681            max_len=max_len,
3682            transform_struct=transform_struct,
3683        )
3684        return self.sql(result)
3685
3686    def lower_sql(self, expression: exp.Lower) -> str:
3687        result_sql = self.func("LOWER", _cast_to_varchar(expression.this))
3688        return _gen_with_cast_to_blob(self, expression, result_sql)
3689
3690    def upper_sql(self, expression: exp.Upper) -> str:
3691        result_sql = self.func("UPPER", _cast_to_varchar(expression.this))
3692        return _gen_with_cast_to_blob(self, expression, result_sql)
3693
3694    def reverse_sql(self, expression: exp.Reverse) -> str:
3695        result_sql = self.func("REVERSE", _cast_to_varchar(expression.this))
3696        return _gen_with_cast_to_blob(self, expression, result_sql)
3697
3698    def _left_right_sql(self, expression: exp.Left | exp.Right, func_name: str) -> str:
3699        arg = expression.this
3700        length = expression.expression
3701        is_binary = _is_binary(arg)
3702
3703        if is_binary:
3704            # LEFT/RIGHT(blob, n) becomes UNHEX(LEFT/RIGHT(HEX(blob), n * 2))
3705            # Each byte becomes 2 hex chars, so multiply length by 2
3706            hex_arg = exp.Hex(this=arg)
3707            hex_length = exp.Mul(this=length, expression=exp.Literal.number(2))
3708            result: exp.Expression = exp.Unhex(
3709                this=exp.Anonymous(this=func_name, expressions=[hex_arg, hex_length])
3710            )
3711        else:
3712            result = exp.Anonymous(this=func_name, expressions=[arg, length])
3713
3714        if expression.args.get("negative_length_returns_empty"):
3715            empty: exp.Expression = exp.Literal.string("")
3716            if is_binary:
3717                empty = exp.Unhex(this=empty)
3718            result = exp.case().when(length < exp.Literal.number(0), empty).else_(result)
3719
3720        return self.sql(result)
3721
3722    def left_sql(self, expression: exp.Left) -> str:
3723        return self._left_right_sql(expression, "LEFT")
3724
3725    def right_sql(self, expression: exp.Right) -> str:
3726        return self._left_right_sql(expression, "RIGHT")
3727
3728    def rtrimmedlength_sql(self, expression: exp.RtrimmedLength) -> str:
3729        return self.func("LENGTH", exp.Trim(this=expression.this, position="TRAILING"))
3730
3731    def stuff_sql(self, expression: exp.Stuff) -> str:
3732        base = expression.this
3733        start = expression.args["start"]
3734        length = expression.args["length"]
3735        insertion = expression.expression
3736        is_binary = _is_binary(base)
3737
3738        if is_binary:
3739            # DuckDB's SUBSTRING doesn't accept BLOB; operate on the HEX string instead
3740            # (each byte = 2 hex chars), then UNHEX back to BLOB
3741            base = exp.Hex(this=base)
3742            insertion = exp.Hex(this=insertion)
3743            left = exp.Substring(
3744                this=base.copy(),
3745                start=exp.Literal.number(1),
3746                length=(start.copy() - exp.Literal.number(1)) * exp.Literal.number(2),
3747            )
3748            right = exp.Substring(
3749                this=base.copy(),
3750                start=((start + length) - exp.Literal.number(1)) * exp.Literal.number(2)
3751                + exp.Literal.number(1),
3752            )
3753        else:
3754            left = exp.Substring(
3755                this=base.copy(),
3756                start=exp.Literal.number(1),
3757                length=start.copy() - exp.Literal.number(1),
3758            )
3759            right = exp.Substring(this=base.copy(), start=start + length)
3760        result: exp.Expr = exp.DPipe(
3761            this=exp.DPipe(this=left, expression=insertion), expression=right
3762        )
3763
3764        if is_binary:
3765            result = exp.Unhex(this=result)
3766
3767        return self.sql(result)
3768
3769    def rand_sql(self, expression: exp.Rand) -> str:
3770        seed = expression.this
3771        if seed is not None:
3772            self.unsupported("RANDOM with seed is not supported in DuckDB")
3773
3774        lower = expression.args.get("lower")
3775        upper = expression.args.get("upper")
3776
3777        if lower and upper:
3778            # scale DuckDB's [0,1) to the specified range
3779            range_size = exp.paren(upper - lower)
3780            scaled = exp.Add(this=lower, expression=exp.func("random") * range_size)
3781
3782            # For now we assume that if bounds are set, return type is BIGINT. Snowflake/Teradata
3783            result = exp.cast(scaled, exp.DType.BIGINT)
3784            return self.sql(result)
3785
3786        # Default DuckDB behavior - just return RANDOM() as float
3787        return "RANDOM()"
3788
3789    def bytelength_sql(self, expression: exp.ByteLength) -> str:
3790        arg = expression.this
3791
3792        # Check if it's a text type (handles both literals and annotated expressions)
3793        if arg.is_type(*exp.DataType.TEXT_TYPES):
3794            return self.func("OCTET_LENGTH", exp.Encode(this=arg))
3795
3796        # Default: pass through as-is (conservative for DuckDB, handles binary and unannotated)
3797        return self.func("OCTET_LENGTH", arg)
3798
3799    def base64encode_sql(self, expression: exp.Base64Encode) -> str:
3800        # DuckDB TO_BASE64 requires BLOB input
3801        # Snowflake BASE64_ENCODE accepts both VARCHAR and BINARY - for VARCHAR it implicitly
3802        # encodes UTF-8 bytes. We add ENCODE unless the input is a binary type.
3803        result = expression.this
3804
3805        # Check if input is a string type - ENCODE only accepts VARCHAR
3806        if result.is_type(*exp.DataType.TEXT_TYPES):
3807            result = exp.Encode(this=result)
3808
3809        result = exp.ToBase64(this=result)
3810
3811        max_line_length = expression.args.get("max_line_length")
3812        alphabet = expression.args.get("alphabet")
3813
3814        # Handle custom alphabet by replacing standard chars with custom ones
3815        result = _apply_base64_alphabet_replacements(result, alphabet)
3816
3817        # Handle max_line_length by inserting newlines every N characters
3818        line_length = (
3819            t.cast(int, max_line_length.to_py())
3820            if isinstance(max_line_length, exp.Literal) and max_line_length.is_number
3821            else 0
3822        )
3823        if line_length > 0:
3824            newline = exp.Chr(expressions=[exp.Literal.number(10)])
3825            result = exp.Trim(
3826                this=exp.RegexpReplace(
3827                    this=result,
3828                    expression=exp.Literal.string(f"(.{{{line_length}}})"),
3829                    replacement=exp.Concat(expressions=[exp.Literal.string("\\1"), newline.copy()]),
3830                ),
3831                expression=newline,
3832                position="TRAILING",
3833            )
3834
3835        return self.sql(result)
3836
3837    def hex_sql(self, expression: exp.Hex) -> str:
3838        case = expression.args.get("case")
3839
3840        if not case:
3841            return self.func("HEX", expression.this)
3842
3843        hex_expr = exp.Hex(this=expression.this)
3844        return self.sql(
3845            exp.case()
3846            .when(case.is_(exp.null()), exp.null())
3847            .when(case.copy().eq(0), exp.Lower(this=hex_expr.copy()))
3848            .else_(hex_expr)
3849        )
3850
3851    def replace_sql(self, expression: exp.Replace) -> str:
3852        result_sql = self.func(
3853            "REPLACE",
3854            _cast_to_varchar(expression.this),
3855            _cast_to_varchar(expression.expression),
3856            _cast_to_varchar(expression.args.get("replacement")),
3857        )
3858        return _gen_with_cast_to_blob(self, expression, result_sql)
3859
3860    def _bitwise_op(self, expression: exp.Binary, op: str) -> str:
3861        _prepare_binary_bitwise_args(expression)
3862        result_sql = self.binary(expression, op)
3863        return _gen_with_cast_to_blob(self, expression, result_sql)
3864
3865    def bitwisexor_sql(self, expression: exp.BitwiseXor) -> str:
3866        _prepare_binary_bitwise_args(expression)
3867        result_sql = self.func("XOR", expression.this, expression.expression)
3868        return _gen_with_cast_to_blob(self, expression, result_sql)
3869
3870    def objectinsert_sql(self, expression: exp.ObjectInsert) -> str:
3871        this = expression.this
3872        key = expression.args.get("key")
3873        key_sql = key.name if isinstance(key, exp.Expr) else ""
3874        value_sql = self.sql(expression, "value")
3875
3876        kv_sql = f"{key_sql} := {value_sql}"
3877
3878        # If the input struct is empty e.g. transpiling OBJECT_INSERT(OBJECT_CONSTRUCT(), key, value) from Snowflake
3879        # then we can generate STRUCT_PACK which will build it since STRUCT_INSERT({}, key := value) is not valid DuckDB
3880        if isinstance(this, exp.Struct) and not this.expressions:
3881            return self.func("STRUCT_PACK", kv_sql)
3882
3883        return self.func("STRUCT_INSERT", this, kv_sql)
3884
3885    def mapcat_sql(self, expression: exp.MapCat) -> str:
3886        result = exp.replace_placeholders(
3887            self.MAPCAT_TEMPLATE.copy(),
3888            map1=expression.this,
3889            map2=expression.expression,
3890        )
3891        return self.sql(result)
3892
3893    def mapcontainskey_sql(self, expression: exp.MapContainsKey) -> str:
3894        return self.func(
3895            "ARRAY_CONTAINS", exp.func("MAP_KEYS", expression.args["key"]), expression.this
3896        )
3897
3898    def mapdelete_sql(self, expression: exp.MapDelete) -> str:
3899        map_arg = expression.this
3900        keys_to_delete = expression.expressions
3901
3902        x_dot_key = exp.Dot(this=exp.to_identifier("x"), expression=exp.to_identifier("key"))
3903
3904        lambda_expr = exp.Lambda(
3905            this=exp.In(this=x_dot_key, expressions=keys_to_delete).not_(),
3906            expressions=[exp.to_identifier("x")],
3907        )
3908        result = exp.func(
3909            "MAP_FROM_ENTRIES",
3910            exp.ArrayFilter(this=exp.func("MAP_ENTRIES", map_arg), expression=lambda_expr),
3911        )
3912        return self.sql(result)
3913
3914    def mappick_sql(self, expression: exp.MapPick) -> str:
3915        map_arg = expression.this
3916        keys_to_pick = expression.expressions
3917
3918        x_dot_key = exp.Dot(this=exp.to_identifier("x"), expression=exp.to_identifier("key"))
3919
3920        if len(keys_to_pick) == 1 and keys_to_pick[0].is_type(exp.DType.ARRAY):
3921            lambda_expr = exp.Lambda(
3922                this=exp.func("ARRAY_CONTAINS", keys_to_pick[0], x_dot_key),
3923                expressions=[exp.to_identifier("x")],
3924            )
3925        else:
3926            lambda_expr = exp.Lambda(
3927                this=exp.In(this=x_dot_key, expressions=keys_to_pick),
3928                expressions=[exp.to_identifier("x")],
3929            )
3930
3931        result = exp.func(
3932            "MAP_FROM_ENTRIES",
3933            exp.func("LIST_FILTER", exp.func("MAP_ENTRIES", map_arg), lambda_expr),
3934        )
3935        return self.sql(result)
3936
3937    def mapsize_sql(self, expression: exp.MapSize) -> str:
3938        return self.func("CARDINALITY", expression.this)
3939
3940    @unsupported_args("update_flag")
3941    def mapinsert_sql(self, expression: exp.MapInsert) -> str:
3942        map_arg = expression.this
3943        key = expression.args.get("key")
3944        value = expression.args.get("value")
3945
3946        map_type = map_arg.type
3947
3948        if value is not None:
3949            if map_type and map_type.expressions and len(map_type.expressions) > 1:
3950                # Extract the value type from MAP(key_type, value_type)
3951                value_type = map_type.expressions[1]
3952                # Cast value to match the map's value type to avoid type conflicts
3953                value = exp.cast(value, value_type)
3954            # else: polymorphic MAP case - no type parameters available, use value as-is
3955
3956        # Create a single-entry map for the new key-value pair
3957        new_entry_struct = exp.Struct(expressions=[exp.PropertyEQ(this=key, expression=value)])
3958        new_entry: exp.Expression = exp.ToMap(this=new_entry_struct)
3959
3960        # Use MAP_CONCAT to merge the original map with the new entry
3961        # This automatically handles both insert and update cases
3962        result = exp.func("MAP_CONCAT", map_arg, new_entry)
3963
3964        return self.sql(result)
3965
3966    def startswith_sql(self, expression: exp.StartsWith) -> str:
3967        return self.func(
3968            "STARTS_WITH",
3969            _cast_to_varchar(expression.this),
3970            _cast_to_varchar(expression.expression),
3971        )
3972
3973    def space_sql(self, expression: exp.Space) -> str:
3974        # DuckDB's REPEAT requires BIGINT for the count parameter
3975        return self.sql(
3976            exp.Repeat(
3977                this=exp.Literal.string(" "),
3978                times=exp.cast(expression.this, exp.DType.BIGINT),
3979            )
3980        )
3981
3982    def tablefromrows_sql(self, expression: exp.TableFromRows) -> str:
3983        # For GENERATOR, unwrap TABLE() - just emit the Generator (becomes RANGE)
3984        if isinstance(expression.this, exp.Generator):
3985            # Preserve alias, joins, and other table-level args
3986            table = exp.Table(
3987                this=expression.this,
3988                alias=expression.args.get("alias"),
3989                joins=expression.args.get("joins"),
3990            )
3991            return self.sql(table)
3992
3993        return super().tablefromrows_sql(expression)
3994
3995    def unnest_sql(self, expression: exp.Unnest) -> str:
3996        explode_array = expression.args.get("explode_array")
3997        if explode_array:
3998            # In BigQuery, UNNESTing a nested array leads to explosion of the top-level array & struct
3999            # This is transpiled to DDB by transforming "FROM UNNEST(...)" to "FROM (SELECT UNNEST(..., max_depth => 2))"
4000            expression.expressions.append(
4001                exp.Kwarg(this=exp.var("max_depth"), expression=exp.Literal.number(2))
4002            )
4003
4004            # If BQ's UNNEST is aliased, we transform it from a column alias to a table alias in DDB
4005            alias = expression.args.get("alias")
4006            if isinstance(alias, exp.TableAlias):
4007                expression.set("alias", None)
4008                if alias.columns:
4009                    alias = exp.TableAlias(this=seq_get(alias.columns, 0))
4010
4011            unnest_sql = super().unnest_sql(expression)
4012            select = exp.Select(expressions=[unnest_sql]).subquery(alias)
4013            return self.sql(select)
4014
4015        return super().unnest_sql(expression)
4016
4017    def arrayagg_sql(self, expression: exp.ArrayAgg) -> str:
4018        if isinstance(expression.this, exp.Limit):
4019            self.unsupported("LIMIT inside ARRAY_AGG is not supported in DuckDB")
4020
4021        return super().arrayagg_sql(expression)
4022
4023    def ignorenulls_sql(self, expression: exp.IgnoreNulls) -> str:
4024        this = expression.this
4025
4026        if isinstance(this, self.IGNORE_RESPECT_NULLS_WINDOW_FUNCTIONS):
4027            # DuckDB should render IGNORE NULLS only for the general-purpose
4028            # window functions that accept it e.g. FIRST_VALUE(... IGNORE NULLS) OVER (...)
4029            return super().ignorenulls_sql(expression)
4030
4031        # For ARRAY_AGG(expr IGNORE NULLS ...), convert IGNORE NULLS to a
4032        # FILTER(WHERE expr IS NOT NULL) clause by setting nulls_excluded on
4033        # the ArrayAgg.  The existing _add_arrayagg_null_filter method will
4034        # emit the FILTER clause during arrayagg_sql / withingroup_sql.
4035        if isinstance(this, exp.ArrayAgg):
4036            this.set("nulls_excluded", True)
4037            return self.sql(this)
4038
4039        if isinstance(this, exp.First):
4040            this = exp.AnyValue(this=this.this)
4041
4042        if not isinstance(this, (exp.AnyValue, exp.ApproxQuantiles)):
4043            self.unsupported("IGNORE NULLS is not supported for non-window functions.")
4044
4045        return self.sql(this)
4046
4047    def split_sql(self, expression: exp.Split) -> str:
4048        base_func = exp.func("STR_SPLIT", expression.this, expression.expression)
4049
4050        case_expr = exp.case().else_(base_func)
4051        needs_case = False
4052
4053        if expression.args.get("null_returns_null"):
4054            case_expr = case_expr.when(expression.expression.is_(exp.null()), exp.null())
4055            needs_case = True
4056
4057        if expression.args.get("empty_delimiter_returns_whole"):
4058            # When delimiter is empty string, return input string as single array element
4059            array_with_input = exp.array(expression.this)
4060            case_expr = case_expr.when(
4061                expression.expression.eq(exp.Literal.string("")), array_with_input
4062            )
4063            needs_case = True
4064
4065        return self.sql(case_expr if needs_case else base_func)
4066
4067    def splitpart_sql(self, expression: exp.SplitPart) -> str:
4068        string_arg = expression.this
4069        delimiter_arg = expression.args.get("delimiter")
4070        part_index_arg = expression.args.get("part_index")
4071
4072        if delimiter_arg and part_index_arg:
4073            # Handle Snowflake's "index 0 and 1 both return first element" behavior
4074            if expression.args.get("part_index_zero_as_one"):
4075                # Convert 0 to 1 for compatibility
4076
4077                part_index_arg = exp.Paren(
4078                    this=exp.case()
4079                    .when(part_index_arg.eq(exp.Literal.number("0")), exp.Literal.number("1"))
4080                    .else_(part_index_arg)
4081                )
4082
4083            # Use Anonymous to avoid recursion
4084            base_func_expr: exp.Expr = exp.Anonymous(
4085                this="SPLIT_PART", expressions=[string_arg, delimiter_arg, part_index_arg]
4086            )
4087            needs_case_transform = False
4088            case_expr = exp.case().else_(base_func_expr)
4089
4090            if expression.args.get("empty_delimiter_returns_whole"):
4091                # When delimiter is empty string:
4092                # - Return whole string if part_index is 1 or -1
4093                # - Return empty string otherwise
4094                empty_case = exp.Paren(
4095                    this=exp.case()
4096                    .when(
4097                        exp.or_(
4098                            part_index_arg.eq(exp.Literal.number("1")),
4099                            part_index_arg.eq(exp.Literal.number("-1")),
4100                        ),
4101                        string_arg,
4102                    )
4103                    .else_(exp.Literal.string(""))
4104                )
4105
4106                case_expr = case_expr.when(delimiter_arg.eq(exp.Literal.string("")), empty_case)
4107                needs_case_transform = True
4108
4109            """
4110            Output looks something like this:
4111
4112            CASE
4113            WHEN delimiter is '' THEN
4114                (
4115                    CASE
4116                    WHEN adjusted_part_index = 1 OR adjusted_part_index = -1 THEN input
4117                    ELSE '' END
4118                )
4119            ELSE SPLIT_PART(input, delimiter, adjusted_part_index)
4120            END
4121
4122            """
4123            return self.sql(case_expr if needs_case_transform else base_func_expr)
4124
4125        return self.function_fallback_sql(expression)
4126
4127    def respectnulls_sql(self, expression: exp.RespectNulls) -> str:
4128        if isinstance(expression.this, self.IGNORE_RESPECT_NULLS_WINDOW_FUNCTIONS):
4129            # DuckDB should render RESPECT NULLS only for the general-purpose
4130            # window functions that accept it e.g. FIRST_VALUE(... RESPECT NULLS) OVER (...)
4131            return super().respectnulls_sql(expression)
4132
4133        self.unsupported("RESPECT NULLS is not supported for non-window functions.")
4134        return self.sql(expression, "this")
4135
4136    def arraytostring_sql(self, expression: exp.ArrayToString) -> str:
4137        null = expression.args.get("null")
4138
4139        if expression.args.get("null_is_empty"):
4140            x = exp.to_identifier("x")
4141            list_transform = exp.Transform(
4142                this=expression.this.copy(),
4143                expression=exp.Lambda(
4144                    this=exp.Coalesce(
4145                        this=exp.cast(x, "TEXT"), expressions=[exp.Literal.string("")]
4146                    ),
4147                    expressions=[x],
4148                ),
4149            )
4150            array_to_string = exp.ArrayToString(
4151                this=list_transform, expression=expression.expression
4152            )
4153            if expression.args.get("null_delim_is_null"):
4154                return self.sql(
4155                    exp.case()
4156                    .when(expression.expression.copy().is_(exp.null()), exp.null())
4157                    .else_(array_to_string)
4158                )
4159            return self.sql(array_to_string)
4160
4161        if null:
4162            x = exp.to_identifier("x")
4163            return self.sql(
4164                exp.ArrayToString(
4165                    this=exp.Transform(
4166                        this=expression.this,
4167                        expression=exp.Lambda(
4168                            this=exp.Coalesce(this=x, expressions=[null]),
4169                            expressions=[x],
4170                        ),
4171                    ),
4172                    expression=expression.expression,
4173                )
4174            )
4175
4176        return self.func("ARRAY_TO_STRING", expression.this, expression.expression)
4177
4178    def concatws_sql(self, expression: exp.ConcatWs) -> str:
4179        # DuckDB-specific: handle binary types using DPipe (||) operator
4180        separator = seq_get(expression.expressions, 0)
4181        args = expression.expressions[1:]
4182
4183        if any(_is_binary(arg) for arg in [separator, *args]):
4184            result = args[0]
4185            for arg in args[1:]:
4186                result = exp.DPipe(
4187                    this=exp.DPipe(this=result, expression=separator), expression=arg
4188                )
4189            return self.sql(result)
4190
4191        if expression.args.get("flatten") and any(
4192            isinstance(arg, exp.Array) or arg.is_type(exp.DType.ARRAY) for arg in args
4193        ):
4194            arrays = []
4195            for arg in args:
4196                if isinstance(arg, exp.Array) or arg.is_type(exp.DType.ARRAY):
4197                    arg = exp.cast(arg, "TEXT[]")
4198                else:
4199                    if (not arg.type or arg.is_type(exp.DType.UNKNOWN)) and not isinstance(
4200                        arg, exp.CONSTANTS
4201                    ):
4202                        self.unsupported(
4203                            "Cannot transpile CONCAT_WS with unknown argument types to DuckDB."
4204                        )
4205                    arg = exp.array(exp.cast(arg, exp.DType.TEXT))
4206
4207                arrays.append(arg)
4208
4209            array = (
4210                exp.ArrayConcat(this=arrays[0], expressions=arrays[1:])
4211                if len(arrays) > 1
4212                else arrays[0]
4213            )
4214            return self.func("CONCAT_WS", separator, self.func("ARRAY_TO_STRING", array, separator))
4215
4216        return super().concatws_sql(expression)
4217
4218    def _regexp_extract_sql(self, expression: exp.RegexpExtract | exp.RegexpExtractAll) -> str:
4219        this = expression.this
4220        group = expression.args.get("group")
4221        params = expression.args.get("parameters")
4222        position = expression.args.get("position")
4223        occurrence = expression.args.get("occurrence")
4224        null_if_pos_overflow = expression.args.get("null_if_pos_overflow")
4225
4226        # Handle Snowflake's 'e' flag: it enables capture group extraction
4227        # In DuckDB, this is controlled by the group parameter directly
4228        if params and params.is_string and "e" in params.name:
4229            params = exp.Literal.string(params.name.replace("e", ""))
4230
4231        validated_flags = self._validate_regexp_flags(params, supported_flags="cims")
4232
4233        # Strip default group when no following params (DuckDB default is same as group=0)
4234        if (
4235            not validated_flags
4236            and group
4237            and group.name == str(self.dialect.REGEXP_EXTRACT_DEFAULT_GROUP)
4238        ):
4239            group = None
4240
4241        flags_expr = exp.Literal.string(validated_flags) if validated_flags else None
4242
4243        # use substring to handle position argument
4244        if position and (not position.is_int or position.to_py() > 1):
4245            this = exp.Substring(this=this, start=position)
4246
4247            if null_if_pos_overflow:
4248                this = exp.Nullif(this=this, expression=exp.Literal.string(""))
4249
4250        is_extract_all = isinstance(expression, exp.RegexpExtractAll)
4251        non_single_occurrence = occurrence and (not occurrence.is_int or occurrence.to_py() > 1)
4252
4253        if is_extract_all or non_single_occurrence:
4254            name = "REGEXP_EXTRACT_ALL"
4255        else:
4256            name = "REGEXP_EXTRACT"
4257
4258        result: exp.Expr = exp.Anonymous(
4259            this=name, expressions=[this, expression.expression, group, flags_expr]
4260        )
4261
4262        # Array slicing for REGEXP_EXTRACT_ALL with occurrence
4263        if is_extract_all and non_single_occurrence:
4264            result = exp.Bracket(this=result, expressions=[exp.Slice(this=occurrence)])
4265        # ARRAY_EXTRACT for REGEXP_EXTRACT with occurrence > 1
4266        elif non_single_occurrence:
4267            result = exp.Anonymous(this="ARRAY_EXTRACT", expressions=[result, occurrence])
4268
4269        return self.sql(result)
4270
4271    def regexpextract_sql(self, expression: exp.RegexpExtract) -> str:
4272        return self._regexp_extract_sql(expression)
4273
4274    def regexpextractall_sql(self, expression: exp.RegexpExtractAll) -> str:
4275        return self._regexp_extract_sql(expression)
4276
4277    def regexpinstr_sql(self, expression: exp.RegexpInstr) -> str:
4278        this = expression.this
4279        pattern = expression.expression
4280        position = expression.args.get("position")
4281        orig_occ = expression.args.get("occurrence")
4282        occurrence = orig_occ or exp.Literal.number(1)
4283        option = expression.args.get("option")
4284        parameters = expression.args.get("parameters")
4285
4286        validated_flags = self._validate_regexp_flags(parameters, supported_flags="ims")
4287        # Checked before the flags are embedded, which would make the pattern non-empty
4288        is_empty = pattern.copy().eq(exp.Literal.string(""))
4289        if validated_flags:
4290            pattern = exp.Concat(expressions=[exp.Literal.string(f"(?{validated_flags})"), pattern])
4291
4292        # Handle starting position offset
4293        pos_offset: exp.Expr = exp.Literal.number(0)
4294        if position and (not position.is_int or position.to_py() > 1):
4295            this = exp.Substring(this=this, start=position)
4296            pos_offset = position - exp.Literal.number(1)
4297
4298        # Helper: LIST_SUM(LIST_TRANSFORM(list[1:end], x -> LENGTH(x)))
4299        def sum_lengths(func_name: str, end: exp.Expr) -> exp.Expr:
4300            lst = exp.Bracket(
4301                this=exp.Anonymous(this=func_name, expressions=[this, pattern]),
4302                expressions=[exp.Slice(this=exp.Literal.number(1), expression=end)],
4303                offset=1,
4304            )
4305            transform = exp.Anonymous(
4306                this="LIST_TRANSFORM",
4307                expressions=[
4308                    lst,
4309                    exp.Lambda(
4310                        this=exp.Length(this=exp.to_identifier("x")),
4311                        expressions=[exp.to_identifier("x")],
4312                    ),
4313                ],
4314            )
4315            return exp.Coalesce(
4316                this=exp.Anonymous(this="LIST_SUM", expressions=[transform]),
4317                expressions=[exp.Literal.number(0)],
4318            )
4319
4320        # Position = 1 + sum(split_lengths[1:occ]) + sum(match_lengths[1:occ-1]) + offset
4321        base_pos: exp.Expr = (
4322            exp.Literal.number(1)
4323            + sum_lengths("STRING_SPLIT_REGEX", occurrence)
4324            + sum_lengths("REGEXP_EXTRACT_ALL", occurrence - exp.Literal.number(1))
4325            + pos_offset
4326        )
4327
4328        # option=1: add match length for end position
4329        if option and option.is_int and option.to_py() == 1:
4330            match_at_occ = exp.Bracket(
4331                this=exp.Anonymous(this="REGEXP_EXTRACT_ALL", expressions=[this, pattern]),
4332                expressions=[occurrence],
4333                offset=1,
4334            )
4335            base_pos = base_pos + exp.Coalesce(
4336                this=exp.Length(this=match_at_occ), expressions=[exp.Literal.number(0)]
4337            )
4338
4339        # NULL checks for all provided arguments
4340        # .copy() is used strictly because .is_() alters the node's parent pointer, mutating the parsed AST
4341        null_args = [
4342            expression.this,
4343            expression.expression,
4344            position,
4345            orig_occ,
4346            option,
4347            parameters,
4348        ]
4349        null_checks = [arg.copy().is_(exp.Null()) for arg in null_args if arg]
4350
4351        matches = exp.Anonymous(this="REGEXP_EXTRACT_ALL", expressions=[this, pattern])
4352
4353        return self.sql(
4354            exp.case()
4355            .when(exp.or_(*null_checks), exp.Null())
4356            .when(is_empty, exp.Literal.number(0))
4357            .when(exp.Length(this=matches) < occurrence, exp.Literal.number(0))
4358            .else_(base_pos)
4359        )
4360
4361    @unsupported_args("culture")
4362    def numbertostr_sql(self, expression: exp.NumberToStr) -> str:
4363        fmt = expression.args.get("format")
4364        if fmt and fmt.is_int:
4365            return self.func("FORMAT", f"'{{:,.{fmt.name}f}}'", expression.this)
4366
4367        self.unsupported("Only integer formats are supported by NumberToStr")
4368        return self.function_fallback_sql(expression)
4369
4370    def autoincrementcolumnconstraint_sql(self, _) -> str:
4371        self.unsupported("The AUTOINCREMENT column constraint is not supported by DuckDB")
4372        return ""
4373
4374    def aliases_sql(self, expression: exp.Aliases) -> str:
4375        this = expression.this
4376        if isinstance(this, exp.Posexplode):
4377            return self.posexplode_sql(this)
4378
4379        return super().aliases_sql(expression)
4380
4381    def posexplode_sql(self, expression: exp.Posexplode) -> str:
4382        this = expression.this
4383        parent = expression.parent
4384
4385        # The default Spark aliases are "pos" and "col", unless specified otherwise
4386        pos, col = exp.to_identifier("pos"), exp.to_identifier("col")
4387
4388        if isinstance(parent, exp.Aliases):
4389            # Column case: SELECT POSEXPLODE(col) [AS (a, b)]
4390            pos, col = parent.expressions
4391        elif isinstance(parent, exp.Table):
4392            # Table case: SELECT * FROM POSEXPLODE(col) [AS (a, b)]
4393            alias = parent.args.get("alias")
4394            if alias:
4395                pos, col = alias.columns or [pos, col]
4396                alias.pop()
4397
4398        # Translate POSEXPLODE to UNNEST + GENERATE_SUBSCRIPTS
4399        # Note: In Spark pos is 0-indexed, but in DuckDB it's 1-indexed, so we subtract 1 from GENERATE_SUBSCRIPTS
4400        unnest_sql = self.sql(exp.Unnest(expressions=[this], alias=col))
4401        gen_subscripts = self.sql(
4402            exp.Alias(
4403                this=exp.Anonymous(
4404                    this="GENERATE_SUBSCRIPTS", expressions=[this, exp.Literal.number(1)]
4405                )
4406                - exp.Literal.number(1),
4407                alias=pos,
4408            )
4409        )
4410
4411        posexplode_sql = self.format_args(gen_subscripts, unnest_sql)
4412
4413        if isinstance(parent, exp.From) or (parent and isinstance(parent.parent, exp.From)):
4414            # SELECT * FROM POSEXPLODE(col) -> SELECT * FROM (SELECT GENERATE_SUBSCRIPTS(...), UNNEST(...))
4415            return self.sql(exp.Subquery(this=exp.Select(expressions=[posexplode_sql])))
4416
4417        return posexplode_sql
4418
4419    def addmonths_sql(self, expression: exp.AddMonths) -> str:
4420        """
4421        Handles three key issues:
4422        1. Float/decimal months: e.g., Snowflake rounds, whereas DuckDB INTERVAL requires integers
4423        2. End-of-month preservation: If input is last day of month, result is last day of result month
4424        3. Type preservation: Maintains DATE/TIMESTAMPTZ types (DuckDB defaults to TIMESTAMP)
4425        """
4426        from sqlglot.optimizer.annotate_types import annotate_types
4427
4428        this = expression.this
4429        if not this.type:
4430            this = annotate_types(this, dialect=self.dialect)
4431
4432        if this.is_type(*exp.DataType.TEXT_TYPES):
4433            this = exp.Cast(this=this, to=exp.DataType(this=exp.DType.TIMESTAMP))
4434
4435        # Detect float/decimal months to apply rounding (Snowflake behavior)
4436        # DuckDB INTERVAL syntax doesn't support non-integer expressions, so use TO_MONTHS
4437        months_expr = expression.expression
4438        if not months_expr.type:
4439            months_expr = annotate_types(months_expr, dialect=self.dialect)
4440
4441        # Build interval or to_months expression based on type
4442        # Float/decimal case: Round and use TO_MONTHS(CAST(ROUND(value) AS INT))
4443        interval_or_to_months = (
4444            exp.func("TO_MONTHS", exp.cast(exp.func("ROUND", months_expr), "INT"))
4445            if months_expr.is_type(
4446                exp.DType.FLOAT,
4447                exp.DType.DOUBLE,
4448                exp.DType.DECIMAL,
4449            )
4450            # Integer case: standard INTERVAL N MONTH syntax
4451            else exp.Interval(this=months_expr, unit=exp.var("MONTH"))
4452        )
4453
4454        date_add_expr = exp.Add(this=this, expression=interval_or_to_months)
4455
4456        # Apply end-of-month preservation if Snowflake flag is set
4457        # CASE WHEN LAST_DAY(date) = date THEN LAST_DAY(result) ELSE result END
4458        preserve_eom = expression.args.get("preserve_end_of_month")
4459        result_expr = (
4460            exp.case()
4461            .when(
4462                exp.EQ(this=exp.func("LAST_DAY", this), expression=this),
4463                exp.func("LAST_DAY", date_add_expr),
4464            )
4465            .else_(date_add_expr)
4466            if preserve_eom
4467            else date_add_expr
4468        )
4469
4470        # DuckDB's DATE_ADD function returns TIMESTAMP/DATETIME by default, even when the input is DATE
4471        # To match for example Snowflake's ADD_MONTHS behavior (which preserves the input type)
4472        # We need to cast the result back to the original type when the input is DATE or TIMESTAMPTZ
4473        # Example: ADD_MONTHS('2023-01-31'::date, 1) should return DATE, not TIMESTAMP
4474        if this.is_type(exp.DType.DATE, exp.DType.TIMESTAMPTZ):
4475            return self.sql(exp.Cast(this=result_expr, to=this.type))
4476        return self.sql(result_expr)
4477
4478    def format_sql(self, expression: exp.Format) -> str:
4479        if expression.name.lower() == "%s" and len(expression.expressions) == 1:
4480            return self.func("FORMAT", "'{}'", expression.expressions[0])
4481
4482        return self.function_fallback_sql(expression)
4483
4484    def hexstring_sql(
4485        self, expression: exp.HexString, binary_function_repr: str | None = None
4486    ) -> str:
4487        # UNHEX('FF') correctly produces blob \xFF in DuckDB
4488        return super().hexstring_sql(expression, binary_function_repr="UNHEX")
4489
4490    def datetrunc_sql(self, expression: exp.DateTrunc) -> str:
4491        unit = expression.args.get("unit")
4492        date = expression.this
4493
4494        week_start = _week_trunc_start_dow(unit)
4495        unit = unit_to_str(expression)
4496
4497        if week_start:
4498            result = self.sql(
4499                _build_week_trunc_expression(date, week_start, preserve_start_day=True)
4500            )
4501        else:
4502            result = self.func("DATE_TRUNC", unit, date)
4503
4504        if (
4505            expression.args.get("input_type_preserved")
4506            and date.is_type(*exp.DataType.TEMPORAL_TYPES)
4507            and not (is_date_unit(unit) and date.is_type(exp.DType.DATE))
4508        ):
4509            return self.sql(exp.Cast(this=result, to=date.type))
4510
4511        return result
4512
4513    def datetimetrunc_sql(self, expression: exp.DatetimeTrunc) -> str:
4514        this = exp.cast(expression.this, exp.DType.DATETIME)
4515        week_start = _week_trunc_start_dow(expression.args.get("unit"))
4516        if week_start:
4517            return self.sql(
4518                _build_week_trunc_expression(
4519                    this, week_start, preserve_start_day=True, cast_to_date=False
4520                )
4521            )
4522
4523        return self.func("DATE_TRUNC", unit_to_str(expression), this)
4524
4525    def timestamptrunc_sql(self, expression: exp.TimestampTrunc) -> str:
4526        zone = expression.args.get("zone")
4527        timestamp = expression.this
4528        week_start = _week_trunc_start_dow(expression.args.get("unit"))
4529
4530        # The week start emulation below is exact, so avoid weekstart_unit_to_str's degrade warning
4531        unit = unit_to_str(expression) if week_start else weekstart_unit_to_str(self, expression)
4532        date_unit = is_date_unit(unit) or bool(week_start)
4533
4534        def _trunc_expr(this: exp.Expr) -> exp.Expr:
4535            if week_start:
4536                return _build_week_trunc_expression(
4537                    this, week_start, preserve_start_day=True, cast_to_date=False
4538                )
4539            return exp.func("DATE_TRUNC", unit, this)
4540
4541        if date_unit and zone:
4542            # BigQuery's TIMESTAMP_TRUNC with timezone truncates in the target timezone and returns as UTC.
4543            # Double AT TIME ZONE needed for BigQuery compatibility:
4544            # 1. First AT TIME ZONE: ensures truncation happens in the target timezone
4545            # 2. Second AT TIME ZONE: converts the DATE result back to TIMESTAMPTZ (preserving time component)
4546            timestamp = exp.AtTimeZone(this=timestamp, zone=zone)
4547            trunced = _trunc_expr(timestamp)
4548            if isinstance(trunced, exp.DateAdd):
4549                # Parenthesize so the trailing AT TIME ZONE binds to the whole shifted expression
4550                trunced = exp.Paren(this=trunced)
4551            return self.sql(exp.AtTimeZone(this=trunced, zone=zone))
4552
4553        result = self.sql(_trunc_expr(timestamp))
4554        if expression.args.get("input_type_preserved"):
4555            if timestamp.type and timestamp.is_type(exp.DType.TIME, exp.DType.TIMETZ):
4556                dummy_date = exp.Cast(
4557                    this=exp.Literal.string("1970-01-01"),
4558                    to=exp.DataType(this=exp.DType.DATE),
4559                )
4560                date_time = exp.Add(this=dummy_date, expression=timestamp)
4561                result = self.func("DATE_TRUNC", unit, date_time)
4562                return self.sql(exp.Cast(this=result, to=timestamp.type))
4563
4564            if timestamp.is_type(*exp.DataType.TEMPORAL_TYPES) and not (
4565                date_unit and timestamp.is_type(exp.DType.DATE)
4566            ):
4567                return self.sql(exp.Cast(this=result, to=timestamp.type))
4568
4569        return result
4570
4571    def trim_sql(self, expression: exp.Trim) -> str:
4572        expression.this.replace(_cast_to_varchar(expression.this))
4573        if expression.expression:
4574            expression.expression.replace(_cast_to_varchar(expression.expression))
4575
4576        result_sql = super().trim_sql(expression)
4577        return _gen_with_cast_to_blob(self, expression, result_sql)
4578
4579    def round_sql(self, expression: exp.Round) -> str:
4580        this = expression.this
4581        decimals = expression.args.get("decimals")
4582        truncate = expression.args.get("truncate")
4583
4584        # DuckDB requires the scale (decimals) argument to be an INT
4585        # Some dialects (e.g., Snowflake) allow non-integer scales and cast to an integer internally
4586        if decimals is not None and expression.args.get("casts_non_integer_decimals"):
4587            if not (decimals.is_int or decimals.is_type(*exp.DataType.INTEGER_TYPES)):
4588                decimals = exp.cast(decimals, exp.DType.INT)
4589
4590        func = "ROUND"
4591        if truncate:
4592            # BigQuery uses ROUND_HALF_EVEN; Snowflake uses HALF_TO_EVEN
4593            if truncate.this in ("ROUND_HALF_EVEN", "HALF_TO_EVEN"):
4594                func = "ROUND_EVEN"
4595                truncate = None
4596            # BigQuery uses ROUND_HALF_AWAY_FROM_ZERO; Snowflake uses HALF_AWAY_FROM_ZERO
4597            elif truncate.this in ("ROUND_HALF_AWAY_FROM_ZERO", "HALF_AWAY_FROM_ZERO"):
4598                truncate = None
4599
4600        return self.func(func, this, decimals, truncate)
4601
4602    def trycast_sql(self, expression: exp.TryCast) -> str:
4603        to = expression.to
4604        to_type = to.this
4605        src = expression.this
4606
4607        if (
4608            expression.args.get("null_on_text_overflow")
4609            and to_type in exp.DataType.TEXT_TYPES
4610            and to.expressions
4611        ):
4612            return self.sql(
4613                exp.case()
4614                .when(
4615                    exp.LTE(this=exp.func("LENGTH", src), expression=to.expressions[0].this),
4616                    exp.cast(src, "TEXT"),
4617                )
4618                .else_(exp.Null())
4619            )
4620        elif to_type == exp.DType.DATE and expression.args.get("probe_date_format"):
4621            slash_strptime = exp.cast(
4622                exp.func("TRY_STRPTIME", src, exp.Literal.string(self._TRYCAST_DATE_SLASH_FMT)),
4623                "DATE",
4624            )
4625            mon_strptime = exp.cast(
4626                exp.func("TRY_STRPTIME", src, exp.Literal.string(self._TRYCAST_DATE_MON_FMT)),
4627                "DATE",
4628            )
4629            return self.sql(
4630                exp.case()
4631                .when(exp.func("CONTAINS", src, exp.Literal.string("/")), slash_strptime)
4632                .when(
4633                    exp.RegexpLike(this=src, expression=exp.Literal.string("[A-Za-z]")),
4634                    mon_strptime,
4635                )
4636                .else_(exp.TryCast(this=src, to=to))
4637            )
4638        elif (
4639            isinstance(to_type, exp.Interval)
4640            and (unit := to_type.unit)
4641            and expression.args.get("requires_string")
4642        ):
4643            interval_type = exp.DataType.build("INTERVAL")
4644            if isinstance(unit, exp.IntervalSpan):
4645                self.unsupported(
4646                    "TRY_CAST to INTERVAL with span (e.g. HOUR TO MINUTE) is not supported in DuckDB"
4647                )
4648                return self.sql(exp.TryCast(this=src, to=interval_type))
4649            return self.sql(
4650                exp.TryCast(
4651                    this=exp.DPipe(this=src, expression=exp.Literal.string(f" {unit.name}")),
4652                    to=interval_type,
4653                )
4654            )
4655
4656        return super().trycast_sql(expression)
4657
4658    def strtok_sql(self, expression: exp.Strtok) -> str:
4659        string_arg = expression.this
4660        delimiter_arg = expression.args.get("delimiter")
4661        part_index_arg = expression.args.get("part_index")
4662
4663        if delimiter_arg and part_index_arg:
4664            # Escape regex chars and build character class at runtime using REGEXP_REPLACE
4665            escaped_delimiter = exp.Anonymous(
4666                this="REGEXP_REPLACE",
4667                expressions=[
4668                    delimiter_arg,
4669                    exp.Literal.string(
4670                        r"([\[\]^.\-*+?(){}|$\\])"
4671                    ),  # Escape problematic regex chars
4672                    exp.Literal.string(
4673                        r"\\\1"
4674                    ),  # Replace with escaped version using $1 backreference
4675                    exp.Literal.string("g"),  # Global flag
4676                ],
4677            )
4678            # CASE WHEN delimiter = '' THEN '' ELSE CONCAT('[', escaped_delimiter, ']') END
4679            regex_pattern = (
4680                exp.case()
4681                .when(delimiter_arg.eq(exp.Literal.string("")), exp.Literal.string(""))
4682                .else_(
4683                    exp.func(
4684                        "CONCAT",
4685                        exp.Literal.string("["),
4686                        escaped_delimiter,
4687                        exp.Literal.string("]"),
4688                    )
4689                )
4690            )
4691
4692            # STRTOK skips empty strings, so we need to filter them out
4693            # LIST_FILTER(REGEXP_SPLIT_TO_ARRAY(string, pattern), x -> x != '')[index]
4694            split_array = exp.func("REGEXP_SPLIT_TO_ARRAY", string_arg, regex_pattern)
4695            x = exp.to_identifier("x")
4696            is_empty = x.eq(exp.Literal.string(""))
4697            filtered_array = exp.func(
4698                "LIST_FILTER",
4699                split_array,
4700                exp.Lambda(this=exp.not_(is_empty.copy()), expressions=[x.copy()]),
4701            )
4702            base_func = exp.Bracket(
4703                this=filtered_array,
4704                expressions=[part_index_arg],
4705                offset=1,
4706            )
4707
4708            # Use template with the built regex pattern
4709            result = exp.replace_placeholders(
4710                self.STRTOK_TEMPLATE.copy(),
4711                string=string_arg,
4712                delimiter=delimiter_arg,
4713                part_index=part_index_arg,
4714                base_func=base_func,
4715            )
4716
4717            return self.sql(result)
4718
4719        return self.function_fallback_sql(expression)
4720
4721    def strtoktoarray_sql(self, expression: exp.StrtokToArray) -> str:
4722        string_arg = expression.this
4723        delimiter_arg = expression.args.get("expression") or exp.Literal.string(" ")
4724
4725        escaped = exp.RegexpReplace(
4726            this=delimiter_arg.copy(),
4727            expression=exp.Literal.string(r"([\[\]^.\-*+?(){}|$\\])"),
4728            replacement=exp.Literal.string(r"\\\1"),
4729            modifiers=exp.Literal.string("g"),
4730        )
4731        return self.sql(
4732            exp.replace_placeholders(
4733                self.STRTOK_TO_ARRAY_TEMPLATE.copy(),
4734                string=string_arg,
4735                delimiter=delimiter_arg,
4736                escaped=escaped,
4737            )
4738        )
4739
4740    def approxquantile_sql(self, expression: exp.ApproxQuantile) -> str:
4741        result = self.func("APPROX_QUANTILE", expression.this, expression.args.get("quantile"))
4742
4743        # DuckDB returns integers for APPROX_QUANTILE, cast to DOUBLE if the expected type is a real type
4744        if expression.is_type(*exp.DataType.REAL_TYPES):
4745            result = f"CAST({result} AS DOUBLE)"
4746
4747        return result
4748
4749    def approxquantiles_sql(self, expression: exp.ApproxQuantiles) -> str:
4750        """
4751        BigQuery's APPROX_QUANTILES(expr, n) returns an array of n+1 approximate quantile values
4752        dividing the input distribution into n equal-sized buckets.
4753
4754        Both BigQuery and DuckDB use approximate algorithms for quantile estimation, but BigQuery
4755        does not document the specific algorithm used so results may differ. DuckDB does not
4756        support RESPECT NULLS.
4757        """
4758        this = expression.this
4759        if isinstance(this, exp.Distinct):
4760            # APPROX_QUANTILES requires 2 args and DISTINCT node grabs both
4761            if len(this.expressions) < 2:
4762                self.unsupported("APPROX_QUANTILES requires a bucket count argument")
4763                return self.function_fallback_sql(expression)
4764            num_quantiles_expr = this.expressions[1].pop()
4765        else:
4766            num_quantiles_expr = expression.expression
4767
4768        if not isinstance(num_quantiles_expr, exp.Literal) or not num_quantiles_expr.is_int:
4769            self.unsupported("APPROX_QUANTILES bucket count must be a positive integer")
4770            return self.function_fallback_sql(expression)
4771
4772        num_quantiles = t.cast(int, num_quantiles_expr.to_py())
4773        if num_quantiles <= 0:
4774            self.unsupported("APPROX_QUANTILES bucket count must be a positive integer")
4775            return self.function_fallback_sql(expression)
4776
4777        quantiles = [
4778            exp.Literal.number(Decimal(i) / Decimal(num_quantiles))
4779            for i in range(num_quantiles + 1)
4780        ]
4781
4782        return self.sql(exp.ApproxQuantile(this=this, quantile=exp.Array(expressions=quantiles)))
4783
4784    def jsonextractscalar_sql(self, expression: exp.JSONExtractScalar) -> str:
4785        if expression.args.get("scalar_only"):
4786            json_value = exp.JSONExtractScalar(
4787                this=rename_func("JSON_VALUE")(self, expression), expression="'$'"
4788            )
4789
4790            # `->>` binds looser than most operators, so the wrap logic needs the parent
4791            json_value.parent = expression.parent
4792            expression = json_value
4793
4794        return _arrow_json_extract_sql(self, expression)
4795
4796    def bitwisenot_sql(self, expression: exp.BitwiseNot) -> str:
4797        this = expression.this
4798
4799        if _is_binary(this):
4800            expression.type = exp.DType.BINARY.into_expr()
4801
4802        arg = _cast_to_bit(this)
4803
4804        if isinstance(this, exp.Neg):
4805            arg = exp.Paren(this=arg)
4806
4807        expression.set("this", arg)
4808
4809        result_sql = f"~{self.sql(expression, 'this')}"
4810
4811        return _gen_with_cast_to_blob(self, expression, result_sql)
4812
4813    def window_sql(self, expression: exp.Window) -> str:
4814        this = expression.this
4815        if isinstance(this, exp.Corr) or (
4816            isinstance(this, exp.Filter) and isinstance(this.this, exp.Corr)
4817        ):
4818            return self._corr_sql(expression)
4819
4820        return super().window_sql(expression)
4821
4822    def filter_sql(self, expression: exp.Filter) -> str:
4823        if isinstance(expression.this, exp.Corr):
4824            return self._corr_sql(expression)
4825
4826        return super().filter_sql(expression)
4827
4828    def _corr_sql(
4829        self,
4830        expression: exp.Filter | exp.Window | exp.Corr,
4831    ) -> str:
4832        if isinstance(expression, exp.Corr) and not expression.args.get("null_on_zero_variance"):
4833            return self.func("CORR", expression.this, expression.expression)
4834
4835        corr_expr = _maybe_corr_null_to_false(expression)
4836        if corr_expr is None:
4837            if isinstance(expression, exp.Window):
4838                return super().window_sql(expression)
4839            if isinstance(expression, exp.Filter):
4840                return super().filter_sql(expression)
4841            corr_expr = expression  # make mypy happy
4842
4843        return self.sql(exp.case().when(exp.IsNan(this=corr_expr), exp.null()).else_(corr_expr))
4844
4845    def uuid_sql(self, expression: exp.Uuid) -> str:
4846        namespace = expression.this
4847        name = expression.args.get("name")
4848
4849        # UUID v5 (namespace + name) - Emulate using SHA1
4850        if namespace and name:
4851            result = exp.replace_placeholders(
4852                self.UUID_V5_TEMPLATE.copy(),
4853                namespace=namespace,
4854                name=name,
4855            )
4856            return self.sql(result)
4857
4858        return super().uuid_sql(expression)

Generator converts a given syntax tree to the corresponding SQL string.

Arguments:
  • pretty: Whether to format the produced SQL string. Default: False.
  • identify: Determines when an identifier should be quoted. Possible values are: False (default): Never quote, except in cases where it's mandatory by the dialect. True: Always quote except for specials cases. 'safe': Only quote identifiers that are case insensitive.
  • normalize: Whether to normalize identifiers to lowercase. Default: False.
  • pad: The pad size in a formatted string. For example, this affects the indentation of a projection in a query, relative to its nesting level. Default: 2.
  • indent: The indentation size in a formatted string. For example, this affects the indentation of subqueries and filters under a WHERE clause. Default: 2.
  • normalize_functions: How to normalize function names. Possible values are: "upper" or True (default): Convert names to uppercase. "lower": Convert names to lowercase. False: Disables function name normalization.
  • unsupported_level: Determines the generator's behavior when it encounters unsupported expressions. Default ErrorLevel.WARN.
  • max_unsupported: Maximum number of unsupported messages to include in a raised UnsupportedError. This is only relevant if unsupported_level is ErrorLevel.RAISE. Default: 3
  • leading_comma: Whether the comma is leading or trailing in select expressions. This is only relevant when generating in pretty mode. Default: False
  • max_text_width: The max number of characters in a segment before creating new lines in pretty mode. The default is on the smaller end because the length only represents a segment and not the true line length. Default: 80
  • comments: Whether to preserve comments in the output SQL code. Default: True
PARAMETER_TOKEN = '$'
NAMED_PLACEHOLDER_TOKEN = '$'
JOIN_HINTS = False
TABLE_HINTS = False
QUERY_HINTS = False
LIMIT_FETCH = 'LIMIT'
STRUCT_DELIMITER = ('(', ')')
RENAME_TABLE_WITH_DB = False
NVL2_SUPPORTED = False
SEMI_ANTI_JOIN_WITH_SIDE = False
CAST_TO_INT_ROUNDS = True
SAFE_INT_DIVISION = True
TABLESAMPLE_KEYWORDS = 'USING SAMPLE'
TABLESAMPLE_SEED_KEYWORD = 'REPEATABLE'
LAST_DAY_SUPPORTS_DATE_PART = False
JSON_KEY_VALUE_PAIR_SEP = ','
IGNORE_NULLS_IN_FUNC = True
IGNORE_NULLS_BEFORE_ORDER = False
JSON_PATH_BRACKETED_KEY_SUPPORTED = False
SUPPORTS_CREATE_TABLE_LIKE = False
MULTI_ARG_DISTINCT = False
CAN_IMPLEMENT_ARRAY_ANY = True
SUPPORTS_TO_NUMBER = False
SELECT_KINDS: tuple[str, ...] = ()
SUPPORTS_DECODE_CASE = False
SUPPORTS_DROP_ALTER_ICEBERG_PROPERTY = False
AFTER_HAVING_MODIFIER_TRANSFORMS = {'windows': <function <lambda>>, 'qualify': <function <lambda>>, 'sample': <function DuckDBGenerator.<lambda>>}
SUPPORTS_WINDOW_EXCLUDE = True
COPY_HAS_INTO_KEYWORD = False
STAR_EXCEPT = 'EXCLUDE'
PAD_FILL_PATTERN_IS_REQUIRED = True
ARRAY_SIZE_DIM_REQUIRED: bool | None = False
NORMALIZE_EXTRACT_DATE_PARTS = True
SUPPORTS_LIKE_QUANTIFIERS = False
HISTORICAL_DATA_POST_ALIAS = True
SET_ASSIGNMENT_REQUIRES_VARIABLE_KEYWORD = True
TRANSFORMS = {<class 'sqlglot.expressions.query.JSONPathKey'>: <function <lambda>>, <class 'sqlglot.expressions.query.JSONPathRoot'>: <function <lambda>>, <class 'sqlglot.expressions.query.JSONPathSubscript'>: <function <lambda>>, <class 'sqlglot.expressions.query.JSONPathWildcard'>: <function <lambda>>, <class 'sqlglot.expressions.core.Adjacent'>: <function Generator.<lambda>>, <class 'sqlglot.expressions.properties.AllowedValuesProperty'>: <function Generator.<lambda>>, <class 'sqlglot.expressions.query.AnalyzeColumns'>: <function Generator.<lambda>>, <class 'sqlglot.expressions.query.AnalyzeWith'>: <function Generator.<lambda>>, <class 'sqlglot.expressions.array.ArrayContainedBy'>: <function Generator.<lambda>>, <class 'sqlglot.expressions.array.ArrayContainsAll'>: <function Generator.<lambda>>, <class 'sqlglot.expressions.array.ArrayOverlaps'>: <function _array_overlaps_sql>, <class 'sqlglot.expressions.constraints.AssumeColumnConstraint'>: <function Generator.<lambda>>, <class 'sqlglot.expressions.properties.AutoRefreshProperty'>: <function Generator.<lambda>>, <class 'sqlglot.expressions.properties.BackupProperty'>: <function Generator.<lambda>>, <class 'sqlglot.expressions.constraints.BinaryColumnConstraint'>: <function Generator.<lambda>>, <class 'sqlglot.expressions.constraints.CaseSpecificColumnConstraint'>: <function Generator.<lambda>>, <class 'sqlglot.expressions.properties.CalledOnNullInputProperty'>: <function Generator.<lambda>>, <class 'sqlglot.expressions.math.Ceil'>: <function _ceil_floor>, <class 'sqlglot.expressions.constraints.CharacterSetColumnConstraint'>: <function Generator.<lambda>>, <class 'sqlglot.expressions.properties.CharacterSetProperty'>: <function Generator.<lambda>>, <class 'sqlglot.expressions.constraints.ClusteredColumnConstraint'>: <function Generator.<lambda>>, <class 'sqlglot.expressions.constraints.CollateColumnConstraint'>: <function Generator.<lambda>>, <class 'sqlglot.expressions.constraints.CommentColumnConstraint'>: <function no_comment_column_constraint_sql>, <class 'sqlglot.expressions.functions.ConnectByRoot'>: <function Generator.<lambda>>, <class 'sqlglot.expressions.string.ConvertToCharset'>: <function Generator.<lambda>>, <class 'sqlglot.expressions.properties.CopyGrantsProperty'>: <function Generator.<lambda>>, <class 'sqlglot.expressions.properties.CredentialsProperty'>: <function Generator.<lambda>>, <class 'sqlglot.expressions.functions.CurrentCatalog'>: <function Generator.<lambda>>, <class 'sqlglot.expressions.functions.SessionUser'>: <function Generator.<lambda>>, <class 'sqlglot.expressions.constraints.DateFormatColumnConstraint'>: <function Generator.<lambda>>, <class 'sqlglot.expressions.constraints.DefaultColumnConstraint'>: <function Generator.<lambda>>, <class 'sqlglot.expressions.properties.ApiProperty'>: <function Generator.<lambda>>, <class 'sqlglot.expressions.properties.ApplicationProperty'>: <function Generator.<lambda>>, <class 'sqlglot.expressions.properties.CatalogProperty'>: <function Generator.<lambda>>, <class 'sqlglot.expressions.properties.ComputeProperty'>: <function Generator.<lambda>>, <class 'sqlglot.expressions.properties.DatabaseProperty'>: <function Generator.<lambda>>, <class 'sqlglot.expressions.properties.DynamicProperty'>: <function Generator.<lambda>>, <class 'sqlglot.expressions.properties.EmptyProperty'>: <function Generator.<lambda>>, <class 'sqlglot.expressions.constraints.EncodeColumnConstraint'>: <function Generator.<lambda>>, <class 'sqlglot.expressions.query.EndStatement'>: <function Generator.<lambda>>, <class 'sqlglot.expressions.properties.EnviromentProperty'>: <function Generator.<lambda>>, <class 'sqlglot.expressions.properties.HandlerProperty'>: <function Generator.<lambda>>, <class 'sqlglot.expressions.properties.ParameterStyleProperty'>: <function Generator.<lambda>>, <class 'sqlglot.expressions.constraints.EphemeralColumnConstraint'>: <function Generator.<lambda>>, <class 'sqlglot.expressions.constraints.ExcludeColumnConstraint'>: <function Generator.<lambda>>, <class 'sqlglot.expressions.properties.ExecuteAsProperty'>: <function Generator.<lambda>>, <class 'sqlglot.expressions.query.Except'>: <function Generator.<lambda>>, <class 'sqlglot.expressions.properties.ExternalProperty'>: <function Generator.<lambda>>, <class 'sqlglot.expressions.math.Floor'>: <function _ceil_floor>, <class 'sqlglot.expressions.query.Get'>: <function Generator.<lambda>>, <class 'sqlglot.expressions.properties.GlobalProperty'>: <function Generator.<lambda>>, <class 'sqlglot.expressions.properties.HeapProperty'>: <function Generator.<lambda>>, <class 'sqlglot.expressions.properties.HybridProperty'>: <function Generator.<lambda>>, <class 'sqlglot.expressions.properties.IcebergProperty'>: <function DuckDBGenerator.<lambda>>, <class 'sqlglot.expressions.properties.InheritsProperty'>: <function Generator.<lambda>>, <class 'sqlglot.expressions.constraints.InlineLengthColumnConstraint'>: <function Generator.<lambda>>, <class 'sqlglot.expressions.properties.InputModelProperty'>: <function Generator.<lambda>>, <class 'sqlglot.expressions.query.Intersect'>: <function Generator.<lambda>>, <class 'sqlglot.expressions.datatypes.IntervalSpan'>: <function Generator.<lambda>>, <class 'sqlglot.expressions.functions.Int64'>: <function Generator.<lambda>>, <class 'sqlglot.expressions.json.JSONBContainsAnyTopKeys'>: <function Generator.<lambda>>, <class 'sqlglot.expressions.json.JSONBContainsAllTopKeys'>: <function Generator.<lambda>>, <class 'sqlglot.expressions.json.JSONBContainsTopKey'>: <function Generator.<lambda>>, <class 'sqlglot.expressions.json.JSONBDeleteAtPath'>: <function Generator.<lambda>>, <class 'sqlglot.expressions.json.JSONBPathExists'>: <function Generator.<lambda>>, <class 'sqlglot.expressions.json.JSONObject'>: <function Generator.<lambda>>, <class 'sqlglot.expressions.json.JSONObjectAgg'>: <function rename_func.<locals>.<lambda>>, <class 'sqlglot.expressions.properties.LanguageProperty'>: <function Generator.<lambda>>, <class 'sqlglot.expressions.properties.LocationProperty'>: <function Generator.<lambda>>, <class 'sqlglot.expressions.properties.LogProperty'>: <function Generator.<lambda>>, <class 'sqlglot.expressions.properties.MaskingProperty'>: <function Generator.<lambda>>, <class 'sqlglot.expressions.properties.MaterializedProperty'>: <function Generator.<lambda>>, <class 'sqlglot.expressions.functions.NetFunc'>: <function Generator.<lambda>>, <class 'sqlglot.expressions.properties.NetworkProperty'>: <function Generator.<lambda>>, <class 'sqlglot.expressions.constraints.NonClusteredColumnConstraint'>: <function Generator.<lambda>>, <class 'sqlglot.expressions.properties.NoPrimaryIndexProperty'>: <function Generator.<lambda>>, <class 'sqlglot.expressions.constraints.NotForReplicationColumnConstraint'>: <function Generator.<lambda>>, <class 'sqlglot.expressions.properties.OnCommitProperty'>: <function Generator.<lambda>>, <class 'sqlglot.expressions.properties.OnProperty'>: <function Generator.<lambda>>, <class 'sqlglot.expressions.constraints.OnUpdateColumnConstraint'>: <function Generator.<lambda>>, <class 'sqlglot.expressions.core.Operator'>: <function Generator.<lambda>>, <class 'sqlglot.expressions.properties.OutputModelProperty'>: <function Generator.<lambda>>, <class 'sqlglot.expressions.core.ExtendsLeft'>: <function Generator.<lambda>>, <class 'sqlglot.expressions.core.ExtendsRight'>: <function Generator.<lambda>>, <class 'sqlglot.expressions.constraints.PathColumnConstraint'>: <function Generator.<lambda>>, <class 'sqlglot.expressions.properties.PartitionedByBucket'>: <function Generator.<lambda>>, <class 'sqlglot.expressions.properties.PartitionByTruncate'>: <function Generator.<lambda>>, <class 'sqlglot.expressions.core.PivotAny'>: <function Generator.<lambda>>, <class 'sqlglot.expressions.array.PositionalColumn'>: <function Generator.<lambda>>, <class 'sqlglot.expressions.constraints.ProjectionPolicyColumnConstraint'>: <function Generator.<lambda>>, <class 'sqlglot.expressions.constraints.InvisibleColumnConstraint'>: <function Generator.<lambda>>, <class 'sqlglot.expressions.constraints.ZeroFillColumnConstraint'>: <function Generator.<lambda>>, <class 'sqlglot.expressions.query.Put'>: <function Generator.<lambda>>, <class 'sqlglot.expressions.properties.RemoteWithConnectionModelProperty'>: <function Generator.<lambda>>, <class 'sqlglot.expressions.properties.ReturnsProperty'>: <function DuckDBGenerator.<lambda>>, <class 'sqlglot.expressions.properties.RowAccessProperty'>: <function Generator.<lambda>>, <class 'sqlglot.expressions.core.SafeFunc'>: <function Generator.<lambda>>, <class 'sqlglot.expressions.properties.SampleProperty'>: <function Generator.<lambda>>, <class 'sqlglot.expressions.properties.SecureProperty'>: <function Generator.<lambda>>, <class 'sqlglot.expressions.properties.SecurityIntegrationProperty'>: <function Generator.<lambda>>, <class 'sqlglot.expressions.properties.SetConfigProperty'>: <function Generator.<lambda>>, <class 'sqlglot.expressions.properties.SetProperty'>: <function Generator.<lambda>>, <class 'sqlglot.expressions.properties.SettingsProperty'>: <function Generator.<lambda>>, <class 'sqlglot.expressions.properties.SharingProperty'>: <function Generator.<lambda>>, <class 'sqlglot.expressions.properties.SqlReadWriteProperty'>: <function Generator.<lambda>>, <class 'sqlglot.expressions.properties.SqlSecurityProperty'>: <function Generator.<lambda>>, <class 'sqlglot.expressions.properties.StabilityProperty'>: <function Generator.<lambda>>, <class 'sqlglot.expressions.query.Stream'>: <function Generator.<lambda>>, <class 'sqlglot.expressions.properties.StreamingTableProperty'>: <function Generator.<lambda>>, <class 'sqlglot.expressions.properties.StrictProperty'>: <function Generator.<lambda>>, <class 'sqlglot.expressions.ddl.SwapTable'>: <function Generator.<lambda>>, <class 'sqlglot.expressions.query.TableColumn'>: <function Generator.<lambda>>, <class 'sqlglot.expressions.properties.Tags'>: <function Generator.<lambda>>, <class 'sqlglot.expressions.properties.TemporaryProperty'>: <function Generator.<lambda>>, <class 'sqlglot.expressions.constraints.TitleColumnConstraint'>: <function Generator.<lambda>>, <class 'sqlglot.expressions.array.ToMap'>: <function Generator.<lambda>>, <class 'sqlglot.expressions.properties.ToTableProperty'>: <function Generator.<lambda>>, <class 'sqlglot.expressions.properties.TransformModelProperty'>: <function Generator.<lambda>>, <class 'sqlglot.expressions.properties.TransientProperty'>: <function Generator.<lambda>>, <class 'sqlglot.expressions.properties.VirtualProperty'>: <function Generator.<lambda>>, <class 'sqlglot.expressions.ddl.TriggerExecute'>: <function Generator.<lambda>>, <class 'sqlglot.expressions.query.Union'>: <function Generator.<lambda>>, <class 'sqlglot.expressions.properties.UnloggedProperty'>: <function Generator.<lambda>>, <class 'sqlglot.expressions.properties.UsingTemplateProperty'>: <function Generator.<lambda>>, <class 'sqlglot.expressions.query.UsingData'>: <function Generator.<lambda>>, <class 'sqlglot.expressions.constraints.UppercaseColumnConstraint'>: <function Generator.<lambda>>, <class 'sqlglot.expressions.temporal.UtcDate'>: <function Generator.<lambda>>, <class 'sqlglot.expressions.temporal.UtcTime'>: <function Generator.<lambda>>, <class 'sqlglot.expressions.temporal.UtcTimestamp'>: <function Generator.<lambda>>, <class 'sqlglot.expressions.query.Variadic'>: <function Generator.<lambda>>, <class 'sqlglot.expressions.array.VarMap'>: <function Generator.<lambda>>, <class 'sqlglot.expressions.properties.ViewAttributeProperty'>: <function Generator.<lambda>>, <class 'sqlglot.expressions.properties.VolatileProperty'>: <function Generator.<lambda>>, <class 'sqlglot.expressions.properties.WithJournalTableProperty'>: <function Generator.<lambda>>, <class 'sqlglot.expressions.properties.WithProcedureOptions'>: <function Generator.<lambda>>, <class 'sqlglot.expressions.properties.WithSchemaBindingProperty'>: <function Generator.<lambda>>, <class 'sqlglot.expressions.constraints.WithOperator'>: <function Generator.<lambda>>, <class 'sqlglot.expressions.properties.ForceProperty'>: <function Generator.<lambda>>, <class 'sqlglot.expressions.aggregate.AnyValue'>: <function _anyvalue_sql>, <class 'sqlglot.expressions.core.ApproxDistinct'>: <function approx_count_distinct_sql>, <class 'sqlglot.expressions.math.Boolnot'>: <function _boolnot_sql>, <class 'sqlglot.expressions.math.Booland'>: <function _booland_sql>, <class 'sqlglot.expressions.math.Boolor'>: <function _boolor_sql>, <class 'sqlglot.expressions.array.Array'>: <function preprocess.<locals>._to_sql>, <class 'sqlglot.expressions.array.ArrayAppend'>: <function array_append_sql.<locals>._array_append_sql>, <class 'sqlglot.expressions.array.ArrayCompact'>: <function array_compact_sql>, <class 'sqlglot.expressions.array.ArrayConstructCompact'>: <function DuckDBGenerator.<lambda>>, <class 'sqlglot.expressions.array.ArrayConcat'>: <function array_concat_sql.<locals>._array_concat_sql>, <class 'sqlglot.expressions.array.ArrayContains'>: <function _array_contains_sql>, <class 'sqlglot.expressions.array.ArrayFilter'>: <function rename_func.<locals>.<lambda>>, <class 'sqlglot.expressions.array.ArrayInsert'>: <function _array_insert_sql>, <class 'sqlglot.expressions.array.ArrayPosition'>: <function DuckDBGenerator.<lambda>>, <class 'sqlglot.expressions.array.ArrayRemoveAt'>: <function _array_remove_at_sql>, <class 'sqlglot.expressions.array.ArrayRemove'>: <function remove_from_array_using_filter>, <class 'sqlglot.expressions.array.ArraySort'>: <function _array_sort_sql>, <class 'sqlglot.expressions.array.ArrayPrepend'>: <function array_append_sql.<locals>._array_append_sql>, <class 'sqlglot.expressions.array.ArraySum'>: <function rename_func.<locals>.<lambda>>, <class 'sqlglot.expressions.array.ArrayMax'>: <function rename_func.<locals>.<lambda>>, <class 'sqlglot.expressions.array.ArrayMin'>: <function rename_func.<locals>.<lambda>>, <class 'sqlglot.expressions.string.Base64DecodeBinary'>: <function DuckDBGenerator.<lambda>>, <class 'sqlglot.expressions.string.Base64DecodeString'>: <function DuckDBGenerator.<lambda>>, <class 'sqlglot.expressions.core.BitwiseAnd'>: <function DuckDBGenerator.<lambda>>, <class 'sqlglot.expressions.math.BitwiseAndAgg'>: <function _bitwise_agg_sql>, <class 'sqlglot.expressions.math.BitwiseCount'>: <function rename_func.<locals>.<lambda>>, <class 'sqlglot.expressions.core.BitwiseLeftShift'>: <function _bitshift_sql>, <class 'sqlglot.expressions.core.BitwiseOr'>: <function DuckDBGenerator.<lambda>>, <class 'sqlglot.expressions.math.BitwiseOrAgg'>: <function _bitwise_agg_sql>, <class 'sqlglot.expressions.core.BitwiseRightShift'>: <function _bitshift_sql>, <class 'sqlglot.expressions.math.BitwiseXorAgg'>: <function _bitwise_agg_sql>, <class 'sqlglot.expressions.aggregate.Corr'>: <function DuckDBGenerator.<lambda>>, <class 'sqlglot.expressions.math.CosineDistance'>: <function rename_func.<locals>.<lambda>>, <class 'sqlglot.expressions.temporal.CurrentTime'>: <function DuckDBGenerator.<lambda>>, <class 'sqlglot.expressions.functions.CurrentSchemas'>: <function DuckDBGenerator.<lambda>>, <class 'sqlglot.expressions.temporal.CurrentTimestamp'>: <function DuckDBGenerator.<lambda>>, <class 'sqlglot.expressions.functions.CurrentVersion'>: <function rename_func.<locals>.<lambda>>, <class 'sqlglot.expressions.temporal.Localtime'>: <function DuckDBGenerator.<lambda>>, <class 'sqlglot.expressions.temporal.DayOfMonth'>: <function rename_func.<locals>.<lambda>>, <class 'sqlglot.expressions.temporal.DayOfWeek'>: <function rename_func.<locals>.<lambda>>, <class 'sqlglot.expressions.temporal.DayOfWeekIso'>: <function rename_func.<locals>.<lambda>>, <class 'sqlglot.expressions.temporal.DayOfYear'>: <function rename_func.<locals>.<lambda>>, <class 'sqlglot.expressions.temporal.Dayname'>: <function DuckDBGenerator.<lambda>>, <class 'sqlglot.expressions.temporal.Monthname'>: <function DuckDBGenerator.<lambda>>, <class 'sqlglot.expressions.temporal.Date'>: <function _date_sql>, <class 'sqlglot.expressions.temporal.DateAdd'>: <function _date_delta_to_binary_interval_op.<locals>._duckdb_date_delta_sql>, <class 'sqlglot.expressions.temporal.DateFromParts'>: <function _date_from_parts_sql>, <class 'sqlglot.expressions.temporal.DateSub'>: <function _date_delta_to_binary_interval_op.<locals>._duckdb_date_delta_sql>, <class 'sqlglot.expressions.temporal.DateDiff'>: <function _date_diff_sql>, <class 'sqlglot.expressions.temporal.DateStrToDate'>: <function datestrtodate_sql>, <class 'sqlglot.expressions.temporal.Datetime'>: <function no_datetime_sql>, <class 'sqlglot.expressions.temporal.DatetimeDiff'>: <function _date_diff_sql>, <class 'sqlglot.expressions.temporal.DatetimeSub'>: <function _date_delta_to_binary_interval_op.<locals>._duckdb_date_delta_sql>, <class 'sqlglot.expressions.temporal.DatetimeAdd'>: <function _date_delta_to_binary_interval_op.<locals>._duckdb_date_delta_sql>, <class 'sqlglot.expressions.temporal.DateToDi'>: <function DuckDBGenerator.<lambda>>, <class 'sqlglot.expressions.string.Decode'>: <function DuckDBGenerator.<lambda>>, <class 'sqlglot.expressions.string.HexDecodeString'>: <function DuckDBGenerator.<lambda>>, <class 'sqlglot.expressions.temporal.DiToDate'>: <function DuckDBGenerator.<lambda>>, <class 'sqlglot.expressions.string.Encode'>: <function DuckDBGenerator.<lambda>>, <class 'sqlglot.expressions.functions.EqualNull'>: <function DuckDBGenerator.<lambda>>, <class 'sqlglot.expressions.math.EuclideanDistance'>: <function rename_func.<locals>.<lambda>>, <class 'sqlglot.expressions.temporal.GenerateDateArray'>: <function _generate_datetime_array_sql>, <class 'sqlglot.expressions.array.GenerateSeries'>: <function generate_series_sql.<locals>._generate_series_sql>, <class 'sqlglot.expressions.temporal.GenerateTimestampArray'>: <function _generate_datetime_array_sql>, <class 'sqlglot.expressions.math.Getbit'>: <function getbit_sql>, <class 'sqlglot.expressions.aggregate.GroupConcat'>: <function DuckDBGenerator.<lambda>>, <class 'sqlglot.expressions.array.Explode'>: <function rename_func.<locals>.<lambda>>, <class 'sqlglot.expressions.core.IntDiv'>: <function DuckDBGenerator.<lambda>>, <class 'sqlglot.expressions.math.IsInf'>: <function rename_func.<locals>.<lambda>>, <class 'sqlglot.expressions.math.IsNan'>: <function rename_func.<locals>.<lambda>>, <class 'sqlglot.expressions.functions.IsNullValue'>: <function DuckDBGenerator.<lambda>>, <class 'sqlglot.expressions.functions.IsArray'>: <function DuckDBGenerator.<lambda>>, <class 'sqlglot.expressions.json.JSONBExists'>: <function rename_func.<locals>.<lambda>>, <class 'sqlglot.expressions.json.JSONExtract'>: <function _arrow_json_extract_sql>, <class 'sqlglot.expressions.json.JSONExtractArray'>: <function _json_extract_value_array_sql>, <class 'sqlglot.expressions.json.JSONFormat'>: <function _json_format_sql>, <class 'sqlglot.expressions.query.JSONValueArray'>: <function _json_extract_value_array_sql>, <class 'sqlglot.expressions.query.Lateral'>: <function _explode_to_unnest_sql>, <class 'sqlglot.expressions.aggregate.LogicalOr'>: <function DuckDBGenerator.<lambda>>, <class 'sqlglot.expressions.aggregate.LogicalAnd'>: <function DuckDBGenerator.<lambda>>, <class 'sqlglot.expressions.query.Select'>: <function preprocess.<locals>._to_sql>, <class 'sqlglot.expressions.functions.Seq1'>: <function DuckDBGenerator.<lambda>>, <class 'sqlglot.expressions.functions.Seq2'>: <function DuckDBGenerator.<lambda>>, <class 'sqlglot.expressions.functions.Seq4'>: <function DuckDBGenerator.<lambda>>, <class 'sqlglot.expressions.functions.Seq8'>: <function DuckDBGenerator.<lambda>>, <class 'sqlglot.expressions.math.BoolxorAgg'>: <function _boolxor_agg_sql>, <class 'sqlglot.expressions.temporal.MakeInterval'>: <function DuckDBGenerator.<lambda>>, <class 'sqlglot.expressions.string.Initcap'>: <function _initcap_sql>, <class 'sqlglot.expressions.string.MD5Digest'>: <function DuckDBGenerator.<lambda>>, <class 'sqlglot.expressions.string.SHA'>: <function DuckDBGenerator.<lambda>>, <class 'sqlglot.expressions.string.SHA1Digest'>: <function DuckDBGenerator.<lambda>>, <class 'sqlglot.expressions.string.SHA2'>: <function DuckDBGenerator.<lambda>>, <class 'sqlglot.expressions.string.SHA2Digest'>: <function DuckDBGenerator.<lambda>>, <class 'sqlglot.expressions.temporal.MonthsBetween'>: <function months_between_sql>, <class 'sqlglot.expressions.temporal.NextDay'>: <function _day_navigation_sql>, <class 'sqlglot.expressions.aggregate.PercentileCont'>: <function rename_func.<locals>.<lambda>>, <class 'sqlglot.expressions.aggregate.PercentileDisc'>: <function rename_func.<locals>.<lambda>>, <class 'sqlglot.expressions.query.Pivot'>: <function preprocess.<locals>._to_sql>, <class 'sqlglot.expressions.temporal.PreviousDay'>: <function _day_navigation_sql>, <class 'sqlglot.expressions.string.RegexpILike'>: <function DuckDBGenerator.<lambda>>, <class 'sqlglot.expressions.string.RegexpSplit'>: <function rename_func.<locals>.<lambda>>, <class 'sqlglot.expressions.aggregate.RegrValx'>: <function _regr_val_sql>, <class 'sqlglot.expressions.aggregate.RegrValy'>: <function _regr_val_sql>, <class 'sqlglot.expressions.query.Return'>: <function DuckDBGenerator.<lambda>>, <class 'sqlglot.expressions.temporal.StrToUnix'>: <function DuckDBGenerator.<lambda>>, <class 'sqlglot.expressions.array.Struct'>: <function _struct_sql>, <class 'sqlglot.expressions.array.Transform'>: <function rename_func.<locals>.<lambda>>, <class 'sqlglot.expressions.temporal.TimeAdd'>: <function _date_delta_to_binary_interval_op.<locals>._duckdb_date_delta_sql>, <class 'sqlglot.expressions.temporal.TimeSub'>: <function _date_delta_to_binary_interval_op.<locals>._duckdb_date_delta_sql>, <class 'sqlglot.expressions.temporal.Time'>: <function no_time_sql>, <class 'sqlglot.expressions.temporal.TimeDiff'>: <function _timediff_sql>, <class 'sqlglot.expressions.temporal.Timestamp'>: <function no_timestamp_sql>, <class 'sqlglot.expressions.temporal.TimestampAdd'>: <function _date_delta_to_binary_interval_op.<locals>._duckdb_date_delta_sql>, <class 'sqlglot.expressions.temporal.TimestampDiff'>: <function DuckDBGenerator.<lambda>>, <class 'sqlglot.expressions.temporal.TimestampSub'>: <function _date_delta_to_binary_interval_op.<locals>._duckdb_date_delta_sql>, <class 'sqlglot.expressions.temporal.TimeStrToDate'>: <function DuckDBGenerator.<lambda>>, <class 'sqlglot.expressions.temporal.TimeStrToTime'>: <function timestrtotime_sql>, <class 'sqlglot.expressions.temporal.TimeStrToUnix'>: <function DuckDBGenerator.<lambda>>, <class 'sqlglot.expressions.temporal.TimeToStr'>: <function DuckDBGenerator.<lambda>>, <class 'sqlglot.expressions.functions.ToBoolean'>: <function _to_boolean_sql>, <class 'sqlglot.expressions.functions.ToVariant'>: <function DuckDBGenerator.<lambda>>, <class 'sqlglot.expressions.temporal.TimeToUnix'>: <function rename_func.<locals>.<lambda>>, <class 'sqlglot.expressions.temporal.TsOrDiToDi'>: <function DuckDBGenerator.<lambda>>, <class 'sqlglot.expressions.temporal.TsOrDsAdd'>: <function _date_delta_to_binary_interval_op.<locals>._duckdb_date_delta_sql>, <class 'sqlglot.expressions.temporal.TsOrDsDiff'>: <function DuckDBGenerator.<lambda>>, <class 'sqlglot.expressions.temporal.UnixMicros'>: <function DuckDBGenerator.<lambda>>, <class 'sqlglot.expressions.temporal.UnixMillis'>: <function DuckDBGenerator.<lambda>>, <class 'sqlglot.expressions.temporal.UnixSeconds'>: <function DuckDBGenerator.<lambda>>, <class 'sqlglot.expressions.temporal.UnixToStr'>: <function DuckDBGenerator.<lambda>>, <class 'sqlglot.expressions.temporal.UnixToTime'>: <function _unix_to_time_sql>, <class 'sqlglot.expressions.temporal.UnixToTimeStr'>: <function DuckDBGenerator.<lambda>>, <class 'sqlglot.expressions.aggregate.VariancePop'>: <function rename_func.<locals>.<lambda>>, <class 'sqlglot.expressions.temporal.WeekOfYear'>: <function rename_func.<locals>.<lambda>>, <class 'sqlglot.expressions.temporal.YearOfWeek'>: <function DuckDBGenerator.<lambda>>, <class 'sqlglot.expressions.temporal.YearOfWeekIso'>: <function DuckDBGenerator.<lambda>>, <class 'sqlglot.expressions.core.Xor'>: <function _xor_sql>, <class 'sqlglot.expressions.json.JSONBObjectAgg'>: <function rename_func.<locals>.<lambda>>, <class 'sqlglot.expressions.temporal.DateBin'>: <function rename_func.<locals>.<lambda>>, <class 'sqlglot.expressions.temporal.LastDay'>: <function _last_day_sql>}
TYPE_MAPPING = {<DType.DATETIME2: 'DATETIME2'>: 'TIMESTAMP', <DType.NCHAR: 'NCHAR'>: 'TEXT', <DType.NVARCHAR: 'NVARCHAR'>: 'TEXT', <DType.MEDIUMTEXT: 'MEDIUMTEXT'>: 'TEXT', <DType.LONGTEXT: 'LONGTEXT'>: 'TEXT', <DType.TINYTEXT: 'TINYTEXT'>: 'TEXT', <DType.BLOB: 'BLOB'>: 'VARBINARY', <DType.MEDIUMBLOB: 'MEDIUMBLOB'>: 'BLOB', <DType.LONGBLOB: 'LONGBLOB'>: 'BLOB', <DType.TINYBLOB: 'TINYBLOB'>: 'BLOB', <DType.INET: 'INET'>: 'INET', <DType.ROWVERSION: 'ROWVERSION'>: 'BLOB', <DType.SMALLDATETIME: 'SMALLDATETIME'>: 'TIMESTAMP', <DType.BINARY: 'BINARY'>: 'BLOB', <DType.BPCHAR: 'BPCHAR'>: 'TEXT', <DType.CHAR: 'CHAR'>: 'TEXT', <DType.DATETIME: 'DATETIME'>: 'TIMESTAMP', <DType.DECFLOAT: 'DECFLOAT'>: 'DECIMAL', <DType.FLOAT: 'FLOAT'>: 'REAL', <DType.JSONB: 'JSONB'>: 'JSON', <DType.UINT: 'UINT'>: 'UINTEGER', <DType.VARBINARY: 'VARBINARY'>: 'BLOB', <DType.VARCHAR: 'VARCHAR'>: 'TEXT', <DType.TIMESTAMPLTZ: 'TIMESTAMPLTZ'>: 'TIMESTAMPTZ', <DType.TIMESTAMPNTZ: 'TIMESTAMPNTZ'>: 'TIMESTAMP', <DType.TIMESTAMP_S: 'TIMESTAMP_S'>: 'TIMESTAMP_S', <DType.TIMESTAMP_MS: 'TIMESTAMP_MS'>: 'TIMESTAMP_MS', <DType.TIMESTAMP_NS: 'TIMESTAMP_NS'>: 'TIMESTAMP_NS', <DType.BIGDECIMAL: 'BIGDECIMAL'>: 'DECIMAL'}
TYPE_PARAM_SETTINGS = {<DType.BIGDECIMAL: 'BIGDECIMAL'>: ((38, 5), (38, 38)), <DType.DECFLOAT: 'DECFLOAT'>: ((38, 5), (38, 38))}
RESERVED_KEYWORDS = {'on', 'current_catalog', 'in_p', 'all', 'not', 'else', 'distinct', 'null_p', 'placing', 'only', 'when', 'column', 'asymmetric', 'order', 'current_time', 'check_p', 'current_user', 'select', 'analyze', 'to', 'fetch', 'true_p', 'current_role', 'where', 'as', 'cast', 'deferrable', 'except', 'primary', 'create_p', 'session_user', 'end_p', 'having', 'current_date', 'desc_p', 'default', 'offset', 'limit', 'for', 'constraint', 'collate', 'table', 'from', 'intersect', 'false_p', 'user', 'initially', 'localtimestamp', 'variadic', 'trailing', 'using', 'grant', 'window', 'leading', 'lateral_p', 'current_timestamp', 'into', 'returning', 'some', 'foreign', 'with', 'unique', 'localtime', 'do', 'any', 'both', 'asc_p', 'then', 'array', 'group_p', 'references', 'or', 'case', 'union', 'analyse', 'symmetric', 'and'}
UNWRAPPED_INTERVAL_VALUES = (<class 'sqlglot.expressions.core.Literal'>, <class 'sqlglot.expressions.core.Paren'>)
PROPERTIES_LOCATION = {<class 'sqlglot.expressions.properties.AllowedValuesProperty'>: <PropertiesLocation.UNSUPPORTED: 'UNSUPPORTED'>, <class 'sqlglot.expressions.properties.AlgorithmProperty'>: <PropertiesLocation.UNSUPPORTED: 'UNSUPPORTED'>, <class 'sqlglot.expressions.properties.ApiProperty'>: <PropertiesLocation.UNSUPPORTED: 'UNSUPPORTED'>, <class 'sqlglot.expressions.properties.ApplicationProperty'>: <PropertiesLocation.UNSUPPORTED: 'UNSUPPORTED'>, <class 'sqlglot.expressions.properties.AutoIncrementProperty'>: <PropertiesLocation.UNSUPPORTED: 'UNSUPPORTED'>, <class 'sqlglot.expressions.properties.AutoRefreshProperty'>: <PropertiesLocation.UNSUPPORTED: 'UNSUPPORTED'>, <class 'sqlglot.expressions.properties.BackupProperty'>: <PropertiesLocation.UNSUPPORTED: 'UNSUPPORTED'>, <class 'sqlglot.expressions.properties.BlockCompressionProperty'>: <PropertiesLocation.UNSUPPORTED: 'UNSUPPORTED'>, <class 'sqlglot.expressions.properties.CalledOnNullInputProperty'>: <PropertiesLocation.UNSUPPORTED: 'UNSUPPORTED'>, <class 'sqlglot.expressions.properties.CatalogProperty'>: <PropertiesLocation.UNSUPPORTED: 'UNSUPPORTED'>, <class 'sqlglot.expressions.properties.CharacterSetProperty'>: <PropertiesLocation.UNSUPPORTED: 'UNSUPPORTED'>, <class 'sqlglot.expressions.properties.ChecksumProperty'>: <PropertiesLocation.UNSUPPORTED: 'UNSUPPORTED'>, <class 'sqlglot.expressions.properties.CollateProperty'>: <PropertiesLocation.UNSUPPORTED: 'UNSUPPORTED'>, <class 'sqlglot.expressions.properties.ComputeProperty'>: <PropertiesLocation.UNSUPPORTED: 'UNSUPPORTED'>, <class 'sqlglot.expressions.properties.CopyGrantsProperty'>: <PropertiesLocation.UNSUPPORTED: 'UNSUPPORTED'>, <class 'sqlglot.expressions.query.Cluster'>: <PropertiesLocation.UNSUPPORTED: 'UNSUPPORTED'>, <class 'sqlglot.expressions.properties.ClusteredByProperty'>: <PropertiesLocation.UNSUPPORTED: 'UNSUPPORTED'>, <class 'sqlglot.expressions.properties.ClusterProperty'>: <PropertiesLocation.UNSUPPORTED: 'UNSUPPORTED'>, <class 'sqlglot.expressions.properties.DistributedByProperty'>: <PropertiesLocation.UNSUPPORTED: 'UNSUPPORTED'>, <class 'sqlglot.expressions.properties.DuplicateKeyProperty'>: <PropertiesLocation.UNSUPPORTED: 'UNSUPPORTED'>, <class 'sqlglot.expressions.properties.DataBlocksizeProperty'>: <PropertiesLocation.UNSUPPORTED: 'UNSUPPORTED'>, <class 'sqlglot.expressions.properties.DatabaseProperty'>: <PropertiesLocation.UNSUPPORTED: 'UNSUPPORTED'>, <class 'sqlglot.expressions.properties.DataDeletionProperty'>: <PropertiesLocation.UNSUPPORTED: 'UNSUPPORTED'>, <class 'sqlglot.expressions.properties.DefinerProperty'>: <PropertiesLocation.UNSUPPORTED: 'UNSUPPORTED'>, <class 'sqlglot.expressions.properties.DictRange'>: <PropertiesLocation.UNSUPPORTED: 'UNSUPPORTED'>, <class 'sqlglot.expressions.properties.DictProperty'>: <PropertiesLocation.UNSUPPORTED: 'UNSUPPORTED'>, <class 'sqlglot.expressions.properties.DynamicProperty'>: <PropertiesLocation.UNSUPPORTED: 'UNSUPPORTED'>, <class 'sqlglot.expressions.properties.DistKeyProperty'>: <PropertiesLocation.UNSUPPORTED: 'UNSUPPORTED'>, <class 'sqlglot.expressions.properties.DistStyleProperty'>: <PropertiesLocation.UNSUPPORTED: 'UNSUPPORTED'>, <class 'sqlglot.expressions.properties.EmptyProperty'>: <PropertiesLocation.UNSUPPORTED: 'UNSUPPORTED'>, <class 'sqlglot.expressions.properties.EncodeProperty'>: <PropertiesLocation.UNSUPPORTED: 'UNSUPPORTED'>, <class 'sqlglot.expressions.properties.EngineProperty'>: <PropertiesLocation.UNSUPPORTED: 'UNSUPPORTED'>, <class 'sqlglot.expressions.properties.EnviromentProperty'>: <PropertiesLocation.UNSUPPORTED: 'UNSUPPORTED'>, <class 'sqlglot.expressions.properties.HandlerProperty'>: <PropertiesLocation.UNSUPPORTED: 'UNSUPPORTED'>, <class 'sqlglot.expressions.properties.ParameterStyleProperty'>: <PropertiesLocation.UNSUPPORTED: 'UNSUPPORTED'>, <class 'sqlglot.expressions.properties.ExecuteAsProperty'>: <PropertiesLocation.UNSUPPORTED: 'UNSUPPORTED'>, <class 'sqlglot.expressions.properties.ExternalProperty'>: <PropertiesLocation.UNSUPPORTED: 'UNSUPPORTED'>, <class 'sqlglot.expressions.properties.FallbackProperty'>: <PropertiesLocation.UNSUPPORTED: 'UNSUPPORTED'>, <class 'sqlglot.expressions.properties.FileFormatProperty'>: <PropertiesLocation.UNSUPPORTED: 'UNSUPPORTED'>, <class 'sqlglot.expressions.properties.FreespaceProperty'>: <PropertiesLocation.UNSUPPORTED: 'UNSUPPORTED'>, <class 'sqlglot.expressions.properties.GlobalProperty'>: <PropertiesLocation.UNSUPPORTED: 'UNSUPPORTED'>, <class 'sqlglot.expressions.properties.HeapProperty'>: <PropertiesLocation.UNSUPPORTED: 'UNSUPPORTED'>, <class 'sqlglot.expressions.properties.HybridProperty'>: <PropertiesLocation.UNSUPPORTED: 'UNSUPPORTED'>, <class 'sqlglot.expressions.properties.InheritsProperty'>: <PropertiesLocation.UNSUPPORTED: 'UNSUPPORTED'>, <class 'sqlglot.expressions.properties.IcebergProperty'>: <PropertiesLocation.POST_CREATE: 'POST_CREATE'>, <class 'sqlglot.expressions.properties.IncludeProperty'>: <PropertiesLocation.UNSUPPORTED: 'UNSUPPORTED'>, <class 'sqlglot.expressions.properties.InputModelProperty'>: <PropertiesLocation.UNSUPPORTED: 'UNSUPPORTED'>, <class 'sqlglot.expressions.properties.IsolatedLoadingProperty'>: <PropertiesLocation.UNSUPPORTED: 'UNSUPPORTED'>, <class 'sqlglot.expressions.properties.JournalProperty'>: <PropertiesLocation.UNSUPPORTED: 'UNSUPPORTED'>, <class 'sqlglot.expressions.properties.LanguageProperty'>: <PropertiesLocation.UNSUPPORTED: 'UNSUPPORTED'>, <class 'sqlglot.expressions.properties.LikeProperty'>: <PropertiesLocation.POST_SCHEMA: 'POST_SCHEMA'>, <class 'sqlglot.expressions.properties.LocationProperty'>: <PropertiesLocation.UNSUPPORTED: 'UNSUPPORTED'>, <class 'sqlglot.expressions.properties.LockProperty'>: <PropertiesLocation.UNSUPPORTED: 'UNSUPPORTED'>, <class 'sqlglot.expressions.properties.LockingProperty'>: <PropertiesLocation.UNSUPPORTED: 'UNSUPPORTED'>, <class 'sqlglot.expressions.properties.LogProperty'>: <PropertiesLocation.UNSUPPORTED: 'UNSUPPORTED'>, <class 'sqlglot.expressions.properties.MaskingProperty'>: <PropertiesLocation.UNSUPPORTED: 'UNSUPPORTED'>, <class 'sqlglot.expressions.properties.MaterializedProperty'>: <PropertiesLocation.UNSUPPORTED: 'UNSUPPORTED'>, <class 'sqlglot.expressions.properties.MergeBlockRatioProperty'>: <PropertiesLocation.UNSUPPORTED: 'UNSUPPORTED'>, <class 'sqlglot.expressions.properties.ModuleProperty'>: <PropertiesLocation.UNSUPPORTED: 'UNSUPPORTED'>, <class 'sqlglot.expressions.properties.NetworkProperty'>: <PropertiesLocation.UNSUPPORTED: 'UNSUPPORTED'>, <class 'sqlglot.expressions.properties.NoPrimaryIndexProperty'>: <PropertiesLocation.UNSUPPORTED: 'UNSUPPORTED'>, <class 'sqlglot.expressions.properties.OnProperty'>: <PropertiesLocation.UNSUPPORTED: 'UNSUPPORTED'>, <class 'sqlglot.expressions.properties.OnCommitProperty'>: <PropertiesLocation.UNSUPPORTED: 'UNSUPPORTED'>, <class 'sqlglot.expressions.query.Order'>: <PropertiesLocation.UNSUPPORTED: 'UNSUPPORTED'>, <class 'sqlglot.expressions.properties.OutputModelProperty'>: <PropertiesLocation.UNSUPPORTED: 'UNSUPPORTED'>, <class 'sqlglot.expressions.properties.PartitionedByProperty'>: <PropertiesLocation.UNSUPPORTED: 'UNSUPPORTED'>, <class 'sqlglot.expressions.properties.PartitionedOfProperty'>: <PropertiesLocation.UNSUPPORTED: 'UNSUPPORTED'>, <class 'sqlglot.expressions.constraints.PrimaryKey'>: <PropertiesLocation.UNSUPPORTED: 'UNSUPPORTED'>, <class 'sqlglot.expressions.properties.Property'>: <PropertiesLocation.UNSUPPORTED: 'UNSUPPORTED'>, <class 'sqlglot.expressions.properties.RefreshTriggerProperty'>: <PropertiesLocation.UNSUPPORTED: 'UNSUPPORTED'>, <class 'sqlglot.expressions.properties.RemoteWithConnectionModelProperty'>: <PropertiesLocation.UNSUPPORTED: 'UNSUPPORTED'>, <class 'sqlglot.expressions.properties.ReturnsProperty'>: <PropertiesLocation.POST_ALIAS: 'POST_ALIAS'>, <class 'sqlglot.expressions.properties.RollupProperty'>: <PropertiesLocation.UNSUPPORTED: 'UNSUPPORTED'>, <class 'sqlglot.expressions.properties.RowAccessProperty'>: <PropertiesLocation.UNSUPPORTED: 'UNSUPPORTED'>, <class 'sqlglot.expressions.properties.RowFormatProperty'>: <PropertiesLocation.UNSUPPORTED: 'UNSUPPORTED'>, <class 'sqlglot.expressions.properties.RowFormatDelimitedProperty'>: <PropertiesLocation.UNSUPPORTED: 'UNSUPPORTED'>, <class 'sqlglot.expressions.properties.RowFormatSerdeProperty'>: <PropertiesLocation.UNSUPPORTED: 'UNSUPPORTED'>, <class 'sqlglot.expressions.properties.SampleProperty'>: <PropertiesLocation.UNSUPPORTED: 'UNSUPPORTED'>, <class 'sqlglot.expressions.properties.SchemaCommentProperty'>: <PropertiesLocation.UNSUPPORTED: 'UNSUPPORTED'>, <class 'sqlglot.expressions.properties.SecureProperty'>: <PropertiesLocation.UNSUPPORTED: 'UNSUPPORTED'>, <class 'sqlglot.expressions.properties.SecurityIntegrationProperty'>: <PropertiesLocation.UNSUPPORTED: 'UNSUPPORTED'>, <class 'sqlglot.expressions.properties.SerdeProperties'>: <PropertiesLocation.UNSUPPORTED: 'UNSUPPORTED'>, <class 'sqlglot.expressions.ddl.Set'>: <PropertiesLocation.UNSUPPORTED: 'UNSUPPORTED'>, <class 'sqlglot.expressions.properties.SettingsProperty'>: <PropertiesLocation.UNSUPPORTED: 'UNSUPPORTED'>, <class 'sqlglot.expressions.properties.SetProperty'>: <PropertiesLocation.UNSUPPORTED: 'UNSUPPORTED'>, <class 'sqlglot.expressions.properties.SetConfigProperty'>: <PropertiesLocation.UNSUPPORTED: 'UNSUPPORTED'>, <class 'sqlglot.expressions.properties.SharingProperty'>: <PropertiesLocation.UNSUPPORTED: 'UNSUPPORTED'>, <class 'sqlglot.expressions.ddl.SequenceProperties'>: <PropertiesLocation.POST_EXPRESSION: 'POST_EXPRESSION'>, <class 'sqlglot.expressions.ddl.TriggerProperties'>: <PropertiesLocation.UNSUPPORTED: 'UNSUPPORTED'>, <class 'sqlglot.expressions.properties.SortKeyProperty'>: <PropertiesLocation.UNSUPPORTED: 'UNSUPPORTED'>, <class 'sqlglot.expressions.properties.SqlReadWriteProperty'>: <PropertiesLocation.UNSUPPORTED: 'UNSUPPORTED'>, <class 'sqlglot.expressions.properties.SqlSecurityProperty'>: <PropertiesLocation.UNSUPPORTED: 'UNSUPPORTED'>, <class 'sqlglot.expressions.properties.StabilityProperty'>: <PropertiesLocation.UNSUPPORTED: 'UNSUPPORTED'>, <class 'sqlglot.expressions.properties.StorageHandlerProperty'>: <PropertiesLocation.UNSUPPORTED: 'UNSUPPORTED'>, <class 'sqlglot.expressions.properties.StreamingTableProperty'>: <PropertiesLocation.UNSUPPORTED: 'UNSUPPORTED'>, <class 'sqlglot.expressions.properties.StrictProperty'>: <PropertiesLocation.UNSUPPORTED: 'UNSUPPORTED'>, <class 'sqlglot.expressions.properties.Tags'>: <PropertiesLocation.UNSUPPORTED: 'UNSUPPORTED'>, <class 'sqlglot.expressions.properties.TemporaryProperty'>: <PropertiesLocation.POST_CREATE: 'POST_CREATE'>, <class 'sqlglot.expressions.properties.ToTableProperty'>: <PropertiesLocation.UNSUPPORTED: 'UNSUPPORTED'>, <class 'sqlglot.expressions.properties.TransientProperty'>: <PropertiesLocation.UNSUPPORTED: 'UNSUPPORTED'>, <class 'sqlglot.expressions.properties.TransformModelProperty'>: <PropertiesLocation.UNSUPPORTED: 'UNSUPPORTED'>, <class 'sqlglot.expressions.ddl.MergeTreeTTL'>: <PropertiesLocation.UNSUPPORTED: 'UNSUPPORTED'>, <class 'sqlglot.expressions.properties.UnloggedProperty'>: <PropertiesLocation.UNSUPPORTED: 'UNSUPPORTED'>, <class 'sqlglot.expressions.properties.UsingProperty'>: <PropertiesLocation.UNSUPPORTED: 'UNSUPPORTED'>, <class 'sqlglot.expressions.properties.UsingTemplateProperty'>: <PropertiesLocation.UNSUPPORTED: 'UNSUPPORTED'>, <class 'sqlglot.expressions.properties.ViewAttributeProperty'>: <PropertiesLocation.UNSUPPORTED: 'UNSUPPORTED'>, <class 'sqlglot.expressions.properties.VirtualProperty'>: <PropertiesLocation.UNSUPPORTED: 'UNSUPPORTED'>, <class 'sqlglot.expressions.properties.VolatileProperty'>: <PropertiesLocation.UNSUPPORTED: 'UNSUPPORTED'>, <class 'sqlglot.expressions.properties.WithDataProperty'>: <PropertiesLocation.UNSUPPORTED: 'UNSUPPORTED'>, <class 'sqlglot.expressions.properties.WithJournalTableProperty'>: <PropertiesLocation.UNSUPPORTED: 'UNSUPPORTED'>, <class 'sqlglot.expressions.properties.WithProcedureOptions'>: <PropertiesLocation.UNSUPPORTED: 'UNSUPPORTED'>, <class 'sqlglot.expressions.properties.WithSchemaBindingProperty'>: <PropertiesLocation.UNSUPPORTED: 'UNSUPPORTED'>, <class 'sqlglot.expressions.properties.WithSystemVersioningProperty'>: <PropertiesLocation.UNSUPPORTED: 'UNSUPPORTED'>, <class 'sqlglot.expressions.properties.ForceProperty'>: <PropertiesLocation.UNSUPPORTED: 'UNSUPPORTED'>}
ZIPF_TEMPLATE: ClassVar[sqlglot.expressions.core.Expr] = Select( expressions=[ Min( this=Column( this=Identifier(this=i, quoted=False)))], from_=From( this=Table( this=Identifier(this=cdf, quoted=False))), where=Where( this=GTE( this=Column( this=Identifier(this=p, quoted=False)), expression=Subquery( this=Select( expressions=[ Column( this=Identifier(this=r, quoted=False))], from_=From( this=Table( this=Identifier(this=rand, quoted=False))))))), with_=With( expressions=[ CTE( this=Select( expressions=[ Alias( this=Placeholder(this=random_expr), alias=Identifier(this=r, quoted=False))]), alias=TableAlias( this=Identifier(this=rand, quoted=False))), CTE( this=Select( expressions=[ Column( this=Identifier(this=i, quoted=False)), Alias( this=Div( this=Literal(this=1.0, is_string=False), expression=Pow( this=Column( this=Identifier(this=i, quoted=False)), expression=Placeholder(this=s)), typed=False, safe=False), alias=Identifier(this=w, quoted=False))], from_=From( this=Table( this=Anonymous( this=RANGE, expressions=[ Literal(this=1, is_string=False), Add( this=Placeholder(this=n), expression=Literal(this=1, is_string=False))]), alias=TableAlias( this=Identifier(this=t, quoted=False), columns=[ Identifier(this=i, quoted=False)])))), alias=TableAlias( this=Identifier(this=weights, quoted=False))), CTE( this=Select( expressions=[ Column( this=Identifier(this=i, quoted=False)), Alias( this=Div( this=Window( this=Sum( this=Column( this=Identifier(this=w, quoted=False))), order=Order( expressions=[ Ordered( this=Column( this=Identifier(this=i, quoted=False)), nulls_first=True)]), over=OVER), expression=Window( this=Sum( this=Column( this=Identifier(this=w, quoted=False))), over=OVER), typed=False, safe=False), alias=Identifier(this=p, quoted=False))], from_=From( this=Table( this=Identifier(this=weights, quoted=False)))), alias=TableAlias( this=Identifier(this=cdf, quoted=False)))]))
NORMAL_TEMPLATE: ClassVar[sqlglot.expressions.core.Expr] = Add( this=Placeholder(this=mean), expression=Paren( this=Mul( this=Mul( this=Placeholder(this=stddev), expression=Sqrt( this=Mul( this=Neg( this=Literal(this=2, is_string=False)), expression=Ln( this=Greatest( this=Placeholder(this=u1), expressions=[ Literal(this=1e-10, is_string=False)], ignore_nulls=True))))), expression=Cos( this=Mul( this=Mul( this=Literal(this=2, is_string=False), expression=Pi()), expression=Placeholder(this=u2))))))
SEEDED_RANDOM_TEMPLATE: ClassVar[sqlglot.expressions.core.Expr] = Div( this=Paren( this=Mod( this=Abs( this=Anonymous( this=HASH, expressions=[ Placeholder(this=seed)])), expression=Literal(this=1000000, is_string=False))), expression=Literal(this=1000000.0, is_string=False), typed=False, safe=False)
SEQ_UNSIGNED: ClassVar[sqlglot.expressions.core.Expr] = Mod( this=Placeholder(this=base), expression=Placeholder(this=max_val))
SEQ_SIGNED: ClassVar[sqlglot.expressions.core.Expr] = Paren( this=Case( ifs=[ If( this=GTE( this=Mod( this=Placeholder(this=base), expression=Placeholder(this=max_val)), expression=Placeholder(this=half)), true=Sub( this=Mod( this=Placeholder(this=base), expression=Placeholder(this=max_val)), expression=Placeholder(this=max_val)))], default=Mod( this=Placeholder(this=base), expression=Placeholder(this=max_val))))
MAPCAT_TEMPLATE: ClassVar[sqlglot.expressions.core.Expr] = Case( ifs=[ If( this=Or( this=Is( this=Placeholder(this=map1), expression=Null()), expression=Is( this=Placeholder(this=map2), expression=Null())), true=Null())], default=MapFromEntries( this=Anonymous( this=LIST_FILTER, expressions=[ Anonymous( this=LIST_TRANSFORM, expressions=[ Anonymous( this=LIST_DISTINCT, expressions=[ Anonymous( this=LIST_CONCAT, expressions=[ MapKeys( this=Placeholder(this=map1)), MapKeys( this=Placeholder(this=map2))])]), Lambda( this=Anonymous( this=STRUCT_PACK, expressions=[ PropertyEQ( this=Identifier(this=key, quoted=False), expression=Identifier(this=__k, quoted=False)), PropertyEQ( this=Identifier(this=value, quoted=False), expression=Coalesce( this=Bracket( this=Placeholder(this=map2), expressions=[ Identifier(this=__k, quoted=False)]), expressions=[ Bracket( this=Placeholder(this=map1), expressions=[ Identifier(this=__k, quoted=False)])]))]), expressions=[ Identifier(this=__k, quoted=False)])]), Lambda( this=Not( this=Is( this=Dot( this=Identifier(this=__x, quoted=False), expression=Identifier(this=value, quoted=False)), expression=Null())), expressions=[ Identifier(this=__x, quoted=False)])])))
EXTRACT_STRFTIME_MAPPINGS: ClassVar[dict[str, tuple[str, str]]] = {'WEEKISO': ('%V', 'INTEGER'), 'YEAROFWEEK': ('%G', 'INTEGER'), 'YEAROFWEEKISO': ('%G', 'INTEGER'), 'NANOSECOND': ('%n', 'BIGINT')}
EXTRACT_EPOCH_MAPPINGS: ClassVar[dict[str, str]] = {'EPOCH_SECOND': 'EPOCH', 'EPOCH_MILLISECOND': 'EPOCH_MS', 'EPOCH_MICROSECOND': 'EPOCH_US', 'EPOCH_NANOSECOND': 'EPOCH_NS'}
BITMAP_CONSTRUCT_AGG_TEMPLATE: ClassVar[sqlglot.expressions.core.Expr] = Select( expressions=[ Case( ifs=[ If( this=Or( this=Is( this=Column( this=Identifier(this=l, quoted=False)), expression=Null()), expression=EQ( this=Length( this=Column( this=Identifier(this=l, quoted=False))), expression=Literal(this=0, is_string=False))), true=Null()), If( this=NEQ( this=Length( this=Column( this=Identifier(this=l, quoted=False))), expression=Length( this=Anonymous( this=LIST_FILTER, expressions=[ Column( this=Identifier(this=l, quoted=False)), Lambda( this=Between( this=Identifier(this=__v, quoted=False), low=Literal(this=0, is_string=False), high=Literal(this=32767, is_string=False)), expressions=[ Identifier(this=__v, quoted=False)])]))), true=Null()), If( this=LT( this=Length( this=Column( this=Identifier(this=l, quoted=False))), expression=Literal(this=5, is_string=False)), true=Unhex( this=DPipe( this=DPipe( this=Anonymous( this=PRINTF, expressions=[ Literal(this='%04X', is_string=True), Length( this=Column( this=Identifier(this=l, quoted=False)))]), expression=Column( this=Identifier(this=h, quoted=False)), safe=True), expression=Repeat( this=Literal(this='00', is_string=True), times=Mul( this=Greatest( this=Literal(this=0, is_string=False), expressions=[ Sub( this=Literal(this=4, is_string=False), expression=Length( this=Column( this=Identifier(this=l, quoted=False))))], ignore_nulls=True), expression=Literal(this=2, is_string=False))), safe=True)))], default=Unhex( this=DPipe( this=Literal(this='08000000000000000000', is_string=True), expression=Column( this=Identifier(this=h, quoted=False)), safe=True)))], from_=From( this=Subquery( this=Select( expressions=[ Column( this=Identifier(this=l, quoted=False)), Alias( this=Coalesce( this=Anonymous( this=LIST_REDUCE, expressions=[ Anonymous( this=LIST_TRANSFORM, expressions=[ Column( this=Identifier(this=l, quoted=False)), Lambda( this=Anonymous( this=PRINTF, expressions=[ Literal(this='%02X%02X', is_string=True), BitwiseAnd( this=Cast( this=Identifier(this=__x, quoted=False), to=DataType(this=DType.INT, nested=False), _type=DataType(this=DType.INT, nested=False)), expression=Literal(this=255, is_string=False)), BitwiseAnd( this=Paren( this=BitwiseRightShift( this=Cast( this=Identifier(this=__x, quoted=False), to=DataType(this=DType.INT, nested=False), _type=DataType(this=DType.INT, nested=False)), expression=Literal(this=8, is_string=False))), expression=Literal(this=255, is_string=False))]), expressions=[ Identifier(this=__x, quoted=False)])]), Lambda( this=DPipe( this=Identifier(this=__a, quoted=False), expression=Identifier(this=__b, quoted=False), safe=True), expressions=[ Identifier(this=__a, quoted=False), Identifier(this=__b, quoted=False)]), Literal(this='', is_string=True)]), expressions=[ Literal(this='', is_string=True)]), alias=Identifier(this=h, quoted=False))], from_=From( this=Subquery( this=Select( expressions=[ Alias( this=Anonymous( this=LIST_SORT, expressions=[ Anonymous( this=LIST_DISTINCT, expressions=[ Filter( this=List( expressions=[ Placeholder(this=arg)]), expression=Where( this=Not( this=Is( this=Placeholder(this=arg), expression=Null()))))])]), alias=Identifier(this=l, quoted=False))])))))))
RANDSTR_TEMPLATE: ClassVar[sqlglot.expressions.core.Expr] = Select( expressions=[ Anonymous( this=LISTAGG, expressions=[ Substring( this=Literal(this='0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz', is_string=True), start=Add( this=Literal(this=1, is_string=False), expression=Cast( this=Floor( this=Mul( this=Column( this=Identifier(this=random_value, quoted=False)), expression=Literal(this=62, is_string=False))), to=DataType(this=DType.INT, nested=False), _type=DataType(this=DType.INT, nested=False))), length=Literal(this=1, is_string=False)), Literal(this='', is_string=True)])], from_=From( this=Subquery( this=Select( expressions=[ Alias( this=Div( this=Paren( this=Mod( this=Abs( this=Anonymous( this=HASH, expressions=[ Add( this=Column( this=Identifier(this=i, quoted=False)), expression=Placeholder(this=seed))])), expression=Literal(this=1000, is_string=False))), expression=Literal(this=1000.0, is_string=False), typed=False, safe=False), alias=Identifier(this=random_value, quoted=False))], from_=From( this=Table( this=Anonymous( this=RANGE, expressions=[ Placeholder(this=length)]), alias=TableAlias( this=Identifier(this=t, quoted=False), columns=[ Identifier(this=i, quoted=False)])))))))
MINHASH_TEMPLATE: ClassVar[sqlglot.expressions.core.Expr] = Select( expressions=[ JSONObject( expressions=[ JSONKeyValue( this=Literal(this='state', is_string=True), expression=List( expressions=[ Order( this=Column( this=Identifier(this=min_h, quoted=False)), expressions=[ Ordered( this=Column( this=Identifier(this=seed, quoted=False)), nulls_first=True)])])), JSONKeyValue( this=Literal(this='type', is_string=True), expression=Literal(this='minhash', is_string=True)), JSONKeyValue( this=Literal(this='version', is_string=True), expression=Literal(this=1, is_string=False))], return_type=False, encoding=False)], from_=From( this=Subquery( this=Select( expressions=[ Column( this=Identifier(this=seed, quoted=False)), Alias( this=Anonymous( this=LIST_MIN, expressions=[ Anonymous( this=LIST_TRANSFORM, expressions=[ Column( this=Identifier(this=vals, quoted=False)), Lambda( this=Anonymous( this=HASH, expressions=[ DPipe( this=Cast( this=Identifier(this=__v, quoted=False), to=DataType(this=DType.VARCHAR, nested=False), _type=DataType(this=DType.VARCHAR, nested=False)), expression=Cast( this=Column( this=Identifier(this=seed, quoted=False)), to=DataType(this=DType.VARCHAR, nested=False), _type=DataType(this=DType.VARCHAR, nested=False)), safe=True)]), expressions=[ Identifier(this=__v, quoted=False)])])]), alias=Identifier(this=min_h, quoted=False))], from_=From( this=Subquery( this=Select( expressions=[ Alias( this=List( expressions=[ Placeholder(this=expr)]), alias=Identifier(this=vals, quoted=False))]))), joins=[ Join( this=Table( this=Anonymous( this=RANGE, expressions=[ Literal(this=0, is_string=False), Placeholder(this=k)]), alias=TableAlias( this=Identifier(this=t, quoted=False), columns=[ Identifier(this=seed, quoted=False)])))]))))
MINHASH_COMBINE_TEMPLATE: ClassVar[sqlglot.expressions.core.Expr] = Select( expressions=[ JSONObject( expressions=[ JSONKeyValue( this=Literal(this='state', is_string=True), expression=List( expressions=[ Order( this=Column( this=Identifier(this=min_h, quoted=False)), expressions=[ Ordered( this=Column( this=Identifier(this=idx, quoted=False)), nulls_first=True)])])), JSONKeyValue( this=Literal(this='type', is_string=True), expression=Literal(this='minhash', is_string=True)), JSONKeyValue( this=Literal(this='version', is_string=True), expression=Literal(this=1, is_string=False))], return_type=False, encoding=False)], from_=From( this=Subquery( this=Select( expressions=[ Alias( this=Column( this=Identifier(this=pos, quoted=False)), alias=Identifier(this=idx, quoted=False)), Alias( this=Min( this=Column( this=Identifier(this=val, quoted=False))), alias=Identifier(this=min_h, quoted=False))], from_=From( this=Unnest( expressions=[ List( expressions=[ Placeholder(this=expr)])], alias=TableAlias( this=Identifier(this=_, quoted=False), columns=[ Identifier(this=sig, quoted=False)]), offset=False)), joins=[ Join( this=Unnest( expressions=[ Cast( this=JSONExtract( this=Column( this=Identifier(this=sig, quoted=False)), expression=JSONPath( expressions=[ JSONPathRoot(), JSONPathKey(this=state)]), only_json_types=False), to=DataType( this=DType.ARRAY, expressions=[ DataType(this=DType.USERDEFINED, kind=UBIGINT)], nested=True), _type=DataType( this=DType.ARRAY, expressions=[ DataType(this=DType.USERDEFINED, kind=UBIGINT)], nested=True))], alias=TableAlias( this=Identifier(this=t, quoted=False), columns=[ Identifier(this=val, quoted=False)]), offset=Identifier(this=pos, quoted=False)))], group=Group( expressions=[ Column( this=Identifier(this=pos, quoted=False))])))))
APPROXIMATE_SIMILARITY_TEMPLATE: ClassVar[sqlglot.expressions.core.Expr] = Select( expressions=[ Div( this=Cast( this=Sum( this=Case( ifs=[ If( this=EQ( this=Column( this=Identifier(this=num_distinct, quoted=False)), expression=Literal(this=1, is_string=False)), true=Literal(this=1, is_string=False))], default=Literal(this=0, is_string=False))), to=DataType(this=DType.DOUBLE, nested=False), _type=DataType(this=DType.DOUBLE, nested=False)), expression=Count( this=Star(), big_int=True), typed=False, safe=False)], from_=From( this=Subquery( this=Select( expressions=[ Column( this=Identifier(this=pos, quoted=False)), Alias( this=Count( this=Distinct( expressions=[ Column( this=Identifier(this=h, quoted=False))]), big_int=True), alias=Identifier(this=num_distinct, quoted=False))], from_=From( this=Subquery( this=Select( expressions=[ Column( this=Identifier(this=h, quoted=False)), Column( this=Identifier(this=pos, quoted=False))], from_=From( this=Unnest( expressions=[ List( expressions=[ Placeholder(this=expr)])], alias=TableAlias( this=Identifier(this=_, quoted=False), columns=[ Identifier(this=sig, quoted=False)]), offset=False)), joins=[ Join( this=Unnest( expressions=[ Cast( this=JSONExtract( this=Column( this=Identifier(this=sig, quoted=False)), expression=JSONPath( expressions=[ JSONPathRoot(), JSONPathKey(this=state)]), only_json_types=False), to=DataType( this=DType.ARRAY, expressions=[ DataType(this=DType.USERDEFINED, kind=UBIGINT)], nested=True), _type=DataType( this=DType.ARRAY, expressions=[ DataType(this=DType.USERDEFINED, kind=UBIGINT)], nested=True))], alias=TableAlias( this=Identifier(this=s, quoted=False), columns=[ Identifier(this=h, quoted=False)]), offset=Identifier(this=pos, quoted=False)))]))), group=Group( expressions=[ Column( this=Identifier(this=pos, quoted=False))])))))
ARRAYS_ZIP_TEMPLATE: ClassVar[sqlglot.expressions.core.Expr] = Case( ifs=[ If( this=Placeholder(this=null_check), true=Null()), If( this=Placeholder(this=all_empty_check), true=Array( expressions=[ Placeholder(this=empty_struct)], value_constructor=False))], default=Anonymous( this=LIST_TRANSFORM, expressions=[ Anonymous( this=RANGE, expressions=[ Literal(this=0, is_string=False), Placeholder(this=max_len)]), Lambda( this=Placeholder(this=transform_struct), expressions=[ Identifier(this=__i, quoted=False)])]))
UUID_V5_TEMPLATE: ClassVar[sqlglot.expressions.core.Expr] = Subquery( this=Select( expressions=[ Lower( this=DPipe( this=DPipe( this=DPipe( this=DPipe( this=DPipe( this=DPipe( this=DPipe( this=DPipe( this=DPipe( this=DPipe( this=Substring( this=Column( this=Identifier(this=h, quoted=False)), start=Literal(this=1, is_string=False), length=Literal(this=8, is_string=False)), expression=Literal(this='-', is_string=True), safe=True), expression=Substring( this=Column( this=Identifier(this=h, quoted=False)), start=Literal(this=9, is_string=False), length=Literal(this=4, is_string=False)), safe=True), expression=Literal(this='-', is_string=True), safe=True), expression=Literal(this='5', is_string=True), safe=True), expression=Substring( this=Column( this=Identifier(this=h, quoted=False)), start=Literal(this=14, is_string=False), length=Literal(this=3, is_string=False)), safe=True), expression=Literal(this='-', is_string=True), safe=True), expression=Format( this=Literal(this='{:02x}', is_string=True), expressions=[ BitwiseOr( this=BitwiseAnd( this=Cast( this=DPipe( this=Literal(this='0x', is_string=True), expression=Substring( this=Column( this=Identifier(this=h, quoted=False)), start=Literal(this=17, is_string=False), length=Literal(this=2, is_string=False)), safe=True), to=DataType(this=DType.INT, nested=False), _type=DataType(this=DType.INT, nested=False)), expression=Literal(this=63, is_string=False)), expression=Literal(this=128, is_string=False))]), safe=True), expression=Substring( this=Column( this=Identifier(this=h, quoted=False)), start=Literal(this=19, is_string=False), length=Literal(this=2, is_string=False)), safe=True), expression=Literal(this='-', is_string=True), safe=True), expression=Substring( this=Column( this=Identifier(this=h, quoted=False)), start=Literal(this=21, is_string=False), length=Literal(this=12, is_string=False)), safe=True))], from_=From( this=Subquery( this=Select( expressions=[ Alias( this=Substring( this=SHA( this=DPipe( this=Unhex( this=Replace( this=Placeholder(this=namespace), expression=Literal(this='-', is_string=True), replacement=Literal(this='', is_string=True))), expression=Encode( this=Placeholder(this=name), charset=Literal(this='utf8', is_string=True)), safe=True)), start=Literal(this=1, is_string=False), length=Literal(this=32, is_string=False)), alias=Identifier(this=h, quoted=False))])))))
ARRAY_BAG_TEMPLATE: ClassVar[sqlglot.expressions.core.Expr] = Case( ifs=[ If( this=Or( this=Is( this=Placeholder(this=arr1), expression=Null()), expression=Is( this=Placeholder(this=arr2), expression=Null())), true=Null())], default=Anonymous( this=LIST_TRANSFORM, expressions=[ Anonymous( this=LIST_FILTER, expressions=[ Anonymous( this=LIST_ZIP, expressions=[ Placeholder(this=arr1), GenerateSeries( start=Literal(this=1, is_string=False), end=Length( this=Placeholder(this=arr1)))]), Lambda( this=Placeholder(this=cond), expressions=[ Identifier(this=pair, quoted=False)])]), Lambda( this=Bracket( this=Identifier(this=pair, quoted=False), expressions=[ Literal(this=0, is_string=False)]), expressions=[ Identifier(this=pair, quoted=False)])]))
ARRAY_EXCEPT_CONDITION: ClassVar[sqlglot.expressions.core.Expr] = GT( this=Length( this=Anonymous( this=LIST_FILTER, expressions=[ Bracket( this=Placeholder(this=arr1), expressions=[ Slice( this=Literal(this=1, is_string=False), expression=Bracket( this=Column( this=Identifier(this=pair, quoted=False)), expressions=[ Literal(this=1, is_string=False)]))]), Lambda( this=NullSafeEQ( this=Identifier(this=e, quoted=False), expression=Bracket( this=Column( this=Identifier(this=pair, quoted=False)), expressions=[ Literal(this=0, is_string=False)])), expressions=[ Identifier(this=e, quoted=False)])])), expression=Length( this=Anonymous( this=LIST_FILTER, expressions=[ Placeholder(this=arr2), Lambda( this=NullSafeEQ( this=Identifier(this=e, quoted=False), expression=Bracket( this=Column( this=Identifier(this=pair, quoted=False)), expressions=[ Literal(this=0, is_string=False)])), expressions=[ Identifier(this=e, quoted=False)])])))
ARRAY_INTERSECTION_CONDITION: ClassVar[sqlglot.expressions.core.Expr] = LTE( this=Length( this=Anonymous( this=LIST_FILTER, expressions=[ Bracket( this=Placeholder(this=arr1), expressions=[ Slice( this=Literal(this=1, is_string=False), expression=Bracket( this=Column( this=Identifier(this=pair, quoted=False)), expressions=[ Literal(this=1, is_string=False)]))]), Lambda( this=NullSafeEQ( this=Identifier(this=e, quoted=False), expression=Bracket( this=Column( this=Identifier(this=pair, quoted=False)), expressions=[ Literal(this=0, is_string=False)])), expressions=[ Identifier(this=e, quoted=False)])])), expression=Length( this=Anonymous( this=LIST_FILTER, expressions=[ Placeholder(this=arr2), Lambda( this=NullSafeEQ( this=Identifier(this=e, quoted=False), expression=Bracket( this=Column( this=Identifier(this=pair, quoted=False)), expressions=[ Literal(this=0, is_string=False)])), expressions=[ Identifier(this=e, quoted=False)])])))
ARRAY_EXCEPT_SET_TEMPLATE: ClassVar[sqlglot.expressions.core.Expr] = Case( ifs=[ If( this=Or( this=Is( this=Placeholder(this=arr1), expression=Null()), expression=Is( this=Placeholder(this=arr2), expression=Null())), true=Null())], default=Anonymous( this=LIST_FILTER, expressions=[ Anonymous( this=LIST_DISTINCT, expressions=[ Placeholder(this=arr1)]), Lambda( this=EQ( this=Length( this=Anonymous( this=LIST_FILTER, expressions=[ Placeholder(this=arr2), Lambda( this=NullSafeEQ( this=Identifier(this=x, quoted=False), expression=Identifier(this=e, quoted=False)), expressions=[ Identifier(this=x, quoted=False)])])), expression=Literal(this=0, is_string=False)), expressions=[ Identifier(this=e, quoted=False)])]))
IN_UNNEST_TEMPLATE: ClassVar[sqlglot.expressions.core.Expr] = Case( ifs=[ If( this=Or( this=Is( this=Placeholder(this=arr), expression=Null()), expression=EQ( this=ArraySize( this=Placeholder(this=arr)), expression=Literal(this=0, is_string=False))), true=Boolean(this=False)), If( this=ArrayContains( this=Placeholder(this=arr), expression=Placeholder(this=value)), true=Boolean(this=True)), If( this=Or( this=Is( this=Placeholder(this=value), expression=Null()), expression=NEQ( this=ArraySize( this=Placeholder(this=arr)), expression=Anonymous( this=LIST_COUNT, expressions=[ Placeholder(this=arr)]))), true=Null())], default=Boolean(this=False))
STRTOK_TO_ARRAY_TEMPLATE: ClassVar[sqlglot.expressions.core.Expr] = Case( ifs=[ If( this=Is( this=Placeholder(this=delimiter), expression=Null()), true=Null())], default=Anonymous( this=LIST_FILTER, expressions=[ Anonymous( this=REGEXP_SPLIT_TO_ARRAY, expressions=[ Placeholder(this=string), Case( ifs=[ If( this=EQ( this=Placeholder(this=delimiter), expression=Literal(this='', is_string=True)), true=Literal(this='.^', is_string=True))], default=Concat( expressions=[ Literal(this='[', is_string=True), Placeholder(this=escaped), Literal(this=']', is_string=True)], safe=True, coalesce=False))]), Lambda( this=Not( this=EQ( this=Identifier(this=x, quoted=False), expression=Literal(this='', is_string=True))), expressions=[ Identifier(this=x, quoted=False)])]))
STRTOK_TEMPLATE: ClassVar[sqlglot.expressions.core.Expr] = Case( ifs=[ If( this=And( this=EQ( this=Placeholder(this=delimiter), expression=Literal(this='', is_string=True)), expression=EQ( this=Placeholder(this=string), expression=Literal(this='', is_string=True))), true=Null()), If( this=And( this=EQ( this=Placeholder(this=delimiter), expression=Literal(this='', is_string=True)), expression=EQ( this=Placeholder(this=part_index), expression=Literal(this=1, is_string=False))), true=Placeholder(this=string)), If( this=EQ( this=Placeholder(this=delimiter), expression=Literal(this='', is_string=True)), true=Null()), If( this=LT( this=Placeholder(this=part_index), expression=Literal(this=0, is_string=False)), true=Null()), If( this=Or( this=Or( this=Is( this=Placeholder(this=string), expression=Null()), expression=Is( this=Placeholder(this=delimiter), expression=Null())), expression=Is( this=Placeholder(this=part_index), expression=Null())), true=Null())], default=Placeholder(this=base_func))
def datatype_sql(self, expression: sqlglot.expressions.datatypes.DataType) -> str:
2307    def datatype_sql(self, expression: exp.DataType) -> str:
2308        if expression.is_type(exp.DType.ARRAY):
2309            return f"{self.expressions(expression, flat=True)}[{self.expressions(expression, key='values', flat=True)}]"
2310
2311        # Modifiers are not supported for TIME, [TIME | TIMESTAMP] WITH TIME ZONE
2312        if expression.is_type(
2313            exp.DType.TIME, exp.DType.TIMETZ, exp.DType.TIMESTAMPTZ, exp.DType.TIMESTAMPLTZ
2314        ):
2315            expression.set("expressions", None)
2316
2317        return super().datatype_sql(expression)
def timeslice_sql(self, expression: sqlglot.expressions.temporal.TimeSlice) -> str:
2325    def timeslice_sql(self, expression: exp.TimeSlice) -> str:
2326        """
2327        Transform Snowflake's TIME_SLICE to DuckDB's time_bucket.
2328
2329        Snowflake: TIME_SLICE(date_expr, slice_length, 'UNIT' [, 'START'|'END'])
2330        DuckDB:    time_bucket(INTERVAL 'slice_length' UNIT, date_expr)
2331
2332        For 'END' kind, add the interval to get the end of the slice.
2333        For DATE type with 'END', cast result back to DATE to preserve type.
2334        """
2335        date_expr = expression.this
2336        slice_length = expression.expression
2337        unit = expression.unit
2338        kind = expression.text("kind").upper()
2339
2340        # Create INTERVAL expression: INTERVAL 'N' UNIT
2341        interval_expr = exp.Interval(this=slice_length, unit=unit)
2342
2343        # Create base time_bucket expression
2344        time_bucket_expr = exp.func("time_bucket", interval_expr, date_expr)
2345
2346        # Check if we need the end of the slice (default is start)
2347        if not kind == "END":
2348            # For 'START', return time_bucket directly
2349            return self.sql(time_bucket_expr)
2350
2351        # For 'END', add the interval to get end of slice
2352        add_expr = exp.Add(this=time_bucket_expr, expression=interval_expr.copy())
2353
2354        # If input is DATE type, cast result back to DATE to preserve type
2355        # DuckDB converts DATE to TIMESTAMP when adding intervals
2356        if date_expr.is_type(exp.DType.DATE):
2357            return self.sql(exp.cast(add_expr, exp.DType.DATE))
2358
2359        return self.sql(add_expr)

Transform Snowflake's TIME_SLICE to DuckDB's time_bucket.

Snowflake: TIME_SLICE(date_expr, slice_length, 'UNIT' [, 'START'|'END']) DuckDB: time_bucket(INTERVAL 'slice_length' UNIT, date_expr)

For 'END' kind, add the interval to get the end of the slice. For DATE type with 'END', cast result back to DATE to preserve type.

def bytestring_sql(self, expression: sqlglot.expressions.query.ByteString) -> str:
2361    def bytestring_sql(self, expression: exp.ByteString) -> str:
2362        if not expression.args.get("is_bytes"):
2363            return super().bytestring_sql(expression)
2364
2365        # Each char is a byte. Casting to BLOB decodes \xHH, so everything but printable ASCII
2366        # (including the backslash itself) is written as such
2367        text = "".join(
2368            ch if ch.isascii() and ch.isprintable() and ch != "\\" else f"\\x{ord(ch):02x}"
2369            for ch in expression.this
2370        )
2371        return self.sql(exp.cast(exp.Literal.string(text), exp.DType.BINARY))
def bitmapbucketnumber_sql(self, expression: sqlglot.expressions.math.BitmapBucketNumber) -> str:
2373    def bitmapbucketnumber_sql(self, expression: exp.BitmapBucketNumber) -> str:
2374        """
2375        Transpile BITMAP_BUCKET_NUMBER function from Snowflake to DuckDB equivalent.
2376
2377        Snowflake's BITMAP_BUCKET_NUMBER returns a 1-based bucket identifier where:
2378        - Each bucket covers 32,768 values
2379        - Bucket numbering starts at 1
2380        - Formula: ((value - 1) // 32768) + 1 for positive values
2381
2382        For non-positive values (0 and negative), we use value // 32768 to avoid
2383        producing bucket 0 or positive bucket IDs for negative inputs.
2384        """
2385        value = expression.this
2386
2387        positive_formula = ((value - 1) // 32768) + 1
2388        non_positive_formula = value // 32768
2389
2390        # CASE WHEN value > 0 THEN ((value - 1) // 32768) + 1 ELSE value // 32768 END
2391        case_expr = (
2392            exp.case()
2393            .when(exp.GT(this=value, expression=exp.Literal.number(0)), positive_formula)
2394            .else_(non_positive_formula)
2395        )
2396        return self.sql(case_expr)

Transpile BITMAP_BUCKET_NUMBER function from Snowflake to DuckDB equivalent.

Snowflake's BITMAP_BUCKET_NUMBER returns a 1-based bucket identifier where:

  • Each bucket covers 32,768 values
  • Bucket numbering starts at 1
  • Formula: ((value - 1) // 32768) + 1 for positive values

For non-positive values (0 and negative), we use value // 32768 to avoid producing bucket 0 or positive bucket IDs for negative inputs.

def bitmapbitposition_sql(self, expression: sqlglot.expressions.math.BitmapBitPosition) -> str:
2398    def bitmapbitposition_sql(self, expression: exp.BitmapBitPosition) -> str:
2399        """
2400        Transpile Snowflake's BITMAP_BIT_POSITION to DuckDB CASE expression.
2401
2402        Snowflake's BITMAP_BIT_POSITION behavior:
2403        - For n <= 0: returns ABS(n) % 32768
2404        - For n > 0: returns (n - 1) % 32768 (maximum return value is 32767)
2405        """
2406        this = expression.this
2407
2408        return self.sql(
2409            exp.Mod(
2410                this=exp.Paren(
2411                    this=exp.If(
2412                        this=exp.GT(this=this, expression=exp.Literal.number(0)),
2413                        true=this - exp.Literal.number(1),
2414                        false=exp.Abs(this=this),
2415                    )
2416                ),
2417                expression=MAX_BIT_POSITION,
2418            )
2419        )

Transpile Snowflake's BITMAP_BIT_POSITION to DuckDB CASE expression.

Snowflake's BITMAP_BIT_POSITION behavior:

  • For n <= 0: returns ABS(n) % 32768
  • For n > 0: returns (n - 1) % 32768 (maximum return value is 32767)
def bitmapconstructagg_sql(self, expression: sqlglot.expressions.math.BitmapConstructAgg) -> str:
2421    def bitmapconstructagg_sql(self, expression: exp.BitmapConstructAgg) -> str:
2422        """
2423        Transpile Snowflake's BITMAP_CONSTRUCT_AGG to DuckDB equivalent.
2424        Uses a pre-parsed template with placeholders replaced by expression nodes.
2425
2426        Snowflake bitmap format:
2427        - Small (< 5 unique values): 2-byte count (big-endian) + values (little-endian) + padding to 10 bytes
2428        - Large (>= 5 unique values): 10-byte header (0x08 + 9 zeros) + values (little-endian)
2429        """
2430        arg = expression.this
2431        return (
2432            f"({self.sql(exp.replace_placeholders(self.BITMAP_CONSTRUCT_AGG_TEMPLATE, arg=arg))})"
2433        )

Transpile Snowflake's BITMAP_CONSTRUCT_AGG to DuckDB equivalent. Uses a pre-parsed template with placeholders replaced by expression nodes.

Snowflake bitmap format:

  • Small (< 5 unique values): 2-byte count (big-endian) + values (little-endian) + padding to 10 bytes
  • Large (>= 5 unique values): 10-byte header (0x08 + 9 zeros) + values (little-endian)
def getignorecase_sql(self, expression: sqlglot.expressions.functions.GetIgnoreCase) -> str:
2435    def getignorecase_sql(self, expression: exp.GetIgnoreCase) -> str:
2436        self.unsupported("DuckDB does not support the GET_IGNORE_CASE() function")
2437        return self.function_fallback_sql(expression)
def compress_sql(self, expression: sqlglot.expressions.string.Compress) -> str:
2439    def compress_sql(self, expression: exp.Compress) -> str:
2440        self.unsupported("DuckDB does not support the COMPRESS() function")
2441        return self.function_fallback_sql(expression)
def encrypt_sql(self, expression: sqlglot.expressions.string.Encrypt) -> str:
2443    def encrypt_sql(self, expression: exp.Encrypt) -> str:
2444        self.unsupported("ENCRYPT is not supported in DuckDB")
2445        return self.function_fallback_sql(expression)
def decrypt_sql(self, expression: sqlglot.expressions.string.Decrypt) -> str:
2447    def decrypt_sql(self, expression: exp.Decrypt) -> str:
2448        func_name = "TRY_DECRYPT" if expression.args.get("safe") else "DECRYPT"
2449        self.unsupported(f"{func_name} is not supported in DuckDB")
2450        return self.function_fallback_sql(expression)
def decryptraw_sql(self, expression: sqlglot.expressions.string.DecryptRaw) -> str:
2452    def decryptraw_sql(self, expression: exp.DecryptRaw) -> str:
2453        func_name = "TRY_DECRYPT_RAW" if expression.args.get("safe") else "DECRYPT_RAW"
2454        self.unsupported(f"{func_name} is not supported in DuckDB")
2455        return self.function_fallback_sql(expression)
def encryptraw_sql(self, expression: sqlglot.expressions.string.EncryptRaw) -> str:
2457    def encryptraw_sql(self, expression: exp.EncryptRaw) -> str:
2458        self.unsupported("ENCRYPT_RAW is not supported in DuckDB")
2459        return self.function_fallback_sql(expression)
def parseurl_sql(self, expression: sqlglot.expressions.string.ParseUrl) -> str:
2461    def parseurl_sql(self, expression: exp.ParseUrl) -> str:
2462        self.unsupported("PARSE_URL is not supported in DuckDB")
2463        return self.function_fallback_sql(expression)
def parseip_sql(self, expression: sqlglot.expressions.functions.ParseIp) -> str:
2465    def parseip_sql(self, expression: exp.ParseIp) -> str:
2466        self.unsupported("PARSE_IP is not supported in DuckDB")
2467        return self.function_fallback_sql(expression)
def decompressstring_sql(self, expression: sqlglot.expressions.string.DecompressString) -> str:
2469    def decompressstring_sql(self, expression: exp.DecompressString) -> str:
2470        self.unsupported("DECOMPRESS_STRING is not supported in DuckDB")
2471        return self.function_fallback_sql(expression)
def decompressbinary_sql(self, expression: sqlglot.expressions.string.DecompressBinary) -> str:
2473    def decompressbinary_sql(self, expression: exp.DecompressBinary) -> str:
2474        self.unsupported("DECOMPRESS_BINARY is not supported in DuckDB")
2475        return self.function_fallback_sql(expression)
def jarowinklersimilarity_sql(self, expression: sqlglot.expressions.math.JarowinklerSimilarity) -> str:
2477    def jarowinklersimilarity_sql(self, expression: exp.JarowinklerSimilarity) -> str:
2478        this = expression.this
2479        expr = expression.expression
2480
2481        if expression.args.get("case_insensitive"):
2482            this = exp.Upper(this=this)
2483            expr = exp.Upper(this=expr)
2484
2485        result = exp.func("JARO_WINKLER_SIMILARITY", this, expr)
2486
2487        if expression.args.get("integer_scale"):
2488            result = exp.cast(result * 100, "INTEGER")
2489
2490        return self.sql(result)
def randstr_sql(self, expression: sqlglot.expressions.functions.Randstr) -> str:
2492    def randstr_sql(self, expression: exp.Randstr) -> str:
2493        """
2494        Transpile Snowflake's RANDSTR to DuckDB equivalent using deterministic hash-based random.
2495        Uses a pre-parsed template with placeholders replaced by expression nodes.
2496
2497        RANDSTR(length, generator) generates a random string of specified length.
2498        - With numeric seed: Use HASH(i + seed) for deterministic output (same seed = same result)
2499        - With RANDOM(): Use RANDOM() in the hash for non-deterministic output
2500        - No generator: Use default seed value
2501        """
2502        length = expression.this
2503        generator = expression.args.get("generator")
2504
2505        if generator:
2506            if isinstance(generator, exp.Rand):
2507                # If it's RANDOM(), use its seed if available, otherwise use RANDOM() itself
2508                seed_value = generator.this or generator
2509            else:
2510                # Const/int or other expression - use as seed directly
2511                seed_value = generator
2512        else:
2513            # No generator specified, use default seed (arbitrary but deterministic)
2514            seed_value = exp.Literal.number(RANDSTR_SEED)
2515
2516        replacements = {"seed": seed_value, "length": length}
2517        return f"({self.sql(exp.replace_placeholders(self.RANDSTR_TEMPLATE, **replacements))})"

Transpile Snowflake's RANDSTR to DuckDB equivalent using deterministic hash-based random. Uses a pre-parsed template with placeholders replaced by expression nodes.

RANDSTR(length, generator) generates a random string of specified length.

  • With numeric seed: Use HASH(i + seed) for deterministic output (same seed = same result)
  • With RANDOM(): Use RANDOM() in the hash for non-deterministic output
  • No generator: Use default seed value
@unsupported_args('finish')
def reduce_sql(self, expression: sqlglot.expressions.array.Reduce) -> str:
2519    @unsupported_args("finish")
2520    def reduce_sql(self, expression: exp.Reduce) -> str:
2521        array_arg = expression.this
2522        initial_value = expression.args.get("initial")
2523        merge_lambda = expression.args.get("merge")
2524
2525        if merge_lambda:
2526            merge_lambda.set("colon", True)
2527
2528        return self.func("list_reduce", array_arg, merge_lambda, initial_value)
def zipf_sql(self, expression: sqlglot.expressions.functions.Zipf) -> str:
2530    def zipf_sql(self, expression: exp.Zipf) -> str:
2531        """
2532        Transpile Snowflake's ZIPF to DuckDB using CDF-based inverse sampling.
2533        Uses a pre-parsed template with placeholders replaced by expression nodes.
2534        """
2535        s = expression.this
2536        n = expression.args["elementcount"]
2537        gen = expression.args["gen"]
2538
2539        if not isinstance(gen, exp.Rand):
2540            # (ABS(HASH(seed)) % 1000000) / 1000000.0
2541            random_expr: exp.Expr = exp.Div(
2542                this=exp.Paren(
2543                    this=exp.Mod(
2544                        this=exp.Abs(this=exp.Anonymous(this="HASH", expressions=[gen.copy()])),
2545                        expression=exp.Literal.number(1000000),
2546                    )
2547                ),
2548                expression=exp.Literal.number(1000000.0),
2549            )
2550        else:
2551            # Use RANDOM() for non-deterministic output
2552            random_expr = exp.Rand()
2553
2554        replacements = {"s": s, "n": n, "random_expr": random_expr}
2555        return f"({self.sql(exp.replace_placeholders(self.ZIPF_TEMPLATE, **replacements))})"

Transpile Snowflake's ZIPF to DuckDB using CDF-based inverse sampling. Uses a pre-parsed template with placeholders replaced by expression nodes.

def tobinary_sql(self, expression: sqlglot.expressions.string.ToBinary) -> str:
2557    def tobinary_sql(self, expression: exp.ToBinary) -> str:
2558        """
2559        TO_BINARY and TRY_TO_BINARY transpilation:
2560        - 'HEX': TO_BINARY('48454C50', 'HEX') -> UNHEX('48454C50')
2561        - 'UTF-8': TO_BINARY('TEST', 'UTF-8') -> ENCODE('TEST')
2562        - 'BASE64': TO_BINARY('SEVMUA==', 'BASE64') -> FROM_BASE64('SEVMUA==')
2563
2564        For TRY_TO_BINARY (safe=True), wrap with TRY():
2565        - 'HEX': TRY_TO_BINARY('invalid', 'HEX') -> TRY(UNHEX('invalid'))
2566        """
2567        value = expression.this
2568        format_arg = expression.args.get("format")
2569        is_safe = expression.args.get("safe")
2570        is_binary = _is_binary(expression)
2571
2572        if not format_arg and not is_binary:
2573            func_name = "TRY_TO_BINARY" if is_safe else "TO_BINARY"
2574            return self.func(func_name, value)
2575
2576        # Snowflake defaults to HEX encoding when no format is specified
2577        fmt = format_arg.name.upper() if format_arg else "HEX"
2578
2579        if fmt in ("UTF-8", "UTF8"):
2580            # DuckDB ENCODE always uses UTF-8, no charset parameter needed
2581            result = self.func("ENCODE", value)
2582        elif fmt == "BASE64":
2583            result = self.func("FROM_BASE64", value)
2584        elif fmt == "HEX":
2585            result = self.func("UNHEX", value)
2586        else:
2587            if is_safe:
2588                return self.sql(exp.null())
2589            else:
2590                self.unsupported(f"format {fmt} is not supported")
2591                result = self.func("TO_BINARY", value)
2592        return f"TRY({result})" if is_safe else result

TO_BINARY and TRY_TO_BINARY transpilation:

  • 'HEX': TO_BINARY('48454C50', 'HEX') -> UNHEX('48454C50')
  • 'UTF-8': TO_BINARY('TEST', 'UTF-8') -> ENCODE('TEST')
  • 'BASE64': TO_BINARY('SEVMUA==', 'BASE64') -> FROM_BASE64('SEVMUA==')

For TRY_TO_BINARY (safe=True), wrap with TRY():

  • 'HEX': TRY_TO_BINARY('invalid', 'HEX') -> TRY(UNHEX('invalid'))
def tonumber_sql(self, expression: sqlglot.expressions.string.ToNumber) -> str:
2594    def tonumber_sql(self, expression: exp.ToNumber) -> str:
2595        fmt = expression.args.get("format")
2596        precision = expression.args.get("precision")
2597        scale = expression.args.get("scale")
2598
2599        if not fmt and precision and scale:
2600            to = exp.DataType.build(f"DECIMAL({precision.name}, {scale.name})", dialect="duckdb")
2601            if expression.args.get("safe"):
2602                return self.sql(exp.TryCast(this=expression.this, to=to))
2603            return self.sql(exp.cast(expression.this, to, dialect="duckdb"))
2604
2605        return super().tonumber_sql(expression)
def generator_sql(self, expression: sqlglot.expressions.array.Generator) -> str:
2631    def generator_sql(self, expression: exp.Generator) -> str:
2632        # Transpile Snowflake GENERATOR to DuckDB range()
2633        rowcount = expression.args.get("rowcount")
2634        time_limit = expression.args.get("time_limit")
2635
2636        if time_limit:
2637            self.unsupported("GENERATOR TIMELIMIT parameter is not supported in DuckDB")
2638
2639        if not rowcount:
2640            self.unsupported("GENERATOR without ROWCOUNT is not supported in DuckDB")
2641            return self.func("range", exp.Literal.number(0))
2642
2643        return self.func("range", rowcount)
def greatest_sql(self, expression: sqlglot.expressions.functions.Greatest) -> str:
2645    def greatest_sql(self, expression: exp.Greatest) -> str:
2646        return self._greatest_least_sql(expression)
def least_sql(self, expression: sqlglot.expressions.functions.Least) -> str:
2648    def least_sql(self, expression: exp.Least) -> str:
2649        return self._greatest_least_sql(expression)
def lambda_sql( self, expression: sqlglot.expressions.query.Lambda, arrow_sep: str = '->', wrap: bool = True) -> str:
2651    def lambda_sql(self, expression: exp.Lambda, arrow_sep: str = "->", wrap: bool = True) -> str:
2652        if expression.args.get("colon"):
2653            prefix = "LAMBDA "
2654            arrow_sep = ":"
2655            wrap = False
2656        else:
2657            prefix = ""
2658
2659        lambda_sql = super().lambda_sql(expression, arrow_sep=arrow_sep, wrap=wrap)
2660        return f"{prefix}{lambda_sql}"
def show_sql(self, expression: sqlglot.expressions.ddl.Show) -> str:
2662    def show_sql(self, expression: exp.Show) -> str:
2663        from_ = self.sql(expression, "from_")
2664        from_ = f" FROM {from_}" if from_ else ""
2665        return f"SHOW {expression.name}{from_}"
def soundex_sql(self, expression: sqlglot.expressions.string.Soundex) -> str:
2667    def soundex_sql(self, expression: exp.Soundex) -> str:
2668        self.unsupported("SOUNDEX is not supported in DuckDB")
2669        return self.func("SOUNDEX", expression.this)
def sortarray_sql(self, expression: sqlglot.expressions.array.SortArray) -> str:
2671    def sortarray_sql(self, expression: exp.SortArray) -> str:
2672        arr = expression.this
2673        asc = expression.args.get("asc")
2674        nulls_first = expression.args.get("nulls_first")
2675
2676        if not isinstance(asc, exp.Boolean) and not isinstance(nulls_first, exp.Boolean):
2677            return self.func("LIST_SORT", arr, asc, nulls_first)
2678
2679        nulls_are_first = nulls_first == exp.true()
2680        nulls_first_sql = exp.Literal.string("NULLS FIRST") if nulls_are_first else None
2681
2682        if not isinstance(asc, exp.Boolean):
2683            return self.func("LIST_SORT", arr, asc, nulls_first_sql)
2684
2685        descending = asc == exp.false()
2686
2687        if not descending and not nulls_are_first:
2688            return self.func("LIST_SORT", arr)
2689        if not nulls_are_first:
2690            return self.func("ARRAY_REVERSE_SORT", arr)
2691        return self.func(
2692            "LIST_SORT",
2693            arr,
2694            exp.Literal.string("DESC" if descending else "ASC"),
2695            exp.Literal.string("NULLS FIRST"),
2696        )
def install_sql(self, expression: sqlglot.expressions.ddl.Install) -> str:
2698    def install_sql(self, expression: exp.Install) -> str:
2699        force = "FORCE " if expression.args.get("force") else ""
2700        this = self.sql(expression, "this")
2701        from_clause = expression.args.get("from_")
2702        from_clause = f" FROM {from_clause}" if from_clause else ""
2703        return f"{force}INSTALL {this}{from_clause}"
def approxtopk_sql(self, expression: sqlglot.expressions.aggregate.ApproxTopK) -> str:
2705    def approxtopk_sql(self, expression: exp.ApproxTopK) -> str:
2706        self.unsupported(
2707            "APPROX_TOP_K cannot be transpiled to DuckDB due to incompatible return types. "
2708        )
2709        return self.function_fallback_sql(expression)
def strposition_sql(self, expression: sqlglot.expressions.string.StrPosition) -> str:
2711    def strposition_sql(self, expression: exp.StrPosition) -> str:
2712        this = expression.this
2713        substr = expression.args.get("substr")
2714        position = expression.args.get("position")
2715
2716        # For BINARY/BLOB: DuckDB's STRPOS doesn't support BLOB types
2717        # Convert to HEX strings, use STRPOS, then convert hex position to byte position
2718        if _is_binary(this):
2719            # Build expression: STRPOS(HEX(haystack), HEX(needle))
2720            hex_strpos = exp.StrPosition(
2721                this=exp.Hex(this=this),
2722                substr=exp.Hex(this=substr),
2723            )
2724
2725            return self.sql(exp.cast((hex_strpos + 1) / 2, exp.DType.INT))
2726
2727        # For VARCHAR: handle clamp_position
2728        if expression.args.get("clamp_position") and position:
2729            expression = expression.copy()
2730            expression.set(
2731                "position",
2732                exp.If(
2733                    this=exp.LTE(this=position, expression=exp.Literal.number(0)),
2734                    true=exp.Literal.number(1),
2735                    false=position.copy(),
2736                ),
2737            )
2738
2739        return strposition_sql(self, expression)
def substring_sql(self, expression: sqlglot.expressions.string.Substring) -> str:
2741    def substring_sql(self, expression: exp.Substring) -> str:
2742        if expression.args.get("zero_start"):
2743            start = expression.args.get("start")
2744            length = expression.args.get("length")
2745
2746            if start := expression.args.get("start"):
2747                start = exp.If(this=start.eq(0), true=exp.Literal.number(1), false=start)
2748            if length := expression.args.get("length"):
2749                length = exp.If(this=length < 0, true=exp.Literal.number(0), false=length)
2750
2751            return self.func("SUBSTRING", expression.this, start, length)
2752
2753        return self.function_fallback_sql(expression)
def strtotime_sql(self, expression: sqlglot.expressions.temporal.StrToTime) -> str:
2755    def strtotime_sql(self, expression: exp.StrToTime) -> str:
2756        # Check if target_type requires TIMESTAMPTZ (for LTZ/TZ variants)
2757        target_type = expression.args.get("target_type")
2758        needs_tz = target_type and target_type.this in (
2759            exp.DType.TIMESTAMPLTZ,
2760            exp.DType.TIMESTAMPTZ,
2761        )
2762
2763        value, formatted_time = self._strptime_default_year(expression)
2764
2765        if expression.args.get("safe"):
2766            cast_type = exp.DType.TIMESTAMPTZ if needs_tz else exp.DType.TIMESTAMP
2767            return self.sql(exp.cast(self.func("TRY_STRPTIME", value, formatted_time), cast_type))
2768
2769        base_sql = self.func("STRPTIME", value, formatted_time)
2770        if needs_tz:
2771            return self.sql(
2772                exp.cast(
2773                    base_sql,
2774                    exp.DataType(this=exp.DType.TIMESTAMPTZ),
2775                )
2776            )
2777        return base_sql
def strtodate_sql(self, expression: sqlglot.expressions.temporal.StrToDate) -> str:
2779    def strtodate_sql(self, expression: exp.StrToDate) -> str:
2780        value, formatted_time = self._strptime_default_year(expression)
2781        function_name = "STRPTIME" if not expression.args.get("safe") else "TRY_STRPTIME"
2782        return self.sql(
2783            exp.cast(
2784                self.func(function_name, value, formatted_time),
2785                exp.DataType(this=exp.DType.DATE),
2786            )
2787        )
def parsedatetime_sql(self, expression: sqlglot.expressions.temporal.ParseDatetime) -> str:
2801    def parsedatetime_sql(self, expression: exp.ParseDatetime) -> str:
2802        value, formatted_time = self._strptime_default_year(expression)
2803        return self.func("STRPTIME", value, formatted_time)
def parsetime_sql(self, expression: sqlglot.expressions.temporal.ParseTime) -> str:
2805    def parsetime_sql(self, expression: exp.ParseTime) -> str:
2806        formatted_time = self.format_time(expression)
2807        return self.sql(
2808            exp.cast(
2809                self.func("STRPTIME", expression.this, formatted_time),
2810                exp.DataType(this=exp.DType.TIME),
2811            )
2812        )
def tsordstotime_sql(self, expression: sqlglot.expressions.temporal.TsOrDsToTime) -> str:
2814    def tsordstotime_sql(self, expression: exp.TsOrDsToTime) -> str:
2815        this = expression.this
2816        time_format = self.format_time(expression)
2817        safe = expression.args.get("safe")
2818        time_type = exp.DataType.from_str("TIME", dialect="duckdb")
2819        cast_expr = exp.TryCast if safe else exp.Cast
2820
2821        if time_format:
2822            func_name = "TRY_STRPTIME" if safe else "STRPTIME"
2823            strptime = exp.Anonymous(this=func_name, expressions=[this, time_format])
2824            return self.sql(cast_expr(this=strptime, to=time_type))
2825
2826        if isinstance(this, exp.TsOrDsToTime) or this.is_type(exp.DType.TIME):
2827            return self.sql(this)
2828
2829        return self.sql(cast_expr(this=this, to=time_type))
def currentdate_sql(self, expression: sqlglot.expressions.temporal.CurrentDate) -> str:
2831    def currentdate_sql(self, expression: exp.CurrentDate) -> str:
2832        if not expression.this:
2833            return "CURRENT_DATE"
2834
2835        expr = exp.Cast(
2836            this=exp.AtTimeZone(this=exp.CurrentTimestamp(), zone=expression.this),
2837            to=exp.DataType(this=exp.DType.DATE),
2838        )
2839        return self.sql(expr)
def checkjson_sql(self, expression: sqlglot.expressions.json.CheckJson) -> str:
2841    def checkjson_sql(self, expression: exp.CheckJson) -> str:
2842        arg = expression.this
2843        return self.sql(
2844            exp.case()
2845            .when(
2846                exp.or_(arg.is_(exp.Null()), arg.eq(""), exp.func("json_valid", arg)),
2847                exp.null(),
2848            )
2849            .else_(exp.Literal.string("Invalid JSON"))
2850        )
def parsejson_sql(self, expression: sqlglot.expressions.json.ParseJSON) -> str:
2852    def parsejson_sql(self, expression: exp.ParseJSON) -> str:
2853        arg = expression.this
2854        if expression.args.get("safe"):
2855            return self.sql(
2856                exp.case()
2857                .when(exp.func("json_valid", arg), exp.cast(arg.copy(), "JSON"))
2858                .else_(exp.null())
2859            )
2860        return self.func("JSON", arg)
def unicode_sql(self, expression: sqlglot.expressions.string.Unicode) -> str:
2862    def unicode_sql(self, expression: exp.Unicode) -> str:
2863        if expression.args.get("empty_is_zero"):
2864            return self.sql(
2865                exp.case()
2866                .when(expression.this.eq(exp.Literal.string("")), exp.Literal.number(0))
2867                .else_(exp.Anonymous(this="UNICODE", expressions=[expression.this]))
2868            )
2869
2870        return self.func("UNICODE", expression.this)
def stripnullvalue_sql(self, expression: sqlglot.expressions.json.StripNullValue) -> str:
2872    def stripnullvalue_sql(self, expression: exp.StripNullValue) -> str:
2873        return self.sql(
2874            exp.case()
2875            .when(exp.func("json_type", expression.this).eq("NULL"), exp.null())
2876            .else_(expression.this)
2877        )
def trunc_sql(self, expression: sqlglot.expressions.math.Trunc) -> str:
2879    def trunc_sql(self, expression: exp.Trunc) -> str:
2880        decimals = expression.args.get("decimals")
2881        if (
2882            expression.args.get("fractions_supported")
2883            and decimals
2884            and not decimals.is_type(exp.DType.INT)
2885        ):
2886            decimals = exp.cast(decimals, exp.DType.INT, dialect="duckdb")
2887
2888        return self.func("TRUNC", expression.this, decimals)
def normal_sql(self, expression: sqlglot.expressions.functions.Normal) -> str:
2890    def normal_sql(self, expression: exp.Normal) -> str:
2891        """
2892        Transpile Snowflake's NORMAL(mean, stddev, gen) to DuckDB.
2893
2894        Uses the Box-Muller transform via NORMAL_TEMPLATE.
2895        """
2896        mean = expression.this
2897        stddev = expression.args["stddev"]
2898        gen: exp.Expr = expression.args["gen"]
2899
2900        # Build two uniform random values [0, 1) for Box-Muller transform
2901        if isinstance(gen, exp.Rand) and gen.this is None:
2902            u1: exp.Expr = exp.Rand()
2903            u2: exp.Expr = exp.Rand()
2904        else:
2905            # Seeded: derive two values using HASH with different inputs
2906            seed = gen.this if isinstance(gen, exp.Rand) else gen
2907            u1 = exp.replace_placeholders(self.SEEDED_RANDOM_TEMPLATE, seed=seed)
2908            u2 = exp.replace_placeholders(
2909                self.SEEDED_RANDOM_TEMPLATE,
2910                seed=exp.Add(this=seed.copy(), expression=exp.Literal.number(1)),
2911            )
2912
2913        replacements = {"mean": mean, "stddev": stddev, "u1": u1, "u2": u2}
2914        return self.sql(exp.replace_placeholders(self.NORMAL_TEMPLATE, **replacements))

Transpile Snowflake's NORMAL(mean, stddev, gen) to DuckDB.

Uses the Box-Muller transform via NORMAL_TEMPLATE.

def uniform_sql(self, expression: sqlglot.expressions.functions.Uniform) -> str:
2916    def uniform_sql(self, expression: exp.Uniform) -> str:
2917        """
2918        Transpile Snowflake's UNIFORM(min, max, gen) to DuckDB.
2919
2920        UNIFORM returns a random value in [min, max]:
2921        - Integer result if both min and max are integers
2922        - Float result if either min or max is a float
2923        """
2924        min_val = expression.this
2925        max_val = expression.expression
2926        gen = expression.args.get("gen") or expression.args.get("seed") or exp.Rand()
2927
2928        # Determine if result should be integer (both bounds are integers).
2929        # We do this to emulate Snowflake's behavior, INT -> INT, FLOAT -> FLOAT
2930        is_int_result = min_val.is_int and max_val.is_int
2931
2932        # Build the random value expression [0, 1)
2933        if not isinstance(gen, exp.Rand):
2934            # Seed value: (ABS(HASH(seed)) % 1000000) / 1000000.0
2935            random_expr: exp.Expr = exp.Div(
2936                this=exp.Paren(
2937                    this=exp.Mod(
2938                        this=exp.Abs(this=exp.Anonymous(this="HASH", expressions=[gen])),
2939                        expression=exp.Literal.number(1000000),
2940                    )
2941                ),
2942                expression=exp.Literal.number(1000000.0),
2943            )
2944        else:
2945            random_expr = exp.Rand()
2946
2947        # Build: min + random * (max - min [+ 1 for int])
2948        range_expr: exp.Expr = exp.Sub(this=max_val, expression=min_val)
2949        if is_int_result:
2950            range_expr = exp.Add(this=range_expr, expression=exp.Literal.number(1))
2951
2952        result: exp.Expr = exp.Add(
2953            this=min_val,
2954            expression=exp.Mul(this=random_expr, expression=exp.Paren(this=range_expr)),
2955        )
2956
2957        if is_int_result:
2958            result = exp.Cast(this=exp.Floor(this=result), to=exp.DType.BIGINT.into_expr())
2959
2960        return self.sql(result)

Transpile Snowflake's UNIFORM(min, max, gen) to DuckDB.

UNIFORM returns a random value in [min, max]:

  • Integer result if both min and max are integers
  • Float result if either min or max is a float
def timefromparts_sql(self, expression: sqlglot.expressions.temporal.TimeFromParts) -> str:
2962    def timefromparts_sql(self, expression: exp.TimeFromParts) -> str:
2963        nano = expression.args.get("nano")
2964        overflow = expression.args.get("overflow")
2965
2966        # Snowflake's TIME_FROM_PARTS supports overflow
2967        if overflow:
2968            hour = expression.args["hour"]
2969            minute = expression.args["min"]
2970            sec = expression.args["sec"]
2971
2972            # Check if values are within normal ranges - use MAKE_TIME for efficiency
2973            if not nano and all(arg.is_int for arg in [hour, minute, sec]):
2974                try:
2975                    h_val = hour.to_py()
2976                    m_val = minute.to_py()
2977                    s_val = sec.to_py()
2978                    if 0 <= h_val <= 23 and 0 <= m_val <= 59 and 0 <= s_val <= 59:
2979                        return rename_func("MAKE_TIME")(self, expression)
2980                except ValueError:
2981                    pass
2982
2983            # Overflow or nanoseconds detected - use INTERVAL arithmetic
2984            if nano:
2985                sec = sec + nano.pop() / exp.Literal.number(1000000000.0)
2986
2987            total_seconds = hour * exp.Literal.number(3600) + minute * exp.Literal.number(60) + sec
2988
2989            return self.sql(
2990                exp.Add(
2991                    this=exp.Cast(
2992                        this=exp.Literal.string("00:00:00"), to=exp.DType.TIME.into_expr()
2993                    ),
2994                    expression=exp.Interval(this=total_seconds, unit=exp.var("SECOND")),
2995                )
2996            )
2997
2998        # Default: MAKE_TIME
2999        if nano:
3000            expression.set(
3001                "sec", expression.args["sec"] + nano.pop() / exp.Literal.number(1000000000.0)
3002            )
3003
3004        return rename_func("MAKE_TIME")(self, expression)
def extract_sql(self, expression: sqlglot.expressions.temporal.Extract) -> str:
3006    def extract_sql(self, expression: exp.Extract) -> str:
3007        """
3008        Transpile EXTRACT/DATE_PART for DuckDB, handling specifiers not natively supported.
3009
3010        DuckDB doesn't support: WEEKISO, YEAROFWEEK, YEAROFWEEKISO, NANOSECOND,
3011        EPOCH_SECOND (as integer), EPOCH_MILLISECOND, EPOCH_MICROSECOND, EPOCH_NANOSECOND
3012        """
3013        this = expression.this
3014        datetime_expr = expression.expression
3015
3016        # TIMESTAMPTZ extractions may produce different results between Snowflake and DuckDB
3017        # because Snowflake applies server timezone while DuckDB uses local timezone
3018        if datetime_expr.is_type(exp.DType.TIMESTAMPTZ, exp.DType.TIMESTAMPLTZ):
3019            self.unsupported(
3020                "EXTRACT from TIMESTAMPTZ / TIMESTAMPLTZ may produce different results due to timezone handling differences"
3021            )
3022
3023        part_name = this.name.upper()
3024
3025        if part_name in self.EXTRACT_STRFTIME_MAPPINGS:
3026            fmt, cast_type = self.EXTRACT_STRFTIME_MAPPINGS[part_name]
3027
3028            # Problem: strftime doesn't accept TIME and there's no NANOSECOND function
3029            # So, for NANOSECOND with TIME, fallback to MICROSECOND * 1000
3030            is_nano_time = part_name == "NANOSECOND" and datetime_expr.is_type(
3031                exp.DType.TIME, exp.DType.TIMETZ
3032            )
3033
3034            if is_nano_time:
3035                self.unsupported("Parameter NANOSECOND is not supported with TIME type in DuckDB")
3036                return self.sql(
3037                    exp.cast(
3038                        exp.Mul(
3039                            this=exp.Extract(this=exp.var("MICROSECOND"), expression=datetime_expr),
3040                            expression=exp.Literal.number(1000),
3041                        ),
3042                        exp.DataType.from_str(cast_type, dialect="duckdb"),
3043                    )
3044                )
3045
3046            # For NANOSECOND, cast to TIMESTAMP_NS to preserve nanosecond precision
3047            strftime_input = datetime_expr
3048            if part_name == "NANOSECOND":
3049                strftime_input = exp.cast(datetime_expr, exp.DType.TIMESTAMP_NS)
3050
3051            return self.sql(
3052                exp.cast(
3053                    exp.Anonymous(
3054                        this="STRFTIME",
3055                        expressions=[strftime_input, exp.Literal.string(fmt)],
3056                    ),
3057                    exp.DataType.from_str(cast_type, dialect="duckdb"),
3058                )
3059            )
3060
3061        if part_name in self.EXTRACT_EPOCH_MAPPINGS:
3062            func_name = self.EXTRACT_EPOCH_MAPPINGS[part_name]
3063            result: exp.Expr = exp.Anonymous(this=func_name, expressions=[datetime_expr])
3064            # EPOCH returns float, cast to BIGINT for integer result
3065            if part_name == "EPOCH_SECOND":
3066                result = exp.cast(result, exp.DataType.from_str("BIGINT", dialect="duckdb"))
3067            return self.sql(result)
3068
3069        return super().extract_sql(expression)

Transpile EXTRACT/DATE_PART for DuckDB, handling specifiers not natively supported.

DuckDB doesn't support: WEEKISO, YEAROFWEEK, YEAROFWEEKISO, NANOSECOND, EPOCH_SECOND (as integer), EPOCH_MILLISECOND, EPOCH_MICROSECOND, EPOCH_NANOSECOND

def timestampfromparts_sql(self, expression: sqlglot.expressions.temporal.TimestampFromParts) -> str:
3071    def timestampfromparts_sql(self, expression: exp.TimestampFromParts) -> str:
3072        # Check if this is the date/time expression form: TIMESTAMP_FROM_PARTS(date_expr, time_expr)
3073        date_expr = expression.this
3074        time_expr = expression.expression
3075
3076        if date_expr is not None and time_expr is not None:
3077            # In DuckDB, DATE + TIME produces TIMESTAMP
3078            return self.sql(exp.Add(this=date_expr, expression=time_expr))
3079
3080        # Component-based form: TIMESTAMP_FROM_PARTS(year, month, day, hour, minute, second, ...)
3081        sec = expression.args.get("sec")
3082        if sec is None:
3083            # This shouldn't happen with valid input, but handle gracefully
3084            return rename_func("MAKE_TIMESTAMP")(self, expression)
3085
3086        milli = expression.args.get("milli")
3087        if milli is not None:
3088            sec += milli.pop() / exp.Literal.number(1000.0)
3089
3090        nano = expression.args.get("nano")
3091        if nano is not None:
3092            sec += nano.pop() / exp.Literal.number(1000000000.0)
3093
3094        if milli or nano:
3095            expression.set("sec", sec)
3096
3097        if zone := expression.args.get("zone"):
3098            return self.sql(exp.AtTimeZone(this=expression, zone=zone.pop()))
3099
3100        return rename_func("MAKE_TIMESTAMP")(self, expression)
@unsupported_args('nano')
def timestampltzfromparts_sql( self, expression: sqlglot.expressions.temporal.TimestampLtzFromParts) -> str:
3102    @unsupported_args("nano")
3103    def timestampltzfromparts_sql(self, expression: exp.TimestampLtzFromParts) -> str:
3104        # Pop nano so rename_func only passes args that MAKE_TIMESTAMP accepts
3105        if nano := expression.args.get("nano"):
3106            nano.pop()
3107
3108        timestamp = rename_func("MAKE_TIMESTAMP")(self, expression)
3109        return f"CAST({timestamp} AS TIMESTAMPTZ)"
@unsupported_args('nano')
def timestamptzfromparts_sql( self, expression: sqlglot.expressions.temporal.TimestampTzFromParts) -> str:
3111    @unsupported_args("nano")
3112    def timestamptzfromparts_sql(self, expression: exp.TimestampTzFromParts) -> str:
3113        # Extract zone before popping
3114        zone = expression.args.get("zone")
3115        # Pop zone and nano so rename_func only passes args that MAKE_TIMESTAMP accepts
3116        if zone:
3117            zone = zone.pop()
3118
3119        if nano := expression.args.get("nano"):
3120            nano.pop()
3121
3122        timestamp = rename_func("MAKE_TIMESTAMP")(self, expression)
3123
3124        if zone:
3125            # Use AT TIME ZONE to apply the explicit timezone
3126            return f"{timestamp} AT TIME ZONE {self.sql(zone)}"
3127
3128        return timestamp
def after_limit_modifiers(self, expression: sqlglot.expressions.core.Expr) -> list[str]:
3130    def after_limit_modifiers(self, expression: exp.Expr) -> list[str]:
3131        locks = self.expressions(expression, key="locks", sep=" ")
3132        return [f" {locks}" if locks else ""]
def tablesample_sql( self, expression: sqlglot.expressions.query.TableSample, tablesample_keyword: str | None = None) -> str:
3134    def tablesample_sql(
3135        self,
3136        expression: exp.TableSample,
3137        tablesample_keyword: str | None = None,
3138    ) -> str:
3139        if not isinstance(expression.parent, exp.Select):
3140            # This sample clause only applies to a single source, not the entire resulting relation
3141            tablesample_keyword = "TABLESAMPLE"
3142
3143        if expression.args.get("size"):
3144            method = expression.args.get("method")
3145            if method and method.name.upper() != "RESERVOIR":
3146                self.unsupported(
3147                    f"Sampling method {method} is not supported with a discrete sample count, "
3148                    "defaulting to reservoir sampling"
3149                )
3150                expression.set("method", exp.var("RESERVOIR"))
3151
3152        return super().tablesample_sql(expression, tablesample_keyword=tablesample_keyword)
def in_sql(self, expression: sqlglot.expressions.core.In) -> str:
3154    def in_sql(self, expression: exp.In) -> str:
3155        unnest = expression.args.get("unnest")
3156        if unnest:
3157            return self.sql(
3158                exp.replace_placeholders(
3159                    self.IN_UNNEST_TEMPLATE, arr=unnest.expressions[0], value=expression.this
3160                )
3161            )
3162        return super().in_sql(expression)
def join_sql(self, expression: sqlglot.expressions.query.Join) -> str:
3164    def join_sql(self, expression: exp.Join) -> str:
3165        if (
3166            not expression.args.get("using")
3167            and not expression.args.get("on")
3168            and not expression.method
3169            and (expression.kind in ("", "INNER", "OUTER"))
3170        ):
3171            # Some dialects support `LEFT/INNER JOIN UNNEST(...)` without an explicit ON clause
3172            # DuckDB doesn't, but we can just add a dummy ON clause that is always true
3173            if isinstance(expression.this, exp.Unnest):
3174                return super().join_sql(expression.on(exp.true()))
3175
3176            expression.set("side", None)
3177            expression.set("kind", None)
3178
3179        return super().join_sql(expression)
def countif_sql(self, expression: sqlglot.expressions.aggregate.CountIf) -> str:
3181    def countif_sql(self, expression: exp.CountIf) -> str:
3182        if self.dialect.version >= (1, 2):
3183            this = expression.this
3184            if expression.args.get("zero_on_all_null") and not isinstance(this, exp.Distinct):
3185                # DuckDB >= 1.2's COUNT_IF returns NULL when the condition is NULL on all rows,
3186                # so we wrap the condition in IS TRUE to preserve count-like semantics
3187                expression = exp.CountIf(this=exp.paren(this).is_(exp.true()))
3188            return self.function_fallback_sql(expression)
3189
3190        # https://github.com/tobymao/sqlglot/pull/4749
3191        return count_if_to_sum(self, expression)
def bracket_sql(self, expression: sqlglot.expressions.core.Bracket) -> str:
3193    def bracket_sql(self, expression: exp.Bracket) -> str:
3194        if self.dialect.version >= (1, 2):
3195            return super().bracket_sql(expression)
3196
3197        # https://duckdb.org/2025/02/05/announcing-duckdb-120.html#breaking-changes
3198        this = expression.this
3199        if isinstance(this, exp.Array):
3200            this.replace(exp.paren(this))
3201
3202        bracket = super().bracket_sql(expression)
3203
3204        if not expression.args.get("returns_list_for_maps"):
3205            if not this.type:
3206                from sqlglot.optimizer.annotate_types import annotate_types
3207
3208                this = annotate_types(this, dialect=self.dialect)
3209
3210            if this.is_type(exp.DType.MAP):
3211                bracket = f"({bracket})[1]"
3212
3213        return bracket
def withingroup_sql(self, expression: sqlglot.expressions.core.WithinGroup) -> str:
3215    def withingroup_sql(self, expression: exp.WithinGroup) -> str:
3216        func = expression.this
3217
3218        # For ARRAY_AGG, DuckDB requires ORDER BY inside the function, not in WITHIN GROUP
3219        # Transform: ARRAY_AGG(x) WITHIN GROUP (ORDER BY y) -> ARRAY_AGG(x ORDER BY y)
3220        if isinstance(func, exp.ArrayAgg):
3221            if not isinstance(order := expression.expression, exp.Order):
3222                return self.sql(func)
3223
3224            # Save the original column for FILTER clause (before wrapping with Order)
3225            original_this = func.this
3226
3227            # Move ORDER BY inside ARRAY_AGG by wrapping its argument with Order
3228            # ArrayAgg.this should become Order(this=ArrayAgg.this, expressions=order.expressions)
3229            func.set(
3230                "this",
3231                exp.Order(
3232                    this=func.this.copy(),
3233                    expressions=order.expressions,
3234                ),
3235            )
3236
3237            # Generate the ARRAY_AGG function with ORDER BY and add FILTER clause if needed
3238            # Use original_this (not the Order-wrapped version) for the FILTER condition
3239            array_agg_sql = self.function_fallback_sql(func)
3240            return self._add_arrayagg_null_filter(array_agg_sql, func, original_this)
3241
3242        # For other functions (like PERCENTILES), use existing logic
3243        expression_sql = self.sql(expression, "expression")
3244
3245        if isinstance(func, exp.PERCENTILES):
3246            # Make the order key the first arg and slide the fraction to the right
3247            # https://duckdb.org/docs/sql/aggregates#ordered-set-aggregate-functions
3248            order_col = expression.find(exp.Ordered)
3249            if order_col:
3250                func.set("expression", func.this)
3251                func.set("this", order_col.this)
3252
3253        this = self.sql(expression, "this").rstrip(")")
3254
3255        return f"{this}{expression_sql})"
def length_sql(self, expression: sqlglot.expressions.string.Length) -> str:
3257    def length_sql(self, expression: exp.Length) -> str:
3258        arg = expression.this
3259
3260        # Dialects like BQ and Snowflake also accept binary values as args, so
3261        # DDB will attempt to infer the type or resort to case/when resolution
3262        if not expression.args.get("binary") or arg.is_string:
3263            return self.func("LENGTH", arg)
3264
3265        if not arg.type:
3266            from sqlglot.optimizer.annotate_types import annotate_types
3267
3268            arg = annotate_types(arg, dialect=self.dialect)
3269
3270        if arg.is_type(*exp.DataType.TEXT_TYPES):
3271            return self.func("LENGTH", arg)
3272
3273        # We need these casts to make duckdb's static type checker happy
3274        blob = exp.cast(arg, exp.DType.VARBINARY)
3275        varchar = exp.cast(arg, exp.DType.VARCHAR)
3276
3277        case = (
3278            exp.case(exp.Anonymous(this="TYPEOF", expressions=[arg]))
3279            .when(exp.Literal.string("BLOB"), exp.ByteLength(this=blob))
3280            .else_(exp.Anonymous(this="LENGTH", expressions=[varchar]))
3281        )
3282        return self.sql(case)
def bitlength_sql(self, expression: sqlglot.expressions.string.BitLength) -> str:
3284    def bitlength_sql(self, expression: exp.BitLength) -> str:
3285        if not _is_binary(arg := expression.this):
3286            return self.func("BIT_LENGTH", arg)
3287
3288        blob = exp.cast(arg, exp.DataType.Type.VARBINARY)
3289        return self.sql(exp.ByteLength(this=blob) * exp.Literal.number(8))
def chr_sql( self, expression: sqlglot.expressions.string.Chr, name: str = 'CHR') -> str:
3291    def chr_sql(self, expression: exp.Chr, name: str = "CHR") -> str:
3292        arg = expression.expressions[0]
3293        if arg.is_type(*exp.DataType.REAL_TYPES):
3294            arg = exp.cast(arg, exp.DType.INT)
3295        return self.func("CHR", arg)
def collation_sql(self, expression: sqlglot.expressions.functions.Collation) -> str:
3297    def collation_sql(self, expression: exp.Collation) -> str:
3298        self.unsupported("COLLATION function is not supported by DuckDB")
3299        return self.function_fallback_sql(expression)
def collate_sql(self, expression: sqlglot.expressions.functions.Collate) -> str:
3301    def collate_sql(self, expression: exp.Collate) -> str:
3302        if not expression.expression.is_string:
3303            return super().collate_sql(expression)
3304
3305        raw = expression.expression.name
3306        if not raw:
3307            return self.sql(expression.this)
3308
3309        parts = []
3310        for part in raw.split("-"):
3311            lower = part.lower()
3312            if lower not in _SNOWFLAKE_COLLATION_DEFAULTS:
3313                if lower in _SNOWFLAKE_COLLATION_UNSUPPORTED:
3314                    self.unsupported(
3315                        f"Snowflake collation specifier '{part}' has no DuckDB equivalent"
3316                    )
3317                parts.append(lower)
3318
3319        if not parts:
3320            return self.sql(expression.this)
3321        return super().collate_sql(
3322            exp.Collate(this=expression.this, expression=exp.var(".".join(parts)))
3323        )
def regexpcount_sql(self, expression: sqlglot.expressions.string.RegexpCount) -> str:
3355    def regexpcount_sql(self, expression: exp.RegexpCount) -> str:
3356        this = expression.this
3357        pattern = expression.expression
3358        position = expression.args.get("position")
3359        parameters = expression.args.get("parameters")
3360
3361        # Validate flags - only "ims" flags are supported for embedded patterns
3362        validated_flags = self._validate_regexp_flags(parameters, supported_flags="ims")
3363
3364        if position:
3365            this = exp.Substring(this=this, start=position)
3366
3367        # Handle empty pattern: Snowflake returns 0, DuckDB would match between every character.
3368        # Checked before the flags are embedded, which would make the pattern non-empty
3369        is_empty = exp.EQ(this=pattern.copy(), expression=exp.Literal.string(""))
3370
3371        # Embed flags in pattern (REGEXP_EXTRACT_ALL doesn't support flags argument)
3372        if validated_flags:
3373            pattern = exp.Concat(expressions=[exp.Literal.string(f"(?{validated_flags})"), pattern])
3374
3375        result = (
3376            exp.case()
3377            .when(is_empty, exp.Literal.number(0))
3378            .else_(
3379                exp.Length(
3380                    this=exp.Anonymous(this="REGEXP_EXTRACT_ALL", expressions=[this, pattern])
3381                )
3382            )
3383        )
3384
3385        return self.sql(result)
def regexpreplace_sql(self, expression: sqlglot.expressions.string.RegexpReplace) -> str:
3387    def regexpreplace_sql(self, expression: exp.RegexpReplace) -> str:
3388        subject = expression.this
3389        pattern = expression.expression
3390        replacement = expression.args.get("replacement") or exp.Literal.string("")
3391        position = expression.args.get("position")
3392        occurrence = expression.args.get("occurrence")
3393        modifiers = expression.args.get("modifiers")
3394
3395        validated_flags = self._validate_regexp_flags(modifiers, supported_flags="cimsg") or ""
3396
3397        # Handle occurrence (only literals supported)
3398        if occurrence and not occurrence.is_int:
3399            self.unsupported("REGEXP_REPLACE with non-literal occurrence")
3400        else:
3401            occurrence = occurrence.to_py() if occurrence and occurrence.is_int else 0
3402            if occurrence > 1:
3403                self.unsupported(f"REGEXP_REPLACE occurrence={occurrence} not supported")
3404            # flag duckdb to do either all or none, single_replace check is for duckdb round trip
3405            elif (
3406                occurrence == 0
3407                and "g" not in validated_flags
3408                and not expression.args.get("single_replace")
3409            ):
3410                validated_flags += "g"
3411
3412        # Handle position (only literals supported)
3413        prefix = None
3414        if position and not position.is_int:
3415            self.unsupported("REGEXP_REPLACE with non-literal position")
3416        elif position and position.is_int and position.to_py() > 1:
3417            pos = position.to_py()
3418            prefix = exp.Substring(
3419                this=subject, start=exp.Literal.number(1), length=exp.Literal.number(pos - 1)
3420            )
3421            subject = exp.Substring(this=subject, start=exp.Literal.number(pos))
3422
3423        result: exp.Expr = exp.Anonymous(
3424            this="REGEXP_REPLACE",
3425            expressions=[
3426                subject,
3427                pattern,
3428                replacement,
3429                exp.Literal.string(validated_flags) if validated_flags else None,
3430            ],
3431        )
3432
3433        if prefix:
3434            result = exp.Concat(expressions=[prefix, result])
3435
3436        return self.sql(result)
def regexplike_sql(self, expression: sqlglot.expressions.core.RegexpLike) -> str:
3438    def regexplike_sql(self, expression: exp.RegexpLike) -> str:
3439        this = expression.this
3440        pattern = expression.expression
3441        flag = expression.args.get("flag")
3442
3443        if expression.args.get("full_match"):
3444            validated_flags = self._validate_regexp_flags(flag, supported_flags="cims")
3445            flag = exp.Literal.string(validated_flags) if validated_flags else None
3446            return self.func("REGEXP_FULL_MATCH", this, pattern, flag)
3447
3448        return self.func("REGEXP_MATCHES", this, pattern, flag)
@unsupported_args('ins_cost', 'del_cost', 'sub_cost')
def levenshtein_sql(self, expression: sqlglot.expressions.string.Levenshtein) -> str:
3450    @unsupported_args("ins_cost", "del_cost", "sub_cost")
3451    def levenshtein_sql(self, expression: exp.Levenshtein) -> str:
3452        this = expression.this
3453        expr = expression.expression
3454        max_dist = expression.args.get("max_dist")
3455
3456        if max_dist is None:
3457            return self.func("LEVENSHTEIN", this, expr)
3458
3459        # Emulate Snowflake semantics: if distance > max_dist, return max_dist
3460        levenshtein = exp.Levenshtein(this=this, expression=expr)
3461        return self.sql(exp.Least(this=levenshtein, expressions=[max_dist]))
def pad_sql(self, expression: sqlglot.expressions.string.Pad) -> str:
3463    def pad_sql(self, expression: exp.Pad) -> str:
3464        """
3465        Handle RPAD/LPAD for VARCHAR and BINARY types.
3466
3467        For VARCHAR: Delegate to parent class
3468        For BINARY: Lower to: input || REPEAT(pad, GREATEST(0, target_len - OCTET_LENGTH(input)))
3469        """
3470        string_arg = expression.this
3471        fill_arg = expression.args.get("fill_pattern") or exp.Literal.string(" ")
3472
3473        if _is_binary(string_arg) or _is_binary(fill_arg):
3474            length_arg = expression.expression
3475            is_left = expression.args.get("is_left")
3476
3477            input_len = exp.ByteLength(this=string_arg)
3478            chars_needed = length_arg - input_len
3479            pad_count = exp.Greatest(
3480                this=exp.Literal.number(0), expressions=[chars_needed], ignore_nulls=True
3481            )
3482            repeat_expr = exp.Repeat(this=fill_arg, times=pad_count)
3483
3484            left, right = string_arg, repeat_expr
3485            if is_left:
3486                left, right = right, left
3487
3488            result = exp.DPipe(this=left, expression=right)
3489            return self.sql(result)
3490
3491        # For VARCHAR: Delegate to parent class (handles PAD_FILL_PATTERN_IS_REQUIRED)
3492        return super().pad_sql(expression)

Handle RPAD/LPAD for VARCHAR and BINARY types.

For VARCHAR: Delegate to parent class For BINARY: Lower to: input || REPEAT(pad, GREATEST(0, target_len - OCTET_LENGTH(input)))

def minhash_sql(self, expression: sqlglot.expressions.aggregate.Minhash) -> str:
3494    def minhash_sql(self, expression: exp.Minhash) -> str:
3495        k = expression.this
3496        exprs = expression.expressions
3497
3498        if len(exprs) != 1 or isinstance(exprs[0], exp.Star):
3499            self.unsupported(
3500                "MINHASH with multiple expressions or * requires manual query restructuring"
3501            )
3502            return self.func("MINHASH", k, *exprs)
3503
3504        expr = exprs[0]
3505        result = exp.replace_placeholders(self.MINHASH_TEMPLATE.copy(), expr=expr, k=k)
3506        return f"({self.sql(result)})"
def minhashcombine_sql(self, expression: sqlglot.expressions.aggregate.MinhashCombine) -> str:
3508    def minhashcombine_sql(self, expression: exp.MinhashCombine) -> str:
3509        expr = expression.this
3510        result = exp.replace_placeholders(self.MINHASH_COMBINE_TEMPLATE.copy(), expr=expr)
3511        return f"({self.sql(result)})"
def approximatesimilarity_sql( self, expression: sqlglot.expressions.aggregate.ApproximateSimilarity) -> str:
3513    def approximatesimilarity_sql(self, expression: exp.ApproximateSimilarity) -> str:
3514        expr = expression.this
3515        result = exp.replace_placeholders(self.APPROXIMATE_SIMILARITY_TEMPLATE.copy(), expr=expr)
3516        return f"({self.sql(result)})"
def arrayuniqueagg_sql(self, expression: sqlglot.expressions.aggregate.ArrayUniqueAgg) -> str:
3518    def arrayuniqueagg_sql(self, expression: exp.ArrayUniqueAgg) -> str:
3519        return self.sql(
3520            exp.Filter(
3521                this=exp.func("LIST", exp.Distinct(expressions=[expression.this])),
3522                expression=exp.Where(this=expression.this.copy().is_(exp.null()).not_()),
3523            )
3524        )
def arrayconcatagg_sql(self, expression: sqlglot.expressions.aggregate.ArrayConcatAgg) -> str:
3526    def arrayconcatagg_sql(self, expression: exp.ArrayConcatAgg) -> str:
3527        this = expression.this
3528
3529        if isinstance(this, exp.Limit):
3530            self.unsupported("LIMIT in ARRAY_CONCAT_AGG cannot be transpiled to DuckDB")
3531            this = this.this
3532
3533        inner = this.this if isinstance(this, exp.Order) else this
3534
3535        return self.func(
3536            "FLATTEN",
3537            exp.Filter(
3538                this=exp.ArrayAgg(this=this),
3539                expression=exp.Where(this=inner.copy().is_(exp.null()).not_()),
3540            ),
3541        )
def arrayunionagg_sql(self, expression: sqlglot.expressions.aggregate.ArrayUnionAgg) -> str:
3543    def arrayunionagg_sql(self, expression: exp.ArrayUnionAgg) -> str:
3544        self.unsupported("ARRAY_UNION_AGG is not supported in DuckDB")
3545        return self.function_fallback_sql(expression)
def arraydistinct_sql(self, expression: sqlglot.expressions.array.ArrayDistinct) -> str:
3547    def arraydistinct_sql(self, expression: exp.ArrayDistinct) -> str:
3548        arr = expression.this
3549        func = self.func("LIST_DISTINCT", arr)
3550
3551        if expression.args.get("check_null"):
3552            add_null_to_array = exp.func(
3553                "LIST_APPEND", exp.func("LIST_DISTINCT", exp.ArrayCompact(this=arr)), exp.Null()
3554            )
3555            return self.sql(
3556                exp.If(
3557                    this=exp.NEQ(
3558                        this=exp.ArraySize(this=arr), expression=exp.func("LIST_COUNT", arr)
3559                    ),
3560                    true=add_null_to_array,
3561                    false=func,
3562                )
3563            )
3564
3565        return func
def arrayintersect_sql(self, expression: sqlglot.expressions.array.ArrayIntersect) -> str:
3567    def arrayintersect_sql(self, expression: exp.ArrayIntersect) -> str:
3568        if expression.args.get("is_multiset") and len(expression.expressions) == 2:
3569            return self._array_bag_sql(
3570                self.ARRAY_INTERSECTION_CONDITION,
3571                expression.expressions[0],
3572                expression.expressions[1],
3573            )
3574        return self.function_fallback_sql(expression)
def arrayexcept_sql(self, expression: sqlglot.expressions.array.ArrayExcept) -> str:
3576    def arrayexcept_sql(self, expression: exp.ArrayExcept) -> str:
3577        arr1, arr2 = expression.this, expression.expression
3578        if expression.args.get("is_multiset"):
3579            return self._array_bag_sql(self.ARRAY_EXCEPT_CONDITION, arr1, arr2)
3580        return self.sql(
3581            exp.replace_placeholders(self.ARRAY_EXCEPT_SET_TEMPLATE, arr1=arr1, arr2=arr2)
3582        )
def arrayslice_sql(self, expression: sqlglot.expressions.array.ArraySlice) -> str:
3584    def arrayslice_sql(self, expression: exp.ArraySlice) -> str:
3585        """
3586        Spark's SLICE uses a length, so normalize negative starts before computing
3587        the inclusive end index. Starts before the array produce an empty slice.
3588
3589        Transpiles Snowflake's ARRAY_SLICE (0-indexed, exclusive end) to DuckDB's
3590        ARRAY_SLICE (1-indexed, inclusive end) by wrapping start and end in CASE
3591        expressions that adjust the index at query time:
3592          - start: CASE WHEN start >= 0 THEN start + 1 ELSE start END
3593          - end:   CASE WHEN end < 0 THEN end - 1 ELSE end END
3594        """
3595        start, end = expression.args.get("start"), expression.args.get("end")
3596
3597        if expression.args.get("zero_based"):
3598            if start is not None:
3599                start = (
3600                    exp.case()
3601                    .when(
3602                        exp.GTE(this=start.copy(), expression=exp.Literal.number(0)),
3603                        exp.Add(this=start.copy(), expression=exp.Literal.number(1)),
3604                    )
3605                    .else_(start)
3606                )
3607            if end is not None and not expression.args.get("is_length"):
3608                end = (
3609                    exp.case()
3610                    .when(
3611                        exp.LT(this=end.copy(), expression=exp.Literal.number(0)),
3612                        exp.Sub(this=end.copy(), expression=exp.Literal.number(1)),
3613                    )
3614                    .else_(end)
3615                )
3616
3617        if expression.args.get("is_length") and start is not None and end is not None:
3618            if not start.is_int or start.to_py() < 0:
3619                size = exp.ArraySize(this=expression.this.copy())
3620                start = (
3621                    exp.case()
3622                    .when(start < -size, size + 1)
3623                    .when(start < 0, size + start + 1)
3624                    .else_(start)
3625                )
3626
3627            if start.is_int and end.is_int:
3628                end = exp.Literal.number(start.to_py() + end.to_py() - 1)
3629            else:
3630                end = start + exp.cast(end, "BIGINT") - 1
3631        return self.func("ARRAY_SLICE", expression.this, start, end, expression.args.get("step"))

Spark's SLICE uses a length, so normalize negative starts before computing the inclusive end index. Starts before the array produce an empty slice.

Transpiles Snowflake's ARRAY_SLICE (0-indexed, exclusive end) to DuckDB's ARRAY_SLICE (1-indexed, inclusive end) by wrapping start and end in CASE expressions that adjust the index at query time:

  • start: CASE WHEN start >= 0 THEN start + 1 ELSE start END
  • end: CASE WHEN end < 0 THEN end - 1 ELSE end END
def arrayszip_sql(self, expression: sqlglot.expressions.array.ArraysZip) -> str:
3633    def arrayszip_sql(self, expression: exp.ArraysZip) -> str:
3634        args = expression.expressions
3635
3636        if not args:
3637            # Return [{}] - using MAP([], []) since DuckDB can't represent empty structs
3638            return self.sql(exp.array(exp.Map(keys=exp.array(), values=exp.array())))
3639
3640        # Build placeholder values for template
3641        lengths = [exp.Length(this=arg) for arg in args]
3642        max_len = (
3643            lengths[0]
3644            if len(lengths) == 1
3645            else exp.Greatest(this=lengths[0], expressions=lengths[1:])
3646        )
3647
3648        # Empty struct with same schema: {'$1': NULL, '$2': NULL, ...}
3649        empty_struct = exp.func(
3650            "STRUCT",
3651            *[
3652                exp.PropertyEQ(this=exp.Literal.string(f"${i + 1}"), expression=exp.Null())
3653                for i in range(len(args))
3654            ],
3655        )
3656
3657        # Struct for transform: {'$1': COALESCE(arr1, [])[__i + 1], ...}
3658        # COALESCE wrapping handles NULL arrays - prevents invalid NULL[i] syntax
3659        index = exp.column("__i") + 1
3660        transform_struct = exp.func(
3661            "STRUCT",
3662            *[
3663                exp.PropertyEQ(
3664                    this=exp.Literal.string(f"${i + 1}"),
3665                    expression=exp.func("COALESCE", arg, exp.array())[index],
3666                )
3667                for i, arg in enumerate(args)
3668            ],
3669        )
3670
3671        result = exp.replace_placeholders(
3672            self.ARRAYS_ZIP_TEMPLATE.copy(),
3673            null_check=exp.or_(*[arg.is_(exp.Null()) for arg in args]),
3674            all_empty_check=exp.and_(
3675                *[
3676                    exp.EQ(this=exp.Length(this=arg), expression=exp.Literal.number(0))
3677                    for arg in args
3678                ]
3679            ),
3680            empty_struct=empty_struct,
3681            max_len=max_len,
3682            transform_struct=transform_struct,
3683        )
3684        return self.sql(result)
def lower_sql(self, expression: sqlglot.expressions.string.Lower) -> str:
3686    def lower_sql(self, expression: exp.Lower) -> str:
3687        result_sql = self.func("LOWER", _cast_to_varchar(expression.this))
3688        return _gen_with_cast_to_blob(self, expression, result_sql)
def upper_sql(self, expression: sqlglot.expressions.string.Upper) -> str:
3690    def upper_sql(self, expression: exp.Upper) -> str:
3691        result_sql = self.func("UPPER", _cast_to_varchar(expression.this))
3692        return _gen_with_cast_to_blob(self, expression, result_sql)
def reverse_sql(self, expression: sqlglot.expressions.string.Reverse) -> str:
3694    def reverse_sql(self, expression: exp.Reverse) -> str:
3695        result_sql = self.func("REVERSE", _cast_to_varchar(expression.this))
3696        return _gen_with_cast_to_blob(self, expression, result_sql)
def left_sql(self, expression: sqlglot.expressions.string.Left) -> str:
3722    def left_sql(self, expression: exp.Left) -> str:
3723        return self._left_right_sql(expression, "LEFT")
def right_sql(self, expression: sqlglot.expressions.string.Right) -> str:
3725    def right_sql(self, expression: exp.Right) -> str:
3726        return self._left_right_sql(expression, "RIGHT")
def rtrimmedlength_sql(self, expression: sqlglot.expressions.string.RtrimmedLength) -> str:
3728    def rtrimmedlength_sql(self, expression: exp.RtrimmedLength) -> str:
3729        return self.func("LENGTH", exp.Trim(this=expression.this, position="TRAILING"))
def stuff_sql(self, expression: sqlglot.expressions.string.Stuff) -> str:
3731    def stuff_sql(self, expression: exp.Stuff) -> str:
3732        base = expression.this
3733        start = expression.args["start"]
3734        length = expression.args["length"]
3735        insertion = expression.expression
3736        is_binary = _is_binary(base)
3737
3738        if is_binary:
3739            # DuckDB's SUBSTRING doesn't accept BLOB; operate on the HEX string instead
3740            # (each byte = 2 hex chars), then UNHEX back to BLOB
3741            base = exp.Hex(this=base)
3742            insertion = exp.Hex(this=insertion)
3743            left = exp.Substring(
3744                this=base.copy(),
3745                start=exp.Literal.number(1),
3746                length=(start.copy() - exp.Literal.number(1)) * exp.Literal.number(2),
3747            )
3748            right = exp.Substring(
3749                this=base.copy(),
3750                start=((start + length) - exp.Literal.number(1)) * exp.Literal.number(2)
3751                + exp.Literal.number(1),
3752            )
3753        else:
3754            left = exp.Substring(
3755                this=base.copy(),
3756                start=exp.Literal.number(1),
3757                length=start.copy() - exp.Literal.number(1),
3758            )
3759            right = exp.Substring(this=base.copy(), start=start + length)
3760        result: exp.Expr = exp.DPipe(
3761            this=exp.DPipe(this=left, expression=insertion), expression=right
3762        )
3763
3764        if is_binary:
3765            result = exp.Unhex(this=result)
3766
3767        return self.sql(result)
def rand_sql(self, expression: sqlglot.expressions.functions.Rand) -> str:
3769    def rand_sql(self, expression: exp.Rand) -> str:
3770        seed = expression.this
3771        if seed is not None:
3772            self.unsupported("RANDOM with seed is not supported in DuckDB")
3773
3774        lower = expression.args.get("lower")
3775        upper = expression.args.get("upper")
3776
3777        if lower and upper:
3778            # scale DuckDB's [0,1) to the specified range
3779            range_size = exp.paren(upper - lower)
3780            scaled = exp.Add(this=lower, expression=exp.func("random") * range_size)
3781
3782            # For now we assume that if bounds are set, return type is BIGINT. Snowflake/Teradata
3783            result = exp.cast(scaled, exp.DType.BIGINT)
3784            return self.sql(result)
3785
3786        # Default DuckDB behavior - just return RANDOM() as float
3787        return "RANDOM()"
def bytelength_sql(self, expression: sqlglot.expressions.string.ByteLength) -> str:
3789    def bytelength_sql(self, expression: exp.ByteLength) -> str:
3790        arg = expression.this
3791
3792        # Check if it's a text type (handles both literals and annotated expressions)
3793        if arg.is_type(*exp.DataType.TEXT_TYPES):
3794            return self.func("OCTET_LENGTH", exp.Encode(this=arg))
3795
3796        # Default: pass through as-is (conservative for DuckDB, handles binary and unannotated)
3797        return self.func("OCTET_LENGTH", arg)
def base64encode_sql(self, expression: sqlglot.expressions.string.Base64Encode) -> str:
3799    def base64encode_sql(self, expression: exp.Base64Encode) -> str:
3800        # DuckDB TO_BASE64 requires BLOB input
3801        # Snowflake BASE64_ENCODE accepts both VARCHAR and BINARY - for VARCHAR it implicitly
3802        # encodes UTF-8 bytes. We add ENCODE unless the input is a binary type.
3803        result = expression.this
3804
3805        # Check if input is a string type - ENCODE only accepts VARCHAR
3806        if result.is_type(*exp.DataType.TEXT_TYPES):
3807            result = exp.Encode(this=result)
3808
3809        result = exp.ToBase64(this=result)
3810
3811        max_line_length = expression.args.get("max_line_length")
3812        alphabet = expression.args.get("alphabet")
3813
3814        # Handle custom alphabet by replacing standard chars with custom ones
3815        result = _apply_base64_alphabet_replacements(result, alphabet)
3816
3817        # Handle max_line_length by inserting newlines every N characters
3818        line_length = (
3819            t.cast(int, max_line_length.to_py())
3820            if isinstance(max_line_length, exp.Literal) and max_line_length.is_number
3821            else 0
3822        )
3823        if line_length > 0:
3824            newline = exp.Chr(expressions=[exp.Literal.number(10)])
3825            result = exp.Trim(
3826                this=exp.RegexpReplace(
3827                    this=result,
3828                    expression=exp.Literal.string(f"(.{{{line_length}}})"),
3829                    replacement=exp.Concat(expressions=[exp.Literal.string("\\1"), newline.copy()]),
3830                ),
3831                expression=newline,
3832                position="TRAILING",
3833            )
3834
3835        return self.sql(result)
def hex_sql(self, expression: sqlglot.expressions.string.Hex) -> str:
3837    def hex_sql(self, expression: exp.Hex) -> str:
3838        case = expression.args.get("case")
3839
3840        if not case:
3841            return self.func("HEX", expression.this)
3842
3843        hex_expr = exp.Hex(this=expression.this)
3844        return self.sql(
3845            exp.case()
3846            .when(case.is_(exp.null()), exp.null())
3847            .when(case.copy().eq(0), exp.Lower(this=hex_expr.copy()))
3848            .else_(hex_expr)
3849        )
def replace_sql(self, expression: sqlglot.expressions.string.Replace) -> str:
3851    def replace_sql(self, expression: exp.Replace) -> str:
3852        result_sql = self.func(
3853            "REPLACE",
3854            _cast_to_varchar(expression.this),
3855            _cast_to_varchar(expression.expression),
3856            _cast_to_varchar(expression.args.get("replacement")),
3857        )
3858        return _gen_with_cast_to_blob(self, expression, result_sql)
def bitwisexor_sql(self, expression: sqlglot.expressions.core.BitwiseXor) -> str:
3865    def bitwisexor_sql(self, expression: exp.BitwiseXor) -> str:
3866        _prepare_binary_bitwise_args(expression)
3867        result_sql = self.func("XOR", expression.this, expression.expression)
3868        return _gen_with_cast_to_blob(self, expression, result_sql)
def objectinsert_sql(self, expression: sqlglot.expressions.json.ObjectInsert) -> str:
3870    def objectinsert_sql(self, expression: exp.ObjectInsert) -> str:
3871        this = expression.this
3872        key = expression.args.get("key")
3873        key_sql = key.name if isinstance(key, exp.Expr) else ""
3874        value_sql = self.sql(expression, "value")
3875
3876        kv_sql = f"{key_sql} := {value_sql}"
3877
3878        # If the input struct is empty e.g. transpiling OBJECT_INSERT(OBJECT_CONSTRUCT(), key, value) from Snowflake
3879        # then we can generate STRUCT_PACK which will build it since STRUCT_INSERT({}, key := value) is not valid DuckDB
3880        if isinstance(this, exp.Struct) and not this.expressions:
3881            return self.func("STRUCT_PACK", kv_sql)
3882
3883        return self.func("STRUCT_INSERT", this, kv_sql)
def mapcat_sql(self, expression: sqlglot.expressions.array.MapCat) -> str:
3885    def mapcat_sql(self, expression: exp.MapCat) -> str:
3886        result = exp.replace_placeholders(
3887            self.MAPCAT_TEMPLATE.copy(),
3888            map1=expression.this,
3889            map2=expression.expression,
3890        )
3891        return self.sql(result)
def mapcontainskey_sql(self, expression: sqlglot.expressions.array.MapContainsKey) -> str:
3893    def mapcontainskey_sql(self, expression: exp.MapContainsKey) -> str:
3894        return self.func(
3895            "ARRAY_CONTAINS", exp.func("MAP_KEYS", expression.args["key"]), expression.this
3896        )
def mapdelete_sql(self, expression: sqlglot.expressions.array.MapDelete) -> str:
3898    def mapdelete_sql(self, expression: exp.MapDelete) -> str:
3899        map_arg = expression.this
3900        keys_to_delete = expression.expressions
3901
3902        x_dot_key = exp.Dot(this=exp.to_identifier("x"), expression=exp.to_identifier("key"))
3903
3904        lambda_expr = exp.Lambda(
3905            this=exp.In(this=x_dot_key, expressions=keys_to_delete).not_(),
3906            expressions=[exp.to_identifier("x")],
3907        )
3908        result = exp.func(
3909            "MAP_FROM_ENTRIES",
3910            exp.ArrayFilter(this=exp.func("MAP_ENTRIES", map_arg), expression=lambda_expr),
3911        )
3912        return self.sql(result)
def mappick_sql(self, expression: sqlglot.expressions.array.MapPick) -> str:
3914    def mappick_sql(self, expression: exp.MapPick) -> str:
3915        map_arg = expression.this
3916        keys_to_pick = expression.expressions
3917
3918        x_dot_key = exp.Dot(this=exp.to_identifier("x"), expression=exp.to_identifier("key"))
3919
3920        if len(keys_to_pick) == 1 and keys_to_pick[0].is_type(exp.DType.ARRAY):
3921            lambda_expr = exp.Lambda(
3922                this=exp.func("ARRAY_CONTAINS", keys_to_pick[0], x_dot_key),
3923                expressions=[exp.to_identifier("x")],
3924            )
3925        else:
3926            lambda_expr = exp.Lambda(
3927                this=exp.In(this=x_dot_key, expressions=keys_to_pick),
3928                expressions=[exp.to_identifier("x")],
3929            )
3930
3931        result = exp.func(
3932            "MAP_FROM_ENTRIES",
3933            exp.func("LIST_FILTER", exp.func("MAP_ENTRIES", map_arg), lambda_expr),
3934        )
3935        return self.sql(result)
def mapsize_sql(self, expression: sqlglot.expressions.array.MapSize) -> str:
3937    def mapsize_sql(self, expression: exp.MapSize) -> str:
3938        return self.func("CARDINALITY", expression.this)
@unsupported_args('update_flag')
def mapinsert_sql(self, expression: sqlglot.expressions.array.MapInsert) -> str:
3940    @unsupported_args("update_flag")
3941    def mapinsert_sql(self, expression: exp.MapInsert) -> str:
3942        map_arg = expression.this
3943        key = expression.args.get("key")
3944        value = expression.args.get("value")
3945
3946        map_type = map_arg.type
3947
3948        if value is not None:
3949            if map_type and map_type.expressions and len(map_type.expressions) > 1:
3950                # Extract the value type from MAP(key_type, value_type)
3951                value_type = map_type.expressions[1]
3952                # Cast value to match the map's value type to avoid type conflicts
3953                value = exp.cast(value, value_type)
3954            # else: polymorphic MAP case - no type parameters available, use value as-is
3955
3956        # Create a single-entry map for the new key-value pair
3957        new_entry_struct = exp.Struct(expressions=[exp.PropertyEQ(this=key, expression=value)])
3958        new_entry: exp.Expression = exp.ToMap(this=new_entry_struct)
3959
3960        # Use MAP_CONCAT to merge the original map with the new entry
3961        # This automatically handles both insert and update cases
3962        result = exp.func("MAP_CONCAT", map_arg, new_entry)
3963
3964        return self.sql(result)
def startswith_sql(self, expression: sqlglot.expressions.string.StartsWith) -> str:
3966    def startswith_sql(self, expression: exp.StartsWith) -> str:
3967        return self.func(
3968            "STARTS_WITH",
3969            _cast_to_varchar(expression.this),
3970            _cast_to_varchar(expression.expression),
3971        )
def space_sql(self, expression: sqlglot.expressions.string.Space) -> str:
3973    def space_sql(self, expression: exp.Space) -> str:
3974        # DuckDB's REPEAT requires BIGINT for the count parameter
3975        return self.sql(
3976            exp.Repeat(
3977                this=exp.Literal.string(" "),
3978                times=exp.cast(expression.this, exp.DType.BIGINT),
3979            )
3980        )
def tablefromrows_sql(self, expression: sqlglot.expressions.query.TableFromRows) -> str:
3982    def tablefromrows_sql(self, expression: exp.TableFromRows) -> str:
3983        # For GENERATOR, unwrap TABLE() - just emit the Generator (becomes RANGE)
3984        if isinstance(expression.this, exp.Generator):
3985            # Preserve alias, joins, and other table-level args
3986            table = exp.Table(
3987                this=expression.this,
3988                alias=expression.args.get("alias"),
3989                joins=expression.args.get("joins"),
3990            )
3991            return self.sql(table)
3992
3993        return super().tablefromrows_sql(expression)
def unnest_sql(self, expression: sqlglot.expressions.array.Unnest) -> str:
3995    def unnest_sql(self, expression: exp.Unnest) -> str:
3996        explode_array = expression.args.get("explode_array")
3997        if explode_array:
3998            # In BigQuery, UNNESTing a nested array leads to explosion of the top-level array & struct
3999            # This is transpiled to DDB by transforming "FROM UNNEST(...)" to "FROM (SELECT UNNEST(..., max_depth => 2))"
4000            expression.expressions.append(
4001                exp.Kwarg(this=exp.var("max_depth"), expression=exp.Literal.number(2))
4002            )
4003
4004            # If BQ's UNNEST is aliased, we transform it from a column alias to a table alias in DDB
4005            alias = expression.args.get("alias")
4006            if isinstance(alias, exp.TableAlias):
4007                expression.set("alias", None)
4008                if alias.columns:
4009                    alias = exp.TableAlias(this=seq_get(alias.columns, 0))
4010
4011            unnest_sql = super().unnest_sql(expression)
4012            select = exp.Select(expressions=[unnest_sql]).subquery(alias)
4013            return self.sql(select)
4014
4015        return super().unnest_sql(expression)
def arrayagg_sql(self, expression: sqlglot.expressions.aggregate.ArrayAgg) -> str:
4017    def arrayagg_sql(self, expression: exp.ArrayAgg) -> str:
4018        if isinstance(expression.this, exp.Limit):
4019            self.unsupported("LIMIT inside ARRAY_AGG is not supported in DuckDB")
4020
4021        return super().arrayagg_sql(expression)
def ignorenulls_sql(self, expression: sqlglot.expressions.core.IgnoreNulls) -> str:
4023    def ignorenulls_sql(self, expression: exp.IgnoreNulls) -> str:
4024        this = expression.this
4025
4026        if isinstance(this, self.IGNORE_RESPECT_NULLS_WINDOW_FUNCTIONS):
4027            # DuckDB should render IGNORE NULLS only for the general-purpose
4028            # window functions that accept it e.g. FIRST_VALUE(... IGNORE NULLS) OVER (...)
4029            return super().ignorenulls_sql(expression)
4030
4031        # For ARRAY_AGG(expr IGNORE NULLS ...), convert IGNORE NULLS to a
4032        # FILTER(WHERE expr IS NOT NULL) clause by setting nulls_excluded on
4033        # the ArrayAgg.  The existing _add_arrayagg_null_filter method will
4034        # emit the FILTER clause during arrayagg_sql / withingroup_sql.
4035        if isinstance(this, exp.ArrayAgg):
4036            this.set("nulls_excluded", True)
4037            return self.sql(this)
4038
4039        if isinstance(this, exp.First):
4040            this = exp.AnyValue(this=this.this)
4041
4042        if not isinstance(this, (exp.AnyValue, exp.ApproxQuantiles)):
4043            self.unsupported("IGNORE NULLS is not supported for non-window functions.")
4044
4045        return self.sql(this)
def split_sql(self, expression: sqlglot.expressions.string.Split) -> str:
4047    def split_sql(self, expression: exp.Split) -> str:
4048        base_func = exp.func("STR_SPLIT", expression.this, expression.expression)
4049
4050        case_expr = exp.case().else_(base_func)
4051        needs_case = False
4052
4053        if expression.args.get("null_returns_null"):
4054            case_expr = case_expr.when(expression.expression.is_(exp.null()), exp.null())
4055            needs_case = True
4056
4057        if expression.args.get("empty_delimiter_returns_whole"):
4058            # When delimiter is empty string, return input string as single array element
4059            array_with_input = exp.array(expression.this)
4060            case_expr = case_expr.when(
4061                expression.expression.eq(exp.Literal.string("")), array_with_input
4062            )
4063            needs_case = True
4064
4065        return self.sql(case_expr if needs_case else base_func)
def splitpart_sql(self, expression: sqlglot.expressions.string.SplitPart) -> str:
4067    def splitpart_sql(self, expression: exp.SplitPart) -> str:
4068        string_arg = expression.this
4069        delimiter_arg = expression.args.get("delimiter")
4070        part_index_arg = expression.args.get("part_index")
4071
4072        if delimiter_arg and part_index_arg:
4073            # Handle Snowflake's "index 0 and 1 both return first element" behavior
4074            if expression.args.get("part_index_zero_as_one"):
4075                # Convert 0 to 1 for compatibility
4076
4077                part_index_arg = exp.Paren(
4078                    this=exp.case()
4079                    .when(part_index_arg.eq(exp.Literal.number("0")), exp.Literal.number("1"))
4080                    .else_(part_index_arg)
4081                )
4082
4083            # Use Anonymous to avoid recursion
4084            base_func_expr: exp.Expr = exp.Anonymous(
4085                this="SPLIT_PART", expressions=[string_arg, delimiter_arg, part_index_arg]
4086            )
4087            needs_case_transform = False
4088            case_expr = exp.case().else_(base_func_expr)
4089
4090            if expression.args.get("empty_delimiter_returns_whole"):
4091                # When delimiter is empty string:
4092                # - Return whole string if part_index is 1 or -1
4093                # - Return empty string otherwise
4094                empty_case = exp.Paren(
4095                    this=exp.case()
4096                    .when(
4097                        exp.or_(
4098                            part_index_arg.eq(exp.Literal.number("1")),
4099                            part_index_arg.eq(exp.Literal.number("-1")),
4100                        ),
4101                        string_arg,
4102                    )
4103                    .else_(exp.Literal.string(""))
4104                )
4105
4106                case_expr = case_expr.when(delimiter_arg.eq(exp.Literal.string("")), empty_case)
4107                needs_case_transform = True
4108
4109            """
4110            Output looks something like this:
4111
4112            CASE
4113            WHEN delimiter is '' THEN
4114                (
4115                    CASE
4116                    WHEN adjusted_part_index = 1 OR adjusted_part_index = -1 THEN input
4117                    ELSE '' END
4118                )
4119            ELSE SPLIT_PART(input, delimiter, adjusted_part_index)
4120            END
4121
4122            """
4123            return self.sql(case_expr if needs_case_transform else base_func_expr)
4124
4125        return self.function_fallback_sql(expression)
def respectnulls_sql(self, expression: sqlglot.expressions.core.RespectNulls) -> str:
4127    def respectnulls_sql(self, expression: exp.RespectNulls) -> str:
4128        if isinstance(expression.this, self.IGNORE_RESPECT_NULLS_WINDOW_FUNCTIONS):
4129            # DuckDB should render RESPECT NULLS only for the general-purpose
4130            # window functions that accept it e.g. FIRST_VALUE(... RESPECT NULLS) OVER (...)
4131            return super().respectnulls_sql(expression)
4132
4133        self.unsupported("RESPECT NULLS is not supported for non-window functions.")
4134        return self.sql(expression, "this")
def arraytostring_sql(self, expression: sqlglot.expressions.array.ArrayToString) -> str:
4136    def arraytostring_sql(self, expression: exp.ArrayToString) -> str:
4137        null = expression.args.get("null")
4138
4139        if expression.args.get("null_is_empty"):
4140            x = exp.to_identifier("x")
4141            list_transform = exp.Transform(
4142                this=expression.this.copy(),
4143                expression=exp.Lambda(
4144                    this=exp.Coalesce(
4145                        this=exp.cast(x, "TEXT"), expressions=[exp.Literal.string("")]
4146                    ),
4147                    expressions=[x],
4148                ),
4149            )
4150            array_to_string = exp.ArrayToString(
4151                this=list_transform, expression=expression.expression
4152            )
4153            if expression.args.get("null_delim_is_null"):
4154                return self.sql(
4155                    exp.case()
4156                    .when(expression.expression.copy().is_(exp.null()), exp.null())
4157                    .else_(array_to_string)
4158                )
4159            return self.sql(array_to_string)
4160
4161        if null:
4162            x = exp.to_identifier("x")
4163            return self.sql(
4164                exp.ArrayToString(
4165                    this=exp.Transform(
4166                        this=expression.this,
4167                        expression=exp.Lambda(
4168                            this=exp.Coalesce(this=x, expressions=[null]),
4169                            expressions=[x],
4170                        ),
4171                    ),
4172                    expression=expression.expression,
4173                )
4174            )
4175
4176        return self.func("ARRAY_TO_STRING", expression.this, expression.expression)
def concatws_sql(self, expression: sqlglot.expressions.string.ConcatWs) -> str:
4178    def concatws_sql(self, expression: exp.ConcatWs) -> str:
4179        # DuckDB-specific: handle binary types using DPipe (||) operator
4180        separator = seq_get(expression.expressions, 0)
4181        args = expression.expressions[1:]
4182
4183        if any(_is_binary(arg) for arg in [separator, *args]):
4184            result = args[0]
4185            for arg in args[1:]:
4186                result = exp.DPipe(
4187                    this=exp.DPipe(this=result, expression=separator), expression=arg
4188                )
4189            return self.sql(result)
4190
4191        if expression.args.get("flatten") and any(
4192            isinstance(arg, exp.Array) or arg.is_type(exp.DType.ARRAY) for arg in args
4193        ):
4194            arrays = []
4195            for arg in args:
4196                if isinstance(arg, exp.Array) or arg.is_type(exp.DType.ARRAY):
4197                    arg = exp.cast(arg, "TEXT[]")
4198                else:
4199                    if (not arg.type or arg.is_type(exp.DType.UNKNOWN)) and not isinstance(
4200                        arg, exp.CONSTANTS
4201                    ):
4202                        self.unsupported(
4203                            "Cannot transpile CONCAT_WS with unknown argument types to DuckDB."
4204                        )
4205                    arg = exp.array(exp.cast(arg, exp.DType.TEXT))
4206
4207                arrays.append(arg)
4208
4209            array = (
4210                exp.ArrayConcat(this=arrays[0], expressions=arrays[1:])
4211                if len(arrays) > 1
4212                else arrays[0]
4213            )
4214            return self.func("CONCAT_WS", separator, self.func("ARRAY_TO_STRING", array, separator))
4215
4216        return super().concatws_sql(expression)
def regexpextract_sql(self, expression: sqlglot.expressions.string.RegexpExtract) -> str:
4271    def regexpextract_sql(self, expression: exp.RegexpExtract) -> str:
4272        return self._regexp_extract_sql(expression)
def regexpextractall_sql(self, expression: sqlglot.expressions.string.RegexpExtractAll) -> str:
4274    def regexpextractall_sql(self, expression: exp.RegexpExtractAll) -> str:
4275        return self._regexp_extract_sql(expression)
def regexpinstr_sql(self, expression: sqlglot.expressions.string.RegexpInstr) -> str:
4277    def regexpinstr_sql(self, expression: exp.RegexpInstr) -> str:
4278        this = expression.this
4279        pattern = expression.expression
4280        position = expression.args.get("position")
4281        orig_occ = expression.args.get("occurrence")
4282        occurrence = orig_occ or exp.Literal.number(1)
4283        option = expression.args.get("option")
4284        parameters = expression.args.get("parameters")
4285
4286        validated_flags = self._validate_regexp_flags(parameters, supported_flags="ims")
4287        # Checked before the flags are embedded, which would make the pattern non-empty
4288        is_empty = pattern.copy().eq(exp.Literal.string(""))
4289        if validated_flags:
4290            pattern = exp.Concat(expressions=[exp.Literal.string(f"(?{validated_flags})"), pattern])
4291
4292        # Handle starting position offset
4293        pos_offset: exp.Expr = exp.Literal.number(0)
4294        if position and (not position.is_int or position.to_py() > 1):
4295            this = exp.Substring(this=this, start=position)
4296            pos_offset = position - exp.Literal.number(1)
4297
4298        # Helper: LIST_SUM(LIST_TRANSFORM(list[1:end], x -> LENGTH(x)))
4299        def sum_lengths(func_name: str, end: exp.Expr) -> exp.Expr:
4300            lst = exp.Bracket(
4301                this=exp.Anonymous(this=func_name, expressions=[this, pattern]),
4302                expressions=[exp.Slice(this=exp.Literal.number(1), expression=end)],
4303                offset=1,
4304            )
4305            transform = exp.Anonymous(
4306                this="LIST_TRANSFORM",
4307                expressions=[
4308                    lst,
4309                    exp.Lambda(
4310                        this=exp.Length(this=exp.to_identifier("x")),
4311                        expressions=[exp.to_identifier("x")],
4312                    ),
4313                ],
4314            )
4315            return exp.Coalesce(
4316                this=exp.Anonymous(this="LIST_SUM", expressions=[transform]),
4317                expressions=[exp.Literal.number(0)],
4318            )
4319
4320        # Position = 1 + sum(split_lengths[1:occ]) + sum(match_lengths[1:occ-1]) + offset
4321        base_pos: exp.Expr = (
4322            exp.Literal.number(1)
4323            + sum_lengths("STRING_SPLIT_REGEX", occurrence)
4324            + sum_lengths("REGEXP_EXTRACT_ALL", occurrence - exp.Literal.number(1))
4325            + pos_offset
4326        )
4327
4328        # option=1: add match length for end position
4329        if option and option.is_int and option.to_py() == 1:
4330            match_at_occ = exp.Bracket(
4331                this=exp.Anonymous(this="REGEXP_EXTRACT_ALL", expressions=[this, pattern]),
4332                expressions=[occurrence],
4333                offset=1,
4334            )
4335            base_pos = base_pos + exp.Coalesce(
4336                this=exp.Length(this=match_at_occ), expressions=[exp.Literal.number(0)]
4337            )
4338
4339        # NULL checks for all provided arguments
4340        # .copy() is used strictly because .is_() alters the node's parent pointer, mutating the parsed AST
4341        null_args = [
4342            expression.this,
4343            expression.expression,
4344            position,
4345            orig_occ,
4346            option,
4347            parameters,
4348        ]
4349        null_checks = [arg.copy().is_(exp.Null()) for arg in null_args if arg]
4350
4351        matches = exp.Anonymous(this="REGEXP_EXTRACT_ALL", expressions=[this, pattern])
4352
4353        return self.sql(
4354            exp.case()
4355            .when(exp.or_(*null_checks), exp.Null())
4356            .when(is_empty, exp.Literal.number(0))
4357            .when(exp.Length(this=matches) < occurrence, exp.Literal.number(0))
4358            .else_(base_pos)
4359        )
@unsupported_args('culture')
def numbertostr_sql(self, expression: sqlglot.expressions.string.NumberToStr) -> str:
4361    @unsupported_args("culture")
4362    def numbertostr_sql(self, expression: exp.NumberToStr) -> str:
4363        fmt = expression.args.get("format")
4364        if fmt and fmt.is_int:
4365            return self.func("FORMAT", f"'{{:,.{fmt.name}f}}'", expression.this)
4366
4367        self.unsupported("Only integer formats are supported by NumberToStr")
4368        return self.function_fallback_sql(expression)
def autoincrementcolumnconstraint_sql(self, _) -> str:
4370    def autoincrementcolumnconstraint_sql(self, _) -> str:
4371        self.unsupported("The AUTOINCREMENT column constraint is not supported by DuckDB")
4372        return ""
def aliases_sql(self, expression: sqlglot.expressions.core.Aliases) -> str:
4374    def aliases_sql(self, expression: exp.Aliases) -> str:
4375        this = expression.this
4376        if isinstance(this, exp.Posexplode):
4377            return self.posexplode_sql(this)
4378
4379        return super().aliases_sql(expression)
def posexplode_sql(self, expression: sqlglot.expressions.array.Posexplode) -> str:
4381    def posexplode_sql(self, expression: exp.Posexplode) -> str:
4382        this = expression.this
4383        parent = expression.parent
4384
4385        # The default Spark aliases are "pos" and "col", unless specified otherwise
4386        pos, col = exp.to_identifier("pos"), exp.to_identifier("col")
4387
4388        if isinstance(parent, exp.Aliases):
4389            # Column case: SELECT POSEXPLODE(col) [AS (a, b)]
4390            pos, col = parent.expressions
4391        elif isinstance(parent, exp.Table):
4392            # Table case: SELECT * FROM POSEXPLODE(col) [AS (a, b)]
4393            alias = parent.args.get("alias")
4394            if alias:
4395                pos, col = alias.columns or [pos, col]
4396                alias.pop()
4397
4398        # Translate POSEXPLODE to UNNEST + GENERATE_SUBSCRIPTS
4399        # Note: In Spark pos is 0-indexed, but in DuckDB it's 1-indexed, so we subtract 1 from GENERATE_SUBSCRIPTS
4400        unnest_sql = self.sql(exp.Unnest(expressions=[this], alias=col))
4401        gen_subscripts = self.sql(
4402            exp.Alias(
4403                this=exp.Anonymous(
4404                    this="GENERATE_SUBSCRIPTS", expressions=[this, exp.Literal.number(1)]
4405                )
4406                - exp.Literal.number(1),
4407                alias=pos,
4408            )
4409        )
4410
4411        posexplode_sql = self.format_args(gen_subscripts, unnest_sql)
4412
4413        if isinstance(parent, exp.From) or (parent and isinstance(parent.parent, exp.From)):
4414            # SELECT * FROM POSEXPLODE(col) -> SELECT * FROM (SELECT GENERATE_SUBSCRIPTS(...), UNNEST(...))
4415            return self.sql(exp.Subquery(this=exp.Select(expressions=[posexplode_sql])))
4416
4417        return posexplode_sql
def addmonths_sql(self, expression: sqlglot.expressions.temporal.AddMonths) -> str:
4419    def addmonths_sql(self, expression: exp.AddMonths) -> str:
4420        """
4421        Handles three key issues:
4422        1. Float/decimal months: e.g., Snowflake rounds, whereas DuckDB INTERVAL requires integers
4423        2. End-of-month preservation: If input is last day of month, result is last day of result month
4424        3. Type preservation: Maintains DATE/TIMESTAMPTZ types (DuckDB defaults to TIMESTAMP)
4425        """
4426        from sqlglot.optimizer.annotate_types import annotate_types
4427
4428        this = expression.this
4429        if not this.type:
4430            this = annotate_types(this, dialect=self.dialect)
4431
4432        if this.is_type(*exp.DataType.TEXT_TYPES):
4433            this = exp.Cast(this=this, to=exp.DataType(this=exp.DType.TIMESTAMP))
4434
4435        # Detect float/decimal months to apply rounding (Snowflake behavior)
4436        # DuckDB INTERVAL syntax doesn't support non-integer expressions, so use TO_MONTHS
4437        months_expr = expression.expression
4438        if not months_expr.type:
4439            months_expr = annotate_types(months_expr, dialect=self.dialect)
4440
4441        # Build interval or to_months expression based on type
4442        # Float/decimal case: Round and use TO_MONTHS(CAST(ROUND(value) AS INT))
4443        interval_or_to_months = (
4444            exp.func("TO_MONTHS", exp.cast(exp.func("ROUND", months_expr), "INT"))
4445            if months_expr.is_type(
4446                exp.DType.FLOAT,
4447                exp.DType.DOUBLE,
4448                exp.DType.DECIMAL,
4449            )
4450            # Integer case: standard INTERVAL N MONTH syntax
4451            else exp.Interval(this=months_expr, unit=exp.var("MONTH"))
4452        )
4453
4454        date_add_expr = exp.Add(this=this, expression=interval_or_to_months)
4455
4456        # Apply end-of-month preservation if Snowflake flag is set
4457        # CASE WHEN LAST_DAY(date) = date THEN LAST_DAY(result) ELSE result END
4458        preserve_eom = expression.args.get("preserve_end_of_month")
4459        result_expr = (
4460            exp.case()
4461            .when(
4462                exp.EQ(this=exp.func("LAST_DAY", this), expression=this),
4463                exp.func("LAST_DAY", date_add_expr),
4464            )
4465            .else_(date_add_expr)
4466            if preserve_eom
4467            else date_add_expr
4468        )
4469
4470        # DuckDB's DATE_ADD function returns TIMESTAMP/DATETIME by default, even when the input is DATE
4471        # To match for example Snowflake's ADD_MONTHS behavior (which preserves the input type)
4472        # We need to cast the result back to the original type when the input is DATE or TIMESTAMPTZ
4473        # Example: ADD_MONTHS('2023-01-31'::date, 1) should return DATE, not TIMESTAMP
4474        if this.is_type(exp.DType.DATE, exp.DType.TIMESTAMPTZ):
4475            return self.sql(exp.Cast(this=result_expr, to=this.type))
4476        return self.sql(result_expr)

Handles three key issues:

  1. Float/decimal months: e.g., Snowflake rounds, whereas DuckDB INTERVAL requires integers
  2. End-of-month preservation: If input is last day of month, result is last day of result month
  3. Type preservation: Maintains DATE/TIMESTAMPTZ types (DuckDB defaults to TIMESTAMP)
def format_sql(self, expression: sqlglot.expressions.string.Format) -> str:
4478    def format_sql(self, expression: exp.Format) -> str:
4479        if expression.name.lower() == "%s" and len(expression.expressions) == 1:
4480            return self.func("FORMAT", "'{}'", expression.expressions[0])
4481
4482        return self.function_fallback_sql(expression)
def hexstring_sql( self, expression: sqlglot.expressions.query.HexString, binary_function_repr: str | None = None) -> str:
4484    def hexstring_sql(
4485        self, expression: exp.HexString, binary_function_repr: str | None = None
4486    ) -> str:
4487        # UNHEX('FF') correctly produces blob \xFF in DuckDB
4488        return super().hexstring_sql(expression, binary_function_repr="UNHEX")
def datetrunc_sql(self, expression: sqlglot.expressions.temporal.DateTrunc) -> str:
4490    def datetrunc_sql(self, expression: exp.DateTrunc) -> str:
4491        unit = expression.args.get("unit")
4492        date = expression.this
4493
4494        week_start = _week_trunc_start_dow(unit)
4495        unit = unit_to_str(expression)
4496
4497        if week_start:
4498            result = self.sql(
4499                _build_week_trunc_expression(date, week_start, preserve_start_day=True)
4500            )
4501        else:
4502            result = self.func("DATE_TRUNC", unit, date)
4503
4504        if (
4505            expression.args.get("input_type_preserved")
4506            and date.is_type(*exp.DataType.TEMPORAL_TYPES)
4507            and not (is_date_unit(unit) and date.is_type(exp.DType.DATE))
4508        ):
4509            return self.sql(exp.Cast(this=result, to=date.type))
4510
4511        return result
def datetimetrunc_sql(self, expression: sqlglot.expressions.temporal.DatetimeTrunc) -> str:
4513    def datetimetrunc_sql(self, expression: exp.DatetimeTrunc) -> str:
4514        this = exp.cast(expression.this, exp.DType.DATETIME)
4515        week_start = _week_trunc_start_dow(expression.args.get("unit"))
4516        if week_start:
4517            return self.sql(
4518                _build_week_trunc_expression(
4519                    this, week_start, preserve_start_day=True, cast_to_date=False
4520                )
4521            )
4522
4523        return self.func("DATE_TRUNC", unit_to_str(expression), this)
def timestamptrunc_sql(self, expression: sqlglot.expressions.temporal.TimestampTrunc) -> str:
4525    def timestamptrunc_sql(self, expression: exp.TimestampTrunc) -> str:
4526        zone = expression.args.get("zone")
4527        timestamp = expression.this
4528        week_start = _week_trunc_start_dow(expression.args.get("unit"))
4529
4530        # The week start emulation below is exact, so avoid weekstart_unit_to_str's degrade warning
4531        unit = unit_to_str(expression) if week_start else weekstart_unit_to_str(self, expression)
4532        date_unit = is_date_unit(unit) or bool(week_start)
4533
4534        def _trunc_expr(this: exp.Expr) -> exp.Expr:
4535            if week_start:
4536                return _build_week_trunc_expression(
4537                    this, week_start, preserve_start_day=True, cast_to_date=False
4538                )
4539            return exp.func("DATE_TRUNC", unit, this)
4540
4541        if date_unit and zone:
4542            # BigQuery's TIMESTAMP_TRUNC with timezone truncates in the target timezone and returns as UTC.
4543            # Double AT TIME ZONE needed for BigQuery compatibility:
4544            # 1. First AT TIME ZONE: ensures truncation happens in the target timezone
4545            # 2. Second AT TIME ZONE: converts the DATE result back to TIMESTAMPTZ (preserving time component)
4546            timestamp = exp.AtTimeZone(this=timestamp, zone=zone)
4547            trunced = _trunc_expr(timestamp)
4548            if isinstance(trunced, exp.DateAdd):
4549                # Parenthesize so the trailing AT TIME ZONE binds to the whole shifted expression
4550                trunced = exp.Paren(this=trunced)
4551            return self.sql(exp.AtTimeZone(this=trunced, zone=zone))
4552
4553        result = self.sql(_trunc_expr(timestamp))
4554        if expression.args.get("input_type_preserved"):
4555            if timestamp.type and timestamp.is_type(exp.DType.TIME, exp.DType.TIMETZ):
4556                dummy_date = exp.Cast(
4557                    this=exp.Literal.string("1970-01-01"),
4558                    to=exp.DataType(this=exp.DType.DATE),
4559                )
4560                date_time = exp.Add(this=dummy_date, expression=timestamp)
4561                result = self.func("DATE_TRUNC", unit, date_time)
4562                return self.sql(exp.Cast(this=result, to=timestamp.type))
4563
4564            if timestamp.is_type(*exp.DataType.TEMPORAL_TYPES) and not (
4565                date_unit and timestamp.is_type(exp.DType.DATE)
4566            ):
4567                return self.sql(exp.Cast(this=result, to=timestamp.type))
4568
4569        return result
def trim_sql(self, expression: sqlglot.expressions.string.Trim) -> str:
4571    def trim_sql(self, expression: exp.Trim) -> str:
4572        expression.this.replace(_cast_to_varchar(expression.this))
4573        if expression.expression:
4574            expression.expression.replace(_cast_to_varchar(expression.expression))
4575
4576        result_sql = super().trim_sql(expression)
4577        return _gen_with_cast_to_blob(self, expression, result_sql)
def round_sql(self, expression: sqlglot.expressions.math.Round) -> str:
4579    def round_sql(self, expression: exp.Round) -> str:
4580        this = expression.this
4581        decimals = expression.args.get("decimals")
4582        truncate = expression.args.get("truncate")
4583
4584        # DuckDB requires the scale (decimals) argument to be an INT
4585        # Some dialects (e.g., Snowflake) allow non-integer scales and cast to an integer internally
4586        if decimals is not None and expression.args.get("casts_non_integer_decimals"):
4587            if not (decimals.is_int or decimals.is_type(*exp.DataType.INTEGER_TYPES)):
4588                decimals = exp.cast(decimals, exp.DType.INT)
4589
4590        func = "ROUND"
4591        if truncate:
4592            # BigQuery uses ROUND_HALF_EVEN; Snowflake uses HALF_TO_EVEN
4593            if truncate.this in ("ROUND_HALF_EVEN", "HALF_TO_EVEN"):
4594                func = "ROUND_EVEN"
4595                truncate = None
4596            # BigQuery uses ROUND_HALF_AWAY_FROM_ZERO; Snowflake uses HALF_AWAY_FROM_ZERO
4597            elif truncate.this in ("ROUND_HALF_AWAY_FROM_ZERO", "HALF_AWAY_FROM_ZERO"):
4598                truncate = None
4599
4600        return self.func(func, this, decimals, truncate)
def trycast_sql(self, expression: sqlglot.expressions.functions.TryCast) -> str:
4602    def trycast_sql(self, expression: exp.TryCast) -> str:
4603        to = expression.to
4604        to_type = to.this
4605        src = expression.this
4606
4607        if (
4608            expression.args.get("null_on_text_overflow")
4609            and to_type in exp.DataType.TEXT_TYPES
4610            and to.expressions
4611        ):
4612            return self.sql(
4613                exp.case()
4614                .when(
4615                    exp.LTE(this=exp.func("LENGTH", src), expression=to.expressions[0].this),
4616                    exp.cast(src, "TEXT"),
4617                )
4618                .else_(exp.Null())
4619            )
4620        elif to_type == exp.DType.DATE and expression.args.get("probe_date_format"):
4621            slash_strptime = exp.cast(
4622                exp.func("TRY_STRPTIME", src, exp.Literal.string(self._TRYCAST_DATE_SLASH_FMT)),
4623                "DATE",
4624            )
4625            mon_strptime = exp.cast(
4626                exp.func("TRY_STRPTIME", src, exp.Literal.string(self._TRYCAST_DATE_MON_FMT)),
4627                "DATE",
4628            )
4629            return self.sql(
4630                exp.case()
4631                .when(exp.func("CONTAINS", src, exp.Literal.string("/")), slash_strptime)
4632                .when(
4633                    exp.RegexpLike(this=src, expression=exp.Literal.string("[A-Za-z]")),
4634                    mon_strptime,
4635                )
4636                .else_(exp.TryCast(this=src, to=to))
4637            )
4638        elif (
4639            isinstance(to_type, exp.Interval)
4640            and (unit := to_type.unit)
4641            and expression.args.get("requires_string")
4642        ):
4643            interval_type = exp.DataType.build("INTERVAL")
4644            if isinstance(unit, exp.IntervalSpan):
4645                self.unsupported(
4646                    "TRY_CAST to INTERVAL with span (e.g. HOUR TO MINUTE) is not supported in DuckDB"
4647                )
4648                return self.sql(exp.TryCast(this=src, to=interval_type))
4649            return self.sql(
4650                exp.TryCast(
4651                    this=exp.DPipe(this=src, expression=exp.Literal.string(f" {unit.name}")),
4652                    to=interval_type,
4653                )
4654            )
4655
4656        return super().trycast_sql(expression)
def strtok_sql(self, expression: sqlglot.expressions.string.Strtok) -> str:
4658    def strtok_sql(self, expression: exp.Strtok) -> str:
4659        string_arg = expression.this
4660        delimiter_arg = expression.args.get("delimiter")
4661        part_index_arg = expression.args.get("part_index")
4662
4663        if delimiter_arg and part_index_arg:
4664            # Escape regex chars and build character class at runtime using REGEXP_REPLACE
4665            escaped_delimiter = exp.Anonymous(
4666                this="REGEXP_REPLACE",
4667                expressions=[
4668                    delimiter_arg,
4669                    exp.Literal.string(
4670                        r"([\[\]^.\-*+?(){}|$\\])"
4671                    ),  # Escape problematic regex chars
4672                    exp.Literal.string(
4673                        r"\\\1"
4674                    ),  # Replace with escaped version using $1 backreference
4675                    exp.Literal.string("g"),  # Global flag
4676                ],
4677            )
4678            # CASE WHEN delimiter = '' THEN '' ELSE CONCAT('[', escaped_delimiter, ']') END
4679            regex_pattern = (
4680                exp.case()
4681                .when(delimiter_arg.eq(exp.Literal.string("")), exp.Literal.string(""))
4682                .else_(
4683                    exp.func(
4684                        "CONCAT",
4685                        exp.Literal.string("["),
4686                        escaped_delimiter,
4687                        exp.Literal.string("]"),
4688                    )
4689                )
4690            )
4691
4692            # STRTOK skips empty strings, so we need to filter them out
4693            # LIST_FILTER(REGEXP_SPLIT_TO_ARRAY(string, pattern), x -> x != '')[index]
4694            split_array = exp.func("REGEXP_SPLIT_TO_ARRAY", string_arg, regex_pattern)
4695            x = exp.to_identifier("x")
4696            is_empty = x.eq(exp.Literal.string(""))
4697            filtered_array = exp.func(
4698                "LIST_FILTER",
4699                split_array,
4700                exp.Lambda(this=exp.not_(is_empty.copy()), expressions=[x.copy()]),
4701            )
4702            base_func = exp.Bracket(
4703                this=filtered_array,
4704                expressions=[part_index_arg],
4705                offset=1,
4706            )
4707
4708            # Use template with the built regex pattern
4709            result = exp.replace_placeholders(
4710                self.STRTOK_TEMPLATE.copy(),
4711                string=string_arg,
4712                delimiter=delimiter_arg,
4713                part_index=part_index_arg,
4714                base_func=base_func,
4715            )
4716
4717            return self.sql(result)
4718
4719        return self.function_fallback_sql(expression)
def strtoktoarray_sql(self, expression: sqlglot.expressions.array.StrtokToArray) -> str:
4721    def strtoktoarray_sql(self, expression: exp.StrtokToArray) -> str:
4722        string_arg = expression.this
4723        delimiter_arg = expression.args.get("expression") or exp.Literal.string(" ")
4724
4725        escaped = exp.RegexpReplace(
4726            this=delimiter_arg.copy(),
4727            expression=exp.Literal.string(r"([\[\]^.\-*+?(){}|$\\])"),
4728            replacement=exp.Literal.string(r"\\\1"),
4729            modifiers=exp.Literal.string("g"),
4730        )
4731        return self.sql(
4732            exp.replace_placeholders(
4733                self.STRTOK_TO_ARRAY_TEMPLATE.copy(),
4734                string=string_arg,
4735                delimiter=delimiter_arg,
4736                escaped=escaped,
4737            )
4738        )
def approxquantile_sql(self, expression: sqlglot.expressions.aggregate.ApproxQuantile) -> str:
4740    def approxquantile_sql(self, expression: exp.ApproxQuantile) -> str:
4741        result = self.func("APPROX_QUANTILE", expression.this, expression.args.get("quantile"))
4742
4743        # DuckDB returns integers for APPROX_QUANTILE, cast to DOUBLE if the expected type is a real type
4744        if expression.is_type(*exp.DataType.REAL_TYPES):
4745            result = f"CAST({result} AS DOUBLE)"
4746
4747        return result
def approxquantiles_sql(self, expression: sqlglot.expressions.aggregate.ApproxQuantiles) -> str:
4749    def approxquantiles_sql(self, expression: exp.ApproxQuantiles) -> str:
4750        """
4751        BigQuery's APPROX_QUANTILES(expr, n) returns an array of n+1 approximate quantile values
4752        dividing the input distribution into n equal-sized buckets.
4753
4754        Both BigQuery and DuckDB use approximate algorithms for quantile estimation, but BigQuery
4755        does not document the specific algorithm used so results may differ. DuckDB does not
4756        support RESPECT NULLS.
4757        """
4758        this = expression.this
4759        if isinstance(this, exp.Distinct):
4760            # APPROX_QUANTILES requires 2 args and DISTINCT node grabs both
4761            if len(this.expressions) < 2:
4762                self.unsupported("APPROX_QUANTILES requires a bucket count argument")
4763                return self.function_fallback_sql(expression)
4764            num_quantiles_expr = this.expressions[1].pop()
4765        else:
4766            num_quantiles_expr = expression.expression
4767
4768        if not isinstance(num_quantiles_expr, exp.Literal) or not num_quantiles_expr.is_int:
4769            self.unsupported("APPROX_QUANTILES bucket count must be a positive integer")
4770            return self.function_fallback_sql(expression)
4771
4772        num_quantiles = t.cast(int, num_quantiles_expr.to_py())
4773        if num_quantiles <= 0:
4774            self.unsupported("APPROX_QUANTILES bucket count must be a positive integer")
4775            return self.function_fallback_sql(expression)
4776
4777        quantiles = [
4778            exp.Literal.number(Decimal(i) / Decimal(num_quantiles))
4779            for i in range(num_quantiles + 1)
4780        ]
4781
4782        return self.sql(exp.ApproxQuantile(this=this, quantile=exp.Array(expressions=quantiles)))

BigQuery's APPROX_QUANTILES(expr, n) returns an array of n+1 approximate quantile values dividing the input distribution into n equal-sized buckets.

Both BigQuery and DuckDB use approximate algorithms for quantile estimation, but BigQuery does not document the specific algorithm used so results may differ. DuckDB does not support RESPECT NULLS.

def jsonextractscalar_sql(self, expression: sqlglot.expressions.json.JSONExtractScalar) -> str:
4784    def jsonextractscalar_sql(self, expression: exp.JSONExtractScalar) -> str:
4785        if expression.args.get("scalar_only"):
4786            json_value = exp.JSONExtractScalar(
4787                this=rename_func("JSON_VALUE")(self, expression), expression="'$'"
4788            )
4789
4790            # `->>` binds looser than most operators, so the wrap logic needs the parent
4791            json_value.parent = expression.parent
4792            expression = json_value
4793
4794        return _arrow_json_extract_sql(self, expression)
def bitwisenot_sql(self, expression: sqlglot.expressions.core.BitwiseNot) -> str:
4796    def bitwisenot_sql(self, expression: exp.BitwiseNot) -> str:
4797        this = expression.this
4798
4799        if _is_binary(this):
4800            expression.type = exp.DType.BINARY.into_expr()
4801
4802        arg = _cast_to_bit(this)
4803
4804        if isinstance(this, exp.Neg):
4805            arg = exp.Paren(this=arg)
4806
4807        expression.set("this", arg)
4808
4809        result_sql = f"~{self.sql(expression, 'this')}"
4810
4811        return _gen_with_cast_to_blob(self, expression, result_sql)
def window_sql(self, expression: sqlglot.expressions.query.Window) -> str:
4813    def window_sql(self, expression: exp.Window) -> str:
4814        this = expression.this
4815        if isinstance(this, exp.Corr) or (
4816            isinstance(this, exp.Filter) and isinstance(this.this, exp.Corr)
4817        ):
4818            return self._corr_sql(expression)
4819
4820        return super().window_sql(expression)
def filter_sql(self, expression: sqlglot.expressions.core.Filter) -> str:
4822    def filter_sql(self, expression: exp.Filter) -> str:
4823        if isinstance(expression.this, exp.Corr):
4824            return self._corr_sql(expression)
4825
4826        return super().filter_sql(expression)
def uuid_sql(self, expression: sqlglot.expressions.functions.Uuid) -> str:
4845    def uuid_sql(self, expression: exp.Uuid) -> str:
4846        namespace = expression.this
4847        name = expression.args.get("name")
4848
4849        # UUID v5 (namespace + name) - Emulate using SHA1
4850        if namespace and name:
4851            result = exp.replace_placeholders(
4852                self.UUID_V5_TEMPLATE.copy(),
4853                namespace=namespace,
4854                name=name,
4855            )
4856            return self.sql(result)
4857
4858        return super().uuid_sql(expression)
Inherited Members
sqlglot.generator.Generator
Generator
NULL_ORDERING_SUPPORTED
WINDOW_FUNCS_WITH_NULL_ORDERING
LOCKING_READS_SUPPORTED
EXCEPT_INTERSECT_SUPPORT_ALL_CLAUSE
WRAP_DERIVED_VALUES
CREATE_FUNCTION_RETURN_AS
MATCHED_BY_SOURCE
SUPPORTS_MERGE_WHERE
SINGLE_STRING_INTERVAL
INTERVAL_ALLOWS_PLURAL_FORM
AUTO_REFRESH_BARE_INTERVALS
LIMIT_ONLY_LITERALS
GROUPINGS_SEP
SUPPORTS_GROUPING_SETS_AS_SUFFIX
INDEX_ON
INOUT_SEPARATOR
DIRECTED_JOINS
QUERY_HINT_SEP
IS_BOOL_ALLOWED
DUPLICATE_KEY_UPDATE_WITH_SET
LIMIT_IS_TOP
RETURNING_END
EXTRACT_ALLOWS_QUOTES
TZ_TO_WITH_TIME_ZONE
VALUES_AS_TABLE
ALTER_TABLE_INCLUDE_COLUMN_KEYWORD
UNNEST_WITH_ORDINALITY
COMPUTED_COLUMN_WITH_TYPE
SUPPORTS_TABLE_COPY
TABLESAMPLE_REQUIRES_PARENS
TABLESAMPLE_SIZE_IS_ROWS
TABLESAMPLE_WITH_METHOD
COLLATE_IS_FUNC
DATA_TYPE_SPECIFIERS_ALLOWED
ENSURE_BOOLS
CTE_RECURSIVE_KEYWORD_REQUIRED
SUPPORTS_SINGLE_ARG_CONCAT
SUPPORTS_TABLE_ALIAS_COLUMNS
SUPPORTS_NAMED_CTE_COLUMNS
UNPIVOT_ALIASES_ARE_IDENTIFIERS
PIVOT_ALIAS_WITH_AS
INSERT_OVERWRITE
SUPPORTS_SELECT_INTO
SUPPORTS_UNLOGGED_TABLES
SUPPORTS_MODIFY_COLUMN
SUPPORTS_CHANGE_COLUMN
SUPPORTS_ALTER_COLUMN_NULLABILITY
SUPPORTS_ALTER_COLUMN_IF_EXISTS
LIKE_PROPERTY_INSIDE_SCHEMA
JSON_TYPE_REQUIRED_FOR_EXTRACTION
JSON_PATH_SINGLE_QUOTE_ESCAPE
JSON_PATH_KEY_QUOTED_FORCES_BRACKETS
SET_OP_MODIFIERS
SET_OP_LIMITS
SET_OP_PARENTHESIZED_OPERANDS
COPY_PARAMS_ARE_WRAPPED
COPY_PARAMS_EQ_REQUIRED
TRY_SUPPORTED
SUPPORTS_UESCAPE
UNICODE_SUBSTITUTE
HEX_FUNC
BYTE_STRING_FUNCTION
WITH_PROPERTIES_PREFIX
QUOTE_JSON_PATH
SUPPORTS_EXPLODING_PROJECTIONS
ARRAY_CONCAT_IS_VAR_LEN
SUPPORTS_CONVERT_TIMEZONE
SUPPORTS_MEDIAN
SUPPORTS_UNIX_SECONDS
ALTER_SET_WRAPPED
PARSE_JSON_NAME
ARRAY_SIZE_NAME
ALTER_SET_TYPE
SUPPORTS_BETWEEN_FLAGS
MATCH_AGAINST_TABLE_PREFIX
DECLARE_DEFAULT_ASSIGNMENT
UPDATE_STATEMENT_SUPPORTS_FROM
STAR_EXCLUDE_REQUIRES_DERIVED_TABLE
UNSUPPORTED_TYPES
TIME_PART_SINGULARS
TOKEN_MAPPING
EXPRESSION_PRECEDES_PROPERTIES_CREATABLES
WITH_SEPARATED_COMMENTS
EXCLUDE_COMMENTS
PARAMETERIZABLE_TEXT_TYPES
EXPRESSIONS_WITHOUT_NESTED_CTES
RESPECT_IGNORE_NULLS_UNSUPPORTED_EXPRESSIONS
MOD_OPERATOR
MOD_PAREN_PARENT_TYPES
ALTER_PARTITION_OPTIONS
SAFE_JSON_PATH_KEY_RE
SENTINEL_LINE_BREAK
pretty
identify
normalize
pad
unsupported_level
max_unsupported
leading_comma
max_text_width
comments
dialect
normalize_functions
unsupported_messages
generate
preprocess
unsupported
sep
seg
sanitize_comment
maybe_comment
wrap
no_identify
normalize_func
indent
sql
uncache_sql
cache_sql
characterset_sql
column_parts
column_sql
pseudocolumn_sql
columnposition_sql
columndef_sql
columnconstraint_sql
computedcolumnconstraint_sql
compresscolumnconstraint_sql
generatedasidentitycolumnconstraint_sql
generatedasrowcolumnconstraint_sql
periodforsystemtimeconstraint_sql
notnullcolumnconstraint_sql
primarykeycolumnconstraint_sql
uniquecolumnconstraint_sql
inoutcolumnconstraint_sql
createable_sql
create_sql
sequenceproperties_sql
triggerproperties_sql
triggerreferencing_sql
triggerevent_sql
clone_sql
describe_sql
heredoc_sql
prepend_ctes
with_sql
cte_sql
tablealias_sql
bitstring_sql
unicodestring_sql
rawstring_sql
datatypeparam_sql
datatype_param_bound_limiter
directory_sql
delete_sql
drop_sql
set_operation
set_operations
fetch_sql
limitoptions_sql
hint_sql
indexparameters_sql
index_sql
dynamicidentifier_sql
identifier_sql
lowerhex_sql
inputoutputformat_sql
national_sql
partition_sql
properties_sql
root_properties
properties
with_properties
locate_properties
property_name
property_sql
uuidproperty_sql
likeproperty_sql
fallbackproperty_sql
journalproperty_sql
freespaceproperty_sql
checksumproperty_sql
mergeblockratioproperty_sql
moduleproperty_sql
datablocksizeproperty_sql
blockcompressionproperty_sql
isolatedloadingproperty_sql
partitionboundspec_sql
partitionedofproperty_sql
lockingproperty_sql
withdataproperty_sql
withsystemversioningproperty_sql
insert_sql
introducer_sql
kill_sql
pseudotype_sql
objectidentifier_sql
onconflict_sql
returning_sql
rowformatdelimitedproperty_sql
withtablehint_sql
indextablehint_sql
historicaldata_sql
table_parts
table_sql
pivot_sql
version_sql
tuple_sql
update_sql
values_sql
var_sql
into_sql
from_sql
groupingsets_sql
rollup_sql
rollupindex_sql
rollupproperty_sql
cube_sql
group_sql
having_sql
connect_sql
prior_sql
lateral_op
lateral_sql
limit_sql
offset_sql
setitem_sql
set_sql
queryband_sql
pragma_sql
lock_sql
literal_sql
escape_str
loaddata_sql
null_sql
boolean_sql
booland_sql
boolor_sql
order_sql
withfill_sql
cluster_sql
clusterproperty_sql
distribute_sql
sort_sql
ordered_sql
matchrecognizemeasure_sql
matchrecognize_sql
query_modifiers
options_modifier
forclause_sql
queryoption_sql
offset_limit_modifiers
select_sql
schema_sql
schema_columns_sql
star_sql
parameter_sql
sessionparameter_sql
placeholder_sql
subquery_sql
qualify_sql
prewhere_sql
where_sql
partition_by_sql
windowspec_sql
between_sql
bracket_offset_expressions
all_sql
any_sql
exists_sql
case_sql
constraint_sql
nextvaluefor_sql
convert_concat_args
concat_sql
check_sql
foreignkey_sql
primarykey_sql
timeserieskey_sql
if_sql
matchagainst_sql
jsonkeyvalue_sql
jsonpath_sql
json_path_part
formatjson_sql
formatphrase_sql
jsonarray_sql
jsonarrayagg_sql
jsoncolumndef_sql
jsonschema_sql
jsontable_sql
openjsoncolumndef_sql
openjson_sql
in_unnest_op
interval_sql
return_sql
reference_sql
anonymous_sql
paren_sql
neg_sql
not_sql
alias_sql
pivotalias_sql
atindex_sql
attimezone_sql
fromtimezone_sql
fromiso8601date_sql
fromiso8601timestamp_sql
fromiso8601timestampnanos_sql
add_sql
and_sql
or_sql
xor_sql
connector_sql
bitwiseand_sql
bitwiseleftshift_sql
bitwiseor_sql
bitwiserightshift_sql
cast_sql
command_sql
comment_sql
mergetreettlaction_sql
mergetreettl_sql
transaction_sql
commit_sql
rollback_sql
altercolumn_sql
modifycolumn_sql
alterindex_sql
alterdiststyle_sql
altersortkey_sql
alterrename_sql
renamecolumn_sql
alterset_sql
alter_sql
altersession_sql
add_column_sql
droppartition_sql
dropprimarykey_sql
addconstraint_sql
addpartition_sql
distinct_sql
havingmax_sql
intdiv_sql
dpipe_sql
div_sql
safedivide_sql
overlaps_sql
distance_sql
distancend_sql
dot_sql
eq_sql
propertyeq_sql
escape_sql
glob_sql
gt_sql
gte_sql
is_sql
like_sql
ilike_sql
match_sql
similarto_sql
lt_sql
lte_sql
mod_sql
mul_sql
neq_sql
nullsafeeq_sql
nullsafeneq_sql
sub_sql
jsoncast_sql
try_sql
log_sql
use_sql
binary
ceil_floor
function_fallback_sql
function_name
func
format_args
too_wide
format_time
expressions
op_expressions
naked_property
tag_sql
token_sql
userdefinedfunction_sql
macrooverloads_sql
macrooverload_sql
joinhint_sql
kwarg_sql
when_sql
whens_sql
merge_sql
tochar_sql
dictproperty_sql
dictrange_sql
dictsubproperty_sql
duplicatekeyproperty_sql
uniquekeyproperty_sql
distributedbyproperty_sql
oncluster_sql
clusteredbyproperty_sql
anyvalue_sql
querytransform_sql
indexconstraintoption_sql
checkcolumnconstraint_sql
indexcolumnconstraint_sql
nvl2_sql
nthvalue_sql
comprehension_sql
columnprefix_sql
opclass_sql
predict_sql
generateembedding_sql
generatetext_sql
generatetable_sql
generatebool_sql
generateint_sql
generatedouble_sql
mltranslate_sql
mlforecast_sql
aiforecast_sql
featuresattime_sql
vectorsearch_sql
forin_sql
refresh_sql
toarray_sql
tsordstotimestamp_sql
tsordstodatetime_sql
tsordstodate_sql
unixdate_sql
lastday_sql
dateadd_sql
arrayinsert_sql
arrayany_sql
struct_sql
partitionrange_sql
truncatetable_sql
convert_sql
copyparameter_sql
credentials_sql
copy_sql
semicolon_sql
datadeletionproperty_sql
maskingpolicycolumnconstraint_sql
gapfill_sql
scope_resolution
scoperesolution_sql
changes_sql
summarize_sql
explodinggenerateseries_sql
converttimezone_sql
json_sql
jsonvalue_sql
skipjsoncolumn_sql
conditionalinsert_sql
multitableinserts_sql
oncondition_sql
jsonextractquote_sql
jsonexists_sql
slice_sql
apply_sql
grant_sql
revoke_sql
grantprivilege_sql
grantprincipal_sql
columns_sql
overlay_sql
todouble_sql
string_sql
median_sql
overflowtruncatebehavior_sql
unixseconds_sql
arraysize_sql
attach_sql
detach_sql
attachoption_sql
watermarkcolumnconstraint_sql
encodeproperty_sql
includeproperty_sql
xmlelement_sql
xmlkeyvalueoption_sql
partitionbyrangeproperty_sql
partitionbyrangepropertydynamic_sql
unpivotcolumns_sql
analyzesample_sql
analyzestatistics_sql
analyzehistogram_sql
analyzedelete_sql
analyzelistchainedrows_sql
analyzevalidate_sql
analyze_sql
xmltable_sql
xmlnamespace_sql
export_sql
declare_sql
declareitem_sql
recursivewithsearch_sql
parameterizedagg_sql
anonymousaggfunc_sql
combinedaggfunc_sql
combinedparameterizedagg_sql
liststage_sql
get_put_sql
translatecharacters_sql
decodecase_sql
semanticview_sql
getextract_sql
datefromunixdate_sql
buildproperty_sql
refreshtriggerproperty_sql
modelattribute_sql
directorystage_sql
initcap_sql
localtime_sql
localtimestamp_sql
weekstart_name
weekstart_sql
weightstring_sql
block_sql
functionspecification_sql
storedprocedure_sql
ifblock_sql
casestatement_sql
whileblock_sql
loopblock_sql
repeatblock_sql
leave_sql
iterate_sql
execute_sql
executesql_sql
altermodifysqlsecurity_sql
usingproperty_sql
renameindex_sql