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