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