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

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:
2451    def bitmapconstructagg_sql(self, expression: exp.BitmapConstructAgg) -> str:
2452        """
2453        Transpile Snowflake's BITMAP_CONSTRUCT_AGG to DuckDB equivalent.
2454        Uses a pre-parsed template with placeholders replaced by expression nodes.
2455
2456        Snowflake bitmap format:
2457        - Small (< 5 unique values): 2-byte count (big-endian) + values (little-endian) + padding to 10 bytes
2458        - Large (>= 5 unique values): 10-byte header (0x08 + 9 zeros) + values (little-endian)
2459        """
2460        arg = expression.this
2461        return (
2462            f"({self.sql(exp.replace_placeholders(self.BITMAP_CONSTRUCT_AGG_TEMPLATE, arg=arg))})"
2463        )

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:
2465    def getignorecase_sql(self, expression: exp.GetIgnoreCase) -> str:
2466        self.unsupported("DuckDB does not support the GET_IGNORE_CASE() function")
2467        return self.function_fallback_sql(expression)
def compress_sql(self, expression: sqlglot.expressions.string.Compress) -> str:
2469    def compress_sql(self, expression: exp.Compress) -> str:
2470        self.unsupported("DuckDB does not support the COMPRESS() function")
2471        return self.function_fallback_sql(expression)
def encrypt_sql(self, expression: sqlglot.expressions.string.Encrypt) -> str:
2473    def encrypt_sql(self, expression: exp.Encrypt) -> str:
2474        self.unsupported("ENCRYPT is not supported in DuckDB")
2475        return self.function_fallback_sql(expression)
def decrypt_sql(self, expression: sqlglot.expressions.string.Decrypt) -> str:
2477    def decrypt_sql(self, expression: exp.Decrypt) -> str:
2478        func_name = "TRY_DECRYPT" if expression.args.get("safe") else "DECRYPT"
2479        self.unsupported(f"{func_name} is not supported in DuckDB")
2480        return self.function_fallback_sql(expression)
def decryptraw_sql(self, expression: sqlglot.expressions.string.DecryptRaw) -> str:
2482    def decryptraw_sql(self, expression: exp.DecryptRaw) -> str:
2483        func_name = "TRY_DECRYPT_RAW" if expression.args.get("safe") else "DECRYPT_RAW"
2484        self.unsupported(f"{func_name} is not supported in DuckDB")
2485        return self.function_fallback_sql(expression)
def encryptraw_sql(self, expression: sqlglot.expressions.string.EncryptRaw) -> str:
2487    def encryptraw_sql(self, expression: exp.EncryptRaw) -> str:
2488        self.unsupported("ENCRYPT_RAW is not supported in DuckDB")
2489        return self.function_fallback_sql(expression)
def parseurl_sql(self, expression: sqlglot.expressions.string.ParseUrl) -> str:
2491    def parseurl_sql(self, expression: exp.ParseUrl) -> str:
2492        self.unsupported("PARSE_URL is not supported in DuckDB")
2493        return self.function_fallback_sql(expression)
def parseip_sql(self, expression: sqlglot.expressions.functions.ParseIp) -> str:
2495    def parseip_sql(self, expression: exp.ParseIp) -> str:
2496        self.unsupported("PARSE_IP is not supported in DuckDB")
2497        return self.function_fallback_sql(expression)
def decompressstring_sql(self, expression: sqlglot.expressions.string.DecompressString) -> str:
2499    def decompressstring_sql(self, expression: exp.DecompressString) -> str:
2500        self.unsupported("DECOMPRESS_STRING is not supported in DuckDB")
2501        return self.function_fallback_sql(expression)
def decompressbinary_sql(self, expression: sqlglot.expressions.string.DecompressBinary) -> str:
2503    def decompressbinary_sql(self, expression: exp.DecompressBinary) -> str:
2504        self.unsupported("DECOMPRESS_BINARY is not supported in DuckDB")
2505        return self.function_fallback_sql(expression)
def jarowinklersimilarity_sql(self, expression: sqlglot.expressions.math.JarowinklerSimilarity) -> str:
2507    def jarowinklersimilarity_sql(self, expression: exp.JarowinklerSimilarity) -> str:
2508        this = expression.this
2509        expr = expression.expression
2510
2511        if expression.args.get("case_insensitive"):
2512            this = exp.Upper(this=this)
2513            expr = exp.Upper(this=expr)
2514
2515        result = exp.func("JARO_WINKLER_SIMILARITY", this, expr)
2516
2517        if expression.args.get("integer_scale"):
2518            result = exp.cast(result * 100, "INTEGER")
2519
2520        return self.sql(result)
def randstr_sql(self, expression: sqlglot.expressions.functions.Randstr) -> str:
2522    def randstr_sql(self, expression: exp.Randstr) -> str:
2523        """
2524        Transpile Snowflake's RANDSTR to DuckDB equivalent using deterministic hash-based random.
2525        Uses a pre-parsed template with placeholders replaced by expression nodes.
2526
2527        RANDSTR(length, generator) generates a random string of specified length.
2528        - With numeric seed: Use HASH(i + seed) for deterministic output (same seed = same result)
2529        - With RANDOM(): Use RANDOM() in the hash for non-deterministic output
2530        - No generator: Use default seed value
2531        """
2532        length = expression.this
2533        generator = expression.args.get("generator")
2534
2535        if generator:
2536            if isinstance(generator, exp.Rand):
2537                # If it's RANDOM(), use its seed if available, otherwise use RANDOM() itself
2538                seed_value = generator.this or generator
2539            else:
2540                # Const/int or other expression - use as seed directly
2541                seed_value = generator
2542        else:
2543            # No generator specified, use default seed (arbitrary but deterministic)
2544            seed_value = exp.Literal.number(RANDSTR_SEED)
2545
2546        replacements = {"seed": seed_value, "length": length}
2547        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:
2549    @unsupported_args("finish")
2550    def reduce_sql(self, expression: exp.Reduce) -> str:
2551        array_arg = expression.this
2552        initial_value = expression.args.get("initial")
2553        merge_lambda = expression.args.get("merge")
2554
2555        if merge_lambda:
2556            merge_lambda.set("colon", True)
2557
2558        return self.func("list_reduce", array_arg, merge_lambda, initial_value)
def zipf_sql(self, expression: sqlglot.expressions.functions.Zipf) -> str:
2560    def zipf_sql(self, expression: exp.Zipf) -> str:
2561        """
2562        Transpile Snowflake's ZIPF to DuckDB using CDF-based inverse sampling.
2563        Uses a pre-parsed template with placeholders replaced by expression nodes.
2564        """
2565        s = expression.this
2566        n = expression.args["elementcount"]
2567        gen = expression.args["gen"]
2568
2569        if not isinstance(gen, exp.Rand):
2570            # (ABS(HASH(seed)) % 1000000) / 1000000.0
2571            random_expr: exp.Expr = exp.Div(
2572                this=exp.Paren(
2573                    this=exp.Mod(
2574                        this=exp.Abs(this=exp.Anonymous(this="HASH", expressions=[gen.copy()])),
2575                        expression=exp.Literal.number(1000000),
2576                    )
2577                ),
2578                expression=exp.Literal.number(1000000.0),
2579            )
2580        else:
2581            # Use RANDOM() for non-deterministic output
2582            random_expr = exp.Rand()
2583
2584        replacements = {"s": s, "n": n, "random_expr": random_expr}
2585        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:
2587    def tobinary_sql(self, expression: exp.ToBinary) -> str:
2588        """
2589        TO_BINARY and TRY_TO_BINARY transpilation:
2590        - 'HEX': TO_BINARY('48454C50', 'HEX') -> UNHEX('48454C50')
2591        - 'UTF-8': TO_BINARY('TEST', 'UTF-8') -> ENCODE('TEST')
2592        - 'BASE64': TO_BINARY('SEVMUA==', 'BASE64') -> FROM_BASE64('SEVMUA==')
2593
2594        For TRY_TO_BINARY (safe=True), wrap with TRY():
2595        - 'HEX': TRY_TO_BINARY('invalid', 'HEX') -> TRY(UNHEX('invalid'))
2596        """
2597        value = expression.this
2598        format_arg = expression.args.get("format")
2599        is_safe = expression.args.get("safe")
2600        is_binary = _is_binary(expression)
2601
2602        if not format_arg and not is_binary:
2603            func_name = "TRY_TO_BINARY" if is_safe else "TO_BINARY"
2604            return self.func(func_name, value)
2605
2606        # Snowflake defaults to HEX encoding when no format is specified
2607        fmt = format_arg.name.upper() if format_arg else "HEX"
2608
2609        if fmt in ("UTF-8", "UTF8"):
2610            # DuckDB ENCODE always uses UTF-8, no charset parameter needed
2611            result = self.func("ENCODE", value)
2612        elif fmt == "BASE64":
2613            result = self.func("FROM_BASE64", value)
2614        elif fmt == "HEX":
2615            result = self.func("UNHEX", value)
2616        else:
2617            if is_safe:
2618                return self.sql(exp.null())
2619            else:
2620                self.unsupported(f"format {fmt} is not supported")
2621                result = self.func("TO_BINARY", value)
2622        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:
2624    def tonumber_sql(self, expression: exp.ToNumber) -> str:
2625        fmt = expression.args.get("format")
2626        precision = expression.args.get("precision")
2627        scale = expression.args.get("scale")
2628
2629        if not fmt and precision and scale:
2630            to = exp.DataType.build(f"DECIMAL({precision.name}, {scale.name})", dialect="duckdb")
2631            if expression.args.get("safe"):
2632                return self.sql(exp.TryCast(this=expression.this, to=to))
2633            return self.sql(exp.cast(expression.this, to, dialect="duckdb"))
2634
2635        return super().tonumber_sql(expression)
def generator_sql(self, expression: sqlglot.expressions.array.Generator) -> str:
2661    def generator_sql(self, expression: exp.Generator) -> str:
2662        # Transpile Snowflake GENERATOR to DuckDB range()
2663        rowcount = expression.args.get("rowcount")
2664        time_limit = expression.args.get("time_limit")
2665
2666        if time_limit:
2667            self.unsupported("GENERATOR TIMELIMIT parameter is not supported in DuckDB")
2668
2669        if not rowcount:
2670            self.unsupported("GENERATOR without ROWCOUNT is not supported in DuckDB")
2671            return self.func("range", exp.Literal.number(0))
2672
2673        return self.func("range", rowcount)
def greatest_sql(self, expression: sqlglot.expressions.functions.Greatest) -> str:
2675    def greatest_sql(self, expression: exp.Greatest) -> str:
2676        return self._greatest_least_sql(expression)
def least_sql(self, expression: sqlglot.expressions.functions.Least) -> str:
2678    def least_sql(self, expression: exp.Least) -> str:
2679        return self._greatest_least_sql(expression)
def lambda_sql( self, expression: sqlglot.expressions.query.Lambda, arrow_sep: str = '->', wrap: bool = True) -> str:
2681    def lambda_sql(self, expression: exp.Lambda, arrow_sep: str = "->", wrap: bool = True) -> str:
2682        if expression.args.get("colon"):
2683            prefix = "LAMBDA "
2684            arrow_sep = ":"
2685            wrap = False
2686        else:
2687            prefix = ""
2688
2689        lambda_sql = super().lambda_sql(expression, arrow_sep=arrow_sep, wrap=wrap)
2690        return f"{prefix}{lambda_sql}"
def show_sql(self, expression: sqlglot.expressions.ddl.Show) -> str:
2692    def show_sql(self, expression: exp.Show) -> str:
2693        from_ = self.sql(expression, "from_")
2694        from_ = f" FROM {from_}" if from_ else ""
2695        return f"SHOW {expression.name}{from_}"
def soundex_sql(self, expression: sqlglot.expressions.string.Soundex) -> str:
2697    def soundex_sql(self, expression: exp.Soundex) -> str:
2698        self.unsupported("SOUNDEX is not supported in DuckDB")
2699        return self.func("SOUNDEX", expression.this)
def install_sql(self, expression: sqlglot.expressions.ddl.Install) -> str:
2701    def install_sql(self, expression: exp.Install) -> str:
2702        force = "FORCE " if expression.args.get("force") else ""
2703        this = self.sql(expression, "this")
2704        from_clause = expression.args.get("from_")
2705        from_clause = f" FROM {from_clause}" if from_clause else ""
2706        return f"{force}INSTALL {this}{from_clause}"
def approxtopk_sql(self, expression: sqlglot.expressions.aggregate.ApproxTopK) -> str:
2708    def approxtopk_sql(self, expression: exp.ApproxTopK) -> str:
2709        self.unsupported(
2710            "APPROX_TOP_K cannot be transpiled to DuckDB due to incompatible return types. "
2711        )
2712        return self.function_fallback_sql(expression)
def strposition_sql(self, expression: sqlglot.expressions.string.StrPosition) -> str:
2714    def strposition_sql(self, expression: exp.StrPosition) -> str:
2715        this = expression.this
2716        substr = expression.args.get("substr")
2717        position = expression.args.get("position")
2718
2719        # For BINARY/BLOB: DuckDB's STRPOS doesn't support BLOB types
2720        # Convert to HEX strings, use STRPOS, then convert hex position to byte position
2721        if _is_binary(this):
2722            # Build expression: STRPOS(HEX(haystack), HEX(needle))
2723            hex_strpos = exp.StrPosition(
2724                this=exp.Hex(this=this),
2725                substr=exp.Hex(this=substr),
2726            )
2727
2728            return self.sql(exp.cast((hex_strpos + 1) / 2, exp.DType.INT))
2729
2730        # For VARCHAR: handle clamp_position
2731        if expression.args.get("clamp_position") and position:
2732            expression = expression.copy()
2733            expression.set(
2734                "position",
2735                exp.If(
2736                    this=exp.LTE(this=position, expression=exp.Literal.number(0)),
2737                    true=exp.Literal.number(1),
2738                    false=position.copy(),
2739                ),
2740            )
2741
2742        return strposition_sql(self, expression)
def substring_sql(self, expression: sqlglot.expressions.string.Substring) -> str:
2744    def substring_sql(self, expression: exp.Substring) -> str:
2745        if expression.args.get("zero_start"):
2746            start = expression.args.get("start")
2747            length = expression.args.get("length")
2748
2749            if start := expression.args.get("start"):
2750                start = exp.If(this=start.eq(0), true=exp.Literal.number(1), false=start)
2751            if length := expression.args.get("length"):
2752                length = exp.If(this=length < 0, true=exp.Literal.number(0), false=length)
2753
2754            return self.func("SUBSTRING", expression.this, start, length)
2755
2756        return self.function_fallback_sql(expression)
def strtotime_sql(self, expression: sqlglot.expressions.temporal.StrToTime) -> str:
2758    def strtotime_sql(self, expression: exp.StrToTime) -> str:
2759        # Check if target_type requires TIMESTAMPTZ (for LTZ/TZ variants)
2760        target_type = expression.args.get("target_type")
2761        needs_tz = target_type and target_type.this in (
2762            exp.DType.TIMESTAMPLTZ,
2763            exp.DType.TIMESTAMPTZ,
2764        )
2765
2766        value, formatted_time = self._strptime_default_year(expression)
2767
2768        if expression.args.get("safe"):
2769            cast_type = exp.DType.TIMESTAMPTZ if needs_tz else exp.DType.TIMESTAMP
2770            return self.sql(exp.cast(self.func("TRY_STRPTIME", value, formatted_time), cast_type))
2771
2772        base_sql = self.func("STRPTIME", value, formatted_time)
2773        if needs_tz:
2774            return self.sql(
2775                exp.cast(
2776                    base_sql,
2777                    exp.DataType(this=exp.DType.TIMESTAMPTZ),
2778                )
2779            )
2780        return base_sql
def strtodate_sql(self, expression: sqlglot.expressions.temporal.StrToDate) -> str:
2782    def strtodate_sql(self, expression: exp.StrToDate) -> str:
2783        value, formatted_time = self._strptime_default_year(expression)
2784        function_name = "STRPTIME" if not expression.args.get("safe") else "TRY_STRPTIME"
2785        return self.sql(
2786            exp.cast(
2787                self.func(function_name, value, formatted_time),
2788                exp.DataType(this=exp.DType.DATE),
2789            )
2790        )
def parsedatetime_sql(self, expression: sqlglot.expressions.temporal.ParseDatetime) -> str:
2804    def parsedatetime_sql(self, expression: exp.ParseDatetime) -> str:
2805        value, formatted_time = self._strptime_default_year(expression)
2806        return self.func("STRPTIME", value, formatted_time)
def parsetime_sql(self, expression: sqlglot.expressions.temporal.ParseTime) -> str:
2808    def parsetime_sql(self, expression: exp.ParseTime) -> str:
2809        formatted_time = self.format_time(expression)
2810        return self.sql(
2811            exp.cast(
2812                self.func("STRPTIME", expression.this, formatted_time),
2813                exp.DataType(this=exp.DType.TIME),
2814            )
2815        )
def tsordstotime_sql(self, expression: sqlglot.expressions.temporal.TsOrDsToTime) -> str:
2817    def tsordstotime_sql(self, expression: exp.TsOrDsToTime) -> str:
2818        this = expression.this
2819        time_format = self.format_time(expression)
2820        safe = expression.args.get("safe")
2821        time_type = exp.DataType.from_str("TIME", dialect="duckdb")
2822        cast_expr = exp.TryCast if safe else exp.Cast
2823
2824        if time_format:
2825            func_name = "TRY_STRPTIME" if safe else "STRPTIME"
2826            strptime = exp.Anonymous(this=func_name, expressions=[this, time_format])
2827            return self.sql(cast_expr(this=strptime, to=time_type))
2828
2829        if isinstance(this, exp.TsOrDsToTime) or this.is_type(exp.DType.TIME):
2830            return self.sql(this)
2831
2832        return self.sql(cast_expr(this=this, to=time_type))
def currentdate_sql(self, expression: sqlglot.expressions.temporal.CurrentDate) -> str:
2834    def currentdate_sql(self, expression: exp.CurrentDate) -> str:
2835        if not expression.this:
2836            return "CURRENT_DATE"
2837
2838        expr = exp.Cast(
2839            this=exp.AtTimeZone(this=exp.CurrentTimestamp(), zone=expression.this),
2840            to=exp.DataType(this=exp.DType.DATE),
2841        )
2842        return self.sql(expr)
def checkjson_sql(self, expression: sqlglot.expressions.json.CheckJson) -> str:
2844    def checkjson_sql(self, expression: exp.CheckJson) -> str:
2845        arg = expression.this
2846        return self.sql(
2847            exp.case()
2848            .when(
2849                exp.or_(arg.is_(exp.Null()), arg.eq(""), exp.func("json_valid", arg)),
2850                exp.null(),
2851            )
2852            .else_(exp.Literal.string("Invalid JSON"))
2853        )
def parsejson_sql(self, expression: sqlglot.expressions.json.ParseJSON) -> str:
2855    def parsejson_sql(self, expression: exp.ParseJSON) -> str:
2856        arg = expression.this
2857        if expression.args.get("safe"):
2858            return self.sql(
2859                exp.case()
2860                .when(exp.func("json_valid", arg), exp.cast(arg.copy(), "JSON"))
2861                .else_(exp.null())
2862            )
2863        return self.func("JSON", arg)
def unicode_sql(self, expression: sqlglot.expressions.string.Unicode) -> str:
2865    def unicode_sql(self, expression: exp.Unicode) -> str:
2866        if expression.args.get("empty_is_zero"):
2867            return self.sql(
2868                exp.case()
2869                .when(expression.this.eq(exp.Literal.string("")), exp.Literal.number(0))
2870                .else_(exp.Anonymous(this="UNICODE", expressions=[expression.this]))
2871            )
2872
2873        return self.func("UNICODE", expression.this)
def stripnullvalue_sql(self, expression: sqlglot.expressions.json.StripNullValue) -> str:
2875    def stripnullvalue_sql(self, expression: exp.StripNullValue) -> str:
2876        return self.sql(
2877            exp.case()
2878            .when(exp.func("json_type", expression.this).eq("NULL"), exp.null())
2879            .else_(expression.this)
2880        )
def trunc_sql(self, expression: sqlglot.expressions.math.Trunc) -> str:
2882    def trunc_sql(self, expression: exp.Trunc) -> str:
2883        decimals = expression.args.get("decimals")
2884        if (
2885            expression.args.get("fractions_supported")
2886            and decimals
2887            and not decimals.is_type(exp.DType.INT)
2888        ):
2889            decimals = exp.cast(decimals, exp.DType.INT, dialect="duckdb")
2890
2891        return self.func("TRUNC", expression.this, decimals)
def normal_sql(self, expression: sqlglot.expressions.functions.Normal) -> str:
2893    def normal_sql(self, expression: exp.Normal) -> str:
2894        """
2895        Transpile Snowflake's NORMAL(mean, stddev, gen) to DuckDB.
2896
2897        Uses the Box-Muller transform via NORMAL_TEMPLATE.
2898        """
2899        mean = expression.this
2900        stddev = expression.args["stddev"]
2901        gen: exp.Expr = expression.args["gen"]
2902
2903        # Build two uniform random values [0, 1) for Box-Muller transform
2904        if isinstance(gen, exp.Rand) and gen.this is None:
2905            u1: exp.Expr = exp.Rand()
2906            u2: exp.Expr = exp.Rand()
2907        else:
2908            # Seeded: derive two values using HASH with different inputs
2909            seed = gen.this if isinstance(gen, exp.Rand) else gen
2910            u1 = exp.replace_placeholders(self.SEEDED_RANDOM_TEMPLATE, seed=seed)
2911            u2 = exp.replace_placeholders(
2912                self.SEEDED_RANDOM_TEMPLATE,
2913                seed=exp.Add(this=seed.copy(), expression=exp.Literal.number(1)),
2914            )
2915
2916        replacements = {"mean": mean, "stddev": stddev, "u1": u1, "u2": u2}
2917        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:
2919    def uniform_sql(self, expression: exp.Uniform) -> str:
2920        """
2921        Transpile Snowflake's UNIFORM(min, max, gen) to DuckDB.
2922
2923        UNIFORM returns a random value in [min, max]:
2924        - Integer result if both min and max are integers
2925        - Float result if either min or max is a float
2926        """
2927        min_val = expression.this
2928        max_val = expression.expression
2929        gen = expression.args.get("gen") or expression.args.get("seed") or exp.Rand()
2930
2931        # Determine if result should be integer (both bounds are integers).
2932        # We do this to emulate Snowflake's behavior, INT -> INT, FLOAT -> FLOAT
2933        is_int_result = min_val.is_int and max_val.is_int
2934
2935        # Build the random value expression [0, 1)
2936        if not isinstance(gen, exp.Rand):
2937            # Seed value: (ABS(HASH(seed)) % 1000000) / 1000000.0
2938            random_expr: exp.Expr = exp.Div(
2939                this=exp.Paren(
2940                    this=exp.Mod(
2941                        this=exp.Abs(this=exp.Anonymous(this="HASH", expressions=[gen])),
2942                        expression=exp.Literal.number(1000000),
2943                    )
2944                ),
2945                expression=exp.Literal.number(1000000.0),
2946            )
2947        else:
2948            random_expr = exp.Rand()
2949
2950        # Build: min + random * (max - min [+ 1 for int])
2951        range_expr: exp.Expr = exp.Sub(this=max_val, expression=min_val)
2952        if is_int_result:
2953            range_expr = exp.Add(this=range_expr, expression=exp.Literal.number(1))
2954
2955        result: exp.Expr = exp.Add(
2956            this=min_val,
2957            expression=exp.Mul(this=random_expr, expression=exp.Paren(this=range_expr)),
2958        )
2959
2960        if is_int_result:
2961            result = exp.Cast(this=exp.Floor(this=result), to=exp.DType.BIGINT.into_expr())
2962
2963        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:
2965    def timefromparts_sql(self, expression: exp.TimeFromParts) -> str:
2966        nano = expression.args.get("nano")
2967        overflow = expression.args.get("overflow")
2968
2969        # Snowflake's TIME_FROM_PARTS supports overflow
2970        if overflow:
2971            hour = expression.args["hour"]
2972            minute = expression.args["min"]
2973            sec = expression.args["sec"]
2974
2975            # Check if values are within normal ranges - use MAKE_TIME for efficiency
2976            if not nano and all(arg.is_int for arg in [hour, minute, sec]):
2977                try:
2978                    h_val = hour.to_py()
2979                    m_val = minute.to_py()
2980                    s_val = sec.to_py()
2981                    if 0 <= h_val <= 23 and 0 <= m_val <= 59 and 0 <= s_val <= 59:
2982                        return rename_func("MAKE_TIME")(self, expression)
2983                except ValueError:
2984                    pass
2985
2986            # Overflow or nanoseconds detected - use INTERVAL arithmetic
2987            if nano:
2988                sec = sec + nano.pop() / exp.Literal.number(1000000000.0)
2989
2990            total_seconds = hour * exp.Literal.number(3600) + minute * exp.Literal.number(60) + sec
2991
2992            return self.sql(
2993                exp.Add(
2994                    this=exp.Cast(
2995                        this=exp.Literal.string("00:00:00"), to=exp.DType.TIME.into_expr()
2996                    ),
2997                    expression=exp.Interval(this=total_seconds, unit=exp.var("SECOND")),
2998                )
2999            )
3000
3001        # Default: MAKE_TIME
3002        if nano:
3003            expression.set(
3004                "sec", expression.args["sec"] + nano.pop() / exp.Literal.number(1000000000.0)
3005            )
3006
3007        return rename_func("MAKE_TIME")(self, expression)
def extract_sql(self, expression: sqlglot.expressions.temporal.Extract) -> str:
3009    def extract_sql(self, expression: exp.Extract) -> str:
3010        """
3011        Transpile EXTRACT/DATE_PART for DuckDB, handling specifiers not natively supported.
3012
3013        DuckDB doesn't support: WEEKISO, YEAROFWEEK, YEAROFWEEKISO, NANOSECOND,
3014        EPOCH_SECOND (as integer), EPOCH_MILLISECOND, EPOCH_MICROSECOND, EPOCH_NANOSECOND
3015        """
3016        this = expression.this
3017        datetime_expr = expression.expression
3018
3019        # TIMESTAMPTZ extractions may produce different results between Snowflake and DuckDB
3020        # because Snowflake applies server timezone while DuckDB uses local timezone
3021        if datetime_expr.is_type(exp.DType.TIMESTAMPTZ, exp.DType.TIMESTAMPLTZ):
3022            self.unsupported(
3023                "EXTRACT from TIMESTAMPTZ / TIMESTAMPLTZ may produce different results due to timezone handling differences"
3024            )
3025
3026        part_name = this.name.upper()
3027
3028        if part_name in self.EXTRACT_STRFTIME_MAPPINGS:
3029            fmt, cast_type = self.EXTRACT_STRFTIME_MAPPINGS[part_name]
3030
3031            # Problem: strftime doesn't accept TIME and there's no NANOSECOND function
3032            # So, for NANOSECOND with TIME, fallback to MICROSECOND * 1000
3033            is_nano_time = part_name == "NANOSECOND" and datetime_expr.is_type(
3034                exp.DType.TIME, exp.DType.TIMETZ
3035            )
3036
3037            if is_nano_time:
3038                self.unsupported("Parameter NANOSECOND is not supported with TIME type in DuckDB")
3039                return self.sql(
3040                    exp.cast(
3041                        exp.Mul(
3042                            this=exp.Extract(this=exp.var("MICROSECOND"), expression=datetime_expr),
3043                            expression=exp.Literal.number(1000),
3044                        ),
3045                        exp.DataType.from_str(cast_type, dialect="duckdb"),
3046                    )
3047                )
3048
3049            # For NANOSECOND, cast to TIMESTAMP_NS to preserve nanosecond precision
3050            strftime_input = datetime_expr
3051            if part_name == "NANOSECOND":
3052                strftime_input = exp.cast(datetime_expr, exp.DType.TIMESTAMP_NS)
3053
3054            return self.sql(
3055                exp.cast(
3056                    exp.Anonymous(
3057                        this="STRFTIME",
3058                        expressions=[strftime_input, exp.Literal.string(fmt)],
3059                    ),
3060                    exp.DataType.from_str(cast_type, dialect="duckdb"),
3061                )
3062            )
3063
3064        if part_name in self.EXTRACT_EPOCH_MAPPINGS:
3065            func_name = self.EXTRACT_EPOCH_MAPPINGS[part_name]
3066            result: exp.Expr = exp.Anonymous(this=func_name, expressions=[datetime_expr])
3067            # EPOCH returns float, cast to BIGINT for integer result
3068            if part_name == "EPOCH_SECOND":
3069                result = exp.cast(result, exp.DataType.from_str("BIGINT", dialect="duckdb"))
3070            return self.sql(result)
3071
3072        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:
3074    def timestampfromparts_sql(self, expression: exp.TimestampFromParts) -> str:
3075        # Check if this is the date/time expression form: TIMESTAMP_FROM_PARTS(date_expr, time_expr)
3076        date_expr = expression.this
3077        time_expr = expression.expression
3078
3079        if date_expr is not None and time_expr is not None:
3080            # In DuckDB, DATE + TIME produces TIMESTAMP
3081            return self.sql(exp.Add(this=date_expr, expression=time_expr))
3082
3083        # Component-based form: TIMESTAMP_FROM_PARTS(year, month, day, hour, minute, second, ...)
3084        sec = expression.args.get("sec")
3085        if sec is None:
3086            # This shouldn't happen with valid input, but handle gracefully
3087            return rename_func("MAKE_TIMESTAMP")(self, expression)
3088
3089        milli = expression.args.get("milli")
3090        if milli is not None:
3091            sec += milli.pop() / exp.Literal.number(1000.0)
3092
3093        nano = expression.args.get("nano")
3094        if nano is not None:
3095            sec += nano.pop() / exp.Literal.number(1000000000.0)
3096
3097        if milli or nano:
3098            expression.set("sec", sec)
3099
3100        if zone := expression.args.get("zone"):
3101            return self.sql(exp.AtTimeZone(this=expression, zone=zone.pop()))
3102
3103        return rename_func("MAKE_TIMESTAMP")(self, expression)
@unsupported_args('nano')
def timestampltzfromparts_sql( self, expression: sqlglot.expressions.temporal.TimestampLtzFromParts) -> str:
3105    @unsupported_args("nano")
3106    def timestampltzfromparts_sql(self, expression: exp.TimestampLtzFromParts) -> str:
3107        # Pop nano so rename_func only passes args that MAKE_TIMESTAMP accepts
3108        if nano := expression.args.get("nano"):
3109            nano.pop()
3110
3111        timestamp = rename_func("MAKE_TIMESTAMP")(self, expression)
3112        return f"CAST({timestamp} AS TIMESTAMPTZ)"
@unsupported_args('nano')
def timestamptzfromparts_sql( self, expression: sqlglot.expressions.temporal.TimestampTzFromParts) -> str:
3114    @unsupported_args("nano")
3115    def timestamptzfromparts_sql(self, expression: exp.TimestampTzFromParts) -> str:
3116        # Extract zone before popping
3117        zone = expression.args.get("zone")
3118        # Pop zone and nano so rename_func only passes args that MAKE_TIMESTAMP accepts
3119        if zone:
3120            zone = zone.pop()
3121
3122        if nano := expression.args.get("nano"):
3123            nano.pop()
3124
3125        timestamp = rename_func("MAKE_TIMESTAMP")(self, expression)
3126
3127        if zone:
3128            # Use AT TIME ZONE to apply the explicit timezone
3129            return f"{timestamp} AT TIME ZONE {self.sql(zone)}"
3130
3131        return timestamp
def after_limit_modifiers(self, expression: sqlglot.expressions.core.Expr) -> list[str]:
3133    def after_limit_modifiers(self, expression: exp.Expr) -> list[str]:
3134        locks = self.expressions(expression, key="locks", sep=" ")
3135        return [f" {locks}" if locks else ""]
def tablesample_sql( self, expression: sqlglot.expressions.query.TableSample, tablesample_keyword: str | None = None) -> str:
3137    def tablesample_sql(
3138        self,
3139        expression: exp.TableSample,
3140        tablesample_keyword: str | None = None,
3141    ) -> str:
3142        if not isinstance(expression.parent, exp.Select):
3143            # This sample clause only applies to a single source, not the entire resulting relation
3144            tablesample_keyword = "TABLESAMPLE"
3145
3146        if expression.args.get("size"):
3147            method = expression.args.get("method")
3148            if method and method.name.upper() != "RESERVOIR":
3149                self.unsupported(
3150                    f"Sampling method {method} is not supported with a discrete sample count, "
3151                    "defaulting to reservoir sampling"
3152                )
3153                expression.set("method", exp.var("RESERVOIR"))
3154
3155        return super().tablesample_sql(expression, tablesample_keyword=tablesample_keyword)
def in_sql(self, expression: sqlglot.expressions.core.In) -> str:
3157    def in_sql(self, expression: exp.In) -> str:
3158        unnest = expression.args.get("unnest")
3159        if unnest:
3160            return self.sql(
3161                exp.replace_placeholders(
3162                    self.IN_UNNEST_TEMPLATE, arr=unnest.expressions[0], value=expression.this
3163                )
3164            )
3165        return super().in_sql(expression)
def join_sql(self, expression: sqlglot.expressions.query.Join) -> str:
3167    def join_sql(self, expression: exp.Join) -> str:
3168        if (
3169            not expression.args.get("using")
3170            and not expression.args.get("on")
3171            and not expression.method
3172            and (expression.kind in ("", "INNER", "OUTER"))
3173        ):
3174            # Some dialects support `LEFT/INNER JOIN UNNEST(...)` without an explicit ON clause
3175            # DuckDB doesn't, but we can just add a dummy ON clause that is always true
3176            if isinstance(expression.this, exp.Unnest):
3177                return super().join_sql(expression.on(exp.true()))
3178
3179            expression.set("side", None)
3180            expression.set("kind", None)
3181
3182        return super().join_sql(expression)
def countif_sql(self, expression: sqlglot.expressions.aggregate.CountIf) -> str:
3184    def countif_sql(self, expression: exp.CountIf) -> str:
3185        if self.dialect.version >= (1, 2):
3186            this = expression.this
3187            if expression.args.get("zero_on_all_null") and not isinstance(this, exp.Distinct):
3188                # DuckDB >= 1.2's COUNT_IF returns NULL when the condition is NULL on all rows,
3189                # so we wrap the condition in IS TRUE to preserve count-like semantics
3190                expression = exp.CountIf(this=exp.paren(this).is_(exp.true()))
3191            return self.function_fallback_sql(expression)
3192
3193        # https://github.com/tobymao/sqlglot/pull/4749
3194        return count_if_to_sum(self, expression)
def bracket_sql(self, expression: sqlglot.expressions.core.Bracket) -> str:
3196    def bracket_sql(self, expression: exp.Bracket) -> str:
3197        if self.dialect.version >= (1, 2):
3198            return super().bracket_sql(expression)
3199
3200        # https://duckdb.org/2025/02/05/announcing-duckdb-120.html#breaking-changes
3201        this = expression.this
3202        if isinstance(this, exp.Array):
3203            this.replace(exp.paren(this))
3204
3205        bracket = super().bracket_sql(expression)
3206
3207        if not expression.args.get("returns_list_for_maps"):
3208            if not this.type:
3209                from sqlglot.optimizer.annotate_types import annotate_types
3210
3211                this = annotate_types(this, dialect=self.dialect)
3212
3213            if this.is_type(exp.DType.MAP):
3214                bracket = f"({bracket})[1]"
3215
3216        return bracket
def withingroup_sql(self, expression: sqlglot.expressions.core.WithinGroup) -> str:
3218    def withingroup_sql(self, expression: exp.WithinGroup) -> str:
3219        func = expression.this
3220
3221        # For ARRAY_AGG, DuckDB requires ORDER BY inside the function, not in WITHIN GROUP
3222        # Transform: ARRAY_AGG(x) WITHIN GROUP (ORDER BY y) -> ARRAY_AGG(x ORDER BY y)
3223        if isinstance(func, exp.ArrayAgg):
3224            if not isinstance(order := expression.expression, exp.Order):
3225                return self.sql(func)
3226
3227            # Save the original column for FILTER clause (before wrapping with Order)
3228            original_this = func.this
3229
3230            # Move ORDER BY inside ARRAY_AGG by wrapping its argument with Order
3231            # ArrayAgg.this should become Order(this=ArrayAgg.this, expressions=order.expressions)
3232            func.set(
3233                "this",
3234                exp.Order(
3235                    this=func.this.copy(),
3236                    expressions=order.expressions,
3237                ),
3238            )
3239
3240            # Generate the ARRAY_AGG function with ORDER BY and add FILTER clause if needed
3241            # Use original_this (not the Order-wrapped version) for the FILTER condition
3242            array_agg_sql = self.function_fallback_sql(func)
3243            return self._add_arrayagg_null_filter(array_agg_sql, func, original_this)
3244
3245        # For other functions (like PERCENTILES), use existing logic
3246        expression_sql = self.sql(expression, "expression")
3247
3248        if isinstance(func, exp.PERCENTILES):
3249            # Make the order key the first arg and slide the fraction to the right
3250            # https://duckdb.org/docs/sql/aggregates#ordered-set-aggregate-functions
3251            order_col = expression.find(exp.Ordered)
3252            if order_col:
3253                func.set("expression", func.this)
3254                func.set("this", order_col.this)
3255
3256        this = self.sql(expression, "this").rstrip(")")
3257
3258        return f"{this}{expression_sql})"
def length_sql(self, expression: sqlglot.expressions.string.Length) -> str:
3260    def length_sql(self, expression: exp.Length) -> str:
3261        arg = expression.this
3262
3263        # Dialects like BQ and Snowflake also accept binary values as args, so
3264        # DDB will attempt to infer the type or resort to case/when resolution
3265        if not expression.args.get("binary") or arg.is_string:
3266            return self.func("LENGTH", arg)
3267
3268        if not arg.type:
3269            from sqlglot.optimizer.annotate_types import annotate_types
3270
3271            arg = annotate_types(arg, dialect=self.dialect)
3272
3273        if arg.is_type(*exp.DataType.TEXT_TYPES):
3274            return self.func("LENGTH", arg)
3275
3276        # We need these casts to make duckdb's static type checker happy
3277        blob = exp.cast(arg, exp.DType.VARBINARY)
3278        varchar = exp.cast(arg, exp.DType.VARCHAR)
3279
3280        case = (
3281            exp.case(exp.Anonymous(this="TYPEOF", expressions=[arg]))
3282            .when(exp.Literal.string("BLOB"), exp.ByteLength(this=blob))
3283            .else_(exp.Anonymous(this="LENGTH", expressions=[varchar]))
3284        )
3285        return self.sql(case)
def bitlength_sql(self, expression: sqlglot.expressions.string.BitLength) -> str:
3287    def bitlength_sql(self, expression: exp.BitLength) -> str:
3288        if not _is_binary(arg := expression.this):
3289            return self.func("BIT_LENGTH", arg)
3290
3291        blob = exp.cast(arg, exp.DataType.Type.VARBINARY)
3292        return self.sql(exp.ByteLength(this=blob) * exp.Literal.number(8))
def chr_sql( self, expression: sqlglot.expressions.string.Chr, name: str = 'CHR') -> str:
3294    def chr_sql(self, expression: exp.Chr, name: str = "CHR") -> str:
3295        arg = expression.expressions[0]
3296        if arg.is_type(*exp.DataType.REAL_TYPES):
3297            arg = exp.cast(arg, exp.DType.INT)
3298        return self.func("CHR", arg)
def collation_sql(self, expression: sqlglot.expressions.functions.Collation) -> str:
3300    def collation_sql(self, expression: exp.Collation) -> str:
3301        self.unsupported("COLLATION function is not supported by DuckDB")
3302        return self.function_fallback_sql(expression)
def collate_sql(self, expression: sqlglot.expressions.functions.Collate) -> str:
3304    def collate_sql(self, expression: exp.Collate) -> str:
3305        if not expression.expression.is_string:
3306            return super().collate_sql(expression)
3307
3308        raw = expression.expression.name
3309        if not raw:
3310            return self.sql(expression.this)
3311
3312        parts = []
3313        for part in raw.split("-"):
3314            lower = part.lower()
3315            if lower not in _SNOWFLAKE_COLLATION_DEFAULTS:
3316                if lower in _SNOWFLAKE_COLLATION_UNSUPPORTED:
3317                    self.unsupported(
3318                        f"Snowflake collation specifier '{part}' has no DuckDB equivalent"
3319                    )
3320                parts.append(lower)
3321
3322        if not parts:
3323            return self.sql(expression.this)
3324        return super().collate_sql(
3325            exp.Collate(this=expression.this, expression=exp.var(".".join(parts)))
3326        )
def regexpcount_sql(self, expression: sqlglot.expressions.string.RegexpCount) -> str:
3358    def regexpcount_sql(self, expression: exp.RegexpCount) -> str:
3359        this = expression.this
3360        pattern = expression.expression
3361        position = expression.args.get("position")
3362        parameters = expression.args.get("parameters")
3363
3364        # Validate flags - only "ims" flags are supported for embedded patterns
3365        validated_flags = self._validate_regexp_flags(parameters, supported_flags="ims")
3366
3367        if position:
3368            this = exp.Substring(this=this, start=position)
3369
3370        # Handle empty pattern: Snowflake returns 0, DuckDB would match between every character.
3371        # Checked before the flags are embedded, which would make the pattern non-empty
3372        is_empty = exp.EQ(this=pattern.copy(), expression=exp.Literal.string(""))
3373
3374        # Embed flags in pattern (REGEXP_EXTRACT_ALL doesn't support flags argument)
3375        if validated_flags:
3376            pattern = exp.Concat(expressions=[exp.Literal.string(f"(?{validated_flags})"), pattern])
3377
3378        result = (
3379            exp.case()
3380            .when(is_empty, exp.Literal.number(0))
3381            .else_(
3382                exp.Length(
3383                    this=exp.Anonymous(this="REGEXP_EXTRACT_ALL", expressions=[this, pattern])
3384                )
3385            )
3386        )
3387
3388        return self.sql(result)
def regexpreplace_sql(self, expression: sqlglot.expressions.string.RegexpReplace) -> str:
3390    def regexpreplace_sql(self, expression: exp.RegexpReplace) -> str:
3391        subject = expression.this
3392        pattern = expression.expression
3393        replacement = expression.args.get("replacement") or exp.Literal.string("")
3394        position = expression.args.get("position")
3395        occurrence = expression.args.get("occurrence")
3396        modifiers = expression.args.get("modifiers")
3397
3398        validated_flags = self._validate_regexp_flags(modifiers, supported_flags="cimsg") or ""
3399
3400        # Handle occurrence (only literals supported)
3401        if occurrence and not occurrence.is_int:
3402            self.unsupported("REGEXP_REPLACE with non-literal occurrence")
3403        else:
3404            occurrence = occurrence.to_py() if occurrence and occurrence.is_int else 0
3405            if occurrence > 1:
3406                self.unsupported(f"REGEXP_REPLACE occurrence={occurrence} not supported")
3407            # flag duckdb to do either all or none, single_replace check is for duckdb round trip
3408            elif (
3409                occurrence == 0
3410                and "g" not in validated_flags
3411                and not expression.args.get("single_replace")
3412            ):
3413                validated_flags += "g"
3414
3415        # Handle position (only literals supported)
3416        prefix = None
3417        if position and not position.is_int:
3418            self.unsupported("REGEXP_REPLACE with non-literal position")
3419        elif position and position.is_int and position.to_py() > 1:
3420            pos = position.to_py()
3421            prefix = exp.Substring(
3422                this=subject, start=exp.Literal.number(1), length=exp.Literal.number(pos - 1)
3423            )
3424            subject = exp.Substring(this=subject, start=exp.Literal.number(pos))
3425
3426        result: exp.Expr = exp.Anonymous(
3427            this="REGEXP_REPLACE",
3428            expressions=[
3429                subject,
3430                pattern,
3431                replacement,
3432                exp.Literal.string(validated_flags) if validated_flags else None,
3433            ],
3434        )
3435
3436        if prefix:
3437            result = exp.Concat(expressions=[prefix, result])
3438
3439        return self.sql(result)
def regexplike_sql(self, expression: sqlglot.expressions.core.RegexpLike) -> str:
3441    def regexplike_sql(self, expression: exp.RegexpLike) -> str:
3442        this = expression.this
3443        pattern = expression.expression
3444        flag = expression.args.get("flag")
3445
3446        if expression.args.get("full_match"):
3447            validated_flags = self._validate_regexp_flags(flag, supported_flags="cims")
3448            flag = exp.Literal.string(validated_flags) if validated_flags else None
3449            return self.func("REGEXP_FULL_MATCH", this, pattern, flag)
3450
3451        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:
3453    @unsupported_args("ins_cost", "del_cost", "sub_cost")
3454    def levenshtein_sql(self, expression: exp.Levenshtein) -> str:
3455        this = expression.this
3456        expr = expression.expression
3457        max_dist = expression.args.get("max_dist")
3458
3459        if max_dist is None:
3460            return self.func("LEVENSHTEIN", this, expr)
3461
3462        # Emulate Snowflake semantics: if distance > max_dist, return max_dist
3463        levenshtein = exp.Levenshtein(this=this, expression=expr)
3464        return self.sql(exp.Least(this=levenshtein, expressions=[max_dist]))
def pad_sql(self, expression: sqlglot.expressions.string.Pad) -> str:
3466    def pad_sql(self, expression: exp.Pad) -> str:
3467        """
3468        Handle RPAD/LPAD for VARCHAR and BINARY types.
3469
3470        For VARCHAR: Delegate to parent class
3471        For BINARY: Lower to: input || REPEAT(pad, GREATEST(0, target_len - OCTET_LENGTH(input)))
3472        """
3473        string_arg = expression.this
3474        fill_arg = expression.args.get("fill_pattern") or exp.Literal.string(" ")
3475
3476        if _is_binary(string_arg) or _is_binary(fill_arg):
3477            length_arg = expression.expression
3478            is_left = expression.args.get("is_left")
3479
3480            input_len = exp.ByteLength(this=string_arg)
3481            chars_needed = length_arg - input_len
3482            pad_count = exp.Greatest(
3483                this=exp.Literal.number(0), expressions=[chars_needed], ignore_nulls=True
3484            )
3485            repeat_expr = exp.Repeat(this=fill_arg, times=pad_count)
3486
3487            left, right = string_arg, repeat_expr
3488            if is_left:
3489                left, right = right, left
3490
3491            result = exp.DPipe(this=left, expression=right)
3492            return self.sql(result)
3493
3494        # For VARCHAR: Delegate to parent class (handles PAD_FILL_PATTERN_IS_REQUIRED)
3495        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:
3497    def minhash_sql(self, expression: exp.Minhash) -> str:
3498        k = expression.this
3499        exprs = expression.expressions
3500
3501        if len(exprs) != 1 or isinstance(exprs[0], exp.Star):
3502            self.unsupported(
3503                "MINHASH with multiple expressions or * requires manual query restructuring"
3504            )
3505            return self.func("MINHASH", k, *exprs)
3506
3507        expr = exprs[0]
3508        result = exp.replace_placeholders(self.MINHASH_TEMPLATE.copy(), expr=expr, k=k)
3509        return f"({self.sql(result)})"
def minhashcombine_sql(self, expression: sqlglot.expressions.aggregate.MinhashCombine) -> str:
3511    def minhashcombine_sql(self, expression: exp.MinhashCombine) -> str:
3512        expr = expression.this
3513        result = exp.replace_placeholders(self.MINHASH_COMBINE_TEMPLATE.copy(), expr=expr)
3514        return f"({self.sql(result)})"
def approximatesimilarity_sql( self, expression: sqlglot.expressions.aggregate.ApproximateSimilarity) -> str:
3516    def approximatesimilarity_sql(self, expression: exp.ApproximateSimilarity) -> str:
3517        expr = expression.this
3518        result = exp.replace_placeholders(self.APPROXIMATE_SIMILARITY_TEMPLATE.copy(), expr=expr)
3519        return f"({self.sql(result)})"
def arrayuniqueagg_sql(self, expression: sqlglot.expressions.aggregate.ArrayUniqueAgg) -> str:
3521    def arrayuniqueagg_sql(self, expression: exp.ArrayUniqueAgg) -> str:
3522        return self.sql(
3523            exp.Filter(
3524                this=exp.func("LIST", exp.Distinct(expressions=[expression.this])),
3525                expression=exp.Where(this=expression.this.copy().is_(exp.null()).not_()),
3526            )
3527        )
def arrayconcatagg_sql(self, expression: sqlglot.expressions.aggregate.ArrayConcatAgg) -> str:
3529    def arrayconcatagg_sql(self, expression: exp.ArrayConcatAgg) -> str:
3530        this = expression.this
3531
3532        if isinstance(this, exp.Limit):
3533            self.unsupported("LIMIT in ARRAY_CONCAT_AGG cannot be transpiled to DuckDB")
3534            this = this.this
3535
3536        inner = this.this if isinstance(this, exp.Order) else this
3537
3538        return self.func(
3539            "FLATTEN",
3540            exp.Filter(
3541                this=exp.ArrayAgg(this=this),
3542                expression=exp.Where(this=inner.copy().is_(exp.null()).not_()),
3543            ),
3544        )
def arrayunionagg_sql(self, expression: sqlglot.expressions.aggregate.ArrayUnionAgg) -> str:
3546    def arrayunionagg_sql(self, expression: exp.ArrayUnionAgg) -> str:
3547        self.unsupported("ARRAY_UNION_AGG is not supported in DuckDB")
3548        return self.function_fallback_sql(expression)
def arraydistinct_sql(self, expression: sqlglot.expressions.array.ArrayDistinct) -> str:
3550    def arraydistinct_sql(self, expression: exp.ArrayDistinct) -> str:
3551        arr = expression.this
3552        func = self.func("LIST_DISTINCT", arr)
3553
3554        if expression.args.get("check_null"):
3555            add_null_to_array = exp.func(
3556                "LIST_APPEND", exp.func("LIST_DISTINCT", exp.ArrayCompact(this=arr)), exp.Null()
3557            )
3558            return self.sql(
3559                exp.If(
3560                    this=exp.NEQ(
3561                        this=exp.ArraySize(this=arr), expression=exp.func("LIST_COUNT", arr)
3562                    ),
3563                    true=add_null_to_array,
3564                    false=func,
3565                )
3566            )
3567
3568        return func
def arrayintersect_sql(self, expression: sqlglot.expressions.array.ArrayIntersect) -> str:
3570    def arrayintersect_sql(self, expression: exp.ArrayIntersect) -> str:
3571        if expression.args.get("is_multiset") and len(expression.expressions) == 2:
3572            return self._array_bag_sql(
3573                self.ARRAY_INTERSECTION_CONDITION,
3574                expression.expressions[0],
3575                expression.expressions[1],
3576            )
3577        return self.function_fallback_sql(expression)
def arrayexcept_sql(self, expression: sqlglot.expressions.array.ArrayExcept) -> str:
3579    def arrayexcept_sql(self, expression: exp.ArrayExcept) -> str:
3580        arr1, arr2 = expression.this, expression.expression
3581        if expression.args.get("is_multiset"):
3582            return self._array_bag_sql(self.ARRAY_EXCEPT_CONDITION, arr1, arr2)
3583        return self.sql(
3584            exp.replace_placeholders(self.ARRAY_EXCEPT_SET_TEMPLATE, arr1=arr1, arr2=arr2)
3585        )
def arrayslice_sql(self, expression: sqlglot.expressions.array.ArraySlice) -> str:
3587    def arrayslice_sql(self, expression: exp.ArraySlice) -> str:
3588        """
3589        Spark's SLICE uses a length, so normalize negative starts before computing
3590        the inclusive end index. Starts before the array produce an empty slice.
3591
3592        Transpiles Snowflake's ARRAY_SLICE (0-indexed, exclusive end) to DuckDB's
3593        ARRAY_SLICE (1-indexed, inclusive end) by wrapping start and end in CASE
3594        expressions that adjust the index at query time:
3595          - start: CASE WHEN start >= 0 THEN start + 1 ELSE start END
3596          - end:   CASE WHEN end < 0 THEN end - 1 ELSE end END
3597        """
3598        start, end = expression.args.get("start"), expression.args.get("end")
3599
3600        if expression.args.get("zero_based"):
3601            if start is not None:
3602                start = (
3603                    exp.case()
3604                    .when(
3605                        exp.GTE(this=start.copy(), expression=exp.Literal.number(0)),
3606                        exp.Add(this=start.copy(), expression=exp.Literal.number(1)),
3607                    )
3608                    .else_(start)
3609                )
3610            if end is not None and not expression.args.get("is_length"):
3611                end = (
3612                    exp.case()
3613                    .when(
3614                        exp.LT(this=end.copy(), expression=exp.Literal.number(0)),
3615                        exp.Sub(this=end.copy(), expression=exp.Literal.number(1)),
3616                    )
3617                    .else_(end)
3618                )
3619
3620        if expression.args.get("is_length") and start is not None and end is not None:
3621            if not start.is_int or start.to_py() < 0:
3622                size = exp.ArraySize(this=expression.this.copy())
3623                start = (
3624                    exp.case()
3625                    .when(start < -size, size + 1)
3626                    .when(start < 0, size + start + 1)
3627                    .else_(start)
3628                )
3629
3630            if start.is_int and end.is_int:
3631                end = exp.Literal.number(start.to_py() + end.to_py() - 1)
3632            else:
3633                end = start + exp.cast(end, "BIGINT") - 1
3634        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:
3636    def arrayszip_sql(self, expression: exp.ArraysZip) -> str:
3637        args = expression.expressions
3638
3639        if not args:
3640            # Return [{}] - using MAP([], []) since DuckDB can't represent empty structs
3641            return self.sql(exp.array(exp.Map(keys=exp.array(), values=exp.array())))
3642
3643        # Build placeholder values for template
3644        lengths = [exp.Length(this=arg) for arg in args]
3645        max_len = (
3646            lengths[0]
3647            if len(lengths) == 1
3648            else exp.Greatest(this=lengths[0], expressions=lengths[1:])
3649        )
3650
3651        # Empty struct with same schema: {'$1': NULL, '$2': NULL, ...}
3652        empty_struct = exp.func(
3653            "STRUCT",
3654            *[
3655                exp.PropertyEQ(this=exp.Literal.string(f"${i + 1}"), expression=exp.Null())
3656                for i in range(len(args))
3657            ],
3658        )
3659
3660        # Struct for transform: {'$1': COALESCE(arr1, [])[__i + 1], ...}
3661        # COALESCE wrapping handles NULL arrays - prevents invalid NULL[i] syntax
3662        index = exp.column("__i") + 1
3663        transform_struct = exp.func(
3664            "STRUCT",
3665            *[
3666                exp.PropertyEQ(
3667                    this=exp.Literal.string(f"${i + 1}"),
3668                    expression=exp.func("COALESCE", arg, exp.array())[index],
3669                )
3670                for i, arg in enumerate(args)
3671            ],
3672        )
3673
3674        result = exp.replace_placeholders(
3675            self.ARRAYS_ZIP_TEMPLATE.copy(),
3676            null_check=exp.or_(*[arg.is_(exp.Null()) for arg in args]),
3677            all_empty_check=exp.and_(
3678                *[
3679                    exp.EQ(this=exp.Length(this=arg), expression=exp.Literal.number(0))
3680                    for arg in args
3681                ]
3682            ),
3683            empty_struct=empty_struct,
3684            max_len=max_len,
3685            transform_struct=transform_struct,
3686        )
3687        return self.sql(result)
def lower_sql(self, expression: sqlglot.expressions.string.Lower) -> str:
3689    def lower_sql(self, expression: exp.Lower) -> str:
3690        result_sql = self.func("LOWER", _cast_to_varchar(expression.this))
3691        return _gen_with_cast_to_blob(self, expression, result_sql)
def upper_sql(self, expression: sqlglot.expressions.string.Upper) -> str:
3693    def upper_sql(self, expression: exp.Upper) -> str:
3694        result_sql = self.func("UPPER", _cast_to_varchar(expression.this))
3695        return _gen_with_cast_to_blob(self, expression, result_sql)
def reverse_sql(self, expression: sqlglot.expressions.string.Reverse) -> str:
3697    def reverse_sql(self, expression: exp.Reverse) -> str:
3698        result_sql = self.func("REVERSE", _cast_to_varchar(expression.this))
3699        return _gen_with_cast_to_blob(self, expression, result_sql)
def left_sql(self, expression: sqlglot.expressions.string.Left) -> str:
3725    def left_sql(self, expression: exp.Left) -> str:
3726        return self._left_right_sql(expression, "LEFT")
def right_sql(self, expression: sqlglot.expressions.string.Right) -> str:
3728    def right_sql(self, expression: exp.Right) -> str:
3729        return self._left_right_sql(expression, "RIGHT")
def rtrimmedlength_sql(self, expression: sqlglot.expressions.string.RtrimmedLength) -> str:
3731    def rtrimmedlength_sql(self, expression: exp.RtrimmedLength) -> str:
3732        return self.func("LENGTH", exp.Trim(this=expression.this, position="TRAILING"))
def stuff_sql(self, expression: sqlglot.expressions.string.Stuff) -> str:
3734    def stuff_sql(self, expression: exp.Stuff) -> str:
3735        base = expression.this
3736        start = expression.args["start"]
3737        length = expression.args["length"]
3738        insertion = expression.expression
3739        is_binary = _is_binary(base)
3740
3741        if is_binary:
3742            # DuckDB's SUBSTRING doesn't accept BLOB; operate on the HEX string instead
3743            # (each byte = 2 hex chars), then UNHEX back to BLOB
3744            base = exp.Hex(this=base)
3745            insertion = exp.Hex(this=insertion)
3746            left = exp.Substring(
3747                this=base.copy(),
3748                start=exp.Literal.number(1),
3749                length=(start.copy() - exp.Literal.number(1)) * exp.Literal.number(2),
3750            )
3751            right = exp.Substring(
3752                this=base.copy(),
3753                start=((start + length) - exp.Literal.number(1)) * exp.Literal.number(2)
3754                + exp.Literal.number(1),
3755            )
3756        else:
3757            left = exp.Substring(
3758                this=base.copy(),
3759                start=exp.Literal.number(1),
3760                length=start.copy() - exp.Literal.number(1),
3761            )
3762            right = exp.Substring(this=base.copy(), start=start + length)
3763        result: exp.Expr = exp.DPipe(
3764            this=exp.DPipe(this=left, expression=insertion), expression=right
3765        )
3766
3767        if is_binary:
3768            result = exp.Unhex(this=result)
3769
3770        return self.sql(result)
def rand_sql(self, expression: sqlglot.expressions.functions.Rand) -> str:
3772    def rand_sql(self, expression: exp.Rand) -> str:
3773        seed = expression.this
3774        if seed is not None:
3775            self.unsupported("RANDOM with seed is not supported in DuckDB")
3776
3777        lower = expression.args.get("lower")
3778        upper = expression.args.get("upper")
3779
3780        if lower and upper:
3781            # scale DuckDB's [0,1) to the specified range
3782            range_size = exp.paren(upper - lower)
3783            scaled = exp.Add(this=lower, expression=exp.func("random") * range_size)
3784
3785            # For now we assume that if bounds are set, return type is BIGINT. Snowflake/Teradata
3786            result = exp.cast(scaled, exp.DType.BIGINT)
3787            return self.sql(result)
3788
3789        # Default DuckDB behavior - just return RANDOM() as float
3790        return "RANDOM()"
def bytelength_sql(self, expression: sqlglot.expressions.string.ByteLength) -> str:
3792    def bytelength_sql(self, expression: exp.ByteLength) -> str:
3793        arg = expression.this
3794
3795        # Check if it's a text type (handles both literals and annotated expressions)
3796        if arg.is_type(*exp.DataType.TEXT_TYPES):
3797            return self.func("OCTET_LENGTH", exp.Encode(this=arg))
3798
3799        # Default: pass through as-is (conservative for DuckDB, handles binary and unannotated)
3800        return self.func("OCTET_LENGTH", arg)
def base64encode_sql(self, expression: sqlglot.expressions.string.Base64Encode) -> str:
3802    def base64encode_sql(self, expression: exp.Base64Encode) -> str:
3803        # DuckDB TO_BASE64 requires BLOB input
3804        # Snowflake BASE64_ENCODE accepts both VARCHAR and BINARY - for VARCHAR it implicitly
3805        # encodes UTF-8 bytes. We add ENCODE unless the input is a binary type.
3806        result = expression.this
3807
3808        # Check if input is a string type - ENCODE only accepts VARCHAR
3809        if result.is_type(*exp.DataType.TEXT_TYPES):
3810            result = exp.Encode(this=result)
3811
3812        result = exp.ToBase64(this=result)
3813
3814        max_line_length = expression.args.get("max_line_length")
3815        alphabet = expression.args.get("alphabet")
3816
3817        # Handle custom alphabet by replacing standard chars with custom ones
3818        result = _apply_base64_alphabet_replacements(result, alphabet)
3819
3820        # Handle max_line_length by inserting newlines every N characters
3821        line_length = (
3822            t.cast(int, max_line_length.to_py())
3823            if isinstance(max_line_length, exp.Literal) and max_line_length.is_number
3824            else 0
3825        )
3826        if line_length > 0:
3827            newline = exp.Chr(expressions=[exp.Literal.number(10)])
3828            result = exp.Trim(
3829                this=exp.RegexpReplace(
3830                    this=result,
3831                    expression=exp.Literal.string(f"(.{{{line_length}}})"),
3832                    replacement=exp.Concat(expressions=[exp.Literal.string("\\1"), newline.copy()]),
3833                ),
3834                expression=newline,
3835                position="TRAILING",
3836            )
3837
3838        return self.sql(result)
def hex_sql(self, expression: sqlglot.expressions.string.Hex) -> str:
3840    def hex_sql(self, expression: exp.Hex) -> str:
3841        case = expression.args.get("case")
3842
3843        if not case:
3844            return self.func("HEX", expression.this)
3845
3846        hex_expr = exp.Hex(this=expression.this)
3847        return self.sql(
3848            exp.case()
3849            .when(case.is_(exp.null()), exp.null())
3850            .when(case.copy().eq(0), exp.Lower(this=hex_expr.copy()))
3851            .else_(hex_expr)
3852        )
def replace_sql(self, expression: sqlglot.expressions.string.Replace) -> str:
3854    def replace_sql(self, expression: exp.Replace) -> str:
3855        result_sql = self.func(
3856            "REPLACE",
3857            _cast_to_varchar(expression.this),
3858            _cast_to_varchar(expression.expression),
3859            _cast_to_varchar(expression.args.get("replacement")),
3860        )
3861        return _gen_with_cast_to_blob(self, expression, result_sql)
def bitwisexor_sql(self, expression: sqlglot.expressions.core.BitwiseXor) -> str:
3868    def bitwisexor_sql(self, expression: exp.BitwiseXor) -> str:
3869        _prepare_binary_bitwise_args(expression)
3870        result_sql = self.func("XOR", expression.this, expression.expression)
3871        return _gen_with_cast_to_blob(self, expression, result_sql)
def objectinsert_sql(self, expression: sqlglot.expressions.json.ObjectInsert) -> str:
3873    def objectinsert_sql(self, expression: exp.ObjectInsert) -> str:
3874        this = expression.this
3875        key = expression.args.get("key")
3876        key_sql = key.name if isinstance(key, exp.Expr) else ""
3877        value_sql = self.sql(expression, "value")
3878
3879        kv_sql = f"{key_sql} := {value_sql}"
3880
3881        # If the input struct is empty e.g. transpiling OBJECT_INSERT(OBJECT_CONSTRUCT(), key, value) from Snowflake
3882        # then we can generate STRUCT_PACK which will build it since STRUCT_INSERT({}, key := value) is not valid DuckDB
3883        if isinstance(this, exp.Struct) and not this.expressions:
3884            return self.func("STRUCT_PACK", kv_sql)
3885
3886        return self.func("STRUCT_INSERT", this, kv_sql)
def mapcat_sql(self, expression: sqlglot.expressions.array.MapCat) -> str:
3888    def mapcat_sql(self, expression: exp.MapCat) -> str:
3889        result = exp.replace_placeholders(
3890            self.MAPCAT_TEMPLATE.copy(),
3891            map1=expression.this,
3892            map2=expression.expression,
3893        )
3894        return self.sql(result)
def mapcontainskey_sql(self, expression: sqlglot.expressions.array.MapContainsKey) -> str:
3896    def mapcontainskey_sql(self, expression: exp.MapContainsKey) -> str:
3897        return self.func(
3898            "ARRAY_CONTAINS", exp.func("MAP_KEYS", expression.args["key"]), expression.this
3899        )
def mapdelete_sql(self, expression: sqlglot.expressions.array.MapDelete) -> str:
3901    def mapdelete_sql(self, expression: exp.MapDelete) -> str:
3902        map_arg = expression.this
3903        keys_to_delete = expression.expressions
3904
3905        x_dot_key = exp.Dot(this=exp.to_identifier("x"), expression=exp.to_identifier("key"))
3906
3907        lambda_expr = exp.Lambda(
3908            this=exp.In(this=x_dot_key, expressions=keys_to_delete).not_(),
3909            expressions=[exp.to_identifier("x")],
3910        )
3911        result = exp.func(
3912            "MAP_FROM_ENTRIES",
3913            exp.ArrayFilter(this=exp.func("MAP_ENTRIES", map_arg), expression=lambda_expr),
3914        )
3915        return self.sql(result)
def mappick_sql(self, expression: sqlglot.expressions.array.MapPick) -> str:
3917    def mappick_sql(self, expression: exp.MapPick) -> str:
3918        map_arg = expression.this
3919        keys_to_pick = expression.expressions
3920
3921        x_dot_key = exp.Dot(this=exp.to_identifier("x"), expression=exp.to_identifier("key"))
3922
3923        if len(keys_to_pick) == 1 and keys_to_pick[0].is_type(exp.DType.ARRAY):
3924            lambda_expr = exp.Lambda(
3925                this=exp.func("ARRAY_CONTAINS", keys_to_pick[0], x_dot_key),
3926                expressions=[exp.to_identifier("x")],
3927            )
3928        else:
3929            lambda_expr = exp.Lambda(
3930                this=exp.In(this=x_dot_key, expressions=keys_to_pick),
3931                expressions=[exp.to_identifier("x")],
3932            )
3933
3934        result = exp.func(
3935            "MAP_FROM_ENTRIES",
3936            exp.func("LIST_FILTER", exp.func("MAP_ENTRIES", map_arg), lambda_expr),
3937        )
3938        return self.sql(result)
def mapsize_sql(self, expression: sqlglot.expressions.array.MapSize) -> str:
3940    def mapsize_sql(self, expression: exp.MapSize) -> str:
3941        return self.func("CARDINALITY", expression.this)
@unsupported_args('update_flag')
def mapinsert_sql(self, expression: sqlglot.expressions.array.MapInsert) -> str:
3943    @unsupported_args("update_flag")
3944    def mapinsert_sql(self, expression: exp.MapInsert) -> str:
3945        map_arg = expression.this
3946        key = expression.args.get("key")
3947        value = expression.args.get("value")
3948
3949        map_type = map_arg.type
3950
3951        if value is not None:
3952            if map_type and map_type.expressions and len(map_type.expressions) > 1:
3953                # Extract the value type from MAP(key_type, value_type)
3954                value_type = map_type.expressions[1]
3955                # Cast value to match the map's value type to avoid type conflicts
3956                value = exp.cast(value, value_type)
3957            # else: polymorphic MAP case - no type parameters available, use value as-is
3958
3959        # Create a single-entry map for the new key-value pair
3960        new_entry_struct = exp.Struct(expressions=[exp.PropertyEQ(this=key, expression=value)])
3961        new_entry: exp.Expression = exp.ToMap(this=new_entry_struct)
3962
3963        # Use MAP_CONCAT to merge the original map with the new entry
3964        # This automatically handles both insert and update cases
3965        result = exp.func("MAP_CONCAT", map_arg, new_entry)
3966
3967        return self.sql(result)
def startswith_sql(self, expression: sqlglot.expressions.string.StartsWith) -> str:
3969    def startswith_sql(self, expression: exp.StartsWith) -> str:
3970        return self.func(
3971            "STARTS_WITH",
3972            _cast_to_varchar(expression.this),
3973            _cast_to_varchar(expression.expression),
3974        )
def space_sql(self, expression: sqlglot.expressions.string.Space) -> str:
3976    def space_sql(self, expression: exp.Space) -> str:
3977        # DuckDB's REPEAT requires BIGINT for the count parameter
3978        return self.sql(
3979            exp.Repeat(
3980                this=exp.Literal.string(" "),
3981                times=exp.cast(expression.this, exp.DType.BIGINT),
3982            )
3983        )
def tablefromrows_sql(self, expression: sqlglot.expressions.query.TableFromRows) -> str:
3985    def tablefromrows_sql(self, expression: exp.TableFromRows) -> str:
3986        # For GENERATOR, unwrap TABLE() - just emit the Generator (becomes RANGE)
3987        if isinstance(expression.this, exp.Generator):
3988            # Preserve alias, joins, and other table-level args
3989            table = exp.Table(
3990                this=expression.this,
3991                alias=expression.args.get("alias"),
3992                joins=expression.args.get("joins"),
3993            )
3994            return self.sql(table)
3995
3996        return super().tablefromrows_sql(expression)
def unnest_sql(self, expression: sqlglot.expressions.array.Unnest) -> str:
3998    def unnest_sql(self, expression: exp.Unnest) -> str:
3999        explode_array = expression.args.get("explode_array")
4000        if explode_array:
4001            # In BigQuery, UNNESTing a nested array leads to explosion of the top-level array & struct
4002            # This is transpiled to DDB by transforming "FROM UNNEST(...)" to "FROM (SELECT UNNEST(..., max_depth => 2))"
4003            expression.expressions.append(
4004                exp.Kwarg(this=exp.var("max_depth"), expression=exp.Literal.number(2))
4005            )
4006
4007            # If BQ's UNNEST is aliased, we transform it from a column alias to a table alias in DDB
4008            alias = expression.args.get("alias")
4009            if isinstance(alias, exp.TableAlias):
4010                expression.set("alias", None)
4011                if alias.columns:
4012                    alias = exp.TableAlias(this=seq_get(alias.columns, 0))
4013
4014            unnest_sql = super().unnest_sql(expression)
4015            select = exp.Select(expressions=[unnest_sql]).subquery(alias)
4016            return self.sql(select)
4017
4018        return super().unnest_sql(expression)
def arrayagg_sql(self, expression: sqlglot.expressions.aggregate.ArrayAgg) -> str:
4020    def arrayagg_sql(self, expression: exp.ArrayAgg) -> str:
4021        if isinstance(expression.this, exp.Limit):
4022            self.unsupported("LIMIT inside ARRAY_AGG is not supported in DuckDB")
4023
4024        return super().arrayagg_sql(expression)
def ignorenulls_sql(self, expression: sqlglot.expressions.core.IgnoreNulls) -> str:
4026    def ignorenulls_sql(self, expression: exp.IgnoreNulls) -> str:
4027        this = expression.this
4028
4029        if isinstance(this, self.IGNORE_RESPECT_NULLS_WINDOW_FUNCTIONS):
4030            # DuckDB should render IGNORE NULLS only for the general-purpose
4031            # window functions that accept it e.g. FIRST_VALUE(... IGNORE NULLS) OVER (...)
4032            return super().ignorenulls_sql(expression)
4033
4034        # For ARRAY_AGG(expr IGNORE NULLS ...), convert IGNORE NULLS to a
4035        # FILTER(WHERE expr IS NOT NULL) clause by setting nulls_excluded on
4036        # the ArrayAgg.  The existing _add_arrayagg_null_filter method will
4037        # emit the FILTER clause during arrayagg_sql / withingroup_sql.
4038        if isinstance(this, exp.ArrayAgg):
4039            this.set("nulls_excluded", True)
4040            return self.sql(this)
4041
4042        if isinstance(this, exp.First):
4043            this = exp.AnyValue(this=this.this)
4044
4045        if not isinstance(this, (exp.AnyValue, exp.ApproxQuantiles)):
4046            self.unsupported("IGNORE NULLS is not supported for non-window functions.")
4047
4048        return self.sql(this)
def split_sql(self, expression: sqlglot.expressions.string.Split) -> str:
4050    def split_sql(self, expression: exp.Split) -> str:
4051        base_func = exp.func("STR_SPLIT", expression.this, expression.expression)
4052
4053        case_expr = exp.case().else_(base_func)
4054        needs_case = False
4055
4056        if expression.args.get("null_returns_null"):
4057            case_expr = case_expr.when(expression.expression.is_(exp.null()), exp.null())
4058            needs_case = True
4059
4060        if expression.args.get("empty_delimiter_returns_whole"):
4061            # When delimiter is empty string, return input string as single array element
4062            array_with_input = exp.array(expression.this)
4063            case_expr = case_expr.when(
4064                expression.expression.eq(exp.Literal.string("")), array_with_input
4065            )
4066            needs_case = True
4067
4068        return self.sql(case_expr if needs_case else base_func)
def splitpart_sql(self, expression: sqlglot.expressions.string.SplitPart) -> str:
4070    def splitpart_sql(self, expression: exp.SplitPart) -> str:
4071        string_arg = expression.this
4072        delimiter_arg = expression.args.get("delimiter")
4073        part_index_arg = expression.args.get("part_index")
4074
4075        if delimiter_arg and part_index_arg:
4076            # Handle Snowflake's "index 0 and 1 both return first element" behavior
4077            if expression.args.get("part_index_zero_as_one"):
4078                # Convert 0 to 1 for compatibility
4079
4080                part_index_arg = exp.Paren(
4081                    this=exp.case()
4082                    .when(part_index_arg.eq(exp.Literal.number("0")), exp.Literal.number("1"))
4083                    .else_(part_index_arg)
4084                )
4085
4086            # Use Anonymous to avoid recursion
4087            base_func_expr: exp.Expr = exp.Anonymous(
4088                this="SPLIT_PART", expressions=[string_arg, delimiter_arg, part_index_arg]
4089            )
4090            needs_case_transform = False
4091            case_expr = exp.case().else_(base_func_expr)
4092
4093            if expression.args.get("empty_delimiter_returns_whole"):
4094                # When delimiter is empty string:
4095                # - Return whole string if part_index is 1 or -1
4096                # - Return empty string otherwise
4097                empty_case = exp.Paren(
4098                    this=exp.case()
4099                    .when(
4100                        exp.or_(
4101                            part_index_arg.eq(exp.Literal.number("1")),
4102                            part_index_arg.eq(exp.Literal.number("-1")),
4103                        ),
4104                        string_arg,
4105                    )
4106                    .else_(exp.Literal.string(""))
4107                )
4108
4109                case_expr = case_expr.when(delimiter_arg.eq(exp.Literal.string("")), empty_case)
4110                needs_case_transform = True
4111
4112            """
4113            Output looks something like this:
4114
4115            CASE
4116            WHEN delimiter is '' THEN
4117                (
4118                    CASE
4119                    WHEN adjusted_part_index = 1 OR adjusted_part_index = -1 THEN input
4120                    ELSE '' END
4121                )
4122            ELSE SPLIT_PART(input, delimiter, adjusted_part_index)
4123            END
4124
4125            """
4126            return self.sql(case_expr if needs_case_transform else base_func_expr)
4127
4128        return self.function_fallback_sql(expression)
def respectnulls_sql(self, expression: sqlglot.expressions.core.RespectNulls) -> str:
4130    def respectnulls_sql(self, expression: exp.RespectNulls) -> str:
4131        if isinstance(expression.this, self.IGNORE_RESPECT_NULLS_WINDOW_FUNCTIONS):
4132            # DuckDB should render RESPECT NULLS only for the general-purpose
4133            # window functions that accept it e.g. FIRST_VALUE(... RESPECT NULLS) OVER (...)
4134            return super().respectnulls_sql(expression)
4135
4136        self.unsupported("RESPECT NULLS is not supported for non-window functions.")
4137        return self.sql(expression, "this")
def arraytostring_sql(self, expression: sqlglot.expressions.array.ArrayToString) -> str:
4139    def arraytostring_sql(self, expression: exp.ArrayToString) -> str:
4140        null = expression.args.get("null")
4141
4142        if expression.args.get("null_is_empty"):
4143            x = exp.to_identifier("x")
4144            list_transform = exp.Transform(
4145                this=expression.this.copy(),
4146                expression=exp.Lambda(
4147                    this=exp.Coalesce(
4148                        this=exp.cast(x, "TEXT"), expressions=[exp.Literal.string("")]
4149                    ),
4150                    expressions=[x],
4151                ),
4152            )
4153            array_to_string = exp.ArrayToString(
4154                this=list_transform, expression=expression.expression
4155            )
4156            if expression.args.get("null_delim_is_null"):
4157                return self.sql(
4158                    exp.case()
4159                    .when(expression.expression.copy().is_(exp.null()), exp.null())
4160                    .else_(array_to_string)
4161                )
4162            return self.sql(array_to_string)
4163
4164        if null:
4165            x = exp.to_identifier("x")
4166            return self.sql(
4167                exp.ArrayToString(
4168                    this=exp.Transform(
4169                        this=expression.this,
4170                        expression=exp.Lambda(
4171                            this=exp.Coalesce(this=exp.cast(x, "TEXT"), expressions=[null]),
4172                            expressions=[x],
4173                        ),
4174                    ),
4175                    expression=expression.expression,
4176                )
4177            )
4178
4179        return self.func("ARRAY_TO_STRING", expression.this, expression.expression)
def concatws_sql(self, expression: sqlglot.expressions.string.ConcatWs) -> str:
4181    def concatws_sql(self, expression: exp.ConcatWs) -> str:
4182        # DuckDB-specific: handle binary types using DPipe (||) operator
4183        separator = seq_get(expression.expressions, 0)
4184        args = expression.expressions[1:]
4185
4186        if any(_is_binary(arg) for arg in [separator, *args]):
4187            result = args[0]
4188            for arg in args[1:]:
4189                result = exp.DPipe(
4190                    this=exp.DPipe(this=result, expression=separator), expression=arg
4191                )
4192            return self.sql(result)
4193
4194        if expression.args.get("flatten") and any(
4195            isinstance(arg, exp.Array) or arg.is_type(exp.DType.ARRAY) for arg in args
4196        ):
4197            arrays = []
4198            for arg in args:
4199                if isinstance(arg, exp.Array) or arg.is_type(exp.DType.ARRAY):
4200                    arg = exp.cast(arg, "TEXT[]")
4201                else:
4202                    if (not arg.type or arg.is_type(exp.DType.UNKNOWN)) and not isinstance(
4203                        arg, exp.CONSTANTS
4204                    ):
4205                        self.unsupported(
4206                            "Cannot transpile CONCAT_WS with unknown argument types to DuckDB."
4207                        )
4208                    arg = exp.array(exp.cast(arg, exp.DType.TEXT))
4209
4210                arrays.append(arg)
4211
4212            array = (
4213                exp.ArrayConcat(this=arrays[0], expressions=arrays[1:])
4214                if len(arrays) > 1
4215                else arrays[0]
4216            )
4217            return self.func("CONCAT_WS", separator, self.func("ARRAY_TO_STRING", array, separator))
4218
4219        return super().concatws_sql(expression)
def regexpextract_sql(self, expression: sqlglot.expressions.string.RegexpExtract) -> str:
4274    def regexpextract_sql(self, expression: exp.RegexpExtract) -> str:
4275        return self._regexp_extract_sql(expression)
def regexpextractall_sql(self, expression: sqlglot.expressions.string.RegexpExtractAll) -> str:
4277    def regexpextractall_sql(self, expression: exp.RegexpExtractAll) -> str:
4278        return self._regexp_extract_sql(expression)
def regexpinstr_sql(self, expression: sqlglot.expressions.string.RegexpInstr) -> str:
4280    def regexpinstr_sql(self, expression: exp.RegexpInstr) -> str:
4281        this = expression.this
4282        pattern = expression.expression
4283        position = expression.args.get("position")
4284        orig_occ = expression.args.get("occurrence")
4285        occurrence = orig_occ or exp.Literal.number(1)
4286        option = expression.args.get("option")
4287        parameters = expression.args.get("parameters")
4288
4289        validated_flags = self._validate_regexp_flags(parameters, supported_flags="ims")
4290        # Checked before the flags are embedded, which would make the pattern non-empty
4291        is_empty = pattern.copy().eq(exp.Literal.string(""))
4292        if validated_flags:
4293            pattern = exp.Concat(expressions=[exp.Literal.string(f"(?{validated_flags})"), pattern])
4294
4295        # Handle starting position offset
4296        pos_offset: exp.Expr = exp.Literal.number(0)
4297        if position and (not position.is_int or position.to_py() > 1):
4298            this = exp.Substring(this=this, start=position)
4299            pos_offset = position - exp.Literal.number(1)
4300
4301        # Helper: LIST_SUM(LIST_TRANSFORM(list[1:end], x -> LENGTH(x)))
4302        def sum_lengths(func_name: str, end: exp.Expr) -> exp.Expr:
4303            lst = exp.Bracket(
4304                this=exp.Anonymous(this=func_name, expressions=[this, pattern]),
4305                expressions=[exp.Slice(this=exp.Literal.number(1), expression=end)],
4306                offset=1,
4307            )
4308            transform = exp.Anonymous(
4309                this="LIST_TRANSFORM",
4310                expressions=[
4311                    lst,
4312                    exp.Lambda(
4313                        this=exp.Length(this=exp.to_identifier("x")),
4314                        expressions=[exp.to_identifier("x")],
4315                    ),
4316                ],
4317            )
4318            return exp.Coalesce(
4319                this=exp.Anonymous(this="LIST_SUM", expressions=[transform]),
4320                expressions=[exp.Literal.number(0)],
4321            )
4322
4323        # Position = 1 + sum(split_lengths[1:occ]) + sum(match_lengths[1:occ-1]) + offset
4324        base_pos: exp.Expr = (
4325            exp.Literal.number(1)
4326            + sum_lengths("STRING_SPLIT_REGEX", occurrence)
4327            + sum_lengths("REGEXP_EXTRACT_ALL", occurrence - exp.Literal.number(1))
4328            + pos_offset
4329        )
4330
4331        # option=1: add match length for end position
4332        if option and option.is_int and option.to_py() == 1:
4333            match_at_occ = exp.Bracket(
4334                this=exp.Anonymous(this="REGEXP_EXTRACT_ALL", expressions=[this, pattern]),
4335                expressions=[occurrence],
4336                offset=1,
4337            )
4338            base_pos = base_pos + exp.Coalesce(
4339                this=exp.Length(this=match_at_occ), expressions=[exp.Literal.number(0)]
4340            )
4341
4342        # NULL checks for all provided arguments
4343        # .copy() is used strictly because .is_() alters the node's parent pointer, mutating the parsed AST
4344        null_args = [
4345            expression.this,
4346            expression.expression,
4347            position,
4348            orig_occ,
4349            option,
4350            parameters,
4351        ]
4352        null_checks = [arg.copy().is_(exp.Null()) for arg in null_args if arg]
4353
4354        matches = exp.Anonymous(this="REGEXP_EXTRACT_ALL", expressions=[this, pattern])
4355
4356        return self.sql(
4357            exp.case()
4358            .when(exp.or_(*null_checks), exp.Null())
4359            .when(is_empty, exp.Literal.number(0))
4360            .when(exp.Length(this=matches) < occurrence, exp.Literal.number(0))
4361            .else_(base_pos)
4362        )
@unsupported_args('culture')
def numbertostr_sql(self, expression: sqlglot.expressions.string.NumberToStr) -> str:
4364    @unsupported_args("culture")
4365    def numbertostr_sql(self, expression: exp.NumberToStr) -> str:
4366        fmt = expression.args.get("format")
4367        if fmt and fmt.is_int:
4368            return self.func("FORMAT", f"'{{:,.{fmt.name}f}}'", expression.this)
4369
4370        self.unsupported("Only integer formats are supported by NumberToStr")
4371        return self.function_fallback_sql(expression)
def autoincrementcolumnconstraint_sql(self, _) -> str:
4373    def autoincrementcolumnconstraint_sql(self, _) -> str:
4374        self.unsupported("The AUTOINCREMENT column constraint is not supported by DuckDB")
4375        return ""
def aliases_sql(self, expression: sqlglot.expressions.core.Aliases) -> str:
4377    def aliases_sql(self, expression: exp.Aliases) -> str:
4378        this = expression.this
4379        if isinstance(this, exp.Posexplode):
4380            return self.posexplode_sql(this)
4381
4382        return super().aliases_sql(expression)
def posexplode_sql(self, expression: sqlglot.expressions.array.Posexplode) -> str:
4384    def posexplode_sql(self, expression: exp.Posexplode) -> str:
4385        this = expression.this
4386        parent = expression.parent
4387
4388        # The default Spark aliases are "pos" and "col", unless specified otherwise
4389        pos, col = exp.to_identifier("pos"), exp.to_identifier("col")
4390
4391        if isinstance(parent, exp.Aliases):
4392            # Column case: SELECT POSEXPLODE(col) [AS (a, b)]
4393            pos, col = parent.expressions
4394        elif isinstance(parent, exp.Table):
4395            # Table case: SELECT * FROM POSEXPLODE(col) [AS (a, b)]
4396            alias = parent.args.get("alias")
4397            if alias:
4398                pos, col = alias.columns or [pos, col]
4399                alias.pop()
4400
4401        # Translate POSEXPLODE to UNNEST + GENERATE_SUBSCRIPTS
4402        # Note: In Spark pos is 0-indexed, but in DuckDB it's 1-indexed, so we subtract 1 from GENERATE_SUBSCRIPTS
4403        unnest_sql = self.sql(exp.Unnest(expressions=[this], alias=col))
4404        gen_subscripts = self.sql(
4405            exp.Alias(
4406                this=exp.Anonymous(
4407                    this="GENERATE_SUBSCRIPTS", expressions=[this, exp.Literal.number(1)]
4408                )
4409                - exp.Literal.number(1),
4410                alias=pos,
4411            )
4412        )
4413
4414        posexplode_sql = self.format_args(gen_subscripts, unnest_sql)
4415
4416        if isinstance(parent, exp.From) or (parent and isinstance(parent.parent, exp.From)):
4417            # SELECT * FROM POSEXPLODE(col) -> SELECT * FROM (SELECT GENERATE_SUBSCRIPTS(...), UNNEST(...))
4418            return self.sql(exp.Subquery(this=exp.Select(expressions=[posexplode_sql])))
4419
4420        return posexplode_sql
def addmonths_sql(self, expression: sqlglot.expressions.temporal.AddMonths) -> str:
4422    def addmonths_sql(self, expression: exp.AddMonths) -> str:
4423        """
4424        Handles three key issues:
4425        1. Float/decimal months: e.g., Snowflake rounds, whereas DuckDB INTERVAL requires integers
4426        2. End-of-month preservation: If input is last day of month, result is last day of result month
4427        3. Type preservation: Maintains DATE/TIMESTAMPTZ types (DuckDB defaults to TIMESTAMP)
4428        """
4429        from sqlglot.optimizer.annotate_types import annotate_types
4430
4431        this = expression.this
4432        if not this.type:
4433            this = annotate_types(this, dialect=self.dialect)
4434
4435        if this.is_type(*exp.DataType.TEXT_TYPES):
4436            this = exp.Cast(this=this, to=exp.DataType(this=exp.DType.TIMESTAMP))
4437
4438        # Detect float/decimal months to apply rounding (Snowflake behavior)
4439        # DuckDB INTERVAL syntax doesn't support non-integer expressions, so use TO_MONTHS
4440        months_expr = expression.expression
4441        if not months_expr.type:
4442            months_expr = annotate_types(months_expr, dialect=self.dialect)
4443
4444        # Build interval or to_months expression based on type
4445        # Float/decimal case: Round and use TO_MONTHS(CAST(ROUND(value) AS INT))
4446        interval_or_to_months = (
4447            exp.func("TO_MONTHS", exp.cast(exp.func("ROUND", months_expr), "INT"))
4448            if months_expr.is_type(
4449                exp.DType.FLOAT,
4450                exp.DType.DOUBLE,
4451                exp.DType.DECIMAL,
4452            )
4453            # Integer case: standard INTERVAL N MONTH syntax
4454            else exp.Interval(this=months_expr, unit=exp.var("MONTH"))
4455        )
4456
4457        date_add_expr = exp.Add(this=this, expression=interval_or_to_months)
4458
4459        # Apply end-of-month preservation if Snowflake flag is set
4460        # CASE WHEN LAST_DAY(date) = date THEN LAST_DAY(result) ELSE result END
4461        preserve_eom = expression.args.get("preserve_end_of_month")
4462        result_expr = (
4463            exp.case()
4464            .when(
4465                exp.EQ(this=exp.func("LAST_DAY", this), expression=this),
4466                exp.func("LAST_DAY", date_add_expr),
4467            )
4468            .else_(date_add_expr)
4469            if preserve_eom
4470            else date_add_expr
4471        )
4472
4473        # DuckDB's DATE_ADD function returns TIMESTAMP/DATETIME by default, even when the input is DATE
4474        # To match for example Snowflake's ADD_MONTHS behavior (which preserves the input type)
4475        # We need to cast the result back to the original type when the input is DATE or TIMESTAMPTZ
4476        # Example: ADD_MONTHS('2023-01-31'::date, 1) should return DATE, not TIMESTAMP
4477        if this.is_type(exp.DType.DATE, exp.DType.TIMESTAMPTZ):
4478            return self.sql(exp.Cast(this=result_expr, to=this.type))
4479        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:
4481    def format_sql(self, expression: exp.Format) -> str:
4482        if expression.name.lower() == "%s" and len(expression.expressions) == 1:
4483            return self.func("FORMAT", "'{}'", expression.expressions[0])
4484
4485        return self.function_fallback_sql(expression)
def hexstring_sql( self, expression: sqlglot.expressions.query.HexString, binary_function_repr: str | None = None) -> str:
4487    def hexstring_sql(
4488        self, expression: exp.HexString, binary_function_repr: str | None = None
4489    ) -> str:
4490        # UNHEX('FF') correctly produces blob \xFF in DuckDB
4491        return super().hexstring_sql(expression, binary_function_repr="UNHEX")
def datetrunc_sql(self, expression: sqlglot.expressions.temporal.DateTrunc) -> str:
4493    def datetrunc_sql(self, expression: exp.DateTrunc) -> str:
4494        unit = expression.args.get("unit")
4495        date = expression.this
4496
4497        week_start = _week_trunc_start_dow(unit)
4498        unit = unit_to_str(expression)
4499
4500        if week_start:
4501            result = self.sql(
4502                _build_week_trunc_expression(date, week_start, preserve_start_day=True)
4503            )
4504        else:
4505            result = self.func("DATE_TRUNC", unit, date)
4506
4507        if (
4508            expression.args.get("input_type_preserved")
4509            and date.is_type(*exp.DataType.TEMPORAL_TYPES)
4510            and not (is_date_unit(unit) and date.is_type(exp.DType.DATE))
4511        ):
4512            return self.sql(exp.Cast(this=result, to=date.type))
4513
4514        return result
def datetimetrunc_sql(self, expression: sqlglot.expressions.temporal.DatetimeTrunc) -> str:
4516    def datetimetrunc_sql(self, expression: exp.DatetimeTrunc) -> str:
4517        this = exp.cast(expression.this, exp.DType.DATETIME)
4518        week_start = _week_trunc_start_dow(expression.args.get("unit"))
4519        if week_start:
4520            return self.sql(
4521                _build_week_trunc_expression(
4522                    this, week_start, preserve_start_day=True, cast_to_date=False
4523                )
4524            )
4525
4526        return self.func("DATE_TRUNC", unit_to_str(expression), this)
def timestamptrunc_sql(self, expression: sqlglot.expressions.temporal.TimestampTrunc) -> str:
4528    def timestamptrunc_sql(self, expression: exp.TimestampTrunc) -> str:
4529        zone = expression.args.get("zone")
4530        timestamp = expression.this
4531        week_start = _week_trunc_start_dow(expression.args.get("unit"))
4532
4533        # The week start emulation below is exact, so avoid weekstart_unit_to_str's degrade warning
4534        unit = unit_to_str(expression) if week_start else weekstart_unit_to_str(self, expression)
4535        date_unit = is_date_unit(unit) or bool(week_start)
4536
4537        def _trunc_expr(this: exp.Expr) -> exp.Expr:
4538            if week_start:
4539                return _build_week_trunc_expression(
4540                    this, week_start, preserve_start_day=True, cast_to_date=False
4541                )
4542            return exp.func("DATE_TRUNC", unit, this)
4543
4544        if date_unit and zone:
4545            # BigQuery's TIMESTAMP_TRUNC with timezone truncates in the target timezone and returns as UTC.
4546            # Double AT TIME ZONE needed for BigQuery compatibility:
4547            # 1. First AT TIME ZONE: ensures truncation happens in the target timezone
4548            # 2. Second AT TIME ZONE: converts the DATE result back to TIMESTAMPTZ (preserving time component)
4549            timestamp = exp.AtTimeZone(this=timestamp, zone=zone)
4550            trunced = _trunc_expr(timestamp)
4551            if isinstance(trunced, exp.DateAdd):
4552                # Parenthesize so the trailing AT TIME ZONE binds to the whole shifted expression
4553                trunced = exp.Paren(this=trunced)
4554            return self.sql(exp.AtTimeZone(this=trunced, zone=zone))
4555
4556        result = self.sql(_trunc_expr(timestamp))
4557        if expression.args.get("input_type_preserved"):
4558            if timestamp.type and timestamp.is_type(exp.DType.TIME, exp.DType.TIMETZ):
4559                dummy_date = exp.Cast(
4560                    this=exp.Literal.string("1970-01-01"),
4561                    to=exp.DataType(this=exp.DType.DATE),
4562                )
4563                date_time = exp.Add(this=dummy_date, expression=timestamp)
4564                result = self.func("DATE_TRUNC", unit, date_time)
4565                return self.sql(exp.Cast(this=result, to=timestamp.type))
4566
4567            if timestamp.is_type(*exp.DataType.TEMPORAL_TYPES) and not (
4568                date_unit and timestamp.is_type(exp.DType.DATE)
4569            ):
4570                return self.sql(exp.Cast(this=result, to=timestamp.type))
4571
4572        return result
def trim_sql(self, expression: sqlglot.expressions.string.Trim) -> str:
4574    def trim_sql(self, expression: exp.Trim) -> str:
4575        expression.this.replace(_cast_to_varchar(expression.this))
4576        if expression.expression:
4577            expression.expression.replace(_cast_to_varchar(expression.expression))
4578
4579        result_sql = super().trim_sql(expression)
4580        return _gen_with_cast_to_blob(self, expression, result_sql)
def round_sql(self, expression: sqlglot.expressions.math.Round) -> str:
4582    def round_sql(self, expression: exp.Round) -> str:
4583        this = expression.this
4584        decimals = expression.args.get("decimals")
4585        truncate = expression.args.get("truncate")
4586
4587        # DuckDB requires the scale (decimals) argument to be an INT
4588        # Some dialects (e.g., Snowflake) allow non-integer scales and cast to an integer internally
4589        if decimals is not None and expression.args.get("casts_non_integer_decimals"):
4590            if not (decimals.is_int or decimals.is_type(*exp.DataType.INTEGER_TYPES)):
4591                decimals = exp.cast(decimals, exp.DType.INT)
4592
4593        func = "ROUND"
4594        if truncate:
4595            # BigQuery uses ROUND_HALF_EVEN; Snowflake uses HALF_TO_EVEN
4596            if truncate.this in ("ROUND_HALF_EVEN", "HALF_TO_EVEN"):
4597                func = "ROUND_EVEN"
4598                truncate = None
4599            # BigQuery uses ROUND_HALF_AWAY_FROM_ZERO; Snowflake uses HALF_AWAY_FROM_ZERO
4600            elif truncate.this in ("ROUND_HALF_AWAY_FROM_ZERO", "HALF_AWAY_FROM_ZERO"):
4601                truncate = None
4602
4603        return self.func(func, this, decimals, truncate)
def trycast_sql(self, expression: sqlglot.expressions.functions.TryCast) -> str:
4605    def trycast_sql(self, expression: exp.TryCast) -> str:
4606        to = expression.to
4607        to_type = to.this
4608        src = expression.this
4609
4610        if (
4611            expression.args.get("null_on_text_overflow")
4612            and to_type in exp.DataType.TEXT_TYPES
4613            and to.expressions
4614        ):
4615            return self.sql(
4616                exp.case()
4617                .when(
4618                    exp.LTE(this=exp.func("LENGTH", src), expression=to.expressions[0].this),
4619                    exp.cast(src, "TEXT"),
4620                )
4621                .else_(exp.Null())
4622            )
4623        elif to_type == exp.DType.DATE and expression.args.get("probe_date_format"):
4624            slash_strptime = exp.cast(
4625                exp.func("TRY_STRPTIME", src, exp.Literal.string(self._TRYCAST_DATE_SLASH_FMT)),
4626                "DATE",
4627            )
4628            mon_strptime = exp.cast(
4629                exp.func("TRY_STRPTIME", src, exp.Literal.string(self._TRYCAST_DATE_MON_FMT)),
4630                "DATE",
4631            )
4632            return self.sql(
4633                exp.case()
4634                .when(exp.func("CONTAINS", src, exp.Literal.string("/")), slash_strptime)
4635                .when(
4636                    exp.RegexpLike(this=src, expression=exp.Literal.string("[A-Za-z]")),
4637                    mon_strptime,
4638                )
4639                .else_(exp.TryCast(this=src, to=to))
4640            )
4641        elif (
4642            isinstance(to_type, exp.Interval)
4643            and (unit := to_type.unit)
4644            and expression.args.get("requires_string")
4645        ):
4646            interval_type = exp.DataType.build("INTERVAL")
4647            if isinstance(unit, exp.IntervalSpan):
4648                self.unsupported(
4649                    "TRY_CAST to INTERVAL with span (e.g. HOUR TO MINUTE) is not supported in DuckDB"
4650                )
4651                return self.sql(exp.TryCast(this=src, to=interval_type))
4652            return self.sql(
4653                exp.TryCast(
4654                    this=exp.DPipe(this=src, expression=exp.Literal.string(f" {unit.name}")),
4655                    to=interval_type,
4656                )
4657            )
4658
4659        return super().trycast_sql(expression)
def strtok_sql(self, expression: sqlglot.expressions.string.Strtok) -> str:
4661    def strtok_sql(self, expression: exp.Strtok) -> str:
4662        string_arg = expression.this
4663        delimiter_arg = expression.args.get("delimiter")
4664        part_index_arg = expression.args.get("part_index")
4665
4666        if delimiter_arg and part_index_arg:
4667            # Escape regex chars and build character class at runtime using REGEXP_REPLACE
4668            escaped_delimiter = exp.Anonymous(
4669                this="REGEXP_REPLACE",
4670                expressions=[
4671                    delimiter_arg,
4672                    exp.Literal.string(
4673                        r"([\[\]^.\-*+?(){}|$\\])"
4674                    ),  # Escape problematic regex chars
4675                    exp.Literal.string(
4676                        r"\\\1"
4677                    ),  # Replace with escaped version using $1 backreference
4678                    exp.Literal.string("g"),  # Global flag
4679                ],
4680            )
4681            # CASE WHEN delimiter = '' THEN '' ELSE CONCAT('[', escaped_delimiter, ']') END
4682            regex_pattern = (
4683                exp.case()
4684                .when(delimiter_arg.eq(exp.Literal.string("")), exp.Literal.string(""))
4685                .else_(
4686                    exp.func(
4687                        "CONCAT",
4688                        exp.Literal.string("["),
4689                        escaped_delimiter,
4690                        exp.Literal.string("]"),
4691                    )
4692                )
4693            )
4694
4695            # STRTOK skips empty strings, so we need to filter them out
4696            # LIST_FILTER(REGEXP_SPLIT_TO_ARRAY(string, pattern), x -> x != '')[index]
4697            split_array = exp.func("REGEXP_SPLIT_TO_ARRAY", string_arg, regex_pattern)
4698            x = exp.to_identifier("x")
4699            is_empty = x.eq(exp.Literal.string(""))
4700            filtered_array = exp.func(
4701                "LIST_FILTER",
4702                split_array,
4703                exp.Lambda(this=exp.not_(is_empty.copy()), expressions=[x.copy()]),
4704            )
4705            base_func = exp.Bracket(
4706                this=filtered_array,
4707                expressions=[part_index_arg],
4708                offset=1,
4709            )
4710
4711            # Use template with the built regex pattern
4712            result = exp.replace_placeholders(
4713                self.STRTOK_TEMPLATE.copy(),
4714                string=string_arg,
4715                delimiter=delimiter_arg,
4716                part_index=part_index_arg,
4717                base_func=base_func,
4718            )
4719
4720            return self.sql(result)
4721
4722        return self.function_fallback_sql(expression)
def strtoktoarray_sql(self, expression: sqlglot.expressions.array.StrtokToArray) -> str:
4724    def strtoktoarray_sql(self, expression: exp.StrtokToArray) -> str:
4725        string_arg = expression.this
4726        delimiter_arg = expression.args.get("expression") or exp.Literal.string(" ")
4727
4728        escaped = exp.RegexpReplace(
4729            this=delimiter_arg.copy(),
4730            expression=exp.Literal.string(r"([\[\]^.\-*+?(){}|$\\])"),
4731            replacement=exp.Literal.string(r"\\\1"),
4732            modifiers=exp.Literal.string("g"),
4733        )
4734        return self.sql(
4735            exp.replace_placeholders(
4736                self.STRTOK_TO_ARRAY_TEMPLATE.copy(),
4737                string=string_arg,
4738                delimiter=delimiter_arg,
4739                escaped=escaped,
4740            )
4741        )
def approxquantile_sql(self, expression: sqlglot.expressions.aggregate.ApproxQuantile) -> str:
4743    def approxquantile_sql(self, expression: exp.ApproxQuantile) -> str:
4744        result = self.func("APPROX_QUANTILE", expression.this, expression.args.get("quantile"))
4745
4746        # DuckDB returns integers for APPROX_QUANTILE, cast to DOUBLE if the expected type is a real type
4747        if expression.is_type(*exp.DataType.REAL_TYPES):
4748            result = f"CAST({result} AS DOUBLE)"
4749
4750        return result
def approxquantiles_sql(self, expression: sqlglot.expressions.aggregate.ApproxQuantiles) -> str:
4752    def approxquantiles_sql(self, expression: exp.ApproxQuantiles) -> str:
4753        """
4754        BigQuery's APPROX_QUANTILES(expr, n) returns an array of n+1 approximate quantile values
4755        dividing the input distribution into n equal-sized buckets.
4756
4757        Both BigQuery and DuckDB use approximate algorithms for quantile estimation, but BigQuery
4758        does not document the specific algorithm used so results may differ. DuckDB does not
4759        support RESPECT NULLS.
4760        """
4761        this = expression.this
4762        if isinstance(this, exp.Distinct):
4763            # APPROX_QUANTILES requires 2 args and DISTINCT node grabs both
4764            if len(this.expressions) < 2:
4765                self.unsupported("APPROX_QUANTILES requires a bucket count argument")
4766                return self.function_fallback_sql(expression)
4767            num_quantiles_expr = this.expressions[1].pop()
4768        else:
4769            num_quantiles_expr = expression.expression
4770
4771        if not isinstance(num_quantiles_expr, exp.Literal) or not num_quantiles_expr.is_int:
4772            self.unsupported("APPROX_QUANTILES bucket count must be a positive integer")
4773            return self.function_fallback_sql(expression)
4774
4775        num_quantiles = t.cast(int, num_quantiles_expr.to_py())
4776        if num_quantiles <= 0:
4777            self.unsupported("APPROX_QUANTILES bucket count must be a positive integer")
4778            return self.function_fallback_sql(expression)
4779
4780        quantiles = [
4781            exp.Literal.number(Decimal(i) / Decimal(num_quantiles))
4782            for i in range(num_quantiles + 1)
4783        ]
4784
4785        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:
4787    def jsonextractscalar_sql(self, expression: exp.JSONExtractScalar) -> str:
4788        if expression.args.get("scalar_only"):
4789            json_value = exp.JSONExtractScalar(
4790                this=rename_func("JSON_VALUE")(self, expression), expression="'$'"
4791            )
4792
4793            # `->>` binds looser than most operators, so the wrap logic needs the parent
4794            json_value.parent = expression.parent
4795            expression = json_value
4796
4797        return _arrow_json_extract_sql(self, expression)
def bitwisenot_sql(self, expression: sqlglot.expressions.core.BitwiseNot) -> str:
4799    def bitwisenot_sql(self, expression: exp.BitwiseNot) -> str:
4800        this = expression.this
4801
4802        if _is_binary(this):
4803            expression.type = exp.DType.BINARY.into_expr()
4804
4805        arg = _cast_to_bit(this)
4806
4807        if isinstance(this, exp.Neg):
4808            arg = exp.Paren(this=arg)
4809
4810        expression.set("this", arg)
4811
4812        result_sql = f"~{self.sql(expression, 'this')}"
4813
4814        return _gen_with_cast_to_blob(self, expression, result_sql)
def window_sql(self, expression: sqlglot.expressions.query.Window) -> str:
4816    def window_sql(self, expression: exp.Window) -> str:
4817        this = expression.this
4818        if isinstance(this, exp.Corr) or (
4819            isinstance(this, exp.Filter) and isinstance(this.this, exp.Corr)
4820        ):
4821            return self._corr_sql(expression)
4822
4823        return super().window_sql(expression)
def filter_sql(self, expression: sqlglot.expressions.core.Filter) -> str:
4825    def filter_sql(self, expression: exp.Filter) -> str:
4826        if isinstance(expression.this, exp.Corr):
4827            return self._corr_sql(expression)
4828
4829        return super().filter_sql(expression)
def uuid_sql(self, expression: sqlglot.expressions.functions.Uuid) -> str:
4848    def uuid_sql(self, expression: exp.Uuid) -> str:
4849        namespace = expression.this
4850        name = expression.args.get("name")
4851
4852        # UUID v5 (namespace + name) - Emulate using SHA1
4853        if namespace and name:
4854            result = exp.replace_placeholders(
4855                self.UUID_V5_TEMPLATE.copy(),
4856                namespace=namespace,
4857                name=name,
4858            )
4859            return self.sql(result)
4860
4861        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
lockingstatement_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
partitionbyexcluding_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
replacecolumns_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
aggregatekeyproperty_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
arraysort_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