sqlglot.generators.duckdb
1from __future__ import annotations 2 3from decimal import Decimal 4from itertools import groupby 5import re 6import typing as t 7 8from sqlglot import exp, generator, transforms 9from sqlglot.dialects.dialect import ( 10 DATETIME_DELTA, 11 JSON_EXTRACT_TYPE, 12 approx_count_distinct_sql, 13 arrow_json_extract_sql, 14 array_append_sql, 15 array_compact_sql, 16 array_concat_sql, 17 count_if_to_sum, 18 date_delta_to_binary_interval_op, 19 datestrtodate_sql, 20 encode_decode_sql, 21 explode_to_unnest_sql, 22 generate_series_sql, 23 getbit_sql, 24 groupconcat_sql, 25 inline_array_unless_query, 26 months_between_sql, 27 no_datetime_sql, 28 no_comment_column_constraint_sql, 29 no_make_interval_sql, 30 no_time_sql, 31 no_timestamp_sql, 32 rename_func, 33 remove_from_array_using_filter, 34 strposition_sql, 35 timestrtotime_sql, 36 unit_to_str, 37 week_unit_to_dow, 38 weekstart_unit_to_str, 39 WEEK_START_DAY_TO_DOW, 40) 41from sqlglot.generator import unsupported_args 42from sqlglot.helper import find_new_name, is_date_unit, seq_get 43from sqlglot.optimizer.scope import find_all_in_scope 44from builtins import type as Type 45 46_CONNECT_BY_ARGS_TO_SKIP = frozenset({"connect", "where", "from_", "with_", "expressions"}) 47 48# Regex to detect time zones in timestamps of the form [+|-]TT[:tt] 49# The pattern matches timezone offsets that appear after the time portion 50TIMEZONE_PATTERN = re.compile(r":\d{2}.*?[+\-]\d{2}(?::\d{2})?") 51 52# Characters that must be escaped when building regex expressions in INITCAP 53REGEX_ESCAPE_REPLACEMENTS = { 54 "\\": "\\\\", 55 "-": r"\-", 56 "^": r"\^", 57 "[": r"\[", 58 "]": r"\]", 59} 60 61# Used to in RANDSTR transpilation 62RANDSTR_CHAR_POOL = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" 63RANDSTR_SEED = 123456 64 65# Whitespace control characters that DuckDB must process with `CHR({val})` calls 66WS_CONTROL_CHARS_TO_DUCK = { 67 "\u000b": 11, 68 "\u001c": 28, 69 "\u001d": 29, 70 "\u001e": 30, 71 "\u001f": 31, 72} 73 74MAX_BIT_POSITION = exp.Literal.number(32768) 75 76# cs/as/ps are Snowflake defaults; DuckDB already behaves the same way, so they are safe to drop. 77# Note: "as" is also a reserved keyword in DuckDB, making it impossible to pass through. 78_SNOWFLAKE_COLLATION_DEFAULTS = frozenset({"cs", "as", "ps"}) 79_SNOWFLAKE_COLLATION_UNSUPPORTED = frozenset( 80 {"ci", "ai", "upper", "lower", "utf8", "bin", "pi", "fl", "fu", "trim", "ltrim", "rtrim"} 81) 82 83# Window functions that support IGNORE/RESPECT NULLS in DuckDB 84_IGNORE_RESPECT_NULLS_WINDOW_FUNCTIONS = ( 85 exp.FirstValue, 86 exp.Lag, 87 exp.LastValue, 88 exp.Lead, 89 exp.NthValue, 90) 91 92# SEQ function constants 93_SEQ_BASE: exp.Expr = exp.maybe_parse("(ROW_NUMBER() OVER (ORDER BY 1) - 1)") 94_SEQ_RESTRICTED = (exp.Where, exp.Having, exp.AggFunc, exp.Order, exp.Select) 95# Maps SEQ expression types to their byte width (suffix indicates bytes: SEQ1=1, SEQ2=2, etc.) 96_SEQ_BYTE_WIDTH = {exp.Seq1: 1, exp.Seq2: 2, exp.Seq4: 4, exp.Seq8: 8} 97 98# Template for generating signed and unsigned SEQ values within a specified range 99_SEQ_UNSIGNED: exp.Expr = exp.maybe_parse(":base % :max_val") 100_SEQ_SIGNED: exp.Expr = exp.maybe_parse( 101 "(CASE WHEN :base % :max_val >= :half " 102 "THEN :base % :max_val - :max_val " 103 "ELSE :base % :max_val END)" 104) 105 106 107def _apply_base64_alphabet_replacements( 108 result: exp.Expr, 109 alphabet: exp.Expr | None, 110 reverse: bool = False, 111) -> exp.Expr: 112 """ 113 Apply base64 alphabet character replacements. 114 115 Base64 alphabet can be 1-3 chars: 1st = index 62 ('+'), 2nd = index 63 ('/'), 3rd = padding ('='). 116 zip truncates to the shorter string, so 1-char alphabet only replaces '+', 2-char replaces '+/', etc. 117 118 Args: 119 result: The expression to apply replacements to 120 alphabet: Custom alphabet literal (expected chars for +/=) 121 reverse: If False, replace default with custom (encode) 122 If True, replace custom with default (decode) 123 """ 124 if isinstance(alphabet, exp.Literal) and alphabet.is_string: 125 for default_char, new_char in zip("+/=", alphabet.this): 126 if new_char != default_char: 127 find, replace = (new_char, default_char) if reverse else (default_char, new_char) 128 result = exp.Replace( 129 this=result, 130 expression=exp.Literal.string(find), 131 replacement=exp.Literal.string(replace), 132 ) 133 return result 134 135 136def _base64_decode_sql(self: DuckDBGenerator, expression: exp.Expr, to_string: bool) -> str: 137 """ 138 Transpile Snowflake BASE64_DECODE_STRING/BINARY to DuckDB. 139 140 DuckDB uses FROM_BASE64() which returns BLOB. For string output, wrap with DECODE(). 141 Custom alphabets require REPLACE() calls to convert to standard base64. 142 """ 143 input_expr = expression.this 144 alphabet = expression.args.get("alphabet") 145 146 # Handle custom alphabet by replacing non-standard chars with standard ones 147 input_expr = _apply_base64_alphabet_replacements(input_expr, alphabet, reverse=True) 148 149 # FROM_BASE64 returns BLOB 150 input_expr = exp.FromBase64(this=input_expr) 151 152 if to_string: 153 input_expr = exp.Decode(this=input_expr) 154 155 return self.sql(input_expr) 156 157 158def _last_day_sql(self: DuckDBGenerator, expression: exp.LastDay) -> str: 159 """ 160 DuckDB's LAST_DAY only supports finding the last day of a month. 161 For other date parts (year, quarter, week), we need to implement equivalent logic. 162 """ 163 date_expr = expression.this 164 unit_expr = expression.args.get("unit") 165 166 week_start = week_unit_to_dow(unit_expr) 167 if week_start: 168 # The week's last day precedes its start day; DuckDB DAYOFWEEK: Sunday=0, ..., Saturday=6 169 last_dow = week_start - 1 170 dow = exp.func("EXTRACT", "DAYOFWEEK", date_expr) 171 172 # Days to the last day of week: (last_dow + 7 - dayofweek) % 7 173 days_to_last_expr = exp.Mod( 174 this=exp.Paren(this=exp.Sub(this=exp.Literal.number(last_dow + 7), expression=dow)), 175 expression=exp.Literal.number(7), 176 ) 177 interval_expr = exp.Interval(this=days_to_last_expr, unit=exp.var("DAY")) 178 add_expr = exp.Add(this=date_expr, expression=interval_expr) 179 180 return self.sql(exp.cast(add_expr, exp.DType.DATE)) 181 182 unit = expression.text("unit") 183 184 if not unit or unit.upper() == "MONTH": 185 # Default behavior - use DuckDB's native LAST_DAY 186 return self.func("LAST_DAY", date_expr) 187 188 if unit.upper() == "YEAR": 189 # Last day of year: December 31st of the same year 190 year_expr = exp.func("EXTRACT", "YEAR", date_expr) 191 make_date_expr = exp.func( 192 "MAKE_DATE", year_expr, exp.Literal.number(12), exp.Literal.number(31) 193 ) 194 return self.sql(make_date_expr) 195 196 if unit.upper() == "QUARTER": 197 # Last day of quarter 198 year_expr = exp.func("EXTRACT", "YEAR", date_expr) 199 quarter_expr = exp.func("EXTRACT", "QUARTER", date_expr) 200 201 # Calculate last month of quarter: quarter * 3. Quarter can be 1 to 4 202 last_month_expr = exp.Mul(this=quarter_expr, expression=exp.Literal.number(3)) 203 first_day_last_month_expr = exp.func( 204 "MAKE_DATE", year_expr, last_month_expr, exp.Literal.number(1) 205 ) 206 207 # Last day of the last month of the quarter 208 last_day_expr = exp.func("LAST_DAY", first_day_last_month_expr) 209 return self.sql(last_day_expr) 210 211 self.unsupported(f"Unsupported date part '{unit}' in LAST_DAY function") 212 return self.function_fallback_sql(expression) 213 214 215def _is_nanosecond_unit(unit: exp.Expr | None) -> bool: 216 return isinstance(unit, (exp.Var, exp.Literal)) and unit.name.upper() == "NANOSECOND" 217 218 219def _handle_nanosecond_diff( 220 self: DuckDBGenerator, 221 end_time: exp.Expr, 222 start_time: exp.Expr, 223) -> str: 224 """Generate NANOSECOND diff using EPOCH_NS since DATE_DIFF doesn't support it.""" 225 end_ns = exp.cast(end_time, exp.DType.TIMESTAMP_NS) 226 start_ns = exp.cast(start_time, exp.DType.TIMESTAMP_NS) 227 228 # Build expression tree: EPOCH_NS(end) - EPOCH_NS(start) 229 return self.sql( 230 exp.Sub(this=exp.func("EPOCH_NS", end_ns), expression=exp.func("EPOCH_NS", start_ns)) 231 ) 232 233 234def _to_boolean_sql(self: DuckDBGenerator, expression: exp.ToBoolean) -> str: 235 """ 236 Transpile TO_BOOLEAN and TRY_TO_BOOLEAN functions from Snowflake to DuckDB equivalent. 237 238 DuckDB's CAST to BOOLEAN supports most of Snowflake's TO_BOOLEAN strings except 'on'/'off'. 239 We need to handle the 'on'/'off' cases explicitly. 240 241 For TO_BOOLEAN (safe=False): NaN and INF values cause errors. We use DuckDB's native ERROR() 242 function to replicate this behavior with a clear error message. 243 244 For TRY_TO_BOOLEAN (safe=True): Use DuckDB's TRY_CAST for conversion, which returns NULL 245 for invalid inputs instead of throwing errors. 246 """ 247 arg = expression.this 248 is_safe = expression.args.get("safe", False) 249 250 base_case_expr = ( 251 exp.case() 252 .when( 253 # Handle 'on' -> TRUE (case insensitive) 254 exp.Upper(this=exp.cast(arg, exp.DType.VARCHAR)).eq(exp.Literal.string("ON")), 255 exp.true(), 256 ) 257 .when( 258 # Handle 'off' -> FALSE (case insensitive) 259 exp.Upper(this=exp.cast(arg, exp.DType.VARCHAR)).eq(exp.Literal.string("OFF")), 260 exp.false(), 261 ) 262 ) 263 264 if is_safe: 265 # TRY_TO_BOOLEAN: handle 'on'/'off' and use TRY_CAST for everything else 266 case_expr = base_case_expr.else_(exp.func("TRY_CAST", arg, exp.DType.BOOLEAN.into_expr())) 267 else: 268 # TO_BOOLEAN: handle NaN/INF errors, 'on'/'off', and use regular CAST 269 cast_to_real = exp.func("TRY_CAST", arg, exp.DType.FLOAT.into_expr()) 270 271 # Check for NaN and INF values 272 nan_inf_check = exp.Or( 273 this=exp.func("ISNAN", cast_to_real), expression=exp.func("ISINF", cast_to_real) 274 ) 275 276 case_expr = base_case_expr.when( 277 nan_inf_check, 278 exp.func( 279 "ERROR", 280 exp.Literal.string("TO_BOOLEAN: Non-numeric values NaN and INF are not supported"), 281 ), 282 ).else_(exp.cast(arg, exp.DType.BOOLEAN)) 283 284 return self.sql(case_expr) 285 286 287# BigQuery -> DuckDB conversion for the DATE function 288def _date_sql(self: DuckDBGenerator, expression: exp.Date) -> str: 289 this = expression.this 290 zone = self.sql(expression, "zone") 291 292 if zone: 293 # BigQuery considers "this" at UTC, converts it to the specified 294 # time zone and then keeps only the DATE part 295 # To micmic that, we: 296 # (1) Cast to TIMESTAMP to remove DuckDB's local tz 297 # (2) Apply consecutive AtTimeZone calls for UTC -> zone conversion 298 this = exp.cast(this, exp.DType.TIMESTAMP) 299 at_utc = exp.AtTimeZone(this=this, zone=exp.Literal.string("UTC")) 300 this = exp.AtTimeZone(this=at_utc, zone=zone) 301 302 return self.sql(exp.cast(expression=this, to=exp.DType.DATE)) 303 304 305# BigQuery -> DuckDB conversion for the TIME_DIFF function 306def _timediff_sql(self: DuckDBGenerator, expression: exp.TimeDiff) -> str: 307 unit = expression.unit 308 309 if _is_nanosecond_unit(unit): 310 return _handle_nanosecond_diff(self, expression.expression, expression.this) 311 312 this = exp.cast(expression.this, exp.DType.TIME) 313 expr = exp.cast(expression.expression, exp.DType.TIME) 314 315 # Although the 2 dialects share similar signatures, BQ seems to inverse 316 # the sign of the result so the start/end time operands are flipped 317 return self.func("DATE_DIFF", unit_to_str(expression), expr, this) 318 319 320def _date_delta_to_binary_interval_op( 321 cast: bool = True, 322) -> t.Callable[[DuckDBGenerator, DATETIME_DELTA], str]: 323 """ 324 DuckDB override to handle: 325 1. NANOSECOND operations (DuckDB doesn't support INTERVAL ... NANOSECOND) 326 2. Float/decimal interval values (DuckDB INTERVAL requires integers) 327 """ 328 base_impl = date_delta_to_binary_interval_op(cast=cast) 329 330 def _duckdb_date_delta_sql(self: DuckDBGenerator, expression: DATETIME_DELTA) -> str: 331 unit = expression.unit 332 interval_value = expression.expression 333 334 # Handle NANOSECOND unit (DuckDB doesn't support INTERVAL ... NANOSECOND) 335 if _is_nanosecond_unit(unit): 336 if isinstance(interval_value, exp.Interval): 337 interval_value = interval_value.this 338 339 timestamp_ns = exp.cast(expression.this, exp.DType.TIMESTAMP_NS) 340 341 return self.sql( 342 exp.func( 343 "MAKE_TIMESTAMP_NS", 344 exp.Add(this=exp.func("EPOCH_NS", timestamp_ns), expression=interval_value), 345 ) 346 ) 347 348 # Handle float/decimal interval values as duckDB INTERVAL requires integer expressions 349 if not interval_value or isinstance(interval_value, exp.Interval): 350 return base_impl(self, expression) 351 352 if interval_value.is_type(*exp.DataType.REAL_TYPES): 353 expression.set("expression", exp.cast(exp.func("ROUND", interval_value), "INT")) 354 355 return base_impl(self, expression) 356 357 return _duckdb_date_delta_sql 358 359 360def _array_insert_sql(self: DuckDBGenerator, expression: exp.ArrayInsert) -> str: 361 """ 362 Transpile ARRAY_INSERT to DuckDB using LIST_CONCAT and slicing. 363 364 Handles: 365 - 0-based and 1-based indexing (normalizes to 0-based for calculations) 366 - Negative position conversion (requires array length) 367 - NULL propagation (source dialects return NULL, DuckDB creates single-element array) 368 - Assumes position is within bounds per user constraint 369 370 Note: All dialects that support ARRAY_INSERT (Snowflake, Spark, Databricks) have 371 ARRAY_FUNCS_PROPAGATES_NULLS=True, so we always assume source propagates NULLs. 372 373 Args: 374 expression: The ArrayInsert expression to transpile. 375 376 Returns: 377 SQL string implementing ARRAY_INSERT behavior. 378 """ 379 this = expression.this 380 position = expression.args.get("position") 381 element = expression.expression 382 element_array = exp.Array(expressions=[element]) 383 index_offset = expression.args.get("offset", 0) 384 385 if not position or not position.is_int: 386 self.unsupported("ARRAY_INSERT can only be transpiled with a literal position") 387 return self.func("ARRAY_INSERT", this, position, element) 388 389 pos_value = position.to_py() 390 391 # Normalize one-based indexing to zero-based for slice calculations 392 # Spark (1-based) -> Snowflake (0-based): 393 # Positive: pos=1 -> pos=0 (subtract 1) 394 # Negative: pos=-2 -> pos=-1 (add 1) 395 # Example: Spark array_insert([a,b,c], -2, d) -> [a,b,d,c] is same as Snowflake pos=-1 396 if pos_value > 0: 397 pos_value = pos_value - index_offset 398 elif pos_value < 0: 399 pos_value = pos_value + index_offset 400 401 # Build the appropriate list_concat expression based on position 402 if pos_value == 0: 403 # insert at beginning 404 concat_exprs = [element_array, this] 405 elif pos_value > 0: 406 # Positive position: LIST_CONCAT(arr[1:pos], [elem], arr[pos+1:]) 407 # 0-based -> DuckDB 1-based slicing 408 409 # left slice: arr[1:pos] 410 slice_start = exp.Bracket( 411 this=this, 412 expressions=[ 413 exp.Slice(this=exp.Literal.number(1), expression=exp.Literal.number(pos_value)) 414 ], 415 ) 416 417 # right slice: arr[pos+1:] 418 slice_end = exp.Bracket( 419 this=this, expressions=[exp.Slice(this=exp.Literal.number(pos_value + 1))] 420 ) 421 422 concat_exprs = [slice_start, element_array, slice_end] 423 else: 424 # Negative position: arr[1:LEN(arr)+pos], [elem], arr[LEN(arr)+pos+1:] 425 # pos=-1 means insert before last element 426 arr_len = exp.Length(this=this) 427 428 # Calculate slice position: LEN(arr) + pos (e.g., LEN(arr) + (-1) = LEN(arr) - 1) 429 slice_end_pos = arr_len + exp.Literal.number(pos_value) 430 slice_start_pos = slice_end_pos + exp.Literal.number(1) 431 432 # left slice: arr[1:LEN(arr)+pos] 433 slice_start = exp.Bracket( 434 this=this, 435 expressions=[exp.Slice(this=exp.Literal.number(1), expression=slice_end_pos)], 436 ) 437 438 # right slice: arr[LEN(arr)+pos+1:] 439 slice_end = exp.Bracket(this=this, expressions=[exp.Slice(this=slice_start_pos)]) 440 441 concat_exprs = [slice_start, element_array, slice_end] 442 443 # All dialects that support ARRAY_INSERT propagate NULLs (Snowflake/Spark/Databricks) 444 # Wrap in CASE WHEN array IS NULL THEN NULL ELSE func_expr END 445 return self.sql( 446 exp.If( 447 this=exp.Is(this=this, expression=exp.Null()), 448 true=exp.Null(), 449 false=self.func("LIST_CONCAT", *concat_exprs), 450 ) 451 ) 452 453 454def _array_remove_at_sql(self: DuckDBGenerator, expression: exp.ArrayRemoveAt) -> str: 455 """ 456 Transpile ARRAY_REMOVE_AT to DuckDB using LIST_CONCAT and slicing. 457 458 Handles: 459 - Positive positions (0-based indexing) 460 - Negative positions (from end of array) 461 - NULL propagation (Snowflake returns NULL for NULL array, DuckDB doesn't auto-propagate) 462 - Only supports literal integer positions (non-literals remain untranspiled) 463 464 Transpilation patterns: 465 - pos=0 (first): arr[2:] 466 - pos>0 (middle): LIST_CONCAT(arr[1:p], arr[p+2:]) 467 - pos=-1 (last): arr[1:LEN(arr)-1] 468 - pos<-1: LIST_CONCAT(arr[1:LEN(arr)+p], arr[LEN(arr)+p+2:]) 469 470 All wrapped in: CASE WHEN arr IS NULL THEN NULL ELSE ... END 471 472 Args: 473 expression: The ArrayRemoveAt expression to transpile. 474 475 Returns: 476 SQL string implementing ARRAY_REMOVE_AT behavior. 477 """ 478 this = expression.this 479 position = expression.args.get("position") 480 481 if not position or not position.is_int: 482 self.unsupported("ARRAY_REMOVE_AT can only be transpiled with a literal position") 483 return self.func("ARRAY_REMOVE_AT", this, position) 484 485 pos_value = position.to_py() 486 487 # Build the appropriate expression based on position 488 if pos_value == 0: 489 # Remove first element: arr[2:] 490 result_expr: exp.Expr | str = exp.Bracket( 491 this=this, 492 expressions=[exp.Slice(this=exp.Literal.number(2))], 493 ) 494 elif pos_value > 0: 495 # Remove at positive position: LIST_CONCAT(arr[1:pos], arr[pos+2:]) 496 # DuckDB uses 1-based slicing 497 left_slice = exp.Bracket( 498 this=this, 499 expressions=[ 500 exp.Slice(this=exp.Literal.number(1), expression=exp.Literal.number(pos_value)) 501 ], 502 ) 503 right_slice = exp.Bracket( 504 this=this, 505 expressions=[exp.Slice(this=exp.Literal.number(pos_value + 2))], 506 ) 507 result_expr = self.func("LIST_CONCAT", left_slice, right_slice) 508 elif pos_value == -1: 509 # Remove last element: arr[1:LEN(arr)-1] 510 # Optimization: simpler than general negative case 511 arr_len = exp.Length(this=this) 512 slice_end = arr_len + exp.Literal.number(-1) 513 result_expr = exp.Bracket( 514 this=this, 515 expressions=[exp.Slice(this=exp.Literal.number(1), expression=slice_end)], 516 ) 517 else: 518 # Remove at negative position: LIST_CONCAT(arr[1:LEN(arr)+pos], arr[LEN(arr)+pos+2:]) 519 arr_len = exp.Length(this=this) 520 slice_end_pos = arr_len + exp.Literal.number(pos_value) 521 slice_start_pos = slice_end_pos + exp.Literal.number(2) 522 523 left_slice = exp.Bracket( 524 this=this, 525 expressions=[exp.Slice(this=exp.Literal.number(1), expression=slice_end_pos)], 526 ) 527 right_slice = exp.Bracket( 528 this=this, 529 expressions=[exp.Slice(this=slice_start_pos)], 530 ) 531 result_expr = self.func("LIST_CONCAT", left_slice, right_slice) 532 533 # Snowflake ARRAY_FUNCS_PROPAGATES_NULLS=True, so wrap in NULL check 534 # CASE WHEN array IS NULL THEN NULL ELSE result_expr END 535 return self.sql( 536 exp.If( 537 this=exp.Is(this=this, expression=exp.Null()), 538 true=exp.Null(), 539 false=result_expr, 540 ) 541 ) 542 543 544@unsupported_args(("expression", "DuckDB's ARRAY_SORT does not support a comparator.")) 545def _array_sort_sql(self: DuckDBGenerator, expression: exp.ArraySort) -> str: 546 return self.func("ARRAY_SORT", expression.this) 547 548 549def _array_contains_sql(self: DuckDBGenerator, expression: exp.ArrayContains) -> str: 550 this = expression.this 551 expr = expression.expression 552 553 func = self.func("ARRAY_CONTAINS", this, expr) 554 555 if expression.args.get("check_null"): 556 check_null_in_array = exp.Nullif( 557 this=exp.NEQ(this=exp.ArraySize(this=this), expression=exp.func("LIST_COUNT", this)), 558 expression=exp.false(), 559 ) 560 return self.sql(exp.If(this=expr.is_(exp.Null()), true=check_null_in_array, false=func)) 561 562 return func 563 564 565def _array_overlaps_sql(self: DuckDBGenerator, expression: exp.ArrayOverlaps) -> str: 566 """ 567 Translates Snowflake's NULL-safe ARRAYS_OVERLAP to DuckDB. 568 569 DuckDB's native && operator is not NULL-safe: [1,NULL,3] && [NULL,4,5] returns FALSE. 570 Snowflake returns TRUE when both arrays contain NULL (NULLs are treated as known values). 571 572 Generated SQL: (arr1 && arr2) OR (ARRAY_LENGTH(arr1) <> LIST_COUNT(arr1) AND ARRAY_LENGTH(arr2) <> LIST_COUNT(arr2)) 573 574 ARRAY_LENGTH counts all elements (including NULLs); LIST_COUNT counts only non-NULLs. 575 When they differ, the array contains at least one NULL, matching Snowflake's NULL-safe semantics. 576 """ 577 if not expression.args.get("null_safe"): 578 return self.binary(expression, "&&") 579 580 arr1 = expression.this 581 arr2 = expression.expression 582 583 check_nulls = exp.and_( 584 exp.NEQ( 585 this=exp.ArraySize(this=arr1.copy()), 586 expression=exp.func("LIST_COUNT", arr1.copy()), 587 ), 588 exp.NEQ( 589 this=exp.ArraySize(this=arr2.copy()), 590 expression=exp.func("LIST_COUNT", arr2.copy()), 591 ), 592 copy=False, 593 ) 594 595 overlap = exp.ArrayOverlaps(this=arr1.copy(), expression=arr2.copy()) 596 597 return self.sql( 598 exp.or_( 599 exp.paren(overlap, copy=False), 600 exp.paren(check_nulls, copy=False), 601 copy=False, 602 wrap=False, 603 ) 604 ) 605 606 607def _struct_sql(self: DuckDBGenerator, expression: exp.Struct) -> str: 608 ancestor_cast = expression.find_ancestor(exp.Cast, exp.Select) 609 ancestor_cast = None if isinstance(ancestor_cast, exp.Select) else ancestor_cast 610 611 # Empty struct cast works with MAP() since DuckDB can't parse {} 612 if not expression.expressions: 613 if isinstance(ancestor_cast, exp.Cast) and ancestor_cast.to.is_type(exp.DType.MAP): 614 return "MAP()" 615 616 args: list[str] = [] 617 618 # BigQuery allows inline construction such as "STRUCT<a STRING, b INTEGER>('str', 1)" which is 619 # canonicalized to "ROW('str', 1) AS STRUCT(a TEXT, b INT)" in DuckDB 620 # The transformation to ROW will take place if: 621 # 1. The STRUCT itself does not have proper fields (key := value) as a "proper" STRUCT would 622 # 2. A cast to STRUCT / ARRAY of STRUCTs is found 623 is_bq_inline_struct = ( 624 (expression.find(exp.PropertyEQ) is None) 625 and ancestor_cast 626 and any( 627 casted_type.is_type(exp.DType.STRUCT) 628 for casted_type in ancestor_cast.find_all(exp.DataType) 629 ) 630 ) 631 632 for i, expr in enumerate(expression.expressions): 633 is_property_eq = isinstance(expr, exp.PropertyEQ) 634 this = expr.this 635 value = expr.expression if is_property_eq else expr 636 637 if is_bq_inline_struct: 638 args.append(self.sql(value)) 639 else: 640 if isinstance(this, exp.Identifier): 641 key = self.sql(exp.Literal.string(expr.name)) 642 elif is_property_eq: 643 key = self.sql(this) 644 else: 645 key = self.sql(exp.Literal.string(f"_{i}")) 646 647 args.append(f"{key}: {self.sql(value)}") 648 649 csv_args = ", ".join(args) 650 651 return f"ROW({csv_args})" if is_bq_inline_struct else f"{{{csv_args}}}" 652 653 654def _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: t.ClassVar[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: t.ClassVar[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: t.ClassVar[exp.Expr] = exp.maybe_parse( 1979 "(ABS(HASH(:seed)) % 1000000) / 1000000.0" 1980 ) 1981 1982 # Template for generating signed and unsigned SEQ values within a specified range 1983 SEQ_UNSIGNED: t.ClassVar[exp.Expr] = _SEQ_UNSIGNED 1984 SEQ_SIGNED: t.ClassVar[exp.Expr] = _SEQ_SIGNED 1985 1986 # Template for MAP_CAT transpilation - Snowflake semantics: 1987 # 1. Returns NULL if either input is NULL 1988 # 2. For duplicate keys, prefers non-NULL value (COALESCE(m2[k], m1[k])) 1989 # 3. Filters out entries with NULL values from the result 1990 MAPCAT_TEMPLATE: t.ClassVar[exp.Expr] = exp.maybe_parse( 1991 """ 1992 CASE 1993 WHEN :map1 IS NULL OR :map2 IS NULL THEN NULL 1994 ELSE MAP_FROM_ENTRIES(LIST_FILTER(LIST_TRANSFORM( 1995 LIST_DISTINCT(LIST_CONCAT(MAP_KEYS(:map1), MAP_KEYS(:map2))), 1996 __k -> STRUCT_PACK(key := __k, value := COALESCE(:map2[__k], :map1[__k])) 1997 ), __x -> __x.value IS NOT NULL)) 1998 END 1999 """ 2000 ) 2001 2002 # Mappings for EXTRACT/DATE_PART transpilation 2003 # Maps Snowflake specifiers unsupported in DuckDB to strftime format codes 2004 EXTRACT_STRFTIME_MAPPINGS: t.ClassVar[dict[str, tuple[str, str]]] = { 2005 "WEEKISO": ("%V", "INTEGER"), 2006 "YEAROFWEEK": ("%G", "INTEGER"), 2007 "YEAROFWEEKISO": ("%G", "INTEGER"), 2008 "NANOSECOND": ("%n", "BIGINT"), 2009 } 2010 2011 # Maps epoch-based specifiers to DuckDB epoch functions 2012 EXTRACT_EPOCH_MAPPINGS: t.ClassVar[dict[str, str]] = { 2013 "EPOCH_SECOND": "EPOCH", 2014 "EPOCH_MILLISECOND": "EPOCH_MS", 2015 "EPOCH_MICROSECOND": "EPOCH_US", 2016 "EPOCH_NANOSECOND": "EPOCH_NS", 2017 } 2018 2019 # Template for BITMAP_CONSTRUCT_AGG transpilation 2020 # 2021 # BACKGROUND: 2022 # Snowflake's BITMAP_CONSTRUCT_AGG aggregates integers into a compact binary bitmap. 2023 # Supports values in range 0-32767, this version returns NULL if any value is out of range 2024 # See: https://docs.snowflake.com/en/sql-reference/functions/bitmap_construct_agg 2025 # See: https://docs.snowflake.com/en/user-guide/querying-bitmaps-for-distinct-counts 2026 # 2027 # Snowflake uses two different formats based on the number of unique values: 2028 # 2029 # Format 1 - Small bitmap (< 5 unique values): Length of 10 bytes 2030 # Bytes 0-1: Count of values as 2-byte big-endian integer (e.g., 3 values = 0x0003) 2031 # Bytes 2-9: Up to 4 values, each as 2-byte little-endian integers, zero-padded to 8 bytes 2032 # Example: Values [1, 2, 3] -> 0x0003 0100 0200 0300 0000 (hex) 2033 # count v1 v2 v3 pad 2034 # 2035 # Format 2 - Large bitmap (>= 5 unique values): Length of 10 + (2 * count) bytes 2036 # Bytes 0-9: Fixed header 0x08 followed by 9 zero bytes 2037 # Bytes 10+: Each value as 2-byte little-endian integer (no padding) 2038 # Example: Values [1,2,3,4,5] -> 0x08 00000000 00000000 00 0100 0200 0300 0400 0500 2039 # hdr ----9 zero bytes---- v1 v2 v3 v4 v5 2040 # 2041 # TEMPLATE STRUCTURE 2042 # 2043 # Phase 1 - Innermost subquery: Data preparation 2044 # SELECT LIST_SORT(...) AS l 2045 # - Aggregates all input values into a list, remove NULLs, duplicates and sorts 2046 # Result: Clean, sorted list of unique non-null integers stored as 'l' 2047 # 2048 # Phase 2 - Middle subquery: Hex string construction 2049 # LIST_TRANSFORM(...) 2050 # - Converts each integer to 2-byte little-endian hex representation 2051 # - & 255 extracts low byte, >> 8 extracts high byte 2052 # - LIST_REDUCE: Concatenates all hex pairs into single string 'h' 2053 # Result: Hex string of all values 2054 # 2055 # Phase 3 - Outer SELECT: Final bitmap assembly 2056 # LENGTH(l) < 5: 2057 # - Small format: 2-byte count (big-endian via %04X) + values + zero padding 2058 # LENGTH(l) >= 5: 2059 # - Large format: Fixed 10-byte header + values (no padding needed) 2060 # Result: Complete binary bitmap as BLOB 2061 # 2062 BITMAP_CONSTRUCT_AGG_TEMPLATE: t.ClassVar[exp.Expr] = exp.maybe_parse( 2063 """ 2064 SELECT CASE 2065 WHEN l IS NULL OR LENGTH(l) = 0 THEN NULL 2066 WHEN LENGTH(l) != LENGTH(LIST_FILTER(l, __v -> __v BETWEEN 0 AND 32767)) THEN NULL 2067 WHEN LENGTH(l) < 5 THEN UNHEX(PRINTF('%04X', LENGTH(l)) || h || REPEAT('00', GREATEST(0, 4 - LENGTH(l)) * 2)) 2068 ELSE UNHEX('08000000000000000000' || h) 2069 END 2070 FROM ( 2071 SELECT l, COALESCE(LIST_REDUCE( 2072 LIST_TRANSFORM(l, __x -> PRINTF('%02X%02X', CAST(__x AS INT) & 255, (CAST(__x AS INT) >> 8) & 255)), 2073 (__a, __b) -> __a || __b, '' 2074 ), '') AS h 2075 FROM (SELECT LIST_SORT(LIST_DISTINCT(LIST(:arg) FILTER(NOT :arg IS NULL))) AS l) 2076 ) 2077 """ 2078 ) 2079 2080 # Template for RANDSTR transpilation - placeholders get replaced with actual parameters 2081 RANDSTR_TEMPLATE: t.ClassVar[exp.Expr] = exp.maybe_parse( 2082 f""" 2083 SELECT LISTAGG( 2084 SUBSTRING( 2085 '{RANDSTR_CHAR_POOL}', 2086 1 + CAST(FLOOR(random_value * 62) AS INT), 2087 1 2088 ), 2089 '' 2090 ) 2091 FROM ( 2092 SELECT (ABS(HASH(i + :seed)) % 1000) / 1000.0 AS random_value 2093 FROM RANGE(:length) AS t(i) 2094 ) 2095 """, 2096 ) 2097 2098 # Template for MINHASH transpilation 2099 # Computes k minimum hash values across aggregated data using DuckDB list functions 2100 # Returns JSON matching Snowflake format: {"state": [...], "type": "minhash", "version": 1} 2101 MINHASH_TEMPLATE: t.ClassVar[exp.Expr] = exp.maybe_parse( 2102 """ 2103 SELECT JSON_OBJECT('state', LIST(min_h ORDER BY seed), 'type', 'minhash', 'version', 1) 2104 FROM ( 2105 SELECT seed, LIST_MIN(LIST_TRANSFORM(vals, __v -> HASH(CAST(__v AS VARCHAR) || CAST(seed AS VARCHAR)))) AS min_h 2106 FROM (SELECT LIST(:expr) AS vals), RANGE(0, :k) AS t(seed) 2107 ) 2108 """, 2109 ) 2110 2111 # Template for MINHASH_COMBINE transpilation 2112 # Combines multiple minhash signatures by taking element-wise minimum 2113 MINHASH_COMBINE_TEMPLATE: t.ClassVar[exp.Expr] = exp.maybe_parse( 2114 """ 2115 SELECT JSON_OBJECT('state', LIST(min_h ORDER BY idx), 'type', 'minhash', 'version', 1) 2116 FROM ( 2117 SELECT 2118 pos AS idx, 2119 MIN(val) AS min_h 2120 FROM 2121 UNNEST(LIST(:expr)) AS _(sig), 2122 UNNEST(CAST(sig -> 'state' AS UBIGINT[])) WITH ORDINALITY AS t(val, pos) 2123 GROUP BY pos 2124 ) 2125 """, 2126 ) 2127 2128 # Template for APPROXIMATE_SIMILARITY transpilation 2129 # Computes multi-way Jaccard similarity: fraction of positions where ALL signatures agree 2130 APPROXIMATE_SIMILARITY_TEMPLATE: t.ClassVar[exp.Expr] = exp.maybe_parse( 2131 """ 2132 SELECT CAST(SUM(CASE WHEN num_distinct = 1 THEN 1 ELSE 0 END) AS DOUBLE) / COUNT(*) 2133 FROM ( 2134 SELECT pos, COUNT(DISTINCT h) AS num_distinct 2135 FROM ( 2136 SELECT h, pos 2137 FROM UNNEST(LIST(:expr)) AS _(sig), 2138 UNNEST(CAST(sig -> 'state' AS UBIGINT[])) WITH ORDINALITY AS s(h, pos) 2139 ) 2140 GROUP BY pos 2141 ) 2142 """, 2143 ) 2144 2145 # Template for ARRAYS_ZIP transpilation 2146 # Snowflake pads to longest array; DuckDB LIST_ZIP truncates to shortest 2147 # Uses RANGE + indexing to match Snowflake behavior 2148 ARRAYS_ZIP_TEMPLATE: t.ClassVar[exp.Expr] = exp.maybe_parse( 2149 """ 2150 CASE WHEN :null_check THEN NULL 2151 WHEN :all_empty_check THEN [:empty_struct] 2152 ELSE LIST_TRANSFORM(RANGE(0, :max_len), __i -> :transform_struct) 2153 END 2154 """, 2155 ) 2156 2157 UUID_V5_TEMPLATE: t.ClassVar[exp.Expr] = exp.maybe_parse( 2158 """ 2159 (SELECT 2160 LOWER( 2161 SUBSTR(h, 1, 8) || '-' || 2162 SUBSTR(h, 9, 4) || '-' || 2163 '5' || SUBSTR(h, 14, 3) || '-' || 2164 FORMAT('{:02x}', CAST('0x' || SUBSTR(h, 17, 2) AS INT) & 63 | 128) || SUBSTR(h, 19, 2) || '-' || 2165 SUBSTR(h, 21, 12) 2166 ) 2167 FROM ( 2168 SELECT SUBSTR(SHA1(UNHEX(REPLACE(:namespace, '-', '')) || ENCODE(:name, 'utf8')), 1, 32) AS h 2169 )) 2170 """ 2171 ) 2172 2173 # Shared bag semantics outer frame for ARRAY_EXCEPT and ARRAY_INTERSECTION. 2174 # Each element is paired with its 1-based position via LIST_ZIP, then filtered 2175 # by a comparison operator (supplied via :cond) that determines the operation: 2176 # EXCEPT (>): keep the N-th occurrence only if N > count in arr2 2177 # e.g. [2,2,2] EXCEPT [2,2] -> [2] 2178 # INTERSECTION (<=): keep the N-th occurrence only if N <= count in arr2 2179 # e.g. [2,2,2] INTERSECT [2,2] -> [2,2] 2180 # IS NOT DISTINCT FROM is used for NULL-safe element comparison. 2181 ARRAY_BAG_TEMPLATE: t.ClassVar[exp.Expr] = exp.maybe_parse( 2182 """ 2183 CASE 2184 WHEN :arr1 IS NULL OR :arr2 IS NULL THEN NULL 2185 ELSE LIST_TRANSFORM( 2186 LIST_FILTER( 2187 LIST_ZIP(:arr1, GENERATE_SERIES(1, LEN(:arr1))), 2188 pair -> :cond 2189 ), 2190 pair -> pair[0] 2191 ) 2192 END 2193 """ 2194 ) 2195 2196 ARRAY_EXCEPT_CONDITION: t.ClassVar[exp.Expr] = exp.maybe_parse( 2197 "LEN(LIST_FILTER(:arr1[1:pair[1]], e -> e IS NOT DISTINCT FROM pair[0]))" 2198 " > LEN(LIST_FILTER(:arr2, e -> e IS NOT DISTINCT FROM pair[0]))" 2199 ) 2200 2201 ARRAY_INTERSECTION_CONDITION: t.ClassVar[exp.Expr] = exp.maybe_parse( 2202 "LEN(LIST_FILTER(:arr1[1:pair[1]], e -> e IS NOT DISTINCT FROM pair[0]))" 2203 " <= LEN(LIST_FILTER(:arr2, e -> e IS NOT DISTINCT FROM pair[0]))" 2204 ) 2205 2206 # Set semantics for ARRAY_EXCEPT. Deduplicates arr1 via LIST_DISTINCT, then 2207 # filters out any element that appears at least once in arr2. 2208 # e.g. [1,1,2,3] EXCEPT [1] -> [2,3] 2209 # IS NOT DISTINCT FROM is used for NULL-safe element comparison. 2210 ARRAY_EXCEPT_SET_TEMPLATE: t.ClassVar[exp.Expr] = exp.maybe_parse( 2211 """ 2212 CASE 2213 WHEN :arr1 IS NULL OR :arr2 IS NULL THEN NULL 2214 ELSE LIST_FILTER( 2215 LIST_DISTINCT(:arr1), 2216 e -> LEN(LIST_FILTER(:arr2, x -> x IS NOT DISTINCT FROM e)) = 0 2217 ) 2218 END 2219 """ 2220 ) 2221 2222 # BigQuery's `x IN UNNEST(arr)` NULL semantics: 2223 # NULL IN UNNEST([1, 2]) -> NULL 2224 # 3 IN UNNEST([1, NULL]) -> NULL 2225 # 3 IN UNNEST([1, 2]) -> FALSE 2226 # 1 IN UNNEST(NULL) -> FALSE (not NULL) 2227 # 1 IN UNNEST([]) -> FALSE 2228 # The default `IN (SELECT UNNEST(...))` rewrite creates a correlated subquery 2229 # that DuckDB rejects inside non-inner joins, so a CASE expression is used instead. 2230 IN_UNNEST_TEMPLATE: t.ClassVar[exp.Expr] = exp.maybe_parse( 2231 """ 2232 CASE 2233 WHEN :arr IS NULL OR ARRAY_LENGTH(:arr) = 0 THEN FALSE 2234 WHEN ARRAY_CONTAINS(:arr, :value) THEN TRUE 2235 WHEN :value IS NULL OR ARRAY_LENGTH(:arr) <> LIST_COUNT(:arr) THEN NULL 2236 ELSE FALSE 2237 END 2238 """ 2239 ) 2240 2241 STRTOK_TO_ARRAY_TEMPLATE: t.ClassVar[exp.Expr] = exp.maybe_parse( 2242 """ 2243 CASE WHEN :delimiter IS NULL THEN NULL 2244 ELSE LIST_FILTER( 2245 REGEXP_SPLIT_TO_ARRAY(:string, CASE WHEN :delimiter = '' THEN '.^' ELSE CONCAT('[', :escaped, ']') END), 2246 x -> NOT x = '' 2247 ) END 2248 """ 2249 ) 2250 2251 # Template for STRTOK function transpilation 2252 # 2253 # DuckDB itself doesn't have a strtok function. This handles the transpilation from Snowflake to DuckDB. 2254 # We may need to adjust this if we want to support transpilation from other dialects 2255 # 2256 # CASE 2257 # -- Snowflake: empty delimiter + empty input string -> NULL 2258 # WHEN delimiter = '' AND input_str = '' THEN NULL 2259 # 2260 # -- Snowflake: empty delimiter + non-empty input string -> treats whole input as 1 token -> return input string if index is 1 2261 # WHEN delimiter = '' AND index = 1 THEN input_str 2262 # 2263 # -- Snowflake: empty delimiter + non-empty input string -> treats whole input as 1 token -> return NULL if index is not 1 2264 # WHEN delimiter = '' THEN NULL 2265 # 2266 # -- Snowflake: negative indices return NULL 2267 # WHEN index < 0 THEN NULL 2268 # 2269 # -- Snowflake: return NULL if any argument is NULL 2270 # WHEN input_str IS NULL OR delimiter IS NULL OR index IS NULL THEN NULL 2271 # 2272 # 2273 # ELSE LIST_FILTER( 2274 # REGEXP_SPLIT_TO_ARRAY( 2275 # input_str, 2276 # CASE 2277 # -- if delimiter is '', we don't want to surround it with '[' and ']' as '[]' is invalid for DuckDB 2278 # WHEN delimiter = '' THEN '' 2279 # 2280 # -- handle problematic regex characters in delimiter with REGEXP_REPLACE 2281 # -- turn delimiter into a regex char set, otherwise DuckDB will match in order, which we don't want 2282 # ELSE '[' || REGEXP_REPLACE(delimiter, problematic_char_set, '\\\1', 'g') || ']' 2283 # END 2284 # ), 2285 # 2286 # -- Snowflake: don't return empty strings 2287 # x -> NOT x = '' 2288 # )[index] 2289 # END 2290 STRTOK_TEMPLATE: t.ClassVar[exp.Expr] = exp.maybe_parse( 2291 """ 2292 CASE 2293 WHEN :delimiter = '' AND :string = '' THEN NULL 2294 WHEN :delimiter = '' AND :part_index = 1 THEN :string 2295 WHEN :delimiter = '' THEN NULL 2296 WHEN :part_index < 0 THEN NULL 2297 WHEN :string IS NULL OR :delimiter IS NULL OR :part_index IS NULL THEN NULL 2298 ELSE :base_func 2299 END 2300 """ 2301 ) 2302 2303 # Snowflake AUTO detects 3 DATE formats: YYYY-MM-DD (ISO-8601), MM/DD/YYYY, DD-MON-YYYY. 2304 # DuckDB TRY_CAST handles ISO-8601 natively. For the other two formats we use CONTAINS('/') 2305 # and REGEXP_MATCHES('[A-Za-z]') as heuristics — these correctly handle single-digit months 2306 # and days (e.g. 1/5/2020, 5-JAN-2020) where a positional char check would fail. 2307 # Ref: https://docs.snowflake.com/en/sql-reference/date-time-input-output#date-formats 2308 _TRYCAST_DATE_SLASH_FMT = "%m/%d/%Y" 2309 _TRYCAST_DATE_MON_FMT = "%d-%b-%Y" 2310 2311 def _array_bag_sql(self, condition: exp.Expr, arr1: exp.Expr, arr2: exp.Expr) -> str: 2312 cond = exp.Paren(this=exp.replace_placeholders(condition, arr1=arr1, arr2=arr2)) 2313 return self.sql( 2314 exp.replace_placeholders(self.ARRAY_BAG_TEMPLATE, arr1=arr1, arr2=arr2, cond=cond) 2315 ) 2316 2317 def timeslice_sql(self, expression: exp.TimeSlice) -> str: 2318 """ 2319 Transform Snowflake's TIME_SLICE to DuckDB's time_bucket. 2320 2321 Snowflake: TIME_SLICE(date_expr, slice_length, 'UNIT' [, 'START'|'END']) 2322 DuckDB: time_bucket(INTERVAL 'slice_length' UNIT, date_expr) 2323 2324 For 'END' kind, add the interval to get the end of the slice. 2325 For DATE type with 'END', cast result back to DATE to preserve type. 2326 """ 2327 date_expr = expression.this 2328 slice_length = expression.expression 2329 unit = expression.unit 2330 kind = expression.text("kind").upper() 2331 2332 # Create INTERVAL expression: INTERVAL 'N' UNIT 2333 interval_expr = exp.Interval(this=slice_length, unit=unit) 2334 2335 # Create base time_bucket expression 2336 time_bucket_expr = exp.func("time_bucket", interval_expr, date_expr) 2337 2338 # Check if we need the end of the slice (default is start) 2339 if not kind == "END": 2340 # For 'START', return time_bucket directly 2341 return self.sql(time_bucket_expr) 2342 2343 # For 'END', add the interval to get end of slice 2344 add_expr = exp.Add(this=time_bucket_expr, expression=interval_expr.copy()) 2345 2346 # If input is DATE type, cast result back to DATE to preserve type 2347 # DuckDB converts DATE to TIMESTAMP when adding intervals 2348 if date_expr.is_type(exp.DType.DATE): 2349 return self.sql(exp.cast(add_expr, exp.DType.DATE)) 2350 2351 return self.sql(add_expr) 2352 2353 def bitmapbucketnumber_sql(self, expression: exp.BitmapBucketNumber) -> str: 2354 """ 2355 Transpile BITMAP_BUCKET_NUMBER function from Snowflake to DuckDB equivalent. 2356 2357 Snowflake's BITMAP_BUCKET_NUMBER returns a 1-based bucket identifier where: 2358 - Each bucket covers 32,768 values 2359 - Bucket numbering starts at 1 2360 - Formula: ((value - 1) // 32768) + 1 for positive values 2361 2362 For non-positive values (0 and negative), we use value // 32768 to avoid 2363 producing bucket 0 or positive bucket IDs for negative inputs. 2364 """ 2365 value = expression.this 2366 2367 positive_formula = ((value - 1) // 32768) + 1 2368 non_positive_formula = value // 32768 2369 2370 # CASE WHEN value > 0 THEN ((value - 1) // 32768) + 1 ELSE value // 32768 END 2371 case_expr = ( 2372 exp.case() 2373 .when(exp.GT(this=value, expression=exp.Literal.number(0)), positive_formula) 2374 .else_(non_positive_formula) 2375 ) 2376 return self.sql(case_expr) 2377 2378 def bitmapbitposition_sql(self, expression: exp.BitmapBitPosition) -> str: 2379 """ 2380 Transpile Snowflake's BITMAP_BIT_POSITION to DuckDB CASE expression. 2381 2382 Snowflake's BITMAP_BIT_POSITION behavior: 2383 - For n <= 0: returns ABS(n) % 32768 2384 - For n > 0: returns (n - 1) % 32768 (maximum return value is 32767) 2385 """ 2386 this = expression.this 2387 2388 return self.sql( 2389 exp.Mod( 2390 this=exp.Paren( 2391 this=exp.If( 2392 this=exp.GT(this=this, expression=exp.Literal.number(0)), 2393 true=this - exp.Literal.number(1), 2394 false=exp.Abs(this=this), 2395 ) 2396 ), 2397 expression=MAX_BIT_POSITION, 2398 ) 2399 ) 2400 2401 def bitmapconstructagg_sql(self, expression: exp.BitmapConstructAgg) -> str: 2402 """ 2403 Transpile Snowflake's BITMAP_CONSTRUCT_AGG to DuckDB equivalent. 2404 Uses a pre-parsed template with placeholders replaced by expression nodes. 2405 2406 Snowflake bitmap format: 2407 - Small (< 5 unique values): 2-byte count (big-endian) + values (little-endian) + padding to 10 bytes 2408 - Large (>= 5 unique values): 10-byte header (0x08 + 9 zeros) + values (little-endian) 2409 """ 2410 arg = expression.this 2411 return ( 2412 f"({self.sql(exp.replace_placeholders(self.BITMAP_CONSTRUCT_AGG_TEMPLATE, arg=arg))})" 2413 ) 2414 2415 def getignorecase_sql(self, expression: exp.GetIgnoreCase) -> str: 2416 self.unsupported("DuckDB does not support the GET_IGNORE_CASE() function") 2417 return self.function_fallback_sql(expression) 2418 2419 def compress_sql(self, expression: exp.Compress) -> str: 2420 self.unsupported("DuckDB does not support the COMPRESS() function") 2421 return self.function_fallback_sql(expression) 2422 2423 def encrypt_sql(self, expression: exp.Encrypt) -> str: 2424 self.unsupported("ENCRYPT is not supported in DuckDB") 2425 return self.function_fallback_sql(expression) 2426 2427 def decrypt_sql(self, expression: exp.Decrypt) -> str: 2428 func_name = "TRY_DECRYPT" if expression.args.get("safe") else "DECRYPT" 2429 self.unsupported(f"{func_name} is not supported in DuckDB") 2430 return self.function_fallback_sql(expression) 2431 2432 def decryptraw_sql(self, expression: exp.DecryptRaw) -> str: 2433 func_name = "TRY_DECRYPT_RAW" if expression.args.get("safe") else "DECRYPT_RAW" 2434 self.unsupported(f"{func_name} is not supported in DuckDB") 2435 return self.function_fallback_sql(expression) 2436 2437 def encryptraw_sql(self, expression: exp.EncryptRaw) -> str: 2438 self.unsupported("ENCRYPT_RAW is not supported in DuckDB") 2439 return self.function_fallback_sql(expression) 2440 2441 def parseurl_sql(self, expression: exp.ParseUrl) -> str: 2442 self.unsupported("PARSE_URL is not supported in DuckDB") 2443 return self.function_fallback_sql(expression) 2444 2445 def parseip_sql(self, expression: exp.ParseIp) -> str: 2446 self.unsupported("PARSE_IP is not supported in DuckDB") 2447 return self.function_fallback_sql(expression) 2448 2449 def decompressstring_sql(self, expression: exp.DecompressString) -> str: 2450 self.unsupported("DECOMPRESS_STRING is not supported in DuckDB") 2451 return self.function_fallback_sql(expression) 2452 2453 def decompressbinary_sql(self, expression: exp.DecompressBinary) -> str: 2454 self.unsupported("DECOMPRESS_BINARY is not supported in DuckDB") 2455 return self.function_fallback_sql(expression) 2456 2457 def jarowinklersimilarity_sql(self, expression: exp.JarowinklerSimilarity) -> str: 2458 this = expression.this 2459 expr = expression.expression 2460 2461 if expression.args.get("case_insensitive"): 2462 this = exp.Upper(this=this) 2463 expr = exp.Upper(this=expr) 2464 2465 result = exp.func("JARO_WINKLER_SIMILARITY", this, expr) 2466 2467 if expression.args.get("integer_scale"): 2468 result = exp.cast(result * 100, "INTEGER") 2469 2470 return self.sql(result) 2471 2472 def randstr_sql(self, expression: exp.Randstr) -> str: 2473 """ 2474 Transpile Snowflake's RANDSTR to DuckDB equivalent using deterministic hash-based random. 2475 Uses a pre-parsed template with placeholders replaced by expression nodes. 2476 2477 RANDSTR(length, generator) generates a random string of specified length. 2478 - With numeric seed: Use HASH(i + seed) for deterministic output (same seed = same result) 2479 - With RANDOM(): Use RANDOM() in the hash for non-deterministic output 2480 - No generator: Use default seed value 2481 """ 2482 length = expression.this 2483 generator = expression.args.get("generator") 2484 2485 if generator: 2486 if isinstance(generator, exp.Rand): 2487 # If it's RANDOM(), use its seed if available, otherwise use RANDOM() itself 2488 seed_value = generator.this or generator 2489 else: 2490 # Const/int or other expression - use as seed directly 2491 seed_value = generator 2492 else: 2493 # No generator specified, use default seed (arbitrary but deterministic) 2494 seed_value = exp.Literal.number(RANDSTR_SEED) 2495 2496 replacements = {"seed": seed_value, "length": length} 2497 return f"({self.sql(exp.replace_placeholders(self.RANDSTR_TEMPLATE, **replacements))})" 2498 2499 @unsupported_args("finish") 2500 def reduce_sql(self, expression: exp.Reduce) -> str: 2501 array_arg = expression.this 2502 initial_value = expression.args.get("initial") 2503 merge_lambda = expression.args.get("merge") 2504 2505 if merge_lambda: 2506 merge_lambda.set("colon", True) 2507 2508 return self.func("list_reduce", array_arg, merge_lambda, initial_value) 2509 2510 def zipf_sql(self, expression: exp.Zipf) -> str: 2511 """ 2512 Transpile Snowflake's ZIPF to DuckDB using CDF-based inverse sampling. 2513 Uses a pre-parsed template with placeholders replaced by expression nodes. 2514 """ 2515 s = expression.this 2516 n = expression.args["elementcount"] 2517 gen = expression.args["gen"] 2518 2519 if not isinstance(gen, exp.Rand): 2520 # (ABS(HASH(seed)) % 1000000) / 1000000.0 2521 random_expr: exp.Expr = exp.Div( 2522 this=exp.Paren( 2523 this=exp.Mod( 2524 this=exp.Abs(this=exp.Anonymous(this="HASH", expressions=[gen.copy()])), 2525 expression=exp.Literal.number(1000000), 2526 ) 2527 ), 2528 expression=exp.Literal.number(1000000.0), 2529 ) 2530 else: 2531 # Use RANDOM() for non-deterministic output 2532 random_expr = exp.Rand() 2533 2534 replacements = {"s": s, "n": n, "random_expr": random_expr} 2535 return f"({self.sql(exp.replace_placeholders(self.ZIPF_TEMPLATE, **replacements))})" 2536 2537 def tobinary_sql(self, expression: exp.ToBinary) -> str: 2538 """ 2539 TO_BINARY and TRY_TO_BINARY transpilation: 2540 - 'HEX': TO_BINARY('48454C50', 'HEX') -> UNHEX('48454C50') 2541 - 'UTF-8': TO_BINARY('TEST', 'UTF-8') -> ENCODE('TEST') 2542 - 'BASE64': TO_BINARY('SEVMUA==', 'BASE64') -> FROM_BASE64('SEVMUA==') 2543 2544 For TRY_TO_BINARY (safe=True), wrap with TRY(): 2545 - 'HEX': TRY_TO_BINARY('invalid', 'HEX') -> TRY(UNHEX('invalid')) 2546 """ 2547 value = expression.this 2548 format_arg = expression.args.get("format") 2549 is_safe = expression.args.get("safe") 2550 is_binary = _is_binary(expression) 2551 2552 if not format_arg and not is_binary: 2553 func_name = "TRY_TO_BINARY" if is_safe else "TO_BINARY" 2554 return self.func(func_name, value) 2555 2556 # Snowflake defaults to HEX encoding when no format is specified 2557 fmt = format_arg.name.upper() if format_arg else "HEX" 2558 2559 if fmt in ("UTF-8", "UTF8"): 2560 # DuckDB ENCODE always uses UTF-8, no charset parameter needed 2561 result = self.func("ENCODE", value) 2562 elif fmt == "BASE64": 2563 result = self.func("FROM_BASE64", value) 2564 elif fmt == "HEX": 2565 result = self.func("UNHEX", value) 2566 else: 2567 if is_safe: 2568 return self.sql(exp.null()) 2569 else: 2570 self.unsupported(f"format {fmt} is not supported") 2571 result = self.func("TO_BINARY", value) 2572 return f"TRY({result})" if is_safe else result 2573 2574 def tonumber_sql(self, expression: exp.ToNumber) -> str: 2575 fmt = expression.args.get("format") 2576 precision = expression.args.get("precision") 2577 scale = expression.args.get("scale") 2578 2579 if not fmt and precision and scale: 2580 return self.sql( 2581 exp.cast( 2582 expression.this, f"DECIMAL({precision.name}, {scale.name})", dialect="duckdb" 2583 ) 2584 ) 2585 2586 return super().tonumber_sql(expression) 2587 2588 def _greatest_least_sql(self, expression: exp.Greatest | exp.Least) -> str: 2589 """ 2590 Handle GREATEST/LEAST functions with dialect-aware NULL behavior. 2591 2592 - If ignore_nulls=False (BigQuery-style): return NULL if any argument is NULL 2593 - If ignore_nulls=True (DuckDB/PostgreSQL-style): ignore NULLs, return greatest/least non-NULL value 2594 """ 2595 # Get all arguments 2596 all_args = [expression.this, *expression.expressions] 2597 fallback_sql = self.function_fallback_sql(expression) 2598 2599 if expression.args.get("ignore_nulls"): 2600 # DuckDB/PostgreSQL behavior: use native GREATEST/LEAST (ignores NULLs) 2601 return self.sql(fallback_sql) 2602 2603 # return NULL if any argument is NULL 2604 case_expr = exp.case().when( 2605 exp.or_(*[arg.is_(exp.null()) for arg in all_args], copy=False), 2606 exp.null(), 2607 copy=False, 2608 ) 2609 case_expr.set("default", fallback_sql) 2610 return self.sql(case_expr) 2611 2612 def generator_sql(self, expression: exp.Generator) -> str: 2613 # Transpile Snowflake GENERATOR to DuckDB range() 2614 rowcount = expression.args.get("rowcount") 2615 time_limit = expression.args.get("time_limit") 2616 2617 if time_limit: 2618 self.unsupported("GENERATOR TIMELIMIT parameter is not supported in DuckDB") 2619 2620 if not rowcount: 2621 self.unsupported("GENERATOR without ROWCOUNT is not supported in DuckDB") 2622 return self.func("range", exp.Literal.number(0)) 2623 2624 return self.func("range", rowcount) 2625 2626 def greatest_sql(self, expression: exp.Greatest) -> str: 2627 return self._greatest_least_sql(expression) 2628 2629 def least_sql(self, expression: exp.Least) -> str: 2630 return self._greatest_least_sql(expression) 2631 2632 def lambda_sql(self, expression: exp.Lambda, arrow_sep: str = "->", wrap: bool = True) -> str: 2633 if expression.args.get("colon"): 2634 prefix = "LAMBDA " 2635 arrow_sep = ":" 2636 wrap = False 2637 else: 2638 prefix = "" 2639 2640 lambda_sql = super().lambda_sql(expression, arrow_sep=arrow_sep, wrap=wrap) 2641 return f"{prefix}{lambda_sql}" 2642 2643 def show_sql(self, expression: exp.Show) -> str: 2644 from_ = self.sql(expression, "from_") 2645 from_ = f" FROM {from_}" if from_ else "" 2646 return f"SHOW {expression.name}{from_}" 2647 2648 def soundex_sql(self, expression: exp.Soundex) -> str: 2649 self.unsupported("SOUNDEX is not supported in DuckDB") 2650 return self.func("SOUNDEX", expression.this) 2651 2652 def sortarray_sql(self, expression: exp.SortArray) -> str: 2653 arr = expression.this 2654 asc = expression.args.get("asc") 2655 nulls_first = expression.args.get("nulls_first") 2656 2657 if not isinstance(asc, exp.Boolean) and not isinstance(nulls_first, exp.Boolean): 2658 return self.func("LIST_SORT", arr, asc, nulls_first) 2659 2660 nulls_are_first = nulls_first == exp.true() 2661 nulls_first_sql = exp.Literal.string("NULLS FIRST") if nulls_are_first else None 2662 2663 if not isinstance(asc, exp.Boolean): 2664 return self.func("LIST_SORT", arr, asc, nulls_first_sql) 2665 2666 descending = asc == exp.false() 2667 2668 if not descending and not nulls_are_first: 2669 return self.func("LIST_SORT", arr) 2670 if not nulls_are_first: 2671 return self.func("ARRAY_REVERSE_SORT", arr) 2672 return self.func( 2673 "LIST_SORT", 2674 arr, 2675 exp.Literal.string("DESC" if descending else "ASC"), 2676 exp.Literal.string("NULLS FIRST"), 2677 ) 2678 2679 def install_sql(self, expression: exp.Install) -> str: 2680 force = "FORCE " if expression.args.get("force") else "" 2681 this = self.sql(expression, "this") 2682 from_clause = expression.args.get("from_") 2683 from_clause = f" FROM {from_clause}" if from_clause else "" 2684 return f"{force}INSTALL {this}{from_clause}" 2685 2686 def approxtopk_sql(self, expression: exp.ApproxTopK) -> str: 2687 self.unsupported( 2688 "APPROX_TOP_K cannot be transpiled to DuckDB due to incompatible return types. " 2689 ) 2690 return self.function_fallback_sql(expression) 2691 2692 def strposition_sql(self, expression: exp.StrPosition) -> str: 2693 this = expression.this 2694 substr = expression.args.get("substr") 2695 position = expression.args.get("position") 2696 2697 # For BINARY/BLOB: DuckDB's STRPOS doesn't support BLOB types 2698 # Convert to HEX strings, use STRPOS, then convert hex position to byte position 2699 if _is_binary(this): 2700 # Build expression: STRPOS(HEX(haystack), HEX(needle)) 2701 hex_strpos = exp.StrPosition( 2702 this=exp.Hex(this=this), 2703 substr=exp.Hex(this=substr), 2704 ) 2705 2706 return self.sql(exp.cast((hex_strpos + 1) / 2, exp.DType.INT)) 2707 2708 # For VARCHAR: handle clamp_position 2709 if expression.args.get("clamp_position") and position: 2710 expression = expression.copy() 2711 expression.set( 2712 "position", 2713 exp.If( 2714 this=exp.LTE(this=position, expression=exp.Literal.number(0)), 2715 true=exp.Literal.number(1), 2716 false=position.copy(), 2717 ), 2718 ) 2719 2720 return strposition_sql(self, expression) 2721 2722 def substring_sql(self, expression: exp.Substring) -> str: 2723 if expression.args.get("zero_start"): 2724 start = expression.args.get("start") 2725 length = expression.args.get("length") 2726 2727 if start := expression.args.get("start"): 2728 start = exp.If(this=start.eq(0), true=exp.Literal.number(1), false=start) 2729 if length := expression.args.get("length"): 2730 length = exp.If(this=length < 0, true=exp.Literal.number(0), false=length) 2731 2732 return self.func("SUBSTRING", expression.this, start, length) 2733 2734 return self.function_fallback_sql(expression) 2735 2736 def strtotime_sql(self, expression: exp.StrToTime) -> str: 2737 # Check if target_type requires TIMESTAMPTZ (for LTZ/TZ variants) 2738 target_type = expression.args.get("target_type") 2739 needs_tz = target_type and target_type.this in ( 2740 exp.DType.TIMESTAMPLTZ, 2741 exp.DType.TIMESTAMPTZ, 2742 ) 2743 2744 value, formatted_time = self._strptime_default_year(expression) 2745 2746 if expression.args.get("safe"): 2747 cast_type = exp.DType.TIMESTAMPTZ if needs_tz else exp.DType.TIMESTAMP 2748 return self.sql(exp.cast(self.func("TRY_STRPTIME", value, formatted_time), cast_type)) 2749 2750 base_sql = self.func("STRPTIME", value, formatted_time) 2751 if needs_tz: 2752 return self.sql( 2753 exp.cast( 2754 base_sql, 2755 exp.DataType(this=exp.DType.TIMESTAMPTZ), 2756 ) 2757 ) 2758 return base_sql 2759 2760 def strtodate_sql(self, expression: exp.StrToDate) -> str: 2761 value, formatted_time = self._strptime_default_year(expression) 2762 function_name = "STRPTIME" if not expression.args.get("safe") else "TRY_STRPTIME" 2763 return self.sql( 2764 exp.cast( 2765 self.func(function_name, value, formatted_time), 2766 exp.DataType(this=exp.DType.DATE), 2767 ) 2768 ) 2769 2770 def _strptime_default_year( 2771 self, expression: exp.StrToTime | exp.StrToDate | exp.ParseDatetime 2772 ) -> tuple[exp.ExpOrStr, exp.ExpOrStr | None]: 2773 value: exp.ExpOrStr = expression.this 2774 formatted_time: exp.ExpOrStr | None = self.format_time(expression) 2775 2776 if default_year := expression.args.get("default_year"): 2777 value = exp.DPipe(this=exp.Literal.string(f"{default_year.name} "), expression=value) 2778 formatted_time = exp.DPipe(this=exp.Literal.string("%Y "), expression=formatted_time) 2779 2780 return value, formatted_time 2781 2782 def parsedatetime_sql(self, expression: exp.ParseDatetime) -> str: 2783 value, formatted_time = self._strptime_default_year(expression) 2784 return self.func("STRPTIME", value, formatted_time) 2785 2786 def parsetime_sql(self, expression: exp.ParseTime) -> str: 2787 formatted_time = self.format_time(expression) 2788 return self.sql( 2789 exp.cast( 2790 self.func("STRPTIME", expression.this, formatted_time), 2791 exp.DataType(this=exp.DType.TIME), 2792 ) 2793 ) 2794 2795 def tsordstotime_sql(self, expression: exp.TsOrDsToTime) -> str: 2796 this = expression.this 2797 time_format = self.format_time(expression) 2798 safe = expression.args.get("safe") 2799 time_type = exp.DataType.from_str("TIME", dialect="duckdb") 2800 cast_expr = exp.TryCast if safe else exp.Cast 2801 2802 if time_format: 2803 func_name = "TRY_STRPTIME" if safe else "STRPTIME" 2804 strptime = exp.Anonymous(this=func_name, expressions=[this, time_format]) 2805 return self.sql(cast_expr(this=strptime, to=time_type)) 2806 2807 if isinstance(this, exp.TsOrDsToTime) or this.is_type(exp.DType.TIME): 2808 return self.sql(this) 2809 2810 return self.sql(cast_expr(this=this, to=time_type)) 2811 2812 def currentdate_sql(self, expression: exp.CurrentDate) -> str: 2813 if not expression.this: 2814 return "CURRENT_DATE" 2815 2816 expr = exp.Cast( 2817 this=exp.AtTimeZone(this=exp.CurrentTimestamp(), zone=expression.this), 2818 to=exp.DataType(this=exp.DType.DATE), 2819 ) 2820 return self.sql(expr) 2821 2822 def checkjson_sql(self, expression: exp.CheckJson) -> str: 2823 arg = expression.this 2824 return self.sql( 2825 exp.case() 2826 .when( 2827 exp.or_(arg.is_(exp.Null()), arg.eq(""), exp.func("json_valid", arg)), 2828 exp.null(), 2829 ) 2830 .else_(exp.Literal.string("Invalid JSON")) 2831 ) 2832 2833 def parsejson_sql(self, expression: exp.ParseJSON) -> str: 2834 arg = expression.this 2835 if expression.args.get("safe"): 2836 return self.sql( 2837 exp.case() 2838 .when(exp.func("json_valid", arg), exp.cast(arg.copy(), "JSON")) 2839 .else_(exp.null()) 2840 ) 2841 return self.func("JSON", arg) 2842 2843 def unicode_sql(self, expression: exp.Unicode) -> str: 2844 if expression.args.get("empty_is_zero"): 2845 return self.sql( 2846 exp.case() 2847 .when(expression.this.eq(exp.Literal.string("")), exp.Literal.number(0)) 2848 .else_(exp.Anonymous(this="UNICODE", expressions=[expression.this])) 2849 ) 2850 2851 return self.func("UNICODE", expression.this) 2852 2853 def stripnullvalue_sql(self, expression: exp.StripNullValue) -> str: 2854 return self.sql( 2855 exp.case() 2856 .when(exp.func("json_type", expression.this).eq("NULL"), exp.null()) 2857 .else_(expression.this) 2858 ) 2859 2860 def trunc_sql(self, expression: exp.Trunc) -> str: 2861 decimals = expression.args.get("decimals") 2862 if ( 2863 expression.args.get("fractions_supported") 2864 and decimals 2865 and not decimals.is_type(exp.DType.INT) 2866 ): 2867 decimals = exp.cast(decimals, exp.DType.INT, dialect="duckdb") 2868 2869 return self.func("TRUNC", expression.this, decimals) 2870 2871 def normal_sql(self, expression: exp.Normal) -> str: 2872 """ 2873 Transpile Snowflake's NORMAL(mean, stddev, gen) to DuckDB. 2874 2875 Uses the Box-Muller transform via NORMAL_TEMPLATE. 2876 """ 2877 mean = expression.this 2878 stddev = expression.args["stddev"] 2879 gen: exp.Expr = expression.args["gen"] 2880 2881 # Build two uniform random values [0, 1) for Box-Muller transform 2882 if isinstance(gen, exp.Rand) and gen.this is None: 2883 u1: exp.Expr = exp.Rand() 2884 u2: exp.Expr = exp.Rand() 2885 else: 2886 # Seeded: derive two values using HASH with different inputs 2887 seed = gen.this if isinstance(gen, exp.Rand) else gen 2888 u1 = exp.replace_placeholders(self.SEEDED_RANDOM_TEMPLATE, seed=seed) 2889 u2 = exp.replace_placeholders( 2890 self.SEEDED_RANDOM_TEMPLATE, 2891 seed=exp.Add(this=seed.copy(), expression=exp.Literal.number(1)), 2892 ) 2893 2894 replacements = {"mean": mean, "stddev": stddev, "u1": u1, "u2": u2} 2895 return self.sql(exp.replace_placeholders(self.NORMAL_TEMPLATE, **replacements)) 2896 2897 def uniform_sql(self, expression: exp.Uniform) -> str: 2898 """ 2899 Transpile Snowflake's UNIFORM(min, max, gen) to DuckDB. 2900 2901 UNIFORM returns a random value in [min, max]: 2902 - Integer result if both min and max are integers 2903 - Float result if either min or max is a float 2904 """ 2905 min_val = expression.this 2906 max_val = expression.expression 2907 gen = expression.args.get("gen") 2908 2909 # Determine if result should be integer (both bounds are integers). 2910 # We do this to emulate Snowflake's behavior, INT -> INT, FLOAT -> FLOAT 2911 is_int_result = min_val.is_int and max_val.is_int 2912 2913 # Build the random value expression [0, 1) 2914 if not isinstance(gen, exp.Rand): 2915 # Seed value: (ABS(HASH(seed)) % 1000000) / 1000000.0 2916 random_expr: exp.Expr = exp.Div( 2917 this=exp.Paren( 2918 this=exp.Mod( 2919 this=exp.Abs(this=exp.Anonymous(this="HASH", expressions=[gen])), 2920 expression=exp.Literal.number(1000000), 2921 ) 2922 ), 2923 expression=exp.Literal.number(1000000.0), 2924 ) 2925 else: 2926 random_expr = exp.Rand() 2927 2928 # Build: min + random * (max - min [+ 1 for int]) 2929 range_expr: exp.Expr = exp.Sub(this=max_val, expression=min_val) 2930 if is_int_result: 2931 range_expr = exp.Add(this=range_expr, expression=exp.Literal.number(1)) 2932 2933 result: exp.Expr = exp.Add( 2934 this=min_val, 2935 expression=exp.Mul(this=random_expr, expression=exp.Paren(this=range_expr)), 2936 ) 2937 2938 if is_int_result: 2939 result = exp.Cast(this=exp.Floor(this=result), to=exp.DType.BIGINT.into_expr()) 2940 2941 return self.sql(result) 2942 2943 def timefromparts_sql(self, expression: exp.TimeFromParts) -> str: 2944 nano = expression.args.get("nano") 2945 overflow = expression.args.get("overflow") 2946 2947 # Snowflake's TIME_FROM_PARTS supports overflow 2948 if overflow: 2949 hour = expression.args["hour"] 2950 minute = expression.args["min"] 2951 sec = expression.args["sec"] 2952 2953 # Check if values are within normal ranges - use MAKE_TIME for efficiency 2954 if not nano and all(arg.is_int for arg in [hour, minute, sec]): 2955 try: 2956 h_val = hour.to_py() 2957 m_val = minute.to_py() 2958 s_val = sec.to_py() 2959 if 0 <= h_val <= 23 and 0 <= m_val <= 59 and 0 <= s_val <= 59: 2960 return rename_func("MAKE_TIME")(self, expression) 2961 except ValueError: 2962 pass 2963 2964 # Overflow or nanoseconds detected - use INTERVAL arithmetic 2965 if nano: 2966 sec = sec + nano.pop() / exp.Literal.number(1000000000.0) 2967 2968 total_seconds = hour * exp.Literal.number(3600) + minute * exp.Literal.number(60) + sec 2969 2970 return self.sql( 2971 exp.Add( 2972 this=exp.Cast( 2973 this=exp.Literal.string("00:00:00"), to=exp.DType.TIME.into_expr() 2974 ), 2975 expression=exp.Interval(this=total_seconds, unit=exp.var("SECOND")), 2976 ) 2977 ) 2978 2979 # Default: MAKE_TIME 2980 if nano: 2981 expression.set( 2982 "sec", expression.args["sec"] + nano.pop() / exp.Literal.number(1000000000.0) 2983 ) 2984 2985 return rename_func("MAKE_TIME")(self, expression) 2986 2987 def extract_sql(self, expression: exp.Extract) -> str: 2988 """ 2989 Transpile EXTRACT/DATE_PART for DuckDB, handling specifiers not natively supported. 2990 2991 DuckDB doesn't support: WEEKISO, YEAROFWEEK, YEAROFWEEKISO, NANOSECOND, 2992 EPOCH_SECOND (as integer), EPOCH_MILLISECOND, EPOCH_MICROSECOND, EPOCH_NANOSECOND 2993 """ 2994 this = expression.this 2995 datetime_expr = expression.expression 2996 2997 # TIMESTAMPTZ extractions may produce different results between Snowflake and DuckDB 2998 # because Snowflake applies server timezone while DuckDB uses local timezone 2999 if datetime_expr.is_type(exp.DType.TIMESTAMPTZ, exp.DType.TIMESTAMPLTZ): 3000 self.unsupported( 3001 "EXTRACT from TIMESTAMPTZ / TIMESTAMPLTZ may produce different results due to timezone handling differences" 3002 ) 3003 3004 part_name = this.name.upper() 3005 3006 if part_name in self.EXTRACT_STRFTIME_MAPPINGS: 3007 fmt, cast_type = self.EXTRACT_STRFTIME_MAPPINGS[part_name] 3008 3009 # Problem: strftime doesn't accept TIME and there's no NANOSECOND function 3010 # So, for NANOSECOND with TIME, fallback to MICROSECOND * 1000 3011 is_nano_time = part_name == "NANOSECOND" and datetime_expr.is_type( 3012 exp.DType.TIME, exp.DType.TIMETZ 3013 ) 3014 3015 if is_nano_time: 3016 self.unsupported("Parameter NANOSECOND is not supported with TIME type in DuckDB") 3017 return self.sql( 3018 exp.cast( 3019 exp.Mul( 3020 this=exp.Extract(this=exp.var("MICROSECOND"), expression=datetime_expr), 3021 expression=exp.Literal.number(1000), 3022 ), 3023 exp.DataType.from_str(cast_type, dialect="duckdb"), 3024 ) 3025 ) 3026 3027 # For NANOSECOND, cast to TIMESTAMP_NS to preserve nanosecond precision 3028 strftime_input = datetime_expr 3029 if part_name == "NANOSECOND": 3030 strftime_input = exp.cast(datetime_expr, exp.DType.TIMESTAMP_NS) 3031 3032 return self.sql( 3033 exp.cast( 3034 exp.Anonymous( 3035 this="STRFTIME", 3036 expressions=[strftime_input, exp.Literal.string(fmt)], 3037 ), 3038 exp.DataType.from_str(cast_type, dialect="duckdb"), 3039 ) 3040 ) 3041 3042 if part_name in self.EXTRACT_EPOCH_MAPPINGS: 3043 func_name = self.EXTRACT_EPOCH_MAPPINGS[part_name] 3044 result: exp.Expr = exp.Anonymous(this=func_name, expressions=[datetime_expr]) 3045 # EPOCH returns float, cast to BIGINT for integer result 3046 if part_name == "EPOCH_SECOND": 3047 result = exp.cast(result, exp.DataType.from_str("BIGINT", dialect="duckdb")) 3048 return self.sql(result) 3049 3050 return super().extract_sql(expression) 3051 3052 def timestampfromparts_sql(self, expression: exp.TimestampFromParts) -> str: 3053 # Check if this is the date/time expression form: TIMESTAMP_FROM_PARTS(date_expr, time_expr) 3054 date_expr = expression.this 3055 time_expr = expression.expression 3056 3057 if date_expr is not None and time_expr is not None: 3058 # In DuckDB, DATE + TIME produces TIMESTAMP 3059 return self.sql(exp.Add(this=date_expr, expression=time_expr)) 3060 3061 # Component-based form: TIMESTAMP_FROM_PARTS(year, month, day, hour, minute, second, ...) 3062 sec = expression.args.get("sec") 3063 if sec is None: 3064 # This shouldn't happen with valid input, but handle gracefully 3065 return rename_func("MAKE_TIMESTAMP")(self, expression) 3066 3067 milli = expression.args.get("milli") 3068 if milli is not None: 3069 sec += milli.pop() / exp.Literal.number(1000.0) 3070 3071 nano = expression.args.get("nano") 3072 if nano is not None: 3073 sec += nano.pop() / exp.Literal.number(1000000000.0) 3074 3075 if milli or nano: 3076 expression.set("sec", sec) 3077 3078 return rename_func("MAKE_TIMESTAMP")(self, expression) 3079 3080 @unsupported_args("nano") 3081 def timestampltzfromparts_sql(self, expression: exp.TimestampLtzFromParts) -> str: 3082 # Pop nano so rename_func only passes args that MAKE_TIMESTAMP accepts 3083 if nano := expression.args.get("nano"): 3084 nano.pop() 3085 3086 timestamp = rename_func("MAKE_TIMESTAMP")(self, expression) 3087 return f"CAST({timestamp} AS TIMESTAMPTZ)" 3088 3089 @unsupported_args("nano") 3090 def timestamptzfromparts_sql(self, expression: exp.TimestampTzFromParts) -> str: 3091 # Extract zone before popping 3092 zone = expression.args.get("zone") 3093 # Pop zone and nano so rename_func only passes args that MAKE_TIMESTAMP accepts 3094 if zone: 3095 zone = zone.pop() 3096 3097 if nano := expression.args.get("nano"): 3098 nano.pop() 3099 3100 timestamp = rename_func("MAKE_TIMESTAMP")(self, expression) 3101 3102 if zone: 3103 # Use AT TIME ZONE to apply the explicit timezone 3104 return f"{timestamp} AT TIME ZONE {self.sql(zone)}" 3105 3106 return timestamp 3107 3108 def tablesample_sql( 3109 self, 3110 expression: exp.TableSample, 3111 tablesample_keyword: str | None = None, 3112 ) -> str: 3113 if not isinstance(expression.parent, exp.Select): 3114 # This sample clause only applies to a single source, not the entire resulting relation 3115 tablesample_keyword = "TABLESAMPLE" 3116 3117 if expression.args.get("size"): 3118 method = expression.args.get("method") 3119 if method and method.name.upper() != "RESERVOIR": 3120 self.unsupported( 3121 f"Sampling method {method} is not supported with a discrete sample count, " 3122 "defaulting to reservoir sampling" 3123 ) 3124 expression.set("method", exp.var("RESERVOIR")) 3125 3126 return super().tablesample_sql(expression, tablesample_keyword=tablesample_keyword) 3127 3128 def in_sql(self, expression: exp.In) -> str: 3129 unnest = expression.args.get("unnest") 3130 if unnest: 3131 return self.sql( 3132 exp.replace_placeholders( 3133 self.IN_UNNEST_TEMPLATE, arr=unnest.expressions[0], value=expression.this 3134 ) 3135 ) 3136 return super().in_sql(expression) 3137 3138 def join_sql(self, expression: exp.Join) -> str: 3139 if ( 3140 not expression.args.get("using") 3141 and not expression.args.get("on") 3142 and not expression.method 3143 and (expression.kind in ("", "INNER", "OUTER")) 3144 ): 3145 # Some dialects support `LEFT/INNER JOIN UNNEST(...)` without an explicit ON clause 3146 # DuckDB doesn't, but we can just add a dummy ON clause that is always true 3147 if isinstance(expression.this, exp.Unnest): 3148 return super().join_sql(expression.on(exp.true())) 3149 3150 expression.set("side", None) 3151 expression.set("kind", None) 3152 3153 return super().join_sql(expression) 3154 3155 def countif_sql(self, expression: exp.CountIf) -> str: 3156 if self.dialect.version >= (1, 2): 3157 this = expression.this 3158 if expression.args.get("zero_on_all_null") and not isinstance(this, exp.Distinct): 3159 # DuckDB >= 1.2's COUNT_IF returns NULL when the condition is NULL on all rows, 3160 # so we wrap the condition in IS TRUE to preserve count-like semantics 3161 expression = exp.CountIf(this=exp.paren(this).is_(exp.true())) 3162 return self.function_fallback_sql(expression) 3163 3164 # https://github.com/tobymao/sqlglot/pull/4749 3165 return count_if_to_sum(self, expression) 3166 3167 def bracket_sql(self, expression: exp.Bracket) -> str: 3168 if self.dialect.version >= (1, 2): 3169 return super().bracket_sql(expression) 3170 3171 # https://duckdb.org/2025/02/05/announcing-duckdb-120.html#breaking-changes 3172 this = expression.this 3173 if isinstance(this, exp.Array): 3174 this.replace(exp.paren(this)) 3175 3176 bracket = super().bracket_sql(expression) 3177 3178 if not expression.args.get("returns_list_for_maps"): 3179 if not this.type: 3180 from sqlglot.optimizer.annotate_types import annotate_types 3181 3182 this = annotate_types(this, dialect=self.dialect) 3183 3184 if this.is_type(exp.DType.MAP): 3185 bracket = f"({bracket})[1]" 3186 3187 return bracket 3188 3189 def withingroup_sql(self, expression: exp.WithinGroup) -> str: 3190 func = expression.this 3191 3192 # For ARRAY_AGG, DuckDB requires ORDER BY inside the function, not in WITHIN GROUP 3193 # Transform: ARRAY_AGG(x) WITHIN GROUP (ORDER BY y) -> ARRAY_AGG(x ORDER BY y) 3194 if isinstance(func, exp.ArrayAgg): 3195 if not isinstance(order := expression.expression, exp.Order): 3196 return self.sql(func) 3197 3198 # Save the original column for FILTER clause (before wrapping with Order) 3199 original_this = func.this 3200 3201 # Move ORDER BY inside ARRAY_AGG by wrapping its argument with Order 3202 # ArrayAgg.this should become Order(this=ArrayAgg.this, expressions=order.expressions) 3203 func.set( 3204 "this", 3205 exp.Order( 3206 this=func.this.copy(), 3207 expressions=order.expressions, 3208 ), 3209 ) 3210 3211 # Generate the ARRAY_AGG function with ORDER BY and add FILTER clause if needed 3212 # Use original_this (not the Order-wrapped version) for the FILTER condition 3213 array_agg_sql = self.function_fallback_sql(func) 3214 return self._add_arrayagg_null_filter(array_agg_sql, func, original_this) 3215 3216 # For other functions (like PERCENTILES), use existing logic 3217 expression_sql = self.sql(expression, "expression") 3218 3219 if isinstance(func, exp.PERCENTILES): 3220 # Make the order key the first arg and slide the fraction to the right 3221 # https://duckdb.org/docs/sql/aggregates#ordered-set-aggregate-functions 3222 order_col = expression.find(exp.Ordered) 3223 if order_col: 3224 func.set("expression", func.this) 3225 func.set("this", order_col.this) 3226 3227 this = self.sql(expression, "this").rstrip(")") 3228 3229 return f"{this}{expression_sql})" 3230 3231 def length_sql(self, expression: exp.Length) -> str: 3232 arg = expression.this 3233 3234 # Dialects like BQ and Snowflake also accept binary values as args, so 3235 # DDB will attempt to infer the type or resort to case/when resolution 3236 if not expression.args.get("binary") or arg.is_string: 3237 return self.func("LENGTH", arg) 3238 3239 if not arg.type: 3240 from sqlglot.optimizer.annotate_types import annotate_types 3241 3242 arg = annotate_types(arg, dialect=self.dialect) 3243 3244 if arg.is_type(*exp.DataType.TEXT_TYPES): 3245 return self.func("LENGTH", arg) 3246 3247 # We need these casts to make duckdb's static type checker happy 3248 blob = exp.cast(arg, exp.DType.VARBINARY) 3249 varchar = exp.cast(arg, exp.DType.VARCHAR) 3250 3251 case = ( 3252 exp.case(exp.Anonymous(this="TYPEOF", expressions=[arg])) 3253 .when(exp.Literal.string("BLOB"), exp.ByteLength(this=blob)) 3254 .else_(exp.Anonymous(this="LENGTH", expressions=[varchar])) 3255 ) 3256 return self.sql(case) 3257 3258 def bitlength_sql(self, expression: exp.BitLength) -> str: 3259 if not _is_binary(arg := expression.this): 3260 return self.func("BIT_LENGTH", arg) 3261 3262 blob = exp.cast(arg, exp.DataType.Type.VARBINARY) 3263 return self.sql(exp.ByteLength(this=blob) * exp.Literal.number(8)) 3264 3265 def chr_sql(self, expression: exp.Chr, name: str = "CHR") -> str: 3266 arg = expression.expressions[0] 3267 if arg.is_type(*exp.DataType.REAL_TYPES): 3268 arg = exp.cast(arg, exp.DType.INT) 3269 return self.func("CHR", arg) 3270 3271 def collation_sql(self, expression: exp.Collation) -> str: 3272 self.unsupported("COLLATION function is not supported by DuckDB") 3273 return self.function_fallback_sql(expression) 3274 3275 def collate_sql(self, expression: exp.Collate) -> str: 3276 if not expression.expression.is_string: 3277 return super().collate_sql(expression) 3278 3279 raw = expression.expression.name 3280 if not raw: 3281 return self.sql(expression.this) 3282 3283 parts = [] 3284 for part in raw.split("-"): 3285 lower = part.lower() 3286 if lower not in _SNOWFLAKE_COLLATION_DEFAULTS: 3287 if lower in _SNOWFLAKE_COLLATION_UNSUPPORTED: 3288 self.unsupported( 3289 f"Snowflake collation specifier '{part}' has no DuckDB equivalent" 3290 ) 3291 parts.append(lower) 3292 3293 if not parts: 3294 return self.sql(expression.this) 3295 return super().collate_sql( 3296 exp.Collate(this=expression.this, expression=exp.var(".".join(parts))) 3297 ) 3298 3299 def _validate_regexp_flags(self, flags: exp.Expr | None, supported_flags: str) -> str | None: 3300 """ 3301 Validate and filter regexp flags for DuckDB compatibility. 3302 3303 Args: 3304 flags: The flags expression to validate 3305 supported_flags: String of supported flags (e.g., "ims", "cims"). 3306 Only these flags will be returned. 3307 3308 Returns: 3309 Validated/filtered flag string, or None if no valid flags remain 3310 """ 3311 if not isinstance(flags, exp.Expr): 3312 return None 3313 3314 if not flags.is_string: 3315 self.unsupported("Non-literal regexp flags are not fully supported in DuckDB") 3316 return None 3317 3318 flag_str = flags.this 3319 unsupported = set(flag_str) - set(supported_flags) 3320 3321 if unsupported: 3322 self.unsupported( 3323 f"Regexp flags {sorted(unsupported)} are not supported in this context" 3324 ) 3325 3326 flag_str = "".join(f for f in flag_str if f in supported_flags) 3327 return flag_str if flag_str else None 3328 3329 def regexpcount_sql(self, expression: exp.RegexpCount) -> str: 3330 this = expression.this 3331 pattern = expression.expression 3332 position = expression.args.get("position") 3333 parameters = expression.args.get("parameters") 3334 3335 # Validate flags - only "ims" flags are supported for embedded patterns 3336 validated_flags = self._validate_regexp_flags(parameters, supported_flags="ims") 3337 3338 if position: 3339 this = exp.Substring(this=this, start=position) 3340 3341 # Embed flags in pattern (REGEXP_EXTRACT_ALL doesn't support flags argument) 3342 if validated_flags: 3343 pattern = exp.Concat(expressions=[exp.Literal.string(f"(?{validated_flags})"), pattern]) 3344 3345 # Handle empty pattern: Snowflake returns 0, DuckDB would match between every character 3346 result = ( 3347 exp.case() 3348 .when( 3349 exp.EQ(this=pattern, expression=exp.Literal.string("")), 3350 exp.Literal.number(0), 3351 ) 3352 .else_( 3353 exp.Length( 3354 this=exp.Anonymous(this="REGEXP_EXTRACT_ALL", expressions=[this, pattern]) 3355 ) 3356 ) 3357 ) 3358 3359 return self.sql(result) 3360 3361 def regexpreplace_sql(self, expression: exp.RegexpReplace) -> str: 3362 subject = expression.this 3363 pattern = expression.expression 3364 replacement = expression.args.get("replacement") or exp.Literal.string("") 3365 position = expression.args.get("position") 3366 occurrence = expression.args.get("occurrence") 3367 modifiers = expression.args.get("modifiers") 3368 3369 validated_flags = self._validate_regexp_flags(modifiers, supported_flags="cimsg") or "" 3370 3371 # Handle occurrence (only literals supported) 3372 if occurrence and not occurrence.is_int: 3373 self.unsupported("REGEXP_REPLACE with non-literal occurrence") 3374 else: 3375 occurrence = occurrence.to_py() if occurrence and occurrence.is_int else 0 3376 if occurrence > 1: 3377 self.unsupported(f"REGEXP_REPLACE occurrence={occurrence} not supported") 3378 # flag duckdb to do either all or none, single_replace check is for duckdb round trip 3379 elif ( 3380 occurrence == 0 3381 and "g" not in validated_flags 3382 and not expression.args.get("single_replace") 3383 ): 3384 validated_flags += "g" 3385 3386 # Handle position (only literals supported) 3387 prefix = None 3388 if position and not position.is_int: 3389 self.unsupported("REGEXP_REPLACE with non-literal position") 3390 elif position and position.is_int and position.to_py() > 1: 3391 pos = position.to_py() 3392 prefix = exp.Substring( 3393 this=subject, start=exp.Literal.number(1), length=exp.Literal.number(pos - 1) 3394 ) 3395 subject = exp.Substring(this=subject, start=exp.Literal.number(pos)) 3396 3397 result: exp.Expr = exp.Anonymous( 3398 this="REGEXP_REPLACE", 3399 expressions=[ 3400 subject, 3401 pattern, 3402 replacement, 3403 exp.Literal.string(validated_flags) if validated_flags else None, 3404 ], 3405 ) 3406 3407 if prefix: 3408 result = exp.Concat(expressions=[prefix, result]) 3409 3410 return self.sql(result) 3411 3412 def regexplike_sql(self, expression: exp.RegexpLike) -> str: 3413 this = expression.this 3414 pattern = expression.expression 3415 flag = expression.args.get("flag") 3416 3417 if expression.args.get("full_match"): 3418 validated_flags = self._validate_regexp_flags(flag, supported_flags="cims") 3419 flag = exp.Literal.string(validated_flags) if validated_flags else None 3420 return self.func("REGEXP_FULL_MATCH", this, pattern, flag) 3421 3422 return self.func("REGEXP_MATCHES", this, pattern, flag) 3423 3424 @unsupported_args("ins_cost", "del_cost", "sub_cost") 3425 def levenshtein_sql(self, expression: exp.Levenshtein) -> str: 3426 this = expression.this 3427 expr = expression.expression 3428 max_dist = expression.args.get("max_dist") 3429 3430 if max_dist is None: 3431 return self.func("LEVENSHTEIN", this, expr) 3432 3433 # Emulate Snowflake semantics: if distance > max_dist, return max_dist 3434 levenshtein = exp.Levenshtein(this=this, expression=expr) 3435 return self.sql(exp.Least(this=levenshtein, expressions=[max_dist])) 3436 3437 def pad_sql(self, expression: exp.Pad) -> str: 3438 """ 3439 Handle RPAD/LPAD for VARCHAR and BINARY types. 3440 3441 For VARCHAR: Delegate to parent class 3442 For BINARY: Lower to: input || REPEAT(pad, GREATEST(0, target_len - OCTET_LENGTH(input))) 3443 """ 3444 string_arg = expression.this 3445 fill_arg = expression.args.get("fill_pattern") or exp.Literal.string(" ") 3446 3447 if _is_binary(string_arg) or _is_binary(fill_arg): 3448 length_arg = expression.expression 3449 is_left = expression.args.get("is_left") 3450 3451 input_len = exp.ByteLength(this=string_arg) 3452 chars_needed = length_arg - input_len 3453 pad_count = exp.Greatest( 3454 this=exp.Literal.number(0), expressions=[chars_needed], ignore_nulls=True 3455 ) 3456 repeat_expr = exp.Repeat(this=fill_arg, times=pad_count) 3457 3458 left, right = string_arg, repeat_expr 3459 if is_left: 3460 left, right = right, left 3461 3462 result = exp.DPipe(this=left, expression=right) 3463 return self.sql(result) 3464 3465 # For VARCHAR: Delegate to parent class (handles PAD_FILL_PATTERN_IS_REQUIRED) 3466 return super().pad_sql(expression) 3467 3468 def minhash_sql(self, expression: exp.Minhash) -> str: 3469 k = expression.this 3470 exprs = expression.expressions 3471 3472 if len(exprs) != 1 or isinstance(exprs[0], exp.Star): 3473 self.unsupported( 3474 "MINHASH with multiple expressions or * requires manual query restructuring" 3475 ) 3476 return self.func("MINHASH", k, *exprs) 3477 3478 expr = exprs[0] 3479 result = exp.replace_placeholders(self.MINHASH_TEMPLATE.copy(), expr=expr, k=k) 3480 return f"({self.sql(result)})" 3481 3482 def minhashcombine_sql(self, expression: exp.MinhashCombine) -> str: 3483 expr = expression.this 3484 result = exp.replace_placeholders(self.MINHASH_COMBINE_TEMPLATE.copy(), expr=expr) 3485 return f"({self.sql(result)})" 3486 3487 def approximatesimilarity_sql(self, expression: exp.ApproximateSimilarity) -> str: 3488 expr = expression.this 3489 result = exp.replace_placeholders(self.APPROXIMATE_SIMILARITY_TEMPLATE.copy(), expr=expr) 3490 return f"({self.sql(result)})" 3491 3492 def arrayuniqueagg_sql(self, expression: exp.ArrayUniqueAgg) -> str: 3493 return self.sql( 3494 exp.Filter( 3495 this=exp.func("LIST", exp.Distinct(expressions=[expression.this])), 3496 expression=exp.Where(this=expression.this.copy().is_(exp.null()).not_()), 3497 ) 3498 ) 3499 3500 def arrayconcatagg_sql(self, expression: exp.ArrayConcatAgg) -> str: 3501 this = expression.this 3502 3503 if isinstance(this, exp.Limit): 3504 self.unsupported("LIMIT in ARRAY_CONCAT_AGG cannot be transpiled to DuckDB") 3505 this = this.this 3506 3507 inner = this.this if isinstance(this, exp.Order) else this 3508 3509 return self.func( 3510 "FLATTEN", 3511 exp.Filter( 3512 this=exp.ArrayAgg(this=this), 3513 expression=exp.Where(this=inner.copy().is_(exp.null()).not_()), 3514 ), 3515 ) 3516 3517 def arrayunionagg_sql(self, expression: exp.ArrayUnionAgg) -> str: 3518 self.unsupported("ARRAY_UNION_AGG is not supported in DuckDB") 3519 return self.function_fallback_sql(expression) 3520 3521 def arraydistinct_sql(self, expression: exp.ArrayDistinct) -> str: 3522 arr = expression.this 3523 func = self.func("LIST_DISTINCT", arr) 3524 3525 if expression.args.get("check_null"): 3526 add_null_to_array = exp.func( 3527 "LIST_APPEND", exp.func("LIST_DISTINCT", exp.ArrayCompact(this=arr)), exp.Null() 3528 ) 3529 return self.sql( 3530 exp.If( 3531 this=exp.NEQ( 3532 this=exp.ArraySize(this=arr), expression=exp.func("LIST_COUNT", arr) 3533 ), 3534 true=add_null_to_array, 3535 false=func, 3536 ) 3537 ) 3538 3539 return func 3540 3541 def arrayintersect_sql(self, expression: exp.ArrayIntersect) -> str: 3542 if expression.args.get("is_multiset") and len(expression.expressions) == 2: 3543 return self._array_bag_sql( 3544 self.ARRAY_INTERSECTION_CONDITION, 3545 expression.expressions[0], 3546 expression.expressions[1], 3547 ) 3548 return self.function_fallback_sql(expression) 3549 3550 def arrayexcept_sql(self, expression: exp.ArrayExcept) -> str: 3551 arr1, arr2 = expression.this, expression.expression 3552 if expression.args.get("is_multiset"): 3553 return self._array_bag_sql(self.ARRAY_EXCEPT_CONDITION, arr1, arr2) 3554 return self.sql( 3555 exp.replace_placeholders(self.ARRAY_EXCEPT_SET_TEMPLATE, arr1=arr1, arr2=arr2) 3556 ) 3557 3558 def arrayslice_sql(self, expression: exp.ArraySlice) -> str: 3559 """ 3560 Transpiles Snowflake's ARRAY_SLICE (0-indexed, exclusive end) to DuckDB's 3561 ARRAY_SLICE (1-indexed, inclusive end) by wrapping start and end in CASE 3562 expressions that adjust the index at query time: 3563 - start: CASE WHEN start >= 0 THEN start + 1 ELSE start END 3564 - end: CASE WHEN end < 0 THEN end - 1 ELSE end END 3565 """ 3566 start, end = expression.args.get("start"), expression.args.get("end") 3567 3568 if expression.args.get("zero_based"): 3569 if start is not None: 3570 start = ( 3571 exp.case() 3572 .when( 3573 exp.GTE(this=start.copy(), expression=exp.Literal.number(0)), 3574 exp.Add(this=start.copy(), expression=exp.Literal.number(1)), 3575 ) 3576 .else_(start) 3577 ) 3578 if end is not None: 3579 end = ( 3580 exp.case() 3581 .when( 3582 exp.LT(this=end.copy(), expression=exp.Literal.number(0)), 3583 exp.Sub(this=end.copy(), expression=exp.Literal.number(1)), 3584 ) 3585 .else_(end) 3586 ) 3587 3588 return self.func("ARRAY_SLICE", expression.this, start, end, expression.args.get("step")) 3589 3590 def arrayszip_sql(self, expression: exp.ArraysZip) -> str: 3591 args = expression.expressions 3592 3593 if not args: 3594 # Return [{}] - using MAP([], []) since DuckDB can't represent empty structs 3595 return self.sql(exp.array(exp.Map(keys=exp.array(), values=exp.array()))) 3596 3597 # Build placeholder values for template 3598 lengths = [exp.Length(this=arg) for arg in args] 3599 max_len = ( 3600 lengths[0] 3601 if len(lengths) == 1 3602 else exp.Greatest(this=lengths[0], expressions=lengths[1:]) 3603 ) 3604 3605 # Empty struct with same schema: {'$1': NULL, '$2': NULL, ...} 3606 empty_struct = exp.func( 3607 "STRUCT", 3608 *[ 3609 exp.PropertyEQ(this=exp.Literal.string(f"${i + 1}"), expression=exp.Null()) 3610 for i in range(len(args)) 3611 ], 3612 ) 3613 3614 # Struct for transform: {'$1': COALESCE(arr1, [])[__i + 1], ...} 3615 # COALESCE wrapping handles NULL arrays - prevents invalid NULL[i] syntax 3616 index = exp.column("__i") + 1 3617 transform_struct = exp.func( 3618 "STRUCT", 3619 *[ 3620 exp.PropertyEQ( 3621 this=exp.Literal.string(f"${i + 1}"), 3622 expression=exp.func("COALESCE", arg, exp.array())[index], 3623 ) 3624 for i, arg in enumerate(args) 3625 ], 3626 ) 3627 3628 result = exp.replace_placeholders( 3629 self.ARRAYS_ZIP_TEMPLATE.copy(), 3630 null_check=exp.or_(*[arg.is_(exp.Null()) for arg in args]), 3631 all_empty_check=exp.and_( 3632 *[ 3633 exp.EQ(this=exp.Length(this=arg), expression=exp.Literal.number(0)) 3634 for arg in args 3635 ] 3636 ), 3637 empty_struct=empty_struct, 3638 max_len=max_len, 3639 transform_struct=transform_struct, 3640 ) 3641 return self.sql(result) 3642 3643 def lower_sql(self, expression: exp.Lower) -> str: 3644 result_sql = self.func("LOWER", _cast_to_varchar(expression.this)) 3645 return _gen_with_cast_to_blob(self, expression, result_sql) 3646 3647 def upper_sql(self, expression: exp.Upper) -> str: 3648 result_sql = self.func("UPPER", _cast_to_varchar(expression.this)) 3649 return _gen_with_cast_to_blob(self, expression, result_sql) 3650 3651 def reverse_sql(self, expression: exp.Reverse) -> str: 3652 result_sql = self.func("REVERSE", _cast_to_varchar(expression.this)) 3653 return _gen_with_cast_to_blob(self, expression, result_sql) 3654 3655 def _left_right_sql(self, expression: exp.Left | exp.Right, func_name: str) -> str: 3656 arg = expression.this 3657 length = expression.expression 3658 is_binary = _is_binary(arg) 3659 3660 if is_binary: 3661 # LEFT/RIGHT(blob, n) becomes UNHEX(LEFT/RIGHT(HEX(blob), n * 2)) 3662 # Each byte becomes 2 hex chars, so multiply length by 2 3663 hex_arg = exp.Hex(this=arg) 3664 hex_length = exp.Mul(this=length, expression=exp.Literal.number(2)) 3665 result: exp.Expression = exp.Unhex( 3666 this=exp.Anonymous(this=func_name, expressions=[hex_arg, hex_length]) 3667 ) 3668 else: 3669 result = exp.Anonymous(this=func_name, expressions=[arg, length]) 3670 3671 if expression.args.get("negative_length_returns_empty"): 3672 empty: exp.Expression = exp.Literal.string("") 3673 if is_binary: 3674 empty = exp.Unhex(this=empty) 3675 result = exp.case().when(length < exp.Literal.number(0), empty).else_(result) 3676 3677 return self.sql(result) 3678 3679 def left_sql(self, expression: exp.Left) -> str: 3680 return self._left_right_sql(expression, "LEFT") 3681 3682 def right_sql(self, expression: exp.Right) -> str: 3683 return self._left_right_sql(expression, "RIGHT") 3684 3685 def rtrimmedlength_sql(self, expression: exp.RtrimmedLength) -> str: 3686 return self.func("LENGTH", exp.Trim(this=expression.this, position="TRAILING")) 3687 3688 def stuff_sql(self, expression: exp.Stuff) -> str: 3689 base = expression.this 3690 start = expression.args["start"] 3691 length = expression.args["length"] 3692 insertion = expression.expression 3693 is_binary = _is_binary(base) 3694 3695 if is_binary: 3696 # DuckDB's SUBSTRING doesn't accept BLOB; operate on the HEX string instead 3697 # (each byte = 2 hex chars), then UNHEX back to BLOB 3698 base = exp.Hex(this=base) 3699 insertion = exp.Hex(this=insertion) 3700 left = exp.Substring( 3701 this=base.copy(), 3702 start=exp.Literal.number(1), 3703 length=(start.copy() - exp.Literal.number(1)) * exp.Literal.number(2), 3704 ) 3705 right = exp.Substring( 3706 this=base.copy(), 3707 start=((start + length) - exp.Literal.number(1)) * exp.Literal.number(2) 3708 + exp.Literal.number(1), 3709 ) 3710 else: 3711 left = exp.Substring( 3712 this=base.copy(), 3713 start=exp.Literal.number(1), 3714 length=start.copy() - exp.Literal.number(1), 3715 ) 3716 right = exp.Substring(this=base.copy(), start=start + length) 3717 result: exp.Expr = exp.DPipe( 3718 this=exp.DPipe(this=left, expression=insertion), expression=right 3719 ) 3720 3721 if is_binary: 3722 result = exp.Unhex(this=result) 3723 3724 return self.sql(result) 3725 3726 def rand_sql(self, expression: exp.Rand) -> str: 3727 seed = expression.this 3728 if seed is not None: 3729 self.unsupported("RANDOM with seed is not supported in DuckDB") 3730 3731 lower = expression.args.get("lower") 3732 upper = expression.args.get("upper") 3733 3734 if lower and upper: 3735 # scale DuckDB's [0,1) to the specified range 3736 range_size = exp.paren(upper - lower) 3737 scaled = exp.Add(this=lower, expression=exp.func("random") * range_size) 3738 3739 # For now we assume that if bounds are set, return type is BIGINT. Snowflake/Teradata 3740 result = exp.cast(scaled, exp.DType.BIGINT) 3741 return self.sql(result) 3742 3743 # Default DuckDB behavior - just return RANDOM() as float 3744 return "RANDOM()" 3745 3746 def bytelength_sql(self, expression: exp.ByteLength) -> str: 3747 arg = expression.this 3748 3749 # Check if it's a text type (handles both literals and annotated expressions) 3750 if arg.is_type(*exp.DataType.TEXT_TYPES): 3751 return self.func("OCTET_LENGTH", exp.Encode(this=arg)) 3752 3753 # Default: pass through as-is (conservative for DuckDB, handles binary and unannotated) 3754 return self.func("OCTET_LENGTH", arg) 3755 3756 def base64encode_sql(self, expression: exp.Base64Encode) -> str: 3757 # DuckDB TO_BASE64 requires BLOB input 3758 # Snowflake BASE64_ENCODE accepts both VARCHAR and BINARY - for VARCHAR it implicitly 3759 # encodes UTF-8 bytes. We add ENCODE unless the input is a binary type. 3760 result = expression.this 3761 3762 # Check if input is a string type - ENCODE only accepts VARCHAR 3763 if result.is_type(*exp.DataType.TEXT_TYPES): 3764 result = exp.Encode(this=result) 3765 3766 result = exp.ToBase64(this=result) 3767 3768 max_line_length = expression.args.get("max_line_length") 3769 alphabet = expression.args.get("alphabet") 3770 3771 # Handle custom alphabet by replacing standard chars with custom ones 3772 result = _apply_base64_alphabet_replacements(result, alphabet) 3773 3774 # Handle max_line_length by inserting newlines every N characters 3775 line_length = ( 3776 t.cast(int, max_line_length.to_py()) 3777 if isinstance(max_line_length, exp.Literal) and max_line_length.is_number 3778 else 0 3779 ) 3780 if line_length > 0: 3781 newline = exp.Chr(expressions=[exp.Literal.number(10)]) 3782 result = exp.Trim( 3783 this=exp.RegexpReplace( 3784 this=result, 3785 expression=exp.Literal.string(f"(.{{{line_length}}})"), 3786 replacement=exp.Concat(expressions=[exp.Literal.string("\\1"), newline.copy()]), 3787 ), 3788 expression=newline, 3789 position="TRAILING", 3790 ) 3791 3792 return self.sql(result) 3793 3794 def hex_sql(self, expression: exp.Hex) -> str: 3795 case = expression.args.get("case") 3796 3797 if not case: 3798 return self.func("HEX", expression.this) 3799 3800 hex_expr = exp.Hex(this=expression.this) 3801 return self.sql( 3802 exp.case() 3803 .when(case.is_(exp.null()), exp.null()) 3804 .when(case.copy().eq(0), exp.Lower(this=hex_expr.copy())) 3805 .else_(hex_expr) 3806 ) 3807 3808 def replace_sql(self, expression: exp.Replace) -> str: 3809 result_sql = self.func( 3810 "REPLACE", 3811 _cast_to_varchar(expression.this), 3812 _cast_to_varchar(expression.expression), 3813 _cast_to_varchar(expression.args.get("replacement")), 3814 ) 3815 return _gen_with_cast_to_blob(self, expression, result_sql) 3816 3817 def _bitwise_op(self, expression: exp.Binary, op: str) -> str: 3818 _prepare_binary_bitwise_args(expression) 3819 result_sql = self.binary(expression, op) 3820 return _gen_with_cast_to_blob(self, expression, result_sql) 3821 3822 def bitwisexor_sql(self, expression: exp.BitwiseXor) -> str: 3823 _prepare_binary_bitwise_args(expression) 3824 result_sql = self.func("XOR", expression.this, expression.expression) 3825 return _gen_with_cast_to_blob(self, expression, result_sql) 3826 3827 def objectinsert_sql(self, expression: exp.ObjectInsert) -> str: 3828 this = expression.this 3829 key = expression.args.get("key") 3830 key_sql = key.name if isinstance(key, exp.Expr) else "" 3831 value_sql = self.sql(expression, "value") 3832 3833 kv_sql = f"{key_sql} := {value_sql}" 3834 3835 # If the input struct is empty e.g. transpiling OBJECT_INSERT(OBJECT_CONSTRUCT(), key, value) from Snowflake 3836 # then we can generate STRUCT_PACK which will build it since STRUCT_INSERT({}, key := value) is not valid DuckDB 3837 if isinstance(this, exp.Struct) and not this.expressions: 3838 return self.func("STRUCT_PACK", kv_sql) 3839 3840 return self.func("STRUCT_INSERT", this, kv_sql) 3841 3842 def mapcat_sql(self, expression: exp.MapCat) -> str: 3843 result = exp.replace_placeholders( 3844 self.MAPCAT_TEMPLATE.copy(), 3845 map1=expression.this, 3846 map2=expression.expression, 3847 ) 3848 return self.sql(result) 3849 3850 def mapcontainskey_sql(self, expression: exp.MapContainsKey) -> str: 3851 return self.func( 3852 "ARRAY_CONTAINS", exp.func("MAP_KEYS", expression.args["key"]), expression.this 3853 ) 3854 3855 def mapdelete_sql(self, expression: exp.MapDelete) -> str: 3856 map_arg = expression.this 3857 keys_to_delete = expression.expressions 3858 3859 x_dot_key = exp.Dot(this=exp.to_identifier("x"), expression=exp.to_identifier("key")) 3860 3861 lambda_expr = exp.Lambda( 3862 this=exp.In(this=x_dot_key, expressions=keys_to_delete).not_(), 3863 expressions=[exp.to_identifier("x")], 3864 ) 3865 result = exp.func( 3866 "MAP_FROM_ENTRIES", 3867 exp.ArrayFilter(this=exp.func("MAP_ENTRIES", map_arg), expression=lambda_expr), 3868 ) 3869 return self.sql(result) 3870 3871 def mappick_sql(self, expression: exp.MapPick) -> str: 3872 map_arg = expression.this 3873 keys_to_pick = expression.expressions 3874 3875 x_dot_key = exp.Dot(this=exp.to_identifier("x"), expression=exp.to_identifier("key")) 3876 3877 if len(keys_to_pick) == 1 and keys_to_pick[0].is_type(exp.DType.ARRAY): 3878 lambda_expr = exp.Lambda( 3879 this=exp.func("ARRAY_CONTAINS", keys_to_pick[0], x_dot_key), 3880 expressions=[exp.to_identifier("x")], 3881 ) 3882 else: 3883 lambda_expr = exp.Lambda( 3884 this=exp.In(this=x_dot_key, expressions=keys_to_pick), 3885 expressions=[exp.to_identifier("x")], 3886 ) 3887 3888 result = exp.func( 3889 "MAP_FROM_ENTRIES", 3890 exp.func("LIST_FILTER", exp.func("MAP_ENTRIES", map_arg), lambda_expr), 3891 ) 3892 return self.sql(result) 3893 3894 def mapsize_sql(self, expression: exp.MapSize) -> str: 3895 return self.func("CARDINALITY", expression.this) 3896 3897 @unsupported_args("update_flag") 3898 def mapinsert_sql(self, expression: exp.MapInsert) -> str: 3899 map_arg = expression.this 3900 key = expression.args.get("key") 3901 value = expression.args.get("value") 3902 3903 map_type = map_arg.type 3904 3905 if value is not None: 3906 if map_type and map_type.expressions and len(map_type.expressions) > 1: 3907 # Extract the value type from MAP(key_type, value_type) 3908 value_type = map_type.expressions[1] 3909 # Cast value to match the map's value type to avoid type conflicts 3910 value = exp.cast(value, value_type) 3911 # else: polymorphic MAP case - no type parameters available, use value as-is 3912 3913 # Create a single-entry map for the new key-value pair 3914 new_entry_struct = exp.Struct(expressions=[exp.PropertyEQ(this=key, expression=value)]) 3915 new_entry: exp.Expression = exp.ToMap(this=new_entry_struct) 3916 3917 # Use MAP_CONCAT to merge the original map with the new entry 3918 # This automatically handles both insert and update cases 3919 result = exp.func("MAP_CONCAT", map_arg, new_entry) 3920 3921 return self.sql(result) 3922 3923 def startswith_sql(self, expression: exp.StartsWith) -> str: 3924 return self.func( 3925 "STARTS_WITH", 3926 _cast_to_varchar(expression.this), 3927 _cast_to_varchar(expression.expression), 3928 ) 3929 3930 def space_sql(self, expression: exp.Space) -> str: 3931 # DuckDB's REPEAT requires BIGINT for the count parameter 3932 return self.sql( 3933 exp.Repeat( 3934 this=exp.Literal.string(" "), 3935 times=exp.cast(expression.this, exp.DType.BIGINT), 3936 ) 3937 ) 3938 3939 def tablefromrows_sql(self, expression: exp.TableFromRows) -> str: 3940 # For GENERATOR, unwrap TABLE() - just emit the Generator (becomes RANGE) 3941 if isinstance(expression.this, exp.Generator): 3942 # Preserve alias, joins, and other table-level args 3943 table = exp.Table( 3944 this=expression.this, 3945 alias=expression.args.get("alias"), 3946 joins=expression.args.get("joins"), 3947 ) 3948 return self.sql(table) 3949 3950 return super().tablefromrows_sql(expression) 3951 3952 def unnest_sql(self, expression: exp.Unnest) -> str: 3953 explode_array = expression.args.get("explode_array") 3954 if explode_array: 3955 # In BigQuery, UNNESTing a nested array leads to explosion of the top-level array & struct 3956 # This is transpiled to DDB by transforming "FROM UNNEST(...)" to "FROM (SELECT UNNEST(..., max_depth => 2))" 3957 expression.expressions.append( 3958 exp.Kwarg(this=exp.var("max_depth"), expression=exp.Literal.number(2)) 3959 ) 3960 3961 # If BQ's UNNEST is aliased, we transform it from a column alias to a table alias in DDB 3962 alias = expression.args.get("alias") 3963 if isinstance(alias, exp.TableAlias): 3964 expression.set("alias", None) 3965 if alias.columns: 3966 alias = exp.TableAlias(this=seq_get(alias.columns, 0)) 3967 3968 unnest_sql = super().unnest_sql(expression) 3969 select = exp.Select(expressions=[unnest_sql]).subquery(alias) 3970 return self.sql(select) 3971 3972 return super().unnest_sql(expression) 3973 3974 def arrayagg_sql(self, expression: exp.ArrayAgg) -> str: 3975 if isinstance(expression.this, exp.Limit): 3976 self.unsupported("LIMIT inside ARRAY_AGG is not supported in DuckDB") 3977 3978 return super().arrayagg_sql(expression) 3979 3980 def ignorenulls_sql(self, expression: exp.IgnoreNulls) -> str: 3981 this = expression.this 3982 3983 if isinstance(this, self.IGNORE_RESPECT_NULLS_WINDOW_FUNCTIONS): 3984 # DuckDB should render IGNORE NULLS only for the general-purpose 3985 # window functions that accept it e.g. FIRST_VALUE(... IGNORE NULLS) OVER (...) 3986 return super().ignorenulls_sql(expression) 3987 3988 # For ARRAY_AGG(expr IGNORE NULLS ...), convert IGNORE NULLS to a 3989 # FILTER(WHERE expr IS NOT NULL) clause by setting nulls_excluded on 3990 # the ArrayAgg. The existing _add_arrayagg_null_filter method will 3991 # emit the FILTER clause during arrayagg_sql / withingroup_sql. 3992 if isinstance(this, exp.ArrayAgg): 3993 this.set("nulls_excluded", True) 3994 return self.sql(this) 3995 3996 if isinstance(this, exp.First): 3997 this = exp.AnyValue(this=this.this) 3998 3999 if not isinstance(this, (exp.AnyValue, exp.ApproxQuantiles)): 4000 self.unsupported("IGNORE NULLS is not supported for non-window functions.") 4001 4002 return self.sql(this) 4003 4004 def split_sql(self, expression: exp.Split) -> str: 4005 base_func = exp.func("STR_SPLIT", expression.this, expression.expression) 4006 4007 case_expr = exp.case().else_(base_func) 4008 needs_case = False 4009 4010 if expression.args.get("null_returns_null"): 4011 case_expr = case_expr.when(expression.expression.is_(exp.null()), exp.null()) 4012 needs_case = True 4013 4014 if expression.args.get("empty_delimiter_returns_whole"): 4015 # When delimiter is empty string, return input string as single array element 4016 array_with_input = exp.array(expression.this) 4017 case_expr = case_expr.when( 4018 expression.expression.eq(exp.Literal.string("")), array_with_input 4019 ) 4020 needs_case = True 4021 4022 return self.sql(case_expr if needs_case else base_func) 4023 4024 def splitpart_sql(self, expression: exp.SplitPart) -> str: 4025 string_arg = expression.this 4026 delimiter_arg = expression.args.get("delimiter") 4027 part_index_arg = expression.args.get("part_index") 4028 4029 if delimiter_arg and part_index_arg: 4030 # Handle Snowflake's "index 0 and 1 both return first element" behavior 4031 if expression.args.get("part_index_zero_as_one"): 4032 # Convert 0 to 1 for compatibility 4033 4034 part_index_arg = exp.Paren( 4035 this=exp.case() 4036 .when(part_index_arg.eq(exp.Literal.number("0")), exp.Literal.number("1")) 4037 .else_(part_index_arg) 4038 ) 4039 4040 # Use Anonymous to avoid recursion 4041 base_func_expr: exp.Expr = exp.Anonymous( 4042 this="SPLIT_PART", expressions=[string_arg, delimiter_arg, part_index_arg] 4043 ) 4044 needs_case_transform = False 4045 case_expr = exp.case().else_(base_func_expr) 4046 4047 if expression.args.get("empty_delimiter_returns_whole"): 4048 # When delimiter is empty string: 4049 # - Return whole string if part_index is 1 or -1 4050 # - Return empty string otherwise 4051 empty_case = exp.Paren( 4052 this=exp.case() 4053 .when( 4054 exp.or_( 4055 part_index_arg.eq(exp.Literal.number("1")), 4056 part_index_arg.eq(exp.Literal.number("-1")), 4057 ), 4058 string_arg, 4059 ) 4060 .else_(exp.Literal.string("")) 4061 ) 4062 4063 case_expr = case_expr.when(delimiter_arg.eq(exp.Literal.string("")), empty_case) 4064 needs_case_transform = True 4065 4066 """ 4067 Output looks something like this: 4068 4069 CASE 4070 WHEN delimiter is '' THEN 4071 ( 4072 CASE 4073 WHEN adjusted_part_index = 1 OR adjusted_part_index = -1 THEN input 4074 ELSE '' END 4075 ) 4076 ELSE SPLIT_PART(input, delimiter, adjusted_part_index) 4077 END 4078 4079 """ 4080 return self.sql(case_expr if needs_case_transform else base_func_expr) 4081 4082 return self.function_fallback_sql(expression) 4083 4084 def respectnulls_sql(self, expression: exp.RespectNulls) -> str: 4085 if isinstance(expression.this, self.IGNORE_RESPECT_NULLS_WINDOW_FUNCTIONS): 4086 # DuckDB should render RESPECT NULLS only for the general-purpose 4087 # window functions that accept it e.g. FIRST_VALUE(... RESPECT NULLS) OVER (...) 4088 return super().respectnulls_sql(expression) 4089 4090 self.unsupported("RESPECT NULLS is not supported for non-window functions.") 4091 return self.sql(expression, "this") 4092 4093 def arraytostring_sql(self, expression: exp.ArrayToString) -> str: 4094 null = expression.args.get("null") 4095 4096 if expression.args.get("null_is_empty"): 4097 x = exp.to_identifier("x") 4098 list_transform = exp.Transform( 4099 this=expression.this.copy(), 4100 expression=exp.Lambda( 4101 this=exp.Coalesce( 4102 this=exp.cast(x, "TEXT"), expressions=[exp.Literal.string("")] 4103 ), 4104 expressions=[x], 4105 ), 4106 ) 4107 array_to_string = exp.ArrayToString( 4108 this=list_transform, expression=expression.expression 4109 ) 4110 if expression.args.get("null_delim_is_null"): 4111 return self.sql( 4112 exp.case() 4113 .when(expression.expression.copy().is_(exp.null()), exp.null()) 4114 .else_(array_to_string) 4115 ) 4116 return self.sql(array_to_string) 4117 4118 if null: 4119 x = exp.to_identifier("x") 4120 return self.sql( 4121 exp.ArrayToString( 4122 this=exp.Transform( 4123 this=expression.this, 4124 expression=exp.Lambda( 4125 this=exp.Coalesce(this=x, expressions=[null]), 4126 expressions=[x], 4127 ), 4128 ), 4129 expression=expression.expression, 4130 ) 4131 ) 4132 4133 return self.func("ARRAY_TO_STRING", expression.this, expression.expression) 4134 4135 def concatws_sql(self, expression: exp.ConcatWs) -> str: 4136 # DuckDB-specific: handle binary types using DPipe (||) operator 4137 separator = seq_get(expression.expressions, 0) 4138 args = expression.expressions[1:] 4139 4140 if any(_is_binary(arg) for arg in [separator, *args]): 4141 result = args[0] 4142 for arg in args[1:]: 4143 result = exp.DPipe( 4144 this=exp.DPipe(this=result, expression=separator), expression=arg 4145 ) 4146 return self.sql(result) 4147 4148 return super().concatws_sql(expression) 4149 4150 def _regexp_extract_sql(self, expression: exp.RegexpExtract | exp.RegexpExtractAll) -> str: 4151 this = expression.this 4152 group = expression.args.get("group") 4153 params = expression.args.get("parameters") 4154 position = expression.args.get("position") 4155 occurrence = expression.args.get("occurrence") 4156 null_if_pos_overflow = expression.args.get("null_if_pos_overflow") 4157 4158 # Handle Snowflake's 'e' flag: it enables capture group extraction 4159 # In DuckDB, this is controlled by the group parameter directly 4160 if params and params.is_string and "e" in params.name: 4161 params = exp.Literal.string(params.name.replace("e", "")) 4162 4163 validated_flags = self._validate_regexp_flags(params, supported_flags="cims") 4164 4165 # Strip default group when no following params (DuckDB default is same as group=0) 4166 if ( 4167 not validated_flags 4168 and group 4169 and group.name == str(self.dialect.REGEXP_EXTRACT_DEFAULT_GROUP) 4170 ): 4171 group = None 4172 4173 flags_expr = exp.Literal.string(validated_flags) if validated_flags else None 4174 4175 # use substring to handle position argument 4176 if position and (not position.is_int or position.to_py() > 1): 4177 this = exp.Substring(this=this, start=position) 4178 4179 if null_if_pos_overflow: 4180 this = exp.Nullif(this=this, expression=exp.Literal.string("")) 4181 4182 is_extract_all = isinstance(expression, exp.RegexpExtractAll) 4183 non_single_occurrence = occurrence and (not occurrence.is_int or occurrence.to_py() > 1) 4184 4185 if is_extract_all or non_single_occurrence: 4186 name = "REGEXP_EXTRACT_ALL" 4187 else: 4188 name = "REGEXP_EXTRACT" 4189 4190 result: exp.Expr = exp.Anonymous( 4191 this=name, expressions=[this, expression.expression, group, flags_expr] 4192 ) 4193 4194 # Array slicing for REGEXP_EXTRACT_ALL with occurrence 4195 if is_extract_all and non_single_occurrence: 4196 result = exp.Bracket(this=result, expressions=[exp.Slice(this=occurrence)]) 4197 # ARRAY_EXTRACT for REGEXP_EXTRACT with occurrence > 1 4198 elif non_single_occurrence: 4199 result = exp.Anonymous(this="ARRAY_EXTRACT", expressions=[result, occurrence]) 4200 4201 return self.sql(result) 4202 4203 def regexpextract_sql(self, expression: exp.RegexpExtract) -> str: 4204 return self._regexp_extract_sql(expression) 4205 4206 def regexpextractall_sql(self, expression: exp.RegexpExtractAll) -> str: 4207 return self._regexp_extract_sql(expression) 4208 4209 def regexpinstr_sql(self, expression: exp.RegexpInstr) -> str: 4210 this = expression.this 4211 pattern = expression.expression 4212 position = expression.args.get("position") 4213 orig_occ = expression.args.get("occurrence") 4214 occurrence = orig_occ or exp.Literal.number(1) 4215 option = expression.args.get("option") 4216 parameters = expression.args.get("parameters") 4217 4218 validated_flags = self._validate_regexp_flags(parameters, supported_flags="ims") 4219 if validated_flags: 4220 pattern = exp.Concat(expressions=[exp.Literal.string(f"(?{validated_flags})"), pattern]) 4221 4222 # Handle starting position offset 4223 pos_offset: exp.Expr = exp.Literal.number(0) 4224 if position and (not position.is_int or position.to_py() > 1): 4225 this = exp.Substring(this=this, start=position) 4226 pos_offset = position - exp.Literal.number(1) 4227 4228 # Helper: LIST_SUM(LIST_TRANSFORM(list[1:end], x -> LENGTH(x))) 4229 def sum_lengths(func_name: str, end: exp.Expr) -> exp.Expr: 4230 lst = exp.Bracket( 4231 this=exp.Anonymous(this=func_name, expressions=[this, pattern]), 4232 expressions=[exp.Slice(this=exp.Literal.number(1), expression=end)], 4233 offset=1, 4234 ) 4235 transform = exp.Anonymous( 4236 this="LIST_TRANSFORM", 4237 expressions=[ 4238 lst, 4239 exp.Lambda( 4240 this=exp.Length(this=exp.to_identifier("x")), 4241 expressions=[exp.to_identifier("x")], 4242 ), 4243 ], 4244 ) 4245 return exp.Coalesce( 4246 this=exp.Anonymous(this="LIST_SUM", expressions=[transform]), 4247 expressions=[exp.Literal.number(0)], 4248 ) 4249 4250 # Position = 1 + sum(split_lengths[1:occ]) + sum(match_lengths[1:occ-1]) + offset 4251 base_pos: exp.Expr = ( 4252 exp.Literal.number(1) 4253 + sum_lengths("STRING_SPLIT_REGEX", occurrence) 4254 + sum_lengths("REGEXP_EXTRACT_ALL", occurrence - exp.Literal.number(1)) 4255 + pos_offset 4256 ) 4257 4258 # option=1: add match length for end position 4259 if option and option.is_int and option.to_py() == 1: 4260 match_at_occ = exp.Bracket( 4261 this=exp.Anonymous(this="REGEXP_EXTRACT_ALL", expressions=[this, pattern]), 4262 expressions=[occurrence], 4263 offset=1, 4264 ) 4265 base_pos = base_pos + exp.Coalesce( 4266 this=exp.Length(this=match_at_occ), expressions=[exp.Literal.number(0)] 4267 ) 4268 4269 # NULL checks for all provided arguments 4270 # .copy() is used strictly because .is_() alters the node's parent pointer, mutating the parsed AST 4271 null_args = [ 4272 expression.this, 4273 expression.expression, 4274 position, 4275 orig_occ, 4276 option, 4277 parameters, 4278 ] 4279 null_checks = [arg.copy().is_(exp.Null()) for arg in null_args if arg] 4280 4281 matches = exp.Anonymous(this="REGEXP_EXTRACT_ALL", expressions=[this, pattern]) 4282 4283 return self.sql( 4284 exp.case() 4285 .when(exp.or_(*null_checks), exp.Null()) 4286 .when(pattern.copy().eq(exp.Literal.string("")), exp.Literal.number(0)) 4287 .when(exp.Length(this=matches) < occurrence, exp.Literal.number(0)) 4288 .else_(base_pos) 4289 ) 4290 4291 @unsupported_args("culture") 4292 def numbertostr_sql(self, expression: exp.NumberToStr) -> str: 4293 fmt = expression.args.get("format") 4294 if fmt and fmt.is_int: 4295 return self.func("FORMAT", f"'{{:,.{fmt.name}f}}'", expression.this) 4296 4297 self.unsupported("Only integer formats are supported by NumberToStr") 4298 return self.function_fallback_sql(expression) 4299 4300 def autoincrementcolumnconstraint_sql(self, _) -> str: 4301 self.unsupported("The AUTOINCREMENT column constraint is not supported by DuckDB") 4302 return "" 4303 4304 def aliases_sql(self, expression: exp.Aliases) -> str: 4305 this = expression.this 4306 if isinstance(this, exp.Posexplode): 4307 return self.posexplode_sql(this) 4308 4309 return super().aliases_sql(expression) 4310 4311 def posexplode_sql(self, expression: exp.Posexplode) -> str: 4312 this = expression.this 4313 parent = expression.parent 4314 4315 # The default Spark aliases are "pos" and "col", unless specified otherwise 4316 pos, col = exp.to_identifier("pos"), exp.to_identifier("col") 4317 4318 if isinstance(parent, exp.Aliases): 4319 # Column case: SELECT POSEXPLODE(col) [AS (a, b)] 4320 pos, col = parent.expressions 4321 elif isinstance(parent, exp.Table): 4322 # Table case: SELECT * FROM POSEXPLODE(col) [AS (a, b)] 4323 alias = parent.args.get("alias") 4324 if alias: 4325 pos, col = alias.columns or [pos, col] 4326 alias.pop() 4327 4328 # Translate POSEXPLODE to UNNEST + GENERATE_SUBSCRIPTS 4329 # Note: In Spark pos is 0-indexed, but in DuckDB it's 1-indexed, so we subtract 1 from GENERATE_SUBSCRIPTS 4330 unnest_sql = self.sql(exp.Unnest(expressions=[this], alias=col)) 4331 gen_subscripts = self.sql( 4332 exp.Alias( 4333 this=exp.Anonymous( 4334 this="GENERATE_SUBSCRIPTS", expressions=[this, exp.Literal.number(1)] 4335 ) 4336 - exp.Literal.number(1), 4337 alias=pos, 4338 ) 4339 ) 4340 4341 posexplode_sql = self.format_args(gen_subscripts, unnest_sql) 4342 4343 if isinstance(parent, exp.From) or (parent and isinstance(parent.parent, exp.From)): 4344 # SELECT * FROM POSEXPLODE(col) -> SELECT * FROM (SELECT GENERATE_SUBSCRIPTS(...), UNNEST(...)) 4345 return self.sql(exp.Subquery(this=exp.Select(expressions=[posexplode_sql]))) 4346 4347 return posexplode_sql 4348 4349 def addmonths_sql(self, expression: exp.AddMonths) -> str: 4350 """ 4351 Handles three key issues: 4352 1. Float/decimal months: e.g., Snowflake rounds, whereas DuckDB INTERVAL requires integers 4353 2. End-of-month preservation: If input is last day of month, result is last day of result month 4354 3. Type preservation: Maintains DATE/TIMESTAMPTZ types (DuckDB defaults to TIMESTAMP) 4355 """ 4356 from sqlglot.optimizer.annotate_types import annotate_types 4357 4358 this = expression.this 4359 if not this.type: 4360 this = annotate_types(this, dialect=self.dialect) 4361 4362 if this.is_type(*exp.DataType.TEXT_TYPES): 4363 this = exp.Cast(this=this, to=exp.DataType(this=exp.DType.TIMESTAMP)) 4364 4365 # Detect float/decimal months to apply rounding (Snowflake behavior) 4366 # DuckDB INTERVAL syntax doesn't support non-integer expressions, so use TO_MONTHS 4367 months_expr = expression.expression 4368 if not months_expr.type: 4369 months_expr = annotate_types(months_expr, dialect=self.dialect) 4370 4371 # Build interval or to_months expression based on type 4372 # Float/decimal case: Round and use TO_MONTHS(CAST(ROUND(value) AS INT)) 4373 interval_or_to_months = ( 4374 exp.func("TO_MONTHS", exp.cast(exp.func("ROUND", months_expr), "INT")) 4375 if months_expr.is_type( 4376 exp.DType.FLOAT, 4377 exp.DType.DOUBLE, 4378 exp.DType.DECIMAL, 4379 ) 4380 # Integer case: standard INTERVAL N MONTH syntax 4381 else exp.Interval(this=months_expr, unit=exp.var("MONTH")) 4382 ) 4383 4384 date_add_expr = exp.Add(this=this, expression=interval_or_to_months) 4385 4386 # Apply end-of-month preservation if Snowflake flag is set 4387 # CASE WHEN LAST_DAY(date) = date THEN LAST_DAY(result) ELSE result END 4388 preserve_eom = expression.args.get("preserve_end_of_month") 4389 result_expr = ( 4390 exp.case() 4391 .when( 4392 exp.EQ(this=exp.func("LAST_DAY", this), expression=this), 4393 exp.func("LAST_DAY", date_add_expr), 4394 ) 4395 .else_(date_add_expr) 4396 if preserve_eom 4397 else date_add_expr 4398 ) 4399 4400 # DuckDB's DATE_ADD function returns TIMESTAMP/DATETIME by default, even when the input is DATE 4401 # To match for example Snowflake's ADD_MONTHS behavior (which preserves the input type) 4402 # We need to cast the result back to the original type when the input is DATE or TIMESTAMPTZ 4403 # Example: ADD_MONTHS('2023-01-31'::date, 1) should return DATE, not TIMESTAMP 4404 if this.is_type(exp.DType.DATE, exp.DType.TIMESTAMPTZ): 4405 return self.sql(exp.Cast(this=result_expr, to=this.type)) 4406 return self.sql(result_expr) 4407 4408 def format_sql(self, expression: exp.Format) -> str: 4409 if expression.name.lower() == "%s" and len(expression.expressions) == 1: 4410 return self.func("FORMAT", "'{}'", expression.expressions[0]) 4411 4412 return self.function_fallback_sql(expression) 4413 4414 def hexstring_sql( 4415 self, expression: exp.HexString, binary_function_repr: str | None = None 4416 ) -> str: 4417 # UNHEX('FF') correctly produces blob \xFF in DuckDB 4418 return super().hexstring_sql(expression, binary_function_repr="UNHEX") 4419 4420 def datetrunc_sql(self, expression: exp.DateTrunc) -> str: 4421 unit = expression.args.get("unit") 4422 date = expression.this 4423 4424 week_start = _week_trunc_start_dow(unit) 4425 unit = unit_to_str(expression) 4426 4427 if week_start: 4428 result = self.sql( 4429 _build_week_trunc_expression(date, week_start, preserve_start_day=True) 4430 ) 4431 else: 4432 result = self.func("DATE_TRUNC", unit, date) 4433 4434 if ( 4435 expression.args.get("input_type_preserved") 4436 and date.is_type(*exp.DataType.TEMPORAL_TYPES) 4437 and not (is_date_unit(unit) and date.is_type(exp.DType.DATE)) 4438 ): 4439 return self.sql(exp.Cast(this=result, to=date.type)) 4440 4441 return result 4442 4443 def datetimetrunc_sql(self, expression: exp.DatetimeTrunc) -> str: 4444 this = exp.cast(expression.this, exp.DType.DATETIME) 4445 week_start = _week_trunc_start_dow(expression.args.get("unit")) 4446 if week_start: 4447 return self.sql( 4448 _build_week_trunc_expression( 4449 this, week_start, preserve_start_day=True, cast_to_date=False 4450 ) 4451 ) 4452 4453 return self.func("DATE_TRUNC", unit_to_str(expression), this) 4454 4455 def timestamptrunc_sql(self, expression: exp.TimestampTrunc) -> str: 4456 zone = expression.args.get("zone") 4457 timestamp = expression.this 4458 week_start = _week_trunc_start_dow(expression.args.get("unit")) 4459 4460 # The week start emulation below is exact, so avoid weekstart_unit_to_str's degrade warning 4461 unit = unit_to_str(expression) if week_start else weekstart_unit_to_str(self, expression) 4462 date_unit = is_date_unit(unit) or bool(week_start) 4463 4464 def _trunc_expr(this: exp.Expr) -> exp.Expr: 4465 if week_start: 4466 return _build_week_trunc_expression( 4467 this, week_start, preserve_start_day=True, cast_to_date=False 4468 ) 4469 return exp.func("DATE_TRUNC", unit, this) 4470 4471 if date_unit and zone: 4472 # BigQuery's TIMESTAMP_TRUNC with timezone truncates in the target timezone and returns as UTC. 4473 # Double AT TIME ZONE needed for BigQuery compatibility: 4474 # 1. First AT TIME ZONE: ensures truncation happens in the target timezone 4475 # 2. Second AT TIME ZONE: converts the DATE result back to TIMESTAMPTZ (preserving time component) 4476 timestamp = exp.AtTimeZone(this=timestamp, zone=zone) 4477 trunced = _trunc_expr(timestamp) 4478 if isinstance(trunced, exp.DateAdd): 4479 # Parenthesize so the trailing AT TIME ZONE binds to the whole shifted expression 4480 trunced = exp.Paren(this=trunced) 4481 return self.sql(exp.AtTimeZone(this=trunced, zone=zone)) 4482 4483 result = self.sql(_trunc_expr(timestamp)) 4484 if expression.args.get("input_type_preserved"): 4485 if timestamp.type and timestamp.is_type(exp.DType.TIME, exp.DType.TIMETZ): 4486 dummy_date = exp.Cast( 4487 this=exp.Literal.string("1970-01-01"), 4488 to=exp.DataType(this=exp.DType.DATE), 4489 ) 4490 date_time = exp.Add(this=dummy_date, expression=timestamp) 4491 result = self.func("DATE_TRUNC", unit, date_time) 4492 return self.sql(exp.Cast(this=result, to=timestamp.type)) 4493 4494 if timestamp.is_type(*exp.DataType.TEMPORAL_TYPES) and not ( 4495 date_unit and timestamp.is_type(exp.DType.DATE) 4496 ): 4497 return self.sql(exp.Cast(this=result, to=timestamp.type)) 4498 4499 return result 4500 4501 def trim_sql(self, expression: exp.Trim) -> str: 4502 expression.this.replace(_cast_to_varchar(expression.this)) 4503 if expression.expression: 4504 expression.expression.replace(_cast_to_varchar(expression.expression)) 4505 4506 result_sql = super().trim_sql(expression) 4507 return _gen_with_cast_to_blob(self, expression, result_sql) 4508 4509 def round_sql(self, expression: exp.Round) -> str: 4510 this = expression.this 4511 decimals = expression.args.get("decimals") 4512 truncate = expression.args.get("truncate") 4513 4514 # DuckDB requires the scale (decimals) argument to be an INT 4515 # Some dialects (e.g., Snowflake) allow non-integer scales and cast to an integer internally 4516 if decimals is not None and expression.args.get("casts_non_integer_decimals"): 4517 if not (decimals.is_int or decimals.is_type(*exp.DataType.INTEGER_TYPES)): 4518 decimals = exp.cast(decimals, exp.DType.INT) 4519 4520 func = "ROUND" 4521 if truncate: 4522 # BigQuery uses ROUND_HALF_EVEN; Snowflake uses HALF_TO_EVEN 4523 if truncate.this in ("ROUND_HALF_EVEN", "HALF_TO_EVEN"): 4524 func = "ROUND_EVEN" 4525 truncate = None 4526 # BigQuery uses ROUND_HALF_AWAY_FROM_ZERO; Snowflake uses HALF_AWAY_FROM_ZERO 4527 elif truncate.this in ("ROUND_HALF_AWAY_FROM_ZERO", "HALF_AWAY_FROM_ZERO"): 4528 truncate = None 4529 4530 return self.func(func, this, decimals, truncate) 4531 4532 def trycast_sql(self, expression: exp.TryCast) -> str: 4533 to = expression.to 4534 to_type = to.this 4535 src = expression.this 4536 4537 if ( 4538 expression.args.get("null_on_text_overflow") 4539 and to_type in exp.DataType.TEXT_TYPES 4540 and to.expressions 4541 ): 4542 return self.sql( 4543 exp.case() 4544 .when( 4545 exp.LTE(this=exp.func("LENGTH", src), expression=to.expressions[0].this), 4546 exp.cast(src, "TEXT"), 4547 ) 4548 .else_(exp.Null()) 4549 ) 4550 elif to_type == exp.DType.DATE and expression.args.get("probe_date_format"): 4551 slash_strptime = exp.cast( 4552 exp.func("TRY_STRPTIME", src, exp.Literal.string(self._TRYCAST_DATE_SLASH_FMT)), 4553 "DATE", 4554 ) 4555 mon_strptime = exp.cast( 4556 exp.func("TRY_STRPTIME", src, exp.Literal.string(self._TRYCAST_DATE_MON_FMT)), 4557 "DATE", 4558 ) 4559 return self.sql( 4560 exp.case() 4561 .when(exp.func("CONTAINS", src, exp.Literal.string("/")), slash_strptime) 4562 .when( 4563 exp.RegexpLike(this=src, expression=exp.Literal.string("[A-Za-z]")), 4564 mon_strptime, 4565 ) 4566 .else_(exp.TryCast(this=src, to=to)) 4567 ) 4568 elif ( 4569 isinstance(to_type, exp.Interval) 4570 and (unit := to_type.unit) 4571 and expression.args.get("requires_string") 4572 ): 4573 interval_type = exp.DataType.build("INTERVAL") 4574 if isinstance(unit, exp.IntervalSpan): 4575 self.unsupported( 4576 "TRY_CAST to INTERVAL with span (e.g. HOUR TO MINUTE) is not supported in DuckDB" 4577 ) 4578 return self.sql(exp.TryCast(this=src, to=interval_type)) 4579 return self.sql( 4580 exp.TryCast( 4581 this=exp.DPipe(this=src, expression=exp.Literal.string(f" {unit.name}")), 4582 to=interval_type, 4583 ) 4584 ) 4585 4586 return super().trycast_sql(expression) 4587 4588 def strtok_sql(self, expression: exp.Strtok) -> str: 4589 string_arg = expression.this 4590 delimiter_arg = expression.args.get("delimiter") 4591 part_index_arg = expression.args.get("part_index") 4592 4593 if delimiter_arg and part_index_arg: 4594 # Escape regex chars and build character class at runtime using REGEXP_REPLACE 4595 escaped_delimiter = exp.Anonymous( 4596 this="REGEXP_REPLACE", 4597 expressions=[ 4598 delimiter_arg, 4599 exp.Literal.string( 4600 r"([\[\]^.\-*+?(){}|$\\])" 4601 ), # Escape problematic regex chars 4602 exp.Literal.string( 4603 r"\\\1" 4604 ), # Replace with escaped version using $1 backreference 4605 exp.Literal.string("g"), # Global flag 4606 ], 4607 ) 4608 # CASE WHEN delimiter = '' THEN '' ELSE CONCAT('[', escaped_delimiter, ']') END 4609 regex_pattern = ( 4610 exp.case() 4611 .when(delimiter_arg.eq(exp.Literal.string("")), exp.Literal.string("")) 4612 .else_( 4613 exp.func( 4614 "CONCAT", 4615 exp.Literal.string("["), 4616 escaped_delimiter, 4617 exp.Literal.string("]"), 4618 ) 4619 ) 4620 ) 4621 4622 # STRTOK skips empty strings, so we need to filter them out 4623 # LIST_FILTER(REGEXP_SPLIT_TO_ARRAY(string, pattern), x -> x != '')[index] 4624 split_array = exp.func("REGEXP_SPLIT_TO_ARRAY", string_arg, regex_pattern) 4625 x = exp.to_identifier("x") 4626 is_empty = x.eq(exp.Literal.string("")) 4627 filtered_array = exp.func( 4628 "LIST_FILTER", 4629 split_array, 4630 exp.Lambda(this=exp.not_(is_empty.copy()), expressions=[x.copy()]), 4631 ) 4632 base_func = exp.Bracket( 4633 this=filtered_array, 4634 expressions=[part_index_arg], 4635 offset=1, 4636 ) 4637 4638 # Use template with the built regex pattern 4639 result = exp.replace_placeholders( 4640 self.STRTOK_TEMPLATE.copy(), 4641 string=string_arg, 4642 delimiter=delimiter_arg, 4643 part_index=part_index_arg, 4644 base_func=base_func, 4645 ) 4646 4647 return self.sql(result) 4648 4649 return self.function_fallback_sql(expression) 4650 4651 def strtoktoarray_sql(self, expression: exp.StrtokToArray) -> str: 4652 string_arg = expression.this 4653 delimiter_arg = expression.args.get("expression") or exp.Literal.string(" ") 4654 4655 escaped = exp.RegexpReplace( 4656 this=delimiter_arg.copy(), 4657 expression=exp.Literal.string(r"([\[\]^.\-*+?(){}|$\\])"), 4658 replacement=exp.Literal.string(r"\\\1"), 4659 modifiers=exp.Literal.string("g"), 4660 ) 4661 return self.sql( 4662 exp.replace_placeholders( 4663 self.STRTOK_TO_ARRAY_TEMPLATE.copy(), 4664 string=string_arg, 4665 delimiter=delimiter_arg, 4666 escaped=escaped, 4667 ) 4668 ) 4669 4670 def approxquantile_sql(self, expression: exp.ApproxQuantile) -> str: 4671 result = self.func("APPROX_QUANTILE", expression.this, expression.args.get("quantile")) 4672 4673 # DuckDB returns integers for APPROX_QUANTILE, cast to DOUBLE if the expected type is a real type 4674 if expression.is_type(*exp.DataType.REAL_TYPES): 4675 result = f"CAST({result} AS DOUBLE)" 4676 4677 return result 4678 4679 def approxquantiles_sql(self, expression: exp.ApproxQuantiles) -> str: 4680 """ 4681 BigQuery's APPROX_QUANTILES(expr, n) returns an array of n+1 approximate quantile values 4682 dividing the input distribution into n equal-sized buckets. 4683 4684 Both BigQuery and DuckDB use approximate algorithms for quantile estimation, but BigQuery 4685 does not document the specific algorithm used so results may differ. DuckDB does not 4686 support RESPECT NULLS. 4687 """ 4688 this = expression.this 4689 if isinstance(this, exp.Distinct): 4690 # APPROX_QUANTILES requires 2 args and DISTINCT node grabs both 4691 if len(this.expressions) < 2: 4692 self.unsupported("APPROX_QUANTILES requires a bucket count argument") 4693 return self.function_fallback_sql(expression) 4694 num_quantiles_expr = this.expressions[1].pop() 4695 else: 4696 num_quantiles_expr = expression.expression 4697 4698 if not isinstance(num_quantiles_expr, exp.Literal) or not num_quantiles_expr.is_int: 4699 self.unsupported("APPROX_QUANTILES bucket count must be a positive integer") 4700 return self.function_fallback_sql(expression) 4701 4702 num_quantiles = t.cast(int, num_quantiles_expr.to_py()) 4703 if num_quantiles <= 0: 4704 self.unsupported("APPROX_QUANTILES bucket count must be a positive integer") 4705 return self.function_fallback_sql(expression) 4706 4707 quantiles = [ 4708 exp.Literal.number(Decimal(i) / Decimal(num_quantiles)) 4709 for i in range(num_quantiles + 1) 4710 ] 4711 4712 return self.sql(exp.ApproxQuantile(this=this, quantile=exp.Array(expressions=quantiles))) 4713 4714 def jsonextractscalar_sql(self, expression: exp.JSONExtractScalar) -> str: 4715 if expression.args.get("scalar_only"): 4716 json_value = exp.JSONExtractScalar( 4717 this=rename_func("JSON_VALUE")(self, expression), expression="'$'" 4718 ) 4719 4720 # `->>` binds looser than most operators, so the wrap logic needs the parent 4721 json_value.parent = expression.parent 4722 expression = json_value 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: t.ClassVar[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: t.ClassVar[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: t.ClassVar[exp.Expr] = exp.maybe_parse( 1980 "(ABS(HASH(:seed)) % 1000000) / 1000000.0" 1981 ) 1982 1983 # Template for generating signed and unsigned SEQ values within a specified range 1984 SEQ_UNSIGNED: t.ClassVar[exp.Expr] = _SEQ_UNSIGNED 1985 SEQ_SIGNED: t.ClassVar[exp.Expr] = _SEQ_SIGNED 1986 1987 # Template for MAP_CAT transpilation - Snowflake semantics: 1988 # 1. Returns NULL if either input is NULL 1989 # 2. For duplicate keys, prefers non-NULL value (COALESCE(m2[k], m1[k])) 1990 # 3. Filters out entries with NULL values from the result 1991 MAPCAT_TEMPLATE: t.ClassVar[exp.Expr] = exp.maybe_parse( 1992 """ 1993 CASE 1994 WHEN :map1 IS NULL OR :map2 IS NULL THEN NULL 1995 ELSE MAP_FROM_ENTRIES(LIST_FILTER(LIST_TRANSFORM( 1996 LIST_DISTINCT(LIST_CONCAT(MAP_KEYS(:map1), MAP_KEYS(:map2))), 1997 __k -> STRUCT_PACK(key := __k, value := COALESCE(:map2[__k], :map1[__k])) 1998 ), __x -> __x.value IS NOT NULL)) 1999 END 2000 """ 2001 ) 2002 2003 # Mappings for EXTRACT/DATE_PART transpilation 2004 # Maps Snowflake specifiers unsupported in DuckDB to strftime format codes 2005 EXTRACT_STRFTIME_MAPPINGS: t.ClassVar[dict[str, tuple[str, str]]] = { 2006 "WEEKISO": ("%V", "INTEGER"), 2007 "YEAROFWEEK": ("%G", "INTEGER"), 2008 "YEAROFWEEKISO": ("%G", "INTEGER"), 2009 "NANOSECOND": ("%n", "BIGINT"), 2010 } 2011 2012 # Maps epoch-based specifiers to DuckDB epoch functions 2013 EXTRACT_EPOCH_MAPPINGS: t.ClassVar[dict[str, str]] = { 2014 "EPOCH_SECOND": "EPOCH", 2015 "EPOCH_MILLISECOND": "EPOCH_MS", 2016 "EPOCH_MICROSECOND": "EPOCH_US", 2017 "EPOCH_NANOSECOND": "EPOCH_NS", 2018 } 2019 2020 # Template for BITMAP_CONSTRUCT_AGG transpilation 2021 # 2022 # BACKGROUND: 2023 # Snowflake's BITMAP_CONSTRUCT_AGG aggregates integers into a compact binary bitmap. 2024 # Supports values in range 0-32767, this version returns NULL if any value is out of range 2025 # See: https://docs.snowflake.com/en/sql-reference/functions/bitmap_construct_agg 2026 # See: https://docs.snowflake.com/en/user-guide/querying-bitmaps-for-distinct-counts 2027 # 2028 # Snowflake uses two different formats based on the number of unique values: 2029 # 2030 # Format 1 - Small bitmap (< 5 unique values): Length of 10 bytes 2031 # Bytes 0-1: Count of values as 2-byte big-endian integer (e.g., 3 values = 0x0003) 2032 # Bytes 2-9: Up to 4 values, each as 2-byte little-endian integers, zero-padded to 8 bytes 2033 # Example: Values [1, 2, 3] -> 0x0003 0100 0200 0300 0000 (hex) 2034 # count v1 v2 v3 pad 2035 # 2036 # Format 2 - Large bitmap (>= 5 unique values): Length of 10 + (2 * count) bytes 2037 # Bytes 0-9: Fixed header 0x08 followed by 9 zero bytes 2038 # Bytes 10+: Each value as 2-byte little-endian integer (no padding) 2039 # Example: Values [1,2,3,4,5] -> 0x08 00000000 00000000 00 0100 0200 0300 0400 0500 2040 # hdr ----9 zero bytes---- v1 v2 v3 v4 v5 2041 # 2042 # TEMPLATE STRUCTURE 2043 # 2044 # Phase 1 - Innermost subquery: Data preparation 2045 # SELECT LIST_SORT(...) AS l 2046 # - Aggregates all input values into a list, remove NULLs, duplicates and sorts 2047 # Result: Clean, sorted list of unique non-null integers stored as 'l' 2048 # 2049 # Phase 2 - Middle subquery: Hex string construction 2050 # LIST_TRANSFORM(...) 2051 # - Converts each integer to 2-byte little-endian hex representation 2052 # - & 255 extracts low byte, >> 8 extracts high byte 2053 # - LIST_REDUCE: Concatenates all hex pairs into single string 'h' 2054 # Result: Hex string of all values 2055 # 2056 # Phase 3 - Outer SELECT: Final bitmap assembly 2057 # LENGTH(l) < 5: 2058 # - Small format: 2-byte count (big-endian via %04X) + values + zero padding 2059 # LENGTH(l) >= 5: 2060 # - Large format: Fixed 10-byte header + values (no padding needed) 2061 # Result: Complete binary bitmap as BLOB 2062 # 2063 BITMAP_CONSTRUCT_AGG_TEMPLATE: t.ClassVar[exp.Expr] = exp.maybe_parse( 2064 """ 2065 SELECT CASE 2066 WHEN l IS NULL OR LENGTH(l) = 0 THEN NULL 2067 WHEN LENGTH(l) != LENGTH(LIST_FILTER(l, __v -> __v BETWEEN 0 AND 32767)) THEN NULL 2068 WHEN LENGTH(l) < 5 THEN UNHEX(PRINTF('%04X', LENGTH(l)) || h || REPEAT('00', GREATEST(0, 4 - LENGTH(l)) * 2)) 2069 ELSE UNHEX('08000000000000000000' || h) 2070 END 2071 FROM ( 2072 SELECT l, COALESCE(LIST_REDUCE( 2073 LIST_TRANSFORM(l, __x -> PRINTF('%02X%02X', CAST(__x AS INT) & 255, (CAST(__x AS INT) >> 8) & 255)), 2074 (__a, __b) -> __a || __b, '' 2075 ), '') AS h 2076 FROM (SELECT LIST_SORT(LIST_DISTINCT(LIST(:arg) FILTER(NOT :arg IS NULL))) AS l) 2077 ) 2078 """ 2079 ) 2080 2081 # Template for RANDSTR transpilation - placeholders get replaced with actual parameters 2082 RANDSTR_TEMPLATE: t.ClassVar[exp.Expr] = exp.maybe_parse( 2083 f""" 2084 SELECT LISTAGG( 2085 SUBSTRING( 2086 '{RANDSTR_CHAR_POOL}', 2087 1 + CAST(FLOOR(random_value * 62) AS INT), 2088 1 2089 ), 2090 '' 2091 ) 2092 FROM ( 2093 SELECT (ABS(HASH(i + :seed)) % 1000) / 1000.0 AS random_value 2094 FROM RANGE(:length) AS t(i) 2095 ) 2096 """, 2097 ) 2098 2099 # Template for MINHASH transpilation 2100 # Computes k minimum hash values across aggregated data using DuckDB list functions 2101 # Returns JSON matching Snowflake format: {"state": [...], "type": "minhash", "version": 1} 2102 MINHASH_TEMPLATE: t.ClassVar[exp.Expr] = exp.maybe_parse( 2103 """ 2104 SELECT JSON_OBJECT('state', LIST(min_h ORDER BY seed), 'type', 'minhash', 'version', 1) 2105 FROM ( 2106 SELECT seed, LIST_MIN(LIST_TRANSFORM(vals, __v -> HASH(CAST(__v AS VARCHAR) || CAST(seed AS VARCHAR)))) AS min_h 2107 FROM (SELECT LIST(:expr) AS vals), RANGE(0, :k) AS t(seed) 2108 ) 2109 """, 2110 ) 2111 2112 # Template for MINHASH_COMBINE transpilation 2113 # Combines multiple minhash signatures by taking element-wise minimum 2114 MINHASH_COMBINE_TEMPLATE: t.ClassVar[exp.Expr] = exp.maybe_parse( 2115 """ 2116 SELECT JSON_OBJECT('state', LIST(min_h ORDER BY idx), 'type', 'minhash', 'version', 1) 2117 FROM ( 2118 SELECT 2119 pos AS idx, 2120 MIN(val) AS min_h 2121 FROM 2122 UNNEST(LIST(:expr)) AS _(sig), 2123 UNNEST(CAST(sig -> 'state' AS UBIGINT[])) WITH ORDINALITY AS t(val, pos) 2124 GROUP BY pos 2125 ) 2126 """, 2127 ) 2128 2129 # Template for APPROXIMATE_SIMILARITY transpilation 2130 # Computes multi-way Jaccard similarity: fraction of positions where ALL signatures agree 2131 APPROXIMATE_SIMILARITY_TEMPLATE: t.ClassVar[exp.Expr] = exp.maybe_parse( 2132 """ 2133 SELECT CAST(SUM(CASE WHEN num_distinct = 1 THEN 1 ELSE 0 END) AS DOUBLE) / COUNT(*) 2134 FROM ( 2135 SELECT pos, COUNT(DISTINCT h) AS num_distinct 2136 FROM ( 2137 SELECT h, pos 2138 FROM UNNEST(LIST(:expr)) AS _(sig), 2139 UNNEST(CAST(sig -> 'state' AS UBIGINT[])) WITH ORDINALITY AS s(h, pos) 2140 ) 2141 GROUP BY pos 2142 ) 2143 """, 2144 ) 2145 2146 # Template for ARRAYS_ZIP transpilation 2147 # Snowflake pads to longest array; DuckDB LIST_ZIP truncates to shortest 2148 # Uses RANGE + indexing to match Snowflake behavior 2149 ARRAYS_ZIP_TEMPLATE: t.ClassVar[exp.Expr] = exp.maybe_parse( 2150 """ 2151 CASE WHEN :null_check THEN NULL 2152 WHEN :all_empty_check THEN [:empty_struct] 2153 ELSE LIST_TRANSFORM(RANGE(0, :max_len), __i -> :transform_struct) 2154 END 2155 """, 2156 ) 2157 2158 UUID_V5_TEMPLATE: t.ClassVar[exp.Expr] = exp.maybe_parse( 2159 """ 2160 (SELECT 2161 LOWER( 2162 SUBSTR(h, 1, 8) || '-' || 2163 SUBSTR(h, 9, 4) || '-' || 2164 '5' || SUBSTR(h, 14, 3) || '-' || 2165 FORMAT('{:02x}', CAST('0x' || SUBSTR(h, 17, 2) AS INT) & 63 | 128) || SUBSTR(h, 19, 2) || '-' || 2166 SUBSTR(h, 21, 12) 2167 ) 2168 FROM ( 2169 SELECT SUBSTR(SHA1(UNHEX(REPLACE(:namespace, '-', '')) || ENCODE(:name, 'utf8')), 1, 32) AS h 2170 )) 2171 """ 2172 ) 2173 2174 # Shared bag semantics outer frame for ARRAY_EXCEPT and ARRAY_INTERSECTION. 2175 # Each element is paired with its 1-based position via LIST_ZIP, then filtered 2176 # by a comparison operator (supplied via :cond) that determines the operation: 2177 # EXCEPT (>): keep the N-th occurrence only if N > count in arr2 2178 # e.g. [2,2,2] EXCEPT [2,2] -> [2] 2179 # INTERSECTION (<=): keep the N-th occurrence only if N <= count in arr2 2180 # e.g. [2,2,2] INTERSECT [2,2] -> [2,2] 2181 # IS NOT DISTINCT FROM is used for NULL-safe element comparison. 2182 ARRAY_BAG_TEMPLATE: t.ClassVar[exp.Expr] = exp.maybe_parse( 2183 """ 2184 CASE 2185 WHEN :arr1 IS NULL OR :arr2 IS NULL THEN NULL 2186 ELSE LIST_TRANSFORM( 2187 LIST_FILTER( 2188 LIST_ZIP(:arr1, GENERATE_SERIES(1, LEN(:arr1))), 2189 pair -> :cond 2190 ), 2191 pair -> pair[0] 2192 ) 2193 END 2194 """ 2195 ) 2196 2197 ARRAY_EXCEPT_CONDITION: t.ClassVar[exp.Expr] = exp.maybe_parse( 2198 "LEN(LIST_FILTER(:arr1[1:pair[1]], e -> e IS NOT DISTINCT FROM pair[0]))" 2199 " > LEN(LIST_FILTER(:arr2, e -> e IS NOT DISTINCT FROM pair[0]))" 2200 ) 2201 2202 ARRAY_INTERSECTION_CONDITION: t.ClassVar[exp.Expr] = exp.maybe_parse( 2203 "LEN(LIST_FILTER(:arr1[1:pair[1]], e -> e IS NOT DISTINCT FROM pair[0]))" 2204 " <= LEN(LIST_FILTER(:arr2, e -> e IS NOT DISTINCT FROM pair[0]))" 2205 ) 2206 2207 # Set semantics for ARRAY_EXCEPT. Deduplicates arr1 via LIST_DISTINCT, then 2208 # filters out any element that appears at least once in arr2. 2209 # e.g. [1,1,2,3] EXCEPT [1] -> [2,3] 2210 # IS NOT DISTINCT FROM is used for NULL-safe element comparison. 2211 ARRAY_EXCEPT_SET_TEMPLATE: t.ClassVar[exp.Expr] = exp.maybe_parse( 2212 """ 2213 CASE 2214 WHEN :arr1 IS NULL OR :arr2 IS NULL THEN NULL 2215 ELSE LIST_FILTER( 2216 LIST_DISTINCT(:arr1), 2217 e -> LEN(LIST_FILTER(:arr2, x -> x IS NOT DISTINCT FROM e)) = 0 2218 ) 2219 END 2220 """ 2221 ) 2222 2223 # BigQuery's `x IN UNNEST(arr)` NULL semantics: 2224 # NULL IN UNNEST([1, 2]) -> NULL 2225 # 3 IN UNNEST([1, NULL]) -> NULL 2226 # 3 IN UNNEST([1, 2]) -> FALSE 2227 # 1 IN UNNEST(NULL) -> FALSE (not NULL) 2228 # 1 IN UNNEST([]) -> FALSE 2229 # The default `IN (SELECT UNNEST(...))` rewrite creates a correlated subquery 2230 # that DuckDB rejects inside non-inner joins, so a CASE expression is used instead. 2231 IN_UNNEST_TEMPLATE: t.ClassVar[exp.Expr] = exp.maybe_parse( 2232 """ 2233 CASE 2234 WHEN :arr IS NULL OR ARRAY_LENGTH(:arr) = 0 THEN FALSE 2235 WHEN ARRAY_CONTAINS(:arr, :value) THEN TRUE 2236 WHEN :value IS NULL OR ARRAY_LENGTH(:arr) <> LIST_COUNT(:arr) THEN NULL 2237 ELSE FALSE 2238 END 2239 """ 2240 ) 2241 2242 STRTOK_TO_ARRAY_TEMPLATE: t.ClassVar[exp.Expr] = exp.maybe_parse( 2243 """ 2244 CASE WHEN :delimiter IS NULL THEN NULL 2245 ELSE LIST_FILTER( 2246 REGEXP_SPLIT_TO_ARRAY(:string, CASE WHEN :delimiter = '' THEN '.^' ELSE CONCAT('[', :escaped, ']') END), 2247 x -> NOT x = '' 2248 ) END 2249 """ 2250 ) 2251 2252 # Template for STRTOK function transpilation 2253 # 2254 # DuckDB itself doesn't have a strtok function. This handles the transpilation from Snowflake to DuckDB. 2255 # We may need to adjust this if we want to support transpilation from other dialects 2256 # 2257 # CASE 2258 # -- Snowflake: empty delimiter + empty input string -> NULL 2259 # WHEN delimiter = '' AND input_str = '' THEN NULL 2260 # 2261 # -- Snowflake: empty delimiter + non-empty input string -> treats whole input as 1 token -> return input string if index is 1 2262 # WHEN delimiter = '' AND index = 1 THEN input_str 2263 # 2264 # -- Snowflake: empty delimiter + non-empty input string -> treats whole input as 1 token -> return NULL if index is not 1 2265 # WHEN delimiter = '' THEN NULL 2266 # 2267 # -- Snowflake: negative indices return NULL 2268 # WHEN index < 0 THEN NULL 2269 # 2270 # -- Snowflake: return NULL if any argument is NULL 2271 # WHEN input_str IS NULL OR delimiter IS NULL OR index IS NULL THEN NULL 2272 # 2273 # 2274 # ELSE LIST_FILTER( 2275 # REGEXP_SPLIT_TO_ARRAY( 2276 # input_str, 2277 # CASE 2278 # -- if delimiter is '', we don't want to surround it with '[' and ']' as '[]' is invalid for DuckDB 2279 # WHEN delimiter = '' THEN '' 2280 # 2281 # -- handle problematic regex characters in delimiter with REGEXP_REPLACE 2282 # -- turn delimiter into a regex char set, otherwise DuckDB will match in order, which we don't want 2283 # ELSE '[' || REGEXP_REPLACE(delimiter, problematic_char_set, '\\\1', 'g') || ']' 2284 # END 2285 # ), 2286 # 2287 # -- Snowflake: don't return empty strings 2288 # x -> NOT x = '' 2289 # )[index] 2290 # END 2291 STRTOK_TEMPLATE: t.ClassVar[exp.Expr] = exp.maybe_parse( 2292 """ 2293 CASE 2294 WHEN :delimiter = '' AND :string = '' THEN NULL 2295 WHEN :delimiter = '' AND :part_index = 1 THEN :string 2296 WHEN :delimiter = '' THEN NULL 2297 WHEN :part_index < 0 THEN NULL 2298 WHEN :string IS NULL OR :delimiter IS NULL OR :part_index IS NULL THEN NULL 2299 ELSE :base_func 2300 END 2301 """ 2302 ) 2303 2304 # Snowflake AUTO detects 3 DATE formats: YYYY-MM-DD (ISO-8601), MM/DD/YYYY, DD-MON-YYYY. 2305 # DuckDB TRY_CAST handles ISO-8601 natively. For the other two formats we use CONTAINS('/') 2306 # and REGEXP_MATCHES('[A-Za-z]') as heuristics — these correctly handle single-digit months 2307 # and days (e.g. 1/5/2020, 5-JAN-2020) where a positional char check would fail. 2308 # Ref: https://docs.snowflake.com/en/sql-reference/date-time-input-output#date-formats 2309 _TRYCAST_DATE_SLASH_FMT = "%m/%d/%Y" 2310 _TRYCAST_DATE_MON_FMT = "%d-%b-%Y" 2311 2312 def _array_bag_sql(self, condition: exp.Expr, arr1: exp.Expr, arr2: exp.Expr) -> str: 2313 cond = exp.Paren(this=exp.replace_placeholders(condition, arr1=arr1, arr2=arr2)) 2314 return self.sql( 2315 exp.replace_placeholders(self.ARRAY_BAG_TEMPLATE, arr1=arr1, arr2=arr2, cond=cond) 2316 ) 2317 2318 def timeslice_sql(self, expression: exp.TimeSlice) -> str: 2319 """ 2320 Transform Snowflake's TIME_SLICE to DuckDB's time_bucket. 2321 2322 Snowflake: TIME_SLICE(date_expr, slice_length, 'UNIT' [, 'START'|'END']) 2323 DuckDB: time_bucket(INTERVAL 'slice_length' UNIT, date_expr) 2324 2325 For 'END' kind, add the interval to get the end of the slice. 2326 For DATE type with 'END', cast result back to DATE to preserve type. 2327 """ 2328 date_expr = expression.this 2329 slice_length = expression.expression 2330 unit = expression.unit 2331 kind = expression.text("kind").upper() 2332 2333 # Create INTERVAL expression: INTERVAL 'N' UNIT 2334 interval_expr = exp.Interval(this=slice_length, unit=unit) 2335 2336 # Create base time_bucket expression 2337 time_bucket_expr = exp.func("time_bucket", interval_expr, date_expr) 2338 2339 # Check if we need the end of the slice (default is start) 2340 if not kind == "END": 2341 # For 'START', return time_bucket directly 2342 return self.sql(time_bucket_expr) 2343 2344 # For 'END', add the interval to get end of slice 2345 add_expr = exp.Add(this=time_bucket_expr, expression=interval_expr.copy()) 2346 2347 # If input is DATE type, cast result back to DATE to preserve type 2348 # DuckDB converts DATE to TIMESTAMP when adding intervals 2349 if date_expr.is_type(exp.DType.DATE): 2350 return self.sql(exp.cast(add_expr, exp.DType.DATE)) 2351 2352 return self.sql(add_expr) 2353 2354 def bitmapbucketnumber_sql(self, expression: exp.BitmapBucketNumber) -> str: 2355 """ 2356 Transpile BITMAP_BUCKET_NUMBER function from Snowflake to DuckDB equivalent. 2357 2358 Snowflake's BITMAP_BUCKET_NUMBER returns a 1-based bucket identifier where: 2359 - Each bucket covers 32,768 values 2360 - Bucket numbering starts at 1 2361 - Formula: ((value - 1) // 32768) + 1 for positive values 2362 2363 For non-positive values (0 and negative), we use value // 32768 to avoid 2364 producing bucket 0 or positive bucket IDs for negative inputs. 2365 """ 2366 value = expression.this 2367 2368 positive_formula = ((value - 1) // 32768) + 1 2369 non_positive_formula = value // 32768 2370 2371 # CASE WHEN value > 0 THEN ((value - 1) // 32768) + 1 ELSE value // 32768 END 2372 case_expr = ( 2373 exp.case() 2374 .when(exp.GT(this=value, expression=exp.Literal.number(0)), positive_formula) 2375 .else_(non_positive_formula) 2376 ) 2377 return self.sql(case_expr) 2378 2379 def bitmapbitposition_sql(self, expression: exp.BitmapBitPosition) -> str: 2380 """ 2381 Transpile Snowflake's BITMAP_BIT_POSITION to DuckDB CASE expression. 2382 2383 Snowflake's BITMAP_BIT_POSITION behavior: 2384 - For n <= 0: returns ABS(n) % 32768 2385 - For n > 0: returns (n - 1) % 32768 (maximum return value is 32767) 2386 """ 2387 this = expression.this 2388 2389 return self.sql( 2390 exp.Mod( 2391 this=exp.Paren( 2392 this=exp.If( 2393 this=exp.GT(this=this, expression=exp.Literal.number(0)), 2394 true=this - exp.Literal.number(1), 2395 false=exp.Abs(this=this), 2396 ) 2397 ), 2398 expression=MAX_BIT_POSITION, 2399 ) 2400 ) 2401 2402 def bitmapconstructagg_sql(self, expression: exp.BitmapConstructAgg) -> str: 2403 """ 2404 Transpile Snowflake's BITMAP_CONSTRUCT_AGG to DuckDB equivalent. 2405 Uses a pre-parsed template with placeholders replaced by expression nodes. 2406 2407 Snowflake bitmap format: 2408 - Small (< 5 unique values): 2-byte count (big-endian) + values (little-endian) + padding to 10 bytes 2409 - Large (>= 5 unique values): 10-byte header (0x08 + 9 zeros) + values (little-endian) 2410 """ 2411 arg = expression.this 2412 return ( 2413 f"({self.sql(exp.replace_placeholders(self.BITMAP_CONSTRUCT_AGG_TEMPLATE, arg=arg))})" 2414 ) 2415 2416 def getignorecase_sql(self, expression: exp.GetIgnoreCase) -> str: 2417 self.unsupported("DuckDB does not support the GET_IGNORE_CASE() function") 2418 return self.function_fallback_sql(expression) 2419 2420 def compress_sql(self, expression: exp.Compress) -> str: 2421 self.unsupported("DuckDB does not support the COMPRESS() function") 2422 return self.function_fallback_sql(expression) 2423 2424 def encrypt_sql(self, expression: exp.Encrypt) -> str: 2425 self.unsupported("ENCRYPT is not supported in DuckDB") 2426 return self.function_fallback_sql(expression) 2427 2428 def decrypt_sql(self, expression: exp.Decrypt) -> str: 2429 func_name = "TRY_DECRYPT" if expression.args.get("safe") else "DECRYPT" 2430 self.unsupported(f"{func_name} is not supported in DuckDB") 2431 return self.function_fallback_sql(expression) 2432 2433 def decryptraw_sql(self, expression: exp.DecryptRaw) -> str: 2434 func_name = "TRY_DECRYPT_RAW" if expression.args.get("safe") else "DECRYPT_RAW" 2435 self.unsupported(f"{func_name} is not supported in DuckDB") 2436 return self.function_fallback_sql(expression) 2437 2438 def encryptraw_sql(self, expression: exp.EncryptRaw) -> str: 2439 self.unsupported("ENCRYPT_RAW is not supported in DuckDB") 2440 return self.function_fallback_sql(expression) 2441 2442 def parseurl_sql(self, expression: exp.ParseUrl) -> str: 2443 self.unsupported("PARSE_URL is not supported in DuckDB") 2444 return self.function_fallback_sql(expression) 2445 2446 def parseip_sql(self, expression: exp.ParseIp) -> str: 2447 self.unsupported("PARSE_IP is not supported in DuckDB") 2448 return self.function_fallback_sql(expression) 2449 2450 def decompressstring_sql(self, expression: exp.DecompressString) -> str: 2451 self.unsupported("DECOMPRESS_STRING is not supported in DuckDB") 2452 return self.function_fallback_sql(expression) 2453 2454 def decompressbinary_sql(self, expression: exp.DecompressBinary) -> str: 2455 self.unsupported("DECOMPRESS_BINARY is not supported in DuckDB") 2456 return self.function_fallback_sql(expression) 2457 2458 def jarowinklersimilarity_sql(self, expression: exp.JarowinklerSimilarity) -> str: 2459 this = expression.this 2460 expr = expression.expression 2461 2462 if expression.args.get("case_insensitive"): 2463 this = exp.Upper(this=this) 2464 expr = exp.Upper(this=expr) 2465 2466 result = exp.func("JARO_WINKLER_SIMILARITY", this, expr) 2467 2468 if expression.args.get("integer_scale"): 2469 result = exp.cast(result * 100, "INTEGER") 2470 2471 return self.sql(result) 2472 2473 def randstr_sql(self, expression: exp.Randstr) -> str: 2474 """ 2475 Transpile Snowflake's RANDSTR to DuckDB equivalent using deterministic hash-based random. 2476 Uses a pre-parsed template with placeholders replaced by expression nodes. 2477 2478 RANDSTR(length, generator) generates a random string of specified length. 2479 - With numeric seed: Use HASH(i + seed) for deterministic output (same seed = same result) 2480 - With RANDOM(): Use RANDOM() in the hash for non-deterministic output 2481 - No generator: Use default seed value 2482 """ 2483 length = expression.this 2484 generator = expression.args.get("generator") 2485 2486 if generator: 2487 if isinstance(generator, exp.Rand): 2488 # If it's RANDOM(), use its seed if available, otherwise use RANDOM() itself 2489 seed_value = generator.this or generator 2490 else: 2491 # Const/int or other expression - use as seed directly 2492 seed_value = generator 2493 else: 2494 # No generator specified, use default seed (arbitrary but deterministic) 2495 seed_value = exp.Literal.number(RANDSTR_SEED) 2496 2497 replacements = {"seed": seed_value, "length": length} 2498 return f"({self.sql(exp.replace_placeholders(self.RANDSTR_TEMPLATE, **replacements))})" 2499 2500 @unsupported_args("finish") 2501 def reduce_sql(self, expression: exp.Reduce) -> str: 2502 array_arg = expression.this 2503 initial_value = expression.args.get("initial") 2504 merge_lambda = expression.args.get("merge") 2505 2506 if merge_lambda: 2507 merge_lambda.set("colon", True) 2508 2509 return self.func("list_reduce", array_arg, merge_lambda, initial_value) 2510 2511 def zipf_sql(self, expression: exp.Zipf) -> str: 2512 """ 2513 Transpile Snowflake's ZIPF to DuckDB using CDF-based inverse sampling. 2514 Uses a pre-parsed template with placeholders replaced by expression nodes. 2515 """ 2516 s = expression.this 2517 n = expression.args["elementcount"] 2518 gen = expression.args["gen"] 2519 2520 if not isinstance(gen, exp.Rand): 2521 # (ABS(HASH(seed)) % 1000000) / 1000000.0 2522 random_expr: exp.Expr = exp.Div( 2523 this=exp.Paren( 2524 this=exp.Mod( 2525 this=exp.Abs(this=exp.Anonymous(this="HASH", expressions=[gen.copy()])), 2526 expression=exp.Literal.number(1000000), 2527 ) 2528 ), 2529 expression=exp.Literal.number(1000000.0), 2530 ) 2531 else: 2532 # Use RANDOM() for non-deterministic output 2533 random_expr = exp.Rand() 2534 2535 replacements = {"s": s, "n": n, "random_expr": random_expr} 2536 return f"({self.sql(exp.replace_placeholders(self.ZIPF_TEMPLATE, **replacements))})" 2537 2538 def tobinary_sql(self, expression: exp.ToBinary) -> str: 2539 """ 2540 TO_BINARY and TRY_TO_BINARY transpilation: 2541 - 'HEX': TO_BINARY('48454C50', 'HEX') -> UNHEX('48454C50') 2542 - 'UTF-8': TO_BINARY('TEST', 'UTF-8') -> ENCODE('TEST') 2543 - 'BASE64': TO_BINARY('SEVMUA==', 'BASE64') -> FROM_BASE64('SEVMUA==') 2544 2545 For TRY_TO_BINARY (safe=True), wrap with TRY(): 2546 - 'HEX': TRY_TO_BINARY('invalid', 'HEX') -> TRY(UNHEX('invalid')) 2547 """ 2548 value = expression.this 2549 format_arg = expression.args.get("format") 2550 is_safe = expression.args.get("safe") 2551 is_binary = _is_binary(expression) 2552 2553 if not format_arg and not is_binary: 2554 func_name = "TRY_TO_BINARY" if is_safe else "TO_BINARY" 2555 return self.func(func_name, value) 2556 2557 # Snowflake defaults to HEX encoding when no format is specified 2558 fmt = format_arg.name.upper() if format_arg else "HEX" 2559 2560 if fmt in ("UTF-8", "UTF8"): 2561 # DuckDB ENCODE always uses UTF-8, no charset parameter needed 2562 result = self.func("ENCODE", value) 2563 elif fmt == "BASE64": 2564 result = self.func("FROM_BASE64", value) 2565 elif fmt == "HEX": 2566 result = self.func("UNHEX", value) 2567 else: 2568 if is_safe: 2569 return self.sql(exp.null()) 2570 else: 2571 self.unsupported(f"format {fmt} is not supported") 2572 result = self.func("TO_BINARY", value) 2573 return f"TRY({result})" if is_safe else result 2574 2575 def tonumber_sql(self, expression: exp.ToNumber) -> str: 2576 fmt = expression.args.get("format") 2577 precision = expression.args.get("precision") 2578 scale = expression.args.get("scale") 2579 2580 if not fmt and precision and scale: 2581 return self.sql( 2582 exp.cast( 2583 expression.this, f"DECIMAL({precision.name}, {scale.name})", dialect="duckdb" 2584 ) 2585 ) 2586 2587 return super().tonumber_sql(expression) 2588 2589 def _greatest_least_sql(self, expression: exp.Greatest | exp.Least) -> str: 2590 """ 2591 Handle GREATEST/LEAST functions with dialect-aware NULL behavior. 2592 2593 - If ignore_nulls=False (BigQuery-style): return NULL if any argument is NULL 2594 - If ignore_nulls=True (DuckDB/PostgreSQL-style): ignore NULLs, return greatest/least non-NULL value 2595 """ 2596 # Get all arguments 2597 all_args = [expression.this, *expression.expressions] 2598 fallback_sql = self.function_fallback_sql(expression) 2599 2600 if expression.args.get("ignore_nulls"): 2601 # DuckDB/PostgreSQL behavior: use native GREATEST/LEAST (ignores NULLs) 2602 return self.sql(fallback_sql) 2603 2604 # return NULL if any argument is NULL 2605 case_expr = exp.case().when( 2606 exp.or_(*[arg.is_(exp.null()) for arg in all_args], copy=False), 2607 exp.null(), 2608 copy=False, 2609 ) 2610 case_expr.set("default", fallback_sql) 2611 return self.sql(case_expr) 2612 2613 def generator_sql(self, expression: exp.Generator) -> str: 2614 # Transpile Snowflake GENERATOR to DuckDB range() 2615 rowcount = expression.args.get("rowcount") 2616 time_limit = expression.args.get("time_limit") 2617 2618 if time_limit: 2619 self.unsupported("GENERATOR TIMELIMIT parameter is not supported in DuckDB") 2620 2621 if not rowcount: 2622 self.unsupported("GENERATOR without ROWCOUNT is not supported in DuckDB") 2623 return self.func("range", exp.Literal.number(0)) 2624 2625 return self.func("range", rowcount) 2626 2627 def greatest_sql(self, expression: exp.Greatest) -> str: 2628 return self._greatest_least_sql(expression) 2629 2630 def least_sql(self, expression: exp.Least) -> str: 2631 return self._greatest_least_sql(expression) 2632 2633 def lambda_sql(self, expression: exp.Lambda, arrow_sep: str = "->", wrap: bool = True) -> str: 2634 if expression.args.get("colon"): 2635 prefix = "LAMBDA " 2636 arrow_sep = ":" 2637 wrap = False 2638 else: 2639 prefix = "" 2640 2641 lambda_sql = super().lambda_sql(expression, arrow_sep=arrow_sep, wrap=wrap) 2642 return f"{prefix}{lambda_sql}" 2643 2644 def show_sql(self, expression: exp.Show) -> str: 2645 from_ = self.sql(expression, "from_") 2646 from_ = f" FROM {from_}" if from_ else "" 2647 return f"SHOW {expression.name}{from_}" 2648 2649 def soundex_sql(self, expression: exp.Soundex) -> str: 2650 self.unsupported("SOUNDEX is not supported in DuckDB") 2651 return self.func("SOUNDEX", expression.this) 2652 2653 def sortarray_sql(self, expression: exp.SortArray) -> str: 2654 arr = expression.this 2655 asc = expression.args.get("asc") 2656 nulls_first = expression.args.get("nulls_first") 2657 2658 if not isinstance(asc, exp.Boolean) and not isinstance(nulls_first, exp.Boolean): 2659 return self.func("LIST_SORT", arr, asc, nulls_first) 2660 2661 nulls_are_first = nulls_first == exp.true() 2662 nulls_first_sql = exp.Literal.string("NULLS FIRST") if nulls_are_first else None 2663 2664 if not isinstance(asc, exp.Boolean): 2665 return self.func("LIST_SORT", arr, asc, nulls_first_sql) 2666 2667 descending = asc == exp.false() 2668 2669 if not descending and not nulls_are_first: 2670 return self.func("LIST_SORT", arr) 2671 if not nulls_are_first: 2672 return self.func("ARRAY_REVERSE_SORT", arr) 2673 return self.func( 2674 "LIST_SORT", 2675 arr, 2676 exp.Literal.string("DESC" if descending else "ASC"), 2677 exp.Literal.string("NULLS FIRST"), 2678 ) 2679 2680 def install_sql(self, expression: exp.Install) -> str: 2681 force = "FORCE " if expression.args.get("force") else "" 2682 this = self.sql(expression, "this") 2683 from_clause = expression.args.get("from_") 2684 from_clause = f" FROM {from_clause}" if from_clause else "" 2685 return f"{force}INSTALL {this}{from_clause}" 2686 2687 def approxtopk_sql(self, expression: exp.ApproxTopK) -> str: 2688 self.unsupported( 2689 "APPROX_TOP_K cannot be transpiled to DuckDB due to incompatible return types. " 2690 ) 2691 return self.function_fallback_sql(expression) 2692 2693 def strposition_sql(self, expression: exp.StrPosition) -> str: 2694 this = expression.this 2695 substr = expression.args.get("substr") 2696 position = expression.args.get("position") 2697 2698 # For BINARY/BLOB: DuckDB's STRPOS doesn't support BLOB types 2699 # Convert to HEX strings, use STRPOS, then convert hex position to byte position 2700 if _is_binary(this): 2701 # Build expression: STRPOS(HEX(haystack), HEX(needle)) 2702 hex_strpos = exp.StrPosition( 2703 this=exp.Hex(this=this), 2704 substr=exp.Hex(this=substr), 2705 ) 2706 2707 return self.sql(exp.cast((hex_strpos + 1) / 2, exp.DType.INT)) 2708 2709 # For VARCHAR: handle clamp_position 2710 if expression.args.get("clamp_position") and position: 2711 expression = expression.copy() 2712 expression.set( 2713 "position", 2714 exp.If( 2715 this=exp.LTE(this=position, expression=exp.Literal.number(0)), 2716 true=exp.Literal.number(1), 2717 false=position.copy(), 2718 ), 2719 ) 2720 2721 return strposition_sql(self, expression) 2722 2723 def substring_sql(self, expression: exp.Substring) -> str: 2724 if expression.args.get("zero_start"): 2725 start = expression.args.get("start") 2726 length = expression.args.get("length") 2727 2728 if start := expression.args.get("start"): 2729 start = exp.If(this=start.eq(0), true=exp.Literal.number(1), false=start) 2730 if length := expression.args.get("length"): 2731 length = exp.If(this=length < 0, true=exp.Literal.number(0), false=length) 2732 2733 return self.func("SUBSTRING", expression.this, start, length) 2734 2735 return self.function_fallback_sql(expression) 2736 2737 def strtotime_sql(self, expression: exp.StrToTime) -> str: 2738 # Check if target_type requires TIMESTAMPTZ (for LTZ/TZ variants) 2739 target_type = expression.args.get("target_type") 2740 needs_tz = target_type and target_type.this in ( 2741 exp.DType.TIMESTAMPLTZ, 2742 exp.DType.TIMESTAMPTZ, 2743 ) 2744 2745 value, formatted_time = self._strptime_default_year(expression) 2746 2747 if expression.args.get("safe"): 2748 cast_type = exp.DType.TIMESTAMPTZ if needs_tz else exp.DType.TIMESTAMP 2749 return self.sql(exp.cast(self.func("TRY_STRPTIME", value, formatted_time), cast_type)) 2750 2751 base_sql = self.func("STRPTIME", value, formatted_time) 2752 if needs_tz: 2753 return self.sql( 2754 exp.cast( 2755 base_sql, 2756 exp.DataType(this=exp.DType.TIMESTAMPTZ), 2757 ) 2758 ) 2759 return base_sql 2760 2761 def strtodate_sql(self, expression: exp.StrToDate) -> str: 2762 value, formatted_time = self._strptime_default_year(expression) 2763 function_name = "STRPTIME" if not expression.args.get("safe") else "TRY_STRPTIME" 2764 return self.sql( 2765 exp.cast( 2766 self.func(function_name, value, formatted_time), 2767 exp.DataType(this=exp.DType.DATE), 2768 ) 2769 ) 2770 2771 def _strptime_default_year( 2772 self, expression: exp.StrToTime | exp.StrToDate | exp.ParseDatetime 2773 ) -> tuple[exp.ExpOrStr, exp.ExpOrStr | None]: 2774 value: exp.ExpOrStr = expression.this 2775 formatted_time: exp.ExpOrStr | None = self.format_time(expression) 2776 2777 if default_year := expression.args.get("default_year"): 2778 value = exp.DPipe(this=exp.Literal.string(f"{default_year.name} "), expression=value) 2779 formatted_time = exp.DPipe(this=exp.Literal.string("%Y "), expression=formatted_time) 2780 2781 return value, formatted_time 2782 2783 def parsedatetime_sql(self, expression: exp.ParseDatetime) -> str: 2784 value, formatted_time = self._strptime_default_year(expression) 2785 return self.func("STRPTIME", value, formatted_time) 2786 2787 def parsetime_sql(self, expression: exp.ParseTime) -> str: 2788 formatted_time = self.format_time(expression) 2789 return self.sql( 2790 exp.cast( 2791 self.func("STRPTIME", expression.this, formatted_time), 2792 exp.DataType(this=exp.DType.TIME), 2793 ) 2794 ) 2795 2796 def tsordstotime_sql(self, expression: exp.TsOrDsToTime) -> str: 2797 this = expression.this 2798 time_format = self.format_time(expression) 2799 safe = expression.args.get("safe") 2800 time_type = exp.DataType.from_str("TIME", dialect="duckdb") 2801 cast_expr = exp.TryCast if safe else exp.Cast 2802 2803 if time_format: 2804 func_name = "TRY_STRPTIME" if safe else "STRPTIME" 2805 strptime = exp.Anonymous(this=func_name, expressions=[this, time_format]) 2806 return self.sql(cast_expr(this=strptime, to=time_type)) 2807 2808 if isinstance(this, exp.TsOrDsToTime) or this.is_type(exp.DType.TIME): 2809 return self.sql(this) 2810 2811 return self.sql(cast_expr(this=this, to=time_type)) 2812 2813 def currentdate_sql(self, expression: exp.CurrentDate) -> str: 2814 if not expression.this: 2815 return "CURRENT_DATE" 2816 2817 expr = exp.Cast( 2818 this=exp.AtTimeZone(this=exp.CurrentTimestamp(), zone=expression.this), 2819 to=exp.DataType(this=exp.DType.DATE), 2820 ) 2821 return self.sql(expr) 2822 2823 def checkjson_sql(self, expression: exp.CheckJson) -> str: 2824 arg = expression.this 2825 return self.sql( 2826 exp.case() 2827 .when( 2828 exp.or_(arg.is_(exp.Null()), arg.eq(""), exp.func("json_valid", arg)), 2829 exp.null(), 2830 ) 2831 .else_(exp.Literal.string("Invalid JSON")) 2832 ) 2833 2834 def parsejson_sql(self, expression: exp.ParseJSON) -> str: 2835 arg = expression.this 2836 if expression.args.get("safe"): 2837 return self.sql( 2838 exp.case() 2839 .when(exp.func("json_valid", arg), exp.cast(arg.copy(), "JSON")) 2840 .else_(exp.null()) 2841 ) 2842 return self.func("JSON", arg) 2843 2844 def unicode_sql(self, expression: exp.Unicode) -> str: 2845 if expression.args.get("empty_is_zero"): 2846 return self.sql( 2847 exp.case() 2848 .when(expression.this.eq(exp.Literal.string("")), exp.Literal.number(0)) 2849 .else_(exp.Anonymous(this="UNICODE", expressions=[expression.this])) 2850 ) 2851 2852 return self.func("UNICODE", expression.this) 2853 2854 def stripnullvalue_sql(self, expression: exp.StripNullValue) -> str: 2855 return self.sql( 2856 exp.case() 2857 .when(exp.func("json_type", expression.this).eq("NULL"), exp.null()) 2858 .else_(expression.this) 2859 ) 2860 2861 def trunc_sql(self, expression: exp.Trunc) -> str: 2862 decimals = expression.args.get("decimals") 2863 if ( 2864 expression.args.get("fractions_supported") 2865 and decimals 2866 and not decimals.is_type(exp.DType.INT) 2867 ): 2868 decimals = exp.cast(decimals, exp.DType.INT, dialect="duckdb") 2869 2870 return self.func("TRUNC", expression.this, decimals) 2871 2872 def normal_sql(self, expression: exp.Normal) -> str: 2873 """ 2874 Transpile Snowflake's NORMAL(mean, stddev, gen) to DuckDB. 2875 2876 Uses the Box-Muller transform via NORMAL_TEMPLATE. 2877 """ 2878 mean = expression.this 2879 stddev = expression.args["stddev"] 2880 gen: exp.Expr = expression.args["gen"] 2881 2882 # Build two uniform random values [0, 1) for Box-Muller transform 2883 if isinstance(gen, exp.Rand) and gen.this is None: 2884 u1: exp.Expr = exp.Rand() 2885 u2: exp.Expr = exp.Rand() 2886 else: 2887 # Seeded: derive two values using HASH with different inputs 2888 seed = gen.this if isinstance(gen, exp.Rand) else gen 2889 u1 = exp.replace_placeholders(self.SEEDED_RANDOM_TEMPLATE, seed=seed) 2890 u2 = exp.replace_placeholders( 2891 self.SEEDED_RANDOM_TEMPLATE, 2892 seed=exp.Add(this=seed.copy(), expression=exp.Literal.number(1)), 2893 ) 2894 2895 replacements = {"mean": mean, "stddev": stddev, "u1": u1, "u2": u2} 2896 return self.sql(exp.replace_placeholders(self.NORMAL_TEMPLATE, **replacements)) 2897 2898 def uniform_sql(self, expression: exp.Uniform) -> str: 2899 """ 2900 Transpile Snowflake's UNIFORM(min, max, gen) to DuckDB. 2901 2902 UNIFORM returns a random value in [min, max]: 2903 - Integer result if both min and max are integers 2904 - Float result if either min or max is a float 2905 """ 2906 min_val = expression.this 2907 max_val = expression.expression 2908 gen = expression.args.get("gen") 2909 2910 # Determine if result should be integer (both bounds are integers). 2911 # We do this to emulate Snowflake's behavior, INT -> INT, FLOAT -> FLOAT 2912 is_int_result = min_val.is_int and max_val.is_int 2913 2914 # Build the random value expression [0, 1) 2915 if not isinstance(gen, exp.Rand): 2916 # Seed value: (ABS(HASH(seed)) % 1000000) / 1000000.0 2917 random_expr: exp.Expr = exp.Div( 2918 this=exp.Paren( 2919 this=exp.Mod( 2920 this=exp.Abs(this=exp.Anonymous(this="HASH", expressions=[gen])), 2921 expression=exp.Literal.number(1000000), 2922 ) 2923 ), 2924 expression=exp.Literal.number(1000000.0), 2925 ) 2926 else: 2927 random_expr = exp.Rand() 2928 2929 # Build: min + random * (max - min [+ 1 for int]) 2930 range_expr: exp.Expr = exp.Sub(this=max_val, expression=min_val) 2931 if is_int_result: 2932 range_expr = exp.Add(this=range_expr, expression=exp.Literal.number(1)) 2933 2934 result: exp.Expr = exp.Add( 2935 this=min_val, 2936 expression=exp.Mul(this=random_expr, expression=exp.Paren(this=range_expr)), 2937 ) 2938 2939 if is_int_result: 2940 result = exp.Cast(this=exp.Floor(this=result), to=exp.DType.BIGINT.into_expr()) 2941 2942 return self.sql(result) 2943 2944 def timefromparts_sql(self, expression: exp.TimeFromParts) -> str: 2945 nano = expression.args.get("nano") 2946 overflow = expression.args.get("overflow") 2947 2948 # Snowflake's TIME_FROM_PARTS supports overflow 2949 if overflow: 2950 hour = expression.args["hour"] 2951 minute = expression.args["min"] 2952 sec = expression.args["sec"] 2953 2954 # Check if values are within normal ranges - use MAKE_TIME for efficiency 2955 if not nano and all(arg.is_int for arg in [hour, minute, sec]): 2956 try: 2957 h_val = hour.to_py() 2958 m_val = minute.to_py() 2959 s_val = sec.to_py() 2960 if 0 <= h_val <= 23 and 0 <= m_val <= 59 and 0 <= s_val <= 59: 2961 return rename_func("MAKE_TIME")(self, expression) 2962 except ValueError: 2963 pass 2964 2965 # Overflow or nanoseconds detected - use INTERVAL arithmetic 2966 if nano: 2967 sec = sec + nano.pop() / exp.Literal.number(1000000000.0) 2968 2969 total_seconds = hour * exp.Literal.number(3600) + minute * exp.Literal.number(60) + sec 2970 2971 return self.sql( 2972 exp.Add( 2973 this=exp.Cast( 2974 this=exp.Literal.string("00:00:00"), to=exp.DType.TIME.into_expr() 2975 ), 2976 expression=exp.Interval(this=total_seconds, unit=exp.var("SECOND")), 2977 ) 2978 ) 2979 2980 # Default: MAKE_TIME 2981 if nano: 2982 expression.set( 2983 "sec", expression.args["sec"] + nano.pop() / exp.Literal.number(1000000000.0) 2984 ) 2985 2986 return rename_func("MAKE_TIME")(self, expression) 2987 2988 def extract_sql(self, expression: exp.Extract) -> str: 2989 """ 2990 Transpile EXTRACT/DATE_PART for DuckDB, handling specifiers not natively supported. 2991 2992 DuckDB doesn't support: WEEKISO, YEAROFWEEK, YEAROFWEEKISO, NANOSECOND, 2993 EPOCH_SECOND (as integer), EPOCH_MILLISECOND, EPOCH_MICROSECOND, EPOCH_NANOSECOND 2994 """ 2995 this = expression.this 2996 datetime_expr = expression.expression 2997 2998 # TIMESTAMPTZ extractions may produce different results between Snowflake and DuckDB 2999 # because Snowflake applies server timezone while DuckDB uses local timezone 3000 if datetime_expr.is_type(exp.DType.TIMESTAMPTZ, exp.DType.TIMESTAMPLTZ): 3001 self.unsupported( 3002 "EXTRACT from TIMESTAMPTZ / TIMESTAMPLTZ may produce different results due to timezone handling differences" 3003 ) 3004 3005 part_name = this.name.upper() 3006 3007 if part_name in self.EXTRACT_STRFTIME_MAPPINGS: 3008 fmt, cast_type = self.EXTRACT_STRFTIME_MAPPINGS[part_name] 3009 3010 # Problem: strftime doesn't accept TIME and there's no NANOSECOND function 3011 # So, for NANOSECOND with TIME, fallback to MICROSECOND * 1000 3012 is_nano_time = part_name == "NANOSECOND" and datetime_expr.is_type( 3013 exp.DType.TIME, exp.DType.TIMETZ 3014 ) 3015 3016 if is_nano_time: 3017 self.unsupported("Parameter NANOSECOND is not supported with TIME type in DuckDB") 3018 return self.sql( 3019 exp.cast( 3020 exp.Mul( 3021 this=exp.Extract(this=exp.var("MICROSECOND"), expression=datetime_expr), 3022 expression=exp.Literal.number(1000), 3023 ), 3024 exp.DataType.from_str(cast_type, dialect="duckdb"), 3025 ) 3026 ) 3027 3028 # For NANOSECOND, cast to TIMESTAMP_NS to preserve nanosecond precision 3029 strftime_input = datetime_expr 3030 if part_name == "NANOSECOND": 3031 strftime_input = exp.cast(datetime_expr, exp.DType.TIMESTAMP_NS) 3032 3033 return self.sql( 3034 exp.cast( 3035 exp.Anonymous( 3036 this="STRFTIME", 3037 expressions=[strftime_input, exp.Literal.string(fmt)], 3038 ), 3039 exp.DataType.from_str(cast_type, dialect="duckdb"), 3040 ) 3041 ) 3042 3043 if part_name in self.EXTRACT_EPOCH_MAPPINGS: 3044 func_name = self.EXTRACT_EPOCH_MAPPINGS[part_name] 3045 result: exp.Expr = exp.Anonymous(this=func_name, expressions=[datetime_expr]) 3046 # EPOCH returns float, cast to BIGINT for integer result 3047 if part_name == "EPOCH_SECOND": 3048 result = exp.cast(result, exp.DataType.from_str("BIGINT", dialect="duckdb")) 3049 return self.sql(result) 3050 3051 return super().extract_sql(expression) 3052 3053 def timestampfromparts_sql(self, expression: exp.TimestampFromParts) -> str: 3054 # Check if this is the date/time expression form: TIMESTAMP_FROM_PARTS(date_expr, time_expr) 3055 date_expr = expression.this 3056 time_expr = expression.expression 3057 3058 if date_expr is not None and time_expr is not None: 3059 # In DuckDB, DATE + TIME produces TIMESTAMP 3060 return self.sql(exp.Add(this=date_expr, expression=time_expr)) 3061 3062 # Component-based form: TIMESTAMP_FROM_PARTS(year, month, day, hour, minute, second, ...) 3063 sec = expression.args.get("sec") 3064 if sec is None: 3065 # This shouldn't happen with valid input, but handle gracefully 3066 return rename_func("MAKE_TIMESTAMP")(self, expression) 3067 3068 milli = expression.args.get("milli") 3069 if milli is not None: 3070 sec += milli.pop() / exp.Literal.number(1000.0) 3071 3072 nano = expression.args.get("nano") 3073 if nano is not None: 3074 sec += nano.pop() / exp.Literal.number(1000000000.0) 3075 3076 if milli or nano: 3077 expression.set("sec", sec) 3078 3079 return rename_func("MAKE_TIMESTAMP")(self, expression) 3080 3081 @unsupported_args("nano") 3082 def timestampltzfromparts_sql(self, expression: exp.TimestampLtzFromParts) -> str: 3083 # Pop nano so rename_func only passes args that MAKE_TIMESTAMP accepts 3084 if nano := expression.args.get("nano"): 3085 nano.pop() 3086 3087 timestamp = rename_func("MAKE_TIMESTAMP")(self, expression) 3088 return f"CAST({timestamp} AS TIMESTAMPTZ)" 3089 3090 @unsupported_args("nano") 3091 def timestamptzfromparts_sql(self, expression: exp.TimestampTzFromParts) -> str: 3092 # Extract zone before popping 3093 zone = expression.args.get("zone") 3094 # Pop zone and nano so rename_func only passes args that MAKE_TIMESTAMP accepts 3095 if zone: 3096 zone = zone.pop() 3097 3098 if nano := expression.args.get("nano"): 3099 nano.pop() 3100 3101 timestamp = rename_func("MAKE_TIMESTAMP")(self, expression) 3102 3103 if zone: 3104 # Use AT TIME ZONE to apply the explicit timezone 3105 return f"{timestamp} AT TIME ZONE {self.sql(zone)}" 3106 3107 return timestamp 3108 3109 def tablesample_sql( 3110 self, 3111 expression: exp.TableSample, 3112 tablesample_keyword: str | None = None, 3113 ) -> str: 3114 if not isinstance(expression.parent, exp.Select): 3115 # This sample clause only applies to a single source, not the entire resulting relation 3116 tablesample_keyword = "TABLESAMPLE" 3117 3118 if expression.args.get("size"): 3119 method = expression.args.get("method") 3120 if method and method.name.upper() != "RESERVOIR": 3121 self.unsupported( 3122 f"Sampling method {method} is not supported with a discrete sample count, " 3123 "defaulting to reservoir sampling" 3124 ) 3125 expression.set("method", exp.var("RESERVOIR")) 3126 3127 return super().tablesample_sql(expression, tablesample_keyword=tablesample_keyword) 3128 3129 def in_sql(self, expression: exp.In) -> str: 3130 unnest = expression.args.get("unnest") 3131 if unnest: 3132 return self.sql( 3133 exp.replace_placeholders( 3134 self.IN_UNNEST_TEMPLATE, arr=unnest.expressions[0], value=expression.this 3135 ) 3136 ) 3137 return super().in_sql(expression) 3138 3139 def join_sql(self, expression: exp.Join) -> str: 3140 if ( 3141 not expression.args.get("using") 3142 and not expression.args.get("on") 3143 and not expression.method 3144 and (expression.kind in ("", "INNER", "OUTER")) 3145 ): 3146 # Some dialects support `LEFT/INNER JOIN UNNEST(...)` without an explicit ON clause 3147 # DuckDB doesn't, but we can just add a dummy ON clause that is always true 3148 if isinstance(expression.this, exp.Unnest): 3149 return super().join_sql(expression.on(exp.true())) 3150 3151 expression.set("side", None) 3152 expression.set("kind", None) 3153 3154 return super().join_sql(expression) 3155 3156 def countif_sql(self, expression: exp.CountIf) -> str: 3157 if self.dialect.version >= (1, 2): 3158 this = expression.this 3159 if expression.args.get("zero_on_all_null") and not isinstance(this, exp.Distinct): 3160 # DuckDB >= 1.2's COUNT_IF returns NULL when the condition is NULL on all rows, 3161 # so we wrap the condition in IS TRUE to preserve count-like semantics 3162 expression = exp.CountIf(this=exp.paren(this).is_(exp.true())) 3163 return self.function_fallback_sql(expression) 3164 3165 # https://github.com/tobymao/sqlglot/pull/4749 3166 return count_if_to_sum(self, expression) 3167 3168 def bracket_sql(self, expression: exp.Bracket) -> str: 3169 if self.dialect.version >= (1, 2): 3170 return super().bracket_sql(expression) 3171 3172 # https://duckdb.org/2025/02/05/announcing-duckdb-120.html#breaking-changes 3173 this = expression.this 3174 if isinstance(this, exp.Array): 3175 this.replace(exp.paren(this)) 3176 3177 bracket = super().bracket_sql(expression) 3178 3179 if not expression.args.get("returns_list_for_maps"): 3180 if not this.type: 3181 from sqlglot.optimizer.annotate_types import annotate_types 3182 3183 this = annotate_types(this, dialect=self.dialect) 3184 3185 if this.is_type(exp.DType.MAP): 3186 bracket = f"({bracket})[1]" 3187 3188 return bracket 3189 3190 def withingroup_sql(self, expression: exp.WithinGroup) -> str: 3191 func = expression.this 3192 3193 # For ARRAY_AGG, DuckDB requires ORDER BY inside the function, not in WITHIN GROUP 3194 # Transform: ARRAY_AGG(x) WITHIN GROUP (ORDER BY y) -> ARRAY_AGG(x ORDER BY y) 3195 if isinstance(func, exp.ArrayAgg): 3196 if not isinstance(order := expression.expression, exp.Order): 3197 return self.sql(func) 3198 3199 # Save the original column for FILTER clause (before wrapping with Order) 3200 original_this = func.this 3201 3202 # Move ORDER BY inside ARRAY_AGG by wrapping its argument with Order 3203 # ArrayAgg.this should become Order(this=ArrayAgg.this, expressions=order.expressions) 3204 func.set( 3205 "this", 3206 exp.Order( 3207 this=func.this.copy(), 3208 expressions=order.expressions, 3209 ), 3210 ) 3211 3212 # Generate the ARRAY_AGG function with ORDER BY and add FILTER clause if needed 3213 # Use original_this (not the Order-wrapped version) for the FILTER condition 3214 array_agg_sql = self.function_fallback_sql(func) 3215 return self._add_arrayagg_null_filter(array_agg_sql, func, original_this) 3216 3217 # For other functions (like PERCENTILES), use existing logic 3218 expression_sql = self.sql(expression, "expression") 3219 3220 if isinstance(func, exp.PERCENTILES): 3221 # Make the order key the first arg and slide the fraction to the right 3222 # https://duckdb.org/docs/sql/aggregates#ordered-set-aggregate-functions 3223 order_col = expression.find(exp.Ordered) 3224 if order_col: 3225 func.set("expression", func.this) 3226 func.set("this", order_col.this) 3227 3228 this = self.sql(expression, "this").rstrip(")") 3229 3230 return f"{this}{expression_sql})" 3231 3232 def length_sql(self, expression: exp.Length) -> str: 3233 arg = expression.this 3234 3235 # Dialects like BQ and Snowflake also accept binary values as args, so 3236 # DDB will attempt to infer the type or resort to case/when resolution 3237 if not expression.args.get("binary") or arg.is_string: 3238 return self.func("LENGTH", arg) 3239 3240 if not arg.type: 3241 from sqlglot.optimizer.annotate_types import annotate_types 3242 3243 arg = annotate_types(arg, dialect=self.dialect) 3244 3245 if arg.is_type(*exp.DataType.TEXT_TYPES): 3246 return self.func("LENGTH", arg) 3247 3248 # We need these casts to make duckdb's static type checker happy 3249 blob = exp.cast(arg, exp.DType.VARBINARY) 3250 varchar = exp.cast(arg, exp.DType.VARCHAR) 3251 3252 case = ( 3253 exp.case(exp.Anonymous(this="TYPEOF", expressions=[arg])) 3254 .when(exp.Literal.string("BLOB"), exp.ByteLength(this=blob)) 3255 .else_(exp.Anonymous(this="LENGTH", expressions=[varchar])) 3256 ) 3257 return self.sql(case) 3258 3259 def bitlength_sql(self, expression: exp.BitLength) -> str: 3260 if not _is_binary(arg := expression.this): 3261 return self.func("BIT_LENGTH", arg) 3262 3263 blob = exp.cast(arg, exp.DataType.Type.VARBINARY) 3264 return self.sql(exp.ByteLength(this=blob) * exp.Literal.number(8)) 3265 3266 def chr_sql(self, expression: exp.Chr, name: str = "CHR") -> str: 3267 arg = expression.expressions[0] 3268 if arg.is_type(*exp.DataType.REAL_TYPES): 3269 arg = exp.cast(arg, exp.DType.INT) 3270 return self.func("CHR", arg) 3271 3272 def collation_sql(self, expression: exp.Collation) -> str: 3273 self.unsupported("COLLATION function is not supported by DuckDB") 3274 return self.function_fallback_sql(expression) 3275 3276 def collate_sql(self, expression: exp.Collate) -> str: 3277 if not expression.expression.is_string: 3278 return super().collate_sql(expression) 3279 3280 raw = expression.expression.name 3281 if not raw: 3282 return self.sql(expression.this) 3283 3284 parts = [] 3285 for part in raw.split("-"): 3286 lower = part.lower() 3287 if lower not in _SNOWFLAKE_COLLATION_DEFAULTS: 3288 if lower in _SNOWFLAKE_COLLATION_UNSUPPORTED: 3289 self.unsupported( 3290 f"Snowflake collation specifier '{part}' has no DuckDB equivalent" 3291 ) 3292 parts.append(lower) 3293 3294 if not parts: 3295 return self.sql(expression.this) 3296 return super().collate_sql( 3297 exp.Collate(this=expression.this, expression=exp.var(".".join(parts))) 3298 ) 3299 3300 def _validate_regexp_flags(self, flags: exp.Expr | None, supported_flags: str) -> str | None: 3301 """ 3302 Validate and filter regexp flags for DuckDB compatibility. 3303 3304 Args: 3305 flags: The flags expression to validate 3306 supported_flags: String of supported flags (e.g., "ims", "cims"). 3307 Only these flags will be returned. 3308 3309 Returns: 3310 Validated/filtered flag string, or None if no valid flags remain 3311 """ 3312 if not isinstance(flags, exp.Expr): 3313 return None 3314 3315 if not flags.is_string: 3316 self.unsupported("Non-literal regexp flags are not fully supported in DuckDB") 3317 return None 3318 3319 flag_str = flags.this 3320 unsupported = set(flag_str) - set(supported_flags) 3321 3322 if unsupported: 3323 self.unsupported( 3324 f"Regexp flags {sorted(unsupported)} are not supported in this context" 3325 ) 3326 3327 flag_str = "".join(f for f in flag_str if f in supported_flags) 3328 return flag_str if flag_str else None 3329 3330 def regexpcount_sql(self, expression: exp.RegexpCount) -> str: 3331 this = expression.this 3332 pattern = expression.expression 3333 position = expression.args.get("position") 3334 parameters = expression.args.get("parameters") 3335 3336 # Validate flags - only "ims" flags are supported for embedded patterns 3337 validated_flags = self._validate_regexp_flags(parameters, supported_flags="ims") 3338 3339 if position: 3340 this = exp.Substring(this=this, start=position) 3341 3342 # Embed flags in pattern (REGEXP_EXTRACT_ALL doesn't support flags argument) 3343 if validated_flags: 3344 pattern = exp.Concat(expressions=[exp.Literal.string(f"(?{validated_flags})"), pattern]) 3345 3346 # Handle empty pattern: Snowflake returns 0, DuckDB would match between every character 3347 result = ( 3348 exp.case() 3349 .when( 3350 exp.EQ(this=pattern, expression=exp.Literal.string("")), 3351 exp.Literal.number(0), 3352 ) 3353 .else_( 3354 exp.Length( 3355 this=exp.Anonymous(this="REGEXP_EXTRACT_ALL", expressions=[this, pattern]) 3356 ) 3357 ) 3358 ) 3359 3360 return self.sql(result) 3361 3362 def regexpreplace_sql(self, expression: exp.RegexpReplace) -> str: 3363 subject = expression.this 3364 pattern = expression.expression 3365 replacement = expression.args.get("replacement") or exp.Literal.string("") 3366 position = expression.args.get("position") 3367 occurrence = expression.args.get("occurrence") 3368 modifiers = expression.args.get("modifiers") 3369 3370 validated_flags = self._validate_regexp_flags(modifiers, supported_flags="cimsg") or "" 3371 3372 # Handle occurrence (only literals supported) 3373 if occurrence and not occurrence.is_int: 3374 self.unsupported("REGEXP_REPLACE with non-literal occurrence") 3375 else: 3376 occurrence = occurrence.to_py() if occurrence and occurrence.is_int else 0 3377 if occurrence > 1: 3378 self.unsupported(f"REGEXP_REPLACE occurrence={occurrence} not supported") 3379 # flag duckdb to do either all or none, single_replace check is for duckdb round trip 3380 elif ( 3381 occurrence == 0 3382 and "g" not in validated_flags 3383 and not expression.args.get("single_replace") 3384 ): 3385 validated_flags += "g" 3386 3387 # Handle position (only literals supported) 3388 prefix = None 3389 if position and not position.is_int: 3390 self.unsupported("REGEXP_REPLACE with non-literal position") 3391 elif position and position.is_int and position.to_py() > 1: 3392 pos = position.to_py() 3393 prefix = exp.Substring( 3394 this=subject, start=exp.Literal.number(1), length=exp.Literal.number(pos - 1) 3395 ) 3396 subject = exp.Substring(this=subject, start=exp.Literal.number(pos)) 3397 3398 result: exp.Expr = exp.Anonymous( 3399 this="REGEXP_REPLACE", 3400 expressions=[ 3401 subject, 3402 pattern, 3403 replacement, 3404 exp.Literal.string(validated_flags) if validated_flags else None, 3405 ], 3406 ) 3407 3408 if prefix: 3409 result = exp.Concat(expressions=[prefix, result]) 3410 3411 return self.sql(result) 3412 3413 def regexplike_sql(self, expression: exp.RegexpLike) -> str: 3414 this = expression.this 3415 pattern = expression.expression 3416 flag = expression.args.get("flag") 3417 3418 if expression.args.get("full_match"): 3419 validated_flags = self._validate_regexp_flags(flag, supported_flags="cims") 3420 flag = exp.Literal.string(validated_flags) if validated_flags else None 3421 return self.func("REGEXP_FULL_MATCH", this, pattern, flag) 3422 3423 return self.func("REGEXP_MATCHES", this, pattern, flag) 3424 3425 @unsupported_args("ins_cost", "del_cost", "sub_cost") 3426 def levenshtein_sql(self, expression: exp.Levenshtein) -> str: 3427 this = expression.this 3428 expr = expression.expression 3429 max_dist = expression.args.get("max_dist") 3430 3431 if max_dist is None: 3432 return self.func("LEVENSHTEIN", this, expr) 3433 3434 # Emulate Snowflake semantics: if distance > max_dist, return max_dist 3435 levenshtein = exp.Levenshtein(this=this, expression=expr) 3436 return self.sql(exp.Least(this=levenshtein, expressions=[max_dist])) 3437 3438 def pad_sql(self, expression: exp.Pad) -> str: 3439 """ 3440 Handle RPAD/LPAD for VARCHAR and BINARY types. 3441 3442 For VARCHAR: Delegate to parent class 3443 For BINARY: Lower to: input || REPEAT(pad, GREATEST(0, target_len - OCTET_LENGTH(input))) 3444 """ 3445 string_arg = expression.this 3446 fill_arg = expression.args.get("fill_pattern") or exp.Literal.string(" ") 3447 3448 if _is_binary(string_arg) or _is_binary(fill_arg): 3449 length_arg = expression.expression 3450 is_left = expression.args.get("is_left") 3451 3452 input_len = exp.ByteLength(this=string_arg) 3453 chars_needed = length_arg - input_len 3454 pad_count = exp.Greatest( 3455 this=exp.Literal.number(0), expressions=[chars_needed], ignore_nulls=True 3456 ) 3457 repeat_expr = exp.Repeat(this=fill_arg, times=pad_count) 3458 3459 left, right = string_arg, repeat_expr 3460 if is_left: 3461 left, right = right, left 3462 3463 result = exp.DPipe(this=left, expression=right) 3464 return self.sql(result) 3465 3466 # For VARCHAR: Delegate to parent class (handles PAD_FILL_PATTERN_IS_REQUIRED) 3467 return super().pad_sql(expression) 3468 3469 def minhash_sql(self, expression: exp.Minhash) -> str: 3470 k = expression.this 3471 exprs = expression.expressions 3472 3473 if len(exprs) != 1 or isinstance(exprs[0], exp.Star): 3474 self.unsupported( 3475 "MINHASH with multiple expressions or * requires manual query restructuring" 3476 ) 3477 return self.func("MINHASH", k, *exprs) 3478 3479 expr = exprs[0] 3480 result = exp.replace_placeholders(self.MINHASH_TEMPLATE.copy(), expr=expr, k=k) 3481 return f"({self.sql(result)})" 3482 3483 def minhashcombine_sql(self, expression: exp.MinhashCombine) -> str: 3484 expr = expression.this 3485 result = exp.replace_placeholders(self.MINHASH_COMBINE_TEMPLATE.copy(), expr=expr) 3486 return f"({self.sql(result)})" 3487 3488 def approximatesimilarity_sql(self, expression: exp.ApproximateSimilarity) -> str: 3489 expr = expression.this 3490 result = exp.replace_placeholders(self.APPROXIMATE_SIMILARITY_TEMPLATE.copy(), expr=expr) 3491 return f"({self.sql(result)})" 3492 3493 def arrayuniqueagg_sql(self, expression: exp.ArrayUniqueAgg) -> str: 3494 return self.sql( 3495 exp.Filter( 3496 this=exp.func("LIST", exp.Distinct(expressions=[expression.this])), 3497 expression=exp.Where(this=expression.this.copy().is_(exp.null()).not_()), 3498 ) 3499 ) 3500 3501 def arrayconcatagg_sql(self, expression: exp.ArrayConcatAgg) -> str: 3502 this = expression.this 3503 3504 if isinstance(this, exp.Limit): 3505 self.unsupported("LIMIT in ARRAY_CONCAT_AGG cannot be transpiled to DuckDB") 3506 this = this.this 3507 3508 inner = this.this if isinstance(this, exp.Order) else this 3509 3510 return self.func( 3511 "FLATTEN", 3512 exp.Filter( 3513 this=exp.ArrayAgg(this=this), 3514 expression=exp.Where(this=inner.copy().is_(exp.null()).not_()), 3515 ), 3516 ) 3517 3518 def arrayunionagg_sql(self, expression: exp.ArrayUnionAgg) -> str: 3519 self.unsupported("ARRAY_UNION_AGG is not supported in DuckDB") 3520 return self.function_fallback_sql(expression) 3521 3522 def arraydistinct_sql(self, expression: exp.ArrayDistinct) -> str: 3523 arr = expression.this 3524 func = self.func("LIST_DISTINCT", arr) 3525 3526 if expression.args.get("check_null"): 3527 add_null_to_array = exp.func( 3528 "LIST_APPEND", exp.func("LIST_DISTINCT", exp.ArrayCompact(this=arr)), exp.Null() 3529 ) 3530 return self.sql( 3531 exp.If( 3532 this=exp.NEQ( 3533 this=exp.ArraySize(this=arr), expression=exp.func("LIST_COUNT", arr) 3534 ), 3535 true=add_null_to_array, 3536 false=func, 3537 ) 3538 ) 3539 3540 return func 3541 3542 def arrayintersect_sql(self, expression: exp.ArrayIntersect) -> str: 3543 if expression.args.get("is_multiset") and len(expression.expressions) == 2: 3544 return self._array_bag_sql( 3545 self.ARRAY_INTERSECTION_CONDITION, 3546 expression.expressions[0], 3547 expression.expressions[1], 3548 ) 3549 return self.function_fallback_sql(expression) 3550 3551 def arrayexcept_sql(self, expression: exp.ArrayExcept) -> str: 3552 arr1, arr2 = expression.this, expression.expression 3553 if expression.args.get("is_multiset"): 3554 return self._array_bag_sql(self.ARRAY_EXCEPT_CONDITION, arr1, arr2) 3555 return self.sql( 3556 exp.replace_placeholders(self.ARRAY_EXCEPT_SET_TEMPLATE, arr1=arr1, arr2=arr2) 3557 ) 3558 3559 def arrayslice_sql(self, expression: exp.ArraySlice) -> str: 3560 """ 3561 Transpiles Snowflake's ARRAY_SLICE (0-indexed, exclusive end) to DuckDB's 3562 ARRAY_SLICE (1-indexed, inclusive end) by wrapping start and end in CASE 3563 expressions that adjust the index at query time: 3564 - start: CASE WHEN start >= 0 THEN start + 1 ELSE start END 3565 - end: CASE WHEN end < 0 THEN end - 1 ELSE end END 3566 """ 3567 start, end = expression.args.get("start"), expression.args.get("end") 3568 3569 if expression.args.get("zero_based"): 3570 if start is not None: 3571 start = ( 3572 exp.case() 3573 .when( 3574 exp.GTE(this=start.copy(), expression=exp.Literal.number(0)), 3575 exp.Add(this=start.copy(), expression=exp.Literal.number(1)), 3576 ) 3577 .else_(start) 3578 ) 3579 if end is not None: 3580 end = ( 3581 exp.case() 3582 .when( 3583 exp.LT(this=end.copy(), expression=exp.Literal.number(0)), 3584 exp.Sub(this=end.copy(), expression=exp.Literal.number(1)), 3585 ) 3586 .else_(end) 3587 ) 3588 3589 return self.func("ARRAY_SLICE", expression.this, start, end, expression.args.get("step")) 3590 3591 def arrayszip_sql(self, expression: exp.ArraysZip) -> str: 3592 args = expression.expressions 3593 3594 if not args: 3595 # Return [{}] - using MAP([], []) since DuckDB can't represent empty structs 3596 return self.sql(exp.array(exp.Map(keys=exp.array(), values=exp.array()))) 3597 3598 # Build placeholder values for template 3599 lengths = [exp.Length(this=arg) for arg in args] 3600 max_len = ( 3601 lengths[0] 3602 if len(lengths) == 1 3603 else exp.Greatest(this=lengths[0], expressions=lengths[1:]) 3604 ) 3605 3606 # Empty struct with same schema: {'$1': NULL, '$2': NULL, ...} 3607 empty_struct = exp.func( 3608 "STRUCT", 3609 *[ 3610 exp.PropertyEQ(this=exp.Literal.string(f"${i + 1}"), expression=exp.Null()) 3611 for i in range(len(args)) 3612 ], 3613 ) 3614 3615 # Struct for transform: {'$1': COALESCE(arr1, [])[__i + 1], ...} 3616 # COALESCE wrapping handles NULL arrays - prevents invalid NULL[i] syntax 3617 index = exp.column("__i") + 1 3618 transform_struct = exp.func( 3619 "STRUCT", 3620 *[ 3621 exp.PropertyEQ( 3622 this=exp.Literal.string(f"${i + 1}"), 3623 expression=exp.func("COALESCE", arg, exp.array())[index], 3624 ) 3625 for i, arg in enumerate(args) 3626 ], 3627 ) 3628 3629 result = exp.replace_placeholders( 3630 self.ARRAYS_ZIP_TEMPLATE.copy(), 3631 null_check=exp.or_(*[arg.is_(exp.Null()) for arg in args]), 3632 all_empty_check=exp.and_( 3633 *[ 3634 exp.EQ(this=exp.Length(this=arg), expression=exp.Literal.number(0)) 3635 for arg in args 3636 ] 3637 ), 3638 empty_struct=empty_struct, 3639 max_len=max_len, 3640 transform_struct=transform_struct, 3641 ) 3642 return self.sql(result) 3643 3644 def lower_sql(self, expression: exp.Lower) -> str: 3645 result_sql = self.func("LOWER", _cast_to_varchar(expression.this)) 3646 return _gen_with_cast_to_blob(self, expression, result_sql) 3647 3648 def upper_sql(self, expression: exp.Upper) -> str: 3649 result_sql = self.func("UPPER", _cast_to_varchar(expression.this)) 3650 return _gen_with_cast_to_blob(self, expression, result_sql) 3651 3652 def reverse_sql(self, expression: exp.Reverse) -> str: 3653 result_sql = self.func("REVERSE", _cast_to_varchar(expression.this)) 3654 return _gen_with_cast_to_blob(self, expression, result_sql) 3655 3656 def _left_right_sql(self, expression: exp.Left | exp.Right, func_name: str) -> str: 3657 arg = expression.this 3658 length = expression.expression 3659 is_binary = _is_binary(arg) 3660 3661 if is_binary: 3662 # LEFT/RIGHT(blob, n) becomes UNHEX(LEFT/RIGHT(HEX(blob), n * 2)) 3663 # Each byte becomes 2 hex chars, so multiply length by 2 3664 hex_arg = exp.Hex(this=arg) 3665 hex_length = exp.Mul(this=length, expression=exp.Literal.number(2)) 3666 result: exp.Expression = exp.Unhex( 3667 this=exp.Anonymous(this=func_name, expressions=[hex_arg, hex_length]) 3668 ) 3669 else: 3670 result = exp.Anonymous(this=func_name, expressions=[arg, length]) 3671 3672 if expression.args.get("negative_length_returns_empty"): 3673 empty: exp.Expression = exp.Literal.string("") 3674 if is_binary: 3675 empty = exp.Unhex(this=empty) 3676 result = exp.case().when(length < exp.Literal.number(0), empty).else_(result) 3677 3678 return self.sql(result) 3679 3680 def left_sql(self, expression: exp.Left) -> str: 3681 return self._left_right_sql(expression, "LEFT") 3682 3683 def right_sql(self, expression: exp.Right) -> str: 3684 return self._left_right_sql(expression, "RIGHT") 3685 3686 def rtrimmedlength_sql(self, expression: exp.RtrimmedLength) -> str: 3687 return self.func("LENGTH", exp.Trim(this=expression.this, position="TRAILING")) 3688 3689 def stuff_sql(self, expression: exp.Stuff) -> str: 3690 base = expression.this 3691 start = expression.args["start"] 3692 length = expression.args["length"] 3693 insertion = expression.expression 3694 is_binary = _is_binary(base) 3695 3696 if is_binary: 3697 # DuckDB's SUBSTRING doesn't accept BLOB; operate on the HEX string instead 3698 # (each byte = 2 hex chars), then UNHEX back to BLOB 3699 base = exp.Hex(this=base) 3700 insertion = exp.Hex(this=insertion) 3701 left = exp.Substring( 3702 this=base.copy(), 3703 start=exp.Literal.number(1), 3704 length=(start.copy() - exp.Literal.number(1)) * exp.Literal.number(2), 3705 ) 3706 right = exp.Substring( 3707 this=base.copy(), 3708 start=((start + length) - exp.Literal.number(1)) * exp.Literal.number(2) 3709 + exp.Literal.number(1), 3710 ) 3711 else: 3712 left = exp.Substring( 3713 this=base.copy(), 3714 start=exp.Literal.number(1), 3715 length=start.copy() - exp.Literal.number(1), 3716 ) 3717 right = exp.Substring(this=base.copy(), start=start + length) 3718 result: exp.Expr = exp.DPipe( 3719 this=exp.DPipe(this=left, expression=insertion), expression=right 3720 ) 3721 3722 if is_binary: 3723 result = exp.Unhex(this=result) 3724 3725 return self.sql(result) 3726 3727 def rand_sql(self, expression: exp.Rand) -> str: 3728 seed = expression.this 3729 if seed is not None: 3730 self.unsupported("RANDOM with seed is not supported in DuckDB") 3731 3732 lower = expression.args.get("lower") 3733 upper = expression.args.get("upper") 3734 3735 if lower and upper: 3736 # scale DuckDB's [0,1) to the specified range 3737 range_size = exp.paren(upper - lower) 3738 scaled = exp.Add(this=lower, expression=exp.func("random") * range_size) 3739 3740 # For now we assume that if bounds are set, return type is BIGINT. Snowflake/Teradata 3741 result = exp.cast(scaled, exp.DType.BIGINT) 3742 return self.sql(result) 3743 3744 # Default DuckDB behavior - just return RANDOM() as float 3745 return "RANDOM()" 3746 3747 def bytelength_sql(self, expression: exp.ByteLength) -> str: 3748 arg = expression.this 3749 3750 # Check if it's a text type (handles both literals and annotated expressions) 3751 if arg.is_type(*exp.DataType.TEXT_TYPES): 3752 return self.func("OCTET_LENGTH", exp.Encode(this=arg)) 3753 3754 # Default: pass through as-is (conservative for DuckDB, handles binary and unannotated) 3755 return self.func("OCTET_LENGTH", arg) 3756 3757 def base64encode_sql(self, expression: exp.Base64Encode) -> str: 3758 # DuckDB TO_BASE64 requires BLOB input 3759 # Snowflake BASE64_ENCODE accepts both VARCHAR and BINARY - for VARCHAR it implicitly 3760 # encodes UTF-8 bytes. We add ENCODE unless the input is a binary type. 3761 result = expression.this 3762 3763 # Check if input is a string type - ENCODE only accepts VARCHAR 3764 if result.is_type(*exp.DataType.TEXT_TYPES): 3765 result = exp.Encode(this=result) 3766 3767 result = exp.ToBase64(this=result) 3768 3769 max_line_length = expression.args.get("max_line_length") 3770 alphabet = expression.args.get("alphabet") 3771 3772 # Handle custom alphabet by replacing standard chars with custom ones 3773 result = _apply_base64_alphabet_replacements(result, alphabet) 3774 3775 # Handle max_line_length by inserting newlines every N characters 3776 line_length = ( 3777 t.cast(int, max_line_length.to_py()) 3778 if isinstance(max_line_length, exp.Literal) and max_line_length.is_number 3779 else 0 3780 ) 3781 if line_length > 0: 3782 newline = exp.Chr(expressions=[exp.Literal.number(10)]) 3783 result = exp.Trim( 3784 this=exp.RegexpReplace( 3785 this=result, 3786 expression=exp.Literal.string(f"(.{{{line_length}}})"), 3787 replacement=exp.Concat(expressions=[exp.Literal.string("\\1"), newline.copy()]), 3788 ), 3789 expression=newline, 3790 position="TRAILING", 3791 ) 3792 3793 return self.sql(result) 3794 3795 def hex_sql(self, expression: exp.Hex) -> str: 3796 case = expression.args.get("case") 3797 3798 if not case: 3799 return self.func("HEX", expression.this) 3800 3801 hex_expr = exp.Hex(this=expression.this) 3802 return self.sql( 3803 exp.case() 3804 .when(case.is_(exp.null()), exp.null()) 3805 .when(case.copy().eq(0), exp.Lower(this=hex_expr.copy())) 3806 .else_(hex_expr) 3807 ) 3808 3809 def replace_sql(self, expression: exp.Replace) -> str: 3810 result_sql = self.func( 3811 "REPLACE", 3812 _cast_to_varchar(expression.this), 3813 _cast_to_varchar(expression.expression), 3814 _cast_to_varchar(expression.args.get("replacement")), 3815 ) 3816 return _gen_with_cast_to_blob(self, expression, result_sql) 3817 3818 def _bitwise_op(self, expression: exp.Binary, op: str) -> str: 3819 _prepare_binary_bitwise_args(expression) 3820 result_sql = self.binary(expression, op) 3821 return _gen_with_cast_to_blob(self, expression, result_sql) 3822 3823 def bitwisexor_sql(self, expression: exp.BitwiseXor) -> str: 3824 _prepare_binary_bitwise_args(expression) 3825 result_sql = self.func("XOR", expression.this, expression.expression) 3826 return _gen_with_cast_to_blob(self, expression, result_sql) 3827 3828 def objectinsert_sql(self, expression: exp.ObjectInsert) -> str: 3829 this = expression.this 3830 key = expression.args.get("key") 3831 key_sql = key.name if isinstance(key, exp.Expr) else "" 3832 value_sql = self.sql(expression, "value") 3833 3834 kv_sql = f"{key_sql} := {value_sql}" 3835 3836 # If the input struct is empty e.g. transpiling OBJECT_INSERT(OBJECT_CONSTRUCT(), key, value) from Snowflake 3837 # then we can generate STRUCT_PACK which will build it since STRUCT_INSERT({}, key := value) is not valid DuckDB 3838 if isinstance(this, exp.Struct) and not this.expressions: 3839 return self.func("STRUCT_PACK", kv_sql) 3840 3841 return self.func("STRUCT_INSERT", this, kv_sql) 3842 3843 def mapcat_sql(self, expression: exp.MapCat) -> str: 3844 result = exp.replace_placeholders( 3845 self.MAPCAT_TEMPLATE.copy(), 3846 map1=expression.this, 3847 map2=expression.expression, 3848 ) 3849 return self.sql(result) 3850 3851 def mapcontainskey_sql(self, expression: exp.MapContainsKey) -> str: 3852 return self.func( 3853 "ARRAY_CONTAINS", exp.func("MAP_KEYS", expression.args["key"]), expression.this 3854 ) 3855 3856 def mapdelete_sql(self, expression: exp.MapDelete) -> str: 3857 map_arg = expression.this 3858 keys_to_delete = expression.expressions 3859 3860 x_dot_key = exp.Dot(this=exp.to_identifier("x"), expression=exp.to_identifier("key")) 3861 3862 lambda_expr = exp.Lambda( 3863 this=exp.In(this=x_dot_key, expressions=keys_to_delete).not_(), 3864 expressions=[exp.to_identifier("x")], 3865 ) 3866 result = exp.func( 3867 "MAP_FROM_ENTRIES", 3868 exp.ArrayFilter(this=exp.func("MAP_ENTRIES", map_arg), expression=lambda_expr), 3869 ) 3870 return self.sql(result) 3871 3872 def mappick_sql(self, expression: exp.MapPick) -> str: 3873 map_arg = expression.this 3874 keys_to_pick = expression.expressions 3875 3876 x_dot_key = exp.Dot(this=exp.to_identifier("x"), expression=exp.to_identifier("key")) 3877 3878 if len(keys_to_pick) == 1 and keys_to_pick[0].is_type(exp.DType.ARRAY): 3879 lambda_expr = exp.Lambda( 3880 this=exp.func("ARRAY_CONTAINS", keys_to_pick[0], x_dot_key), 3881 expressions=[exp.to_identifier("x")], 3882 ) 3883 else: 3884 lambda_expr = exp.Lambda( 3885 this=exp.In(this=x_dot_key, expressions=keys_to_pick), 3886 expressions=[exp.to_identifier("x")], 3887 ) 3888 3889 result = exp.func( 3890 "MAP_FROM_ENTRIES", 3891 exp.func("LIST_FILTER", exp.func("MAP_ENTRIES", map_arg), lambda_expr), 3892 ) 3893 return self.sql(result) 3894 3895 def mapsize_sql(self, expression: exp.MapSize) -> str: 3896 return self.func("CARDINALITY", expression.this) 3897 3898 @unsupported_args("update_flag") 3899 def mapinsert_sql(self, expression: exp.MapInsert) -> str: 3900 map_arg = expression.this 3901 key = expression.args.get("key") 3902 value = expression.args.get("value") 3903 3904 map_type = map_arg.type 3905 3906 if value is not None: 3907 if map_type and map_type.expressions and len(map_type.expressions) > 1: 3908 # Extract the value type from MAP(key_type, value_type) 3909 value_type = map_type.expressions[1] 3910 # Cast value to match the map's value type to avoid type conflicts 3911 value = exp.cast(value, value_type) 3912 # else: polymorphic MAP case - no type parameters available, use value as-is 3913 3914 # Create a single-entry map for the new key-value pair 3915 new_entry_struct = exp.Struct(expressions=[exp.PropertyEQ(this=key, expression=value)]) 3916 new_entry: exp.Expression = exp.ToMap(this=new_entry_struct) 3917 3918 # Use MAP_CONCAT to merge the original map with the new entry 3919 # This automatically handles both insert and update cases 3920 result = exp.func("MAP_CONCAT", map_arg, new_entry) 3921 3922 return self.sql(result) 3923 3924 def startswith_sql(self, expression: exp.StartsWith) -> str: 3925 return self.func( 3926 "STARTS_WITH", 3927 _cast_to_varchar(expression.this), 3928 _cast_to_varchar(expression.expression), 3929 ) 3930 3931 def space_sql(self, expression: exp.Space) -> str: 3932 # DuckDB's REPEAT requires BIGINT for the count parameter 3933 return self.sql( 3934 exp.Repeat( 3935 this=exp.Literal.string(" "), 3936 times=exp.cast(expression.this, exp.DType.BIGINT), 3937 ) 3938 ) 3939 3940 def tablefromrows_sql(self, expression: exp.TableFromRows) -> str: 3941 # For GENERATOR, unwrap TABLE() - just emit the Generator (becomes RANGE) 3942 if isinstance(expression.this, exp.Generator): 3943 # Preserve alias, joins, and other table-level args 3944 table = exp.Table( 3945 this=expression.this, 3946 alias=expression.args.get("alias"), 3947 joins=expression.args.get("joins"), 3948 ) 3949 return self.sql(table) 3950 3951 return super().tablefromrows_sql(expression) 3952 3953 def unnest_sql(self, expression: exp.Unnest) -> str: 3954 explode_array = expression.args.get("explode_array") 3955 if explode_array: 3956 # In BigQuery, UNNESTing a nested array leads to explosion of the top-level array & struct 3957 # This is transpiled to DDB by transforming "FROM UNNEST(...)" to "FROM (SELECT UNNEST(..., max_depth => 2))" 3958 expression.expressions.append( 3959 exp.Kwarg(this=exp.var("max_depth"), expression=exp.Literal.number(2)) 3960 ) 3961 3962 # If BQ's UNNEST is aliased, we transform it from a column alias to a table alias in DDB 3963 alias = expression.args.get("alias") 3964 if isinstance(alias, exp.TableAlias): 3965 expression.set("alias", None) 3966 if alias.columns: 3967 alias = exp.TableAlias(this=seq_get(alias.columns, 0)) 3968 3969 unnest_sql = super().unnest_sql(expression) 3970 select = exp.Select(expressions=[unnest_sql]).subquery(alias) 3971 return self.sql(select) 3972 3973 return super().unnest_sql(expression) 3974 3975 def arrayagg_sql(self, expression: exp.ArrayAgg) -> str: 3976 if isinstance(expression.this, exp.Limit): 3977 self.unsupported("LIMIT inside ARRAY_AGG is not supported in DuckDB") 3978 3979 return super().arrayagg_sql(expression) 3980 3981 def ignorenulls_sql(self, expression: exp.IgnoreNulls) -> str: 3982 this = expression.this 3983 3984 if isinstance(this, self.IGNORE_RESPECT_NULLS_WINDOW_FUNCTIONS): 3985 # DuckDB should render IGNORE NULLS only for the general-purpose 3986 # window functions that accept it e.g. FIRST_VALUE(... IGNORE NULLS) OVER (...) 3987 return super().ignorenulls_sql(expression) 3988 3989 # For ARRAY_AGG(expr IGNORE NULLS ...), convert IGNORE NULLS to a 3990 # FILTER(WHERE expr IS NOT NULL) clause by setting nulls_excluded on 3991 # the ArrayAgg. The existing _add_arrayagg_null_filter method will 3992 # emit the FILTER clause during arrayagg_sql / withingroup_sql. 3993 if isinstance(this, exp.ArrayAgg): 3994 this.set("nulls_excluded", True) 3995 return self.sql(this) 3996 3997 if isinstance(this, exp.First): 3998 this = exp.AnyValue(this=this.this) 3999 4000 if not isinstance(this, (exp.AnyValue, exp.ApproxQuantiles)): 4001 self.unsupported("IGNORE NULLS is not supported for non-window functions.") 4002 4003 return self.sql(this) 4004 4005 def split_sql(self, expression: exp.Split) -> str: 4006 base_func = exp.func("STR_SPLIT", expression.this, expression.expression) 4007 4008 case_expr = exp.case().else_(base_func) 4009 needs_case = False 4010 4011 if expression.args.get("null_returns_null"): 4012 case_expr = case_expr.when(expression.expression.is_(exp.null()), exp.null()) 4013 needs_case = True 4014 4015 if expression.args.get("empty_delimiter_returns_whole"): 4016 # When delimiter is empty string, return input string as single array element 4017 array_with_input = exp.array(expression.this) 4018 case_expr = case_expr.when( 4019 expression.expression.eq(exp.Literal.string("")), array_with_input 4020 ) 4021 needs_case = True 4022 4023 return self.sql(case_expr if needs_case else base_func) 4024 4025 def splitpart_sql(self, expression: exp.SplitPart) -> str: 4026 string_arg = expression.this 4027 delimiter_arg = expression.args.get("delimiter") 4028 part_index_arg = expression.args.get("part_index") 4029 4030 if delimiter_arg and part_index_arg: 4031 # Handle Snowflake's "index 0 and 1 both return first element" behavior 4032 if expression.args.get("part_index_zero_as_one"): 4033 # Convert 0 to 1 for compatibility 4034 4035 part_index_arg = exp.Paren( 4036 this=exp.case() 4037 .when(part_index_arg.eq(exp.Literal.number("0")), exp.Literal.number("1")) 4038 .else_(part_index_arg) 4039 ) 4040 4041 # Use Anonymous to avoid recursion 4042 base_func_expr: exp.Expr = exp.Anonymous( 4043 this="SPLIT_PART", expressions=[string_arg, delimiter_arg, part_index_arg] 4044 ) 4045 needs_case_transform = False 4046 case_expr = exp.case().else_(base_func_expr) 4047 4048 if expression.args.get("empty_delimiter_returns_whole"): 4049 # When delimiter is empty string: 4050 # - Return whole string if part_index is 1 or -1 4051 # - Return empty string otherwise 4052 empty_case = exp.Paren( 4053 this=exp.case() 4054 .when( 4055 exp.or_( 4056 part_index_arg.eq(exp.Literal.number("1")), 4057 part_index_arg.eq(exp.Literal.number("-1")), 4058 ), 4059 string_arg, 4060 ) 4061 .else_(exp.Literal.string("")) 4062 ) 4063 4064 case_expr = case_expr.when(delimiter_arg.eq(exp.Literal.string("")), empty_case) 4065 needs_case_transform = True 4066 4067 """ 4068 Output looks something like this: 4069 4070 CASE 4071 WHEN delimiter is '' THEN 4072 ( 4073 CASE 4074 WHEN adjusted_part_index = 1 OR adjusted_part_index = -1 THEN input 4075 ELSE '' END 4076 ) 4077 ELSE SPLIT_PART(input, delimiter, adjusted_part_index) 4078 END 4079 4080 """ 4081 return self.sql(case_expr if needs_case_transform else base_func_expr) 4082 4083 return self.function_fallback_sql(expression) 4084 4085 def respectnulls_sql(self, expression: exp.RespectNulls) -> str: 4086 if isinstance(expression.this, self.IGNORE_RESPECT_NULLS_WINDOW_FUNCTIONS): 4087 # DuckDB should render RESPECT NULLS only for the general-purpose 4088 # window functions that accept it e.g. FIRST_VALUE(... RESPECT NULLS) OVER (...) 4089 return super().respectnulls_sql(expression) 4090 4091 self.unsupported("RESPECT NULLS is not supported for non-window functions.") 4092 return self.sql(expression, "this") 4093 4094 def arraytostring_sql(self, expression: exp.ArrayToString) -> str: 4095 null = expression.args.get("null") 4096 4097 if expression.args.get("null_is_empty"): 4098 x = exp.to_identifier("x") 4099 list_transform = exp.Transform( 4100 this=expression.this.copy(), 4101 expression=exp.Lambda( 4102 this=exp.Coalesce( 4103 this=exp.cast(x, "TEXT"), expressions=[exp.Literal.string("")] 4104 ), 4105 expressions=[x], 4106 ), 4107 ) 4108 array_to_string = exp.ArrayToString( 4109 this=list_transform, expression=expression.expression 4110 ) 4111 if expression.args.get("null_delim_is_null"): 4112 return self.sql( 4113 exp.case() 4114 .when(expression.expression.copy().is_(exp.null()), exp.null()) 4115 .else_(array_to_string) 4116 ) 4117 return self.sql(array_to_string) 4118 4119 if null: 4120 x = exp.to_identifier("x") 4121 return self.sql( 4122 exp.ArrayToString( 4123 this=exp.Transform( 4124 this=expression.this, 4125 expression=exp.Lambda( 4126 this=exp.Coalesce(this=x, expressions=[null]), 4127 expressions=[x], 4128 ), 4129 ), 4130 expression=expression.expression, 4131 ) 4132 ) 4133 4134 return self.func("ARRAY_TO_STRING", expression.this, expression.expression) 4135 4136 def concatws_sql(self, expression: exp.ConcatWs) -> str: 4137 # DuckDB-specific: handle binary types using DPipe (||) operator 4138 separator = seq_get(expression.expressions, 0) 4139 args = expression.expressions[1:] 4140 4141 if any(_is_binary(arg) for arg in [separator, *args]): 4142 result = args[0] 4143 for arg in args[1:]: 4144 result = exp.DPipe( 4145 this=exp.DPipe(this=result, expression=separator), expression=arg 4146 ) 4147 return self.sql(result) 4148 4149 return super().concatws_sql(expression) 4150 4151 def _regexp_extract_sql(self, expression: exp.RegexpExtract | exp.RegexpExtractAll) -> str: 4152 this = expression.this 4153 group = expression.args.get("group") 4154 params = expression.args.get("parameters") 4155 position = expression.args.get("position") 4156 occurrence = expression.args.get("occurrence") 4157 null_if_pos_overflow = expression.args.get("null_if_pos_overflow") 4158 4159 # Handle Snowflake's 'e' flag: it enables capture group extraction 4160 # In DuckDB, this is controlled by the group parameter directly 4161 if params and params.is_string and "e" in params.name: 4162 params = exp.Literal.string(params.name.replace("e", "")) 4163 4164 validated_flags = self._validate_regexp_flags(params, supported_flags="cims") 4165 4166 # Strip default group when no following params (DuckDB default is same as group=0) 4167 if ( 4168 not validated_flags 4169 and group 4170 and group.name == str(self.dialect.REGEXP_EXTRACT_DEFAULT_GROUP) 4171 ): 4172 group = None 4173 4174 flags_expr = exp.Literal.string(validated_flags) if validated_flags else None 4175 4176 # use substring to handle position argument 4177 if position and (not position.is_int or position.to_py() > 1): 4178 this = exp.Substring(this=this, start=position) 4179 4180 if null_if_pos_overflow: 4181 this = exp.Nullif(this=this, expression=exp.Literal.string("")) 4182 4183 is_extract_all = isinstance(expression, exp.RegexpExtractAll) 4184 non_single_occurrence = occurrence and (not occurrence.is_int or occurrence.to_py() > 1) 4185 4186 if is_extract_all or non_single_occurrence: 4187 name = "REGEXP_EXTRACT_ALL" 4188 else: 4189 name = "REGEXP_EXTRACT" 4190 4191 result: exp.Expr = exp.Anonymous( 4192 this=name, expressions=[this, expression.expression, group, flags_expr] 4193 ) 4194 4195 # Array slicing for REGEXP_EXTRACT_ALL with occurrence 4196 if is_extract_all and non_single_occurrence: 4197 result = exp.Bracket(this=result, expressions=[exp.Slice(this=occurrence)]) 4198 # ARRAY_EXTRACT for REGEXP_EXTRACT with occurrence > 1 4199 elif non_single_occurrence: 4200 result = exp.Anonymous(this="ARRAY_EXTRACT", expressions=[result, occurrence]) 4201 4202 return self.sql(result) 4203 4204 def regexpextract_sql(self, expression: exp.RegexpExtract) -> str: 4205 return self._regexp_extract_sql(expression) 4206 4207 def regexpextractall_sql(self, expression: exp.RegexpExtractAll) -> str: 4208 return self._regexp_extract_sql(expression) 4209 4210 def regexpinstr_sql(self, expression: exp.RegexpInstr) -> str: 4211 this = expression.this 4212 pattern = expression.expression 4213 position = expression.args.get("position") 4214 orig_occ = expression.args.get("occurrence") 4215 occurrence = orig_occ or exp.Literal.number(1) 4216 option = expression.args.get("option") 4217 parameters = expression.args.get("parameters") 4218 4219 validated_flags = self._validate_regexp_flags(parameters, supported_flags="ims") 4220 if validated_flags: 4221 pattern = exp.Concat(expressions=[exp.Literal.string(f"(?{validated_flags})"), pattern]) 4222 4223 # Handle starting position offset 4224 pos_offset: exp.Expr = exp.Literal.number(0) 4225 if position and (not position.is_int or position.to_py() > 1): 4226 this = exp.Substring(this=this, start=position) 4227 pos_offset = position - exp.Literal.number(1) 4228 4229 # Helper: LIST_SUM(LIST_TRANSFORM(list[1:end], x -> LENGTH(x))) 4230 def sum_lengths(func_name: str, end: exp.Expr) -> exp.Expr: 4231 lst = exp.Bracket( 4232 this=exp.Anonymous(this=func_name, expressions=[this, pattern]), 4233 expressions=[exp.Slice(this=exp.Literal.number(1), expression=end)], 4234 offset=1, 4235 ) 4236 transform = exp.Anonymous( 4237 this="LIST_TRANSFORM", 4238 expressions=[ 4239 lst, 4240 exp.Lambda( 4241 this=exp.Length(this=exp.to_identifier("x")), 4242 expressions=[exp.to_identifier("x")], 4243 ), 4244 ], 4245 ) 4246 return exp.Coalesce( 4247 this=exp.Anonymous(this="LIST_SUM", expressions=[transform]), 4248 expressions=[exp.Literal.number(0)], 4249 ) 4250 4251 # Position = 1 + sum(split_lengths[1:occ]) + sum(match_lengths[1:occ-1]) + offset 4252 base_pos: exp.Expr = ( 4253 exp.Literal.number(1) 4254 + sum_lengths("STRING_SPLIT_REGEX", occurrence) 4255 + sum_lengths("REGEXP_EXTRACT_ALL", occurrence - exp.Literal.number(1)) 4256 + pos_offset 4257 ) 4258 4259 # option=1: add match length for end position 4260 if option and option.is_int and option.to_py() == 1: 4261 match_at_occ = exp.Bracket( 4262 this=exp.Anonymous(this="REGEXP_EXTRACT_ALL", expressions=[this, pattern]), 4263 expressions=[occurrence], 4264 offset=1, 4265 ) 4266 base_pos = base_pos + exp.Coalesce( 4267 this=exp.Length(this=match_at_occ), expressions=[exp.Literal.number(0)] 4268 ) 4269 4270 # NULL checks for all provided arguments 4271 # .copy() is used strictly because .is_() alters the node's parent pointer, mutating the parsed AST 4272 null_args = [ 4273 expression.this, 4274 expression.expression, 4275 position, 4276 orig_occ, 4277 option, 4278 parameters, 4279 ] 4280 null_checks = [arg.copy().is_(exp.Null()) for arg in null_args if arg] 4281 4282 matches = exp.Anonymous(this="REGEXP_EXTRACT_ALL", expressions=[this, pattern]) 4283 4284 return self.sql( 4285 exp.case() 4286 .when(exp.or_(*null_checks), exp.Null()) 4287 .when(pattern.copy().eq(exp.Literal.string("")), exp.Literal.number(0)) 4288 .when(exp.Length(this=matches) < occurrence, exp.Literal.number(0)) 4289 .else_(base_pos) 4290 ) 4291 4292 @unsupported_args("culture") 4293 def numbertostr_sql(self, expression: exp.NumberToStr) -> str: 4294 fmt = expression.args.get("format") 4295 if fmt and fmt.is_int: 4296 return self.func("FORMAT", f"'{{:,.{fmt.name}f}}'", expression.this) 4297 4298 self.unsupported("Only integer formats are supported by NumberToStr") 4299 return self.function_fallback_sql(expression) 4300 4301 def autoincrementcolumnconstraint_sql(self, _) -> str: 4302 self.unsupported("The AUTOINCREMENT column constraint is not supported by DuckDB") 4303 return "" 4304 4305 def aliases_sql(self, expression: exp.Aliases) -> str: 4306 this = expression.this 4307 if isinstance(this, exp.Posexplode): 4308 return self.posexplode_sql(this) 4309 4310 return super().aliases_sql(expression) 4311 4312 def posexplode_sql(self, expression: exp.Posexplode) -> str: 4313 this = expression.this 4314 parent = expression.parent 4315 4316 # The default Spark aliases are "pos" and "col", unless specified otherwise 4317 pos, col = exp.to_identifier("pos"), exp.to_identifier("col") 4318 4319 if isinstance(parent, exp.Aliases): 4320 # Column case: SELECT POSEXPLODE(col) [AS (a, b)] 4321 pos, col = parent.expressions 4322 elif isinstance(parent, exp.Table): 4323 # Table case: SELECT * FROM POSEXPLODE(col) [AS (a, b)] 4324 alias = parent.args.get("alias") 4325 if alias: 4326 pos, col = alias.columns or [pos, col] 4327 alias.pop() 4328 4329 # Translate POSEXPLODE to UNNEST + GENERATE_SUBSCRIPTS 4330 # Note: In Spark pos is 0-indexed, but in DuckDB it's 1-indexed, so we subtract 1 from GENERATE_SUBSCRIPTS 4331 unnest_sql = self.sql(exp.Unnest(expressions=[this], alias=col)) 4332 gen_subscripts = self.sql( 4333 exp.Alias( 4334 this=exp.Anonymous( 4335 this="GENERATE_SUBSCRIPTS", expressions=[this, exp.Literal.number(1)] 4336 ) 4337 - exp.Literal.number(1), 4338 alias=pos, 4339 ) 4340 ) 4341 4342 posexplode_sql = self.format_args(gen_subscripts, unnest_sql) 4343 4344 if isinstance(parent, exp.From) or (parent and isinstance(parent.parent, exp.From)): 4345 # SELECT * FROM POSEXPLODE(col) -> SELECT * FROM (SELECT GENERATE_SUBSCRIPTS(...), UNNEST(...)) 4346 return self.sql(exp.Subquery(this=exp.Select(expressions=[posexplode_sql]))) 4347 4348 return posexplode_sql 4349 4350 def addmonths_sql(self, expression: exp.AddMonths) -> str: 4351 """ 4352 Handles three key issues: 4353 1. Float/decimal months: e.g., Snowflake rounds, whereas DuckDB INTERVAL requires integers 4354 2. End-of-month preservation: If input is last day of month, result is last day of result month 4355 3. Type preservation: Maintains DATE/TIMESTAMPTZ types (DuckDB defaults to TIMESTAMP) 4356 """ 4357 from sqlglot.optimizer.annotate_types import annotate_types 4358 4359 this = expression.this 4360 if not this.type: 4361 this = annotate_types(this, dialect=self.dialect) 4362 4363 if this.is_type(*exp.DataType.TEXT_TYPES): 4364 this = exp.Cast(this=this, to=exp.DataType(this=exp.DType.TIMESTAMP)) 4365 4366 # Detect float/decimal months to apply rounding (Snowflake behavior) 4367 # DuckDB INTERVAL syntax doesn't support non-integer expressions, so use TO_MONTHS 4368 months_expr = expression.expression 4369 if not months_expr.type: 4370 months_expr = annotate_types(months_expr, dialect=self.dialect) 4371 4372 # Build interval or to_months expression based on type 4373 # Float/decimal case: Round and use TO_MONTHS(CAST(ROUND(value) AS INT)) 4374 interval_or_to_months = ( 4375 exp.func("TO_MONTHS", exp.cast(exp.func("ROUND", months_expr), "INT")) 4376 if months_expr.is_type( 4377 exp.DType.FLOAT, 4378 exp.DType.DOUBLE, 4379 exp.DType.DECIMAL, 4380 ) 4381 # Integer case: standard INTERVAL N MONTH syntax 4382 else exp.Interval(this=months_expr, unit=exp.var("MONTH")) 4383 ) 4384 4385 date_add_expr = exp.Add(this=this, expression=interval_or_to_months) 4386 4387 # Apply end-of-month preservation if Snowflake flag is set 4388 # CASE WHEN LAST_DAY(date) = date THEN LAST_DAY(result) ELSE result END 4389 preserve_eom = expression.args.get("preserve_end_of_month") 4390 result_expr = ( 4391 exp.case() 4392 .when( 4393 exp.EQ(this=exp.func("LAST_DAY", this), expression=this), 4394 exp.func("LAST_DAY", date_add_expr), 4395 ) 4396 .else_(date_add_expr) 4397 if preserve_eom 4398 else date_add_expr 4399 ) 4400 4401 # DuckDB's DATE_ADD function returns TIMESTAMP/DATETIME by default, even when the input is DATE 4402 # To match for example Snowflake's ADD_MONTHS behavior (which preserves the input type) 4403 # We need to cast the result back to the original type when the input is DATE or TIMESTAMPTZ 4404 # Example: ADD_MONTHS('2023-01-31'::date, 1) should return DATE, not TIMESTAMP 4405 if this.is_type(exp.DType.DATE, exp.DType.TIMESTAMPTZ): 4406 return self.sql(exp.Cast(this=result_expr, to=this.type)) 4407 return self.sql(result_expr) 4408 4409 def format_sql(self, expression: exp.Format) -> str: 4410 if expression.name.lower() == "%s" and len(expression.expressions) == 1: 4411 return self.func("FORMAT", "'{}'", expression.expressions[0]) 4412 4413 return self.function_fallback_sql(expression) 4414 4415 def hexstring_sql( 4416 self, expression: exp.HexString, binary_function_repr: str | None = None 4417 ) -> str: 4418 # UNHEX('FF') correctly produces blob \xFF in DuckDB 4419 return super().hexstring_sql(expression, binary_function_repr="UNHEX") 4420 4421 def datetrunc_sql(self, expression: exp.DateTrunc) -> str: 4422 unit = expression.args.get("unit") 4423 date = expression.this 4424 4425 week_start = _week_trunc_start_dow(unit) 4426 unit = unit_to_str(expression) 4427 4428 if week_start: 4429 result = self.sql( 4430 _build_week_trunc_expression(date, week_start, preserve_start_day=True) 4431 ) 4432 else: 4433 result = self.func("DATE_TRUNC", unit, date) 4434 4435 if ( 4436 expression.args.get("input_type_preserved") 4437 and date.is_type(*exp.DataType.TEMPORAL_TYPES) 4438 and not (is_date_unit(unit) and date.is_type(exp.DType.DATE)) 4439 ): 4440 return self.sql(exp.Cast(this=result, to=date.type)) 4441 4442 return result 4443 4444 def datetimetrunc_sql(self, expression: exp.DatetimeTrunc) -> str: 4445 this = exp.cast(expression.this, exp.DType.DATETIME) 4446 week_start = _week_trunc_start_dow(expression.args.get("unit")) 4447 if week_start: 4448 return self.sql( 4449 _build_week_trunc_expression( 4450 this, week_start, preserve_start_day=True, cast_to_date=False 4451 ) 4452 ) 4453 4454 return self.func("DATE_TRUNC", unit_to_str(expression), this) 4455 4456 def timestamptrunc_sql(self, expression: exp.TimestampTrunc) -> str: 4457 zone = expression.args.get("zone") 4458 timestamp = expression.this 4459 week_start = _week_trunc_start_dow(expression.args.get("unit")) 4460 4461 # The week start emulation below is exact, so avoid weekstart_unit_to_str's degrade warning 4462 unit = unit_to_str(expression) if week_start else weekstart_unit_to_str(self, expression) 4463 date_unit = is_date_unit(unit) or bool(week_start) 4464 4465 def _trunc_expr(this: exp.Expr) -> exp.Expr: 4466 if week_start: 4467 return _build_week_trunc_expression( 4468 this, week_start, preserve_start_day=True, cast_to_date=False 4469 ) 4470 return exp.func("DATE_TRUNC", unit, this) 4471 4472 if date_unit and zone: 4473 # BigQuery's TIMESTAMP_TRUNC with timezone truncates in the target timezone and returns as UTC. 4474 # Double AT TIME ZONE needed for BigQuery compatibility: 4475 # 1. First AT TIME ZONE: ensures truncation happens in the target timezone 4476 # 2. Second AT TIME ZONE: converts the DATE result back to TIMESTAMPTZ (preserving time component) 4477 timestamp = exp.AtTimeZone(this=timestamp, zone=zone) 4478 trunced = _trunc_expr(timestamp) 4479 if isinstance(trunced, exp.DateAdd): 4480 # Parenthesize so the trailing AT TIME ZONE binds to the whole shifted expression 4481 trunced = exp.Paren(this=trunced) 4482 return self.sql(exp.AtTimeZone(this=trunced, zone=zone)) 4483 4484 result = self.sql(_trunc_expr(timestamp)) 4485 if expression.args.get("input_type_preserved"): 4486 if timestamp.type and timestamp.is_type(exp.DType.TIME, exp.DType.TIMETZ): 4487 dummy_date = exp.Cast( 4488 this=exp.Literal.string("1970-01-01"), 4489 to=exp.DataType(this=exp.DType.DATE), 4490 ) 4491 date_time = exp.Add(this=dummy_date, expression=timestamp) 4492 result = self.func("DATE_TRUNC", unit, date_time) 4493 return self.sql(exp.Cast(this=result, to=timestamp.type)) 4494 4495 if timestamp.is_type(*exp.DataType.TEMPORAL_TYPES) and not ( 4496 date_unit and timestamp.is_type(exp.DType.DATE) 4497 ): 4498 return self.sql(exp.Cast(this=result, to=timestamp.type)) 4499 4500 return result 4501 4502 def trim_sql(self, expression: exp.Trim) -> str: 4503 expression.this.replace(_cast_to_varchar(expression.this)) 4504 if expression.expression: 4505 expression.expression.replace(_cast_to_varchar(expression.expression)) 4506 4507 result_sql = super().trim_sql(expression) 4508 return _gen_with_cast_to_blob(self, expression, result_sql) 4509 4510 def round_sql(self, expression: exp.Round) -> str: 4511 this = expression.this 4512 decimals = expression.args.get("decimals") 4513 truncate = expression.args.get("truncate") 4514 4515 # DuckDB requires the scale (decimals) argument to be an INT 4516 # Some dialects (e.g., Snowflake) allow non-integer scales and cast to an integer internally 4517 if decimals is not None and expression.args.get("casts_non_integer_decimals"): 4518 if not (decimals.is_int or decimals.is_type(*exp.DataType.INTEGER_TYPES)): 4519 decimals = exp.cast(decimals, exp.DType.INT) 4520 4521 func = "ROUND" 4522 if truncate: 4523 # BigQuery uses ROUND_HALF_EVEN; Snowflake uses HALF_TO_EVEN 4524 if truncate.this in ("ROUND_HALF_EVEN", "HALF_TO_EVEN"): 4525 func = "ROUND_EVEN" 4526 truncate = None 4527 # BigQuery uses ROUND_HALF_AWAY_FROM_ZERO; Snowflake uses HALF_AWAY_FROM_ZERO 4528 elif truncate.this in ("ROUND_HALF_AWAY_FROM_ZERO", "HALF_AWAY_FROM_ZERO"): 4529 truncate = None 4530 4531 return self.func(func, this, decimals, truncate) 4532 4533 def trycast_sql(self, expression: exp.TryCast) -> str: 4534 to = expression.to 4535 to_type = to.this 4536 src = expression.this 4537 4538 if ( 4539 expression.args.get("null_on_text_overflow") 4540 and to_type in exp.DataType.TEXT_TYPES 4541 and to.expressions 4542 ): 4543 return self.sql( 4544 exp.case() 4545 .when( 4546 exp.LTE(this=exp.func("LENGTH", src), expression=to.expressions[0].this), 4547 exp.cast(src, "TEXT"), 4548 ) 4549 .else_(exp.Null()) 4550 ) 4551 elif to_type == exp.DType.DATE and expression.args.get("probe_date_format"): 4552 slash_strptime = exp.cast( 4553 exp.func("TRY_STRPTIME", src, exp.Literal.string(self._TRYCAST_DATE_SLASH_FMT)), 4554 "DATE", 4555 ) 4556 mon_strptime = exp.cast( 4557 exp.func("TRY_STRPTIME", src, exp.Literal.string(self._TRYCAST_DATE_MON_FMT)), 4558 "DATE", 4559 ) 4560 return self.sql( 4561 exp.case() 4562 .when(exp.func("CONTAINS", src, exp.Literal.string("/")), slash_strptime) 4563 .when( 4564 exp.RegexpLike(this=src, expression=exp.Literal.string("[A-Za-z]")), 4565 mon_strptime, 4566 ) 4567 .else_(exp.TryCast(this=src, to=to)) 4568 ) 4569 elif ( 4570 isinstance(to_type, exp.Interval) 4571 and (unit := to_type.unit) 4572 and expression.args.get("requires_string") 4573 ): 4574 interval_type = exp.DataType.build("INTERVAL") 4575 if isinstance(unit, exp.IntervalSpan): 4576 self.unsupported( 4577 "TRY_CAST to INTERVAL with span (e.g. HOUR TO MINUTE) is not supported in DuckDB" 4578 ) 4579 return self.sql(exp.TryCast(this=src, to=interval_type)) 4580 return self.sql( 4581 exp.TryCast( 4582 this=exp.DPipe(this=src, expression=exp.Literal.string(f" {unit.name}")), 4583 to=interval_type, 4584 ) 4585 ) 4586 4587 return super().trycast_sql(expression) 4588 4589 def strtok_sql(self, expression: exp.Strtok) -> str: 4590 string_arg = expression.this 4591 delimiter_arg = expression.args.get("delimiter") 4592 part_index_arg = expression.args.get("part_index") 4593 4594 if delimiter_arg and part_index_arg: 4595 # Escape regex chars and build character class at runtime using REGEXP_REPLACE 4596 escaped_delimiter = exp.Anonymous( 4597 this="REGEXP_REPLACE", 4598 expressions=[ 4599 delimiter_arg, 4600 exp.Literal.string( 4601 r"([\[\]^.\-*+?(){}|$\\])" 4602 ), # Escape problematic regex chars 4603 exp.Literal.string( 4604 r"\\\1" 4605 ), # Replace with escaped version using $1 backreference 4606 exp.Literal.string("g"), # Global flag 4607 ], 4608 ) 4609 # CASE WHEN delimiter = '' THEN '' ELSE CONCAT('[', escaped_delimiter, ']') END 4610 regex_pattern = ( 4611 exp.case() 4612 .when(delimiter_arg.eq(exp.Literal.string("")), exp.Literal.string("")) 4613 .else_( 4614 exp.func( 4615 "CONCAT", 4616 exp.Literal.string("["), 4617 escaped_delimiter, 4618 exp.Literal.string("]"), 4619 ) 4620 ) 4621 ) 4622 4623 # STRTOK skips empty strings, so we need to filter them out 4624 # LIST_FILTER(REGEXP_SPLIT_TO_ARRAY(string, pattern), x -> x != '')[index] 4625 split_array = exp.func("REGEXP_SPLIT_TO_ARRAY", string_arg, regex_pattern) 4626 x = exp.to_identifier("x") 4627 is_empty = x.eq(exp.Literal.string("")) 4628 filtered_array = exp.func( 4629 "LIST_FILTER", 4630 split_array, 4631 exp.Lambda(this=exp.not_(is_empty.copy()), expressions=[x.copy()]), 4632 ) 4633 base_func = exp.Bracket( 4634 this=filtered_array, 4635 expressions=[part_index_arg], 4636 offset=1, 4637 ) 4638 4639 # Use template with the built regex pattern 4640 result = exp.replace_placeholders( 4641 self.STRTOK_TEMPLATE.copy(), 4642 string=string_arg, 4643 delimiter=delimiter_arg, 4644 part_index=part_index_arg, 4645 base_func=base_func, 4646 ) 4647 4648 return self.sql(result) 4649 4650 return self.function_fallback_sql(expression) 4651 4652 def strtoktoarray_sql(self, expression: exp.StrtokToArray) -> str: 4653 string_arg = expression.this 4654 delimiter_arg = expression.args.get("expression") or exp.Literal.string(" ") 4655 4656 escaped = exp.RegexpReplace( 4657 this=delimiter_arg.copy(), 4658 expression=exp.Literal.string(r"([\[\]^.\-*+?(){}|$\\])"), 4659 replacement=exp.Literal.string(r"\\\1"), 4660 modifiers=exp.Literal.string("g"), 4661 ) 4662 return self.sql( 4663 exp.replace_placeholders( 4664 self.STRTOK_TO_ARRAY_TEMPLATE.copy(), 4665 string=string_arg, 4666 delimiter=delimiter_arg, 4667 escaped=escaped, 4668 ) 4669 ) 4670 4671 def approxquantile_sql(self, expression: exp.ApproxQuantile) -> str: 4672 result = self.func("APPROX_QUANTILE", expression.this, expression.args.get("quantile")) 4673 4674 # DuckDB returns integers for APPROX_QUANTILE, cast to DOUBLE if the expected type is a real type 4675 if expression.is_type(*exp.DataType.REAL_TYPES): 4676 result = f"CAST({result} AS DOUBLE)" 4677 4678 return result 4679 4680 def approxquantiles_sql(self, expression: exp.ApproxQuantiles) -> str: 4681 """ 4682 BigQuery's APPROX_QUANTILES(expr, n) returns an array of n+1 approximate quantile values 4683 dividing the input distribution into n equal-sized buckets. 4684 4685 Both BigQuery and DuckDB use approximate algorithms for quantile estimation, but BigQuery 4686 does not document the specific algorithm used so results may differ. DuckDB does not 4687 support RESPECT NULLS. 4688 """ 4689 this = expression.this 4690 if isinstance(this, exp.Distinct): 4691 # APPROX_QUANTILES requires 2 args and DISTINCT node grabs both 4692 if len(this.expressions) < 2: 4693 self.unsupported("APPROX_QUANTILES requires a bucket count argument") 4694 return self.function_fallback_sql(expression) 4695 num_quantiles_expr = this.expressions[1].pop() 4696 else: 4697 num_quantiles_expr = expression.expression 4698 4699 if not isinstance(num_quantiles_expr, exp.Literal) or not num_quantiles_expr.is_int: 4700 self.unsupported("APPROX_QUANTILES bucket count must be a positive integer") 4701 return self.function_fallback_sql(expression) 4702 4703 num_quantiles = t.cast(int, num_quantiles_expr.to_py()) 4704 if num_quantiles <= 0: 4705 self.unsupported("APPROX_QUANTILES bucket count must be a positive integer") 4706 return self.function_fallback_sql(expression) 4707 4708 quantiles = [ 4709 exp.Literal.number(Decimal(i) / Decimal(num_quantiles)) 4710 for i in range(num_quantiles + 1) 4711 ] 4712 4713 return self.sql(exp.ApproxQuantile(this=this, quantile=exp.Array(expressions=quantiles))) 4714 4715 def jsonextractscalar_sql(self, expression: exp.JSONExtractScalar) -> str: 4716 if expression.args.get("scalar_only"): 4717 json_value = exp.JSONExtractScalar( 4718 this=rename_func("JSON_VALUE")(self, expression), expression="'$'" 4719 ) 4720 4721 # `->>` binds looser than most operators, so the wrap logic needs the parent 4722 json_value.parent = expression.parent 4723 expression = json_value 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
2318 def timeslice_sql(self, expression: exp.TimeSlice) -> str: 2319 """ 2320 Transform Snowflake's TIME_SLICE to DuckDB's time_bucket. 2321 2322 Snowflake: TIME_SLICE(date_expr, slice_length, 'UNIT' [, 'START'|'END']) 2323 DuckDB: time_bucket(INTERVAL 'slice_length' UNIT, date_expr) 2324 2325 For 'END' kind, add the interval to get the end of the slice. 2326 For DATE type with 'END', cast result back to DATE to preserve type. 2327 """ 2328 date_expr = expression.this 2329 slice_length = expression.expression 2330 unit = expression.unit 2331 kind = expression.text("kind").upper() 2332 2333 # Create INTERVAL expression: INTERVAL 'N' UNIT 2334 interval_expr = exp.Interval(this=slice_length, unit=unit) 2335 2336 # Create base time_bucket expression 2337 time_bucket_expr = exp.func("time_bucket", interval_expr, date_expr) 2338 2339 # Check if we need the end of the slice (default is start) 2340 if not kind == "END": 2341 # For 'START', return time_bucket directly 2342 return self.sql(time_bucket_expr) 2343 2344 # For 'END', add the interval to get end of slice 2345 add_expr = exp.Add(this=time_bucket_expr, expression=interval_expr.copy()) 2346 2347 # If input is DATE type, cast result back to DATE to preserve type 2348 # DuckDB converts DATE to TIMESTAMP when adding intervals 2349 if date_expr.is_type(exp.DType.DATE): 2350 return self.sql(exp.cast(add_expr, exp.DType.DATE)) 2351 2352 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.
2354 def bitmapbucketnumber_sql(self, expression: exp.BitmapBucketNumber) -> str: 2355 """ 2356 Transpile BITMAP_BUCKET_NUMBER function from Snowflake to DuckDB equivalent. 2357 2358 Snowflake's BITMAP_BUCKET_NUMBER returns a 1-based bucket identifier where: 2359 - Each bucket covers 32,768 values 2360 - Bucket numbering starts at 1 2361 - Formula: ((value - 1) // 32768) + 1 for positive values 2362 2363 For non-positive values (0 and negative), we use value // 32768 to avoid 2364 producing bucket 0 or positive bucket IDs for negative inputs. 2365 """ 2366 value = expression.this 2367 2368 positive_formula = ((value - 1) // 32768) + 1 2369 non_positive_formula = value // 32768 2370 2371 # CASE WHEN value > 0 THEN ((value - 1) // 32768) + 1 ELSE value // 32768 END 2372 case_expr = ( 2373 exp.case() 2374 .when(exp.GT(this=value, expression=exp.Literal.number(0)), positive_formula) 2375 .else_(non_positive_formula) 2376 ) 2377 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.
2379 def bitmapbitposition_sql(self, expression: exp.BitmapBitPosition) -> str: 2380 """ 2381 Transpile Snowflake's BITMAP_BIT_POSITION to DuckDB CASE expression. 2382 2383 Snowflake's BITMAP_BIT_POSITION behavior: 2384 - For n <= 0: returns ABS(n) % 32768 2385 - For n > 0: returns (n - 1) % 32768 (maximum return value is 32767) 2386 """ 2387 this = expression.this 2388 2389 return self.sql( 2390 exp.Mod( 2391 this=exp.Paren( 2392 this=exp.If( 2393 this=exp.GT(this=this, expression=exp.Literal.number(0)), 2394 true=this - exp.Literal.number(1), 2395 false=exp.Abs(this=this), 2396 ) 2397 ), 2398 expression=MAX_BIT_POSITION, 2399 ) 2400 )
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)
2402 def bitmapconstructagg_sql(self, expression: exp.BitmapConstructAgg) -> str: 2403 """ 2404 Transpile Snowflake's BITMAP_CONSTRUCT_AGG to DuckDB equivalent. 2405 Uses a pre-parsed template with placeholders replaced by expression nodes. 2406 2407 Snowflake bitmap format: 2408 - Small (< 5 unique values): 2-byte count (big-endian) + values (little-endian) + padding to 10 bytes 2409 - Large (>= 5 unique values): 10-byte header (0x08 + 9 zeros) + values (little-endian) 2410 """ 2411 arg = expression.this 2412 return ( 2413 f"({self.sql(exp.replace_placeholders(self.BITMAP_CONSTRUCT_AGG_TEMPLATE, arg=arg))})" 2414 )
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)
2458 def jarowinklersimilarity_sql(self, expression: exp.JarowinklerSimilarity) -> str: 2459 this = expression.this 2460 expr = expression.expression 2461 2462 if expression.args.get("case_insensitive"): 2463 this = exp.Upper(this=this) 2464 expr = exp.Upper(this=expr) 2465 2466 result = exp.func("JARO_WINKLER_SIMILARITY", this, expr) 2467 2468 if expression.args.get("integer_scale"): 2469 result = exp.cast(result * 100, "INTEGER") 2470 2471 return self.sql(result)
2473 def randstr_sql(self, expression: exp.Randstr) -> str: 2474 """ 2475 Transpile Snowflake's RANDSTR to DuckDB equivalent using deterministic hash-based random. 2476 Uses a pre-parsed template with placeholders replaced by expression nodes. 2477 2478 RANDSTR(length, generator) generates a random string of specified length. 2479 - With numeric seed: Use HASH(i + seed) for deterministic output (same seed = same result) 2480 - With RANDOM(): Use RANDOM() in the hash for non-deterministic output 2481 - No generator: Use default seed value 2482 """ 2483 length = expression.this 2484 generator = expression.args.get("generator") 2485 2486 if generator: 2487 if isinstance(generator, exp.Rand): 2488 # If it's RANDOM(), use its seed if available, otherwise use RANDOM() itself 2489 seed_value = generator.this or generator 2490 else: 2491 # Const/int or other expression - use as seed directly 2492 seed_value = generator 2493 else: 2494 # No generator specified, use default seed (arbitrary but deterministic) 2495 seed_value = exp.Literal.number(RANDSTR_SEED) 2496 2497 replacements = {"seed": seed_value, "length": length} 2498 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
2500 @unsupported_args("finish") 2501 def reduce_sql(self, expression: exp.Reduce) -> str: 2502 array_arg = expression.this 2503 initial_value = expression.args.get("initial") 2504 merge_lambda = expression.args.get("merge") 2505 2506 if merge_lambda: 2507 merge_lambda.set("colon", True) 2508 2509 return self.func("list_reduce", array_arg, merge_lambda, initial_value)
2511 def zipf_sql(self, expression: exp.Zipf) -> str: 2512 """ 2513 Transpile Snowflake's ZIPF to DuckDB using CDF-based inverse sampling. 2514 Uses a pre-parsed template with placeholders replaced by expression nodes. 2515 """ 2516 s = expression.this 2517 n = expression.args["elementcount"] 2518 gen = expression.args["gen"] 2519 2520 if not isinstance(gen, exp.Rand): 2521 # (ABS(HASH(seed)) % 1000000) / 1000000.0 2522 random_expr: exp.Expr = exp.Div( 2523 this=exp.Paren( 2524 this=exp.Mod( 2525 this=exp.Abs(this=exp.Anonymous(this="HASH", expressions=[gen.copy()])), 2526 expression=exp.Literal.number(1000000), 2527 ) 2528 ), 2529 expression=exp.Literal.number(1000000.0), 2530 ) 2531 else: 2532 # Use RANDOM() for non-deterministic output 2533 random_expr = exp.Rand() 2534 2535 replacements = {"s": s, "n": n, "random_expr": random_expr} 2536 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.
2538 def tobinary_sql(self, expression: exp.ToBinary) -> str: 2539 """ 2540 TO_BINARY and TRY_TO_BINARY transpilation: 2541 - 'HEX': TO_BINARY('48454C50', 'HEX') -> UNHEX('48454C50') 2542 - 'UTF-8': TO_BINARY('TEST', 'UTF-8') -> ENCODE('TEST') 2543 - 'BASE64': TO_BINARY('SEVMUA==', 'BASE64') -> FROM_BASE64('SEVMUA==') 2544 2545 For TRY_TO_BINARY (safe=True), wrap with TRY(): 2546 - 'HEX': TRY_TO_BINARY('invalid', 'HEX') -> TRY(UNHEX('invalid')) 2547 """ 2548 value = expression.this 2549 format_arg = expression.args.get("format") 2550 is_safe = expression.args.get("safe") 2551 is_binary = _is_binary(expression) 2552 2553 if not format_arg and not is_binary: 2554 func_name = "TRY_TO_BINARY" if is_safe else "TO_BINARY" 2555 return self.func(func_name, value) 2556 2557 # Snowflake defaults to HEX encoding when no format is specified 2558 fmt = format_arg.name.upper() if format_arg else "HEX" 2559 2560 if fmt in ("UTF-8", "UTF8"): 2561 # DuckDB ENCODE always uses UTF-8, no charset parameter needed 2562 result = self.func("ENCODE", value) 2563 elif fmt == "BASE64": 2564 result = self.func("FROM_BASE64", value) 2565 elif fmt == "HEX": 2566 result = self.func("UNHEX", value) 2567 else: 2568 if is_safe: 2569 return self.sql(exp.null()) 2570 else: 2571 self.unsupported(f"format {fmt} is not supported") 2572 result = self.func("TO_BINARY", value) 2573 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'))
2575 def tonumber_sql(self, expression: exp.ToNumber) -> str: 2576 fmt = expression.args.get("format") 2577 precision = expression.args.get("precision") 2578 scale = expression.args.get("scale") 2579 2580 if not fmt and precision and scale: 2581 return self.sql( 2582 exp.cast( 2583 expression.this, f"DECIMAL({precision.name}, {scale.name})", dialect="duckdb" 2584 ) 2585 ) 2586 2587 return super().tonumber_sql(expression)
2613 def generator_sql(self, expression: exp.Generator) -> str: 2614 # Transpile Snowflake GENERATOR to DuckDB range() 2615 rowcount = expression.args.get("rowcount") 2616 time_limit = expression.args.get("time_limit") 2617 2618 if time_limit: 2619 self.unsupported("GENERATOR TIMELIMIT parameter is not supported in DuckDB") 2620 2621 if not rowcount: 2622 self.unsupported("GENERATOR without ROWCOUNT is not supported in DuckDB") 2623 return self.func("range", exp.Literal.number(0)) 2624 2625 return self.func("range", rowcount)
2633 def lambda_sql(self, expression: exp.Lambda, arrow_sep: str = "->", wrap: bool = True) -> str: 2634 if expression.args.get("colon"): 2635 prefix = "LAMBDA " 2636 arrow_sep = ":" 2637 wrap = False 2638 else: 2639 prefix = "" 2640 2641 lambda_sql = super().lambda_sql(expression, arrow_sep=arrow_sep, wrap=wrap) 2642 return f"{prefix}{lambda_sql}"
2653 def sortarray_sql(self, expression: exp.SortArray) -> str: 2654 arr = expression.this 2655 asc = expression.args.get("asc") 2656 nulls_first = expression.args.get("nulls_first") 2657 2658 if not isinstance(asc, exp.Boolean) and not isinstance(nulls_first, exp.Boolean): 2659 return self.func("LIST_SORT", arr, asc, nulls_first) 2660 2661 nulls_are_first = nulls_first == exp.true() 2662 nulls_first_sql = exp.Literal.string("NULLS FIRST") if nulls_are_first else None 2663 2664 if not isinstance(asc, exp.Boolean): 2665 return self.func("LIST_SORT", arr, asc, nulls_first_sql) 2666 2667 descending = asc == exp.false() 2668 2669 if not descending and not nulls_are_first: 2670 return self.func("LIST_SORT", arr) 2671 if not nulls_are_first: 2672 return self.func("ARRAY_REVERSE_SORT", arr) 2673 return self.func( 2674 "LIST_SORT", 2675 arr, 2676 exp.Literal.string("DESC" if descending else "ASC"), 2677 exp.Literal.string("NULLS FIRST"), 2678 )
2680 def install_sql(self, expression: exp.Install) -> str: 2681 force = "FORCE " if expression.args.get("force") else "" 2682 this = self.sql(expression, "this") 2683 from_clause = expression.args.get("from_") 2684 from_clause = f" FROM {from_clause}" if from_clause else "" 2685 return f"{force}INSTALL {this}{from_clause}"
2693 def strposition_sql(self, expression: exp.StrPosition) -> str: 2694 this = expression.this 2695 substr = expression.args.get("substr") 2696 position = expression.args.get("position") 2697 2698 # For BINARY/BLOB: DuckDB's STRPOS doesn't support BLOB types 2699 # Convert to HEX strings, use STRPOS, then convert hex position to byte position 2700 if _is_binary(this): 2701 # Build expression: STRPOS(HEX(haystack), HEX(needle)) 2702 hex_strpos = exp.StrPosition( 2703 this=exp.Hex(this=this), 2704 substr=exp.Hex(this=substr), 2705 ) 2706 2707 return self.sql(exp.cast((hex_strpos + 1) / 2, exp.DType.INT)) 2708 2709 # For VARCHAR: handle clamp_position 2710 if expression.args.get("clamp_position") and position: 2711 expression = expression.copy() 2712 expression.set( 2713 "position", 2714 exp.If( 2715 this=exp.LTE(this=position, expression=exp.Literal.number(0)), 2716 true=exp.Literal.number(1), 2717 false=position.copy(), 2718 ), 2719 ) 2720 2721 return strposition_sql(self, expression)
2723 def substring_sql(self, expression: exp.Substring) -> str: 2724 if expression.args.get("zero_start"): 2725 start = expression.args.get("start") 2726 length = expression.args.get("length") 2727 2728 if start := expression.args.get("start"): 2729 start = exp.If(this=start.eq(0), true=exp.Literal.number(1), false=start) 2730 if length := expression.args.get("length"): 2731 length = exp.If(this=length < 0, true=exp.Literal.number(0), false=length) 2732 2733 return self.func("SUBSTRING", expression.this, start, length) 2734 2735 return self.function_fallback_sql(expression)
2737 def strtotime_sql(self, expression: exp.StrToTime) -> str: 2738 # Check if target_type requires TIMESTAMPTZ (for LTZ/TZ variants) 2739 target_type = expression.args.get("target_type") 2740 needs_tz = target_type and target_type.this in ( 2741 exp.DType.TIMESTAMPLTZ, 2742 exp.DType.TIMESTAMPTZ, 2743 ) 2744 2745 value, formatted_time = self._strptime_default_year(expression) 2746 2747 if expression.args.get("safe"): 2748 cast_type = exp.DType.TIMESTAMPTZ if needs_tz else exp.DType.TIMESTAMP 2749 return self.sql(exp.cast(self.func("TRY_STRPTIME", value, formatted_time), cast_type)) 2750 2751 base_sql = self.func("STRPTIME", value, formatted_time) 2752 if needs_tz: 2753 return self.sql( 2754 exp.cast( 2755 base_sql, 2756 exp.DataType(this=exp.DType.TIMESTAMPTZ), 2757 ) 2758 ) 2759 return base_sql
2761 def strtodate_sql(self, expression: exp.StrToDate) -> str: 2762 value, formatted_time = self._strptime_default_year(expression) 2763 function_name = "STRPTIME" if not expression.args.get("safe") else "TRY_STRPTIME" 2764 return self.sql( 2765 exp.cast( 2766 self.func(function_name, value, formatted_time), 2767 exp.DataType(this=exp.DType.DATE), 2768 ) 2769 )
2796 def tsordstotime_sql(self, expression: exp.TsOrDsToTime) -> str: 2797 this = expression.this 2798 time_format = self.format_time(expression) 2799 safe = expression.args.get("safe") 2800 time_type = exp.DataType.from_str("TIME", dialect="duckdb") 2801 cast_expr = exp.TryCast if safe else exp.Cast 2802 2803 if time_format: 2804 func_name = "TRY_STRPTIME" if safe else "STRPTIME" 2805 strptime = exp.Anonymous(this=func_name, expressions=[this, time_format]) 2806 return self.sql(cast_expr(this=strptime, to=time_type)) 2807 2808 if isinstance(this, exp.TsOrDsToTime) or this.is_type(exp.DType.TIME): 2809 return self.sql(this) 2810 2811 return self.sql(cast_expr(this=this, to=time_type))
2813 def currentdate_sql(self, expression: exp.CurrentDate) -> str: 2814 if not expression.this: 2815 return "CURRENT_DATE" 2816 2817 expr = exp.Cast( 2818 this=exp.AtTimeZone(this=exp.CurrentTimestamp(), zone=expression.this), 2819 to=exp.DataType(this=exp.DType.DATE), 2820 ) 2821 return self.sql(expr)
2834 def parsejson_sql(self, expression: exp.ParseJSON) -> str: 2835 arg = expression.this 2836 if expression.args.get("safe"): 2837 return self.sql( 2838 exp.case() 2839 .when(exp.func("json_valid", arg), exp.cast(arg.copy(), "JSON")) 2840 .else_(exp.null()) 2841 ) 2842 return self.func("JSON", arg)
2844 def unicode_sql(self, expression: exp.Unicode) -> str: 2845 if expression.args.get("empty_is_zero"): 2846 return self.sql( 2847 exp.case() 2848 .when(expression.this.eq(exp.Literal.string("")), exp.Literal.number(0)) 2849 .else_(exp.Anonymous(this="UNICODE", expressions=[expression.this])) 2850 ) 2851 2852 return self.func("UNICODE", expression.this)
2861 def trunc_sql(self, expression: exp.Trunc) -> str: 2862 decimals = expression.args.get("decimals") 2863 if ( 2864 expression.args.get("fractions_supported") 2865 and decimals 2866 and not decimals.is_type(exp.DType.INT) 2867 ): 2868 decimals = exp.cast(decimals, exp.DType.INT, dialect="duckdb") 2869 2870 return self.func("TRUNC", expression.this, decimals)
2872 def normal_sql(self, expression: exp.Normal) -> str: 2873 """ 2874 Transpile Snowflake's NORMAL(mean, stddev, gen) to DuckDB. 2875 2876 Uses the Box-Muller transform via NORMAL_TEMPLATE. 2877 """ 2878 mean = expression.this 2879 stddev = expression.args["stddev"] 2880 gen: exp.Expr = expression.args["gen"] 2881 2882 # Build two uniform random values [0, 1) for Box-Muller transform 2883 if isinstance(gen, exp.Rand) and gen.this is None: 2884 u1: exp.Expr = exp.Rand() 2885 u2: exp.Expr = exp.Rand() 2886 else: 2887 # Seeded: derive two values using HASH with different inputs 2888 seed = gen.this if isinstance(gen, exp.Rand) else gen 2889 u1 = exp.replace_placeholders(self.SEEDED_RANDOM_TEMPLATE, seed=seed) 2890 u2 = exp.replace_placeholders( 2891 self.SEEDED_RANDOM_TEMPLATE, 2892 seed=exp.Add(this=seed.copy(), expression=exp.Literal.number(1)), 2893 ) 2894 2895 replacements = {"mean": mean, "stddev": stddev, "u1": u1, "u2": u2} 2896 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.
2898 def uniform_sql(self, expression: exp.Uniform) -> str: 2899 """ 2900 Transpile Snowflake's UNIFORM(min, max, gen) to DuckDB. 2901 2902 UNIFORM returns a random value in [min, max]: 2903 - Integer result if both min and max are integers 2904 - Float result if either min or max is a float 2905 """ 2906 min_val = expression.this 2907 max_val = expression.expression 2908 gen = expression.args.get("gen") 2909 2910 # Determine if result should be integer (both bounds are integers). 2911 # We do this to emulate Snowflake's behavior, INT -> INT, FLOAT -> FLOAT 2912 is_int_result = min_val.is_int and max_val.is_int 2913 2914 # Build the random value expression [0, 1) 2915 if not isinstance(gen, exp.Rand): 2916 # Seed value: (ABS(HASH(seed)) % 1000000) / 1000000.0 2917 random_expr: exp.Expr = exp.Div( 2918 this=exp.Paren( 2919 this=exp.Mod( 2920 this=exp.Abs(this=exp.Anonymous(this="HASH", expressions=[gen])), 2921 expression=exp.Literal.number(1000000), 2922 ) 2923 ), 2924 expression=exp.Literal.number(1000000.0), 2925 ) 2926 else: 2927 random_expr = exp.Rand() 2928 2929 # Build: min + random * (max - min [+ 1 for int]) 2930 range_expr: exp.Expr = exp.Sub(this=max_val, expression=min_val) 2931 if is_int_result: 2932 range_expr = exp.Add(this=range_expr, expression=exp.Literal.number(1)) 2933 2934 result: exp.Expr = exp.Add( 2935 this=min_val, 2936 expression=exp.Mul(this=random_expr, expression=exp.Paren(this=range_expr)), 2937 ) 2938 2939 if is_int_result: 2940 result = exp.Cast(this=exp.Floor(this=result), to=exp.DType.BIGINT.into_expr()) 2941 2942 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
2944 def timefromparts_sql(self, expression: exp.TimeFromParts) -> str: 2945 nano = expression.args.get("nano") 2946 overflow = expression.args.get("overflow") 2947 2948 # Snowflake's TIME_FROM_PARTS supports overflow 2949 if overflow: 2950 hour = expression.args["hour"] 2951 minute = expression.args["min"] 2952 sec = expression.args["sec"] 2953 2954 # Check if values are within normal ranges - use MAKE_TIME for efficiency 2955 if not nano and all(arg.is_int for arg in [hour, minute, sec]): 2956 try: 2957 h_val = hour.to_py() 2958 m_val = minute.to_py() 2959 s_val = sec.to_py() 2960 if 0 <= h_val <= 23 and 0 <= m_val <= 59 and 0 <= s_val <= 59: 2961 return rename_func("MAKE_TIME")(self, expression) 2962 except ValueError: 2963 pass 2964 2965 # Overflow or nanoseconds detected - use INTERVAL arithmetic 2966 if nano: 2967 sec = sec + nano.pop() / exp.Literal.number(1000000000.0) 2968 2969 total_seconds = hour * exp.Literal.number(3600) + minute * exp.Literal.number(60) + sec 2970 2971 return self.sql( 2972 exp.Add( 2973 this=exp.Cast( 2974 this=exp.Literal.string("00:00:00"), to=exp.DType.TIME.into_expr() 2975 ), 2976 expression=exp.Interval(this=total_seconds, unit=exp.var("SECOND")), 2977 ) 2978 ) 2979 2980 # Default: MAKE_TIME 2981 if nano: 2982 expression.set( 2983 "sec", expression.args["sec"] + nano.pop() / exp.Literal.number(1000000000.0) 2984 ) 2985 2986 return rename_func("MAKE_TIME")(self, expression)
2988 def extract_sql(self, expression: exp.Extract) -> str: 2989 """ 2990 Transpile EXTRACT/DATE_PART for DuckDB, handling specifiers not natively supported. 2991 2992 DuckDB doesn't support: WEEKISO, YEAROFWEEK, YEAROFWEEKISO, NANOSECOND, 2993 EPOCH_SECOND (as integer), EPOCH_MILLISECOND, EPOCH_MICROSECOND, EPOCH_NANOSECOND 2994 """ 2995 this = expression.this 2996 datetime_expr = expression.expression 2997 2998 # TIMESTAMPTZ extractions may produce different results between Snowflake and DuckDB 2999 # because Snowflake applies server timezone while DuckDB uses local timezone 3000 if datetime_expr.is_type(exp.DType.TIMESTAMPTZ, exp.DType.TIMESTAMPLTZ): 3001 self.unsupported( 3002 "EXTRACT from TIMESTAMPTZ / TIMESTAMPLTZ may produce different results due to timezone handling differences" 3003 ) 3004 3005 part_name = this.name.upper() 3006 3007 if part_name in self.EXTRACT_STRFTIME_MAPPINGS: 3008 fmt, cast_type = self.EXTRACT_STRFTIME_MAPPINGS[part_name] 3009 3010 # Problem: strftime doesn't accept TIME and there's no NANOSECOND function 3011 # So, for NANOSECOND with TIME, fallback to MICROSECOND * 1000 3012 is_nano_time = part_name == "NANOSECOND" and datetime_expr.is_type( 3013 exp.DType.TIME, exp.DType.TIMETZ 3014 ) 3015 3016 if is_nano_time: 3017 self.unsupported("Parameter NANOSECOND is not supported with TIME type in DuckDB") 3018 return self.sql( 3019 exp.cast( 3020 exp.Mul( 3021 this=exp.Extract(this=exp.var("MICROSECOND"), expression=datetime_expr), 3022 expression=exp.Literal.number(1000), 3023 ), 3024 exp.DataType.from_str(cast_type, dialect="duckdb"), 3025 ) 3026 ) 3027 3028 # For NANOSECOND, cast to TIMESTAMP_NS to preserve nanosecond precision 3029 strftime_input = datetime_expr 3030 if part_name == "NANOSECOND": 3031 strftime_input = exp.cast(datetime_expr, exp.DType.TIMESTAMP_NS) 3032 3033 return self.sql( 3034 exp.cast( 3035 exp.Anonymous( 3036 this="STRFTIME", 3037 expressions=[strftime_input, exp.Literal.string(fmt)], 3038 ), 3039 exp.DataType.from_str(cast_type, dialect="duckdb"), 3040 ) 3041 ) 3042 3043 if part_name in self.EXTRACT_EPOCH_MAPPINGS: 3044 func_name = self.EXTRACT_EPOCH_MAPPINGS[part_name] 3045 result: exp.Expr = exp.Anonymous(this=func_name, expressions=[datetime_expr]) 3046 # EPOCH returns float, cast to BIGINT for integer result 3047 if part_name == "EPOCH_SECOND": 3048 result = exp.cast(result, exp.DataType.from_str("BIGINT", dialect="duckdb")) 3049 return self.sql(result) 3050 3051 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
3053 def timestampfromparts_sql(self, expression: exp.TimestampFromParts) -> str: 3054 # Check if this is the date/time expression form: TIMESTAMP_FROM_PARTS(date_expr, time_expr) 3055 date_expr = expression.this 3056 time_expr = expression.expression 3057 3058 if date_expr is not None and time_expr is not None: 3059 # In DuckDB, DATE + TIME produces TIMESTAMP 3060 return self.sql(exp.Add(this=date_expr, expression=time_expr)) 3061 3062 # Component-based form: TIMESTAMP_FROM_PARTS(year, month, day, hour, minute, second, ...) 3063 sec = expression.args.get("sec") 3064 if sec is None: 3065 # This shouldn't happen with valid input, but handle gracefully 3066 return rename_func("MAKE_TIMESTAMP")(self, expression) 3067 3068 milli = expression.args.get("milli") 3069 if milli is not None: 3070 sec += milli.pop() / exp.Literal.number(1000.0) 3071 3072 nano = expression.args.get("nano") 3073 if nano is not None: 3074 sec += nano.pop() / exp.Literal.number(1000000000.0) 3075 3076 if milli or nano: 3077 expression.set("sec", sec) 3078 3079 return rename_func("MAKE_TIMESTAMP")(self, expression)
3081 @unsupported_args("nano") 3082 def timestampltzfromparts_sql(self, expression: exp.TimestampLtzFromParts) -> str: 3083 # Pop nano so rename_func only passes args that MAKE_TIMESTAMP accepts 3084 if nano := expression.args.get("nano"): 3085 nano.pop() 3086 3087 timestamp = rename_func("MAKE_TIMESTAMP")(self, expression) 3088 return f"CAST({timestamp} AS TIMESTAMPTZ)"
3090 @unsupported_args("nano") 3091 def timestamptzfromparts_sql(self, expression: exp.TimestampTzFromParts) -> str: 3092 # Extract zone before popping 3093 zone = expression.args.get("zone") 3094 # Pop zone and nano so rename_func only passes args that MAKE_TIMESTAMP accepts 3095 if zone: 3096 zone = zone.pop() 3097 3098 if nano := expression.args.get("nano"): 3099 nano.pop() 3100 3101 timestamp = rename_func("MAKE_TIMESTAMP")(self, expression) 3102 3103 if zone: 3104 # Use AT TIME ZONE to apply the explicit timezone 3105 return f"{timestamp} AT TIME ZONE {self.sql(zone)}" 3106 3107 return timestamp
3109 def tablesample_sql( 3110 self, 3111 expression: exp.TableSample, 3112 tablesample_keyword: str | None = None, 3113 ) -> str: 3114 if not isinstance(expression.parent, exp.Select): 3115 # This sample clause only applies to a single source, not the entire resulting relation 3116 tablesample_keyword = "TABLESAMPLE" 3117 3118 if expression.args.get("size"): 3119 method = expression.args.get("method") 3120 if method and method.name.upper() != "RESERVOIR": 3121 self.unsupported( 3122 f"Sampling method {method} is not supported with a discrete sample count, " 3123 "defaulting to reservoir sampling" 3124 ) 3125 expression.set("method", exp.var("RESERVOIR")) 3126 3127 return super().tablesample_sql(expression, tablesample_keyword=tablesample_keyword)
3139 def join_sql(self, expression: exp.Join) -> str: 3140 if ( 3141 not expression.args.get("using") 3142 and not expression.args.get("on") 3143 and not expression.method 3144 and (expression.kind in ("", "INNER", "OUTER")) 3145 ): 3146 # Some dialects support `LEFT/INNER JOIN UNNEST(...)` without an explicit ON clause 3147 # DuckDB doesn't, but we can just add a dummy ON clause that is always true 3148 if isinstance(expression.this, exp.Unnest): 3149 return super().join_sql(expression.on(exp.true())) 3150 3151 expression.set("side", None) 3152 expression.set("kind", None) 3153 3154 return super().join_sql(expression)
3156 def countif_sql(self, expression: exp.CountIf) -> str: 3157 if self.dialect.version >= (1, 2): 3158 this = expression.this 3159 if expression.args.get("zero_on_all_null") and not isinstance(this, exp.Distinct): 3160 # DuckDB >= 1.2's COUNT_IF returns NULL when the condition is NULL on all rows, 3161 # so we wrap the condition in IS TRUE to preserve count-like semantics 3162 expression = exp.CountIf(this=exp.paren(this).is_(exp.true())) 3163 return self.function_fallback_sql(expression) 3164 3165 # https://github.com/tobymao/sqlglot/pull/4749 3166 return count_if_to_sum(self, expression)
3168 def bracket_sql(self, expression: exp.Bracket) -> str: 3169 if self.dialect.version >= (1, 2): 3170 return super().bracket_sql(expression) 3171 3172 # https://duckdb.org/2025/02/05/announcing-duckdb-120.html#breaking-changes 3173 this = expression.this 3174 if isinstance(this, exp.Array): 3175 this.replace(exp.paren(this)) 3176 3177 bracket = super().bracket_sql(expression) 3178 3179 if not expression.args.get("returns_list_for_maps"): 3180 if not this.type: 3181 from sqlglot.optimizer.annotate_types import annotate_types 3182 3183 this = annotate_types(this, dialect=self.dialect) 3184 3185 if this.is_type(exp.DType.MAP): 3186 bracket = f"({bracket})[1]" 3187 3188 return bracket
3190 def withingroup_sql(self, expression: exp.WithinGroup) -> str: 3191 func = expression.this 3192 3193 # For ARRAY_AGG, DuckDB requires ORDER BY inside the function, not in WITHIN GROUP 3194 # Transform: ARRAY_AGG(x) WITHIN GROUP (ORDER BY y) -> ARRAY_AGG(x ORDER BY y) 3195 if isinstance(func, exp.ArrayAgg): 3196 if not isinstance(order := expression.expression, exp.Order): 3197 return self.sql(func) 3198 3199 # Save the original column for FILTER clause (before wrapping with Order) 3200 original_this = func.this 3201 3202 # Move ORDER BY inside ARRAY_AGG by wrapping its argument with Order 3203 # ArrayAgg.this should become Order(this=ArrayAgg.this, expressions=order.expressions) 3204 func.set( 3205 "this", 3206 exp.Order( 3207 this=func.this.copy(), 3208 expressions=order.expressions, 3209 ), 3210 ) 3211 3212 # Generate the ARRAY_AGG function with ORDER BY and add FILTER clause if needed 3213 # Use original_this (not the Order-wrapped version) for the FILTER condition 3214 array_agg_sql = self.function_fallback_sql(func) 3215 return self._add_arrayagg_null_filter(array_agg_sql, func, original_this) 3216 3217 # For other functions (like PERCENTILES), use existing logic 3218 expression_sql = self.sql(expression, "expression") 3219 3220 if isinstance(func, exp.PERCENTILES): 3221 # Make the order key the first arg and slide the fraction to the right 3222 # https://duckdb.org/docs/sql/aggregates#ordered-set-aggregate-functions 3223 order_col = expression.find(exp.Ordered) 3224 if order_col: 3225 func.set("expression", func.this) 3226 func.set("this", order_col.this) 3227 3228 this = self.sql(expression, "this").rstrip(")") 3229 3230 return f"{this}{expression_sql})"
3232 def length_sql(self, expression: exp.Length) -> str: 3233 arg = expression.this 3234 3235 # Dialects like BQ and Snowflake also accept binary values as args, so 3236 # DDB will attempt to infer the type or resort to case/when resolution 3237 if not expression.args.get("binary") or arg.is_string: 3238 return self.func("LENGTH", arg) 3239 3240 if not arg.type: 3241 from sqlglot.optimizer.annotate_types import annotate_types 3242 3243 arg = annotate_types(arg, dialect=self.dialect) 3244 3245 if arg.is_type(*exp.DataType.TEXT_TYPES): 3246 return self.func("LENGTH", arg) 3247 3248 # We need these casts to make duckdb's static type checker happy 3249 blob = exp.cast(arg, exp.DType.VARBINARY) 3250 varchar = exp.cast(arg, exp.DType.VARCHAR) 3251 3252 case = ( 3253 exp.case(exp.Anonymous(this="TYPEOF", expressions=[arg])) 3254 .when(exp.Literal.string("BLOB"), exp.ByteLength(this=blob)) 3255 .else_(exp.Anonymous(this="LENGTH", expressions=[varchar])) 3256 ) 3257 return self.sql(case)
3276 def collate_sql(self, expression: exp.Collate) -> str: 3277 if not expression.expression.is_string: 3278 return super().collate_sql(expression) 3279 3280 raw = expression.expression.name 3281 if not raw: 3282 return self.sql(expression.this) 3283 3284 parts = [] 3285 for part in raw.split("-"): 3286 lower = part.lower() 3287 if lower not in _SNOWFLAKE_COLLATION_DEFAULTS: 3288 if lower in _SNOWFLAKE_COLLATION_UNSUPPORTED: 3289 self.unsupported( 3290 f"Snowflake collation specifier '{part}' has no DuckDB equivalent" 3291 ) 3292 parts.append(lower) 3293 3294 if not parts: 3295 return self.sql(expression.this) 3296 return super().collate_sql( 3297 exp.Collate(this=expression.this, expression=exp.var(".".join(parts))) 3298 )
3330 def regexpcount_sql(self, expression: exp.RegexpCount) -> str: 3331 this = expression.this 3332 pattern = expression.expression 3333 position = expression.args.get("position") 3334 parameters = expression.args.get("parameters") 3335 3336 # Validate flags - only "ims" flags are supported for embedded patterns 3337 validated_flags = self._validate_regexp_flags(parameters, supported_flags="ims") 3338 3339 if position: 3340 this = exp.Substring(this=this, start=position) 3341 3342 # Embed flags in pattern (REGEXP_EXTRACT_ALL doesn't support flags argument) 3343 if validated_flags: 3344 pattern = exp.Concat(expressions=[exp.Literal.string(f"(?{validated_flags})"), pattern]) 3345 3346 # Handle empty pattern: Snowflake returns 0, DuckDB would match between every character 3347 result = ( 3348 exp.case() 3349 .when( 3350 exp.EQ(this=pattern, expression=exp.Literal.string("")), 3351 exp.Literal.number(0), 3352 ) 3353 .else_( 3354 exp.Length( 3355 this=exp.Anonymous(this="REGEXP_EXTRACT_ALL", expressions=[this, pattern]) 3356 ) 3357 ) 3358 ) 3359 3360 return self.sql(result)
3362 def regexpreplace_sql(self, expression: exp.RegexpReplace) -> str: 3363 subject = expression.this 3364 pattern = expression.expression 3365 replacement = expression.args.get("replacement") or exp.Literal.string("") 3366 position = expression.args.get("position") 3367 occurrence = expression.args.get("occurrence") 3368 modifiers = expression.args.get("modifiers") 3369 3370 validated_flags = self._validate_regexp_flags(modifiers, supported_flags="cimsg") or "" 3371 3372 # Handle occurrence (only literals supported) 3373 if occurrence and not occurrence.is_int: 3374 self.unsupported("REGEXP_REPLACE with non-literal occurrence") 3375 else: 3376 occurrence = occurrence.to_py() if occurrence and occurrence.is_int else 0 3377 if occurrence > 1: 3378 self.unsupported(f"REGEXP_REPLACE occurrence={occurrence} not supported") 3379 # flag duckdb to do either all or none, single_replace check is for duckdb round trip 3380 elif ( 3381 occurrence == 0 3382 and "g" not in validated_flags 3383 and not expression.args.get("single_replace") 3384 ): 3385 validated_flags += "g" 3386 3387 # Handle position (only literals supported) 3388 prefix = None 3389 if position and not position.is_int: 3390 self.unsupported("REGEXP_REPLACE with non-literal position") 3391 elif position and position.is_int and position.to_py() > 1: 3392 pos = position.to_py() 3393 prefix = exp.Substring( 3394 this=subject, start=exp.Literal.number(1), length=exp.Literal.number(pos - 1) 3395 ) 3396 subject = exp.Substring(this=subject, start=exp.Literal.number(pos)) 3397 3398 result: exp.Expr = exp.Anonymous( 3399 this="REGEXP_REPLACE", 3400 expressions=[ 3401 subject, 3402 pattern, 3403 replacement, 3404 exp.Literal.string(validated_flags) if validated_flags else None, 3405 ], 3406 ) 3407 3408 if prefix: 3409 result = exp.Concat(expressions=[prefix, result]) 3410 3411 return self.sql(result)
3413 def regexplike_sql(self, expression: exp.RegexpLike) -> str: 3414 this = expression.this 3415 pattern = expression.expression 3416 flag = expression.args.get("flag") 3417 3418 if expression.args.get("full_match"): 3419 validated_flags = self._validate_regexp_flags(flag, supported_flags="cims") 3420 flag = exp.Literal.string(validated_flags) if validated_flags else None 3421 return self.func("REGEXP_FULL_MATCH", this, pattern, flag) 3422 3423 return self.func("REGEXP_MATCHES", this, pattern, flag)
3425 @unsupported_args("ins_cost", "del_cost", "sub_cost") 3426 def levenshtein_sql(self, expression: exp.Levenshtein) -> str: 3427 this = expression.this 3428 expr = expression.expression 3429 max_dist = expression.args.get("max_dist") 3430 3431 if max_dist is None: 3432 return self.func("LEVENSHTEIN", this, expr) 3433 3434 # Emulate Snowflake semantics: if distance > max_dist, return max_dist 3435 levenshtein = exp.Levenshtein(this=this, expression=expr) 3436 return self.sql(exp.Least(this=levenshtein, expressions=[max_dist]))
3438 def pad_sql(self, expression: exp.Pad) -> str: 3439 """ 3440 Handle RPAD/LPAD for VARCHAR and BINARY types. 3441 3442 For VARCHAR: Delegate to parent class 3443 For BINARY: Lower to: input || REPEAT(pad, GREATEST(0, target_len - OCTET_LENGTH(input))) 3444 """ 3445 string_arg = expression.this 3446 fill_arg = expression.args.get("fill_pattern") or exp.Literal.string(" ") 3447 3448 if _is_binary(string_arg) or _is_binary(fill_arg): 3449 length_arg = expression.expression 3450 is_left = expression.args.get("is_left") 3451 3452 input_len = exp.ByteLength(this=string_arg) 3453 chars_needed = length_arg - input_len 3454 pad_count = exp.Greatest( 3455 this=exp.Literal.number(0), expressions=[chars_needed], ignore_nulls=True 3456 ) 3457 repeat_expr = exp.Repeat(this=fill_arg, times=pad_count) 3458 3459 left, right = string_arg, repeat_expr 3460 if is_left: 3461 left, right = right, left 3462 3463 result = exp.DPipe(this=left, expression=right) 3464 return self.sql(result) 3465 3466 # For VARCHAR: Delegate to parent class (handles PAD_FILL_PATTERN_IS_REQUIRED) 3467 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)))
3469 def minhash_sql(self, expression: exp.Minhash) -> str: 3470 k = expression.this 3471 exprs = expression.expressions 3472 3473 if len(exprs) != 1 or isinstance(exprs[0], exp.Star): 3474 self.unsupported( 3475 "MINHASH with multiple expressions or * requires manual query restructuring" 3476 ) 3477 return self.func("MINHASH", k, *exprs) 3478 3479 expr = exprs[0] 3480 result = exp.replace_placeholders(self.MINHASH_TEMPLATE.copy(), expr=expr, k=k) 3481 return f"({self.sql(result)})"
3501 def arrayconcatagg_sql(self, expression: exp.ArrayConcatAgg) -> str: 3502 this = expression.this 3503 3504 if isinstance(this, exp.Limit): 3505 self.unsupported("LIMIT in ARRAY_CONCAT_AGG cannot be transpiled to DuckDB") 3506 this = this.this 3507 3508 inner = this.this if isinstance(this, exp.Order) else this 3509 3510 return self.func( 3511 "FLATTEN", 3512 exp.Filter( 3513 this=exp.ArrayAgg(this=this), 3514 expression=exp.Where(this=inner.copy().is_(exp.null()).not_()), 3515 ), 3516 )
3522 def arraydistinct_sql(self, expression: exp.ArrayDistinct) -> str: 3523 arr = expression.this 3524 func = self.func("LIST_DISTINCT", arr) 3525 3526 if expression.args.get("check_null"): 3527 add_null_to_array = exp.func( 3528 "LIST_APPEND", exp.func("LIST_DISTINCT", exp.ArrayCompact(this=arr)), exp.Null() 3529 ) 3530 return self.sql( 3531 exp.If( 3532 this=exp.NEQ( 3533 this=exp.ArraySize(this=arr), expression=exp.func("LIST_COUNT", arr) 3534 ), 3535 true=add_null_to_array, 3536 false=func, 3537 ) 3538 ) 3539 3540 return func
3542 def arrayintersect_sql(self, expression: exp.ArrayIntersect) -> str: 3543 if expression.args.get("is_multiset") and len(expression.expressions) == 2: 3544 return self._array_bag_sql( 3545 self.ARRAY_INTERSECTION_CONDITION, 3546 expression.expressions[0], 3547 expression.expressions[1], 3548 ) 3549 return self.function_fallback_sql(expression)
3551 def arrayexcept_sql(self, expression: exp.ArrayExcept) -> str: 3552 arr1, arr2 = expression.this, expression.expression 3553 if expression.args.get("is_multiset"): 3554 return self._array_bag_sql(self.ARRAY_EXCEPT_CONDITION, arr1, arr2) 3555 return self.sql( 3556 exp.replace_placeholders(self.ARRAY_EXCEPT_SET_TEMPLATE, arr1=arr1, arr2=arr2) 3557 )
3559 def arrayslice_sql(self, expression: exp.ArraySlice) -> str: 3560 """ 3561 Transpiles Snowflake's ARRAY_SLICE (0-indexed, exclusive end) to DuckDB's 3562 ARRAY_SLICE (1-indexed, inclusive end) by wrapping start and end in CASE 3563 expressions that adjust the index at query time: 3564 - start: CASE WHEN start >= 0 THEN start + 1 ELSE start END 3565 - end: CASE WHEN end < 0 THEN end - 1 ELSE end END 3566 """ 3567 start, end = expression.args.get("start"), expression.args.get("end") 3568 3569 if expression.args.get("zero_based"): 3570 if start is not None: 3571 start = ( 3572 exp.case() 3573 .when( 3574 exp.GTE(this=start.copy(), expression=exp.Literal.number(0)), 3575 exp.Add(this=start.copy(), expression=exp.Literal.number(1)), 3576 ) 3577 .else_(start) 3578 ) 3579 if end is not None: 3580 end = ( 3581 exp.case() 3582 .when( 3583 exp.LT(this=end.copy(), expression=exp.Literal.number(0)), 3584 exp.Sub(this=end.copy(), expression=exp.Literal.number(1)), 3585 ) 3586 .else_(end) 3587 ) 3588 3589 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
3591 def arrayszip_sql(self, expression: exp.ArraysZip) -> str: 3592 args = expression.expressions 3593 3594 if not args: 3595 # Return [{}] - using MAP([], []) since DuckDB can't represent empty structs 3596 return self.sql(exp.array(exp.Map(keys=exp.array(), values=exp.array()))) 3597 3598 # Build placeholder values for template 3599 lengths = [exp.Length(this=arg) for arg in args] 3600 max_len = ( 3601 lengths[0] 3602 if len(lengths) == 1 3603 else exp.Greatest(this=lengths[0], expressions=lengths[1:]) 3604 ) 3605 3606 # Empty struct with same schema: {'$1': NULL, '$2': NULL, ...} 3607 empty_struct = exp.func( 3608 "STRUCT", 3609 *[ 3610 exp.PropertyEQ(this=exp.Literal.string(f"${i + 1}"), expression=exp.Null()) 3611 for i in range(len(args)) 3612 ], 3613 ) 3614 3615 # Struct for transform: {'$1': COALESCE(arr1, [])[__i + 1], ...} 3616 # COALESCE wrapping handles NULL arrays - prevents invalid NULL[i] syntax 3617 index = exp.column("__i") + 1 3618 transform_struct = exp.func( 3619 "STRUCT", 3620 *[ 3621 exp.PropertyEQ( 3622 this=exp.Literal.string(f"${i + 1}"), 3623 expression=exp.func("COALESCE", arg, exp.array())[index], 3624 ) 3625 for i, arg in enumerate(args) 3626 ], 3627 ) 3628 3629 result = exp.replace_placeholders( 3630 self.ARRAYS_ZIP_TEMPLATE.copy(), 3631 null_check=exp.or_(*[arg.is_(exp.Null()) for arg in args]), 3632 all_empty_check=exp.and_( 3633 *[ 3634 exp.EQ(this=exp.Length(this=arg), expression=exp.Literal.number(0)) 3635 for arg in args 3636 ] 3637 ), 3638 empty_struct=empty_struct, 3639 max_len=max_len, 3640 transform_struct=transform_struct, 3641 ) 3642 return self.sql(result)
3689 def stuff_sql(self, expression: exp.Stuff) -> str: 3690 base = expression.this 3691 start = expression.args["start"] 3692 length = expression.args["length"] 3693 insertion = expression.expression 3694 is_binary = _is_binary(base) 3695 3696 if is_binary: 3697 # DuckDB's SUBSTRING doesn't accept BLOB; operate on the HEX string instead 3698 # (each byte = 2 hex chars), then UNHEX back to BLOB 3699 base = exp.Hex(this=base) 3700 insertion = exp.Hex(this=insertion) 3701 left = exp.Substring( 3702 this=base.copy(), 3703 start=exp.Literal.number(1), 3704 length=(start.copy() - exp.Literal.number(1)) * exp.Literal.number(2), 3705 ) 3706 right = exp.Substring( 3707 this=base.copy(), 3708 start=((start + length) - exp.Literal.number(1)) * exp.Literal.number(2) 3709 + exp.Literal.number(1), 3710 ) 3711 else: 3712 left = exp.Substring( 3713 this=base.copy(), 3714 start=exp.Literal.number(1), 3715 length=start.copy() - exp.Literal.number(1), 3716 ) 3717 right = exp.Substring(this=base.copy(), start=start + length) 3718 result: exp.Expr = exp.DPipe( 3719 this=exp.DPipe(this=left, expression=insertion), expression=right 3720 ) 3721 3722 if is_binary: 3723 result = exp.Unhex(this=result) 3724 3725 return self.sql(result)
3727 def rand_sql(self, expression: exp.Rand) -> str: 3728 seed = expression.this 3729 if seed is not None: 3730 self.unsupported("RANDOM with seed is not supported in DuckDB") 3731 3732 lower = expression.args.get("lower") 3733 upper = expression.args.get("upper") 3734 3735 if lower and upper: 3736 # scale DuckDB's [0,1) to the specified range 3737 range_size = exp.paren(upper - lower) 3738 scaled = exp.Add(this=lower, expression=exp.func("random") * range_size) 3739 3740 # For now we assume that if bounds are set, return type is BIGINT. Snowflake/Teradata 3741 result = exp.cast(scaled, exp.DType.BIGINT) 3742 return self.sql(result) 3743 3744 # Default DuckDB behavior - just return RANDOM() as float 3745 return "RANDOM()"
3747 def bytelength_sql(self, expression: exp.ByteLength) -> str: 3748 arg = expression.this 3749 3750 # Check if it's a text type (handles both literals and annotated expressions) 3751 if arg.is_type(*exp.DataType.TEXT_TYPES): 3752 return self.func("OCTET_LENGTH", exp.Encode(this=arg)) 3753 3754 # Default: pass through as-is (conservative for DuckDB, handles binary and unannotated) 3755 return self.func("OCTET_LENGTH", arg)
3757 def base64encode_sql(self, expression: exp.Base64Encode) -> str: 3758 # DuckDB TO_BASE64 requires BLOB input 3759 # Snowflake BASE64_ENCODE accepts both VARCHAR and BINARY - for VARCHAR it implicitly 3760 # encodes UTF-8 bytes. We add ENCODE unless the input is a binary type. 3761 result = expression.this 3762 3763 # Check if input is a string type - ENCODE only accepts VARCHAR 3764 if result.is_type(*exp.DataType.TEXT_TYPES): 3765 result = exp.Encode(this=result) 3766 3767 result = exp.ToBase64(this=result) 3768 3769 max_line_length = expression.args.get("max_line_length") 3770 alphabet = expression.args.get("alphabet") 3771 3772 # Handle custom alphabet by replacing standard chars with custom ones 3773 result = _apply_base64_alphabet_replacements(result, alphabet) 3774 3775 # Handle max_line_length by inserting newlines every N characters 3776 line_length = ( 3777 t.cast(int, max_line_length.to_py()) 3778 if isinstance(max_line_length, exp.Literal) and max_line_length.is_number 3779 else 0 3780 ) 3781 if line_length > 0: 3782 newline = exp.Chr(expressions=[exp.Literal.number(10)]) 3783 result = exp.Trim( 3784 this=exp.RegexpReplace( 3785 this=result, 3786 expression=exp.Literal.string(f"(.{{{line_length}}})"), 3787 replacement=exp.Concat(expressions=[exp.Literal.string("\\1"), newline.copy()]), 3788 ), 3789 expression=newline, 3790 position="TRAILING", 3791 ) 3792 3793 return self.sql(result)
3795 def hex_sql(self, expression: exp.Hex) -> str: 3796 case = expression.args.get("case") 3797 3798 if not case: 3799 return self.func("HEX", expression.this) 3800 3801 hex_expr = exp.Hex(this=expression.this) 3802 return self.sql( 3803 exp.case() 3804 .when(case.is_(exp.null()), exp.null()) 3805 .when(case.copy().eq(0), exp.Lower(this=hex_expr.copy())) 3806 .else_(hex_expr) 3807 )
3809 def replace_sql(self, expression: exp.Replace) -> str: 3810 result_sql = self.func( 3811 "REPLACE", 3812 _cast_to_varchar(expression.this), 3813 _cast_to_varchar(expression.expression), 3814 _cast_to_varchar(expression.args.get("replacement")), 3815 ) 3816 return _gen_with_cast_to_blob(self, expression, result_sql)
3828 def objectinsert_sql(self, expression: exp.ObjectInsert) -> str: 3829 this = expression.this 3830 key = expression.args.get("key") 3831 key_sql = key.name if isinstance(key, exp.Expr) else "" 3832 value_sql = self.sql(expression, "value") 3833 3834 kv_sql = f"{key_sql} := {value_sql}" 3835 3836 # If the input struct is empty e.g. transpiling OBJECT_INSERT(OBJECT_CONSTRUCT(), key, value) from Snowflake 3837 # then we can generate STRUCT_PACK which will build it since STRUCT_INSERT({}, key := value) is not valid DuckDB 3838 if isinstance(this, exp.Struct) and not this.expressions: 3839 return self.func("STRUCT_PACK", kv_sql) 3840 3841 return self.func("STRUCT_INSERT", this, kv_sql)
3856 def mapdelete_sql(self, expression: exp.MapDelete) -> str: 3857 map_arg = expression.this 3858 keys_to_delete = expression.expressions 3859 3860 x_dot_key = exp.Dot(this=exp.to_identifier("x"), expression=exp.to_identifier("key")) 3861 3862 lambda_expr = exp.Lambda( 3863 this=exp.In(this=x_dot_key, expressions=keys_to_delete).not_(), 3864 expressions=[exp.to_identifier("x")], 3865 ) 3866 result = exp.func( 3867 "MAP_FROM_ENTRIES", 3868 exp.ArrayFilter(this=exp.func("MAP_ENTRIES", map_arg), expression=lambda_expr), 3869 ) 3870 return self.sql(result)
3872 def mappick_sql(self, expression: exp.MapPick) -> str: 3873 map_arg = expression.this 3874 keys_to_pick = expression.expressions 3875 3876 x_dot_key = exp.Dot(this=exp.to_identifier("x"), expression=exp.to_identifier("key")) 3877 3878 if len(keys_to_pick) == 1 and keys_to_pick[0].is_type(exp.DType.ARRAY): 3879 lambda_expr = exp.Lambda( 3880 this=exp.func("ARRAY_CONTAINS", keys_to_pick[0], x_dot_key), 3881 expressions=[exp.to_identifier("x")], 3882 ) 3883 else: 3884 lambda_expr = exp.Lambda( 3885 this=exp.In(this=x_dot_key, expressions=keys_to_pick), 3886 expressions=[exp.to_identifier("x")], 3887 ) 3888 3889 result = exp.func( 3890 "MAP_FROM_ENTRIES", 3891 exp.func("LIST_FILTER", exp.func("MAP_ENTRIES", map_arg), lambda_expr), 3892 ) 3893 return self.sql(result)
3898 @unsupported_args("update_flag") 3899 def mapinsert_sql(self, expression: exp.MapInsert) -> str: 3900 map_arg = expression.this 3901 key = expression.args.get("key") 3902 value = expression.args.get("value") 3903 3904 map_type = map_arg.type 3905 3906 if value is not None: 3907 if map_type and map_type.expressions and len(map_type.expressions) > 1: 3908 # Extract the value type from MAP(key_type, value_type) 3909 value_type = map_type.expressions[1] 3910 # Cast value to match the map's value type to avoid type conflicts 3911 value = exp.cast(value, value_type) 3912 # else: polymorphic MAP case - no type parameters available, use value as-is 3913 3914 # Create a single-entry map for the new key-value pair 3915 new_entry_struct = exp.Struct(expressions=[exp.PropertyEQ(this=key, expression=value)]) 3916 new_entry: exp.Expression = exp.ToMap(this=new_entry_struct) 3917 3918 # Use MAP_CONCAT to merge the original map with the new entry 3919 # This automatically handles both insert and update cases 3920 result = exp.func("MAP_CONCAT", map_arg, new_entry) 3921 3922 return self.sql(result)
3940 def tablefromrows_sql(self, expression: exp.TableFromRows) -> str: 3941 # For GENERATOR, unwrap TABLE() - just emit the Generator (becomes RANGE) 3942 if isinstance(expression.this, exp.Generator): 3943 # Preserve alias, joins, and other table-level args 3944 table = exp.Table( 3945 this=expression.this, 3946 alias=expression.args.get("alias"), 3947 joins=expression.args.get("joins"), 3948 ) 3949 return self.sql(table) 3950 3951 return super().tablefromrows_sql(expression)
3953 def unnest_sql(self, expression: exp.Unnest) -> str: 3954 explode_array = expression.args.get("explode_array") 3955 if explode_array: 3956 # In BigQuery, UNNESTing a nested array leads to explosion of the top-level array & struct 3957 # This is transpiled to DDB by transforming "FROM UNNEST(...)" to "FROM (SELECT UNNEST(..., max_depth => 2))" 3958 expression.expressions.append( 3959 exp.Kwarg(this=exp.var("max_depth"), expression=exp.Literal.number(2)) 3960 ) 3961 3962 # If BQ's UNNEST is aliased, we transform it from a column alias to a table alias in DDB 3963 alias = expression.args.get("alias") 3964 if isinstance(alias, exp.TableAlias): 3965 expression.set("alias", None) 3966 if alias.columns: 3967 alias = exp.TableAlias(this=seq_get(alias.columns, 0)) 3968 3969 unnest_sql = super().unnest_sql(expression) 3970 select = exp.Select(expressions=[unnest_sql]).subquery(alias) 3971 return self.sql(select) 3972 3973 return super().unnest_sql(expression)
3981 def ignorenulls_sql(self, expression: exp.IgnoreNulls) -> str: 3982 this = expression.this 3983 3984 if isinstance(this, self.IGNORE_RESPECT_NULLS_WINDOW_FUNCTIONS): 3985 # DuckDB should render IGNORE NULLS only for the general-purpose 3986 # window functions that accept it e.g. FIRST_VALUE(... IGNORE NULLS) OVER (...) 3987 return super().ignorenulls_sql(expression) 3988 3989 # For ARRAY_AGG(expr IGNORE NULLS ...), convert IGNORE NULLS to a 3990 # FILTER(WHERE expr IS NOT NULL) clause by setting nulls_excluded on 3991 # the ArrayAgg. The existing _add_arrayagg_null_filter method will 3992 # emit the FILTER clause during arrayagg_sql / withingroup_sql. 3993 if isinstance(this, exp.ArrayAgg): 3994 this.set("nulls_excluded", True) 3995 return self.sql(this) 3996 3997 if isinstance(this, exp.First): 3998 this = exp.AnyValue(this=this.this) 3999 4000 if not isinstance(this, (exp.AnyValue, exp.ApproxQuantiles)): 4001 self.unsupported("IGNORE NULLS is not supported for non-window functions.") 4002 4003 return self.sql(this)
4005 def split_sql(self, expression: exp.Split) -> str: 4006 base_func = exp.func("STR_SPLIT", expression.this, expression.expression) 4007 4008 case_expr = exp.case().else_(base_func) 4009 needs_case = False 4010 4011 if expression.args.get("null_returns_null"): 4012 case_expr = case_expr.when(expression.expression.is_(exp.null()), exp.null()) 4013 needs_case = True 4014 4015 if expression.args.get("empty_delimiter_returns_whole"): 4016 # When delimiter is empty string, return input string as single array element 4017 array_with_input = exp.array(expression.this) 4018 case_expr = case_expr.when( 4019 expression.expression.eq(exp.Literal.string("")), array_with_input 4020 ) 4021 needs_case = True 4022 4023 return self.sql(case_expr if needs_case else base_func)
4025 def splitpart_sql(self, expression: exp.SplitPart) -> str: 4026 string_arg = expression.this 4027 delimiter_arg = expression.args.get("delimiter") 4028 part_index_arg = expression.args.get("part_index") 4029 4030 if delimiter_arg and part_index_arg: 4031 # Handle Snowflake's "index 0 and 1 both return first element" behavior 4032 if expression.args.get("part_index_zero_as_one"): 4033 # Convert 0 to 1 for compatibility 4034 4035 part_index_arg = exp.Paren( 4036 this=exp.case() 4037 .when(part_index_arg.eq(exp.Literal.number("0")), exp.Literal.number("1")) 4038 .else_(part_index_arg) 4039 ) 4040 4041 # Use Anonymous to avoid recursion 4042 base_func_expr: exp.Expr = exp.Anonymous( 4043 this="SPLIT_PART", expressions=[string_arg, delimiter_arg, part_index_arg] 4044 ) 4045 needs_case_transform = False 4046 case_expr = exp.case().else_(base_func_expr) 4047 4048 if expression.args.get("empty_delimiter_returns_whole"): 4049 # When delimiter is empty string: 4050 # - Return whole string if part_index is 1 or -1 4051 # - Return empty string otherwise 4052 empty_case = exp.Paren( 4053 this=exp.case() 4054 .when( 4055 exp.or_( 4056 part_index_arg.eq(exp.Literal.number("1")), 4057 part_index_arg.eq(exp.Literal.number("-1")), 4058 ), 4059 string_arg, 4060 ) 4061 .else_(exp.Literal.string("")) 4062 ) 4063 4064 case_expr = case_expr.when(delimiter_arg.eq(exp.Literal.string("")), empty_case) 4065 needs_case_transform = True 4066 4067 """ 4068 Output looks something like this: 4069 4070 CASE 4071 WHEN delimiter is '' THEN 4072 ( 4073 CASE 4074 WHEN adjusted_part_index = 1 OR adjusted_part_index = -1 THEN input 4075 ELSE '' END 4076 ) 4077 ELSE SPLIT_PART(input, delimiter, adjusted_part_index) 4078 END 4079 4080 """ 4081 return self.sql(case_expr if needs_case_transform else base_func_expr) 4082 4083 return self.function_fallback_sql(expression)
4085 def respectnulls_sql(self, expression: exp.RespectNulls) -> str: 4086 if isinstance(expression.this, self.IGNORE_RESPECT_NULLS_WINDOW_FUNCTIONS): 4087 # DuckDB should render RESPECT NULLS only for the general-purpose 4088 # window functions that accept it e.g. FIRST_VALUE(... RESPECT NULLS) OVER (...) 4089 return super().respectnulls_sql(expression) 4090 4091 self.unsupported("RESPECT NULLS is not supported for non-window functions.") 4092 return self.sql(expression, "this")
4094 def arraytostring_sql(self, expression: exp.ArrayToString) -> str: 4095 null = expression.args.get("null") 4096 4097 if expression.args.get("null_is_empty"): 4098 x = exp.to_identifier("x") 4099 list_transform = exp.Transform( 4100 this=expression.this.copy(), 4101 expression=exp.Lambda( 4102 this=exp.Coalesce( 4103 this=exp.cast(x, "TEXT"), expressions=[exp.Literal.string("")] 4104 ), 4105 expressions=[x], 4106 ), 4107 ) 4108 array_to_string = exp.ArrayToString( 4109 this=list_transform, expression=expression.expression 4110 ) 4111 if expression.args.get("null_delim_is_null"): 4112 return self.sql( 4113 exp.case() 4114 .when(expression.expression.copy().is_(exp.null()), exp.null()) 4115 .else_(array_to_string) 4116 ) 4117 return self.sql(array_to_string) 4118 4119 if null: 4120 x = exp.to_identifier("x") 4121 return self.sql( 4122 exp.ArrayToString( 4123 this=exp.Transform( 4124 this=expression.this, 4125 expression=exp.Lambda( 4126 this=exp.Coalesce(this=x, expressions=[null]), 4127 expressions=[x], 4128 ), 4129 ), 4130 expression=expression.expression, 4131 ) 4132 ) 4133 4134 return self.func("ARRAY_TO_STRING", expression.this, expression.expression)
4136 def concatws_sql(self, expression: exp.ConcatWs) -> str: 4137 # DuckDB-specific: handle binary types using DPipe (||) operator 4138 separator = seq_get(expression.expressions, 0) 4139 args = expression.expressions[1:] 4140 4141 if any(_is_binary(arg) for arg in [separator, *args]): 4142 result = args[0] 4143 for arg in args[1:]: 4144 result = exp.DPipe( 4145 this=exp.DPipe(this=result, expression=separator), expression=arg 4146 ) 4147 return self.sql(result) 4148 4149 return super().concatws_sql(expression)
4210 def regexpinstr_sql(self, expression: exp.RegexpInstr) -> str: 4211 this = expression.this 4212 pattern = expression.expression 4213 position = expression.args.get("position") 4214 orig_occ = expression.args.get("occurrence") 4215 occurrence = orig_occ or exp.Literal.number(1) 4216 option = expression.args.get("option") 4217 parameters = expression.args.get("parameters") 4218 4219 validated_flags = self._validate_regexp_flags(parameters, supported_flags="ims") 4220 if validated_flags: 4221 pattern = exp.Concat(expressions=[exp.Literal.string(f"(?{validated_flags})"), pattern]) 4222 4223 # Handle starting position offset 4224 pos_offset: exp.Expr = exp.Literal.number(0) 4225 if position and (not position.is_int or position.to_py() > 1): 4226 this = exp.Substring(this=this, start=position) 4227 pos_offset = position - exp.Literal.number(1) 4228 4229 # Helper: LIST_SUM(LIST_TRANSFORM(list[1:end], x -> LENGTH(x))) 4230 def sum_lengths(func_name: str, end: exp.Expr) -> exp.Expr: 4231 lst = exp.Bracket( 4232 this=exp.Anonymous(this=func_name, expressions=[this, pattern]), 4233 expressions=[exp.Slice(this=exp.Literal.number(1), expression=end)], 4234 offset=1, 4235 ) 4236 transform = exp.Anonymous( 4237 this="LIST_TRANSFORM", 4238 expressions=[ 4239 lst, 4240 exp.Lambda( 4241 this=exp.Length(this=exp.to_identifier("x")), 4242 expressions=[exp.to_identifier("x")], 4243 ), 4244 ], 4245 ) 4246 return exp.Coalesce( 4247 this=exp.Anonymous(this="LIST_SUM", expressions=[transform]), 4248 expressions=[exp.Literal.number(0)], 4249 ) 4250 4251 # Position = 1 + sum(split_lengths[1:occ]) + sum(match_lengths[1:occ-1]) + offset 4252 base_pos: exp.Expr = ( 4253 exp.Literal.number(1) 4254 + sum_lengths("STRING_SPLIT_REGEX", occurrence) 4255 + sum_lengths("REGEXP_EXTRACT_ALL", occurrence - exp.Literal.number(1)) 4256 + pos_offset 4257 ) 4258 4259 # option=1: add match length for end position 4260 if option and option.is_int and option.to_py() == 1: 4261 match_at_occ = exp.Bracket( 4262 this=exp.Anonymous(this="REGEXP_EXTRACT_ALL", expressions=[this, pattern]), 4263 expressions=[occurrence], 4264 offset=1, 4265 ) 4266 base_pos = base_pos + exp.Coalesce( 4267 this=exp.Length(this=match_at_occ), expressions=[exp.Literal.number(0)] 4268 ) 4269 4270 # NULL checks for all provided arguments 4271 # .copy() is used strictly because .is_() alters the node's parent pointer, mutating the parsed AST 4272 null_args = [ 4273 expression.this, 4274 expression.expression, 4275 position, 4276 orig_occ, 4277 option, 4278 parameters, 4279 ] 4280 null_checks = [arg.copy().is_(exp.Null()) for arg in null_args if arg] 4281 4282 matches = exp.Anonymous(this="REGEXP_EXTRACT_ALL", expressions=[this, pattern]) 4283 4284 return self.sql( 4285 exp.case() 4286 .when(exp.or_(*null_checks), exp.Null()) 4287 .when(pattern.copy().eq(exp.Literal.string("")), exp.Literal.number(0)) 4288 .when(exp.Length(this=matches) < occurrence, exp.Literal.number(0)) 4289 .else_(base_pos) 4290 )
4292 @unsupported_args("culture") 4293 def numbertostr_sql(self, expression: exp.NumberToStr) -> str: 4294 fmt = expression.args.get("format") 4295 if fmt and fmt.is_int: 4296 return self.func("FORMAT", f"'{{:,.{fmt.name}f}}'", expression.this) 4297 4298 self.unsupported("Only integer formats are supported by NumberToStr") 4299 return self.function_fallback_sql(expression)
4312 def posexplode_sql(self, expression: exp.Posexplode) -> str: 4313 this = expression.this 4314 parent = expression.parent 4315 4316 # The default Spark aliases are "pos" and "col", unless specified otherwise 4317 pos, col = exp.to_identifier("pos"), exp.to_identifier("col") 4318 4319 if isinstance(parent, exp.Aliases): 4320 # Column case: SELECT POSEXPLODE(col) [AS (a, b)] 4321 pos, col = parent.expressions 4322 elif isinstance(parent, exp.Table): 4323 # Table case: SELECT * FROM POSEXPLODE(col) [AS (a, b)] 4324 alias = parent.args.get("alias") 4325 if alias: 4326 pos, col = alias.columns or [pos, col] 4327 alias.pop() 4328 4329 # Translate POSEXPLODE to UNNEST + GENERATE_SUBSCRIPTS 4330 # Note: In Spark pos is 0-indexed, but in DuckDB it's 1-indexed, so we subtract 1 from GENERATE_SUBSCRIPTS 4331 unnest_sql = self.sql(exp.Unnest(expressions=[this], alias=col)) 4332 gen_subscripts = self.sql( 4333 exp.Alias( 4334 this=exp.Anonymous( 4335 this="GENERATE_SUBSCRIPTS", expressions=[this, exp.Literal.number(1)] 4336 ) 4337 - exp.Literal.number(1), 4338 alias=pos, 4339 ) 4340 ) 4341 4342 posexplode_sql = self.format_args(gen_subscripts, unnest_sql) 4343 4344 if isinstance(parent, exp.From) or (parent and isinstance(parent.parent, exp.From)): 4345 # SELECT * FROM POSEXPLODE(col) -> SELECT * FROM (SELECT GENERATE_SUBSCRIPTS(...), UNNEST(...)) 4346 return self.sql(exp.Subquery(this=exp.Select(expressions=[posexplode_sql]))) 4347 4348 return posexplode_sql
4350 def addmonths_sql(self, expression: exp.AddMonths) -> str: 4351 """ 4352 Handles three key issues: 4353 1. Float/decimal months: e.g., Snowflake rounds, whereas DuckDB INTERVAL requires integers 4354 2. End-of-month preservation: If input is last day of month, result is last day of result month 4355 3. Type preservation: Maintains DATE/TIMESTAMPTZ types (DuckDB defaults to TIMESTAMP) 4356 """ 4357 from sqlglot.optimizer.annotate_types import annotate_types 4358 4359 this = expression.this 4360 if not this.type: 4361 this = annotate_types(this, dialect=self.dialect) 4362 4363 if this.is_type(*exp.DataType.TEXT_TYPES): 4364 this = exp.Cast(this=this, to=exp.DataType(this=exp.DType.TIMESTAMP)) 4365 4366 # Detect float/decimal months to apply rounding (Snowflake behavior) 4367 # DuckDB INTERVAL syntax doesn't support non-integer expressions, so use TO_MONTHS 4368 months_expr = expression.expression 4369 if not months_expr.type: 4370 months_expr = annotate_types(months_expr, dialect=self.dialect) 4371 4372 # Build interval or to_months expression based on type 4373 # Float/decimal case: Round and use TO_MONTHS(CAST(ROUND(value) AS INT)) 4374 interval_or_to_months = ( 4375 exp.func("TO_MONTHS", exp.cast(exp.func("ROUND", months_expr), "INT")) 4376 if months_expr.is_type( 4377 exp.DType.FLOAT, 4378 exp.DType.DOUBLE, 4379 exp.DType.DECIMAL, 4380 ) 4381 # Integer case: standard INTERVAL N MONTH syntax 4382 else exp.Interval(this=months_expr, unit=exp.var("MONTH")) 4383 ) 4384 4385 date_add_expr = exp.Add(this=this, expression=interval_or_to_months) 4386 4387 # Apply end-of-month preservation if Snowflake flag is set 4388 # CASE WHEN LAST_DAY(date) = date THEN LAST_DAY(result) ELSE result END 4389 preserve_eom = expression.args.get("preserve_end_of_month") 4390 result_expr = ( 4391 exp.case() 4392 .when( 4393 exp.EQ(this=exp.func("LAST_DAY", this), expression=this), 4394 exp.func("LAST_DAY", date_add_expr), 4395 ) 4396 .else_(date_add_expr) 4397 if preserve_eom 4398 else date_add_expr 4399 ) 4400 4401 # DuckDB's DATE_ADD function returns TIMESTAMP/DATETIME by default, even when the input is DATE 4402 # To match for example Snowflake's ADD_MONTHS behavior (which preserves the input type) 4403 # We need to cast the result back to the original type when the input is DATE or TIMESTAMPTZ 4404 # Example: ADD_MONTHS('2023-01-31'::date, 1) should return DATE, not TIMESTAMP 4405 if this.is_type(exp.DType.DATE, exp.DType.TIMESTAMPTZ): 4406 return self.sql(exp.Cast(this=result_expr, to=this.type)) 4407 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)
4421 def datetrunc_sql(self, expression: exp.DateTrunc) -> str: 4422 unit = expression.args.get("unit") 4423 date = expression.this 4424 4425 week_start = _week_trunc_start_dow(unit) 4426 unit = unit_to_str(expression) 4427 4428 if week_start: 4429 result = self.sql( 4430 _build_week_trunc_expression(date, week_start, preserve_start_day=True) 4431 ) 4432 else: 4433 result = self.func("DATE_TRUNC", unit, date) 4434 4435 if ( 4436 expression.args.get("input_type_preserved") 4437 and date.is_type(*exp.DataType.TEMPORAL_TYPES) 4438 and not (is_date_unit(unit) and date.is_type(exp.DType.DATE)) 4439 ): 4440 return self.sql(exp.Cast(this=result, to=date.type)) 4441 4442 return result
4444 def datetimetrunc_sql(self, expression: exp.DatetimeTrunc) -> str: 4445 this = exp.cast(expression.this, exp.DType.DATETIME) 4446 week_start = _week_trunc_start_dow(expression.args.get("unit")) 4447 if week_start: 4448 return self.sql( 4449 _build_week_trunc_expression( 4450 this, week_start, preserve_start_day=True, cast_to_date=False 4451 ) 4452 ) 4453 4454 return self.func("DATE_TRUNC", unit_to_str(expression), this)
4456 def timestamptrunc_sql(self, expression: exp.TimestampTrunc) -> str: 4457 zone = expression.args.get("zone") 4458 timestamp = expression.this 4459 week_start = _week_trunc_start_dow(expression.args.get("unit")) 4460 4461 # The week start emulation below is exact, so avoid weekstart_unit_to_str's degrade warning 4462 unit = unit_to_str(expression) if week_start else weekstart_unit_to_str(self, expression) 4463 date_unit = is_date_unit(unit) or bool(week_start) 4464 4465 def _trunc_expr(this: exp.Expr) -> exp.Expr: 4466 if week_start: 4467 return _build_week_trunc_expression( 4468 this, week_start, preserve_start_day=True, cast_to_date=False 4469 ) 4470 return exp.func("DATE_TRUNC", unit, this) 4471 4472 if date_unit and zone: 4473 # BigQuery's TIMESTAMP_TRUNC with timezone truncates in the target timezone and returns as UTC. 4474 # Double AT TIME ZONE needed for BigQuery compatibility: 4475 # 1. First AT TIME ZONE: ensures truncation happens in the target timezone 4476 # 2. Second AT TIME ZONE: converts the DATE result back to TIMESTAMPTZ (preserving time component) 4477 timestamp = exp.AtTimeZone(this=timestamp, zone=zone) 4478 trunced = _trunc_expr(timestamp) 4479 if isinstance(trunced, exp.DateAdd): 4480 # Parenthesize so the trailing AT TIME ZONE binds to the whole shifted expression 4481 trunced = exp.Paren(this=trunced) 4482 return self.sql(exp.AtTimeZone(this=trunced, zone=zone)) 4483 4484 result = self.sql(_trunc_expr(timestamp)) 4485 if expression.args.get("input_type_preserved"): 4486 if timestamp.type and timestamp.is_type(exp.DType.TIME, exp.DType.TIMETZ): 4487 dummy_date = exp.Cast( 4488 this=exp.Literal.string("1970-01-01"), 4489 to=exp.DataType(this=exp.DType.DATE), 4490 ) 4491 date_time = exp.Add(this=dummy_date, expression=timestamp) 4492 result = self.func("DATE_TRUNC", unit, date_time) 4493 return self.sql(exp.Cast(this=result, to=timestamp.type)) 4494 4495 if timestamp.is_type(*exp.DataType.TEMPORAL_TYPES) and not ( 4496 date_unit and timestamp.is_type(exp.DType.DATE) 4497 ): 4498 return self.sql(exp.Cast(this=result, to=timestamp.type)) 4499 4500 return result
4502 def trim_sql(self, expression: exp.Trim) -> str: 4503 expression.this.replace(_cast_to_varchar(expression.this)) 4504 if expression.expression: 4505 expression.expression.replace(_cast_to_varchar(expression.expression)) 4506 4507 result_sql = super().trim_sql(expression) 4508 return _gen_with_cast_to_blob(self, expression, result_sql)
4510 def round_sql(self, expression: exp.Round) -> str: 4511 this = expression.this 4512 decimals = expression.args.get("decimals") 4513 truncate = expression.args.get("truncate") 4514 4515 # DuckDB requires the scale (decimals) argument to be an INT 4516 # Some dialects (e.g., Snowflake) allow non-integer scales and cast to an integer internally 4517 if decimals is not None and expression.args.get("casts_non_integer_decimals"): 4518 if not (decimals.is_int or decimals.is_type(*exp.DataType.INTEGER_TYPES)): 4519 decimals = exp.cast(decimals, exp.DType.INT) 4520 4521 func = "ROUND" 4522 if truncate: 4523 # BigQuery uses ROUND_HALF_EVEN; Snowflake uses HALF_TO_EVEN 4524 if truncate.this in ("ROUND_HALF_EVEN", "HALF_TO_EVEN"): 4525 func = "ROUND_EVEN" 4526 truncate = None 4527 # BigQuery uses ROUND_HALF_AWAY_FROM_ZERO; Snowflake uses HALF_AWAY_FROM_ZERO 4528 elif truncate.this in ("ROUND_HALF_AWAY_FROM_ZERO", "HALF_AWAY_FROM_ZERO"): 4529 truncate = None 4530 4531 return self.func(func, this, decimals, truncate)
4533 def trycast_sql(self, expression: exp.TryCast) -> str: 4534 to = expression.to 4535 to_type = to.this 4536 src = expression.this 4537 4538 if ( 4539 expression.args.get("null_on_text_overflow") 4540 and to_type in exp.DataType.TEXT_TYPES 4541 and to.expressions 4542 ): 4543 return self.sql( 4544 exp.case() 4545 .when( 4546 exp.LTE(this=exp.func("LENGTH", src), expression=to.expressions[0].this), 4547 exp.cast(src, "TEXT"), 4548 ) 4549 .else_(exp.Null()) 4550 ) 4551 elif to_type == exp.DType.DATE and expression.args.get("probe_date_format"): 4552 slash_strptime = exp.cast( 4553 exp.func("TRY_STRPTIME", src, exp.Literal.string(self._TRYCAST_DATE_SLASH_FMT)), 4554 "DATE", 4555 ) 4556 mon_strptime = exp.cast( 4557 exp.func("TRY_STRPTIME", src, exp.Literal.string(self._TRYCAST_DATE_MON_FMT)), 4558 "DATE", 4559 ) 4560 return self.sql( 4561 exp.case() 4562 .when(exp.func("CONTAINS", src, exp.Literal.string("/")), slash_strptime) 4563 .when( 4564 exp.RegexpLike(this=src, expression=exp.Literal.string("[A-Za-z]")), 4565 mon_strptime, 4566 ) 4567 .else_(exp.TryCast(this=src, to=to)) 4568 ) 4569 elif ( 4570 isinstance(to_type, exp.Interval) 4571 and (unit := to_type.unit) 4572 and expression.args.get("requires_string") 4573 ): 4574 interval_type = exp.DataType.build("INTERVAL") 4575 if isinstance(unit, exp.IntervalSpan): 4576 self.unsupported( 4577 "TRY_CAST to INTERVAL with span (e.g. HOUR TO MINUTE) is not supported in DuckDB" 4578 ) 4579 return self.sql(exp.TryCast(this=src, to=interval_type)) 4580 return self.sql( 4581 exp.TryCast( 4582 this=exp.DPipe(this=src, expression=exp.Literal.string(f" {unit.name}")), 4583 to=interval_type, 4584 ) 4585 ) 4586 4587 return super().trycast_sql(expression)
4589 def strtok_sql(self, expression: exp.Strtok) -> str: 4590 string_arg = expression.this 4591 delimiter_arg = expression.args.get("delimiter") 4592 part_index_arg = expression.args.get("part_index") 4593 4594 if delimiter_arg and part_index_arg: 4595 # Escape regex chars and build character class at runtime using REGEXP_REPLACE 4596 escaped_delimiter = exp.Anonymous( 4597 this="REGEXP_REPLACE", 4598 expressions=[ 4599 delimiter_arg, 4600 exp.Literal.string( 4601 r"([\[\]^.\-*+?(){}|$\\])" 4602 ), # Escape problematic regex chars 4603 exp.Literal.string( 4604 r"\\\1" 4605 ), # Replace with escaped version using $1 backreference 4606 exp.Literal.string("g"), # Global flag 4607 ], 4608 ) 4609 # CASE WHEN delimiter = '' THEN '' ELSE CONCAT('[', escaped_delimiter, ']') END 4610 regex_pattern = ( 4611 exp.case() 4612 .when(delimiter_arg.eq(exp.Literal.string("")), exp.Literal.string("")) 4613 .else_( 4614 exp.func( 4615 "CONCAT", 4616 exp.Literal.string("["), 4617 escaped_delimiter, 4618 exp.Literal.string("]"), 4619 ) 4620 ) 4621 ) 4622 4623 # STRTOK skips empty strings, so we need to filter them out 4624 # LIST_FILTER(REGEXP_SPLIT_TO_ARRAY(string, pattern), x -> x != '')[index] 4625 split_array = exp.func("REGEXP_SPLIT_TO_ARRAY", string_arg, regex_pattern) 4626 x = exp.to_identifier("x") 4627 is_empty = x.eq(exp.Literal.string("")) 4628 filtered_array = exp.func( 4629 "LIST_FILTER", 4630 split_array, 4631 exp.Lambda(this=exp.not_(is_empty.copy()), expressions=[x.copy()]), 4632 ) 4633 base_func = exp.Bracket( 4634 this=filtered_array, 4635 expressions=[part_index_arg], 4636 offset=1, 4637 ) 4638 4639 # Use template with the built regex pattern 4640 result = exp.replace_placeholders( 4641 self.STRTOK_TEMPLATE.copy(), 4642 string=string_arg, 4643 delimiter=delimiter_arg, 4644 part_index=part_index_arg, 4645 base_func=base_func, 4646 ) 4647 4648 return self.sql(result) 4649 4650 return self.function_fallback_sql(expression)
4652 def strtoktoarray_sql(self, expression: exp.StrtokToArray) -> str: 4653 string_arg = expression.this 4654 delimiter_arg = expression.args.get("expression") or exp.Literal.string(" ") 4655 4656 escaped = exp.RegexpReplace( 4657 this=delimiter_arg.copy(), 4658 expression=exp.Literal.string(r"([\[\]^.\-*+?(){}|$\\])"), 4659 replacement=exp.Literal.string(r"\\\1"), 4660 modifiers=exp.Literal.string("g"), 4661 ) 4662 return self.sql( 4663 exp.replace_placeholders( 4664 self.STRTOK_TO_ARRAY_TEMPLATE.copy(), 4665 string=string_arg, 4666 delimiter=delimiter_arg, 4667 escaped=escaped, 4668 ) 4669 )
4671 def approxquantile_sql(self, expression: exp.ApproxQuantile) -> str: 4672 result = self.func("APPROX_QUANTILE", expression.this, expression.args.get("quantile")) 4673 4674 # DuckDB returns integers for APPROX_QUANTILE, cast to DOUBLE if the expected type is a real type 4675 if expression.is_type(*exp.DataType.REAL_TYPES): 4676 result = f"CAST({result} AS DOUBLE)" 4677 4678 return result
4680 def approxquantiles_sql(self, expression: exp.ApproxQuantiles) -> str: 4681 """ 4682 BigQuery's APPROX_QUANTILES(expr, n) returns an array of n+1 approximate quantile values 4683 dividing the input distribution into n equal-sized buckets. 4684 4685 Both BigQuery and DuckDB use approximate algorithms for quantile estimation, but BigQuery 4686 does not document the specific algorithm used so results may differ. DuckDB does not 4687 support RESPECT NULLS. 4688 """ 4689 this = expression.this 4690 if isinstance(this, exp.Distinct): 4691 # APPROX_QUANTILES requires 2 args and DISTINCT node grabs both 4692 if len(this.expressions) < 2: 4693 self.unsupported("APPROX_QUANTILES requires a bucket count argument") 4694 return self.function_fallback_sql(expression) 4695 num_quantiles_expr = this.expressions[1].pop() 4696 else: 4697 num_quantiles_expr = expression.expression 4698 4699 if not isinstance(num_quantiles_expr, exp.Literal) or not num_quantiles_expr.is_int: 4700 self.unsupported("APPROX_QUANTILES bucket count must be a positive integer") 4701 return self.function_fallback_sql(expression) 4702 4703 num_quantiles = t.cast(int, num_quantiles_expr.to_py()) 4704 if num_quantiles <= 0: 4705 self.unsupported("APPROX_QUANTILES bucket count must be a positive integer") 4706 return self.function_fallback_sql(expression) 4707 4708 quantiles = [ 4709 exp.Literal.number(Decimal(i) / Decimal(num_quantiles)) 4710 for i in range(num_quantiles + 1) 4711 ] 4712 4713 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.
4715 def jsonextractscalar_sql(self, expression: exp.JSONExtractScalar) -> str: 4716 if expression.args.get("scalar_only"): 4717 json_value = exp.JSONExtractScalar( 4718 this=rename_func("JSON_VALUE")(self, expression), expression="'$'" 4719 ) 4720 4721 # `->>` binds looser than most operators, so the wrap logic needs the parent 4722 json_value.parent = expression.parent 4723 expression = json_value 4724 4725 return _arrow_json_extract_sql(self, expression)
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
- SUPPORTS_GROUPING_SETS_AS_SUFFIX
- INDEX_ON
- INOUT_SEPARATOR
- DIRECTED_JOINS
- QUERY_HINT_SEP
- IS_BOOL_ALLOWED
- DUPLICATE_KEY_UPDATE_WITH_SET
- LIMIT_IS_TOP
- RETURNING_END
- EXTRACT_ALLOWS_QUOTES
- TZ_TO_WITH_TIME_ZONE
- VALUES_AS_TABLE
- ALTER_TABLE_INCLUDE_COLUMN_KEYWORD
- UNNEST_WITH_ORDINALITY
- COMPUTED_COLUMN_WITH_TYPE
- SUPPORTS_TABLE_COPY
- TABLESAMPLE_REQUIRES_PARENS
- TABLESAMPLE_SIZE_IS_ROWS
- TABLESAMPLE_WITH_METHOD
- COLLATE_IS_FUNC
- DATA_TYPE_SPECIFIERS_ALLOWED
- ENSURE_BOOLS
- CTE_RECURSIVE_KEYWORD_REQUIRED
- SUPPORTS_SINGLE_ARG_CONCAT
- SUPPORTS_TABLE_ALIAS_COLUMNS
- SUPPORTS_NAMED_CTE_COLUMNS
- UNPIVOT_ALIASES_ARE_IDENTIFIERS
- PIVOT_ALIAS_WITH_AS
- INSERT_OVERWRITE
- SUPPORTS_SELECT_INTO
- SUPPORTS_UNLOGGED_TABLES
- SUPPORTS_MODIFY_COLUMN
- SUPPORTS_CHANGE_COLUMN
- SUPPORTS_ALTER_COLUMN_NULLABILITY
- SUPPORTS_ALTER_COLUMN_IF_EXISTS
- LIKE_PROPERTY_INSIDE_SCHEMA
- JSON_TYPE_REQUIRED_FOR_EXTRACTION
- JSON_PATH_SINGLE_QUOTE_ESCAPE
- JSON_PATH_KEY_QUOTED_FORCES_BRACKETS
- SET_OP_MODIFIERS
- 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
- MOD_OPERATOR
- MOD_PAREN_PARENT_TYPES
- 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
- nthvalue_sql
- comprehension_sql
- columnprefix_sql
- opclass_sql
- predict_sql
- generateembedding_sql
- generatetext_sql
- generatetable_sql
- generatebool_sql
- generateint_sql
- generatedouble_sql
- mltranslate_sql
- mlforecast_sql
- aiforecast_sql
- featuresattime_sql
- vectorsearch_sql
- forin_sql
- refresh_sql
- toarray_sql
- tsordstotimestamp_sql
- tsordstodatetime_sql
- tsordstodate_sql
- unixdate_sql
- lastday_sql
- dateadd_sql
- 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
- casestatement_sql
- whileblock_sql
- loopblock_sql
- repeatblock_sql
- leave_sql
- iterate_sql
- execute_sql
- executesql_sql
- altermodifysqlsecurity_sql
- usingproperty_sql
- renameindex_sql