sqlglot.generators.duckdb
1from __future__ import annotations 2 3from decimal import Decimal 4from itertools import groupby 5import re 6import typing as t 7 8from sqlglot import exp, generator, transforms 9from sqlglot.dialects.dialect import ( 10 DATETIME_DELTA, 11 JSON_EXTRACT_TYPE, 12 approx_count_distinct_sql, 13 arrow_json_extract_sql, 14 array_append_sql, 15 array_compact_sql, 16 array_concat_sql, 17 count_if_to_sum, 18 date_delta_to_binary_interval_op, 19 datestrtodate_sql, 20 encode_decode_sql, 21 explode_to_unnest_sql, 22 generate_series_sql, 23 getbit_sql, 24 groupconcat_sql, 25 inline_array_unless_query, 26 months_between_sql, 27 no_datetime_sql, 28 no_comment_column_constraint_sql, 29 no_make_interval_sql, 30 no_time_sql, 31 no_timestamp_sql, 32 rename_func, 33 remove_from_array_using_filter, 34 strposition_sql, 35 timestrtotime_sql, 36 unit_to_str, 37 week_unit_to_dow, 38 weekstart_unit_to_str, 39 WEEK_START_DAY_TO_DOW, 40) 41from sqlglot.generator import unsupported_args 42from sqlglot.helper import find_new_name, is_date_unit, seq_get 43from sqlglot.optimizer.scope import find_all_in_scope 44from builtins import type as Type 45 46_CONNECT_BY_ARGS_TO_SKIP = frozenset({"connect", "where", "from_", "with_", "expressions"}) 47 48# Regex to detect time zones in timestamps of the form [+|-]TT[:tt] 49# The pattern matches timezone offsets that appear after the time portion 50TIMEZONE_PATTERN = re.compile(r":\d{2}.*?[+\-]\d{2}(?::\d{2})?") 51 52# Characters that must be escaped when building regex expressions in INITCAP 53REGEX_ESCAPE_REPLACEMENTS = { 54 "\\": "\\\\", 55 "-": r"\-", 56 "^": r"\^", 57 "[": r"\[", 58 "]": r"\]", 59} 60 61# Used to in RANDSTR transpilation 62RANDSTR_CHAR_POOL = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" 63RANDSTR_SEED = 123456 64 65# Whitespace control characters that DuckDB must process with `CHR({val})` calls 66WS_CONTROL_CHARS_TO_DUCK = { 67 "\u000b": 11, 68 "\u001c": 28, 69 "\u001d": 29, 70 "\u001e": 30, 71 "\u001f": 31, 72} 73 74MAX_BIT_POSITION = exp.Literal.number(32768) 75 76# cs/as/ps are Snowflake defaults; DuckDB already behaves the same way, so they are safe to drop. 77# Note: "as" is also a reserved keyword in DuckDB, making it impossible to pass through. 78_SNOWFLAKE_COLLATION_DEFAULTS = frozenset({"cs", "as", "ps"}) 79_SNOWFLAKE_COLLATION_UNSUPPORTED = frozenset( 80 {"ci", "ai", "upper", "lower", "utf8", "bin", "pi", "fl", "fu", "trim", "ltrim", "rtrim"} 81) 82 83# Window functions that support IGNORE/RESPECT NULLS in DuckDB 84_IGNORE_RESPECT_NULLS_WINDOW_FUNCTIONS = ( 85 exp.FirstValue, 86 exp.Lag, 87 exp.LastValue, 88 exp.Lead, 89 exp.NthValue, 90) 91 92# SEQ function constants 93_SEQ_BASE: exp.Expr = exp.maybe_parse("(ROW_NUMBER() OVER (ORDER BY 1) - 1)") 94_SEQ_RESTRICTED = (exp.Where, exp.Having, exp.AggFunc, exp.Order, exp.Select) 95# Maps SEQ expression types to their byte width (suffix indicates bytes: SEQ1=1, SEQ2=2, etc.) 96_SEQ_BYTE_WIDTH = {exp.Seq1: 1, exp.Seq2: 2, exp.Seq4: 4, exp.Seq8: 8} 97 98# Template for generating signed and unsigned SEQ values within a specified range 99_SEQ_UNSIGNED: exp.Expr = exp.maybe_parse(":base % :max_val") 100_SEQ_SIGNED: exp.Expr = exp.maybe_parse( 101 "(CASE WHEN :base % :max_val >= :half " 102 "THEN :base % :max_val - :max_val " 103 "ELSE :base % :max_val END)" 104) 105 106 107def _apply_base64_alphabet_replacements( 108 result: exp.Expr, 109 alphabet: exp.Expr | None, 110 reverse: bool = False, 111) -> exp.Expr: 112 """ 113 Apply base64 alphabet character replacements. 114 115 Base64 alphabet can be 1-3 chars: 1st = index 62 ('+'), 2nd = index 63 ('/'), 3rd = padding ('='). 116 zip truncates to the shorter string, so 1-char alphabet only replaces '+', 2-char replaces '+/', etc. 117 118 Args: 119 result: The expression to apply replacements to 120 alphabet: Custom alphabet literal (expected chars for +/=) 121 reverse: If False, replace default with custom (encode) 122 If True, replace custom with default (decode) 123 """ 124 if isinstance(alphabet, exp.Literal) and alphabet.is_string: 125 for default_char, new_char in zip("+/=", alphabet.this): 126 if new_char != default_char: 127 find, replace = (new_char, default_char) if reverse else (default_char, new_char) 128 result = exp.Replace( 129 this=result, 130 expression=exp.Literal.string(find), 131 replacement=exp.Literal.string(replace), 132 ) 133 return result 134 135 136def _base64_decode_sql(self: DuckDBGenerator, expression: exp.Expr, to_string: bool) -> str: 137 """ 138 Transpile Snowflake BASE64_DECODE_STRING/BINARY to DuckDB. 139 140 DuckDB uses FROM_BASE64() which returns BLOB. For string output, wrap with DECODE(). 141 Custom alphabets require REPLACE() calls to convert to standard base64. 142 """ 143 input_expr = expression.this 144 alphabet = expression.args.get("alphabet") 145 146 # Handle custom alphabet by replacing non-standard chars with standard ones 147 input_expr = _apply_base64_alphabet_replacements(input_expr, alphabet, reverse=True) 148 149 # FROM_BASE64 returns BLOB 150 input_expr = exp.FromBase64(this=input_expr) 151 152 if to_string: 153 input_expr = exp.Decode(this=input_expr) 154 155 return self.sql(input_expr) 156 157 158def _last_day_sql(self: DuckDBGenerator, expression: exp.LastDay) -> str: 159 """ 160 DuckDB's LAST_DAY only supports finding the last day of a month. 161 For other date parts (year, quarter, week), we need to implement equivalent logic. 162 """ 163 date_expr = expression.this 164 unit_expr = expression.args.get("unit") 165 166 week_start = week_unit_to_dow(unit_expr) 167 if week_start: 168 # The week's last day precedes its start day; DuckDB DAYOFWEEK: Sunday=0, ..., Saturday=6 169 last_dow = week_start - 1 170 dow = exp.func("EXTRACT", "DAYOFWEEK", date_expr) 171 172 # Days to the last day of week: (last_dow + 7 - dayofweek) % 7 173 days_to_last_expr = exp.Mod( 174 this=exp.Paren(this=exp.Sub(this=exp.Literal.number(last_dow + 7), expression=dow)), 175 expression=exp.Literal.number(7), 176 ) 177 interval_expr = exp.Interval(this=days_to_last_expr, unit=exp.var("DAY")) 178 add_expr = exp.Add(this=date_expr, expression=interval_expr) 179 180 return self.sql(exp.cast(add_expr, exp.DType.DATE)) 181 182 unit = expression.text("unit") 183 184 if not unit or unit.upper() == "MONTH": 185 # Default behavior - use DuckDB's native LAST_DAY 186 return self.func("LAST_DAY", date_expr) 187 188 if unit.upper() == "YEAR": 189 # Last day of year: December 31st of the same year 190 year_expr = exp.func("EXTRACT", "YEAR", date_expr) 191 make_date_expr = exp.func( 192 "MAKE_DATE", year_expr, exp.Literal.number(12), exp.Literal.number(31) 193 ) 194 return self.sql(make_date_expr) 195 196 if unit.upper() == "QUARTER": 197 # Last day of quarter 198 year_expr = exp.func("EXTRACT", "YEAR", date_expr) 199 quarter_expr = exp.func("EXTRACT", "QUARTER", date_expr) 200 201 # Calculate last month of quarter: quarter * 3. Quarter can be 1 to 4 202 last_month_expr = exp.Mul(this=quarter_expr, expression=exp.Literal.number(3)) 203 first_day_last_month_expr = exp.func( 204 "MAKE_DATE", year_expr, last_month_expr, exp.Literal.number(1) 205 ) 206 207 # Last day of the last month of the quarter 208 last_day_expr = exp.func("LAST_DAY", first_day_last_month_expr) 209 return self.sql(last_day_expr) 210 211 self.unsupported(f"Unsupported date part '{unit}' in LAST_DAY function") 212 return self.function_fallback_sql(expression) 213 214 215def _is_nanosecond_unit(unit: exp.Expr | None) -> bool: 216 return isinstance(unit, (exp.Var, exp.Literal)) and unit.name.upper() == "NANOSECOND" 217 218 219def _handle_nanosecond_diff( 220 self: DuckDBGenerator, 221 end_time: exp.Expr, 222 start_time: exp.Expr, 223) -> str: 224 """Generate NANOSECOND diff using EPOCH_NS since DATE_DIFF doesn't support it.""" 225 end_ns = exp.cast(end_time, exp.DType.TIMESTAMP_NS) 226 start_ns = exp.cast(start_time, exp.DType.TIMESTAMP_NS) 227 228 # Build expression tree: EPOCH_NS(end) - EPOCH_NS(start) 229 return self.sql( 230 exp.Sub(this=exp.func("EPOCH_NS", end_ns), expression=exp.func("EPOCH_NS", start_ns)) 231 ) 232 233 234def _to_boolean_sql(self: DuckDBGenerator, expression: exp.ToBoolean) -> str: 235 """ 236 Transpile TO_BOOLEAN and TRY_TO_BOOLEAN functions from Snowflake to DuckDB equivalent. 237 238 DuckDB's CAST to BOOLEAN supports most of Snowflake's TO_BOOLEAN strings except 'on'/'off'. 239 We need to handle the 'on'/'off' cases explicitly. 240 241 For TO_BOOLEAN (safe=False): NaN and INF values cause errors. We use DuckDB's native ERROR() 242 function to replicate this behavior with a clear error message. 243 244 For TRY_TO_BOOLEAN (safe=True): Use DuckDB's TRY_CAST for conversion, which returns NULL 245 for invalid inputs instead of throwing errors. 246 """ 247 arg = expression.this 248 is_safe = expression.args.get("safe", False) 249 250 base_case_expr = ( 251 exp.case() 252 .when( 253 # Handle 'on' -> TRUE (case insensitive) 254 exp.Upper(this=exp.cast(arg, exp.DType.VARCHAR)).eq(exp.Literal.string("ON")), 255 exp.true(), 256 ) 257 .when( 258 # Handle 'off' -> FALSE (case insensitive) 259 exp.Upper(this=exp.cast(arg, exp.DType.VARCHAR)).eq(exp.Literal.string("OFF")), 260 exp.false(), 261 ) 262 ) 263 264 if is_safe: 265 # TRY_TO_BOOLEAN: handle 'on'/'off' and use TRY_CAST for everything else 266 case_expr = base_case_expr.else_(exp.func("TRY_CAST", arg, exp.DType.BOOLEAN.into_expr())) 267 else: 268 # TO_BOOLEAN: handle NaN/INF errors, 'on'/'off', and use regular CAST 269 cast_to_real = exp.func("TRY_CAST", arg, exp.DType.FLOAT.into_expr()) 270 271 # Check for NaN and INF values 272 nan_inf_check = exp.Or( 273 this=exp.func("ISNAN", cast_to_real), expression=exp.func("ISINF", cast_to_real) 274 ) 275 276 case_expr = base_case_expr.when( 277 nan_inf_check, 278 exp.func( 279 "ERROR", 280 exp.Literal.string("TO_BOOLEAN: Non-numeric values NaN and INF are not supported"), 281 ), 282 ).else_(exp.cast(arg, exp.DType.BOOLEAN)) 283 284 return self.sql(case_expr) 285 286 287# BigQuery -> DuckDB conversion for the DATE function 288def _date_sql(self: DuckDBGenerator, expression: exp.Date) -> str: 289 this = expression.this 290 zone = self.sql(expression, "zone") 291 292 if zone: 293 # BigQuery considers "this" at UTC, converts it to the specified 294 # time zone and then keeps only the DATE part 295 # To micmic that, we: 296 # (1) Cast to TIMESTAMP to remove DuckDB's local tz 297 # (2) Apply consecutive AtTimeZone calls for UTC -> zone conversion 298 this = exp.cast(this, exp.DType.TIMESTAMP) 299 at_utc = exp.AtTimeZone(this=this, zone=exp.Literal.string("UTC")) 300 this = exp.AtTimeZone(this=at_utc, zone=zone) 301 302 return self.sql(exp.cast(expression=this, to=exp.DType.DATE)) 303 304 305# BigQuery -> DuckDB conversion for the TIME_DIFF function 306def _timediff_sql(self: DuckDBGenerator, expression: exp.TimeDiff) -> str: 307 unit = expression.unit 308 309 if _is_nanosecond_unit(unit): 310 return _handle_nanosecond_diff(self, expression.expression, expression.this) 311 312 this = exp.cast(expression.this, exp.DType.TIME) 313 expr = exp.cast(expression.expression, exp.DType.TIME) 314 315 # Although the 2 dialects share similar signatures, BQ seems to inverse 316 # the sign of the result so the start/end time operands are flipped 317 return self.func("DATE_DIFF", unit_to_str(expression), expr, this) 318 319 320def _date_delta_to_binary_interval_op( 321 cast: bool = True, 322) -> t.Callable[[DuckDBGenerator, DATETIME_DELTA], str]: 323 """ 324 DuckDB override to handle: 325 1. NANOSECOND operations (DuckDB doesn't support INTERVAL ... NANOSECOND) 326 2. Float/decimal interval values (DuckDB INTERVAL requires integers) 327 """ 328 base_impl = date_delta_to_binary_interval_op(cast=cast) 329 330 def _duckdb_date_delta_sql(self: DuckDBGenerator, expression: DATETIME_DELTA) -> str: 331 unit = expression.unit 332 interval_value = expression.expression 333 334 # Handle NANOSECOND unit (DuckDB doesn't support INTERVAL ... NANOSECOND) 335 if _is_nanosecond_unit(unit): 336 if isinstance(interval_value, exp.Interval): 337 interval_value = interval_value.this 338 339 timestamp_ns = exp.cast(expression.this, exp.DType.TIMESTAMP_NS) 340 341 return self.sql( 342 exp.func( 343 "MAKE_TIMESTAMP_NS", 344 exp.Add(this=exp.func("EPOCH_NS", timestamp_ns), expression=interval_value), 345 ) 346 ) 347 348 # Handle float/decimal interval values as duckDB INTERVAL requires integer expressions 349 if not interval_value or isinstance(interval_value, exp.Interval): 350 return base_impl(self, expression) 351 352 if interval_value.is_type(*exp.DataType.REAL_TYPES): 353 expression.set("expression", exp.cast(exp.func("ROUND", interval_value), "INT")) 354 355 return base_impl(self, expression) 356 357 return _duckdb_date_delta_sql 358 359 360def _array_insert_sql(self: DuckDBGenerator, expression: exp.ArrayInsert) -> str: 361 """ 362 Transpile ARRAY_INSERT to DuckDB using LIST_CONCAT and slicing. 363 364 Handles: 365 - 0-based and 1-based indexing (normalizes to 0-based for calculations) 366 - Negative position conversion (requires array length) 367 - NULL propagation (source dialects return NULL, DuckDB creates single-element array) 368 - Assumes position is within bounds per user constraint 369 370 Note: All dialects that support ARRAY_INSERT (Snowflake, Spark, Databricks) have 371 ARRAY_FUNCS_PROPAGATES_NULLS=True, so we always assume source propagates NULLs. 372 373 Args: 374 expression: The ArrayInsert expression to transpile. 375 376 Returns: 377 SQL string implementing ARRAY_INSERT behavior. 378 """ 379 this = expression.this 380 position = expression.args.get("position") 381 element = expression.expression 382 element_array = exp.Array(expressions=[element]) 383 index_offset = expression.args.get("offset", 0) 384 385 if not position or not position.is_int: 386 self.unsupported("ARRAY_INSERT can only be transpiled with a literal position") 387 return self.func("ARRAY_INSERT", this, position, element) 388 389 pos_value = position.to_py() 390 391 # Normalize one-based indexing to zero-based for slice calculations 392 # Spark (1-based) -> Snowflake (0-based): 393 # Positive: pos=1 -> pos=0 (subtract 1) 394 # Negative: pos=-2 -> pos=-1 (add 1) 395 # Example: Spark array_insert([a,b,c], -2, d) -> [a,b,d,c] is same as Snowflake pos=-1 396 if pos_value > 0: 397 pos_value = pos_value - index_offset 398 elif pos_value < 0: 399 pos_value = pos_value + index_offset 400 401 # Build the appropriate list_concat expression based on position 402 if pos_value == 0: 403 # insert at beginning 404 concat_exprs = [element_array, this] 405 elif pos_value > 0: 406 # Positive position: LIST_CONCAT(arr[1:pos], [elem], arr[pos+1:]) 407 # 0-based -> DuckDB 1-based slicing 408 409 # left slice: arr[1:pos] 410 slice_start = exp.Bracket( 411 this=this, 412 expressions=[ 413 exp.Slice(this=exp.Literal.number(1), expression=exp.Literal.number(pos_value)) 414 ], 415 ) 416 417 # right slice: arr[pos+1:] 418 slice_end = exp.Bracket( 419 this=this, expressions=[exp.Slice(this=exp.Literal.number(pos_value + 1))] 420 ) 421 422 concat_exprs = [slice_start, element_array, slice_end] 423 else: 424 # Negative position: arr[1:LEN(arr)+pos], [elem], arr[LEN(arr)+pos+1:] 425 # pos=-1 means insert before last element 426 arr_len = exp.Length(this=this) 427 428 # Calculate slice position: LEN(arr) + pos (e.g., LEN(arr) + (-1) = LEN(arr) - 1) 429 slice_end_pos = arr_len + exp.Literal.number(pos_value) 430 slice_start_pos = slice_end_pos + exp.Literal.number(1) 431 432 # left slice: arr[1:LEN(arr)+pos] 433 slice_start = exp.Bracket( 434 this=this, 435 expressions=[exp.Slice(this=exp.Literal.number(1), expression=slice_end_pos)], 436 ) 437 438 # right slice: arr[LEN(arr)+pos+1:] 439 slice_end = exp.Bracket(this=this, expressions=[exp.Slice(this=slice_start_pos)]) 440 441 concat_exprs = [slice_start, element_array, slice_end] 442 443 # All dialects that support ARRAY_INSERT propagate NULLs (Snowflake/Spark/Databricks) 444 # Wrap in CASE WHEN array IS NULL THEN NULL ELSE func_expr END 445 return self.sql( 446 exp.If( 447 this=exp.Is(this=this, expression=exp.Null()), 448 true=exp.Null(), 449 false=self.func("LIST_CONCAT", *concat_exprs), 450 ) 451 ) 452 453 454def _array_remove_at_sql(self: DuckDBGenerator, expression: exp.ArrayRemoveAt) -> str: 455 """ 456 Transpile ARRAY_REMOVE_AT to DuckDB using LIST_CONCAT and slicing. 457 458 Handles: 459 - Positive positions (0-based indexing) 460 - Negative positions (from end of array) 461 - NULL propagation (Snowflake returns NULL for NULL array, DuckDB doesn't auto-propagate) 462 - Only supports literal integer positions (non-literals remain untranspiled) 463 464 Transpilation patterns: 465 - pos=0 (first): arr[2:] 466 - pos>0 (middle): LIST_CONCAT(arr[1:p], arr[p+2:]) 467 - pos=-1 (last): arr[1:LEN(arr)-1] 468 - pos<-1: LIST_CONCAT(arr[1:LEN(arr)+p], arr[LEN(arr)+p+2:]) 469 470 All wrapped in: CASE WHEN arr IS NULL THEN NULL ELSE ... END 471 472 Args: 473 expression: The ArrayRemoveAt expression to transpile. 474 475 Returns: 476 SQL string implementing ARRAY_REMOVE_AT behavior. 477 """ 478 this = expression.this 479 position = expression.args.get("position") 480 481 if not position or not position.is_int: 482 self.unsupported("ARRAY_REMOVE_AT can only be transpiled with a literal position") 483 return self.func("ARRAY_REMOVE_AT", this, position) 484 485 pos_value = position.to_py() 486 487 # Build the appropriate expression based on position 488 if pos_value == 0: 489 # Remove first element: arr[2:] 490 result_expr: exp.Expr | str = exp.Bracket( 491 this=this, 492 expressions=[exp.Slice(this=exp.Literal.number(2))], 493 ) 494 elif pos_value > 0: 495 # Remove at positive position: LIST_CONCAT(arr[1:pos], arr[pos+2:]) 496 # DuckDB uses 1-based slicing 497 left_slice = exp.Bracket( 498 this=this, 499 expressions=[ 500 exp.Slice(this=exp.Literal.number(1), expression=exp.Literal.number(pos_value)) 501 ], 502 ) 503 right_slice = exp.Bracket( 504 this=this, 505 expressions=[exp.Slice(this=exp.Literal.number(pos_value + 2))], 506 ) 507 result_expr = self.func("LIST_CONCAT", left_slice, right_slice) 508 elif pos_value == -1: 509 # Remove last element: arr[1:LEN(arr)-1] 510 # Optimization: simpler than general negative case 511 arr_len = exp.Length(this=this) 512 slice_end = arr_len + exp.Literal.number(-1) 513 result_expr = exp.Bracket( 514 this=this, 515 expressions=[exp.Slice(this=exp.Literal.number(1), expression=slice_end)], 516 ) 517 else: 518 # Remove at negative position: LIST_CONCAT(arr[1:LEN(arr)+pos], arr[LEN(arr)+pos+2:]) 519 arr_len = exp.Length(this=this) 520 slice_end_pos = arr_len + exp.Literal.number(pos_value) 521 slice_start_pos = slice_end_pos + exp.Literal.number(2) 522 523 left_slice = exp.Bracket( 524 this=this, 525 expressions=[exp.Slice(this=exp.Literal.number(1), expression=slice_end_pos)], 526 ) 527 right_slice = exp.Bracket( 528 this=this, 529 expressions=[exp.Slice(this=slice_start_pos)], 530 ) 531 result_expr = self.func("LIST_CONCAT", left_slice, right_slice) 532 533 # Snowflake ARRAY_FUNCS_PROPAGATES_NULLS=True, so wrap in NULL check 534 # CASE WHEN array IS NULL THEN NULL ELSE result_expr END 535 return self.sql( 536 exp.If( 537 this=exp.Is(this=this, expression=exp.Null()), 538 true=exp.Null(), 539 false=result_expr, 540 ) 541 ) 542 543 544@unsupported_args(("expression", "DuckDB's ARRAY_SORT does not support a comparator.")) 545def _array_sort_sql(self: DuckDBGenerator, expression: exp.ArraySort) -> str: 546 return self.func("ARRAY_SORT", expression.this) 547 548 549def _array_contains_sql(self: DuckDBGenerator, expression: exp.ArrayContains) -> str: 550 this = expression.this 551 expr = expression.expression 552 553 func = self.func("ARRAY_CONTAINS", this, expr) 554 555 if expression.args.get("check_null"): 556 check_null_in_array = exp.Nullif( 557 this=exp.NEQ(this=exp.ArraySize(this=this), expression=exp.func("LIST_COUNT", this)), 558 expression=exp.false(), 559 ) 560 return self.sql(exp.If(this=expr.is_(exp.Null()), true=check_null_in_array, false=func)) 561 562 return func 563 564 565def _array_overlaps_sql(self: DuckDBGenerator, expression: exp.ArrayOverlaps) -> str: 566 """ 567 Translates Snowflake's NULL-safe ARRAYS_OVERLAP to DuckDB. 568 569 DuckDB's native && operator is not NULL-safe: [1,NULL,3] && [NULL,4,5] returns FALSE. 570 Snowflake returns TRUE when both arrays contain NULL (NULLs are treated as known values). 571 572 Generated SQL: (arr1 && arr2) OR (ARRAY_LENGTH(arr1) <> LIST_COUNT(arr1) AND ARRAY_LENGTH(arr2) <> LIST_COUNT(arr2)) 573 574 ARRAY_LENGTH counts all elements (including NULLs); LIST_COUNT counts only non-NULLs. 575 When they differ, the array contains at least one NULL, matching Snowflake's NULL-safe semantics. 576 """ 577 if not expression.args.get("null_safe"): 578 return self.binary(expression, "&&") 579 580 arr1 = expression.this 581 arr2 = expression.expression 582 583 check_nulls = exp.and_( 584 exp.NEQ( 585 this=exp.ArraySize(this=arr1.copy()), 586 expression=exp.func("LIST_COUNT", arr1.copy()), 587 ), 588 exp.NEQ( 589 this=exp.ArraySize(this=arr2.copy()), 590 expression=exp.func("LIST_COUNT", arr2.copy()), 591 ), 592 copy=False, 593 ) 594 595 overlap = exp.ArrayOverlaps(this=arr1.copy(), expression=arr2.copy()) 596 597 return self.sql( 598 exp.or_( 599 exp.paren(overlap, copy=False), 600 exp.paren(check_nulls, copy=False), 601 copy=False, 602 wrap=False, 603 ) 604 ) 605 606 607def _struct_sql(self: DuckDBGenerator, expression: exp.Struct) -> str: 608 ancestor_cast = expression.find_ancestor(exp.Cast, exp.Select) 609 ancestor_cast = None if isinstance(ancestor_cast, exp.Select) else ancestor_cast 610 611 # Empty struct cast works with MAP() since DuckDB can't parse {} 612 if not expression.expressions: 613 if isinstance(ancestor_cast, exp.Cast) and ancestor_cast.to.is_type(exp.DType.MAP): 614 return "MAP()" 615 616 args: list[str] = [] 617 618 # BigQuery allows inline construction such as "STRUCT<a STRING, b INTEGER>('str', 1)" which is 619 # canonicalized to "ROW('str', 1) AS STRUCT(a TEXT, b INT)" in DuckDB 620 # The transformation to ROW will take place if: 621 # 1. The STRUCT itself does not have proper fields (key := value) as a "proper" STRUCT would 622 # 2. A cast to STRUCT / ARRAY of STRUCTs is found 623 is_bq_inline_struct = ( 624 (expression.find(exp.PropertyEQ) is None) 625 and ancestor_cast 626 and any( 627 casted_type.is_type(exp.DType.STRUCT) 628 for casted_type in ancestor_cast.find_all(exp.DataType) 629 ) 630 ) 631 632 for i, expr in enumerate(expression.expressions): 633 is_property_eq = isinstance(expr, exp.PropertyEQ) 634 this = expr.this 635 value = expr.expression if is_property_eq else expr 636 637 if is_bq_inline_struct: 638 args.append(self.sql(value)) 639 else: 640 if isinstance(this, exp.Identifier): 641 key = self.sql(exp.Literal.string(expr.name)) 642 elif is_property_eq: 643 key = self.sql(this) 644 else: 645 key = self.sql(exp.Literal.string(f"_{i}")) 646 647 args.append(f"{key}: {self.sql(value)}") 648 649 csv_args = ", ".join(args) 650 651 return f"ROW({csv_args})" if is_bq_inline_struct else f"{{{csv_args}}}" 652 653 654def _json_format_sql(self: DuckDBGenerator, expression: exp.JSONFormat) -> str: 655 sql = self.func("TO_JSON", expression.this, expression.args.get("options")) 656 return f"CAST({sql} AS TEXT)" 657 658 659def _build_seq_expression(base: exp.Expr, byte_width: int, signed: bool) -> exp.Expr: 660 """Build a SEQ expression with the given base, byte width, and signedness.""" 661 bits = byte_width * 8 662 max_val = exp.Literal.number(2**bits) 663 664 if signed: 665 half = exp.Literal.number(2 ** (bits - 1)) 666 return exp.replace_placeholders(_SEQ_SIGNED.copy(), base=base, max_val=max_val, half=half) 667 return exp.replace_placeholders(_SEQ_UNSIGNED.copy(), base=base, max_val=max_val) 668 669 670def _seq_to_range_in_generator(expression: exp.Expr) -> exp.Expr: 671 """ 672 Transform SEQ functions to `range` column references when inside a GENERATOR context. 673 674 When GENERATOR(ROWCOUNT => N) becomes RANGE(N) in DuckDB, it produces a column 675 named `range` with values 0, 1, ..., N-1. SEQ functions produce the same sequence, 676 so we replace them with `range % max_val` to avoid nested window function issues. 677 """ 678 if not isinstance(expression, exp.Select): 679 return expression 680 681 from_ = expression.args.get("from_") 682 if not ( 683 from_ 684 and isinstance(from_.this, exp.TableFromRows) 685 and isinstance(from_.this.this, exp.Generator) 686 ): 687 return expression 688 689 def replace_seq(node: exp.Expr) -> exp.Expr: 690 if isinstance(node, (exp.Seq1, exp.Seq2, exp.Seq4, exp.Seq8)): 691 byte_width = _SEQ_BYTE_WIDTH[type(node)] 692 return _build_seq_expression(exp.column("range"), byte_width, signed=node.name == "1") 693 return node 694 695 return expression.transform(replace_seq, copy=False) 696 697 698def connect_by_to_recursive_cte(expression: exp.Expr) -> exp.Expr: 699 # Rewrites START WITH ... CONNECT BY PRIOR into WITH RECURSIVE 700 # Falls through unchanged if there are no PRIORs. 701 if not isinstance(expression, exp.Select) or not expression.args.get("connect"): 702 return expression 703 704 connect = expression.args["connect"] 705 connect_pred = connect.args["connect"] 706 707 priors = list(connect_pred.find_all(exp.Prior)) 708 if not priors: 709 return expression 710 711 from_ = expression.args.get("from_") 712 if not from_ or expression.args.get("joins"): 713 return expression 714 715 source_table = from_.this 716 base_select_exprs = expression.expressions 717 base_where = expression.args.get("where") 718 base_with = expression.args.get("with_") 719 720 # LEVEL is a Snowflake pseudo-column: it's always computed as a depth counter in the CTE. 721 has_level = any( 722 isinstance(col, exp.Column) and col.name.upper() == "LEVEL" 723 for e in base_select_exprs 724 for col in e.find_all(exp.Column) 725 ) 726 has_star = expression.is_star 727 728 # CONNECT_BY_ROOT col yields the value of `col` from the START WITH row that begins each 729 # branch. Each one is threaded through the CTE as an extra column: the anchor binds it to the 730 # row's own value, the recursive arm forwards the parent's value unchanged. 731 root_col_names: list[str] = [] 732 anchor_root_cols: list[exp.Expr] = [] 733 inner_root_cols: list[exp.Expr] = [] 734 roots = [root for e in base_select_exprs for root in e.find_all(exp.ConnectByRoot)] 735 736 for i, root in enumerate(roots): 737 name = f"_connect_by_root_{i}" 738 root_col_names.append(name) 739 anchor_root_cols.append(exp.alias_(root.this, name)) 740 inner_root_cols.append(exp.alias_(exp.column(name, "_parent_row"), name)) 741 root.replace(exp.column(name)) 742 743 # Build the join condition from the full CONNECT BY predicate: 744 # PRIOR(col) → _parent_row.col, unqualified cols → _child_row.col. 745 def _qualify_connect_pred(node: exp.Expression) -> exp.Expression: 746 for col in find_all_in_scope(node, exp.Column): 747 col.set( 748 "table", 749 exp.to_identifier( 750 "_parent_row" if isinstance(col.parent, exp.Prior) else "_child_row" 751 ), 752 ) 753 for prior in find_all_in_scope(node, exp.Prior): 754 prior.replace(prior.this) 755 return node 756 757 # Avoid colliding with any CTE names already on the query. 758 cte_name = find_new_name( 759 {cte.alias for cte in (base_with.expressions if base_with else [])}, "_rootcte" 760 ) 761 762 # Anchor: project all source columns + seed LEVEL at 1 + bind each root column to its own value. 763 anchor = exp.select( 764 exp.Star(), exp.alias_(exp.Literal.number(1), "level"), *anchor_root_cols 765 ).from_(source_table) 766 if connect.args.get("start"): 767 anchor = anchor.where(connect.args["start"]) 768 769 # Recursive arm: carry all child columns + increment level + forward each root value. 770 # SELECT * in both arms means WHERE/PRIOR columns are always available without explicit tracking. 771 inner_query = ( 772 exp.select( 773 exp.Column(this=exp.Star(), table=exp.to_identifier("_child_row")), 774 exp.alias_(exp.column("level", "_parent_row") + 1, "level"), 775 *inner_root_cols, 776 ) 777 .from_(source_table.as_("_child_row")) 778 .join(exp.to_table(cte_name).as_("_parent_row"), on=_qualify_connect_pred(connect_pred)) 779 ) 780 781 # Outer SELECT re-projects from the CTE. Synthetic level/root columns are excluded from any 782 # star expansion (level only when not referenced) but kept where explicitly projected. 783 if has_star: 784 except_cols = [] if has_level else [exp.column("level")] 785 except_cols.extend(exp.column(name) for name in root_col_names) 786 star = exp.Star(except_=except_cols) if except_cols else exp.Star() 787 outer_select_exprs: list[exp.Expr] = [ 788 star, 789 *(e for e in base_select_exprs if not e.is_star), 790 ] 791 else: 792 outer_select_exprs = base_select_exprs 793 outer_query = exp.select(*outer_select_exprs).from_(cte_name) 794 if base_where: 795 outer_query = outer_query.where(base_where.this) 796 797 # Attach the CTE, marking the WITH clause recursive. 798 if base_with: 799 outer_query.set("with_", base_with) 800 outer_query = outer_query.with_( 801 cte_name, as_=anchor.union(inner_query, distinct=False), recursive=True, copy=False 802 ) 803 804 for arg, val in expression.args.items(): 805 if val and arg not in _CONNECT_BY_ARGS_TO_SKIP: 806 outer_query.set(arg, val) 807 808 # Strip stale source table qualifiers in one pass; CTEs are child scopes so 809 # find_all_in_scope stays within the outer query only. 810 for col in find_all_in_scope(outer_query, exp.Column): 811 col.set("table", None) 812 813 return outer_query 814 815 816def _seq_sql(self: DuckDBGenerator, expression: exp.Func, byte_width: int) -> str: 817 """ 818 Transpile Snowflake SEQ1/SEQ2/SEQ4/SEQ8 to DuckDB. 819 820 Generates monotonically increasing integers starting from 0. 821 The signed parameter (0 or 1) affects wrap-around behavior: 822 - Unsigned (0): wraps at 2^(bits) - 1 823 - Signed (1): wraps at 2^(bits-1) - 1, then goes negative 824 """ 825 # Warn if SEQ is in a restricted context (Select stops search at current scope) 826 ancestor = expression.find_ancestor(*_SEQ_RESTRICTED) 827 if ancestor and ( 828 (not isinstance(ancestor, (exp.Order, exp.Select))) 829 or (isinstance(ancestor, exp.Order) and isinstance(ancestor.parent, exp.Window)) 830 ): 831 self.unsupported("SEQ in restricted context is not supported - use CTE or subquery") 832 833 result = _build_seq_expression(_SEQ_BASE.copy(), byte_width, signed=expression.name == "1") 834 return self.sql(result) 835 836 837def _unix_to_time_sql(self: DuckDBGenerator, expression: exp.UnixToTime) -> str: 838 scale = expression.args.get("scale") 839 timestamp = expression.this 840 target_type = expression.args.get("target_type") 841 842 # Check if we need NTZ (naive timestamp in UTC) 843 is_ntz = target_type and target_type.this in ( 844 exp.DType.TIMESTAMP, 845 exp.DType.TIMESTAMPNTZ, 846 ) 847 848 if scale == exp.UnixToTime.MILLIS: 849 # EPOCH_MS already returns TIMESTAMP (naive, UTC) 850 return self.func("EPOCH_MS", timestamp) 851 if scale == exp.UnixToTime.MICROS: 852 # MAKE_TIMESTAMP already returns TIMESTAMP (naive, UTC) 853 return self.func("MAKE_TIMESTAMP", timestamp) 854 855 # Other scales: divide and use TO_TIMESTAMP 856 if scale not in (None, exp.UnixToTime.SECONDS): 857 timestamp = exp.Div(this=timestamp, expression=exp.func("POW", 10, scale)) 858 859 to_timestamp: exp.Expr = exp.Anonymous(this="TO_TIMESTAMP", expressions=[timestamp]) 860 861 if is_ntz: 862 to_timestamp = exp.AtTimeZone(this=to_timestamp, zone=exp.Literal.string("UTC")) 863 864 return self.sql(to_timestamp) 865 866 867WRAPPED_JSON_EXTRACT_EXPRESSIONS = (exp.Binary, exp.Bracket, exp.In, exp.Not) 868 869 870def _arrow_json_extract_sql(self: DuckDBGenerator, expression: JSON_EXTRACT_TYPE) -> str: 871 arrow_sql = arrow_json_extract_sql(self, expression) 872 if not expression.same_parent and isinstance( 873 expression.parent, WRAPPED_JSON_EXTRACT_EXPRESSIONS 874 ): 875 arrow_sql = self.wrap(arrow_sql) 876 return arrow_sql 877 878 879def _implicit_datetime_cast( 880 arg: exp.Expr | None, type: exp.DType = exp.DType.DATE 881) -> exp.Expr | None: 882 if isinstance(arg, exp.Literal) and arg.is_string: 883 ts = arg.name 884 if type == exp.DType.DATE and ":" in ts: 885 type = exp.DType.TIMESTAMPTZ if TIMEZONE_PATTERN.search(ts) else exp.DType.TIMESTAMP 886 887 arg = exp.cast(arg, type) 888 889 return arg 890 891 892def _week_trunc_start_dow(unit: exp.Expr | None) -> int | None: 893 # DuckDB's weeks are ISO 8601, so ISOWEEK maps to its plain WEEK unit 894 if isinstance(unit, exp.Literal) and unit.name.upper() == "ISOWEEK": 895 return 1 896 return week_unit_to_dow(unit) 897 898 899def _build_week_trunc_expression( 900 date_expr: exp.Expr, 901 start_dow: int, 902 preserve_start_day: bool = False, 903 cast_to_date: bool = True, 904) -> exp.Expr: 905 """ 906 Build DATE_TRUNC expression for week boundaries with custom start day. 907 908 DuckDB's DATE_TRUNC('WEEK', ...) always returns Monday. To align to a different 909 start day, we shift the date before truncating. 910 911 Args: 912 date_expr: The date expression to truncate. 913 start_dow: ISO 8601 day-of-week number (Monday=1, ..., Sunday=7). 914 preserve_start_day: If True, reverse the shift after truncating so the result lands on the 915 correct week start day. Needed for DATE_TRUNC (absolute result matters) but 916 not for DATE_DIFF (only relative alignment matters). 917 cast_to_date: If True, cast the shifted result back to DATE; set to False for 918 timestamp-valued inputs, where the result must remain a timestamp. 919 920 Shift formula: Sunday (7) gets +1, others get (1 - start_dow). 921 """ 922 shift_days = 1 if start_dow == 7 else 1 - start_dow 923 truncated = exp.func("DATE_TRUNC", unit=exp.var("WEEK"), this=date_expr) 924 925 if shift_days == 0: 926 return truncated 927 928 shift = exp.Interval(this=exp.Literal.string(str(shift_days)), unit=exp.var("DAY")) 929 shifted_date = exp.DateAdd(this=date_expr, expression=shift) 930 truncated.set("this", shifted_date) 931 932 if preserve_start_day: 933 interval = exp.Interval(this=exp.Literal.string(str(-shift_days)), unit=exp.var("DAY")) 934 shifted_back: exp.Expr = exp.DateAdd(this=truncated, expression=interval) 935 if cast_to_date: 936 return exp.cast(shifted_back, to=exp.DType.DATE, copy=False) 937 return shifted_back 938 939 return truncated 940 941 942def _date_diff_sql(self: DuckDBGenerator, expression: exp.DateDiff | exp.DatetimeDiff) -> str: 943 unit = expression.unit 944 945 if _is_nanosecond_unit(unit): 946 return _handle_nanosecond_diff(self, expression.this, expression.expression) 947 948 this = _implicit_datetime_cast(expression.this) 949 expr = _implicit_datetime_cast(expression.expression) 950 951 # DuckDB's WEEK diff does not respect Monday crossing (week boundaries), it checks (end_day - start_day) / 7: 952 # SELECT DATE_DIFF('WEEK', CAST('2024-12-13' AS DATE), CAST('2024-12-17' AS DATE)) --> 0 (Monday crossed) 953 # SELECT DATE_DIFF('WEEK', CAST('2024-12-13' AS DATE), CAST('2024-12-20' AS DATE)) --> 1 (7 days difference) 954 # Whereas for other units such as MONTH it does respect month boundaries: 955 # SELECT DATE_DIFF('MONTH', CAST('2024-11-30' AS DATE), CAST('2024-12-01' AS DATE)) --> 1 (Month crossed) 956 date_part_boundary = expression.args.get("date_part_boundary") 957 958 # Extract week start day; returns None if day is dynamic (column/placeholder) 959 week_start = week_unit_to_dow(unit) 960 if date_part_boundary and week_start and this and expr: 961 expression.set("unit", exp.Literal.string("WEEK")) 962 963 # Truncate both dates to week boundaries to respect input dialect semantics 964 this = _build_week_trunc_expression(this, week_start) 965 expr = _build_week_trunc_expression(expr, week_start) 966 967 return self.func("DATE_DIFF", unit_to_str(expression), expr, this) 968 969 970def _generate_datetime_array_sql( 971 self: DuckDBGenerator, expression: exp.GenerateDateArray | exp.GenerateTimestampArray 972) -> str: 973 is_generate_date_array = isinstance(expression, exp.GenerateDateArray) 974 975 type = exp.DType.DATE if is_generate_date_array else exp.DType.TIMESTAMP 976 start = _implicit_datetime_cast(expression.args.get("start"), type=type) 977 end = _implicit_datetime_cast(expression.args.get("end"), type=type) 978 979 # BQ's GENERATE_DATE_ARRAY & GENERATE_TIMESTAMP_ARRAY are transformed to DuckDB'S GENERATE_SERIES 980 gen_series: exp.GenerateSeries | exp.Cast = exp.GenerateSeries( 981 start=start, end=end, step=expression.args.get("step") 982 ) 983 984 if is_generate_date_array: 985 # The GENERATE_SERIES result type is TIMESTAMP array, so to match BQ's semantics for 986 # GENERATE_DATE_ARRAY we must cast it back to DATE array 987 gen_series = exp.cast(gen_series, exp.DataType.from_str("ARRAY<DATE>")) 988 989 return self.sql(gen_series) 990 991 992def _json_extract_value_array_sql( 993 self: DuckDBGenerator, expression: exp.JSONValueArray | exp.JSONExtractArray 994) -> str: 995 json_extract = exp.JSONExtract(this=expression.this, expression=expression.expression) 996 data_type = "ARRAY<STRING>" if isinstance(expression, exp.JSONValueArray) else "ARRAY<JSON>" 997 return self.sql(exp.cast(json_extract, to=exp.DataType.from_str(data_type))) 998 999 1000def _cast_to_varchar(arg: exp.Expr | None) -> exp.Expr | None: 1001 if arg and arg.type and not arg.is_type(*exp.DataType.TEXT_TYPES, exp.DType.UNKNOWN): 1002 return exp.cast(arg, exp.DType.VARCHAR) 1003 return arg 1004 1005 1006def _cast_to_boolean(arg: exp.Expr | None) -> exp.Expr | None: 1007 if arg and not arg.is_type(exp.DType.BOOLEAN): 1008 return exp.cast(arg, exp.DType.BOOLEAN) 1009 return arg 1010 1011 1012def _is_binary(arg: exp.Expr) -> bool: 1013 return arg.is_type( 1014 exp.DType.BINARY, 1015 exp.DType.VARBINARY, 1016 exp.DType.BLOB, 1017 ) 1018 1019 1020def _gen_with_cast_to_blob(self: DuckDBGenerator, expression: exp.Expr, result_sql: str) -> str: 1021 if _is_binary(expression): 1022 blob = exp.DataType.from_str("BLOB", dialect="duckdb") 1023 result_sql = self.sql(exp.Cast(this=result_sql, to=blob)) 1024 return result_sql 1025 1026 1027def _cast_to_bit(arg: exp.Expr) -> exp.Expr: 1028 if not _is_binary(arg): 1029 return arg 1030 1031 if isinstance(arg, exp.HexString): 1032 arg = exp.Unhex(this=exp.Literal.string(arg.this)) 1033 1034 return exp.cast(arg, exp.DType.BIT) 1035 1036 1037def _prepare_binary_bitwise_args(expression: exp.Binary) -> None: 1038 if _is_binary(expression.this): 1039 expression.set("this", _cast_to_bit(expression.this)) 1040 if _is_binary(expression.expression): 1041 expression.set("expression", _cast_to_bit(expression.expression)) 1042 1043 1044def _day_navigation_sql(self: DuckDBGenerator, expression: exp.NextDay | exp.PreviousDay) -> str: 1045 """ 1046 Transpile Snowflake's NEXT_DAY / PREVIOUS_DAY to DuckDB using date arithmetic. 1047 1048 Returns the DATE of the next/previous occurrence of the specified weekday. 1049 1050 Formulas: 1051 - NEXT_DAY: (target_dow - current_dow + 6) % 7 + 1 1052 - PREVIOUS_DAY: (current_dow - target_dow + 6) % 7 + 1 1053 1054 Supports both literal and non-literal day names: 1055 - Literal: Direct lookup (e.g., 'Monday' -> 1) 1056 - Non-literal: CASE statement for runtime evaluation 1057 1058 Examples: 1059 NEXT_DAY('2024-01-01' (Monday), 'Monday') 1060 -> (1 - 1 + 6) % 7 + 1 = 6 % 7 + 1 = 7 days -> 2024-01-08 1061 1062 PREVIOUS_DAY('2024-01-15' (Monday), 'Friday') 1063 -> (1 - 5 + 6) % 7 + 1 = 2 % 7 + 1 = 3 days -> 2024-01-12 1064 """ 1065 date_expr = expression.this 1066 day_name_expr = expression.expression 1067 1068 # Build ISODOW call for current day of week 1069 isodow_call = exp.func("ISODOW", date_expr) 1070 1071 # Determine target day of week 1072 if isinstance(day_name_expr, exp.Literal): 1073 # Literal day name: lookup target_dow directly 1074 day_name_str = day_name_expr.name.upper() 1075 matching_day = next( 1076 (day for day in WEEK_START_DAY_TO_DOW if day.startswith(day_name_str)), None 1077 ) 1078 if matching_day: 1079 target_dow: exp.Expr = exp.Literal.number(WEEK_START_DAY_TO_DOW[matching_day]) 1080 else: 1081 # Unrecognized day name, use fallback 1082 return self.function_fallback_sql(expression) 1083 else: 1084 # Non-literal day name: build CASE statement for runtime mapping 1085 upper_day_name = exp.Upper(this=day_name_expr) 1086 target_dow = exp.Case( 1087 ifs=[ 1088 exp.If( 1089 this=exp.func( 1090 "STARTS_WITH", upper_day_name.copy(), exp.Literal.string(day[:2]) 1091 ), 1092 true=exp.Literal.number(dow_num), 1093 ) 1094 for day, dow_num in WEEK_START_DAY_TO_DOW.items() 1095 ] 1096 ) 1097 1098 # Calculate days offset and apply interval based on direction 1099 if isinstance(expression, exp.NextDay): 1100 # NEXT_DAY: (target_dow - current_dow + 6) % 7 + 1 1101 days_offset = exp.paren(target_dow - isodow_call + 6, copy=False) % 7 + 1 1102 date_with_offset = date_expr + exp.Interval(this=days_offset, unit=exp.var("DAY")) 1103 else: # exp.PreviousDay 1104 # PREVIOUS_DAY: (current_dow - target_dow + 6) % 7 + 1 1105 days_offset = exp.paren(isodow_call - target_dow + 6, copy=False) % 7 + 1 1106 date_with_offset = date_expr - exp.Interval(this=days_offset, unit=exp.var("DAY")) 1107 1108 # Build final: CAST(date_with_offset AS DATE) 1109 return self.sql(exp.cast(date_with_offset, exp.DType.DATE)) 1110 1111 1112def _anyvalue_sql(self: DuckDBGenerator, expression: exp.AnyValue) -> str: 1113 # Transform ANY_VALUE(expr HAVING MAX/MIN having_expr) to ARG_MAX_NULL/ARG_MIN_NULL 1114 having = expression.this 1115 if isinstance(having, exp.HavingMax): 1116 func_name = "ARG_MAX_NULL" if having.args.get("max") else "ARG_MIN_NULL" 1117 return self.func(func_name, having.this, having.expression) 1118 return self.function_fallback_sql(expression) 1119 1120 1121def _bitwise_agg_sql( 1122 self: DuckDBGenerator, 1123 expression: exp.BitwiseOrAgg | exp.BitwiseAndAgg | exp.BitwiseXorAgg, 1124) -> str: 1125 """ 1126 DuckDB's bitwise aggregate functions only accept integer types. For other types: 1127 - DECIMAL/STRING: Use CAST(arg AS INT) to convert directly, will round to nearest int 1128 - FLOAT/DOUBLE: Use ROUND(arg)::INT to round to nearest integer, required due to float precision loss 1129 """ 1130 if isinstance(expression, exp.BitwiseOrAgg): 1131 func_name = "BIT_OR" 1132 elif isinstance(expression, exp.BitwiseAndAgg): 1133 func_name = "BIT_AND" 1134 else: # exp.BitwiseXorAgg 1135 func_name = "BIT_XOR" 1136 1137 arg = expression.this 1138 1139 if not arg.type: 1140 from sqlglot.optimizer.annotate_types import annotate_types 1141 1142 arg = annotate_types(arg, dialect=self.dialect) 1143 1144 if arg.is_type(*exp.DataType.REAL_TYPES, *exp.DataType.TEXT_TYPES): 1145 if arg.is_type(*exp.DataType.FLOAT_TYPES): 1146 # float types need to be rounded first due to precision loss 1147 arg = exp.func("ROUND", arg) 1148 1149 arg = exp.cast(arg, exp.DType.INT) 1150 1151 return self.func(func_name, arg) 1152 1153 1154def _literal_sql_with_ws_chr(self: DuckDBGenerator, literal: str) -> str: 1155 # DuckDB does not support \uXXXX escapes, so we must use CHR() instead of replacing them directly 1156 if not any(ch in WS_CONTROL_CHARS_TO_DUCK for ch in literal): 1157 return self.sql(exp.Literal.string(literal)) 1158 1159 sql_segments: list[str] = [] 1160 for is_ws_control, group in groupby(literal, key=lambda ch: ch in WS_CONTROL_CHARS_TO_DUCK): 1161 if is_ws_control: 1162 for ch in group: 1163 duckdb_char_code = WS_CONTROL_CHARS_TO_DUCK[ch] 1164 sql_segments.append(self.func("CHR", exp.Literal.number(str(duckdb_char_code)))) 1165 else: 1166 sql_segments.append(self.sql(exp.Literal.string("".join(group)))) 1167 1168 sql = " || ".join(sql_segments) 1169 return sql if len(sql_segments) == 1 else f"({sql})" 1170 1171 1172def _escape_regex_metachars( 1173 self: DuckDBGenerator, delimiters: exp.Expr | None, delimiters_sql: str 1174) -> str: 1175 r""" 1176 Escapes regex metacharacters \ - ^ [ ] for use in character classes regex expressions. 1177 1178 Literal strings are escaped at transpile time, expressions handled with REPLACE() calls. 1179 """ 1180 if not delimiters: 1181 return delimiters_sql 1182 1183 if delimiters.is_string: 1184 literal_value = delimiters.this 1185 escaped_literal = "".join(REGEX_ESCAPE_REPLACEMENTS.get(ch, ch) for ch in literal_value) 1186 return _literal_sql_with_ws_chr(self, escaped_literal) 1187 1188 escaped_sql = delimiters_sql 1189 for raw, escaped in REGEX_ESCAPE_REPLACEMENTS.items(): 1190 escaped_sql = self.func( 1191 "REPLACE", 1192 escaped_sql, 1193 self.sql(exp.Literal.string(raw)), 1194 self.sql(exp.Literal.string(escaped)), 1195 ) 1196 1197 return escaped_sql 1198 1199 1200def _build_capitalization_sql( 1201 self: DuckDBGenerator, 1202 value_to_split: str, 1203 delimiters_sql: str, 1204) -> str: 1205 # empty string delimiter --> treat value as one word, no need to split 1206 if delimiters_sql == "''": 1207 return f"UPPER(LEFT({value_to_split}, 1)) || LOWER(SUBSTRING({value_to_split}, 2))" 1208 1209 delim_regex_sql = f"CONCAT('[', {delimiters_sql}, ']')" 1210 split_regex_sql = f"CONCAT('([', {delimiters_sql}, ']+|[^', {delimiters_sql}, ']+)')" 1211 1212 # REGEXP_EXTRACT_ALL produces a list of string segments, alternating between delimiter and non-delimiter segments. 1213 # We do not know whether the first segment is a delimiter or not, so we check the first character of the string 1214 # with REGEXP_MATCHES. If the first char is a delimiter, we capitalize even list indexes, otherwise capitalize odd. 1215 return self.func( 1216 "ARRAY_TO_STRING", 1217 exp.case() 1218 .when( 1219 f"REGEXP_MATCHES(LEFT({value_to_split}, 1), {delim_regex_sql})", 1220 self.func( 1221 "LIST_TRANSFORM", 1222 self.func("REGEXP_EXTRACT_ALL", value_to_split, split_regex_sql), 1223 "(seg, idx) -> CASE WHEN idx % 2 = 0 THEN UPPER(LEFT(seg, 1)) || LOWER(SUBSTRING(seg, 2)) ELSE seg END", 1224 ), 1225 ) 1226 .else_( 1227 self.func( 1228 "LIST_TRANSFORM", 1229 self.func("REGEXP_EXTRACT_ALL", value_to_split, split_regex_sql), 1230 "(seg, idx) -> CASE WHEN idx % 2 = 1 THEN UPPER(LEFT(seg, 1)) || LOWER(SUBSTRING(seg, 2)) ELSE seg END", 1231 ), 1232 ), 1233 "''", 1234 ) 1235 1236 1237def _initcap_sql(self: DuckDBGenerator, expression: exp.Initcap) -> str: 1238 this_sql = self.sql(expression, "this") 1239 delimiters = expression.args.get("expression") 1240 if delimiters is None: 1241 # fallback for manually created exp.Initcap w/o delimiters arg 1242 delimiters = exp.Literal.string(self.dialect.INITCAP_DEFAULT_DELIMITER_CHARS) 1243 delimiters_sql = self.sql(delimiters) 1244 1245 escaped_delimiters_sql = _escape_regex_metachars(self, delimiters, delimiters_sql) 1246 1247 return _build_capitalization_sql(self, this_sql, escaped_delimiters_sql) 1248 1249 1250def _boolxor_agg_sql(self: DuckDBGenerator, expression: exp.BoolxorAgg) -> str: 1251 """ 1252 Snowflake's `BOOLXOR_AGG(col)` returns TRUE if exactly one input in `col` is TRUE, FALSE otherwise; 1253 Since DuckDB does not have a mapping function, we mimic the behavior by generating `COUNT_IF(col) = 1`. 1254 1255 DuckDB's COUNT_IF strictly requires boolean inputs, so cast if not already boolean. 1256 """ 1257 return self.sql( 1258 exp.EQ( 1259 this=exp.CountIf(this=_cast_to_boolean(expression.this)), 1260 expression=exp.Literal.number(1), 1261 ) 1262 ) 1263 1264 1265def _bitshift_sql( 1266 self: DuckDBGenerator, expression: exp.BitwiseLeftShift | exp.BitwiseRightShift 1267) -> str: 1268 """ 1269 Transform bitshift expressions for DuckDB by injecting BIT/INT128 casts. 1270 1271 DuckDB's bitwise shift operators don't work with BLOB/BINARY types, so we cast 1272 them to BIT for the operation, then cast the result back to the original type. 1273 1274 Note: Assumes type annotation has been applied with the source dialect. 1275 """ 1276 operator = "<<" if isinstance(expression, exp.BitwiseLeftShift) else ">>" 1277 result_is_blob = False 1278 this = expression.this 1279 1280 if _is_binary(this): 1281 result_is_blob = True 1282 expression.set("this", exp.cast(this, exp.DType.BIT)) 1283 elif expression.args.get("requires_int128"): 1284 this.replace(exp.cast(this, exp.DType.INT128)) 1285 1286 result_sql = self.binary(expression, operator) 1287 1288 # Wrap in parentheses if parent is a bitwise operator to "fix" DuckDB precedence issue 1289 # DuckDB parses: a << b | c << d as (a << b | c) << d 1290 if isinstance(expression.parent, exp.Binary): 1291 result_sql = self.sql(exp.Paren(this=result_sql)) 1292 1293 if result_is_blob: 1294 result_sql = self.sql( 1295 exp.Cast(this=result_sql, to=exp.DataType.from_str("BLOB", dialect="duckdb")) 1296 ) 1297 1298 return result_sql 1299 1300 1301def _scale_rounding_sql( 1302 self: DuckDBGenerator, 1303 expression: exp.Expr, 1304 rounding_func: Type[exp.Expr], 1305) -> str | None: 1306 """ 1307 Handle scale parameter transformation for rounding functions. 1308 1309 DuckDB doesn't support the scale parameter for certain functions (e.g., FLOOR, CEIL), 1310 so we transform: FUNC(x, n) to ROUND(FUNC(x * 10^n) / 10^n, n) 1311 1312 Args: 1313 self: The DuckDB generator instance 1314 expression: The expression to transform (must have 'this', 'decimals', and 'to' args) 1315 rounding_func: The rounding function class to use in the transformation 1316 1317 Returns: 1318 The transformed SQL string if decimals parameter exists, None otherwise 1319 """ 1320 decimals = expression.args.get("decimals") 1321 1322 if decimals is None or expression.args.get("to") is not None: 1323 return None 1324 1325 this = expression.this 1326 if isinstance(this, exp.Binary): 1327 this = exp.Paren(this=this) 1328 1329 n_int = decimals 1330 if not (decimals.is_int or decimals.is_type(*exp.DataType.INTEGER_TYPES)): 1331 n_int = exp.cast(decimals, exp.DType.INT) 1332 1333 pow_ = exp.Pow(this=exp.Literal.number("10"), expression=n_int) 1334 rounded = rounding_func(this=exp.Mul(this=this, expression=pow_)) 1335 result = exp.Div(this=rounded, expression=pow_.copy()) 1336 1337 return self.round_sql( 1338 exp.Round(this=result, decimals=decimals, casts_non_integer_decimals=True) 1339 ) 1340 1341 1342def _ceil_floor(self: DuckDBGenerator, expression: exp.Floor | exp.Ceil) -> str: 1343 scaled_sql = _scale_rounding_sql(self, expression, type(expression)) 1344 if scaled_sql is not None: 1345 return scaled_sql 1346 return self.ceil_floor(expression) 1347 1348 1349def _regr_val_sql( 1350 self: DuckDBGenerator, 1351 expression: exp.RegrValx | exp.RegrValy, 1352) -> str: 1353 """ 1354 Transpile Snowflake's REGR_VALX/REGR_VALY to DuckDB equivalent. 1355 1356 REGR_VALX(y, x) returns NULL if y is NULL; otherwise returns x. 1357 REGR_VALY(y, x) returns NULL if x is NULL; otherwise returns y. 1358 """ 1359 from sqlglot.optimizer.annotate_types import annotate_types 1360 1361 y = expression.this 1362 x = expression.expression 1363 1364 # Determine which argument to check for NULL and which to return based on expression type 1365 if isinstance(expression, exp.RegrValx): 1366 # REGR_VALX: check y for NULL, return x 1367 check_for_null = y 1368 return_value = x 1369 return_value_attr = "expression" 1370 else: 1371 # REGR_VALY: check x for NULL, return y 1372 check_for_null = x 1373 return_value = y 1374 return_value_attr = "this" 1375 1376 # Get the type from the return argument 1377 result_type = return_value.type 1378 1379 # If no type info, annotate the expression to infer types 1380 if not result_type or result_type.this == exp.DType.UNKNOWN: 1381 try: 1382 annotated = annotate_types(expression.copy(), dialect=self.dialect) 1383 result_type = getattr(annotated, return_value_attr).type 1384 except Exception: 1385 pass 1386 1387 # Default to DOUBLE for regression functions if type still unknown 1388 if not result_type or result_type.this == exp.DType.UNKNOWN: 1389 result_type = exp.DType.DOUBLE.into_expr() 1390 1391 # Cast NULL to the same type as return_value to avoid DuckDB type inference issues 1392 typed_null = exp.Cast(this=exp.Null(), to=result_type) 1393 1394 return self.sql( 1395 exp.If( 1396 this=exp.Is(this=check_for_null.copy(), expression=exp.Null()), 1397 true=typed_null, 1398 false=return_value.copy(), 1399 ) 1400 ) 1401 1402 1403def _maybe_corr_null_to_false( 1404 expression: exp.Filter | exp.Window | exp.Corr, 1405) -> exp.Filter | exp.Window | exp.Corr | None: 1406 corr = expression 1407 while isinstance(corr, (exp.Window, exp.Filter)): 1408 corr = corr.this 1409 1410 if not isinstance(corr, exp.Corr) or not corr.args.get("null_on_zero_variance"): 1411 return None 1412 1413 corr.set("null_on_zero_variance", False) 1414 return expression 1415 1416 1417def _date_from_parts_sql(self, expression: exp.DateFromParts) -> str: 1418 """ 1419 Snowflake's DATE_FROM_PARTS allows out-of-range values for the month and day input. 1420 E.g., larger values (month=13, day=100), zero-values (month=0, day=0), negative values (month=-13, day=-100). 1421 1422 DuckDB's MAKE_DATE does not support out-of-range values, but DuckDB's INTERVAL type does. 1423 1424 We convert to date arithmetic: 1425 DATE_FROM_PARTS(year, month, day) 1426 - MAKE_DATE(year, 1, 1) + INTERVAL (month-1) MONTH + INTERVAL (day-1) DAY 1427 """ 1428 year_expr = expression.args.get("year") 1429 month_expr = expression.args.get("month") 1430 day_expr = expression.args.get("day") 1431 1432 if expression.args.get("allow_overflow"): 1433 base_date: exp.Expr = exp.func( 1434 "MAKE_DATE", year_expr, exp.Literal.number(1), exp.Literal.number(1) 1435 ) 1436 1437 if month_expr: 1438 base_date = base_date + exp.Interval(this=month_expr - 1, unit=exp.var("MONTH")) 1439 1440 if day_expr: 1441 base_date = base_date + exp.Interval(this=day_expr - 1, unit=exp.var("DAY")) 1442 1443 return self.sql(exp.cast(expression=base_date, to=exp.DType.DATE)) 1444 1445 return self.func("MAKE_DATE", year_expr, month_expr, day_expr) 1446 1447 1448def _round_arg(arg: exp.Expr, round_input: bool | None = None) -> exp.Expr: 1449 if round_input: 1450 return exp.func("ROUND", arg, exp.Literal.number(0)) 1451 return arg 1452 1453 1454def _boolnot_sql(self: DuckDBGenerator, expression: exp.Boolnot) -> str: 1455 arg = _round_arg(expression.this, expression.args.get("round_input")) 1456 return self.sql(exp.not_(exp.paren(arg))) 1457 1458 1459def _booland_sql(self: DuckDBGenerator, expression: exp.Booland) -> str: 1460 round_input = expression.args.get("round_input") 1461 left = _round_arg(expression.this, round_input) 1462 right = _round_arg(expression.expression, round_input) 1463 return self.sql(exp.paren(exp.and_(exp.paren(left), exp.paren(right), wrap=False))) 1464 1465 1466def _boolor_sql(self: DuckDBGenerator, expression: exp.Boolor) -> str: 1467 round_input = expression.args.get("round_input") 1468 left = _round_arg(expression.this, round_input) 1469 right = _round_arg(expression.expression, round_input) 1470 return self.sql(exp.paren(exp.or_(exp.paren(left), exp.paren(right), wrap=False))) 1471 1472 1473def _xor_sql(self: DuckDBGenerator, expression: exp.Xor) -> str: 1474 round_input = expression.args.get("round_input") 1475 left = _round_arg(expression.this, round_input) 1476 right = _round_arg(expression.expression, round_input) 1477 return self.sql( 1478 exp.or_( 1479 exp.paren(exp.and_(left.copy(), exp.paren(right.not_()), wrap=False)), 1480 exp.paren(exp.and_(exp.paren(left.not_()), right.copy(), wrap=False)), 1481 wrap=False, 1482 ) 1483 ) 1484 1485 1486def _explode_to_unnest_sql(self: DuckDBGenerator, expression: exp.Lateral) -> str: 1487 """Handle LATERAL VIEW EXPLODE/INLINE conversion to UNNEST for DuckDB.""" 1488 explode = expression.this 1489 1490 if isinstance(explode, exp.Inline): 1491 # For INLINE, create CROSS JOIN LATERAL (SELECT UNNEST(..., max_depth => 2)) 1492 # Build the UNNEST call with DuckDB-style named parameter 1493 unnest_expr = exp.Unnest( 1494 expressions=[ 1495 explode.this, 1496 exp.Kwarg(this=exp.var("max_depth"), expression=exp.Literal.number(2)), 1497 ] 1498 ) 1499 select_expr = exp.Select(expressions=[unnest_expr]).subquery() 1500 1501 alias_expr = expression.args.get("alias") 1502 if alias_expr and not alias_expr.this: 1503 # we need to provide a table name if not present 1504 alias_expr.set("this", exp.to_identifier(f"_u_{expression.index}")) 1505 1506 transformed_lateral_expr = exp.Lateral(this=select_expr, alias=alias_expr) 1507 cross_join_lateral_expr = exp.Join(this=transformed_lateral_expr, kind="CROSS") 1508 1509 return self.sql(cross_join_lateral_expr) 1510 1511 # For other cases, use the standard conversion 1512 return explode_to_unnest_sql(self, expression) 1513 1514 1515def _sha_sql( 1516 self: DuckDBGenerator, 1517 expression: exp.Expr, 1518 hash_func: str, 1519 is_binary: bool = False, 1520) -> str: 1521 arg = expression.this 1522 1523 # For SHA2 variants, check digest length (DuckDB only supports SHA256) 1524 if hash_func == "SHA256": 1525 length = expression.text("length") or "256" 1526 if length != "256": 1527 self.unsupported("DuckDB only supports SHA256 hashing algorithm.") 1528 1529 # Cast if type is incompatible with DuckDB 1530 if ( 1531 arg.type 1532 and arg.type.this != exp.DType.UNKNOWN 1533 and not arg.is_type(*exp.DataType.TEXT_TYPES) 1534 and not _is_binary(arg) 1535 ): 1536 arg = exp.cast(arg, exp.DType.VARCHAR) 1537 1538 result = self.func(hash_func, arg) 1539 return self.func("UNHEX", result) if is_binary else result 1540 1541 1542class DuckDBGenerator(generator.Generator): 1543 PARAMETER_TOKEN = "$" 1544 NAMED_PLACEHOLDER_TOKEN = "$" 1545 JOIN_HINTS = False 1546 TABLE_HINTS = False 1547 QUERY_HINTS = False 1548 LIMIT_FETCH = "LIMIT" 1549 STRUCT_DELIMITER = ("(", ")") 1550 RENAME_TABLE_WITH_DB = False 1551 NVL2_SUPPORTED = False 1552 SEMI_ANTI_JOIN_WITH_SIDE = False 1553 CAST_TO_INT_ROUNDS = True 1554 SAFE_INT_DIVISION = True 1555 TABLESAMPLE_KEYWORDS = "USING SAMPLE" 1556 TABLESAMPLE_SEED_KEYWORD = "REPEATABLE" 1557 LAST_DAY_SUPPORTS_DATE_PART = False 1558 JSON_KEY_VALUE_PAIR_SEP = "," 1559 IGNORE_NULLS_IN_FUNC = True 1560 IGNORE_NULLS_BEFORE_ORDER = False 1561 JSON_PATH_BRACKETED_KEY_SUPPORTED = False 1562 SUPPORTS_CREATE_TABLE_LIKE = False 1563 MULTI_ARG_DISTINCT = False 1564 CAN_IMPLEMENT_ARRAY_ANY = True 1565 SUPPORTS_TO_NUMBER = False 1566 SELECT_KINDS: tuple[str, ...] = () 1567 SUPPORTS_DECODE_CASE = False 1568 SUPPORTS_DROP_ALTER_ICEBERG_PROPERTY = False 1569 1570 # USING SAMPLE goes after QUALIFY and before ORDER BY / LIMIT 1571 # https://duckdb.org/docs/preview/sql/query_syntax/sample 1572 AFTER_HAVING_MODIFIER_TRANSFORMS = { 1573 **generator.AFTER_HAVING_MODIFIER_TRANSFORMS, 1574 "sample": lambda self, e: self.sql(e, "sample"), 1575 } 1576 SUPPORTS_WINDOW_EXCLUDE = True 1577 COPY_HAS_INTO_KEYWORD = False 1578 STAR_EXCEPT = "EXCLUDE" 1579 PAD_FILL_PATTERN_IS_REQUIRED = True 1580 ARRAY_SIZE_DIM_REQUIRED: bool | None = False 1581 NORMALIZE_EXTRACT_DATE_PARTS = True 1582 SUPPORTS_LIKE_QUANTIFIERS = False 1583 HISTORICAL_DATA_POST_ALIAS = True 1584 SET_ASSIGNMENT_REQUIRES_VARIABLE_KEYWORD = True 1585 1586 TRANSFORMS = { 1587 **generator.Generator.TRANSFORMS, 1588 exp.AnyValue: _anyvalue_sql, 1589 exp.ApproxDistinct: approx_count_distinct_sql, 1590 exp.Boolnot: _boolnot_sql, 1591 exp.Booland: _booland_sql, 1592 exp.Boolor: _boolor_sql, 1593 exp.Array: transforms.preprocess( 1594 [transforms.inherit_struct_field_names], 1595 generator=inline_array_unless_query, 1596 ), 1597 exp.ArrayAppend: array_append_sql("LIST_APPEND"), 1598 exp.ArrayCompact: array_compact_sql, 1599 exp.ArrayConstructCompact: lambda self, e: self.sql( 1600 exp.ArrayCompact(this=exp.Array(expressions=e.expressions)) 1601 ), 1602 exp.ArrayConcat: array_concat_sql("LIST_CONCAT"), 1603 exp.ArrayContains: _array_contains_sql, 1604 exp.ArrayOverlaps: _array_overlaps_sql, 1605 exp.ArrayFilter: rename_func("LIST_FILTER"), 1606 exp.ArrayInsert: _array_insert_sql, 1607 exp.ArrayPosition: lambda self, e: ( 1608 self.sql( 1609 exp.Sub( 1610 this=exp.ArrayPosition(this=e.this, expression=e.expression), 1611 expression=exp.Literal.number(1), 1612 ) 1613 ) 1614 if e.args.get("zero_based") 1615 else self.func("ARRAY_POSITION", e.this, e.expression) 1616 ), 1617 exp.ArrayRemoveAt: _array_remove_at_sql, 1618 exp.ArrayRemove: remove_from_array_using_filter, 1619 exp.ArraySort: _array_sort_sql, 1620 exp.ArrayPrepend: array_append_sql("LIST_PREPEND", swap_params=True), 1621 exp.ArraySum: rename_func("LIST_SUM"), 1622 exp.ArrayMax: rename_func("LIST_MAX"), 1623 exp.ArrayMin: rename_func("LIST_MIN"), 1624 exp.Base64DecodeBinary: lambda self, e: _base64_decode_sql(self, e, to_string=False), 1625 exp.Base64DecodeString: lambda self, e: _base64_decode_sql(self, e, to_string=True), 1626 exp.BitwiseAnd: lambda self, e: self._bitwise_op(e, "&"), 1627 exp.BitwiseAndAgg: _bitwise_agg_sql, 1628 exp.BitwiseCount: rename_func("BIT_COUNT"), 1629 exp.BitwiseLeftShift: _bitshift_sql, 1630 exp.BitwiseOr: lambda self, e: self._bitwise_op(e, "|"), 1631 exp.BitwiseOrAgg: _bitwise_agg_sql, 1632 exp.BitwiseRightShift: _bitshift_sql, 1633 exp.BitwiseXorAgg: _bitwise_agg_sql, 1634 exp.CommentColumnConstraint: no_comment_column_constraint_sql, 1635 exp.Corr: lambda self, e: self._corr_sql(e), 1636 exp.CosineDistance: rename_func("LIST_COSINE_DISTANCE"), 1637 exp.CurrentTime: lambda *_: "CURRENT_TIME", 1638 exp.CurrentSchemas: lambda self, e: self.func( 1639 "current_schemas", e.this if e.this else exp.true() 1640 ), 1641 exp.CurrentTimestamp: lambda self, e: ( 1642 self.sql( 1643 exp.AtTimeZone(this=exp.var("CURRENT_TIMESTAMP"), zone=exp.Literal.string("UTC")) 1644 ) 1645 if e.args.get("sysdate") 1646 else "CURRENT_TIMESTAMP" 1647 ), 1648 exp.CurrentVersion: rename_func("version"), 1649 exp.Localtime: unsupported_args("this")(lambda *_: "LOCALTIME"), 1650 exp.DayOfMonth: rename_func("DAYOFMONTH"), 1651 exp.DayOfWeek: rename_func("DAYOFWEEK"), 1652 exp.DayOfWeekIso: rename_func("ISODOW"), 1653 exp.DayOfYear: rename_func("DAYOFYEAR"), 1654 exp.Dayname: lambda self, e: ( 1655 self.func("STRFTIME", e.this, exp.Literal.string("%a")) 1656 if e.args.get("abbreviated") 1657 else self.func("DAYNAME", e.this) 1658 ), 1659 exp.Monthname: lambda self, e: ( 1660 self.func("STRFTIME", e.this, exp.Literal.string("%b")) 1661 if e.args.get("abbreviated") 1662 else self.func("MONTHNAME", e.this) 1663 ), 1664 exp.Date: _date_sql, 1665 exp.DateAdd: _date_delta_to_binary_interval_op(), 1666 exp.DateFromParts: _date_from_parts_sql, 1667 exp.DateSub: _date_delta_to_binary_interval_op(), 1668 exp.DateDiff: _date_diff_sql, 1669 exp.DateStrToDate: datestrtodate_sql, 1670 exp.Datetime: no_datetime_sql, 1671 exp.DatetimeDiff: _date_diff_sql, 1672 exp.DatetimeSub: _date_delta_to_binary_interval_op(), 1673 exp.DatetimeAdd: _date_delta_to_binary_interval_op(), 1674 exp.DateToDi: lambda self, e: ( 1675 f"CAST(STRFTIME({self.sql(e, 'this')}, {self.dialect.DATEINT_FORMAT}) AS INT)" 1676 ), 1677 exp.Decode: lambda self, e: encode_decode_sql(self, e, "DECODE", replace=False), 1678 exp.HexDecodeString: lambda self, e: self.sql(exp.Decode(this=exp.Unhex(this=e.this))), 1679 exp.DiToDate: lambda self, e: ( 1680 f"CAST(STRPTIME(CAST({self.sql(e, 'this')} AS TEXT), {self.dialect.DATEINT_FORMAT}) AS DATE)" 1681 ), 1682 exp.Encode: lambda self, e: encode_decode_sql(self, e, "ENCODE", replace=False), 1683 exp.EqualNull: lambda self, e: self.sql( 1684 exp.NullSafeEQ(this=e.this, expression=e.expression) 1685 ), 1686 exp.EuclideanDistance: rename_func("LIST_DISTANCE"), 1687 exp.GenerateDateArray: _generate_datetime_array_sql, 1688 exp.GenerateSeries: generate_series_sql("GENERATE_SERIES", "RANGE"), 1689 exp.GenerateTimestampArray: _generate_datetime_array_sql, 1690 exp.Getbit: getbit_sql, 1691 exp.GroupConcat: lambda self, e: groupconcat_sql(self, e, within_group=False), 1692 exp.Explode: rename_func("UNNEST"), 1693 exp.IcebergProperty: lambda *_: "", 1694 exp.IntDiv: lambda self, e: self.binary(e, "//"), 1695 exp.IsInf: rename_func("ISINF"), 1696 exp.IsNan: rename_func("ISNAN"), 1697 exp.IsNullValue: lambda self, e: self.sql( 1698 exp.func("JSON_TYPE", e.this).eq(exp.Literal.string("NULL")) 1699 ), 1700 exp.IsArray: lambda self, e: self.sql( 1701 exp.func("JSON_TYPE", e.this).eq(exp.Literal.string("ARRAY")) 1702 ), 1703 exp.Ceil: _ceil_floor, 1704 exp.Floor: _ceil_floor, 1705 exp.JSONBExists: rename_func("JSON_EXISTS"), 1706 exp.JSONExtract: _arrow_json_extract_sql, 1707 exp.JSONExtractArray: _json_extract_value_array_sql, 1708 exp.JSONFormat: _json_format_sql, 1709 exp.JSONValueArray: _json_extract_value_array_sql, 1710 exp.Lateral: _explode_to_unnest_sql, 1711 exp.LogicalOr: lambda self, e: self.func("BOOL_OR", _cast_to_boolean(e.this)), 1712 exp.LogicalAnd: lambda self, e: self.func("BOOL_AND", _cast_to_boolean(e.this)), 1713 exp.Select: transforms.preprocess( 1714 [connect_by_to_recursive_cte, _seq_to_range_in_generator] 1715 ), 1716 exp.Seq1: lambda self, e: _seq_sql(self, e, 1), 1717 exp.Seq2: lambda self, e: _seq_sql(self, e, 2), 1718 exp.Seq4: lambda self, e: _seq_sql(self, e, 4), 1719 exp.Seq8: lambda self, e: _seq_sql(self, e, 8), 1720 exp.BoolxorAgg: _boolxor_agg_sql, 1721 exp.MakeInterval: lambda self, e: no_make_interval_sql(self, e, sep=" "), 1722 exp.Initcap: _initcap_sql, 1723 exp.MD5Digest: lambda self, e: self.func("UNHEX", self.func("MD5", e.this)), 1724 exp.SHA: lambda self, e: _sha_sql(self, e, "SHA1"), 1725 exp.SHA1Digest: lambda self, e: _sha_sql(self, e, "SHA1", is_binary=True), 1726 exp.SHA2: lambda self, e: _sha_sql(self, e, "SHA256"), 1727 exp.SHA2Digest: lambda self, e: _sha_sql(self, e, "SHA256", is_binary=True), 1728 exp.MonthsBetween: months_between_sql, 1729 exp.NextDay: _day_navigation_sql, 1730 exp.PercentileCont: rename_func("QUANTILE_CONT"), 1731 exp.PercentileDisc: rename_func("QUANTILE_DISC"), 1732 # DuckDB doesn't allow qualified columns inside of PIVOT expressions. 1733 # See: https://github.com/duckdb/duckdb/blob/671faf92411182f81dce42ac43de8bfb05d9909e/src/planner/binder/tableref/bind_pivot.cpp#L61-L62 1734 exp.Pivot: transforms.preprocess([transforms.unqualify_columns]), 1735 exp.PreviousDay: _day_navigation_sql, 1736 exp.RegexpILike: lambda self, e: self.func( 1737 "REGEXP_MATCHES", e.this, e.expression, exp.Literal.string("i") 1738 ), 1739 exp.RegexpSplit: rename_func("STR_SPLIT_REGEX"), 1740 exp.RegrValx: _regr_val_sql, 1741 exp.RegrValy: _regr_val_sql, 1742 exp.Return: lambda self, e: self.sql(e, "this"), 1743 exp.ReturnsProperty: lambda self, e: "TABLE" if isinstance(e.this, exp.Schema) else "", 1744 exp.StrToUnix: lambda self, e: self.func( 1745 "EPOCH", self.func("STRPTIME", e.this, self.format_time(e)) 1746 ), 1747 exp.Struct: _struct_sql, 1748 exp.Transform: rename_func("LIST_TRANSFORM"), 1749 exp.TimeAdd: _date_delta_to_binary_interval_op(), 1750 exp.TimeSub: _date_delta_to_binary_interval_op(), 1751 exp.Time: no_time_sql, 1752 exp.TimeDiff: _timediff_sql, 1753 exp.Timestamp: no_timestamp_sql, 1754 exp.TimestampAdd: _date_delta_to_binary_interval_op(), 1755 exp.TimestampDiff: lambda self, e: self.func( 1756 "DATE_DIFF", exp.Literal.string(e.unit), e.expression, e.this 1757 ), 1758 exp.TimestampSub: _date_delta_to_binary_interval_op(), 1759 exp.TimeStrToDate: lambda self, e: self.sql(exp.cast(e.this, exp.DType.DATE)), 1760 exp.TimeStrToTime: timestrtotime_sql, 1761 exp.TimeStrToUnix: lambda self, e: self.func( 1762 "EPOCH", exp.cast(e.this, exp.DType.TIMESTAMP) 1763 ), 1764 exp.TimeToStr: lambda self, e: self.func("STRFTIME", e.this, self.format_time(e)), 1765 exp.ToBoolean: _to_boolean_sql, 1766 exp.ToVariant: lambda self, e: self.sql( 1767 exp.cast(e.this, exp.DataType.from_str("VARIANT", dialect="duckdb")) 1768 ), 1769 exp.TimeToUnix: rename_func("EPOCH"), 1770 exp.TsOrDiToDi: lambda self, e: ( 1771 f"CAST(SUBSTR(REPLACE(CAST({self.sql(e, 'this')} AS TEXT), '-', ''), 1, 8) AS INT)" 1772 ), 1773 exp.TsOrDsAdd: _date_delta_to_binary_interval_op(), 1774 exp.TsOrDsDiff: lambda self, e: self.func( 1775 "DATE_DIFF", 1776 f"'{e.args.get('unit') or 'DAY'}'", 1777 exp.cast(e.expression, exp.DType.TIMESTAMP), 1778 exp.cast(e.this, exp.DType.TIMESTAMP), 1779 ), 1780 exp.UnixMicros: lambda self, e: self.func("EPOCH_US", _implicit_datetime_cast(e.this)), 1781 exp.UnixMillis: lambda self, e: self.func("EPOCH_MS", _implicit_datetime_cast(e.this)), 1782 exp.UnixSeconds: lambda self, e: self.sql( 1783 exp.cast(self.func("EPOCH", _implicit_datetime_cast(e.this)), exp.DType.BIGINT) 1784 ), 1785 exp.UnixToStr: lambda self, e: self.func( 1786 "STRFTIME", self.func("TO_TIMESTAMP", e.this), self.format_time(e) 1787 ), 1788 exp.UnixToTime: _unix_to_time_sql, 1789 exp.UnixToTimeStr: lambda self, e: f"CAST(TO_TIMESTAMP({self.sql(e, 'this')}) AS TEXT)", 1790 exp.VariancePop: rename_func("VAR_POP"), 1791 exp.WeekOfYear: rename_func("WEEKOFYEAR"), 1792 exp.YearOfWeek: lambda self, e: self.sql( 1793 exp.Extract( 1794 this=exp.Var(this="ISOYEAR"), 1795 expression=e.this, 1796 ) 1797 ), 1798 exp.YearOfWeekIso: lambda self, e: self.sql( 1799 exp.Extract( 1800 this=exp.Var(this="ISOYEAR"), 1801 expression=e.this, 1802 ) 1803 ), 1804 exp.Xor: _xor_sql, 1805 exp.JSONObjectAgg: rename_func("JSON_GROUP_OBJECT"), 1806 exp.JSONBObjectAgg: rename_func("JSON_GROUP_OBJECT"), 1807 exp.DateBin: rename_func("TIME_BUCKET"), 1808 exp.LastDay: _last_day_sql, 1809 } 1810 1811 SUPPORTED_JSON_PATH_PARTS = { 1812 exp.JSONPathKey, 1813 exp.JSONPathRoot, 1814 exp.JSONPathSubscript, 1815 exp.JSONPathWildcard, 1816 } 1817 1818 TYPE_MAPPING = { 1819 **generator.Generator.TYPE_MAPPING, 1820 exp.DType.BINARY: "BLOB", 1821 exp.DType.BPCHAR: "TEXT", 1822 exp.DType.CHAR: "TEXT", 1823 exp.DType.DATETIME: "TIMESTAMP", 1824 exp.DType.DECFLOAT: "DECIMAL", 1825 exp.DType.FLOAT: "REAL", 1826 exp.DType.JSONB: "JSON", 1827 exp.DType.NCHAR: "TEXT", 1828 exp.DType.NVARCHAR: "TEXT", 1829 exp.DType.UINT: "UINTEGER", 1830 exp.DType.VARBINARY: "BLOB", 1831 exp.DType.ROWVERSION: "BLOB", 1832 exp.DType.VARCHAR: "TEXT", 1833 exp.DType.TIMESTAMPLTZ: "TIMESTAMPTZ", 1834 exp.DType.TIMESTAMPNTZ: "TIMESTAMP", 1835 exp.DType.TIMESTAMP_S: "TIMESTAMP_S", 1836 exp.DType.TIMESTAMP_MS: "TIMESTAMP_MS", 1837 exp.DType.TIMESTAMP_NS: "TIMESTAMP_NS", 1838 exp.DType.BIGDECIMAL: "DECIMAL", 1839 } 1840 1841 TYPE_PARAM_SETTINGS = { 1842 **generator.Generator.TYPE_PARAM_SETTINGS, 1843 exp.DType.BIGDECIMAL: ((38, 5), (38, 38)), 1844 exp.DType.DECFLOAT: ((38, 5), (38, 38)), 1845 } 1846 1847 # https://github.com/duckdb/duckdb/blob/ff7f24fd8e3128d94371827523dae85ebaf58713/third_party/libpg_query/grammar/keywords/reserved_keywords.list#L1-L77 1848 RESERVED_KEYWORDS = { 1849 "array", 1850 "analyse", 1851 "union", 1852 "all", 1853 "when", 1854 "in_p", 1855 "default", 1856 "create_p", 1857 "window", 1858 "asymmetric", 1859 "to", 1860 "else", 1861 "localtime", 1862 "from", 1863 "end_p", 1864 "select", 1865 "current_date", 1866 "foreign", 1867 "with", 1868 "grant", 1869 "session_user", 1870 "or", 1871 "except", 1872 "references", 1873 "fetch", 1874 "limit", 1875 "group_p", 1876 "leading", 1877 "into", 1878 "collate", 1879 "offset", 1880 "do", 1881 "then", 1882 "localtimestamp", 1883 "check_p", 1884 "lateral_p", 1885 "current_role", 1886 "where", 1887 "asc_p", 1888 "placing", 1889 "desc_p", 1890 "user", 1891 "unique", 1892 "initially", 1893 "column", 1894 "both", 1895 "some", 1896 "as", 1897 "any", 1898 "only", 1899 "deferrable", 1900 "null_p", 1901 "current_time", 1902 "true_p", 1903 "table", 1904 "case", 1905 "trailing", 1906 "variadic", 1907 "for", 1908 "on", 1909 "distinct", 1910 "false_p", 1911 "not", 1912 "constraint", 1913 "current_timestamp", 1914 "returning", 1915 "primary", 1916 "intersect", 1917 "having", 1918 "analyze", 1919 "current_user", 1920 "and", 1921 "cast", 1922 "symmetric", 1923 "using", 1924 "order", 1925 "current_catalog", 1926 } 1927 1928 UNWRAPPED_INTERVAL_VALUES = (exp.Literal, exp.Paren) 1929 1930 # DuckDB doesn't generally support CREATE TABLE .. properties 1931 # https://duckdb.org/docs/sql/statements/create_table.html 1932 # There are a few exceptions (e.g. temporary tables) which are supported or 1933 # can be transpiled to DuckDB, so we explicitly override them accordingly 1934 PROPERTIES_LOCATION = { 1935 **{ 1936 prop: exp.Properties.Location.UNSUPPORTED 1937 for prop in generator.Generator.PROPERTIES_LOCATION 1938 }, 1939 exp.LikeProperty: exp.Properties.Location.POST_SCHEMA, 1940 exp.TemporaryProperty: exp.Properties.Location.POST_CREATE, 1941 exp.ReturnsProperty: exp.Properties.Location.POST_ALIAS, 1942 exp.SequenceProperties: exp.Properties.Location.POST_EXPRESSION, 1943 exp.IcebergProperty: exp.Properties.Location.POST_CREATE, 1944 } 1945 1946 IGNORE_RESPECT_NULLS_WINDOW_FUNCTIONS: t.ClassVar = _IGNORE_RESPECT_NULLS_WINDOW_FUNCTIONS 1947 1948 # Template for ZIPF transpilation - placeholders get replaced with actual parameters 1949 ZIPF_TEMPLATE: t.ClassVar[exp.Expr] = exp.maybe_parse( 1950 """ 1951 WITH rand AS (SELECT :random_expr AS r), 1952 weights AS ( 1953 SELECT i, 1.0 / POWER(i, :s) AS w 1954 FROM RANGE(1, :n + 1) AS t(i) 1955 ), 1956 cdf AS ( 1957 SELECT i, SUM(w) OVER (ORDER BY i) / SUM(w) OVER () AS p 1958 FROM weights 1959 ) 1960 SELECT MIN(i) 1961 FROM cdf 1962 WHERE p >= (SELECT r FROM rand) 1963 """ 1964 ) 1965 1966 # Template for NORMAL transpilation using Box-Muller transform 1967 # mean + (stddev * sqrt(-2 * ln(u1)) * cos(2 * pi * u2)) 1968 NORMAL_TEMPLATE: t.ClassVar[exp.Expr] = exp.maybe_parse( 1969 ":mean + (:stddev * SQRT(-2 * LN(GREATEST(:u1, 1e-10))) * COS(2 * PI() * :u2))" 1970 ) 1971 1972 # Template for generating a seeded pseudo-random value in [0, 1) from a hash 1973 SEEDED_RANDOM_TEMPLATE: t.ClassVar[exp.Expr] = exp.maybe_parse( 1974 "(ABS(HASH(:seed)) % 1000000) / 1000000.0" 1975 ) 1976 1977 # Template for generating signed and unsigned SEQ values within a specified range 1978 SEQ_UNSIGNED: t.ClassVar[exp.Expr] = _SEQ_UNSIGNED 1979 SEQ_SIGNED: t.ClassVar[exp.Expr] = _SEQ_SIGNED 1980 1981 # Template for MAP_CAT transpilation - Snowflake semantics: 1982 # 1. Returns NULL if either input is NULL 1983 # 2. For duplicate keys, prefers non-NULL value (COALESCE(m2[k], m1[k])) 1984 # 3. Filters out entries with NULL values from the result 1985 MAPCAT_TEMPLATE: t.ClassVar[exp.Expr] = exp.maybe_parse( 1986 """ 1987 CASE 1988 WHEN :map1 IS NULL OR :map2 IS NULL THEN NULL 1989 ELSE MAP_FROM_ENTRIES(LIST_FILTER(LIST_TRANSFORM( 1990 LIST_DISTINCT(LIST_CONCAT(MAP_KEYS(:map1), MAP_KEYS(:map2))), 1991 __k -> STRUCT_PACK(key := __k, value := COALESCE(:map2[__k], :map1[__k])) 1992 ), __x -> __x.value IS NOT NULL)) 1993 END 1994 """ 1995 ) 1996 1997 # Mappings for EXTRACT/DATE_PART transpilation 1998 # Maps Snowflake specifiers unsupported in DuckDB to strftime format codes 1999 EXTRACT_STRFTIME_MAPPINGS: t.ClassVar[dict[str, tuple[str, str]]] = { 2000 "WEEKISO": ("%V", "INTEGER"), 2001 "YEAROFWEEK": ("%G", "INTEGER"), 2002 "YEAROFWEEKISO": ("%G", "INTEGER"), 2003 "NANOSECOND": ("%n", "BIGINT"), 2004 } 2005 2006 # Maps epoch-based specifiers to DuckDB epoch functions 2007 EXTRACT_EPOCH_MAPPINGS: t.ClassVar[dict[str, str]] = { 2008 "EPOCH_SECOND": "EPOCH", 2009 "EPOCH_MILLISECOND": "EPOCH_MS", 2010 "EPOCH_MICROSECOND": "EPOCH_US", 2011 "EPOCH_NANOSECOND": "EPOCH_NS", 2012 } 2013 2014 # Template for BITMAP_CONSTRUCT_AGG transpilation 2015 # 2016 # BACKGROUND: 2017 # Snowflake's BITMAP_CONSTRUCT_AGG aggregates integers into a compact binary bitmap. 2018 # Supports values in range 0-32767, this version returns NULL if any value is out of range 2019 # See: https://docs.snowflake.com/en/sql-reference/functions/bitmap_construct_agg 2020 # See: https://docs.snowflake.com/en/user-guide/querying-bitmaps-for-distinct-counts 2021 # 2022 # Snowflake uses two different formats based on the number of unique values: 2023 # 2024 # Format 1 - Small bitmap (< 5 unique values): Length of 10 bytes 2025 # Bytes 0-1: Count of values as 2-byte big-endian integer (e.g., 3 values = 0x0003) 2026 # Bytes 2-9: Up to 4 values, each as 2-byte little-endian integers, zero-padded to 8 bytes 2027 # Example: Values [1, 2, 3] -> 0x0003 0100 0200 0300 0000 (hex) 2028 # count v1 v2 v3 pad 2029 # 2030 # Format 2 - Large bitmap (>= 5 unique values): Length of 10 + (2 * count) bytes 2031 # Bytes 0-9: Fixed header 0x08 followed by 9 zero bytes 2032 # Bytes 10+: Each value as 2-byte little-endian integer (no padding) 2033 # Example: Values [1,2,3,4,5] -> 0x08 00000000 00000000 00 0100 0200 0300 0400 0500 2034 # hdr ----9 zero bytes---- v1 v2 v3 v4 v5 2035 # 2036 # TEMPLATE STRUCTURE 2037 # 2038 # Phase 1 - Innermost subquery: Data preparation 2039 # SELECT LIST_SORT(...) AS l 2040 # - Aggregates all input values into a list, remove NULLs, duplicates and sorts 2041 # Result: Clean, sorted list of unique non-null integers stored as 'l' 2042 # 2043 # Phase 2 - Middle subquery: Hex string construction 2044 # LIST_TRANSFORM(...) 2045 # - Converts each integer to 2-byte little-endian hex representation 2046 # - & 255 extracts low byte, >> 8 extracts high byte 2047 # - LIST_REDUCE: Concatenates all hex pairs into single string 'h' 2048 # Result: Hex string of all values 2049 # 2050 # Phase 3 - Outer SELECT: Final bitmap assembly 2051 # LENGTH(l) < 5: 2052 # - Small format: 2-byte count (big-endian via %04X) + values + zero padding 2053 # LENGTH(l) >= 5: 2054 # - Large format: Fixed 10-byte header + values (no padding needed) 2055 # Result: Complete binary bitmap as BLOB 2056 # 2057 BITMAP_CONSTRUCT_AGG_TEMPLATE: t.ClassVar[exp.Expr] = exp.maybe_parse( 2058 """ 2059 SELECT CASE 2060 WHEN l IS NULL OR LENGTH(l) = 0 THEN NULL 2061 WHEN LENGTH(l) != LENGTH(LIST_FILTER(l, __v -> __v BETWEEN 0 AND 32767)) THEN NULL 2062 WHEN LENGTH(l) < 5 THEN UNHEX(PRINTF('%04X', LENGTH(l)) || h || REPEAT('00', GREATEST(0, 4 - LENGTH(l)) * 2)) 2063 ELSE UNHEX('08000000000000000000' || h) 2064 END 2065 FROM ( 2066 SELECT l, COALESCE(LIST_REDUCE( 2067 LIST_TRANSFORM(l, __x -> PRINTF('%02X%02X', CAST(__x AS INT) & 255, (CAST(__x AS INT) >> 8) & 255)), 2068 (__a, __b) -> __a || __b, '' 2069 ), '') AS h 2070 FROM (SELECT LIST_SORT(LIST_DISTINCT(LIST(:arg) FILTER(NOT :arg IS NULL))) AS l) 2071 ) 2072 """ 2073 ) 2074 2075 # Template for RANDSTR transpilation - placeholders get replaced with actual parameters 2076 RANDSTR_TEMPLATE: t.ClassVar[exp.Expr] = exp.maybe_parse( 2077 f""" 2078 SELECT LISTAGG( 2079 SUBSTRING( 2080 '{RANDSTR_CHAR_POOL}', 2081 1 + CAST(FLOOR(random_value * 62) AS INT), 2082 1 2083 ), 2084 '' 2085 ) 2086 FROM ( 2087 SELECT (ABS(HASH(i + :seed)) % 1000) / 1000.0 AS random_value 2088 FROM RANGE(:length) AS t(i) 2089 ) 2090 """, 2091 ) 2092 2093 # Template for MINHASH transpilation 2094 # Computes k minimum hash values across aggregated data using DuckDB list functions 2095 # Returns JSON matching Snowflake format: {"state": [...], "type": "minhash", "version": 1} 2096 MINHASH_TEMPLATE: t.ClassVar[exp.Expr] = exp.maybe_parse( 2097 """ 2098 SELECT JSON_OBJECT('state', LIST(min_h ORDER BY seed), 'type', 'minhash', 'version', 1) 2099 FROM ( 2100 SELECT seed, LIST_MIN(LIST_TRANSFORM(vals, __v -> HASH(CAST(__v AS VARCHAR) || CAST(seed AS VARCHAR)))) AS min_h 2101 FROM (SELECT LIST(:expr) AS vals), RANGE(0, :k) AS t(seed) 2102 ) 2103 """, 2104 ) 2105 2106 # Template for MINHASH_COMBINE transpilation 2107 # Combines multiple minhash signatures by taking element-wise minimum 2108 MINHASH_COMBINE_TEMPLATE: t.ClassVar[exp.Expr] = exp.maybe_parse( 2109 """ 2110 SELECT JSON_OBJECT('state', LIST(min_h ORDER BY idx), 'type', 'minhash', 'version', 1) 2111 FROM ( 2112 SELECT 2113 pos AS idx, 2114 MIN(val) AS min_h 2115 FROM 2116 UNNEST(LIST(:expr)) AS _(sig), 2117 UNNEST(CAST(sig -> 'state' AS UBIGINT[])) WITH ORDINALITY AS t(val, pos) 2118 GROUP BY pos 2119 ) 2120 """, 2121 ) 2122 2123 # Template for APPROXIMATE_SIMILARITY transpilation 2124 # Computes multi-way Jaccard similarity: fraction of positions where ALL signatures agree 2125 APPROXIMATE_SIMILARITY_TEMPLATE: t.ClassVar[exp.Expr] = exp.maybe_parse( 2126 """ 2127 SELECT CAST(SUM(CASE WHEN num_distinct = 1 THEN 1 ELSE 0 END) AS DOUBLE) / COUNT(*) 2128 FROM ( 2129 SELECT pos, COUNT(DISTINCT h) AS num_distinct 2130 FROM ( 2131 SELECT h, pos 2132 FROM UNNEST(LIST(:expr)) AS _(sig), 2133 UNNEST(CAST(sig -> 'state' AS UBIGINT[])) WITH ORDINALITY AS s(h, pos) 2134 ) 2135 GROUP BY pos 2136 ) 2137 """, 2138 ) 2139 2140 # Template for ARRAYS_ZIP transpilation 2141 # Snowflake pads to longest array; DuckDB LIST_ZIP truncates to shortest 2142 # Uses RANGE + indexing to match Snowflake behavior 2143 ARRAYS_ZIP_TEMPLATE: t.ClassVar[exp.Expr] = exp.maybe_parse( 2144 """ 2145 CASE WHEN :null_check THEN NULL 2146 WHEN :all_empty_check THEN [:empty_struct] 2147 ELSE LIST_TRANSFORM(RANGE(0, :max_len), __i -> :transform_struct) 2148 END 2149 """, 2150 ) 2151 2152 UUID_V5_TEMPLATE: t.ClassVar[exp.Expr] = exp.maybe_parse( 2153 """ 2154 (SELECT 2155 LOWER( 2156 SUBSTR(h, 1, 8) || '-' || 2157 SUBSTR(h, 9, 4) || '-' || 2158 '5' || SUBSTR(h, 14, 3) || '-' || 2159 FORMAT('{:02x}', CAST('0x' || SUBSTR(h, 17, 2) AS INT) & 63 | 128) || SUBSTR(h, 19, 2) || '-' || 2160 SUBSTR(h, 21, 12) 2161 ) 2162 FROM ( 2163 SELECT SUBSTR(SHA1(UNHEX(REPLACE(:namespace, '-', '')) || ENCODE(:name, 'utf8')), 1, 32) AS h 2164 )) 2165 """ 2166 ) 2167 2168 # Shared bag semantics outer frame for ARRAY_EXCEPT and ARRAY_INTERSECTION. 2169 # Each element is paired with its 1-based position via LIST_ZIP, then filtered 2170 # by a comparison operator (supplied via :cond) that determines the operation: 2171 # EXCEPT (>): keep the N-th occurrence only if N > count in arr2 2172 # e.g. [2,2,2] EXCEPT [2,2] -> [2] 2173 # INTERSECTION (<=): keep the N-th occurrence only if N <= count in arr2 2174 # e.g. [2,2,2] INTERSECT [2,2] -> [2,2] 2175 # IS NOT DISTINCT FROM is used for NULL-safe element comparison. 2176 ARRAY_BAG_TEMPLATE: t.ClassVar[exp.Expr] = exp.maybe_parse( 2177 """ 2178 CASE 2179 WHEN :arr1 IS NULL OR :arr2 IS NULL THEN NULL 2180 ELSE LIST_TRANSFORM( 2181 LIST_FILTER( 2182 LIST_ZIP(:arr1, GENERATE_SERIES(1, LEN(:arr1))), 2183 pair -> :cond 2184 ), 2185 pair -> pair[0] 2186 ) 2187 END 2188 """ 2189 ) 2190 2191 ARRAY_EXCEPT_CONDITION: t.ClassVar[exp.Expr] = exp.maybe_parse( 2192 "LEN(LIST_FILTER(:arr1[1:pair[1]], e -> e IS NOT DISTINCT FROM pair[0]))" 2193 " > LEN(LIST_FILTER(:arr2, e -> e IS NOT DISTINCT FROM pair[0]))" 2194 ) 2195 2196 ARRAY_INTERSECTION_CONDITION: t.ClassVar[exp.Expr] = exp.maybe_parse( 2197 "LEN(LIST_FILTER(:arr1[1:pair[1]], e -> e IS NOT DISTINCT FROM pair[0]))" 2198 " <= LEN(LIST_FILTER(:arr2, e -> e IS NOT DISTINCT FROM pair[0]))" 2199 ) 2200 2201 # Set semantics for ARRAY_EXCEPT. Deduplicates arr1 via LIST_DISTINCT, then 2202 # filters out any element that appears at least once in arr2. 2203 # e.g. [1,1,2,3] EXCEPT [1] -> [2,3] 2204 # IS NOT DISTINCT FROM is used for NULL-safe element comparison. 2205 ARRAY_EXCEPT_SET_TEMPLATE: t.ClassVar[exp.Expr] = exp.maybe_parse( 2206 """ 2207 CASE 2208 WHEN :arr1 IS NULL OR :arr2 IS NULL THEN NULL 2209 ELSE LIST_FILTER( 2210 LIST_DISTINCT(:arr1), 2211 e -> LEN(LIST_FILTER(:arr2, x -> x IS NOT DISTINCT FROM e)) = 0 2212 ) 2213 END 2214 """ 2215 ) 2216 2217 # BigQuery's `x IN UNNEST(arr)` NULL semantics: 2218 # NULL IN UNNEST([1, 2]) -> NULL 2219 # 3 IN UNNEST([1, NULL]) -> NULL 2220 # 3 IN UNNEST([1, 2]) -> FALSE 2221 # 1 IN UNNEST(NULL) -> FALSE (not NULL) 2222 # 1 IN UNNEST([]) -> FALSE 2223 # The default `IN (SELECT UNNEST(...))` rewrite creates a correlated subquery 2224 # that DuckDB rejects inside non-inner joins, so a CASE expression is used instead. 2225 IN_UNNEST_TEMPLATE: t.ClassVar[exp.Expr] = exp.maybe_parse( 2226 """ 2227 CASE 2228 WHEN :arr IS NULL OR ARRAY_LENGTH(:arr) = 0 THEN FALSE 2229 WHEN ARRAY_CONTAINS(:arr, :value) THEN TRUE 2230 WHEN :value IS NULL OR ARRAY_LENGTH(:arr) <> LIST_COUNT(:arr) THEN NULL 2231 ELSE FALSE 2232 END 2233 """ 2234 ) 2235 2236 STRTOK_TO_ARRAY_TEMPLATE: t.ClassVar[exp.Expr] = exp.maybe_parse( 2237 """ 2238 CASE WHEN :delimiter IS NULL THEN NULL 2239 ELSE LIST_FILTER( 2240 REGEXP_SPLIT_TO_ARRAY(:string, CASE WHEN :delimiter = '' THEN '.^' ELSE CONCAT('[', :escaped, ']') END), 2241 x -> NOT x = '' 2242 ) END 2243 """ 2244 ) 2245 2246 # Template for STRTOK function transpilation 2247 # 2248 # DuckDB itself doesn't have a strtok function. This handles the transpilation from Snowflake to DuckDB. 2249 # We may need to adjust this if we want to support transpilation from other dialects 2250 # 2251 # CASE 2252 # -- Snowflake: empty delimiter + empty input string -> NULL 2253 # WHEN delimiter = '' AND input_str = '' THEN NULL 2254 # 2255 # -- Snowflake: empty delimiter + non-empty input string -> treats whole input as 1 token -> return input string if index is 1 2256 # WHEN delimiter = '' AND index = 1 THEN input_str 2257 # 2258 # -- Snowflake: empty delimiter + non-empty input string -> treats whole input as 1 token -> return NULL if index is not 1 2259 # WHEN delimiter = '' THEN NULL 2260 # 2261 # -- Snowflake: negative indices return NULL 2262 # WHEN index < 0 THEN NULL 2263 # 2264 # -- Snowflake: return NULL if any argument is NULL 2265 # WHEN input_str IS NULL OR delimiter IS NULL OR index IS NULL THEN NULL 2266 # 2267 # 2268 # ELSE LIST_FILTER( 2269 # REGEXP_SPLIT_TO_ARRAY( 2270 # input_str, 2271 # CASE 2272 # -- if delimiter is '', we don't want to surround it with '[' and ']' as '[]' is invalid for DuckDB 2273 # WHEN delimiter = '' THEN '' 2274 # 2275 # -- handle problematic regex characters in delimiter with REGEXP_REPLACE 2276 # -- turn delimiter into a regex char set, otherwise DuckDB will match in order, which we don't want 2277 # ELSE '[' || REGEXP_REPLACE(delimiter, problematic_char_set, '\\\1', 'g') || ']' 2278 # END 2279 # ), 2280 # 2281 # -- Snowflake: don't return empty strings 2282 # x -> NOT x = '' 2283 # )[index] 2284 # END 2285 STRTOK_TEMPLATE: t.ClassVar[exp.Expr] = exp.maybe_parse( 2286 """ 2287 CASE 2288 WHEN :delimiter = '' AND :string = '' THEN NULL 2289 WHEN :delimiter = '' AND :part_index = 1 THEN :string 2290 WHEN :delimiter = '' THEN NULL 2291 WHEN :part_index < 0 THEN NULL 2292 WHEN :string IS NULL OR :delimiter IS NULL OR :part_index IS NULL THEN NULL 2293 ELSE :base_func 2294 END 2295 """ 2296 ) 2297 2298 # Snowflake AUTO detects 3 DATE formats: YYYY-MM-DD (ISO-8601), MM/DD/YYYY, DD-MON-YYYY. 2299 # DuckDB TRY_CAST handles ISO-8601 natively. For the other two formats we use CONTAINS('/') 2300 # and REGEXP_MATCHES('[A-Za-z]') as heuristics — these correctly handle single-digit months 2301 # and days (e.g. 1/5/2020, 5-JAN-2020) where a positional char check would fail. 2302 # Ref: https://docs.snowflake.com/en/sql-reference/date-time-input-output#date-formats 2303 _TRYCAST_DATE_SLASH_FMT = "%m/%d/%Y" 2304 _TRYCAST_DATE_MON_FMT = "%d-%b-%Y" 2305 2306 def datatype_sql(self, expression: exp.DataType) -> str: 2307 if expression.is_type(exp.DType.ARRAY): 2308 return f"{self.expressions(expression, flat=True)}[{self.expressions(expression, key='values', flat=True)}]" 2309 2310 # Modifiers are not supported for TIME, [TIME | TIMESTAMP] WITH TIME ZONE 2311 if expression.is_type( 2312 exp.DType.TIME, exp.DType.TIMETZ, exp.DType.TIMESTAMPTZ, exp.DType.TIMESTAMPLTZ 2313 ): 2314 expression.set("expressions", None) 2315 2316 return super().datatype_sql(expression) 2317 2318 def _array_bag_sql(self, condition: exp.Expr, arr1: exp.Expr, arr2: exp.Expr) -> str: 2319 cond = exp.Paren(this=exp.replace_placeholders(condition, arr1=arr1, arr2=arr2)) 2320 return self.sql( 2321 exp.replace_placeholders(self.ARRAY_BAG_TEMPLATE, arr1=arr1, arr2=arr2, cond=cond) 2322 ) 2323 2324 def timeslice_sql(self, expression: exp.TimeSlice) -> str: 2325 """ 2326 Transform Snowflake's TIME_SLICE to DuckDB's time_bucket. 2327 2328 Snowflake: TIME_SLICE(date_expr, slice_length, 'UNIT' [, 'START'|'END']) 2329 DuckDB: time_bucket(INTERVAL 'slice_length' UNIT, date_expr) 2330 2331 For 'END' kind, add the interval to get the end of the slice. 2332 For DATE type with 'END', cast result back to DATE to preserve type. 2333 """ 2334 date_expr = expression.this 2335 slice_length = expression.expression 2336 unit = expression.unit 2337 kind = expression.text("kind").upper() 2338 2339 # Create INTERVAL expression: INTERVAL 'N' UNIT 2340 interval_expr = exp.Interval(this=slice_length, unit=unit) 2341 2342 # Create base time_bucket expression 2343 time_bucket_expr = exp.func("time_bucket", interval_expr, date_expr) 2344 2345 # Check if we need the end of the slice (default is start) 2346 if not kind == "END": 2347 # For 'START', return time_bucket directly 2348 return self.sql(time_bucket_expr) 2349 2350 # For 'END', add the interval to get end of slice 2351 add_expr = exp.Add(this=time_bucket_expr, expression=interval_expr.copy()) 2352 2353 # If input is DATE type, cast result back to DATE to preserve type 2354 # DuckDB converts DATE to TIMESTAMP when adding intervals 2355 if date_expr.is_type(exp.DType.DATE): 2356 return self.sql(exp.cast(add_expr, exp.DType.DATE)) 2357 2358 return self.sql(add_expr) 2359 2360 def bytestring_sql(self, expression: exp.ByteString) -> str: 2361 if not expression.args.get("is_bytes"): 2362 return super().bytestring_sql(expression) 2363 2364 # Each char is a byte. Casting to BLOB decodes \xHH, so everything but printable ASCII 2365 # (including the backslash itself) is written as such 2366 text = "".join( 2367 ch if ch.isascii() and ch.isprintable() and ch != "\\" else f"\\x{ord(ch):02x}" 2368 for ch in expression.this 2369 ) 2370 return self.sql(exp.cast(exp.Literal.string(text), exp.DType.BINARY)) 2371 2372 def bitmapbucketnumber_sql(self, expression: exp.BitmapBucketNumber) -> str: 2373 """ 2374 Transpile BITMAP_BUCKET_NUMBER function from Snowflake to DuckDB equivalent. 2375 2376 Snowflake's BITMAP_BUCKET_NUMBER returns a 1-based bucket identifier where: 2377 - Each bucket covers 32,768 values 2378 - Bucket numbering starts at 1 2379 - Formula: ((value - 1) // 32768) + 1 for positive values 2380 2381 For non-positive values (0 and negative), we use value // 32768 to avoid 2382 producing bucket 0 or positive bucket IDs for negative inputs. 2383 """ 2384 value = expression.this 2385 2386 positive_formula = ((value - 1) // 32768) + 1 2387 non_positive_formula = value // 32768 2388 2389 # CASE WHEN value > 0 THEN ((value - 1) // 32768) + 1 ELSE value // 32768 END 2390 case_expr = ( 2391 exp.case() 2392 .when(exp.GT(this=value, expression=exp.Literal.number(0)), positive_formula) 2393 .else_(non_positive_formula) 2394 ) 2395 return self.sql(case_expr) 2396 2397 def bitmapbitposition_sql(self, expression: exp.BitmapBitPosition) -> str: 2398 """ 2399 Transpile Snowflake's BITMAP_BIT_POSITION to DuckDB CASE expression. 2400 2401 Snowflake's BITMAP_BIT_POSITION behavior: 2402 - For n <= 0: returns ABS(n) % 32768 2403 - For n > 0: returns (n - 1) % 32768 (maximum return value is 32767) 2404 """ 2405 this = expression.this 2406 2407 return self.sql( 2408 exp.Mod( 2409 this=exp.Paren( 2410 this=exp.If( 2411 this=exp.GT(this=this, expression=exp.Literal.number(0)), 2412 true=this - exp.Literal.number(1), 2413 false=exp.Abs(this=this), 2414 ) 2415 ), 2416 expression=MAX_BIT_POSITION, 2417 ) 2418 ) 2419 2420 def bitmapconstructagg_sql(self, expression: exp.BitmapConstructAgg) -> str: 2421 """ 2422 Transpile Snowflake's BITMAP_CONSTRUCT_AGG to DuckDB equivalent. 2423 Uses a pre-parsed template with placeholders replaced by expression nodes. 2424 2425 Snowflake bitmap format: 2426 - Small (< 5 unique values): 2-byte count (big-endian) + values (little-endian) + padding to 10 bytes 2427 - Large (>= 5 unique values): 10-byte header (0x08 + 9 zeros) + values (little-endian) 2428 """ 2429 arg = expression.this 2430 return ( 2431 f"({self.sql(exp.replace_placeholders(self.BITMAP_CONSTRUCT_AGG_TEMPLATE, arg=arg))})" 2432 ) 2433 2434 def getignorecase_sql(self, expression: exp.GetIgnoreCase) -> str: 2435 self.unsupported("DuckDB does not support the GET_IGNORE_CASE() function") 2436 return self.function_fallback_sql(expression) 2437 2438 def compress_sql(self, expression: exp.Compress) -> str: 2439 self.unsupported("DuckDB does not support the COMPRESS() function") 2440 return self.function_fallback_sql(expression) 2441 2442 def encrypt_sql(self, expression: exp.Encrypt) -> str: 2443 self.unsupported("ENCRYPT is not supported in DuckDB") 2444 return self.function_fallback_sql(expression) 2445 2446 def decrypt_sql(self, expression: exp.Decrypt) -> str: 2447 func_name = "TRY_DECRYPT" if expression.args.get("safe") else "DECRYPT" 2448 self.unsupported(f"{func_name} is not supported in DuckDB") 2449 return self.function_fallback_sql(expression) 2450 2451 def decryptraw_sql(self, expression: exp.DecryptRaw) -> str: 2452 func_name = "TRY_DECRYPT_RAW" if expression.args.get("safe") else "DECRYPT_RAW" 2453 self.unsupported(f"{func_name} is not supported in DuckDB") 2454 return self.function_fallback_sql(expression) 2455 2456 def encryptraw_sql(self, expression: exp.EncryptRaw) -> str: 2457 self.unsupported("ENCRYPT_RAW is not supported in DuckDB") 2458 return self.function_fallback_sql(expression) 2459 2460 def parseurl_sql(self, expression: exp.ParseUrl) -> str: 2461 self.unsupported("PARSE_URL is not supported in DuckDB") 2462 return self.function_fallback_sql(expression) 2463 2464 def parseip_sql(self, expression: exp.ParseIp) -> str: 2465 self.unsupported("PARSE_IP is not supported in DuckDB") 2466 return self.function_fallback_sql(expression) 2467 2468 def decompressstring_sql(self, expression: exp.DecompressString) -> str: 2469 self.unsupported("DECOMPRESS_STRING is not supported in DuckDB") 2470 return self.function_fallback_sql(expression) 2471 2472 def decompressbinary_sql(self, expression: exp.DecompressBinary) -> str: 2473 self.unsupported("DECOMPRESS_BINARY is not supported in DuckDB") 2474 return self.function_fallback_sql(expression) 2475 2476 def jarowinklersimilarity_sql(self, expression: exp.JarowinklerSimilarity) -> str: 2477 this = expression.this 2478 expr = expression.expression 2479 2480 if expression.args.get("case_insensitive"): 2481 this = exp.Upper(this=this) 2482 expr = exp.Upper(this=expr) 2483 2484 result = exp.func("JARO_WINKLER_SIMILARITY", this, expr) 2485 2486 if expression.args.get("integer_scale"): 2487 result = exp.cast(result * 100, "INTEGER") 2488 2489 return self.sql(result) 2490 2491 def randstr_sql(self, expression: exp.Randstr) -> str: 2492 """ 2493 Transpile Snowflake's RANDSTR to DuckDB equivalent using deterministic hash-based random. 2494 Uses a pre-parsed template with placeholders replaced by expression nodes. 2495 2496 RANDSTR(length, generator) generates a random string of specified length. 2497 - With numeric seed: Use HASH(i + seed) for deterministic output (same seed = same result) 2498 - With RANDOM(): Use RANDOM() in the hash for non-deterministic output 2499 - No generator: Use default seed value 2500 """ 2501 length = expression.this 2502 generator = expression.args.get("generator") 2503 2504 if generator: 2505 if isinstance(generator, exp.Rand): 2506 # If it's RANDOM(), use its seed if available, otherwise use RANDOM() itself 2507 seed_value = generator.this or generator 2508 else: 2509 # Const/int or other expression - use as seed directly 2510 seed_value = generator 2511 else: 2512 # No generator specified, use default seed (arbitrary but deterministic) 2513 seed_value = exp.Literal.number(RANDSTR_SEED) 2514 2515 replacements = {"seed": seed_value, "length": length} 2516 return f"({self.sql(exp.replace_placeholders(self.RANDSTR_TEMPLATE, **replacements))})" 2517 2518 @unsupported_args("finish") 2519 def reduce_sql(self, expression: exp.Reduce) -> str: 2520 array_arg = expression.this 2521 initial_value = expression.args.get("initial") 2522 merge_lambda = expression.args.get("merge") 2523 2524 if merge_lambda: 2525 merge_lambda.set("colon", True) 2526 2527 return self.func("list_reduce", array_arg, merge_lambda, initial_value) 2528 2529 def zipf_sql(self, expression: exp.Zipf) -> str: 2530 """ 2531 Transpile Snowflake's ZIPF to DuckDB using CDF-based inverse sampling. 2532 Uses a pre-parsed template with placeholders replaced by expression nodes. 2533 """ 2534 s = expression.this 2535 n = expression.args["elementcount"] 2536 gen = expression.args["gen"] 2537 2538 if not isinstance(gen, exp.Rand): 2539 # (ABS(HASH(seed)) % 1000000) / 1000000.0 2540 random_expr: exp.Expr = exp.Div( 2541 this=exp.Paren( 2542 this=exp.Mod( 2543 this=exp.Abs(this=exp.Anonymous(this="HASH", expressions=[gen.copy()])), 2544 expression=exp.Literal.number(1000000), 2545 ) 2546 ), 2547 expression=exp.Literal.number(1000000.0), 2548 ) 2549 else: 2550 # Use RANDOM() for non-deterministic output 2551 random_expr = exp.Rand() 2552 2553 replacements = {"s": s, "n": n, "random_expr": random_expr} 2554 return f"({self.sql(exp.replace_placeholders(self.ZIPF_TEMPLATE, **replacements))})" 2555 2556 def tobinary_sql(self, expression: exp.ToBinary) -> str: 2557 """ 2558 TO_BINARY and TRY_TO_BINARY transpilation: 2559 - 'HEX': TO_BINARY('48454C50', 'HEX') -> UNHEX('48454C50') 2560 - 'UTF-8': TO_BINARY('TEST', 'UTF-8') -> ENCODE('TEST') 2561 - 'BASE64': TO_BINARY('SEVMUA==', 'BASE64') -> FROM_BASE64('SEVMUA==') 2562 2563 For TRY_TO_BINARY (safe=True), wrap with TRY(): 2564 - 'HEX': TRY_TO_BINARY('invalid', 'HEX') -> TRY(UNHEX('invalid')) 2565 """ 2566 value = expression.this 2567 format_arg = expression.args.get("format") 2568 is_safe = expression.args.get("safe") 2569 is_binary = _is_binary(expression) 2570 2571 if not format_arg and not is_binary: 2572 func_name = "TRY_TO_BINARY" if is_safe else "TO_BINARY" 2573 return self.func(func_name, value) 2574 2575 # Snowflake defaults to HEX encoding when no format is specified 2576 fmt = format_arg.name.upper() if format_arg else "HEX" 2577 2578 if fmt in ("UTF-8", "UTF8"): 2579 # DuckDB ENCODE always uses UTF-8, no charset parameter needed 2580 result = self.func("ENCODE", value) 2581 elif fmt == "BASE64": 2582 result = self.func("FROM_BASE64", value) 2583 elif fmt == "HEX": 2584 result = self.func("UNHEX", value) 2585 else: 2586 if is_safe: 2587 return self.sql(exp.null()) 2588 else: 2589 self.unsupported(f"format {fmt} is not supported") 2590 result = self.func("TO_BINARY", value) 2591 return f"TRY({result})" if is_safe else result 2592 2593 def tonumber_sql(self, expression: exp.ToNumber) -> str: 2594 fmt = expression.args.get("format") 2595 precision = expression.args.get("precision") 2596 scale = expression.args.get("scale") 2597 2598 if not fmt and precision and scale: 2599 to = exp.DataType.build(f"DECIMAL({precision.name}, {scale.name})", dialect="duckdb") 2600 if expression.args.get("safe"): 2601 return self.sql(exp.TryCast(this=expression.this, to=to)) 2602 return self.sql(exp.cast(expression.this, to, dialect="duckdb")) 2603 2604 return super().tonumber_sql(expression) 2605 2606 def _greatest_least_sql(self, expression: exp.Greatest | exp.Least) -> str: 2607 """ 2608 Handle GREATEST/LEAST functions with dialect-aware NULL behavior. 2609 2610 - If ignore_nulls=False (BigQuery-style): return NULL if any argument is NULL 2611 - If ignore_nulls=True (DuckDB/PostgreSQL-style): ignore NULLs, return greatest/least non-NULL value 2612 """ 2613 # Get all arguments 2614 all_args = [expression.this, *expression.expressions] 2615 fallback_sql = self.function_fallback_sql(expression) 2616 2617 if expression.args.get("ignore_nulls"): 2618 # DuckDB/PostgreSQL behavior: use native GREATEST/LEAST (ignores NULLs) 2619 return self.sql(fallback_sql) 2620 2621 # return NULL if any argument is NULL 2622 case_expr = exp.case().when( 2623 exp.or_(*[arg.is_(exp.null()) for arg in all_args], copy=False), 2624 exp.null(), 2625 copy=False, 2626 ) 2627 case_expr.set("default", fallback_sql) 2628 return self.sql(case_expr) 2629 2630 def generator_sql(self, expression: exp.Generator) -> str: 2631 # Transpile Snowflake GENERATOR to DuckDB range() 2632 rowcount = expression.args.get("rowcount") 2633 time_limit = expression.args.get("time_limit") 2634 2635 if time_limit: 2636 self.unsupported("GENERATOR TIMELIMIT parameter is not supported in DuckDB") 2637 2638 if not rowcount: 2639 self.unsupported("GENERATOR without ROWCOUNT is not supported in DuckDB") 2640 return self.func("range", exp.Literal.number(0)) 2641 2642 return self.func("range", rowcount) 2643 2644 def greatest_sql(self, expression: exp.Greatest) -> str: 2645 return self._greatest_least_sql(expression) 2646 2647 def least_sql(self, expression: exp.Least) -> str: 2648 return self._greatest_least_sql(expression) 2649 2650 def lambda_sql(self, expression: exp.Lambda, arrow_sep: str = "->", wrap: bool = True) -> str: 2651 if expression.args.get("colon"): 2652 prefix = "LAMBDA " 2653 arrow_sep = ":" 2654 wrap = False 2655 else: 2656 prefix = "" 2657 2658 lambda_sql = super().lambda_sql(expression, arrow_sep=arrow_sep, wrap=wrap) 2659 return f"{prefix}{lambda_sql}" 2660 2661 def show_sql(self, expression: exp.Show) -> str: 2662 from_ = self.sql(expression, "from_") 2663 from_ = f" FROM {from_}" if from_ else "" 2664 return f"SHOW {expression.name}{from_}" 2665 2666 def soundex_sql(self, expression: exp.Soundex) -> str: 2667 self.unsupported("SOUNDEX is not supported in DuckDB") 2668 return self.func("SOUNDEX", expression.this) 2669 2670 def sortarray_sql(self, expression: exp.SortArray) -> str: 2671 arr = expression.this 2672 asc = expression.args.get("asc") 2673 nulls_first = expression.args.get("nulls_first") 2674 2675 if not isinstance(asc, exp.Boolean) and not isinstance(nulls_first, exp.Boolean): 2676 return self.func("LIST_SORT", arr, asc, nulls_first) 2677 2678 nulls_are_first = nulls_first == exp.true() 2679 nulls_first_sql = exp.Literal.string("NULLS FIRST") if nulls_are_first else None 2680 2681 if not isinstance(asc, exp.Boolean): 2682 return self.func("LIST_SORT", arr, asc, nulls_first_sql) 2683 2684 descending = asc == exp.false() 2685 2686 if not descending and not nulls_are_first: 2687 return self.func("LIST_SORT", arr) 2688 if not nulls_are_first: 2689 return self.func("ARRAY_REVERSE_SORT", arr) 2690 return self.func( 2691 "LIST_SORT", 2692 arr, 2693 exp.Literal.string("DESC" if descending else "ASC"), 2694 exp.Literal.string("NULLS FIRST"), 2695 ) 2696 2697 def install_sql(self, expression: exp.Install) -> str: 2698 force = "FORCE " if expression.args.get("force") else "" 2699 this = self.sql(expression, "this") 2700 from_clause = expression.args.get("from_") 2701 from_clause = f" FROM {from_clause}" if from_clause else "" 2702 return f"{force}INSTALL {this}{from_clause}" 2703 2704 def approxtopk_sql(self, expression: exp.ApproxTopK) -> str: 2705 self.unsupported( 2706 "APPROX_TOP_K cannot be transpiled to DuckDB due to incompatible return types. " 2707 ) 2708 return self.function_fallback_sql(expression) 2709 2710 def strposition_sql(self, expression: exp.StrPosition) -> str: 2711 this = expression.this 2712 substr = expression.args.get("substr") 2713 position = expression.args.get("position") 2714 2715 # For BINARY/BLOB: DuckDB's STRPOS doesn't support BLOB types 2716 # Convert to HEX strings, use STRPOS, then convert hex position to byte position 2717 if _is_binary(this): 2718 # Build expression: STRPOS(HEX(haystack), HEX(needle)) 2719 hex_strpos = exp.StrPosition( 2720 this=exp.Hex(this=this), 2721 substr=exp.Hex(this=substr), 2722 ) 2723 2724 return self.sql(exp.cast((hex_strpos + 1) / 2, exp.DType.INT)) 2725 2726 # For VARCHAR: handle clamp_position 2727 if expression.args.get("clamp_position") and position: 2728 expression = expression.copy() 2729 expression.set( 2730 "position", 2731 exp.If( 2732 this=exp.LTE(this=position, expression=exp.Literal.number(0)), 2733 true=exp.Literal.number(1), 2734 false=position.copy(), 2735 ), 2736 ) 2737 2738 return strposition_sql(self, expression) 2739 2740 def substring_sql(self, expression: exp.Substring) -> str: 2741 if expression.args.get("zero_start"): 2742 start = expression.args.get("start") 2743 length = expression.args.get("length") 2744 2745 if start := expression.args.get("start"): 2746 start = exp.If(this=start.eq(0), true=exp.Literal.number(1), false=start) 2747 if length := expression.args.get("length"): 2748 length = exp.If(this=length < 0, true=exp.Literal.number(0), false=length) 2749 2750 return self.func("SUBSTRING", expression.this, start, length) 2751 2752 return self.function_fallback_sql(expression) 2753 2754 def strtotime_sql(self, expression: exp.StrToTime) -> str: 2755 # Check if target_type requires TIMESTAMPTZ (for LTZ/TZ variants) 2756 target_type = expression.args.get("target_type") 2757 needs_tz = target_type and target_type.this in ( 2758 exp.DType.TIMESTAMPLTZ, 2759 exp.DType.TIMESTAMPTZ, 2760 ) 2761 2762 value, formatted_time = self._strptime_default_year(expression) 2763 2764 if expression.args.get("safe"): 2765 cast_type = exp.DType.TIMESTAMPTZ if needs_tz else exp.DType.TIMESTAMP 2766 return self.sql(exp.cast(self.func("TRY_STRPTIME", value, formatted_time), cast_type)) 2767 2768 base_sql = self.func("STRPTIME", value, formatted_time) 2769 if needs_tz: 2770 return self.sql( 2771 exp.cast( 2772 base_sql, 2773 exp.DataType(this=exp.DType.TIMESTAMPTZ), 2774 ) 2775 ) 2776 return base_sql 2777 2778 def strtodate_sql(self, expression: exp.StrToDate) -> str: 2779 value, formatted_time = self._strptime_default_year(expression) 2780 function_name = "STRPTIME" if not expression.args.get("safe") else "TRY_STRPTIME" 2781 return self.sql( 2782 exp.cast( 2783 self.func(function_name, value, formatted_time), 2784 exp.DataType(this=exp.DType.DATE), 2785 ) 2786 ) 2787 2788 def _strptime_default_year( 2789 self, expression: exp.StrToTime | exp.StrToDate | exp.ParseDatetime 2790 ) -> tuple[exp.ExpOrStr, exp.ExpOrStr | None]: 2791 value: exp.ExpOrStr = expression.this 2792 formatted_time: exp.ExpOrStr | None = self.format_time(expression) 2793 2794 if default_year := expression.args.get("default_year"): 2795 value = exp.DPipe(this=exp.Literal.string(f"{default_year.name} "), expression=value) 2796 formatted_time = exp.DPipe(this=exp.Literal.string("%Y "), expression=formatted_time) 2797 2798 return value, formatted_time 2799 2800 def parsedatetime_sql(self, expression: exp.ParseDatetime) -> str: 2801 value, formatted_time = self._strptime_default_year(expression) 2802 return self.func("STRPTIME", value, formatted_time) 2803 2804 def parsetime_sql(self, expression: exp.ParseTime) -> str: 2805 formatted_time = self.format_time(expression) 2806 return self.sql( 2807 exp.cast( 2808 self.func("STRPTIME", expression.this, formatted_time), 2809 exp.DataType(this=exp.DType.TIME), 2810 ) 2811 ) 2812 2813 def tsordstotime_sql(self, expression: exp.TsOrDsToTime) -> str: 2814 this = expression.this 2815 time_format = self.format_time(expression) 2816 safe = expression.args.get("safe") 2817 time_type = exp.DataType.from_str("TIME", dialect="duckdb") 2818 cast_expr = exp.TryCast if safe else exp.Cast 2819 2820 if time_format: 2821 func_name = "TRY_STRPTIME" if safe else "STRPTIME" 2822 strptime = exp.Anonymous(this=func_name, expressions=[this, time_format]) 2823 return self.sql(cast_expr(this=strptime, to=time_type)) 2824 2825 if isinstance(this, exp.TsOrDsToTime) or this.is_type(exp.DType.TIME): 2826 return self.sql(this) 2827 2828 return self.sql(cast_expr(this=this, to=time_type)) 2829 2830 def currentdate_sql(self, expression: exp.CurrentDate) -> str: 2831 if not expression.this: 2832 return "CURRENT_DATE" 2833 2834 expr = exp.Cast( 2835 this=exp.AtTimeZone(this=exp.CurrentTimestamp(), zone=expression.this), 2836 to=exp.DataType(this=exp.DType.DATE), 2837 ) 2838 return self.sql(expr) 2839 2840 def checkjson_sql(self, expression: exp.CheckJson) -> str: 2841 arg = expression.this 2842 return self.sql( 2843 exp.case() 2844 .when( 2845 exp.or_(arg.is_(exp.Null()), arg.eq(""), exp.func("json_valid", arg)), 2846 exp.null(), 2847 ) 2848 .else_(exp.Literal.string("Invalid JSON")) 2849 ) 2850 2851 def parsejson_sql(self, expression: exp.ParseJSON) -> str: 2852 arg = expression.this 2853 if expression.args.get("safe"): 2854 return self.sql( 2855 exp.case() 2856 .when(exp.func("json_valid", arg), exp.cast(arg.copy(), "JSON")) 2857 .else_(exp.null()) 2858 ) 2859 return self.func("JSON", arg) 2860 2861 def unicode_sql(self, expression: exp.Unicode) -> str: 2862 if expression.args.get("empty_is_zero"): 2863 return self.sql( 2864 exp.case() 2865 .when(expression.this.eq(exp.Literal.string("")), exp.Literal.number(0)) 2866 .else_(exp.Anonymous(this="UNICODE", expressions=[expression.this])) 2867 ) 2868 2869 return self.func("UNICODE", expression.this) 2870 2871 def stripnullvalue_sql(self, expression: exp.StripNullValue) -> str: 2872 return self.sql( 2873 exp.case() 2874 .when(exp.func("json_type", expression.this).eq("NULL"), exp.null()) 2875 .else_(expression.this) 2876 ) 2877 2878 def trunc_sql(self, expression: exp.Trunc) -> str: 2879 decimals = expression.args.get("decimals") 2880 if ( 2881 expression.args.get("fractions_supported") 2882 and decimals 2883 and not decimals.is_type(exp.DType.INT) 2884 ): 2885 decimals = exp.cast(decimals, exp.DType.INT, dialect="duckdb") 2886 2887 return self.func("TRUNC", expression.this, decimals) 2888 2889 def normal_sql(self, expression: exp.Normal) -> str: 2890 """ 2891 Transpile Snowflake's NORMAL(mean, stddev, gen) to DuckDB. 2892 2893 Uses the Box-Muller transform via NORMAL_TEMPLATE. 2894 """ 2895 mean = expression.this 2896 stddev = expression.args["stddev"] 2897 gen: exp.Expr = expression.args["gen"] 2898 2899 # Build two uniform random values [0, 1) for Box-Muller transform 2900 if isinstance(gen, exp.Rand) and gen.this is None: 2901 u1: exp.Expr = exp.Rand() 2902 u2: exp.Expr = exp.Rand() 2903 else: 2904 # Seeded: derive two values using HASH with different inputs 2905 seed = gen.this if isinstance(gen, exp.Rand) else gen 2906 u1 = exp.replace_placeholders(self.SEEDED_RANDOM_TEMPLATE, seed=seed) 2907 u2 = exp.replace_placeholders( 2908 self.SEEDED_RANDOM_TEMPLATE, 2909 seed=exp.Add(this=seed.copy(), expression=exp.Literal.number(1)), 2910 ) 2911 2912 replacements = {"mean": mean, "stddev": stddev, "u1": u1, "u2": u2} 2913 return self.sql(exp.replace_placeholders(self.NORMAL_TEMPLATE, **replacements)) 2914 2915 def uniform_sql(self, expression: exp.Uniform) -> str: 2916 """ 2917 Transpile Snowflake's UNIFORM(min, max, gen) to DuckDB. 2918 2919 UNIFORM returns a random value in [min, max]: 2920 - Integer result if both min and max are integers 2921 - Float result if either min or max is a float 2922 """ 2923 min_val = expression.this 2924 max_val = expression.expression 2925 gen = expression.args.get("gen") or expression.args.get("seed") or exp.Rand() 2926 2927 # Determine if result should be integer (both bounds are integers). 2928 # We do this to emulate Snowflake's behavior, INT -> INT, FLOAT -> FLOAT 2929 is_int_result = min_val.is_int and max_val.is_int 2930 2931 # Build the random value expression [0, 1) 2932 if not isinstance(gen, exp.Rand): 2933 # Seed value: (ABS(HASH(seed)) % 1000000) / 1000000.0 2934 random_expr: exp.Expr = exp.Div( 2935 this=exp.Paren( 2936 this=exp.Mod( 2937 this=exp.Abs(this=exp.Anonymous(this="HASH", expressions=[gen])), 2938 expression=exp.Literal.number(1000000), 2939 ) 2940 ), 2941 expression=exp.Literal.number(1000000.0), 2942 ) 2943 else: 2944 random_expr = exp.Rand() 2945 2946 # Build: min + random * (max - min [+ 1 for int]) 2947 range_expr: exp.Expr = exp.Sub(this=max_val, expression=min_val) 2948 if is_int_result: 2949 range_expr = exp.Add(this=range_expr, expression=exp.Literal.number(1)) 2950 2951 result: exp.Expr = exp.Add( 2952 this=min_val, 2953 expression=exp.Mul(this=random_expr, expression=exp.Paren(this=range_expr)), 2954 ) 2955 2956 if is_int_result: 2957 result = exp.Cast(this=exp.Floor(this=result), to=exp.DType.BIGINT.into_expr()) 2958 2959 return self.sql(result) 2960 2961 def timefromparts_sql(self, expression: exp.TimeFromParts) -> str: 2962 nano = expression.args.get("nano") 2963 overflow = expression.args.get("overflow") 2964 2965 # Snowflake's TIME_FROM_PARTS supports overflow 2966 if overflow: 2967 hour = expression.args["hour"] 2968 minute = expression.args["min"] 2969 sec = expression.args["sec"] 2970 2971 # Check if values are within normal ranges - use MAKE_TIME for efficiency 2972 if not nano and all(arg.is_int for arg in [hour, minute, sec]): 2973 try: 2974 h_val = hour.to_py() 2975 m_val = minute.to_py() 2976 s_val = sec.to_py() 2977 if 0 <= h_val <= 23 and 0 <= m_val <= 59 and 0 <= s_val <= 59: 2978 return rename_func("MAKE_TIME")(self, expression) 2979 except ValueError: 2980 pass 2981 2982 # Overflow or nanoseconds detected - use INTERVAL arithmetic 2983 if nano: 2984 sec = sec + nano.pop() / exp.Literal.number(1000000000.0) 2985 2986 total_seconds = hour * exp.Literal.number(3600) + minute * exp.Literal.number(60) + sec 2987 2988 return self.sql( 2989 exp.Add( 2990 this=exp.Cast( 2991 this=exp.Literal.string("00:00:00"), to=exp.DType.TIME.into_expr() 2992 ), 2993 expression=exp.Interval(this=total_seconds, unit=exp.var("SECOND")), 2994 ) 2995 ) 2996 2997 # Default: MAKE_TIME 2998 if nano: 2999 expression.set( 3000 "sec", expression.args["sec"] + nano.pop() / exp.Literal.number(1000000000.0) 3001 ) 3002 3003 return rename_func("MAKE_TIME")(self, expression) 3004 3005 def extract_sql(self, expression: exp.Extract) -> str: 3006 """ 3007 Transpile EXTRACT/DATE_PART for DuckDB, handling specifiers not natively supported. 3008 3009 DuckDB doesn't support: WEEKISO, YEAROFWEEK, YEAROFWEEKISO, NANOSECOND, 3010 EPOCH_SECOND (as integer), EPOCH_MILLISECOND, EPOCH_MICROSECOND, EPOCH_NANOSECOND 3011 """ 3012 this = expression.this 3013 datetime_expr = expression.expression 3014 3015 # TIMESTAMPTZ extractions may produce different results between Snowflake and DuckDB 3016 # because Snowflake applies server timezone while DuckDB uses local timezone 3017 if datetime_expr.is_type(exp.DType.TIMESTAMPTZ, exp.DType.TIMESTAMPLTZ): 3018 self.unsupported( 3019 "EXTRACT from TIMESTAMPTZ / TIMESTAMPLTZ may produce different results due to timezone handling differences" 3020 ) 3021 3022 part_name = this.name.upper() 3023 3024 if part_name in self.EXTRACT_STRFTIME_MAPPINGS: 3025 fmt, cast_type = self.EXTRACT_STRFTIME_MAPPINGS[part_name] 3026 3027 # Problem: strftime doesn't accept TIME and there's no NANOSECOND function 3028 # So, for NANOSECOND with TIME, fallback to MICROSECOND * 1000 3029 is_nano_time = part_name == "NANOSECOND" and datetime_expr.is_type( 3030 exp.DType.TIME, exp.DType.TIMETZ 3031 ) 3032 3033 if is_nano_time: 3034 self.unsupported("Parameter NANOSECOND is not supported with TIME type in DuckDB") 3035 return self.sql( 3036 exp.cast( 3037 exp.Mul( 3038 this=exp.Extract(this=exp.var("MICROSECOND"), expression=datetime_expr), 3039 expression=exp.Literal.number(1000), 3040 ), 3041 exp.DataType.from_str(cast_type, dialect="duckdb"), 3042 ) 3043 ) 3044 3045 # For NANOSECOND, cast to TIMESTAMP_NS to preserve nanosecond precision 3046 strftime_input = datetime_expr 3047 if part_name == "NANOSECOND": 3048 strftime_input = exp.cast(datetime_expr, exp.DType.TIMESTAMP_NS) 3049 3050 return self.sql( 3051 exp.cast( 3052 exp.Anonymous( 3053 this="STRFTIME", 3054 expressions=[strftime_input, exp.Literal.string(fmt)], 3055 ), 3056 exp.DataType.from_str(cast_type, dialect="duckdb"), 3057 ) 3058 ) 3059 3060 if part_name in self.EXTRACT_EPOCH_MAPPINGS: 3061 func_name = self.EXTRACT_EPOCH_MAPPINGS[part_name] 3062 result: exp.Expr = exp.Anonymous(this=func_name, expressions=[datetime_expr]) 3063 # EPOCH returns float, cast to BIGINT for integer result 3064 if part_name == "EPOCH_SECOND": 3065 result = exp.cast(result, exp.DataType.from_str("BIGINT", dialect="duckdb")) 3066 return self.sql(result) 3067 3068 return super().extract_sql(expression) 3069 3070 def timestampfromparts_sql(self, expression: exp.TimestampFromParts) -> str: 3071 # Check if this is the date/time expression form: TIMESTAMP_FROM_PARTS(date_expr, time_expr) 3072 date_expr = expression.this 3073 time_expr = expression.expression 3074 3075 if date_expr is not None and time_expr is not None: 3076 # In DuckDB, DATE + TIME produces TIMESTAMP 3077 return self.sql(exp.Add(this=date_expr, expression=time_expr)) 3078 3079 # Component-based form: TIMESTAMP_FROM_PARTS(year, month, day, hour, minute, second, ...) 3080 sec = expression.args.get("sec") 3081 if sec is None: 3082 # This shouldn't happen with valid input, but handle gracefully 3083 return rename_func("MAKE_TIMESTAMP")(self, expression) 3084 3085 milli = expression.args.get("milli") 3086 if milli is not None: 3087 sec += milli.pop() / exp.Literal.number(1000.0) 3088 3089 nano = expression.args.get("nano") 3090 if nano is not None: 3091 sec += nano.pop() / exp.Literal.number(1000000000.0) 3092 3093 if milli or nano: 3094 expression.set("sec", sec) 3095 3096 if zone := expression.args.get("zone"): 3097 return self.sql(exp.AtTimeZone(this=expression, zone=zone.pop())) 3098 3099 return rename_func("MAKE_TIMESTAMP")(self, expression) 3100 3101 @unsupported_args("nano") 3102 def timestampltzfromparts_sql(self, expression: exp.TimestampLtzFromParts) -> str: 3103 # Pop nano so rename_func only passes args that MAKE_TIMESTAMP accepts 3104 if nano := expression.args.get("nano"): 3105 nano.pop() 3106 3107 timestamp = rename_func("MAKE_TIMESTAMP")(self, expression) 3108 return f"CAST({timestamp} AS TIMESTAMPTZ)" 3109 3110 @unsupported_args("nano") 3111 def timestamptzfromparts_sql(self, expression: exp.TimestampTzFromParts) -> str: 3112 # Extract zone before popping 3113 zone = expression.args.get("zone") 3114 # Pop zone and nano so rename_func only passes args that MAKE_TIMESTAMP accepts 3115 if zone: 3116 zone = zone.pop() 3117 3118 if nano := expression.args.get("nano"): 3119 nano.pop() 3120 3121 timestamp = rename_func("MAKE_TIMESTAMP")(self, expression) 3122 3123 if zone: 3124 # Use AT TIME ZONE to apply the explicit timezone 3125 return f"{timestamp} AT TIME ZONE {self.sql(zone)}" 3126 3127 return timestamp 3128 3129 def after_limit_modifiers(self, expression: exp.Expr) -> list[str]: 3130 locks = self.expressions(expression, key="locks", sep=" ") 3131 return [f" {locks}" if locks else ""] 3132 3133 def tablesample_sql( 3134 self, 3135 expression: exp.TableSample, 3136 tablesample_keyword: str | None = None, 3137 ) -> str: 3138 if not isinstance(expression.parent, exp.Select): 3139 # This sample clause only applies to a single source, not the entire resulting relation 3140 tablesample_keyword = "TABLESAMPLE" 3141 3142 if expression.args.get("size"): 3143 method = expression.args.get("method") 3144 if method and method.name.upper() != "RESERVOIR": 3145 self.unsupported( 3146 f"Sampling method {method} is not supported with a discrete sample count, " 3147 "defaulting to reservoir sampling" 3148 ) 3149 expression.set("method", exp.var("RESERVOIR")) 3150 3151 return super().tablesample_sql(expression, tablesample_keyword=tablesample_keyword) 3152 3153 def in_sql(self, expression: exp.In) -> str: 3154 unnest = expression.args.get("unnest") 3155 if unnest: 3156 return self.sql( 3157 exp.replace_placeholders( 3158 self.IN_UNNEST_TEMPLATE, arr=unnest.expressions[0], value=expression.this 3159 ) 3160 ) 3161 return super().in_sql(expression) 3162 3163 def join_sql(self, expression: exp.Join) -> str: 3164 if ( 3165 not expression.args.get("using") 3166 and not expression.args.get("on") 3167 and not expression.method 3168 and (expression.kind in ("", "INNER", "OUTER")) 3169 ): 3170 # Some dialects support `LEFT/INNER JOIN UNNEST(...)` without an explicit ON clause 3171 # DuckDB doesn't, but we can just add a dummy ON clause that is always true 3172 if isinstance(expression.this, exp.Unnest): 3173 return super().join_sql(expression.on(exp.true())) 3174 3175 expression.set("side", None) 3176 expression.set("kind", None) 3177 3178 return super().join_sql(expression) 3179 3180 def countif_sql(self, expression: exp.CountIf) -> str: 3181 if self.dialect.version >= (1, 2): 3182 this = expression.this 3183 if expression.args.get("zero_on_all_null") and not isinstance(this, exp.Distinct): 3184 # DuckDB >= 1.2's COUNT_IF returns NULL when the condition is NULL on all rows, 3185 # so we wrap the condition in IS TRUE to preserve count-like semantics 3186 expression = exp.CountIf(this=exp.paren(this).is_(exp.true())) 3187 return self.function_fallback_sql(expression) 3188 3189 # https://github.com/tobymao/sqlglot/pull/4749 3190 return count_if_to_sum(self, expression) 3191 3192 def bracket_sql(self, expression: exp.Bracket) -> str: 3193 if self.dialect.version >= (1, 2): 3194 return super().bracket_sql(expression) 3195 3196 # https://duckdb.org/2025/02/05/announcing-duckdb-120.html#breaking-changes 3197 this = expression.this 3198 if isinstance(this, exp.Array): 3199 this.replace(exp.paren(this)) 3200 3201 bracket = super().bracket_sql(expression) 3202 3203 if not expression.args.get("returns_list_for_maps"): 3204 if not this.type: 3205 from sqlglot.optimizer.annotate_types import annotate_types 3206 3207 this = annotate_types(this, dialect=self.dialect) 3208 3209 if this.is_type(exp.DType.MAP): 3210 bracket = f"({bracket})[1]" 3211 3212 return bracket 3213 3214 def withingroup_sql(self, expression: exp.WithinGroup) -> str: 3215 func = expression.this 3216 3217 # For ARRAY_AGG, DuckDB requires ORDER BY inside the function, not in WITHIN GROUP 3218 # Transform: ARRAY_AGG(x) WITHIN GROUP (ORDER BY y) -> ARRAY_AGG(x ORDER BY y) 3219 if isinstance(func, exp.ArrayAgg): 3220 if not isinstance(order := expression.expression, exp.Order): 3221 return self.sql(func) 3222 3223 # Save the original column for FILTER clause (before wrapping with Order) 3224 original_this = func.this 3225 3226 # Move ORDER BY inside ARRAY_AGG by wrapping its argument with Order 3227 # ArrayAgg.this should become Order(this=ArrayAgg.this, expressions=order.expressions) 3228 func.set( 3229 "this", 3230 exp.Order( 3231 this=func.this.copy(), 3232 expressions=order.expressions, 3233 ), 3234 ) 3235 3236 # Generate the ARRAY_AGG function with ORDER BY and add FILTER clause if needed 3237 # Use original_this (not the Order-wrapped version) for the FILTER condition 3238 array_agg_sql = self.function_fallback_sql(func) 3239 return self._add_arrayagg_null_filter(array_agg_sql, func, original_this) 3240 3241 # For other functions (like PERCENTILES), use existing logic 3242 expression_sql = self.sql(expression, "expression") 3243 3244 if isinstance(func, exp.PERCENTILES): 3245 # Make the order key the first arg and slide the fraction to the right 3246 # https://duckdb.org/docs/sql/aggregates#ordered-set-aggregate-functions 3247 order_col = expression.find(exp.Ordered) 3248 if order_col: 3249 func.set("expression", func.this) 3250 func.set("this", order_col.this) 3251 3252 this = self.sql(expression, "this").rstrip(")") 3253 3254 return f"{this}{expression_sql})" 3255 3256 def length_sql(self, expression: exp.Length) -> str: 3257 arg = expression.this 3258 3259 # Dialects like BQ and Snowflake also accept binary values as args, so 3260 # DDB will attempt to infer the type or resort to case/when resolution 3261 if not expression.args.get("binary") or arg.is_string: 3262 return self.func("LENGTH", arg) 3263 3264 if not arg.type: 3265 from sqlglot.optimizer.annotate_types import annotate_types 3266 3267 arg = annotate_types(arg, dialect=self.dialect) 3268 3269 if arg.is_type(*exp.DataType.TEXT_TYPES): 3270 return self.func("LENGTH", arg) 3271 3272 # We need these casts to make duckdb's static type checker happy 3273 blob = exp.cast(arg, exp.DType.VARBINARY) 3274 varchar = exp.cast(arg, exp.DType.VARCHAR) 3275 3276 case = ( 3277 exp.case(exp.Anonymous(this="TYPEOF", expressions=[arg])) 3278 .when(exp.Literal.string("BLOB"), exp.ByteLength(this=blob)) 3279 .else_(exp.Anonymous(this="LENGTH", expressions=[varchar])) 3280 ) 3281 return self.sql(case) 3282 3283 def bitlength_sql(self, expression: exp.BitLength) -> str: 3284 if not _is_binary(arg := expression.this): 3285 return self.func("BIT_LENGTH", arg) 3286 3287 blob = exp.cast(arg, exp.DataType.Type.VARBINARY) 3288 return self.sql(exp.ByteLength(this=blob) * exp.Literal.number(8)) 3289 3290 def chr_sql(self, expression: exp.Chr, name: str = "CHR") -> str: 3291 arg = expression.expressions[0] 3292 if arg.is_type(*exp.DataType.REAL_TYPES): 3293 arg = exp.cast(arg, exp.DType.INT) 3294 return self.func("CHR", arg) 3295 3296 def collation_sql(self, expression: exp.Collation) -> str: 3297 self.unsupported("COLLATION function is not supported by DuckDB") 3298 return self.function_fallback_sql(expression) 3299 3300 def collate_sql(self, expression: exp.Collate) -> str: 3301 if not expression.expression.is_string: 3302 return super().collate_sql(expression) 3303 3304 raw = expression.expression.name 3305 if not raw: 3306 return self.sql(expression.this) 3307 3308 parts = [] 3309 for part in raw.split("-"): 3310 lower = part.lower() 3311 if lower not in _SNOWFLAKE_COLLATION_DEFAULTS: 3312 if lower in _SNOWFLAKE_COLLATION_UNSUPPORTED: 3313 self.unsupported( 3314 f"Snowflake collation specifier '{part}' has no DuckDB equivalent" 3315 ) 3316 parts.append(lower) 3317 3318 if not parts: 3319 return self.sql(expression.this) 3320 return super().collate_sql( 3321 exp.Collate(this=expression.this, expression=exp.var(".".join(parts))) 3322 ) 3323 3324 def _validate_regexp_flags(self, flags: exp.Expr | None, supported_flags: str) -> str | None: 3325 """ 3326 Validate and filter regexp flags for DuckDB compatibility. 3327 3328 Args: 3329 flags: The flags expression to validate 3330 supported_flags: String of supported flags (e.g., "ims", "cims"). 3331 Only these flags will be returned. 3332 3333 Returns: 3334 Validated/filtered flag string, or None if no valid flags remain 3335 """ 3336 if not isinstance(flags, exp.Expr): 3337 return None 3338 3339 if not flags.is_string: 3340 self.unsupported("Non-literal regexp flags are not fully supported in DuckDB") 3341 return None 3342 3343 flag_str = flags.this 3344 unsupported = set(flag_str) - set(supported_flags) 3345 3346 if unsupported: 3347 self.unsupported( 3348 f"Regexp flags {sorted(unsupported)} are not supported in this context" 3349 ) 3350 3351 flag_str = "".join(f for f in flag_str if f in supported_flags) 3352 return flag_str if flag_str else None 3353 3354 def regexpcount_sql(self, expression: exp.RegexpCount) -> str: 3355 this = expression.this 3356 pattern = expression.expression 3357 position = expression.args.get("position") 3358 parameters = expression.args.get("parameters") 3359 3360 # Validate flags - only "ims" flags are supported for embedded patterns 3361 validated_flags = self._validate_regexp_flags(parameters, supported_flags="ims") 3362 3363 if position: 3364 this = exp.Substring(this=this, start=position) 3365 3366 # Handle empty pattern: Snowflake returns 0, DuckDB would match between every character. 3367 # Checked before the flags are embedded, which would make the pattern non-empty 3368 is_empty = exp.EQ(this=pattern.copy(), expression=exp.Literal.string("")) 3369 3370 # Embed flags in pattern (REGEXP_EXTRACT_ALL doesn't support flags argument) 3371 if validated_flags: 3372 pattern = exp.Concat(expressions=[exp.Literal.string(f"(?{validated_flags})"), pattern]) 3373 3374 result = ( 3375 exp.case() 3376 .when(is_empty, exp.Literal.number(0)) 3377 .else_( 3378 exp.Length( 3379 this=exp.Anonymous(this="REGEXP_EXTRACT_ALL", expressions=[this, pattern]) 3380 ) 3381 ) 3382 ) 3383 3384 return self.sql(result) 3385 3386 def regexpreplace_sql(self, expression: exp.RegexpReplace) -> str: 3387 subject = expression.this 3388 pattern = expression.expression 3389 replacement = expression.args.get("replacement") or exp.Literal.string("") 3390 position = expression.args.get("position") 3391 occurrence = expression.args.get("occurrence") 3392 modifiers = expression.args.get("modifiers") 3393 3394 validated_flags = self._validate_regexp_flags(modifiers, supported_flags="cimsg") or "" 3395 3396 # Handle occurrence (only literals supported) 3397 if occurrence and not occurrence.is_int: 3398 self.unsupported("REGEXP_REPLACE with non-literal occurrence") 3399 else: 3400 occurrence = occurrence.to_py() if occurrence and occurrence.is_int else 0 3401 if occurrence > 1: 3402 self.unsupported(f"REGEXP_REPLACE occurrence={occurrence} not supported") 3403 # flag duckdb to do either all or none, single_replace check is for duckdb round trip 3404 elif ( 3405 occurrence == 0 3406 and "g" not in validated_flags 3407 and not expression.args.get("single_replace") 3408 ): 3409 validated_flags += "g" 3410 3411 # Handle position (only literals supported) 3412 prefix = None 3413 if position and not position.is_int: 3414 self.unsupported("REGEXP_REPLACE with non-literal position") 3415 elif position and position.is_int and position.to_py() > 1: 3416 pos = position.to_py() 3417 prefix = exp.Substring( 3418 this=subject, start=exp.Literal.number(1), length=exp.Literal.number(pos - 1) 3419 ) 3420 subject = exp.Substring(this=subject, start=exp.Literal.number(pos)) 3421 3422 result: exp.Expr = exp.Anonymous( 3423 this="REGEXP_REPLACE", 3424 expressions=[ 3425 subject, 3426 pattern, 3427 replacement, 3428 exp.Literal.string(validated_flags) if validated_flags else None, 3429 ], 3430 ) 3431 3432 if prefix: 3433 result = exp.Concat(expressions=[prefix, result]) 3434 3435 return self.sql(result) 3436 3437 def regexplike_sql(self, expression: exp.RegexpLike) -> str: 3438 this = expression.this 3439 pattern = expression.expression 3440 flag = expression.args.get("flag") 3441 3442 if expression.args.get("full_match"): 3443 validated_flags = self._validate_regexp_flags(flag, supported_flags="cims") 3444 flag = exp.Literal.string(validated_flags) if validated_flags else None 3445 return self.func("REGEXP_FULL_MATCH", this, pattern, flag) 3446 3447 return self.func("REGEXP_MATCHES", this, pattern, flag) 3448 3449 @unsupported_args("ins_cost", "del_cost", "sub_cost") 3450 def levenshtein_sql(self, expression: exp.Levenshtein) -> str: 3451 this = expression.this 3452 expr = expression.expression 3453 max_dist = expression.args.get("max_dist") 3454 3455 if max_dist is None: 3456 return self.func("LEVENSHTEIN", this, expr) 3457 3458 # Emulate Snowflake semantics: if distance > max_dist, return max_dist 3459 levenshtein = exp.Levenshtein(this=this, expression=expr) 3460 return self.sql(exp.Least(this=levenshtein, expressions=[max_dist])) 3461 3462 def pad_sql(self, expression: exp.Pad) -> str: 3463 """ 3464 Handle RPAD/LPAD for VARCHAR and BINARY types. 3465 3466 For VARCHAR: Delegate to parent class 3467 For BINARY: Lower to: input || REPEAT(pad, GREATEST(0, target_len - OCTET_LENGTH(input))) 3468 """ 3469 string_arg = expression.this 3470 fill_arg = expression.args.get("fill_pattern") or exp.Literal.string(" ") 3471 3472 if _is_binary(string_arg) or _is_binary(fill_arg): 3473 length_arg = expression.expression 3474 is_left = expression.args.get("is_left") 3475 3476 input_len = exp.ByteLength(this=string_arg) 3477 chars_needed = length_arg - input_len 3478 pad_count = exp.Greatest( 3479 this=exp.Literal.number(0), expressions=[chars_needed], ignore_nulls=True 3480 ) 3481 repeat_expr = exp.Repeat(this=fill_arg, times=pad_count) 3482 3483 left, right = string_arg, repeat_expr 3484 if is_left: 3485 left, right = right, left 3486 3487 result = exp.DPipe(this=left, expression=right) 3488 return self.sql(result) 3489 3490 # For VARCHAR: Delegate to parent class (handles PAD_FILL_PATTERN_IS_REQUIRED) 3491 return super().pad_sql(expression) 3492 3493 def minhash_sql(self, expression: exp.Minhash) -> str: 3494 k = expression.this 3495 exprs = expression.expressions 3496 3497 if len(exprs) != 1 or isinstance(exprs[0], exp.Star): 3498 self.unsupported( 3499 "MINHASH with multiple expressions or * requires manual query restructuring" 3500 ) 3501 return self.func("MINHASH", k, *exprs) 3502 3503 expr = exprs[0] 3504 result = exp.replace_placeholders(self.MINHASH_TEMPLATE.copy(), expr=expr, k=k) 3505 return f"({self.sql(result)})" 3506 3507 def minhashcombine_sql(self, expression: exp.MinhashCombine) -> str: 3508 expr = expression.this 3509 result = exp.replace_placeholders(self.MINHASH_COMBINE_TEMPLATE.copy(), expr=expr) 3510 return f"({self.sql(result)})" 3511 3512 def approximatesimilarity_sql(self, expression: exp.ApproximateSimilarity) -> str: 3513 expr = expression.this 3514 result = exp.replace_placeholders(self.APPROXIMATE_SIMILARITY_TEMPLATE.copy(), expr=expr) 3515 return f"({self.sql(result)})" 3516 3517 def arrayuniqueagg_sql(self, expression: exp.ArrayUniqueAgg) -> str: 3518 return self.sql( 3519 exp.Filter( 3520 this=exp.func("LIST", exp.Distinct(expressions=[expression.this])), 3521 expression=exp.Where(this=expression.this.copy().is_(exp.null()).not_()), 3522 ) 3523 ) 3524 3525 def arrayconcatagg_sql(self, expression: exp.ArrayConcatAgg) -> str: 3526 this = expression.this 3527 3528 if isinstance(this, exp.Limit): 3529 self.unsupported("LIMIT in ARRAY_CONCAT_AGG cannot be transpiled to DuckDB") 3530 this = this.this 3531 3532 inner = this.this if isinstance(this, exp.Order) else this 3533 3534 return self.func( 3535 "FLATTEN", 3536 exp.Filter( 3537 this=exp.ArrayAgg(this=this), 3538 expression=exp.Where(this=inner.copy().is_(exp.null()).not_()), 3539 ), 3540 ) 3541 3542 def arrayunionagg_sql(self, expression: exp.ArrayUnionAgg) -> str: 3543 self.unsupported("ARRAY_UNION_AGG is not supported in DuckDB") 3544 return self.function_fallback_sql(expression) 3545 3546 def arraydistinct_sql(self, expression: exp.ArrayDistinct) -> str: 3547 arr = expression.this 3548 func = self.func("LIST_DISTINCT", arr) 3549 3550 if expression.args.get("check_null"): 3551 add_null_to_array = exp.func( 3552 "LIST_APPEND", exp.func("LIST_DISTINCT", exp.ArrayCompact(this=arr)), exp.Null() 3553 ) 3554 return self.sql( 3555 exp.If( 3556 this=exp.NEQ( 3557 this=exp.ArraySize(this=arr), expression=exp.func("LIST_COUNT", arr) 3558 ), 3559 true=add_null_to_array, 3560 false=func, 3561 ) 3562 ) 3563 3564 return func 3565 3566 def arrayintersect_sql(self, expression: exp.ArrayIntersect) -> str: 3567 if expression.args.get("is_multiset") and len(expression.expressions) == 2: 3568 return self._array_bag_sql( 3569 self.ARRAY_INTERSECTION_CONDITION, 3570 expression.expressions[0], 3571 expression.expressions[1], 3572 ) 3573 return self.function_fallback_sql(expression) 3574 3575 def arrayexcept_sql(self, expression: exp.ArrayExcept) -> str: 3576 arr1, arr2 = expression.this, expression.expression 3577 if expression.args.get("is_multiset"): 3578 return self._array_bag_sql(self.ARRAY_EXCEPT_CONDITION, arr1, arr2) 3579 return self.sql( 3580 exp.replace_placeholders(self.ARRAY_EXCEPT_SET_TEMPLATE, arr1=arr1, arr2=arr2) 3581 ) 3582 3583 def arrayslice_sql(self, expression: exp.ArraySlice) -> str: 3584 """ 3585 Spark's SLICE uses a length, so normalize negative starts before computing 3586 the inclusive end index. Starts before the array produce an empty slice. 3587 3588 Transpiles Snowflake's ARRAY_SLICE (0-indexed, exclusive end) to DuckDB's 3589 ARRAY_SLICE (1-indexed, inclusive end) by wrapping start and end in CASE 3590 expressions that adjust the index at query time: 3591 - start: CASE WHEN start >= 0 THEN start + 1 ELSE start END 3592 - end: CASE WHEN end < 0 THEN end - 1 ELSE end END 3593 """ 3594 start, end = expression.args.get("start"), expression.args.get("end") 3595 3596 if expression.args.get("zero_based"): 3597 if start is not None: 3598 start = ( 3599 exp.case() 3600 .when( 3601 exp.GTE(this=start.copy(), expression=exp.Literal.number(0)), 3602 exp.Add(this=start.copy(), expression=exp.Literal.number(1)), 3603 ) 3604 .else_(start) 3605 ) 3606 if end is not None and not expression.args.get("is_length"): 3607 end = ( 3608 exp.case() 3609 .when( 3610 exp.LT(this=end.copy(), expression=exp.Literal.number(0)), 3611 exp.Sub(this=end.copy(), expression=exp.Literal.number(1)), 3612 ) 3613 .else_(end) 3614 ) 3615 3616 if expression.args.get("is_length") and start is not None and end is not None: 3617 if not start.is_int or start.to_py() < 0: 3618 size = exp.ArraySize(this=expression.this.copy()) 3619 start = ( 3620 exp.case() 3621 .when(start < -size, size + 1) 3622 .when(start < 0, size + start + 1) 3623 .else_(start) 3624 ) 3625 3626 if start.is_int and end.is_int: 3627 end = exp.Literal.number(start.to_py() + end.to_py() - 1) 3628 else: 3629 end = start + exp.cast(end, "BIGINT") - 1 3630 return self.func("ARRAY_SLICE", expression.this, start, end, expression.args.get("step")) 3631 3632 def arrayszip_sql(self, expression: exp.ArraysZip) -> str: 3633 args = expression.expressions 3634 3635 if not args: 3636 # Return [{}] - using MAP([], []) since DuckDB can't represent empty structs 3637 return self.sql(exp.array(exp.Map(keys=exp.array(), values=exp.array()))) 3638 3639 # Build placeholder values for template 3640 lengths = [exp.Length(this=arg) for arg in args] 3641 max_len = ( 3642 lengths[0] 3643 if len(lengths) == 1 3644 else exp.Greatest(this=lengths[0], expressions=lengths[1:]) 3645 ) 3646 3647 # Empty struct with same schema: {'$1': NULL, '$2': NULL, ...} 3648 empty_struct = exp.func( 3649 "STRUCT", 3650 *[ 3651 exp.PropertyEQ(this=exp.Literal.string(f"${i + 1}"), expression=exp.Null()) 3652 for i in range(len(args)) 3653 ], 3654 ) 3655 3656 # Struct for transform: {'$1': COALESCE(arr1, [])[__i + 1], ...} 3657 # COALESCE wrapping handles NULL arrays - prevents invalid NULL[i] syntax 3658 index = exp.column("__i") + 1 3659 transform_struct = exp.func( 3660 "STRUCT", 3661 *[ 3662 exp.PropertyEQ( 3663 this=exp.Literal.string(f"${i + 1}"), 3664 expression=exp.func("COALESCE", arg, exp.array())[index], 3665 ) 3666 for i, arg in enumerate(args) 3667 ], 3668 ) 3669 3670 result = exp.replace_placeholders( 3671 self.ARRAYS_ZIP_TEMPLATE.copy(), 3672 null_check=exp.or_(*[arg.is_(exp.Null()) for arg in args]), 3673 all_empty_check=exp.and_( 3674 *[ 3675 exp.EQ(this=exp.Length(this=arg), expression=exp.Literal.number(0)) 3676 for arg in args 3677 ] 3678 ), 3679 empty_struct=empty_struct, 3680 max_len=max_len, 3681 transform_struct=transform_struct, 3682 ) 3683 return self.sql(result) 3684 3685 def lower_sql(self, expression: exp.Lower) -> str: 3686 result_sql = self.func("LOWER", _cast_to_varchar(expression.this)) 3687 return _gen_with_cast_to_blob(self, expression, result_sql) 3688 3689 def upper_sql(self, expression: exp.Upper) -> str: 3690 result_sql = self.func("UPPER", _cast_to_varchar(expression.this)) 3691 return _gen_with_cast_to_blob(self, expression, result_sql) 3692 3693 def reverse_sql(self, expression: exp.Reverse) -> str: 3694 result_sql = self.func("REVERSE", _cast_to_varchar(expression.this)) 3695 return _gen_with_cast_to_blob(self, expression, result_sql) 3696 3697 def _left_right_sql(self, expression: exp.Left | exp.Right, func_name: str) -> str: 3698 arg = expression.this 3699 length = expression.expression 3700 is_binary = _is_binary(arg) 3701 3702 if is_binary: 3703 # LEFT/RIGHT(blob, n) becomes UNHEX(LEFT/RIGHT(HEX(blob), n * 2)) 3704 # Each byte becomes 2 hex chars, so multiply length by 2 3705 hex_arg = exp.Hex(this=arg) 3706 hex_length = exp.Mul(this=length, expression=exp.Literal.number(2)) 3707 result: exp.Expression = exp.Unhex( 3708 this=exp.Anonymous(this=func_name, expressions=[hex_arg, hex_length]) 3709 ) 3710 else: 3711 result = exp.Anonymous(this=func_name, expressions=[arg, length]) 3712 3713 if expression.args.get("negative_length_returns_empty"): 3714 empty: exp.Expression = exp.Literal.string("") 3715 if is_binary: 3716 empty = exp.Unhex(this=empty) 3717 result = exp.case().when(length < exp.Literal.number(0), empty).else_(result) 3718 3719 return self.sql(result) 3720 3721 def left_sql(self, expression: exp.Left) -> str: 3722 return self._left_right_sql(expression, "LEFT") 3723 3724 def right_sql(self, expression: exp.Right) -> str: 3725 return self._left_right_sql(expression, "RIGHT") 3726 3727 def rtrimmedlength_sql(self, expression: exp.RtrimmedLength) -> str: 3728 return self.func("LENGTH", exp.Trim(this=expression.this, position="TRAILING")) 3729 3730 def stuff_sql(self, expression: exp.Stuff) -> str: 3731 base = expression.this 3732 start = expression.args["start"] 3733 length = expression.args["length"] 3734 insertion = expression.expression 3735 is_binary = _is_binary(base) 3736 3737 if is_binary: 3738 # DuckDB's SUBSTRING doesn't accept BLOB; operate on the HEX string instead 3739 # (each byte = 2 hex chars), then UNHEX back to BLOB 3740 base = exp.Hex(this=base) 3741 insertion = exp.Hex(this=insertion) 3742 left = exp.Substring( 3743 this=base.copy(), 3744 start=exp.Literal.number(1), 3745 length=(start.copy() - exp.Literal.number(1)) * exp.Literal.number(2), 3746 ) 3747 right = exp.Substring( 3748 this=base.copy(), 3749 start=((start + length) - exp.Literal.number(1)) * exp.Literal.number(2) 3750 + exp.Literal.number(1), 3751 ) 3752 else: 3753 left = exp.Substring( 3754 this=base.copy(), 3755 start=exp.Literal.number(1), 3756 length=start.copy() - exp.Literal.number(1), 3757 ) 3758 right = exp.Substring(this=base.copy(), start=start + length) 3759 result: exp.Expr = exp.DPipe( 3760 this=exp.DPipe(this=left, expression=insertion), expression=right 3761 ) 3762 3763 if is_binary: 3764 result = exp.Unhex(this=result) 3765 3766 return self.sql(result) 3767 3768 def rand_sql(self, expression: exp.Rand) -> str: 3769 seed = expression.this 3770 if seed is not None: 3771 self.unsupported("RANDOM with seed is not supported in DuckDB") 3772 3773 lower = expression.args.get("lower") 3774 upper = expression.args.get("upper") 3775 3776 if lower and upper: 3777 # scale DuckDB's [0,1) to the specified range 3778 range_size = exp.paren(upper - lower) 3779 scaled = exp.Add(this=lower, expression=exp.func("random") * range_size) 3780 3781 # For now we assume that if bounds are set, return type is BIGINT. Snowflake/Teradata 3782 result = exp.cast(scaled, exp.DType.BIGINT) 3783 return self.sql(result) 3784 3785 # Default DuckDB behavior - just return RANDOM() as float 3786 return "RANDOM()" 3787 3788 def bytelength_sql(self, expression: exp.ByteLength) -> str: 3789 arg = expression.this 3790 3791 # Check if it's a text type (handles both literals and annotated expressions) 3792 if arg.is_type(*exp.DataType.TEXT_TYPES): 3793 return self.func("OCTET_LENGTH", exp.Encode(this=arg)) 3794 3795 # Default: pass through as-is (conservative for DuckDB, handles binary and unannotated) 3796 return self.func("OCTET_LENGTH", arg) 3797 3798 def base64encode_sql(self, expression: exp.Base64Encode) -> str: 3799 # DuckDB TO_BASE64 requires BLOB input 3800 # Snowflake BASE64_ENCODE accepts both VARCHAR and BINARY - for VARCHAR it implicitly 3801 # encodes UTF-8 bytes. We add ENCODE unless the input is a binary type. 3802 result = expression.this 3803 3804 # Check if input is a string type - ENCODE only accepts VARCHAR 3805 if result.is_type(*exp.DataType.TEXT_TYPES): 3806 result = exp.Encode(this=result) 3807 3808 result = exp.ToBase64(this=result) 3809 3810 max_line_length = expression.args.get("max_line_length") 3811 alphabet = expression.args.get("alphabet") 3812 3813 # Handle custom alphabet by replacing standard chars with custom ones 3814 result = _apply_base64_alphabet_replacements(result, alphabet) 3815 3816 # Handle max_line_length by inserting newlines every N characters 3817 line_length = ( 3818 t.cast(int, max_line_length.to_py()) 3819 if isinstance(max_line_length, exp.Literal) and max_line_length.is_number 3820 else 0 3821 ) 3822 if line_length > 0: 3823 newline = exp.Chr(expressions=[exp.Literal.number(10)]) 3824 result = exp.Trim( 3825 this=exp.RegexpReplace( 3826 this=result, 3827 expression=exp.Literal.string(f"(.{{{line_length}}})"), 3828 replacement=exp.Concat(expressions=[exp.Literal.string("\\1"), newline.copy()]), 3829 ), 3830 expression=newline, 3831 position="TRAILING", 3832 ) 3833 3834 return self.sql(result) 3835 3836 def hex_sql(self, expression: exp.Hex) -> str: 3837 case = expression.args.get("case") 3838 3839 if not case: 3840 return self.func("HEX", expression.this) 3841 3842 hex_expr = exp.Hex(this=expression.this) 3843 return self.sql( 3844 exp.case() 3845 .when(case.is_(exp.null()), exp.null()) 3846 .when(case.copy().eq(0), exp.Lower(this=hex_expr.copy())) 3847 .else_(hex_expr) 3848 ) 3849 3850 def replace_sql(self, expression: exp.Replace) -> str: 3851 result_sql = self.func( 3852 "REPLACE", 3853 _cast_to_varchar(expression.this), 3854 _cast_to_varchar(expression.expression), 3855 _cast_to_varchar(expression.args.get("replacement")), 3856 ) 3857 return _gen_with_cast_to_blob(self, expression, result_sql) 3858 3859 def _bitwise_op(self, expression: exp.Binary, op: str) -> str: 3860 _prepare_binary_bitwise_args(expression) 3861 result_sql = self.binary(expression, op) 3862 return _gen_with_cast_to_blob(self, expression, result_sql) 3863 3864 def bitwisexor_sql(self, expression: exp.BitwiseXor) -> str: 3865 _prepare_binary_bitwise_args(expression) 3866 result_sql = self.func("XOR", expression.this, expression.expression) 3867 return _gen_with_cast_to_blob(self, expression, result_sql) 3868 3869 def objectinsert_sql(self, expression: exp.ObjectInsert) -> str: 3870 this = expression.this 3871 key = expression.args.get("key") 3872 key_sql = key.name if isinstance(key, exp.Expr) else "" 3873 value_sql = self.sql(expression, "value") 3874 3875 kv_sql = f"{key_sql} := {value_sql}" 3876 3877 # If the input struct is empty e.g. transpiling OBJECT_INSERT(OBJECT_CONSTRUCT(), key, value) from Snowflake 3878 # then we can generate STRUCT_PACK which will build it since STRUCT_INSERT({}, key := value) is not valid DuckDB 3879 if isinstance(this, exp.Struct) and not this.expressions: 3880 return self.func("STRUCT_PACK", kv_sql) 3881 3882 return self.func("STRUCT_INSERT", this, kv_sql) 3883 3884 def mapcat_sql(self, expression: exp.MapCat) -> str: 3885 result = exp.replace_placeholders( 3886 self.MAPCAT_TEMPLATE.copy(), 3887 map1=expression.this, 3888 map2=expression.expression, 3889 ) 3890 return self.sql(result) 3891 3892 def mapcontainskey_sql(self, expression: exp.MapContainsKey) -> str: 3893 return self.func( 3894 "ARRAY_CONTAINS", exp.func("MAP_KEYS", expression.args["key"]), expression.this 3895 ) 3896 3897 def mapdelete_sql(self, expression: exp.MapDelete) -> str: 3898 map_arg = expression.this 3899 keys_to_delete = expression.expressions 3900 3901 x_dot_key = exp.Dot(this=exp.to_identifier("x"), expression=exp.to_identifier("key")) 3902 3903 lambda_expr = exp.Lambda( 3904 this=exp.In(this=x_dot_key, expressions=keys_to_delete).not_(), 3905 expressions=[exp.to_identifier("x")], 3906 ) 3907 result = exp.func( 3908 "MAP_FROM_ENTRIES", 3909 exp.ArrayFilter(this=exp.func("MAP_ENTRIES", map_arg), expression=lambda_expr), 3910 ) 3911 return self.sql(result) 3912 3913 def mappick_sql(self, expression: exp.MapPick) -> str: 3914 map_arg = expression.this 3915 keys_to_pick = expression.expressions 3916 3917 x_dot_key = exp.Dot(this=exp.to_identifier("x"), expression=exp.to_identifier("key")) 3918 3919 if len(keys_to_pick) == 1 and keys_to_pick[0].is_type(exp.DType.ARRAY): 3920 lambda_expr = exp.Lambda( 3921 this=exp.func("ARRAY_CONTAINS", keys_to_pick[0], x_dot_key), 3922 expressions=[exp.to_identifier("x")], 3923 ) 3924 else: 3925 lambda_expr = exp.Lambda( 3926 this=exp.In(this=x_dot_key, expressions=keys_to_pick), 3927 expressions=[exp.to_identifier("x")], 3928 ) 3929 3930 result = exp.func( 3931 "MAP_FROM_ENTRIES", 3932 exp.func("LIST_FILTER", exp.func("MAP_ENTRIES", map_arg), lambda_expr), 3933 ) 3934 return self.sql(result) 3935 3936 def mapsize_sql(self, expression: exp.MapSize) -> str: 3937 return self.func("CARDINALITY", expression.this) 3938 3939 @unsupported_args("update_flag") 3940 def mapinsert_sql(self, expression: exp.MapInsert) -> str: 3941 map_arg = expression.this 3942 key = expression.args.get("key") 3943 value = expression.args.get("value") 3944 3945 map_type = map_arg.type 3946 3947 if value is not None: 3948 if map_type and map_type.expressions and len(map_type.expressions) > 1: 3949 # Extract the value type from MAP(key_type, value_type) 3950 value_type = map_type.expressions[1] 3951 # Cast value to match the map's value type to avoid type conflicts 3952 value = exp.cast(value, value_type) 3953 # else: polymorphic MAP case - no type parameters available, use value as-is 3954 3955 # Create a single-entry map for the new key-value pair 3956 new_entry_struct = exp.Struct(expressions=[exp.PropertyEQ(this=key, expression=value)]) 3957 new_entry: exp.Expression = exp.ToMap(this=new_entry_struct) 3958 3959 # Use MAP_CONCAT to merge the original map with the new entry 3960 # This automatically handles both insert and update cases 3961 result = exp.func("MAP_CONCAT", map_arg, new_entry) 3962 3963 return self.sql(result) 3964 3965 def startswith_sql(self, expression: exp.StartsWith) -> str: 3966 return self.func( 3967 "STARTS_WITH", 3968 _cast_to_varchar(expression.this), 3969 _cast_to_varchar(expression.expression), 3970 ) 3971 3972 def space_sql(self, expression: exp.Space) -> str: 3973 # DuckDB's REPEAT requires BIGINT for the count parameter 3974 return self.sql( 3975 exp.Repeat( 3976 this=exp.Literal.string(" "), 3977 times=exp.cast(expression.this, exp.DType.BIGINT), 3978 ) 3979 ) 3980 3981 def tablefromrows_sql(self, expression: exp.TableFromRows) -> str: 3982 # For GENERATOR, unwrap TABLE() - just emit the Generator (becomes RANGE) 3983 if isinstance(expression.this, exp.Generator): 3984 # Preserve alias, joins, and other table-level args 3985 table = exp.Table( 3986 this=expression.this, 3987 alias=expression.args.get("alias"), 3988 joins=expression.args.get("joins"), 3989 ) 3990 return self.sql(table) 3991 3992 return super().tablefromrows_sql(expression) 3993 3994 def unnest_sql(self, expression: exp.Unnest) -> str: 3995 explode_array = expression.args.get("explode_array") 3996 if explode_array: 3997 # In BigQuery, UNNESTing a nested array leads to explosion of the top-level array & struct 3998 # This is transpiled to DDB by transforming "FROM UNNEST(...)" to "FROM (SELECT UNNEST(..., max_depth => 2))" 3999 expression.expressions.append( 4000 exp.Kwarg(this=exp.var("max_depth"), expression=exp.Literal.number(2)) 4001 ) 4002 4003 # If BQ's UNNEST is aliased, we transform it from a column alias to a table alias in DDB 4004 alias = expression.args.get("alias") 4005 if isinstance(alias, exp.TableAlias): 4006 expression.set("alias", None) 4007 if alias.columns: 4008 alias = exp.TableAlias(this=seq_get(alias.columns, 0)) 4009 4010 unnest_sql = super().unnest_sql(expression) 4011 select = exp.Select(expressions=[unnest_sql]).subquery(alias) 4012 return self.sql(select) 4013 4014 return super().unnest_sql(expression) 4015 4016 def arrayagg_sql(self, expression: exp.ArrayAgg) -> str: 4017 if isinstance(expression.this, exp.Limit): 4018 self.unsupported("LIMIT inside ARRAY_AGG is not supported in DuckDB") 4019 4020 return super().arrayagg_sql(expression) 4021 4022 def ignorenulls_sql(self, expression: exp.IgnoreNulls) -> str: 4023 this = expression.this 4024 4025 if isinstance(this, self.IGNORE_RESPECT_NULLS_WINDOW_FUNCTIONS): 4026 # DuckDB should render IGNORE NULLS only for the general-purpose 4027 # window functions that accept it e.g. FIRST_VALUE(... IGNORE NULLS) OVER (...) 4028 return super().ignorenulls_sql(expression) 4029 4030 # For ARRAY_AGG(expr IGNORE NULLS ...), convert IGNORE NULLS to a 4031 # FILTER(WHERE expr IS NOT NULL) clause by setting nulls_excluded on 4032 # the ArrayAgg. The existing _add_arrayagg_null_filter method will 4033 # emit the FILTER clause during arrayagg_sql / withingroup_sql. 4034 if isinstance(this, exp.ArrayAgg): 4035 this.set("nulls_excluded", True) 4036 return self.sql(this) 4037 4038 if isinstance(this, exp.First): 4039 this = exp.AnyValue(this=this.this) 4040 4041 if not isinstance(this, (exp.AnyValue, exp.ApproxQuantiles)): 4042 self.unsupported("IGNORE NULLS is not supported for non-window functions.") 4043 4044 return self.sql(this) 4045 4046 def split_sql(self, expression: exp.Split) -> str: 4047 base_func = exp.func("STR_SPLIT", expression.this, expression.expression) 4048 4049 case_expr = exp.case().else_(base_func) 4050 needs_case = False 4051 4052 if expression.args.get("null_returns_null"): 4053 case_expr = case_expr.when(expression.expression.is_(exp.null()), exp.null()) 4054 needs_case = True 4055 4056 if expression.args.get("empty_delimiter_returns_whole"): 4057 # When delimiter is empty string, return input string as single array element 4058 array_with_input = exp.array(expression.this) 4059 case_expr = case_expr.when( 4060 expression.expression.eq(exp.Literal.string("")), array_with_input 4061 ) 4062 needs_case = True 4063 4064 return self.sql(case_expr if needs_case else base_func) 4065 4066 def splitpart_sql(self, expression: exp.SplitPart) -> str: 4067 string_arg = expression.this 4068 delimiter_arg = expression.args.get("delimiter") 4069 part_index_arg = expression.args.get("part_index") 4070 4071 if delimiter_arg and part_index_arg: 4072 # Handle Snowflake's "index 0 and 1 both return first element" behavior 4073 if expression.args.get("part_index_zero_as_one"): 4074 # Convert 0 to 1 for compatibility 4075 4076 part_index_arg = exp.Paren( 4077 this=exp.case() 4078 .when(part_index_arg.eq(exp.Literal.number("0")), exp.Literal.number("1")) 4079 .else_(part_index_arg) 4080 ) 4081 4082 # Use Anonymous to avoid recursion 4083 base_func_expr: exp.Expr = exp.Anonymous( 4084 this="SPLIT_PART", expressions=[string_arg, delimiter_arg, part_index_arg] 4085 ) 4086 needs_case_transform = False 4087 case_expr = exp.case().else_(base_func_expr) 4088 4089 if expression.args.get("empty_delimiter_returns_whole"): 4090 # When delimiter is empty string: 4091 # - Return whole string if part_index is 1 or -1 4092 # - Return empty string otherwise 4093 empty_case = exp.Paren( 4094 this=exp.case() 4095 .when( 4096 exp.or_( 4097 part_index_arg.eq(exp.Literal.number("1")), 4098 part_index_arg.eq(exp.Literal.number("-1")), 4099 ), 4100 string_arg, 4101 ) 4102 .else_(exp.Literal.string("")) 4103 ) 4104 4105 case_expr = case_expr.when(delimiter_arg.eq(exp.Literal.string("")), empty_case) 4106 needs_case_transform = True 4107 4108 """ 4109 Output looks something like this: 4110 4111 CASE 4112 WHEN delimiter is '' THEN 4113 ( 4114 CASE 4115 WHEN adjusted_part_index = 1 OR adjusted_part_index = -1 THEN input 4116 ELSE '' END 4117 ) 4118 ELSE SPLIT_PART(input, delimiter, adjusted_part_index) 4119 END 4120 4121 """ 4122 return self.sql(case_expr if needs_case_transform else base_func_expr) 4123 4124 return self.function_fallback_sql(expression) 4125 4126 def respectnulls_sql(self, expression: exp.RespectNulls) -> str: 4127 if isinstance(expression.this, self.IGNORE_RESPECT_NULLS_WINDOW_FUNCTIONS): 4128 # DuckDB should render RESPECT NULLS only for the general-purpose 4129 # window functions that accept it e.g. FIRST_VALUE(... RESPECT NULLS) OVER (...) 4130 return super().respectnulls_sql(expression) 4131 4132 self.unsupported("RESPECT NULLS is not supported for non-window functions.") 4133 return self.sql(expression, "this") 4134 4135 def arraytostring_sql(self, expression: exp.ArrayToString) -> str: 4136 null = expression.args.get("null") 4137 4138 if expression.args.get("null_is_empty"): 4139 x = exp.to_identifier("x") 4140 list_transform = exp.Transform( 4141 this=expression.this.copy(), 4142 expression=exp.Lambda( 4143 this=exp.Coalesce( 4144 this=exp.cast(x, "TEXT"), expressions=[exp.Literal.string("")] 4145 ), 4146 expressions=[x], 4147 ), 4148 ) 4149 array_to_string = exp.ArrayToString( 4150 this=list_transform, expression=expression.expression 4151 ) 4152 if expression.args.get("null_delim_is_null"): 4153 return self.sql( 4154 exp.case() 4155 .when(expression.expression.copy().is_(exp.null()), exp.null()) 4156 .else_(array_to_string) 4157 ) 4158 return self.sql(array_to_string) 4159 4160 if null: 4161 x = exp.to_identifier("x") 4162 return self.sql( 4163 exp.ArrayToString( 4164 this=exp.Transform( 4165 this=expression.this, 4166 expression=exp.Lambda( 4167 this=exp.Coalesce(this=x, expressions=[null]), 4168 expressions=[x], 4169 ), 4170 ), 4171 expression=expression.expression, 4172 ) 4173 ) 4174 4175 return self.func("ARRAY_TO_STRING", expression.this, expression.expression) 4176 4177 def concatws_sql(self, expression: exp.ConcatWs) -> str: 4178 # DuckDB-specific: handle binary types using DPipe (||) operator 4179 separator = seq_get(expression.expressions, 0) 4180 args = expression.expressions[1:] 4181 4182 if any(_is_binary(arg) for arg in [separator, *args]): 4183 result = args[0] 4184 for arg in args[1:]: 4185 result = exp.DPipe( 4186 this=exp.DPipe(this=result, expression=separator), expression=arg 4187 ) 4188 return self.sql(result) 4189 4190 if expression.args.get("flatten") and any( 4191 isinstance(arg, exp.Array) or arg.is_type(exp.DType.ARRAY) for arg in args 4192 ): 4193 arrays = [] 4194 for arg in args: 4195 if isinstance(arg, exp.Array) or arg.is_type(exp.DType.ARRAY): 4196 arg = exp.cast(arg, "TEXT[]") 4197 else: 4198 if (not arg.type or arg.is_type(exp.DType.UNKNOWN)) and not isinstance( 4199 arg, exp.CONSTANTS 4200 ): 4201 self.unsupported( 4202 "Cannot transpile CONCAT_WS with unknown argument types to DuckDB." 4203 ) 4204 arg = exp.array(exp.cast(arg, exp.DType.TEXT)) 4205 4206 arrays.append(arg) 4207 4208 array = ( 4209 exp.ArrayConcat(this=arrays[0], expressions=arrays[1:]) 4210 if len(arrays) > 1 4211 else arrays[0] 4212 ) 4213 return self.func("CONCAT_WS", separator, self.func("ARRAY_TO_STRING", array, separator)) 4214 4215 return super().concatws_sql(expression) 4216 4217 def _regexp_extract_sql(self, expression: exp.RegexpExtract | exp.RegexpExtractAll) -> str: 4218 this = expression.this 4219 group = expression.args.get("group") 4220 params = expression.args.get("parameters") 4221 position = expression.args.get("position") 4222 occurrence = expression.args.get("occurrence") 4223 null_if_pos_overflow = expression.args.get("null_if_pos_overflow") 4224 4225 # Handle Snowflake's 'e' flag: it enables capture group extraction 4226 # In DuckDB, this is controlled by the group parameter directly 4227 if params and params.is_string and "e" in params.name: 4228 params = exp.Literal.string(params.name.replace("e", "")) 4229 4230 validated_flags = self._validate_regexp_flags(params, supported_flags="cims") 4231 4232 # Strip default group when no following params (DuckDB default is same as group=0) 4233 if ( 4234 not validated_flags 4235 and group 4236 and group.name == str(self.dialect.REGEXP_EXTRACT_DEFAULT_GROUP) 4237 ): 4238 group = None 4239 4240 flags_expr = exp.Literal.string(validated_flags) if validated_flags else None 4241 4242 # use substring to handle position argument 4243 if position and (not position.is_int or position.to_py() > 1): 4244 this = exp.Substring(this=this, start=position) 4245 4246 if null_if_pos_overflow: 4247 this = exp.Nullif(this=this, expression=exp.Literal.string("")) 4248 4249 is_extract_all = isinstance(expression, exp.RegexpExtractAll) 4250 non_single_occurrence = occurrence and (not occurrence.is_int or occurrence.to_py() > 1) 4251 4252 if is_extract_all or non_single_occurrence: 4253 name = "REGEXP_EXTRACT_ALL" 4254 else: 4255 name = "REGEXP_EXTRACT" 4256 4257 result: exp.Expr = exp.Anonymous( 4258 this=name, expressions=[this, expression.expression, group, flags_expr] 4259 ) 4260 4261 # Array slicing for REGEXP_EXTRACT_ALL with occurrence 4262 if is_extract_all and non_single_occurrence: 4263 result = exp.Bracket(this=result, expressions=[exp.Slice(this=occurrence)]) 4264 # ARRAY_EXTRACT for REGEXP_EXTRACT with occurrence > 1 4265 elif non_single_occurrence: 4266 result = exp.Anonymous(this="ARRAY_EXTRACT", expressions=[result, occurrence]) 4267 4268 return self.sql(result) 4269 4270 def regexpextract_sql(self, expression: exp.RegexpExtract) -> str: 4271 return self._regexp_extract_sql(expression) 4272 4273 def regexpextractall_sql(self, expression: exp.RegexpExtractAll) -> str: 4274 return self._regexp_extract_sql(expression) 4275 4276 def regexpinstr_sql(self, expression: exp.RegexpInstr) -> str: 4277 this = expression.this 4278 pattern = expression.expression 4279 position = expression.args.get("position") 4280 orig_occ = expression.args.get("occurrence") 4281 occurrence = orig_occ or exp.Literal.number(1) 4282 option = expression.args.get("option") 4283 parameters = expression.args.get("parameters") 4284 4285 validated_flags = self._validate_regexp_flags(parameters, supported_flags="ims") 4286 # Checked before the flags are embedded, which would make the pattern non-empty 4287 is_empty = pattern.copy().eq(exp.Literal.string("")) 4288 if validated_flags: 4289 pattern = exp.Concat(expressions=[exp.Literal.string(f"(?{validated_flags})"), pattern]) 4290 4291 # Handle starting position offset 4292 pos_offset: exp.Expr = exp.Literal.number(0) 4293 if position and (not position.is_int or position.to_py() > 1): 4294 this = exp.Substring(this=this, start=position) 4295 pos_offset = position - exp.Literal.number(1) 4296 4297 # Helper: LIST_SUM(LIST_TRANSFORM(list[1:end], x -> LENGTH(x))) 4298 def sum_lengths(func_name: str, end: exp.Expr) -> exp.Expr: 4299 lst = exp.Bracket( 4300 this=exp.Anonymous(this=func_name, expressions=[this, pattern]), 4301 expressions=[exp.Slice(this=exp.Literal.number(1), expression=end)], 4302 offset=1, 4303 ) 4304 transform = exp.Anonymous( 4305 this="LIST_TRANSFORM", 4306 expressions=[ 4307 lst, 4308 exp.Lambda( 4309 this=exp.Length(this=exp.to_identifier("x")), 4310 expressions=[exp.to_identifier("x")], 4311 ), 4312 ], 4313 ) 4314 return exp.Coalesce( 4315 this=exp.Anonymous(this="LIST_SUM", expressions=[transform]), 4316 expressions=[exp.Literal.number(0)], 4317 ) 4318 4319 # Position = 1 + sum(split_lengths[1:occ]) + sum(match_lengths[1:occ-1]) + offset 4320 base_pos: exp.Expr = ( 4321 exp.Literal.number(1) 4322 + sum_lengths("STRING_SPLIT_REGEX", occurrence) 4323 + sum_lengths("REGEXP_EXTRACT_ALL", occurrence - exp.Literal.number(1)) 4324 + pos_offset 4325 ) 4326 4327 # option=1: add match length for end position 4328 if option and option.is_int and option.to_py() == 1: 4329 match_at_occ = exp.Bracket( 4330 this=exp.Anonymous(this="REGEXP_EXTRACT_ALL", expressions=[this, pattern]), 4331 expressions=[occurrence], 4332 offset=1, 4333 ) 4334 base_pos = base_pos + exp.Coalesce( 4335 this=exp.Length(this=match_at_occ), expressions=[exp.Literal.number(0)] 4336 ) 4337 4338 # NULL checks for all provided arguments 4339 # .copy() is used strictly because .is_() alters the node's parent pointer, mutating the parsed AST 4340 null_args = [ 4341 expression.this, 4342 expression.expression, 4343 position, 4344 orig_occ, 4345 option, 4346 parameters, 4347 ] 4348 null_checks = [arg.copy().is_(exp.Null()) for arg in null_args if arg] 4349 4350 matches = exp.Anonymous(this="REGEXP_EXTRACT_ALL", expressions=[this, pattern]) 4351 4352 return self.sql( 4353 exp.case() 4354 .when(exp.or_(*null_checks), exp.Null()) 4355 .when(is_empty, exp.Literal.number(0)) 4356 .when(exp.Length(this=matches) < occurrence, exp.Literal.number(0)) 4357 .else_(base_pos) 4358 ) 4359 4360 @unsupported_args("culture") 4361 def numbertostr_sql(self, expression: exp.NumberToStr) -> str: 4362 fmt = expression.args.get("format") 4363 if fmt and fmt.is_int: 4364 return self.func("FORMAT", f"'{{:,.{fmt.name}f}}'", expression.this) 4365 4366 self.unsupported("Only integer formats are supported by NumberToStr") 4367 return self.function_fallback_sql(expression) 4368 4369 def autoincrementcolumnconstraint_sql(self, _) -> str: 4370 self.unsupported("The AUTOINCREMENT column constraint is not supported by DuckDB") 4371 return "" 4372 4373 def aliases_sql(self, expression: exp.Aliases) -> str: 4374 this = expression.this 4375 if isinstance(this, exp.Posexplode): 4376 return self.posexplode_sql(this) 4377 4378 return super().aliases_sql(expression) 4379 4380 def posexplode_sql(self, expression: exp.Posexplode) -> str: 4381 this = expression.this 4382 parent = expression.parent 4383 4384 # The default Spark aliases are "pos" and "col", unless specified otherwise 4385 pos, col = exp.to_identifier("pos"), exp.to_identifier("col") 4386 4387 if isinstance(parent, exp.Aliases): 4388 # Column case: SELECT POSEXPLODE(col) [AS (a, b)] 4389 pos, col = parent.expressions 4390 elif isinstance(parent, exp.Table): 4391 # Table case: SELECT * FROM POSEXPLODE(col) [AS (a, b)] 4392 alias = parent.args.get("alias") 4393 if alias: 4394 pos, col = alias.columns or [pos, col] 4395 alias.pop() 4396 4397 # Translate POSEXPLODE to UNNEST + GENERATE_SUBSCRIPTS 4398 # Note: In Spark pos is 0-indexed, but in DuckDB it's 1-indexed, so we subtract 1 from GENERATE_SUBSCRIPTS 4399 unnest_sql = self.sql(exp.Unnest(expressions=[this], alias=col)) 4400 gen_subscripts = self.sql( 4401 exp.Alias( 4402 this=exp.Anonymous( 4403 this="GENERATE_SUBSCRIPTS", expressions=[this, exp.Literal.number(1)] 4404 ) 4405 - exp.Literal.number(1), 4406 alias=pos, 4407 ) 4408 ) 4409 4410 posexplode_sql = self.format_args(gen_subscripts, unnest_sql) 4411 4412 if isinstance(parent, exp.From) or (parent and isinstance(parent.parent, exp.From)): 4413 # SELECT * FROM POSEXPLODE(col) -> SELECT * FROM (SELECT GENERATE_SUBSCRIPTS(...), UNNEST(...)) 4414 return self.sql(exp.Subquery(this=exp.Select(expressions=[posexplode_sql]))) 4415 4416 return posexplode_sql 4417 4418 def addmonths_sql(self, expression: exp.AddMonths) -> str: 4419 """ 4420 Handles three key issues: 4421 1. Float/decimal months: e.g., Snowflake rounds, whereas DuckDB INTERVAL requires integers 4422 2. End-of-month preservation: If input is last day of month, result is last day of result month 4423 3. Type preservation: Maintains DATE/TIMESTAMPTZ types (DuckDB defaults to TIMESTAMP) 4424 """ 4425 from sqlglot.optimizer.annotate_types import annotate_types 4426 4427 this = expression.this 4428 if not this.type: 4429 this = annotate_types(this, dialect=self.dialect) 4430 4431 if this.is_type(*exp.DataType.TEXT_TYPES): 4432 this = exp.Cast(this=this, to=exp.DataType(this=exp.DType.TIMESTAMP)) 4433 4434 # Detect float/decimal months to apply rounding (Snowflake behavior) 4435 # DuckDB INTERVAL syntax doesn't support non-integer expressions, so use TO_MONTHS 4436 months_expr = expression.expression 4437 if not months_expr.type: 4438 months_expr = annotate_types(months_expr, dialect=self.dialect) 4439 4440 # Build interval or to_months expression based on type 4441 # Float/decimal case: Round and use TO_MONTHS(CAST(ROUND(value) AS INT)) 4442 interval_or_to_months = ( 4443 exp.func("TO_MONTHS", exp.cast(exp.func("ROUND", months_expr), "INT")) 4444 if months_expr.is_type( 4445 exp.DType.FLOAT, 4446 exp.DType.DOUBLE, 4447 exp.DType.DECIMAL, 4448 ) 4449 # Integer case: standard INTERVAL N MONTH syntax 4450 else exp.Interval(this=months_expr, unit=exp.var("MONTH")) 4451 ) 4452 4453 date_add_expr = exp.Add(this=this, expression=interval_or_to_months) 4454 4455 # Apply end-of-month preservation if Snowflake flag is set 4456 # CASE WHEN LAST_DAY(date) = date THEN LAST_DAY(result) ELSE result END 4457 preserve_eom = expression.args.get("preserve_end_of_month") 4458 result_expr = ( 4459 exp.case() 4460 .when( 4461 exp.EQ(this=exp.func("LAST_DAY", this), expression=this), 4462 exp.func("LAST_DAY", date_add_expr), 4463 ) 4464 .else_(date_add_expr) 4465 if preserve_eom 4466 else date_add_expr 4467 ) 4468 4469 # DuckDB's DATE_ADD function returns TIMESTAMP/DATETIME by default, even when the input is DATE 4470 # To match for example Snowflake's ADD_MONTHS behavior (which preserves the input type) 4471 # We need to cast the result back to the original type when the input is DATE or TIMESTAMPTZ 4472 # Example: ADD_MONTHS('2023-01-31'::date, 1) should return DATE, not TIMESTAMP 4473 if this.is_type(exp.DType.DATE, exp.DType.TIMESTAMPTZ): 4474 return self.sql(exp.Cast(this=result_expr, to=this.type)) 4475 return self.sql(result_expr) 4476 4477 def format_sql(self, expression: exp.Format) -> str: 4478 if expression.name.lower() == "%s" and len(expression.expressions) == 1: 4479 return self.func("FORMAT", "'{}'", expression.expressions[0]) 4480 4481 return self.function_fallback_sql(expression) 4482 4483 def hexstring_sql( 4484 self, expression: exp.HexString, binary_function_repr: str | None = None 4485 ) -> str: 4486 # UNHEX('FF') correctly produces blob \xFF in DuckDB 4487 return super().hexstring_sql(expression, binary_function_repr="UNHEX") 4488 4489 def datetrunc_sql(self, expression: exp.DateTrunc) -> str: 4490 unit = expression.args.get("unit") 4491 date = expression.this 4492 4493 week_start = _week_trunc_start_dow(unit) 4494 unit = unit_to_str(expression) 4495 4496 if week_start: 4497 result = self.sql( 4498 _build_week_trunc_expression(date, week_start, preserve_start_day=True) 4499 ) 4500 else: 4501 result = self.func("DATE_TRUNC", unit, date) 4502 4503 if ( 4504 expression.args.get("input_type_preserved") 4505 and date.is_type(*exp.DataType.TEMPORAL_TYPES) 4506 and not (is_date_unit(unit) and date.is_type(exp.DType.DATE)) 4507 ): 4508 return self.sql(exp.Cast(this=result, to=date.type)) 4509 4510 return result 4511 4512 def datetimetrunc_sql(self, expression: exp.DatetimeTrunc) -> str: 4513 this = exp.cast(expression.this, exp.DType.DATETIME) 4514 week_start = _week_trunc_start_dow(expression.args.get("unit")) 4515 if week_start: 4516 return self.sql( 4517 _build_week_trunc_expression( 4518 this, week_start, preserve_start_day=True, cast_to_date=False 4519 ) 4520 ) 4521 4522 return self.func("DATE_TRUNC", unit_to_str(expression), this) 4523 4524 def timestamptrunc_sql(self, expression: exp.TimestampTrunc) -> str: 4525 zone = expression.args.get("zone") 4526 timestamp = expression.this 4527 week_start = _week_trunc_start_dow(expression.args.get("unit")) 4528 4529 # The week start emulation below is exact, so avoid weekstart_unit_to_str's degrade warning 4530 unit = unit_to_str(expression) if week_start else weekstart_unit_to_str(self, expression) 4531 date_unit = is_date_unit(unit) or bool(week_start) 4532 4533 def _trunc_expr(this: exp.Expr) -> exp.Expr: 4534 if week_start: 4535 return _build_week_trunc_expression( 4536 this, week_start, preserve_start_day=True, cast_to_date=False 4537 ) 4538 return exp.func("DATE_TRUNC", unit, this) 4539 4540 if date_unit and zone: 4541 # BigQuery's TIMESTAMP_TRUNC with timezone truncates in the target timezone and returns as UTC. 4542 # Double AT TIME ZONE needed for BigQuery compatibility: 4543 # 1. First AT TIME ZONE: ensures truncation happens in the target timezone 4544 # 2. Second AT TIME ZONE: converts the DATE result back to TIMESTAMPTZ (preserving time component) 4545 timestamp = exp.AtTimeZone(this=timestamp, zone=zone) 4546 trunced = _trunc_expr(timestamp) 4547 if isinstance(trunced, exp.DateAdd): 4548 # Parenthesize so the trailing AT TIME ZONE binds to the whole shifted expression 4549 trunced = exp.Paren(this=trunced) 4550 return self.sql(exp.AtTimeZone(this=trunced, zone=zone)) 4551 4552 result = self.sql(_trunc_expr(timestamp)) 4553 if expression.args.get("input_type_preserved"): 4554 if timestamp.type and timestamp.is_type(exp.DType.TIME, exp.DType.TIMETZ): 4555 dummy_date = exp.Cast( 4556 this=exp.Literal.string("1970-01-01"), 4557 to=exp.DataType(this=exp.DType.DATE), 4558 ) 4559 date_time = exp.Add(this=dummy_date, expression=timestamp) 4560 result = self.func("DATE_TRUNC", unit, date_time) 4561 return self.sql(exp.Cast(this=result, to=timestamp.type)) 4562 4563 if timestamp.is_type(*exp.DataType.TEMPORAL_TYPES) and not ( 4564 date_unit and timestamp.is_type(exp.DType.DATE) 4565 ): 4566 return self.sql(exp.Cast(this=result, to=timestamp.type)) 4567 4568 return result 4569 4570 def trim_sql(self, expression: exp.Trim) -> str: 4571 expression.this.replace(_cast_to_varchar(expression.this)) 4572 if expression.expression: 4573 expression.expression.replace(_cast_to_varchar(expression.expression)) 4574 4575 result_sql = super().trim_sql(expression) 4576 return _gen_with_cast_to_blob(self, expression, result_sql) 4577 4578 def round_sql(self, expression: exp.Round) -> str: 4579 this = expression.this 4580 decimals = expression.args.get("decimals") 4581 truncate = expression.args.get("truncate") 4582 4583 # DuckDB requires the scale (decimals) argument to be an INT 4584 # Some dialects (e.g., Snowflake) allow non-integer scales and cast to an integer internally 4585 if decimals is not None and expression.args.get("casts_non_integer_decimals"): 4586 if not (decimals.is_int or decimals.is_type(*exp.DataType.INTEGER_TYPES)): 4587 decimals = exp.cast(decimals, exp.DType.INT) 4588 4589 func = "ROUND" 4590 if truncate: 4591 # BigQuery uses ROUND_HALF_EVEN; Snowflake uses HALF_TO_EVEN 4592 if truncate.this in ("ROUND_HALF_EVEN", "HALF_TO_EVEN"): 4593 func = "ROUND_EVEN" 4594 truncate = None 4595 # BigQuery uses ROUND_HALF_AWAY_FROM_ZERO; Snowflake uses HALF_AWAY_FROM_ZERO 4596 elif truncate.this in ("ROUND_HALF_AWAY_FROM_ZERO", "HALF_AWAY_FROM_ZERO"): 4597 truncate = None 4598 4599 return self.func(func, this, decimals, truncate) 4600 4601 def trycast_sql(self, expression: exp.TryCast) -> str: 4602 to = expression.to 4603 to_type = to.this 4604 src = expression.this 4605 4606 if ( 4607 expression.args.get("null_on_text_overflow") 4608 and to_type in exp.DataType.TEXT_TYPES 4609 and to.expressions 4610 ): 4611 return self.sql( 4612 exp.case() 4613 .when( 4614 exp.LTE(this=exp.func("LENGTH", src), expression=to.expressions[0].this), 4615 exp.cast(src, "TEXT"), 4616 ) 4617 .else_(exp.Null()) 4618 ) 4619 elif to_type == exp.DType.DATE and expression.args.get("probe_date_format"): 4620 slash_strptime = exp.cast( 4621 exp.func("TRY_STRPTIME", src, exp.Literal.string(self._TRYCAST_DATE_SLASH_FMT)), 4622 "DATE", 4623 ) 4624 mon_strptime = exp.cast( 4625 exp.func("TRY_STRPTIME", src, exp.Literal.string(self._TRYCAST_DATE_MON_FMT)), 4626 "DATE", 4627 ) 4628 return self.sql( 4629 exp.case() 4630 .when(exp.func("CONTAINS", src, exp.Literal.string("/")), slash_strptime) 4631 .when( 4632 exp.RegexpLike(this=src, expression=exp.Literal.string("[A-Za-z]")), 4633 mon_strptime, 4634 ) 4635 .else_(exp.TryCast(this=src, to=to)) 4636 ) 4637 elif ( 4638 isinstance(to_type, exp.Interval) 4639 and (unit := to_type.unit) 4640 and expression.args.get("requires_string") 4641 ): 4642 interval_type = exp.DataType.build("INTERVAL") 4643 if isinstance(unit, exp.IntervalSpan): 4644 self.unsupported( 4645 "TRY_CAST to INTERVAL with span (e.g. HOUR TO MINUTE) is not supported in DuckDB" 4646 ) 4647 return self.sql(exp.TryCast(this=src, to=interval_type)) 4648 return self.sql( 4649 exp.TryCast( 4650 this=exp.DPipe(this=src, expression=exp.Literal.string(f" {unit.name}")), 4651 to=interval_type, 4652 ) 4653 ) 4654 4655 return super().trycast_sql(expression) 4656 4657 def strtok_sql(self, expression: exp.Strtok) -> str: 4658 string_arg = expression.this 4659 delimiter_arg = expression.args.get("delimiter") 4660 part_index_arg = expression.args.get("part_index") 4661 4662 if delimiter_arg and part_index_arg: 4663 # Escape regex chars and build character class at runtime using REGEXP_REPLACE 4664 escaped_delimiter = exp.Anonymous( 4665 this="REGEXP_REPLACE", 4666 expressions=[ 4667 delimiter_arg, 4668 exp.Literal.string( 4669 r"([\[\]^.\-*+?(){}|$\\])" 4670 ), # Escape problematic regex chars 4671 exp.Literal.string( 4672 r"\\\1" 4673 ), # Replace with escaped version using $1 backreference 4674 exp.Literal.string("g"), # Global flag 4675 ], 4676 ) 4677 # CASE WHEN delimiter = '' THEN '' ELSE CONCAT('[', escaped_delimiter, ']') END 4678 regex_pattern = ( 4679 exp.case() 4680 .when(delimiter_arg.eq(exp.Literal.string("")), exp.Literal.string("")) 4681 .else_( 4682 exp.func( 4683 "CONCAT", 4684 exp.Literal.string("["), 4685 escaped_delimiter, 4686 exp.Literal.string("]"), 4687 ) 4688 ) 4689 ) 4690 4691 # STRTOK skips empty strings, so we need to filter them out 4692 # LIST_FILTER(REGEXP_SPLIT_TO_ARRAY(string, pattern), x -> x != '')[index] 4693 split_array = exp.func("REGEXP_SPLIT_TO_ARRAY", string_arg, regex_pattern) 4694 x = exp.to_identifier("x") 4695 is_empty = x.eq(exp.Literal.string("")) 4696 filtered_array = exp.func( 4697 "LIST_FILTER", 4698 split_array, 4699 exp.Lambda(this=exp.not_(is_empty.copy()), expressions=[x.copy()]), 4700 ) 4701 base_func = exp.Bracket( 4702 this=filtered_array, 4703 expressions=[part_index_arg], 4704 offset=1, 4705 ) 4706 4707 # Use template with the built regex pattern 4708 result = exp.replace_placeholders( 4709 self.STRTOK_TEMPLATE.copy(), 4710 string=string_arg, 4711 delimiter=delimiter_arg, 4712 part_index=part_index_arg, 4713 base_func=base_func, 4714 ) 4715 4716 return self.sql(result) 4717 4718 return self.function_fallback_sql(expression) 4719 4720 def strtoktoarray_sql(self, expression: exp.StrtokToArray) -> str: 4721 string_arg = expression.this 4722 delimiter_arg = expression.args.get("expression") or exp.Literal.string(" ") 4723 4724 escaped = exp.RegexpReplace( 4725 this=delimiter_arg.copy(), 4726 expression=exp.Literal.string(r"([\[\]^.\-*+?(){}|$\\])"), 4727 replacement=exp.Literal.string(r"\\\1"), 4728 modifiers=exp.Literal.string("g"), 4729 ) 4730 return self.sql( 4731 exp.replace_placeholders( 4732 self.STRTOK_TO_ARRAY_TEMPLATE.copy(), 4733 string=string_arg, 4734 delimiter=delimiter_arg, 4735 escaped=escaped, 4736 ) 4737 ) 4738 4739 def approxquantile_sql(self, expression: exp.ApproxQuantile) -> str: 4740 result = self.func("APPROX_QUANTILE", expression.this, expression.args.get("quantile")) 4741 4742 # DuckDB returns integers for APPROX_QUANTILE, cast to DOUBLE if the expected type is a real type 4743 if expression.is_type(*exp.DataType.REAL_TYPES): 4744 result = f"CAST({result} AS DOUBLE)" 4745 4746 return result 4747 4748 def approxquantiles_sql(self, expression: exp.ApproxQuantiles) -> str: 4749 """ 4750 BigQuery's APPROX_QUANTILES(expr, n) returns an array of n+1 approximate quantile values 4751 dividing the input distribution into n equal-sized buckets. 4752 4753 Both BigQuery and DuckDB use approximate algorithms for quantile estimation, but BigQuery 4754 does not document the specific algorithm used so results may differ. DuckDB does not 4755 support RESPECT NULLS. 4756 """ 4757 this = expression.this 4758 if isinstance(this, exp.Distinct): 4759 # APPROX_QUANTILES requires 2 args and DISTINCT node grabs both 4760 if len(this.expressions) < 2: 4761 self.unsupported("APPROX_QUANTILES requires a bucket count argument") 4762 return self.function_fallback_sql(expression) 4763 num_quantiles_expr = this.expressions[1].pop() 4764 else: 4765 num_quantiles_expr = expression.expression 4766 4767 if not isinstance(num_quantiles_expr, exp.Literal) or not num_quantiles_expr.is_int: 4768 self.unsupported("APPROX_QUANTILES bucket count must be a positive integer") 4769 return self.function_fallback_sql(expression) 4770 4771 num_quantiles = t.cast(int, num_quantiles_expr.to_py()) 4772 if num_quantiles <= 0: 4773 self.unsupported("APPROX_QUANTILES bucket count must be a positive integer") 4774 return self.function_fallback_sql(expression) 4775 4776 quantiles = [ 4777 exp.Literal.number(Decimal(i) / Decimal(num_quantiles)) 4778 for i in range(num_quantiles + 1) 4779 ] 4780 4781 return self.sql(exp.ApproxQuantile(this=this, quantile=exp.Array(expressions=quantiles))) 4782 4783 def jsonextractscalar_sql(self, expression: exp.JSONExtractScalar) -> str: 4784 if expression.args.get("scalar_only"): 4785 json_value = exp.JSONExtractScalar( 4786 this=rename_func("JSON_VALUE")(self, expression), expression="'$'" 4787 ) 4788 4789 # `->>` binds looser than most operators, so the wrap logic needs the parent 4790 json_value.parent = expression.parent 4791 expression = json_value 4792 4793 return _arrow_json_extract_sql(self, expression) 4794 4795 def bitwisenot_sql(self, expression: exp.BitwiseNot) -> str: 4796 this = expression.this 4797 4798 if _is_binary(this): 4799 expression.type = exp.DType.BINARY.into_expr() 4800 4801 arg = _cast_to_bit(this) 4802 4803 if isinstance(this, exp.Neg): 4804 arg = exp.Paren(this=arg) 4805 4806 expression.set("this", arg) 4807 4808 result_sql = f"~{self.sql(expression, 'this')}" 4809 4810 return _gen_with_cast_to_blob(self, expression, result_sql) 4811 4812 def window_sql(self, expression: exp.Window) -> str: 4813 this = expression.this 4814 if isinstance(this, exp.Corr) or ( 4815 isinstance(this, exp.Filter) and isinstance(this.this, exp.Corr) 4816 ): 4817 return self._corr_sql(expression) 4818 4819 return super().window_sql(expression) 4820 4821 def filter_sql(self, expression: exp.Filter) -> str: 4822 if isinstance(expression.this, exp.Corr): 4823 return self._corr_sql(expression) 4824 4825 return super().filter_sql(expression) 4826 4827 def _corr_sql( 4828 self, 4829 expression: exp.Filter | exp.Window | exp.Corr, 4830 ) -> str: 4831 if isinstance(expression, exp.Corr) and not expression.args.get("null_on_zero_variance"): 4832 return self.func("CORR", expression.this, expression.expression) 4833 4834 corr_expr = _maybe_corr_null_to_false(expression) 4835 if corr_expr is None: 4836 if isinstance(expression, exp.Window): 4837 return super().window_sql(expression) 4838 if isinstance(expression, exp.Filter): 4839 return super().filter_sql(expression) 4840 corr_expr = expression # make mypy happy 4841 4842 return self.sql(exp.case().when(exp.IsNan(this=corr_expr), exp.null()).else_(corr_expr)) 4843 4844 def uuid_sql(self, expression: exp.Uuid) -> str: 4845 namespace = expression.this 4846 name = expression.args.get("name") 4847 4848 # UUID v5 (namespace + name) - Emulate using SHA1 4849 if namespace and name: 4850 result = exp.replace_placeholders( 4851 self.UUID_V5_TEMPLATE.copy(), 4852 namespace=namespace, 4853 name=name, 4854 ) 4855 return self.sql(result) 4856 4857 return super().uuid_sql(expression)
699def connect_by_to_recursive_cte(expression: exp.Expr) -> exp.Expr: 700 # Rewrites START WITH ... CONNECT BY PRIOR into WITH RECURSIVE 701 # Falls through unchanged if there are no PRIORs. 702 if not isinstance(expression, exp.Select) or not expression.args.get("connect"): 703 return expression 704 705 connect = expression.args["connect"] 706 connect_pred = connect.args["connect"] 707 708 priors = list(connect_pred.find_all(exp.Prior)) 709 if not priors: 710 return expression 711 712 from_ = expression.args.get("from_") 713 if not from_ or expression.args.get("joins"): 714 return expression 715 716 source_table = from_.this 717 base_select_exprs = expression.expressions 718 base_where = expression.args.get("where") 719 base_with = expression.args.get("with_") 720 721 # LEVEL is a Snowflake pseudo-column: it's always computed as a depth counter in the CTE. 722 has_level = any( 723 isinstance(col, exp.Column) and col.name.upper() == "LEVEL" 724 for e in base_select_exprs 725 for col in e.find_all(exp.Column) 726 ) 727 has_star = expression.is_star 728 729 # CONNECT_BY_ROOT col yields the value of `col` from the START WITH row that begins each 730 # branch. Each one is threaded through the CTE as an extra column: the anchor binds it to the 731 # row's own value, the recursive arm forwards the parent's value unchanged. 732 root_col_names: list[str] = [] 733 anchor_root_cols: list[exp.Expr] = [] 734 inner_root_cols: list[exp.Expr] = [] 735 roots = [root for e in base_select_exprs for root in e.find_all(exp.ConnectByRoot)] 736 737 for i, root in enumerate(roots): 738 name = f"_connect_by_root_{i}" 739 root_col_names.append(name) 740 anchor_root_cols.append(exp.alias_(root.this, name)) 741 inner_root_cols.append(exp.alias_(exp.column(name, "_parent_row"), name)) 742 root.replace(exp.column(name)) 743 744 # Build the join condition from the full CONNECT BY predicate: 745 # PRIOR(col) → _parent_row.col, unqualified cols → _child_row.col. 746 def _qualify_connect_pred(node: exp.Expression) -> exp.Expression: 747 for col in find_all_in_scope(node, exp.Column): 748 col.set( 749 "table", 750 exp.to_identifier( 751 "_parent_row" if isinstance(col.parent, exp.Prior) else "_child_row" 752 ), 753 ) 754 for prior in find_all_in_scope(node, exp.Prior): 755 prior.replace(prior.this) 756 return node 757 758 # Avoid colliding with any CTE names already on the query. 759 cte_name = find_new_name( 760 {cte.alias for cte in (base_with.expressions if base_with else [])}, "_rootcte" 761 ) 762 763 # Anchor: project all source columns + seed LEVEL at 1 + bind each root column to its own value. 764 anchor = exp.select( 765 exp.Star(), exp.alias_(exp.Literal.number(1), "level"), *anchor_root_cols 766 ).from_(source_table) 767 if connect.args.get("start"): 768 anchor = anchor.where(connect.args["start"]) 769 770 # Recursive arm: carry all child columns + increment level + forward each root value. 771 # SELECT * in both arms means WHERE/PRIOR columns are always available without explicit tracking. 772 inner_query = ( 773 exp.select( 774 exp.Column(this=exp.Star(), table=exp.to_identifier("_child_row")), 775 exp.alias_(exp.column("level", "_parent_row") + 1, "level"), 776 *inner_root_cols, 777 ) 778 .from_(source_table.as_("_child_row")) 779 .join(exp.to_table(cte_name).as_("_parent_row"), on=_qualify_connect_pred(connect_pred)) 780 ) 781 782 # Outer SELECT re-projects from the CTE. Synthetic level/root columns are excluded from any 783 # star expansion (level only when not referenced) but kept where explicitly projected. 784 if has_star: 785 except_cols = [] if has_level else [exp.column("level")] 786 except_cols.extend(exp.column(name) for name in root_col_names) 787 star = exp.Star(except_=except_cols) if except_cols else exp.Star() 788 outer_select_exprs: list[exp.Expr] = [ 789 star, 790 *(e for e in base_select_exprs if not e.is_star), 791 ] 792 else: 793 outer_select_exprs = base_select_exprs 794 outer_query = exp.select(*outer_select_exprs).from_(cte_name) 795 if base_where: 796 outer_query = outer_query.where(base_where.this) 797 798 # Attach the CTE, marking the WITH clause recursive. 799 if base_with: 800 outer_query.set("with_", base_with) 801 outer_query = outer_query.with_( 802 cte_name, as_=anchor.union(inner_query, distinct=False), recursive=True, copy=False 803 ) 804 805 for arg, val in expression.args.items(): 806 if val and arg not in _CONNECT_BY_ARGS_TO_SKIP: 807 outer_query.set(arg, val) 808 809 # Strip stale source table qualifiers in one pass; CTEs are child scopes so 810 # find_all_in_scope stays within the outer query only. 811 for col in find_all_in_scope(outer_query, exp.Column): 812 col.set("table", None) 813 814 return outer_query
1543class DuckDBGenerator(generator.Generator): 1544 PARAMETER_TOKEN = "$" 1545 NAMED_PLACEHOLDER_TOKEN = "$" 1546 JOIN_HINTS = False 1547 TABLE_HINTS = False 1548 QUERY_HINTS = False 1549 LIMIT_FETCH = "LIMIT" 1550 STRUCT_DELIMITER = ("(", ")") 1551 RENAME_TABLE_WITH_DB = False 1552 NVL2_SUPPORTED = False 1553 SEMI_ANTI_JOIN_WITH_SIDE = False 1554 CAST_TO_INT_ROUNDS = True 1555 SAFE_INT_DIVISION = True 1556 TABLESAMPLE_KEYWORDS = "USING SAMPLE" 1557 TABLESAMPLE_SEED_KEYWORD = "REPEATABLE" 1558 LAST_DAY_SUPPORTS_DATE_PART = False 1559 JSON_KEY_VALUE_PAIR_SEP = "," 1560 IGNORE_NULLS_IN_FUNC = True 1561 IGNORE_NULLS_BEFORE_ORDER = False 1562 JSON_PATH_BRACKETED_KEY_SUPPORTED = False 1563 SUPPORTS_CREATE_TABLE_LIKE = False 1564 MULTI_ARG_DISTINCT = False 1565 CAN_IMPLEMENT_ARRAY_ANY = True 1566 SUPPORTS_TO_NUMBER = False 1567 SELECT_KINDS: tuple[str, ...] = () 1568 SUPPORTS_DECODE_CASE = False 1569 SUPPORTS_DROP_ALTER_ICEBERG_PROPERTY = False 1570 1571 # USING SAMPLE goes after QUALIFY and before ORDER BY / LIMIT 1572 # https://duckdb.org/docs/preview/sql/query_syntax/sample 1573 AFTER_HAVING_MODIFIER_TRANSFORMS = { 1574 **generator.AFTER_HAVING_MODIFIER_TRANSFORMS, 1575 "sample": lambda self, e: self.sql(e, "sample"), 1576 } 1577 SUPPORTS_WINDOW_EXCLUDE = True 1578 COPY_HAS_INTO_KEYWORD = False 1579 STAR_EXCEPT = "EXCLUDE" 1580 PAD_FILL_PATTERN_IS_REQUIRED = True 1581 ARRAY_SIZE_DIM_REQUIRED: bool | None = False 1582 NORMALIZE_EXTRACT_DATE_PARTS = True 1583 SUPPORTS_LIKE_QUANTIFIERS = False 1584 HISTORICAL_DATA_POST_ALIAS = True 1585 SET_ASSIGNMENT_REQUIRES_VARIABLE_KEYWORD = True 1586 1587 TRANSFORMS = { 1588 **generator.Generator.TRANSFORMS, 1589 exp.AnyValue: _anyvalue_sql, 1590 exp.ApproxDistinct: approx_count_distinct_sql, 1591 exp.Boolnot: _boolnot_sql, 1592 exp.Booland: _booland_sql, 1593 exp.Boolor: _boolor_sql, 1594 exp.Array: transforms.preprocess( 1595 [transforms.inherit_struct_field_names], 1596 generator=inline_array_unless_query, 1597 ), 1598 exp.ArrayAppend: array_append_sql("LIST_APPEND"), 1599 exp.ArrayCompact: array_compact_sql, 1600 exp.ArrayConstructCompact: lambda self, e: self.sql( 1601 exp.ArrayCompact(this=exp.Array(expressions=e.expressions)) 1602 ), 1603 exp.ArrayConcat: array_concat_sql("LIST_CONCAT"), 1604 exp.ArrayContains: _array_contains_sql, 1605 exp.ArrayOverlaps: _array_overlaps_sql, 1606 exp.ArrayFilter: rename_func("LIST_FILTER"), 1607 exp.ArrayInsert: _array_insert_sql, 1608 exp.ArrayPosition: lambda self, e: ( 1609 self.sql( 1610 exp.Sub( 1611 this=exp.ArrayPosition(this=e.this, expression=e.expression), 1612 expression=exp.Literal.number(1), 1613 ) 1614 ) 1615 if e.args.get("zero_based") 1616 else self.func("ARRAY_POSITION", e.this, e.expression) 1617 ), 1618 exp.ArrayRemoveAt: _array_remove_at_sql, 1619 exp.ArrayRemove: remove_from_array_using_filter, 1620 exp.ArraySort: _array_sort_sql, 1621 exp.ArrayPrepend: array_append_sql("LIST_PREPEND", swap_params=True), 1622 exp.ArraySum: rename_func("LIST_SUM"), 1623 exp.ArrayMax: rename_func("LIST_MAX"), 1624 exp.ArrayMin: rename_func("LIST_MIN"), 1625 exp.Base64DecodeBinary: lambda self, e: _base64_decode_sql(self, e, to_string=False), 1626 exp.Base64DecodeString: lambda self, e: _base64_decode_sql(self, e, to_string=True), 1627 exp.BitwiseAnd: lambda self, e: self._bitwise_op(e, "&"), 1628 exp.BitwiseAndAgg: _bitwise_agg_sql, 1629 exp.BitwiseCount: rename_func("BIT_COUNT"), 1630 exp.BitwiseLeftShift: _bitshift_sql, 1631 exp.BitwiseOr: lambda self, e: self._bitwise_op(e, "|"), 1632 exp.BitwiseOrAgg: _bitwise_agg_sql, 1633 exp.BitwiseRightShift: _bitshift_sql, 1634 exp.BitwiseXorAgg: _bitwise_agg_sql, 1635 exp.CommentColumnConstraint: no_comment_column_constraint_sql, 1636 exp.Corr: lambda self, e: self._corr_sql(e), 1637 exp.CosineDistance: rename_func("LIST_COSINE_DISTANCE"), 1638 exp.CurrentTime: lambda *_: "CURRENT_TIME", 1639 exp.CurrentSchemas: lambda self, e: self.func( 1640 "current_schemas", e.this if e.this else exp.true() 1641 ), 1642 exp.CurrentTimestamp: lambda self, e: ( 1643 self.sql( 1644 exp.AtTimeZone(this=exp.var("CURRENT_TIMESTAMP"), zone=exp.Literal.string("UTC")) 1645 ) 1646 if e.args.get("sysdate") 1647 else "CURRENT_TIMESTAMP" 1648 ), 1649 exp.CurrentVersion: rename_func("version"), 1650 exp.Localtime: unsupported_args("this")(lambda *_: "LOCALTIME"), 1651 exp.DayOfMonth: rename_func("DAYOFMONTH"), 1652 exp.DayOfWeek: rename_func("DAYOFWEEK"), 1653 exp.DayOfWeekIso: rename_func("ISODOW"), 1654 exp.DayOfYear: rename_func("DAYOFYEAR"), 1655 exp.Dayname: lambda self, e: ( 1656 self.func("STRFTIME", e.this, exp.Literal.string("%a")) 1657 if e.args.get("abbreviated") 1658 else self.func("DAYNAME", e.this) 1659 ), 1660 exp.Monthname: lambda self, e: ( 1661 self.func("STRFTIME", e.this, exp.Literal.string("%b")) 1662 if e.args.get("abbreviated") 1663 else self.func("MONTHNAME", e.this) 1664 ), 1665 exp.Date: _date_sql, 1666 exp.DateAdd: _date_delta_to_binary_interval_op(), 1667 exp.DateFromParts: _date_from_parts_sql, 1668 exp.DateSub: _date_delta_to_binary_interval_op(), 1669 exp.DateDiff: _date_diff_sql, 1670 exp.DateStrToDate: datestrtodate_sql, 1671 exp.Datetime: no_datetime_sql, 1672 exp.DatetimeDiff: _date_diff_sql, 1673 exp.DatetimeSub: _date_delta_to_binary_interval_op(), 1674 exp.DatetimeAdd: _date_delta_to_binary_interval_op(), 1675 exp.DateToDi: lambda self, e: ( 1676 f"CAST(STRFTIME({self.sql(e, 'this')}, {self.dialect.DATEINT_FORMAT}) AS INT)" 1677 ), 1678 exp.Decode: lambda self, e: encode_decode_sql(self, e, "DECODE", replace=False), 1679 exp.HexDecodeString: lambda self, e: self.sql(exp.Decode(this=exp.Unhex(this=e.this))), 1680 exp.DiToDate: lambda self, e: ( 1681 f"CAST(STRPTIME(CAST({self.sql(e, 'this')} AS TEXT), {self.dialect.DATEINT_FORMAT}) AS DATE)" 1682 ), 1683 exp.Encode: lambda self, e: encode_decode_sql(self, e, "ENCODE", replace=False), 1684 exp.EqualNull: lambda self, e: self.sql( 1685 exp.NullSafeEQ(this=e.this, expression=e.expression) 1686 ), 1687 exp.EuclideanDistance: rename_func("LIST_DISTANCE"), 1688 exp.GenerateDateArray: _generate_datetime_array_sql, 1689 exp.GenerateSeries: generate_series_sql("GENERATE_SERIES", "RANGE"), 1690 exp.GenerateTimestampArray: _generate_datetime_array_sql, 1691 exp.Getbit: getbit_sql, 1692 exp.GroupConcat: lambda self, e: groupconcat_sql(self, e, within_group=False), 1693 exp.Explode: rename_func("UNNEST"), 1694 exp.IcebergProperty: lambda *_: "", 1695 exp.IntDiv: lambda self, e: self.binary(e, "//"), 1696 exp.IsInf: rename_func("ISINF"), 1697 exp.IsNan: rename_func("ISNAN"), 1698 exp.IsNullValue: lambda self, e: self.sql( 1699 exp.func("JSON_TYPE", e.this).eq(exp.Literal.string("NULL")) 1700 ), 1701 exp.IsArray: lambda self, e: self.sql( 1702 exp.func("JSON_TYPE", e.this).eq(exp.Literal.string("ARRAY")) 1703 ), 1704 exp.Ceil: _ceil_floor, 1705 exp.Floor: _ceil_floor, 1706 exp.JSONBExists: rename_func("JSON_EXISTS"), 1707 exp.JSONExtract: _arrow_json_extract_sql, 1708 exp.JSONExtractArray: _json_extract_value_array_sql, 1709 exp.JSONFormat: _json_format_sql, 1710 exp.JSONValueArray: _json_extract_value_array_sql, 1711 exp.Lateral: _explode_to_unnest_sql, 1712 exp.LogicalOr: lambda self, e: self.func("BOOL_OR", _cast_to_boolean(e.this)), 1713 exp.LogicalAnd: lambda self, e: self.func("BOOL_AND", _cast_to_boolean(e.this)), 1714 exp.Select: transforms.preprocess( 1715 [connect_by_to_recursive_cte, _seq_to_range_in_generator] 1716 ), 1717 exp.Seq1: lambda self, e: _seq_sql(self, e, 1), 1718 exp.Seq2: lambda self, e: _seq_sql(self, e, 2), 1719 exp.Seq4: lambda self, e: _seq_sql(self, e, 4), 1720 exp.Seq8: lambda self, e: _seq_sql(self, e, 8), 1721 exp.BoolxorAgg: _boolxor_agg_sql, 1722 exp.MakeInterval: lambda self, e: no_make_interval_sql(self, e, sep=" "), 1723 exp.Initcap: _initcap_sql, 1724 exp.MD5Digest: lambda self, e: self.func("UNHEX", self.func("MD5", e.this)), 1725 exp.SHA: lambda self, e: _sha_sql(self, e, "SHA1"), 1726 exp.SHA1Digest: lambda self, e: _sha_sql(self, e, "SHA1", is_binary=True), 1727 exp.SHA2: lambda self, e: _sha_sql(self, e, "SHA256"), 1728 exp.SHA2Digest: lambda self, e: _sha_sql(self, e, "SHA256", is_binary=True), 1729 exp.MonthsBetween: months_between_sql, 1730 exp.NextDay: _day_navigation_sql, 1731 exp.PercentileCont: rename_func("QUANTILE_CONT"), 1732 exp.PercentileDisc: rename_func("QUANTILE_DISC"), 1733 # DuckDB doesn't allow qualified columns inside of PIVOT expressions. 1734 # See: https://github.com/duckdb/duckdb/blob/671faf92411182f81dce42ac43de8bfb05d9909e/src/planner/binder/tableref/bind_pivot.cpp#L61-L62 1735 exp.Pivot: transforms.preprocess([transforms.unqualify_columns]), 1736 exp.PreviousDay: _day_navigation_sql, 1737 exp.RegexpILike: lambda self, e: self.func( 1738 "REGEXP_MATCHES", e.this, e.expression, exp.Literal.string("i") 1739 ), 1740 exp.RegexpSplit: rename_func("STR_SPLIT_REGEX"), 1741 exp.RegrValx: _regr_val_sql, 1742 exp.RegrValy: _regr_val_sql, 1743 exp.Return: lambda self, e: self.sql(e, "this"), 1744 exp.ReturnsProperty: lambda self, e: "TABLE" if isinstance(e.this, exp.Schema) else "", 1745 exp.StrToUnix: lambda self, e: self.func( 1746 "EPOCH", self.func("STRPTIME", e.this, self.format_time(e)) 1747 ), 1748 exp.Struct: _struct_sql, 1749 exp.Transform: rename_func("LIST_TRANSFORM"), 1750 exp.TimeAdd: _date_delta_to_binary_interval_op(), 1751 exp.TimeSub: _date_delta_to_binary_interval_op(), 1752 exp.Time: no_time_sql, 1753 exp.TimeDiff: _timediff_sql, 1754 exp.Timestamp: no_timestamp_sql, 1755 exp.TimestampAdd: _date_delta_to_binary_interval_op(), 1756 exp.TimestampDiff: lambda self, e: self.func( 1757 "DATE_DIFF", exp.Literal.string(e.unit), e.expression, e.this 1758 ), 1759 exp.TimestampSub: _date_delta_to_binary_interval_op(), 1760 exp.TimeStrToDate: lambda self, e: self.sql(exp.cast(e.this, exp.DType.DATE)), 1761 exp.TimeStrToTime: timestrtotime_sql, 1762 exp.TimeStrToUnix: lambda self, e: self.func( 1763 "EPOCH", exp.cast(e.this, exp.DType.TIMESTAMP) 1764 ), 1765 exp.TimeToStr: lambda self, e: self.func("STRFTIME", e.this, self.format_time(e)), 1766 exp.ToBoolean: _to_boolean_sql, 1767 exp.ToVariant: lambda self, e: self.sql( 1768 exp.cast(e.this, exp.DataType.from_str("VARIANT", dialect="duckdb")) 1769 ), 1770 exp.TimeToUnix: rename_func("EPOCH"), 1771 exp.TsOrDiToDi: lambda self, e: ( 1772 f"CAST(SUBSTR(REPLACE(CAST({self.sql(e, 'this')} AS TEXT), '-', ''), 1, 8) AS INT)" 1773 ), 1774 exp.TsOrDsAdd: _date_delta_to_binary_interval_op(), 1775 exp.TsOrDsDiff: lambda self, e: self.func( 1776 "DATE_DIFF", 1777 f"'{e.args.get('unit') or 'DAY'}'", 1778 exp.cast(e.expression, exp.DType.TIMESTAMP), 1779 exp.cast(e.this, exp.DType.TIMESTAMP), 1780 ), 1781 exp.UnixMicros: lambda self, e: self.func("EPOCH_US", _implicit_datetime_cast(e.this)), 1782 exp.UnixMillis: lambda self, e: self.func("EPOCH_MS", _implicit_datetime_cast(e.this)), 1783 exp.UnixSeconds: lambda self, e: self.sql( 1784 exp.cast(self.func("EPOCH", _implicit_datetime_cast(e.this)), exp.DType.BIGINT) 1785 ), 1786 exp.UnixToStr: lambda self, e: self.func( 1787 "STRFTIME", self.func("TO_TIMESTAMP", e.this), self.format_time(e) 1788 ), 1789 exp.UnixToTime: _unix_to_time_sql, 1790 exp.UnixToTimeStr: lambda self, e: f"CAST(TO_TIMESTAMP({self.sql(e, 'this')}) AS TEXT)", 1791 exp.VariancePop: rename_func("VAR_POP"), 1792 exp.WeekOfYear: rename_func("WEEKOFYEAR"), 1793 exp.YearOfWeek: lambda self, e: self.sql( 1794 exp.Extract( 1795 this=exp.Var(this="ISOYEAR"), 1796 expression=e.this, 1797 ) 1798 ), 1799 exp.YearOfWeekIso: lambda self, e: self.sql( 1800 exp.Extract( 1801 this=exp.Var(this="ISOYEAR"), 1802 expression=e.this, 1803 ) 1804 ), 1805 exp.Xor: _xor_sql, 1806 exp.JSONObjectAgg: rename_func("JSON_GROUP_OBJECT"), 1807 exp.JSONBObjectAgg: rename_func("JSON_GROUP_OBJECT"), 1808 exp.DateBin: rename_func("TIME_BUCKET"), 1809 exp.LastDay: _last_day_sql, 1810 } 1811 1812 SUPPORTED_JSON_PATH_PARTS = { 1813 exp.JSONPathKey, 1814 exp.JSONPathRoot, 1815 exp.JSONPathSubscript, 1816 exp.JSONPathWildcard, 1817 } 1818 1819 TYPE_MAPPING = { 1820 **generator.Generator.TYPE_MAPPING, 1821 exp.DType.BINARY: "BLOB", 1822 exp.DType.BPCHAR: "TEXT", 1823 exp.DType.CHAR: "TEXT", 1824 exp.DType.DATETIME: "TIMESTAMP", 1825 exp.DType.DECFLOAT: "DECIMAL", 1826 exp.DType.FLOAT: "REAL", 1827 exp.DType.JSONB: "JSON", 1828 exp.DType.NCHAR: "TEXT", 1829 exp.DType.NVARCHAR: "TEXT", 1830 exp.DType.UINT: "UINTEGER", 1831 exp.DType.VARBINARY: "BLOB", 1832 exp.DType.ROWVERSION: "BLOB", 1833 exp.DType.VARCHAR: "TEXT", 1834 exp.DType.TIMESTAMPLTZ: "TIMESTAMPTZ", 1835 exp.DType.TIMESTAMPNTZ: "TIMESTAMP", 1836 exp.DType.TIMESTAMP_S: "TIMESTAMP_S", 1837 exp.DType.TIMESTAMP_MS: "TIMESTAMP_MS", 1838 exp.DType.TIMESTAMP_NS: "TIMESTAMP_NS", 1839 exp.DType.BIGDECIMAL: "DECIMAL", 1840 } 1841 1842 TYPE_PARAM_SETTINGS = { 1843 **generator.Generator.TYPE_PARAM_SETTINGS, 1844 exp.DType.BIGDECIMAL: ((38, 5), (38, 38)), 1845 exp.DType.DECFLOAT: ((38, 5), (38, 38)), 1846 } 1847 1848 # https://github.com/duckdb/duckdb/blob/ff7f24fd8e3128d94371827523dae85ebaf58713/third_party/libpg_query/grammar/keywords/reserved_keywords.list#L1-L77 1849 RESERVED_KEYWORDS = { 1850 "array", 1851 "analyse", 1852 "union", 1853 "all", 1854 "when", 1855 "in_p", 1856 "default", 1857 "create_p", 1858 "window", 1859 "asymmetric", 1860 "to", 1861 "else", 1862 "localtime", 1863 "from", 1864 "end_p", 1865 "select", 1866 "current_date", 1867 "foreign", 1868 "with", 1869 "grant", 1870 "session_user", 1871 "or", 1872 "except", 1873 "references", 1874 "fetch", 1875 "limit", 1876 "group_p", 1877 "leading", 1878 "into", 1879 "collate", 1880 "offset", 1881 "do", 1882 "then", 1883 "localtimestamp", 1884 "check_p", 1885 "lateral_p", 1886 "current_role", 1887 "where", 1888 "asc_p", 1889 "placing", 1890 "desc_p", 1891 "user", 1892 "unique", 1893 "initially", 1894 "column", 1895 "both", 1896 "some", 1897 "as", 1898 "any", 1899 "only", 1900 "deferrable", 1901 "null_p", 1902 "current_time", 1903 "true_p", 1904 "table", 1905 "case", 1906 "trailing", 1907 "variadic", 1908 "for", 1909 "on", 1910 "distinct", 1911 "false_p", 1912 "not", 1913 "constraint", 1914 "current_timestamp", 1915 "returning", 1916 "primary", 1917 "intersect", 1918 "having", 1919 "analyze", 1920 "current_user", 1921 "and", 1922 "cast", 1923 "symmetric", 1924 "using", 1925 "order", 1926 "current_catalog", 1927 } 1928 1929 UNWRAPPED_INTERVAL_VALUES = (exp.Literal, exp.Paren) 1930 1931 # DuckDB doesn't generally support CREATE TABLE .. properties 1932 # https://duckdb.org/docs/sql/statements/create_table.html 1933 # There are a few exceptions (e.g. temporary tables) which are supported or 1934 # can be transpiled to DuckDB, so we explicitly override them accordingly 1935 PROPERTIES_LOCATION = { 1936 **{ 1937 prop: exp.Properties.Location.UNSUPPORTED 1938 for prop in generator.Generator.PROPERTIES_LOCATION 1939 }, 1940 exp.LikeProperty: exp.Properties.Location.POST_SCHEMA, 1941 exp.TemporaryProperty: exp.Properties.Location.POST_CREATE, 1942 exp.ReturnsProperty: exp.Properties.Location.POST_ALIAS, 1943 exp.SequenceProperties: exp.Properties.Location.POST_EXPRESSION, 1944 exp.IcebergProperty: exp.Properties.Location.POST_CREATE, 1945 } 1946 1947 IGNORE_RESPECT_NULLS_WINDOW_FUNCTIONS: t.ClassVar = _IGNORE_RESPECT_NULLS_WINDOW_FUNCTIONS 1948 1949 # Template for ZIPF transpilation - placeholders get replaced with actual parameters 1950 ZIPF_TEMPLATE: t.ClassVar[exp.Expr] = exp.maybe_parse( 1951 """ 1952 WITH rand AS (SELECT :random_expr AS r), 1953 weights AS ( 1954 SELECT i, 1.0 / POWER(i, :s) AS w 1955 FROM RANGE(1, :n + 1) AS t(i) 1956 ), 1957 cdf AS ( 1958 SELECT i, SUM(w) OVER (ORDER BY i) / SUM(w) OVER () AS p 1959 FROM weights 1960 ) 1961 SELECT MIN(i) 1962 FROM cdf 1963 WHERE p >= (SELECT r FROM rand) 1964 """ 1965 ) 1966 1967 # Template for NORMAL transpilation using Box-Muller transform 1968 # mean + (stddev * sqrt(-2 * ln(u1)) * cos(2 * pi * u2)) 1969 NORMAL_TEMPLATE: t.ClassVar[exp.Expr] = exp.maybe_parse( 1970 ":mean + (:stddev * SQRT(-2 * LN(GREATEST(:u1, 1e-10))) * COS(2 * PI() * :u2))" 1971 ) 1972 1973 # Template for generating a seeded pseudo-random value in [0, 1) from a hash 1974 SEEDED_RANDOM_TEMPLATE: t.ClassVar[exp.Expr] = exp.maybe_parse( 1975 "(ABS(HASH(:seed)) % 1000000) / 1000000.0" 1976 ) 1977 1978 # Template for generating signed and unsigned SEQ values within a specified range 1979 SEQ_UNSIGNED: t.ClassVar[exp.Expr] = _SEQ_UNSIGNED 1980 SEQ_SIGNED: t.ClassVar[exp.Expr] = _SEQ_SIGNED 1981 1982 # Template for MAP_CAT transpilation - Snowflake semantics: 1983 # 1. Returns NULL if either input is NULL 1984 # 2. For duplicate keys, prefers non-NULL value (COALESCE(m2[k], m1[k])) 1985 # 3. Filters out entries with NULL values from the result 1986 MAPCAT_TEMPLATE: t.ClassVar[exp.Expr] = exp.maybe_parse( 1987 """ 1988 CASE 1989 WHEN :map1 IS NULL OR :map2 IS NULL THEN NULL 1990 ELSE MAP_FROM_ENTRIES(LIST_FILTER(LIST_TRANSFORM( 1991 LIST_DISTINCT(LIST_CONCAT(MAP_KEYS(:map1), MAP_KEYS(:map2))), 1992 __k -> STRUCT_PACK(key := __k, value := COALESCE(:map2[__k], :map1[__k])) 1993 ), __x -> __x.value IS NOT NULL)) 1994 END 1995 """ 1996 ) 1997 1998 # Mappings for EXTRACT/DATE_PART transpilation 1999 # Maps Snowflake specifiers unsupported in DuckDB to strftime format codes 2000 EXTRACT_STRFTIME_MAPPINGS: t.ClassVar[dict[str, tuple[str, str]]] = { 2001 "WEEKISO": ("%V", "INTEGER"), 2002 "YEAROFWEEK": ("%G", "INTEGER"), 2003 "YEAROFWEEKISO": ("%G", "INTEGER"), 2004 "NANOSECOND": ("%n", "BIGINT"), 2005 } 2006 2007 # Maps epoch-based specifiers to DuckDB epoch functions 2008 EXTRACT_EPOCH_MAPPINGS: t.ClassVar[dict[str, str]] = { 2009 "EPOCH_SECOND": "EPOCH", 2010 "EPOCH_MILLISECOND": "EPOCH_MS", 2011 "EPOCH_MICROSECOND": "EPOCH_US", 2012 "EPOCH_NANOSECOND": "EPOCH_NS", 2013 } 2014 2015 # Template for BITMAP_CONSTRUCT_AGG transpilation 2016 # 2017 # BACKGROUND: 2018 # Snowflake's BITMAP_CONSTRUCT_AGG aggregates integers into a compact binary bitmap. 2019 # Supports values in range 0-32767, this version returns NULL if any value is out of range 2020 # See: https://docs.snowflake.com/en/sql-reference/functions/bitmap_construct_agg 2021 # See: https://docs.snowflake.com/en/user-guide/querying-bitmaps-for-distinct-counts 2022 # 2023 # Snowflake uses two different formats based on the number of unique values: 2024 # 2025 # Format 1 - Small bitmap (< 5 unique values): Length of 10 bytes 2026 # Bytes 0-1: Count of values as 2-byte big-endian integer (e.g., 3 values = 0x0003) 2027 # Bytes 2-9: Up to 4 values, each as 2-byte little-endian integers, zero-padded to 8 bytes 2028 # Example: Values [1, 2, 3] -> 0x0003 0100 0200 0300 0000 (hex) 2029 # count v1 v2 v3 pad 2030 # 2031 # Format 2 - Large bitmap (>= 5 unique values): Length of 10 + (2 * count) bytes 2032 # Bytes 0-9: Fixed header 0x08 followed by 9 zero bytes 2033 # Bytes 10+: Each value as 2-byte little-endian integer (no padding) 2034 # Example: Values [1,2,3,4,5] -> 0x08 00000000 00000000 00 0100 0200 0300 0400 0500 2035 # hdr ----9 zero bytes---- v1 v2 v3 v4 v5 2036 # 2037 # TEMPLATE STRUCTURE 2038 # 2039 # Phase 1 - Innermost subquery: Data preparation 2040 # SELECT LIST_SORT(...) AS l 2041 # - Aggregates all input values into a list, remove NULLs, duplicates and sorts 2042 # Result: Clean, sorted list of unique non-null integers stored as 'l' 2043 # 2044 # Phase 2 - Middle subquery: Hex string construction 2045 # LIST_TRANSFORM(...) 2046 # - Converts each integer to 2-byte little-endian hex representation 2047 # - & 255 extracts low byte, >> 8 extracts high byte 2048 # - LIST_REDUCE: Concatenates all hex pairs into single string 'h' 2049 # Result: Hex string of all values 2050 # 2051 # Phase 3 - Outer SELECT: Final bitmap assembly 2052 # LENGTH(l) < 5: 2053 # - Small format: 2-byte count (big-endian via %04X) + values + zero padding 2054 # LENGTH(l) >= 5: 2055 # - Large format: Fixed 10-byte header + values (no padding needed) 2056 # Result: Complete binary bitmap as BLOB 2057 # 2058 BITMAP_CONSTRUCT_AGG_TEMPLATE: t.ClassVar[exp.Expr] = exp.maybe_parse( 2059 """ 2060 SELECT CASE 2061 WHEN l IS NULL OR LENGTH(l) = 0 THEN NULL 2062 WHEN LENGTH(l) != LENGTH(LIST_FILTER(l, __v -> __v BETWEEN 0 AND 32767)) THEN NULL 2063 WHEN LENGTH(l) < 5 THEN UNHEX(PRINTF('%04X', LENGTH(l)) || h || REPEAT('00', GREATEST(0, 4 - LENGTH(l)) * 2)) 2064 ELSE UNHEX('08000000000000000000' || h) 2065 END 2066 FROM ( 2067 SELECT l, COALESCE(LIST_REDUCE( 2068 LIST_TRANSFORM(l, __x -> PRINTF('%02X%02X', CAST(__x AS INT) & 255, (CAST(__x AS INT) >> 8) & 255)), 2069 (__a, __b) -> __a || __b, '' 2070 ), '') AS h 2071 FROM (SELECT LIST_SORT(LIST_DISTINCT(LIST(:arg) FILTER(NOT :arg IS NULL))) AS l) 2072 ) 2073 """ 2074 ) 2075 2076 # Template for RANDSTR transpilation - placeholders get replaced with actual parameters 2077 RANDSTR_TEMPLATE: t.ClassVar[exp.Expr] = exp.maybe_parse( 2078 f""" 2079 SELECT LISTAGG( 2080 SUBSTRING( 2081 '{RANDSTR_CHAR_POOL}', 2082 1 + CAST(FLOOR(random_value * 62) AS INT), 2083 1 2084 ), 2085 '' 2086 ) 2087 FROM ( 2088 SELECT (ABS(HASH(i + :seed)) % 1000) / 1000.0 AS random_value 2089 FROM RANGE(:length) AS t(i) 2090 ) 2091 """, 2092 ) 2093 2094 # Template for MINHASH transpilation 2095 # Computes k minimum hash values across aggregated data using DuckDB list functions 2096 # Returns JSON matching Snowflake format: {"state": [...], "type": "minhash", "version": 1} 2097 MINHASH_TEMPLATE: t.ClassVar[exp.Expr] = exp.maybe_parse( 2098 """ 2099 SELECT JSON_OBJECT('state', LIST(min_h ORDER BY seed), 'type', 'minhash', 'version', 1) 2100 FROM ( 2101 SELECT seed, LIST_MIN(LIST_TRANSFORM(vals, __v -> HASH(CAST(__v AS VARCHAR) || CAST(seed AS VARCHAR)))) AS min_h 2102 FROM (SELECT LIST(:expr) AS vals), RANGE(0, :k) AS t(seed) 2103 ) 2104 """, 2105 ) 2106 2107 # Template for MINHASH_COMBINE transpilation 2108 # Combines multiple minhash signatures by taking element-wise minimum 2109 MINHASH_COMBINE_TEMPLATE: t.ClassVar[exp.Expr] = exp.maybe_parse( 2110 """ 2111 SELECT JSON_OBJECT('state', LIST(min_h ORDER BY idx), 'type', 'minhash', 'version', 1) 2112 FROM ( 2113 SELECT 2114 pos AS idx, 2115 MIN(val) AS min_h 2116 FROM 2117 UNNEST(LIST(:expr)) AS _(sig), 2118 UNNEST(CAST(sig -> 'state' AS UBIGINT[])) WITH ORDINALITY AS t(val, pos) 2119 GROUP BY pos 2120 ) 2121 """, 2122 ) 2123 2124 # Template for APPROXIMATE_SIMILARITY transpilation 2125 # Computes multi-way Jaccard similarity: fraction of positions where ALL signatures agree 2126 APPROXIMATE_SIMILARITY_TEMPLATE: t.ClassVar[exp.Expr] = exp.maybe_parse( 2127 """ 2128 SELECT CAST(SUM(CASE WHEN num_distinct = 1 THEN 1 ELSE 0 END) AS DOUBLE) / COUNT(*) 2129 FROM ( 2130 SELECT pos, COUNT(DISTINCT h) AS num_distinct 2131 FROM ( 2132 SELECT h, pos 2133 FROM UNNEST(LIST(:expr)) AS _(sig), 2134 UNNEST(CAST(sig -> 'state' AS UBIGINT[])) WITH ORDINALITY AS s(h, pos) 2135 ) 2136 GROUP BY pos 2137 ) 2138 """, 2139 ) 2140 2141 # Template for ARRAYS_ZIP transpilation 2142 # Snowflake pads to longest array; DuckDB LIST_ZIP truncates to shortest 2143 # Uses RANGE + indexing to match Snowflake behavior 2144 ARRAYS_ZIP_TEMPLATE: t.ClassVar[exp.Expr] = exp.maybe_parse( 2145 """ 2146 CASE WHEN :null_check THEN NULL 2147 WHEN :all_empty_check THEN [:empty_struct] 2148 ELSE LIST_TRANSFORM(RANGE(0, :max_len), __i -> :transform_struct) 2149 END 2150 """, 2151 ) 2152 2153 UUID_V5_TEMPLATE: t.ClassVar[exp.Expr] = exp.maybe_parse( 2154 """ 2155 (SELECT 2156 LOWER( 2157 SUBSTR(h, 1, 8) || '-' || 2158 SUBSTR(h, 9, 4) || '-' || 2159 '5' || SUBSTR(h, 14, 3) || '-' || 2160 FORMAT('{:02x}', CAST('0x' || SUBSTR(h, 17, 2) AS INT) & 63 | 128) || SUBSTR(h, 19, 2) || '-' || 2161 SUBSTR(h, 21, 12) 2162 ) 2163 FROM ( 2164 SELECT SUBSTR(SHA1(UNHEX(REPLACE(:namespace, '-', '')) || ENCODE(:name, 'utf8')), 1, 32) AS h 2165 )) 2166 """ 2167 ) 2168 2169 # Shared bag semantics outer frame for ARRAY_EXCEPT and ARRAY_INTERSECTION. 2170 # Each element is paired with its 1-based position via LIST_ZIP, then filtered 2171 # by a comparison operator (supplied via :cond) that determines the operation: 2172 # EXCEPT (>): keep the N-th occurrence only if N > count in arr2 2173 # e.g. [2,2,2] EXCEPT [2,2] -> [2] 2174 # INTERSECTION (<=): keep the N-th occurrence only if N <= count in arr2 2175 # e.g. [2,2,2] INTERSECT [2,2] -> [2,2] 2176 # IS NOT DISTINCT FROM is used for NULL-safe element comparison. 2177 ARRAY_BAG_TEMPLATE: t.ClassVar[exp.Expr] = exp.maybe_parse( 2178 """ 2179 CASE 2180 WHEN :arr1 IS NULL OR :arr2 IS NULL THEN NULL 2181 ELSE LIST_TRANSFORM( 2182 LIST_FILTER( 2183 LIST_ZIP(:arr1, GENERATE_SERIES(1, LEN(:arr1))), 2184 pair -> :cond 2185 ), 2186 pair -> pair[0] 2187 ) 2188 END 2189 """ 2190 ) 2191 2192 ARRAY_EXCEPT_CONDITION: t.ClassVar[exp.Expr] = exp.maybe_parse( 2193 "LEN(LIST_FILTER(:arr1[1:pair[1]], e -> e IS NOT DISTINCT FROM pair[0]))" 2194 " > LEN(LIST_FILTER(:arr2, e -> e IS NOT DISTINCT FROM pair[0]))" 2195 ) 2196 2197 ARRAY_INTERSECTION_CONDITION: t.ClassVar[exp.Expr] = exp.maybe_parse( 2198 "LEN(LIST_FILTER(:arr1[1:pair[1]], e -> e IS NOT DISTINCT FROM pair[0]))" 2199 " <= LEN(LIST_FILTER(:arr2, e -> e IS NOT DISTINCT FROM pair[0]))" 2200 ) 2201 2202 # Set semantics for ARRAY_EXCEPT. Deduplicates arr1 via LIST_DISTINCT, then 2203 # filters out any element that appears at least once in arr2. 2204 # e.g. [1,1,2,3] EXCEPT [1] -> [2,3] 2205 # IS NOT DISTINCT FROM is used for NULL-safe element comparison. 2206 ARRAY_EXCEPT_SET_TEMPLATE: t.ClassVar[exp.Expr] = exp.maybe_parse( 2207 """ 2208 CASE 2209 WHEN :arr1 IS NULL OR :arr2 IS NULL THEN NULL 2210 ELSE LIST_FILTER( 2211 LIST_DISTINCT(:arr1), 2212 e -> LEN(LIST_FILTER(:arr2, x -> x IS NOT DISTINCT FROM e)) = 0 2213 ) 2214 END 2215 """ 2216 ) 2217 2218 # BigQuery's `x IN UNNEST(arr)` NULL semantics: 2219 # NULL IN UNNEST([1, 2]) -> NULL 2220 # 3 IN UNNEST([1, NULL]) -> NULL 2221 # 3 IN UNNEST([1, 2]) -> FALSE 2222 # 1 IN UNNEST(NULL) -> FALSE (not NULL) 2223 # 1 IN UNNEST([]) -> FALSE 2224 # The default `IN (SELECT UNNEST(...))` rewrite creates a correlated subquery 2225 # that DuckDB rejects inside non-inner joins, so a CASE expression is used instead. 2226 IN_UNNEST_TEMPLATE: t.ClassVar[exp.Expr] = exp.maybe_parse( 2227 """ 2228 CASE 2229 WHEN :arr IS NULL OR ARRAY_LENGTH(:arr) = 0 THEN FALSE 2230 WHEN ARRAY_CONTAINS(:arr, :value) THEN TRUE 2231 WHEN :value IS NULL OR ARRAY_LENGTH(:arr) <> LIST_COUNT(:arr) THEN NULL 2232 ELSE FALSE 2233 END 2234 """ 2235 ) 2236 2237 STRTOK_TO_ARRAY_TEMPLATE: t.ClassVar[exp.Expr] = exp.maybe_parse( 2238 """ 2239 CASE WHEN :delimiter IS NULL THEN NULL 2240 ELSE LIST_FILTER( 2241 REGEXP_SPLIT_TO_ARRAY(:string, CASE WHEN :delimiter = '' THEN '.^' ELSE CONCAT('[', :escaped, ']') END), 2242 x -> NOT x = '' 2243 ) END 2244 """ 2245 ) 2246 2247 # Template for STRTOK function transpilation 2248 # 2249 # DuckDB itself doesn't have a strtok function. This handles the transpilation from Snowflake to DuckDB. 2250 # We may need to adjust this if we want to support transpilation from other dialects 2251 # 2252 # CASE 2253 # -- Snowflake: empty delimiter + empty input string -> NULL 2254 # WHEN delimiter = '' AND input_str = '' THEN NULL 2255 # 2256 # -- Snowflake: empty delimiter + non-empty input string -> treats whole input as 1 token -> return input string if index is 1 2257 # WHEN delimiter = '' AND index = 1 THEN input_str 2258 # 2259 # -- Snowflake: empty delimiter + non-empty input string -> treats whole input as 1 token -> return NULL if index is not 1 2260 # WHEN delimiter = '' THEN NULL 2261 # 2262 # -- Snowflake: negative indices return NULL 2263 # WHEN index < 0 THEN NULL 2264 # 2265 # -- Snowflake: return NULL if any argument is NULL 2266 # WHEN input_str IS NULL OR delimiter IS NULL OR index IS NULL THEN NULL 2267 # 2268 # 2269 # ELSE LIST_FILTER( 2270 # REGEXP_SPLIT_TO_ARRAY( 2271 # input_str, 2272 # CASE 2273 # -- if delimiter is '', we don't want to surround it with '[' and ']' as '[]' is invalid for DuckDB 2274 # WHEN delimiter = '' THEN '' 2275 # 2276 # -- handle problematic regex characters in delimiter with REGEXP_REPLACE 2277 # -- turn delimiter into a regex char set, otherwise DuckDB will match in order, which we don't want 2278 # ELSE '[' || REGEXP_REPLACE(delimiter, problematic_char_set, '\\\1', 'g') || ']' 2279 # END 2280 # ), 2281 # 2282 # -- Snowflake: don't return empty strings 2283 # x -> NOT x = '' 2284 # )[index] 2285 # END 2286 STRTOK_TEMPLATE: t.ClassVar[exp.Expr] = exp.maybe_parse( 2287 """ 2288 CASE 2289 WHEN :delimiter = '' AND :string = '' THEN NULL 2290 WHEN :delimiter = '' AND :part_index = 1 THEN :string 2291 WHEN :delimiter = '' THEN NULL 2292 WHEN :part_index < 0 THEN NULL 2293 WHEN :string IS NULL OR :delimiter IS NULL OR :part_index IS NULL THEN NULL 2294 ELSE :base_func 2295 END 2296 """ 2297 ) 2298 2299 # Snowflake AUTO detects 3 DATE formats: YYYY-MM-DD (ISO-8601), MM/DD/YYYY, DD-MON-YYYY. 2300 # DuckDB TRY_CAST handles ISO-8601 natively. For the other two formats we use CONTAINS('/') 2301 # and REGEXP_MATCHES('[A-Za-z]') as heuristics — these correctly handle single-digit months 2302 # and days (e.g. 1/5/2020, 5-JAN-2020) where a positional char check would fail. 2303 # Ref: https://docs.snowflake.com/en/sql-reference/date-time-input-output#date-formats 2304 _TRYCAST_DATE_SLASH_FMT = "%m/%d/%Y" 2305 _TRYCAST_DATE_MON_FMT = "%d-%b-%Y" 2306 2307 def datatype_sql(self, expression: exp.DataType) -> str: 2308 if expression.is_type(exp.DType.ARRAY): 2309 return f"{self.expressions(expression, flat=True)}[{self.expressions(expression, key='values', flat=True)}]" 2310 2311 # Modifiers are not supported for TIME, [TIME | TIMESTAMP] WITH TIME ZONE 2312 if expression.is_type( 2313 exp.DType.TIME, exp.DType.TIMETZ, exp.DType.TIMESTAMPTZ, exp.DType.TIMESTAMPLTZ 2314 ): 2315 expression.set("expressions", None) 2316 2317 return super().datatype_sql(expression) 2318 2319 def _array_bag_sql(self, condition: exp.Expr, arr1: exp.Expr, arr2: exp.Expr) -> str: 2320 cond = exp.Paren(this=exp.replace_placeholders(condition, arr1=arr1, arr2=arr2)) 2321 return self.sql( 2322 exp.replace_placeholders(self.ARRAY_BAG_TEMPLATE, arr1=arr1, arr2=arr2, cond=cond) 2323 ) 2324 2325 def timeslice_sql(self, expression: exp.TimeSlice) -> str: 2326 """ 2327 Transform Snowflake's TIME_SLICE to DuckDB's time_bucket. 2328 2329 Snowflake: TIME_SLICE(date_expr, slice_length, 'UNIT' [, 'START'|'END']) 2330 DuckDB: time_bucket(INTERVAL 'slice_length' UNIT, date_expr) 2331 2332 For 'END' kind, add the interval to get the end of the slice. 2333 For DATE type with 'END', cast result back to DATE to preserve type. 2334 """ 2335 date_expr = expression.this 2336 slice_length = expression.expression 2337 unit = expression.unit 2338 kind = expression.text("kind").upper() 2339 2340 # Create INTERVAL expression: INTERVAL 'N' UNIT 2341 interval_expr = exp.Interval(this=slice_length, unit=unit) 2342 2343 # Create base time_bucket expression 2344 time_bucket_expr = exp.func("time_bucket", interval_expr, date_expr) 2345 2346 # Check if we need the end of the slice (default is start) 2347 if not kind == "END": 2348 # For 'START', return time_bucket directly 2349 return self.sql(time_bucket_expr) 2350 2351 # For 'END', add the interval to get end of slice 2352 add_expr = exp.Add(this=time_bucket_expr, expression=interval_expr.copy()) 2353 2354 # If input is DATE type, cast result back to DATE to preserve type 2355 # DuckDB converts DATE to TIMESTAMP when adding intervals 2356 if date_expr.is_type(exp.DType.DATE): 2357 return self.sql(exp.cast(add_expr, exp.DType.DATE)) 2358 2359 return self.sql(add_expr) 2360 2361 def bytestring_sql(self, expression: exp.ByteString) -> str: 2362 if not expression.args.get("is_bytes"): 2363 return super().bytestring_sql(expression) 2364 2365 # Each char is a byte. Casting to BLOB decodes \xHH, so everything but printable ASCII 2366 # (including the backslash itself) is written as such 2367 text = "".join( 2368 ch if ch.isascii() and ch.isprintable() and ch != "\\" else f"\\x{ord(ch):02x}" 2369 for ch in expression.this 2370 ) 2371 return self.sql(exp.cast(exp.Literal.string(text), exp.DType.BINARY)) 2372 2373 def bitmapbucketnumber_sql(self, expression: exp.BitmapBucketNumber) -> str: 2374 """ 2375 Transpile BITMAP_BUCKET_NUMBER function from Snowflake to DuckDB equivalent. 2376 2377 Snowflake's BITMAP_BUCKET_NUMBER returns a 1-based bucket identifier where: 2378 - Each bucket covers 32,768 values 2379 - Bucket numbering starts at 1 2380 - Formula: ((value - 1) // 32768) + 1 for positive values 2381 2382 For non-positive values (0 and negative), we use value // 32768 to avoid 2383 producing bucket 0 or positive bucket IDs for negative inputs. 2384 """ 2385 value = expression.this 2386 2387 positive_formula = ((value - 1) // 32768) + 1 2388 non_positive_formula = value // 32768 2389 2390 # CASE WHEN value > 0 THEN ((value - 1) // 32768) + 1 ELSE value // 32768 END 2391 case_expr = ( 2392 exp.case() 2393 .when(exp.GT(this=value, expression=exp.Literal.number(0)), positive_formula) 2394 .else_(non_positive_formula) 2395 ) 2396 return self.sql(case_expr) 2397 2398 def bitmapbitposition_sql(self, expression: exp.BitmapBitPosition) -> str: 2399 """ 2400 Transpile Snowflake's BITMAP_BIT_POSITION to DuckDB CASE expression. 2401 2402 Snowflake's BITMAP_BIT_POSITION behavior: 2403 - For n <= 0: returns ABS(n) % 32768 2404 - For n > 0: returns (n - 1) % 32768 (maximum return value is 32767) 2405 """ 2406 this = expression.this 2407 2408 return self.sql( 2409 exp.Mod( 2410 this=exp.Paren( 2411 this=exp.If( 2412 this=exp.GT(this=this, expression=exp.Literal.number(0)), 2413 true=this - exp.Literal.number(1), 2414 false=exp.Abs(this=this), 2415 ) 2416 ), 2417 expression=MAX_BIT_POSITION, 2418 ) 2419 ) 2420 2421 def bitmapconstructagg_sql(self, expression: exp.BitmapConstructAgg) -> str: 2422 """ 2423 Transpile Snowflake's BITMAP_CONSTRUCT_AGG to DuckDB equivalent. 2424 Uses a pre-parsed template with placeholders replaced by expression nodes. 2425 2426 Snowflake bitmap format: 2427 - Small (< 5 unique values): 2-byte count (big-endian) + values (little-endian) + padding to 10 bytes 2428 - Large (>= 5 unique values): 10-byte header (0x08 + 9 zeros) + values (little-endian) 2429 """ 2430 arg = expression.this 2431 return ( 2432 f"({self.sql(exp.replace_placeholders(self.BITMAP_CONSTRUCT_AGG_TEMPLATE, arg=arg))})" 2433 ) 2434 2435 def getignorecase_sql(self, expression: exp.GetIgnoreCase) -> str: 2436 self.unsupported("DuckDB does not support the GET_IGNORE_CASE() function") 2437 return self.function_fallback_sql(expression) 2438 2439 def compress_sql(self, expression: exp.Compress) -> str: 2440 self.unsupported("DuckDB does not support the COMPRESS() function") 2441 return self.function_fallback_sql(expression) 2442 2443 def encrypt_sql(self, expression: exp.Encrypt) -> str: 2444 self.unsupported("ENCRYPT is not supported in DuckDB") 2445 return self.function_fallback_sql(expression) 2446 2447 def decrypt_sql(self, expression: exp.Decrypt) -> str: 2448 func_name = "TRY_DECRYPT" if expression.args.get("safe") else "DECRYPT" 2449 self.unsupported(f"{func_name} is not supported in DuckDB") 2450 return self.function_fallback_sql(expression) 2451 2452 def decryptraw_sql(self, expression: exp.DecryptRaw) -> str: 2453 func_name = "TRY_DECRYPT_RAW" if expression.args.get("safe") else "DECRYPT_RAW" 2454 self.unsupported(f"{func_name} is not supported in DuckDB") 2455 return self.function_fallback_sql(expression) 2456 2457 def encryptraw_sql(self, expression: exp.EncryptRaw) -> str: 2458 self.unsupported("ENCRYPT_RAW is not supported in DuckDB") 2459 return self.function_fallback_sql(expression) 2460 2461 def parseurl_sql(self, expression: exp.ParseUrl) -> str: 2462 self.unsupported("PARSE_URL is not supported in DuckDB") 2463 return self.function_fallback_sql(expression) 2464 2465 def parseip_sql(self, expression: exp.ParseIp) -> str: 2466 self.unsupported("PARSE_IP is not supported in DuckDB") 2467 return self.function_fallback_sql(expression) 2468 2469 def decompressstring_sql(self, expression: exp.DecompressString) -> str: 2470 self.unsupported("DECOMPRESS_STRING is not supported in DuckDB") 2471 return self.function_fallback_sql(expression) 2472 2473 def decompressbinary_sql(self, expression: exp.DecompressBinary) -> str: 2474 self.unsupported("DECOMPRESS_BINARY is not supported in DuckDB") 2475 return self.function_fallback_sql(expression) 2476 2477 def jarowinklersimilarity_sql(self, expression: exp.JarowinklerSimilarity) -> str: 2478 this = expression.this 2479 expr = expression.expression 2480 2481 if expression.args.get("case_insensitive"): 2482 this = exp.Upper(this=this) 2483 expr = exp.Upper(this=expr) 2484 2485 result = exp.func("JARO_WINKLER_SIMILARITY", this, expr) 2486 2487 if expression.args.get("integer_scale"): 2488 result = exp.cast(result * 100, "INTEGER") 2489 2490 return self.sql(result) 2491 2492 def randstr_sql(self, expression: exp.Randstr) -> str: 2493 """ 2494 Transpile Snowflake's RANDSTR to DuckDB equivalent using deterministic hash-based random. 2495 Uses a pre-parsed template with placeholders replaced by expression nodes. 2496 2497 RANDSTR(length, generator) generates a random string of specified length. 2498 - With numeric seed: Use HASH(i + seed) for deterministic output (same seed = same result) 2499 - With RANDOM(): Use RANDOM() in the hash for non-deterministic output 2500 - No generator: Use default seed value 2501 """ 2502 length = expression.this 2503 generator = expression.args.get("generator") 2504 2505 if generator: 2506 if isinstance(generator, exp.Rand): 2507 # If it's RANDOM(), use its seed if available, otherwise use RANDOM() itself 2508 seed_value = generator.this or generator 2509 else: 2510 # Const/int or other expression - use as seed directly 2511 seed_value = generator 2512 else: 2513 # No generator specified, use default seed (arbitrary but deterministic) 2514 seed_value = exp.Literal.number(RANDSTR_SEED) 2515 2516 replacements = {"seed": seed_value, "length": length} 2517 return f"({self.sql(exp.replace_placeholders(self.RANDSTR_TEMPLATE, **replacements))})" 2518 2519 @unsupported_args("finish") 2520 def reduce_sql(self, expression: exp.Reduce) -> str: 2521 array_arg = expression.this 2522 initial_value = expression.args.get("initial") 2523 merge_lambda = expression.args.get("merge") 2524 2525 if merge_lambda: 2526 merge_lambda.set("colon", True) 2527 2528 return self.func("list_reduce", array_arg, merge_lambda, initial_value) 2529 2530 def zipf_sql(self, expression: exp.Zipf) -> str: 2531 """ 2532 Transpile Snowflake's ZIPF to DuckDB using CDF-based inverse sampling. 2533 Uses a pre-parsed template with placeholders replaced by expression nodes. 2534 """ 2535 s = expression.this 2536 n = expression.args["elementcount"] 2537 gen = expression.args["gen"] 2538 2539 if not isinstance(gen, exp.Rand): 2540 # (ABS(HASH(seed)) % 1000000) / 1000000.0 2541 random_expr: exp.Expr = exp.Div( 2542 this=exp.Paren( 2543 this=exp.Mod( 2544 this=exp.Abs(this=exp.Anonymous(this="HASH", expressions=[gen.copy()])), 2545 expression=exp.Literal.number(1000000), 2546 ) 2547 ), 2548 expression=exp.Literal.number(1000000.0), 2549 ) 2550 else: 2551 # Use RANDOM() for non-deterministic output 2552 random_expr = exp.Rand() 2553 2554 replacements = {"s": s, "n": n, "random_expr": random_expr} 2555 return f"({self.sql(exp.replace_placeholders(self.ZIPF_TEMPLATE, **replacements))})" 2556 2557 def tobinary_sql(self, expression: exp.ToBinary) -> str: 2558 """ 2559 TO_BINARY and TRY_TO_BINARY transpilation: 2560 - 'HEX': TO_BINARY('48454C50', 'HEX') -> UNHEX('48454C50') 2561 - 'UTF-8': TO_BINARY('TEST', 'UTF-8') -> ENCODE('TEST') 2562 - 'BASE64': TO_BINARY('SEVMUA==', 'BASE64') -> FROM_BASE64('SEVMUA==') 2563 2564 For TRY_TO_BINARY (safe=True), wrap with TRY(): 2565 - 'HEX': TRY_TO_BINARY('invalid', 'HEX') -> TRY(UNHEX('invalid')) 2566 """ 2567 value = expression.this 2568 format_arg = expression.args.get("format") 2569 is_safe = expression.args.get("safe") 2570 is_binary = _is_binary(expression) 2571 2572 if not format_arg and not is_binary: 2573 func_name = "TRY_TO_BINARY" if is_safe else "TO_BINARY" 2574 return self.func(func_name, value) 2575 2576 # Snowflake defaults to HEX encoding when no format is specified 2577 fmt = format_arg.name.upper() if format_arg else "HEX" 2578 2579 if fmt in ("UTF-8", "UTF8"): 2580 # DuckDB ENCODE always uses UTF-8, no charset parameter needed 2581 result = self.func("ENCODE", value) 2582 elif fmt == "BASE64": 2583 result = self.func("FROM_BASE64", value) 2584 elif fmt == "HEX": 2585 result = self.func("UNHEX", value) 2586 else: 2587 if is_safe: 2588 return self.sql(exp.null()) 2589 else: 2590 self.unsupported(f"format {fmt} is not supported") 2591 result = self.func("TO_BINARY", value) 2592 return f"TRY({result})" if is_safe else result 2593 2594 def tonumber_sql(self, expression: exp.ToNumber) -> str: 2595 fmt = expression.args.get("format") 2596 precision = expression.args.get("precision") 2597 scale = expression.args.get("scale") 2598 2599 if not fmt and precision and scale: 2600 to = exp.DataType.build(f"DECIMAL({precision.name}, {scale.name})", dialect="duckdb") 2601 if expression.args.get("safe"): 2602 return self.sql(exp.TryCast(this=expression.this, to=to)) 2603 return self.sql(exp.cast(expression.this, to, dialect="duckdb")) 2604 2605 return super().tonumber_sql(expression) 2606 2607 def _greatest_least_sql(self, expression: exp.Greatest | exp.Least) -> str: 2608 """ 2609 Handle GREATEST/LEAST functions with dialect-aware NULL behavior. 2610 2611 - If ignore_nulls=False (BigQuery-style): return NULL if any argument is NULL 2612 - If ignore_nulls=True (DuckDB/PostgreSQL-style): ignore NULLs, return greatest/least non-NULL value 2613 """ 2614 # Get all arguments 2615 all_args = [expression.this, *expression.expressions] 2616 fallback_sql = self.function_fallback_sql(expression) 2617 2618 if expression.args.get("ignore_nulls"): 2619 # DuckDB/PostgreSQL behavior: use native GREATEST/LEAST (ignores NULLs) 2620 return self.sql(fallback_sql) 2621 2622 # return NULL if any argument is NULL 2623 case_expr = exp.case().when( 2624 exp.or_(*[arg.is_(exp.null()) for arg in all_args], copy=False), 2625 exp.null(), 2626 copy=False, 2627 ) 2628 case_expr.set("default", fallback_sql) 2629 return self.sql(case_expr) 2630 2631 def generator_sql(self, expression: exp.Generator) -> str: 2632 # Transpile Snowflake GENERATOR to DuckDB range() 2633 rowcount = expression.args.get("rowcount") 2634 time_limit = expression.args.get("time_limit") 2635 2636 if time_limit: 2637 self.unsupported("GENERATOR TIMELIMIT parameter is not supported in DuckDB") 2638 2639 if not rowcount: 2640 self.unsupported("GENERATOR without ROWCOUNT is not supported in DuckDB") 2641 return self.func("range", exp.Literal.number(0)) 2642 2643 return self.func("range", rowcount) 2644 2645 def greatest_sql(self, expression: exp.Greatest) -> str: 2646 return self._greatest_least_sql(expression) 2647 2648 def least_sql(self, expression: exp.Least) -> str: 2649 return self._greatest_least_sql(expression) 2650 2651 def lambda_sql(self, expression: exp.Lambda, arrow_sep: str = "->", wrap: bool = True) -> str: 2652 if expression.args.get("colon"): 2653 prefix = "LAMBDA " 2654 arrow_sep = ":" 2655 wrap = False 2656 else: 2657 prefix = "" 2658 2659 lambda_sql = super().lambda_sql(expression, arrow_sep=arrow_sep, wrap=wrap) 2660 return f"{prefix}{lambda_sql}" 2661 2662 def show_sql(self, expression: exp.Show) -> str: 2663 from_ = self.sql(expression, "from_") 2664 from_ = f" FROM {from_}" if from_ else "" 2665 return f"SHOW {expression.name}{from_}" 2666 2667 def soundex_sql(self, expression: exp.Soundex) -> str: 2668 self.unsupported("SOUNDEX is not supported in DuckDB") 2669 return self.func("SOUNDEX", expression.this) 2670 2671 def sortarray_sql(self, expression: exp.SortArray) -> str: 2672 arr = expression.this 2673 asc = expression.args.get("asc") 2674 nulls_first = expression.args.get("nulls_first") 2675 2676 if not isinstance(asc, exp.Boolean) and not isinstance(nulls_first, exp.Boolean): 2677 return self.func("LIST_SORT", arr, asc, nulls_first) 2678 2679 nulls_are_first = nulls_first == exp.true() 2680 nulls_first_sql = exp.Literal.string("NULLS FIRST") if nulls_are_first else None 2681 2682 if not isinstance(asc, exp.Boolean): 2683 return self.func("LIST_SORT", arr, asc, nulls_first_sql) 2684 2685 descending = asc == exp.false() 2686 2687 if not descending and not nulls_are_first: 2688 return self.func("LIST_SORT", arr) 2689 if not nulls_are_first: 2690 return self.func("ARRAY_REVERSE_SORT", arr) 2691 return self.func( 2692 "LIST_SORT", 2693 arr, 2694 exp.Literal.string("DESC" if descending else "ASC"), 2695 exp.Literal.string("NULLS FIRST"), 2696 ) 2697 2698 def install_sql(self, expression: exp.Install) -> str: 2699 force = "FORCE " if expression.args.get("force") else "" 2700 this = self.sql(expression, "this") 2701 from_clause = expression.args.get("from_") 2702 from_clause = f" FROM {from_clause}" if from_clause else "" 2703 return f"{force}INSTALL {this}{from_clause}" 2704 2705 def approxtopk_sql(self, expression: exp.ApproxTopK) -> str: 2706 self.unsupported( 2707 "APPROX_TOP_K cannot be transpiled to DuckDB due to incompatible return types. " 2708 ) 2709 return self.function_fallback_sql(expression) 2710 2711 def strposition_sql(self, expression: exp.StrPosition) -> str: 2712 this = expression.this 2713 substr = expression.args.get("substr") 2714 position = expression.args.get("position") 2715 2716 # For BINARY/BLOB: DuckDB's STRPOS doesn't support BLOB types 2717 # Convert to HEX strings, use STRPOS, then convert hex position to byte position 2718 if _is_binary(this): 2719 # Build expression: STRPOS(HEX(haystack), HEX(needle)) 2720 hex_strpos = exp.StrPosition( 2721 this=exp.Hex(this=this), 2722 substr=exp.Hex(this=substr), 2723 ) 2724 2725 return self.sql(exp.cast((hex_strpos + 1) / 2, exp.DType.INT)) 2726 2727 # For VARCHAR: handle clamp_position 2728 if expression.args.get("clamp_position") and position: 2729 expression = expression.copy() 2730 expression.set( 2731 "position", 2732 exp.If( 2733 this=exp.LTE(this=position, expression=exp.Literal.number(0)), 2734 true=exp.Literal.number(1), 2735 false=position.copy(), 2736 ), 2737 ) 2738 2739 return strposition_sql(self, expression) 2740 2741 def substring_sql(self, expression: exp.Substring) -> str: 2742 if expression.args.get("zero_start"): 2743 start = expression.args.get("start") 2744 length = expression.args.get("length") 2745 2746 if start := expression.args.get("start"): 2747 start = exp.If(this=start.eq(0), true=exp.Literal.number(1), false=start) 2748 if length := expression.args.get("length"): 2749 length = exp.If(this=length < 0, true=exp.Literal.number(0), false=length) 2750 2751 return self.func("SUBSTRING", expression.this, start, length) 2752 2753 return self.function_fallback_sql(expression) 2754 2755 def strtotime_sql(self, expression: exp.StrToTime) -> str: 2756 # Check if target_type requires TIMESTAMPTZ (for LTZ/TZ variants) 2757 target_type = expression.args.get("target_type") 2758 needs_tz = target_type and target_type.this in ( 2759 exp.DType.TIMESTAMPLTZ, 2760 exp.DType.TIMESTAMPTZ, 2761 ) 2762 2763 value, formatted_time = self._strptime_default_year(expression) 2764 2765 if expression.args.get("safe"): 2766 cast_type = exp.DType.TIMESTAMPTZ if needs_tz else exp.DType.TIMESTAMP 2767 return self.sql(exp.cast(self.func("TRY_STRPTIME", value, formatted_time), cast_type)) 2768 2769 base_sql = self.func("STRPTIME", value, formatted_time) 2770 if needs_tz: 2771 return self.sql( 2772 exp.cast( 2773 base_sql, 2774 exp.DataType(this=exp.DType.TIMESTAMPTZ), 2775 ) 2776 ) 2777 return base_sql 2778 2779 def strtodate_sql(self, expression: exp.StrToDate) -> str: 2780 value, formatted_time = self._strptime_default_year(expression) 2781 function_name = "STRPTIME" if not expression.args.get("safe") else "TRY_STRPTIME" 2782 return self.sql( 2783 exp.cast( 2784 self.func(function_name, value, formatted_time), 2785 exp.DataType(this=exp.DType.DATE), 2786 ) 2787 ) 2788 2789 def _strptime_default_year( 2790 self, expression: exp.StrToTime | exp.StrToDate | exp.ParseDatetime 2791 ) -> tuple[exp.ExpOrStr, exp.ExpOrStr | None]: 2792 value: exp.ExpOrStr = expression.this 2793 formatted_time: exp.ExpOrStr | None = self.format_time(expression) 2794 2795 if default_year := expression.args.get("default_year"): 2796 value = exp.DPipe(this=exp.Literal.string(f"{default_year.name} "), expression=value) 2797 formatted_time = exp.DPipe(this=exp.Literal.string("%Y "), expression=formatted_time) 2798 2799 return value, formatted_time 2800 2801 def parsedatetime_sql(self, expression: exp.ParseDatetime) -> str: 2802 value, formatted_time = self._strptime_default_year(expression) 2803 return self.func("STRPTIME", value, formatted_time) 2804 2805 def parsetime_sql(self, expression: exp.ParseTime) -> str: 2806 formatted_time = self.format_time(expression) 2807 return self.sql( 2808 exp.cast( 2809 self.func("STRPTIME", expression.this, formatted_time), 2810 exp.DataType(this=exp.DType.TIME), 2811 ) 2812 ) 2813 2814 def tsordstotime_sql(self, expression: exp.TsOrDsToTime) -> str: 2815 this = expression.this 2816 time_format = self.format_time(expression) 2817 safe = expression.args.get("safe") 2818 time_type = exp.DataType.from_str("TIME", dialect="duckdb") 2819 cast_expr = exp.TryCast if safe else exp.Cast 2820 2821 if time_format: 2822 func_name = "TRY_STRPTIME" if safe else "STRPTIME" 2823 strptime = exp.Anonymous(this=func_name, expressions=[this, time_format]) 2824 return self.sql(cast_expr(this=strptime, to=time_type)) 2825 2826 if isinstance(this, exp.TsOrDsToTime) or this.is_type(exp.DType.TIME): 2827 return self.sql(this) 2828 2829 return self.sql(cast_expr(this=this, to=time_type)) 2830 2831 def currentdate_sql(self, expression: exp.CurrentDate) -> str: 2832 if not expression.this: 2833 return "CURRENT_DATE" 2834 2835 expr = exp.Cast( 2836 this=exp.AtTimeZone(this=exp.CurrentTimestamp(), zone=expression.this), 2837 to=exp.DataType(this=exp.DType.DATE), 2838 ) 2839 return self.sql(expr) 2840 2841 def checkjson_sql(self, expression: exp.CheckJson) -> str: 2842 arg = expression.this 2843 return self.sql( 2844 exp.case() 2845 .when( 2846 exp.or_(arg.is_(exp.Null()), arg.eq(""), exp.func("json_valid", arg)), 2847 exp.null(), 2848 ) 2849 .else_(exp.Literal.string("Invalid JSON")) 2850 ) 2851 2852 def parsejson_sql(self, expression: exp.ParseJSON) -> str: 2853 arg = expression.this 2854 if expression.args.get("safe"): 2855 return self.sql( 2856 exp.case() 2857 .when(exp.func("json_valid", arg), exp.cast(arg.copy(), "JSON")) 2858 .else_(exp.null()) 2859 ) 2860 return self.func("JSON", arg) 2861 2862 def unicode_sql(self, expression: exp.Unicode) -> str: 2863 if expression.args.get("empty_is_zero"): 2864 return self.sql( 2865 exp.case() 2866 .when(expression.this.eq(exp.Literal.string("")), exp.Literal.number(0)) 2867 .else_(exp.Anonymous(this="UNICODE", expressions=[expression.this])) 2868 ) 2869 2870 return self.func("UNICODE", expression.this) 2871 2872 def stripnullvalue_sql(self, expression: exp.StripNullValue) -> str: 2873 return self.sql( 2874 exp.case() 2875 .when(exp.func("json_type", expression.this).eq("NULL"), exp.null()) 2876 .else_(expression.this) 2877 ) 2878 2879 def trunc_sql(self, expression: exp.Trunc) -> str: 2880 decimals = expression.args.get("decimals") 2881 if ( 2882 expression.args.get("fractions_supported") 2883 and decimals 2884 and not decimals.is_type(exp.DType.INT) 2885 ): 2886 decimals = exp.cast(decimals, exp.DType.INT, dialect="duckdb") 2887 2888 return self.func("TRUNC", expression.this, decimals) 2889 2890 def normal_sql(self, expression: exp.Normal) -> str: 2891 """ 2892 Transpile Snowflake's NORMAL(mean, stddev, gen) to DuckDB. 2893 2894 Uses the Box-Muller transform via NORMAL_TEMPLATE. 2895 """ 2896 mean = expression.this 2897 stddev = expression.args["stddev"] 2898 gen: exp.Expr = expression.args["gen"] 2899 2900 # Build two uniform random values [0, 1) for Box-Muller transform 2901 if isinstance(gen, exp.Rand) and gen.this is None: 2902 u1: exp.Expr = exp.Rand() 2903 u2: exp.Expr = exp.Rand() 2904 else: 2905 # Seeded: derive two values using HASH with different inputs 2906 seed = gen.this if isinstance(gen, exp.Rand) else gen 2907 u1 = exp.replace_placeholders(self.SEEDED_RANDOM_TEMPLATE, seed=seed) 2908 u2 = exp.replace_placeholders( 2909 self.SEEDED_RANDOM_TEMPLATE, 2910 seed=exp.Add(this=seed.copy(), expression=exp.Literal.number(1)), 2911 ) 2912 2913 replacements = {"mean": mean, "stddev": stddev, "u1": u1, "u2": u2} 2914 return self.sql(exp.replace_placeholders(self.NORMAL_TEMPLATE, **replacements)) 2915 2916 def uniform_sql(self, expression: exp.Uniform) -> str: 2917 """ 2918 Transpile Snowflake's UNIFORM(min, max, gen) to DuckDB. 2919 2920 UNIFORM returns a random value in [min, max]: 2921 - Integer result if both min and max are integers 2922 - Float result if either min or max is a float 2923 """ 2924 min_val = expression.this 2925 max_val = expression.expression 2926 gen = expression.args.get("gen") or expression.args.get("seed") or exp.Rand() 2927 2928 # Determine if result should be integer (both bounds are integers). 2929 # We do this to emulate Snowflake's behavior, INT -> INT, FLOAT -> FLOAT 2930 is_int_result = min_val.is_int and max_val.is_int 2931 2932 # Build the random value expression [0, 1) 2933 if not isinstance(gen, exp.Rand): 2934 # Seed value: (ABS(HASH(seed)) % 1000000) / 1000000.0 2935 random_expr: exp.Expr = exp.Div( 2936 this=exp.Paren( 2937 this=exp.Mod( 2938 this=exp.Abs(this=exp.Anonymous(this="HASH", expressions=[gen])), 2939 expression=exp.Literal.number(1000000), 2940 ) 2941 ), 2942 expression=exp.Literal.number(1000000.0), 2943 ) 2944 else: 2945 random_expr = exp.Rand() 2946 2947 # Build: min + random * (max - min [+ 1 for int]) 2948 range_expr: exp.Expr = exp.Sub(this=max_val, expression=min_val) 2949 if is_int_result: 2950 range_expr = exp.Add(this=range_expr, expression=exp.Literal.number(1)) 2951 2952 result: exp.Expr = exp.Add( 2953 this=min_val, 2954 expression=exp.Mul(this=random_expr, expression=exp.Paren(this=range_expr)), 2955 ) 2956 2957 if is_int_result: 2958 result = exp.Cast(this=exp.Floor(this=result), to=exp.DType.BIGINT.into_expr()) 2959 2960 return self.sql(result) 2961 2962 def timefromparts_sql(self, expression: exp.TimeFromParts) -> str: 2963 nano = expression.args.get("nano") 2964 overflow = expression.args.get("overflow") 2965 2966 # Snowflake's TIME_FROM_PARTS supports overflow 2967 if overflow: 2968 hour = expression.args["hour"] 2969 minute = expression.args["min"] 2970 sec = expression.args["sec"] 2971 2972 # Check if values are within normal ranges - use MAKE_TIME for efficiency 2973 if not nano and all(arg.is_int for arg in [hour, minute, sec]): 2974 try: 2975 h_val = hour.to_py() 2976 m_val = minute.to_py() 2977 s_val = sec.to_py() 2978 if 0 <= h_val <= 23 and 0 <= m_val <= 59 and 0 <= s_val <= 59: 2979 return rename_func("MAKE_TIME")(self, expression) 2980 except ValueError: 2981 pass 2982 2983 # Overflow or nanoseconds detected - use INTERVAL arithmetic 2984 if nano: 2985 sec = sec + nano.pop() / exp.Literal.number(1000000000.0) 2986 2987 total_seconds = hour * exp.Literal.number(3600) + minute * exp.Literal.number(60) + sec 2988 2989 return self.sql( 2990 exp.Add( 2991 this=exp.Cast( 2992 this=exp.Literal.string("00:00:00"), to=exp.DType.TIME.into_expr() 2993 ), 2994 expression=exp.Interval(this=total_seconds, unit=exp.var("SECOND")), 2995 ) 2996 ) 2997 2998 # Default: MAKE_TIME 2999 if nano: 3000 expression.set( 3001 "sec", expression.args["sec"] + nano.pop() / exp.Literal.number(1000000000.0) 3002 ) 3003 3004 return rename_func("MAKE_TIME")(self, expression) 3005 3006 def extract_sql(self, expression: exp.Extract) -> str: 3007 """ 3008 Transpile EXTRACT/DATE_PART for DuckDB, handling specifiers not natively supported. 3009 3010 DuckDB doesn't support: WEEKISO, YEAROFWEEK, YEAROFWEEKISO, NANOSECOND, 3011 EPOCH_SECOND (as integer), EPOCH_MILLISECOND, EPOCH_MICROSECOND, EPOCH_NANOSECOND 3012 """ 3013 this = expression.this 3014 datetime_expr = expression.expression 3015 3016 # TIMESTAMPTZ extractions may produce different results between Snowflake and DuckDB 3017 # because Snowflake applies server timezone while DuckDB uses local timezone 3018 if datetime_expr.is_type(exp.DType.TIMESTAMPTZ, exp.DType.TIMESTAMPLTZ): 3019 self.unsupported( 3020 "EXTRACT from TIMESTAMPTZ / TIMESTAMPLTZ may produce different results due to timezone handling differences" 3021 ) 3022 3023 part_name = this.name.upper() 3024 3025 if part_name in self.EXTRACT_STRFTIME_MAPPINGS: 3026 fmt, cast_type = self.EXTRACT_STRFTIME_MAPPINGS[part_name] 3027 3028 # Problem: strftime doesn't accept TIME and there's no NANOSECOND function 3029 # So, for NANOSECOND with TIME, fallback to MICROSECOND * 1000 3030 is_nano_time = part_name == "NANOSECOND" and datetime_expr.is_type( 3031 exp.DType.TIME, exp.DType.TIMETZ 3032 ) 3033 3034 if is_nano_time: 3035 self.unsupported("Parameter NANOSECOND is not supported with TIME type in DuckDB") 3036 return self.sql( 3037 exp.cast( 3038 exp.Mul( 3039 this=exp.Extract(this=exp.var("MICROSECOND"), expression=datetime_expr), 3040 expression=exp.Literal.number(1000), 3041 ), 3042 exp.DataType.from_str(cast_type, dialect="duckdb"), 3043 ) 3044 ) 3045 3046 # For NANOSECOND, cast to TIMESTAMP_NS to preserve nanosecond precision 3047 strftime_input = datetime_expr 3048 if part_name == "NANOSECOND": 3049 strftime_input = exp.cast(datetime_expr, exp.DType.TIMESTAMP_NS) 3050 3051 return self.sql( 3052 exp.cast( 3053 exp.Anonymous( 3054 this="STRFTIME", 3055 expressions=[strftime_input, exp.Literal.string(fmt)], 3056 ), 3057 exp.DataType.from_str(cast_type, dialect="duckdb"), 3058 ) 3059 ) 3060 3061 if part_name in self.EXTRACT_EPOCH_MAPPINGS: 3062 func_name = self.EXTRACT_EPOCH_MAPPINGS[part_name] 3063 result: exp.Expr = exp.Anonymous(this=func_name, expressions=[datetime_expr]) 3064 # EPOCH returns float, cast to BIGINT for integer result 3065 if part_name == "EPOCH_SECOND": 3066 result = exp.cast(result, exp.DataType.from_str("BIGINT", dialect="duckdb")) 3067 return self.sql(result) 3068 3069 return super().extract_sql(expression) 3070 3071 def timestampfromparts_sql(self, expression: exp.TimestampFromParts) -> str: 3072 # Check if this is the date/time expression form: TIMESTAMP_FROM_PARTS(date_expr, time_expr) 3073 date_expr = expression.this 3074 time_expr = expression.expression 3075 3076 if date_expr is not None and time_expr is not None: 3077 # In DuckDB, DATE + TIME produces TIMESTAMP 3078 return self.sql(exp.Add(this=date_expr, expression=time_expr)) 3079 3080 # Component-based form: TIMESTAMP_FROM_PARTS(year, month, day, hour, minute, second, ...) 3081 sec = expression.args.get("sec") 3082 if sec is None: 3083 # This shouldn't happen with valid input, but handle gracefully 3084 return rename_func("MAKE_TIMESTAMP")(self, expression) 3085 3086 milli = expression.args.get("milli") 3087 if milli is not None: 3088 sec += milli.pop() / exp.Literal.number(1000.0) 3089 3090 nano = expression.args.get("nano") 3091 if nano is not None: 3092 sec += nano.pop() / exp.Literal.number(1000000000.0) 3093 3094 if milli or nano: 3095 expression.set("sec", sec) 3096 3097 if zone := expression.args.get("zone"): 3098 return self.sql(exp.AtTimeZone(this=expression, zone=zone.pop())) 3099 3100 return rename_func("MAKE_TIMESTAMP")(self, expression) 3101 3102 @unsupported_args("nano") 3103 def timestampltzfromparts_sql(self, expression: exp.TimestampLtzFromParts) -> str: 3104 # Pop nano so rename_func only passes args that MAKE_TIMESTAMP accepts 3105 if nano := expression.args.get("nano"): 3106 nano.pop() 3107 3108 timestamp = rename_func("MAKE_TIMESTAMP")(self, expression) 3109 return f"CAST({timestamp} AS TIMESTAMPTZ)" 3110 3111 @unsupported_args("nano") 3112 def timestamptzfromparts_sql(self, expression: exp.TimestampTzFromParts) -> str: 3113 # Extract zone before popping 3114 zone = expression.args.get("zone") 3115 # Pop zone and nano so rename_func only passes args that MAKE_TIMESTAMP accepts 3116 if zone: 3117 zone = zone.pop() 3118 3119 if nano := expression.args.get("nano"): 3120 nano.pop() 3121 3122 timestamp = rename_func("MAKE_TIMESTAMP")(self, expression) 3123 3124 if zone: 3125 # Use AT TIME ZONE to apply the explicit timezone 3126 return f"{timestamp} AT TIME ZONE {self.sql(zone)}" 3127 3128 return timestamp 3129 3130 def after_limit_modifiers(self, expression: exp.Expr) -> list[str]: 3131 locks = self.expressions(expression, key="locks", sep=" ") 3132 return [f" {locks}" if locks else ""] 3133 3134 def tablesample_sql( 3135 self, 3136 expression: exp.TableSample, 3137 tablesample_keyword: str | None = None, 3138 ) -> str: 3139 if not isinstance(expression.parent, exp.Select): 3140 # This sample clause only applies to a single source, not the entire resulting relation 3141 tablesample_keyword = "TABLESAMPLE" 3142 3143 if expression.args.get("size"): 3144 method = expression.args.get("method") 3145 if method and method.name.upper() != "RESERVOIR": 3146 self.unsupported( 3147 f"Sampling method {method} is not supported with a discrete sample count, " 3148 "defaulting to reservoir sampling" 3149 ) 3150 expression.set("method", exp.var("RESERVOIR")) 3151 3152 return super().tablesample_sql(expression, tablesample_keyword=tablesample_keyword) 3153 3154 def in_sql(self, expression: exp.In) -> str: 3155 unnest = expression.args.get("unnest") 3156 if unnest: 3157 return self.sql( 3158 exp.replace_placeholders( 3159 self.IN_UNNEST_TEMPLATE, arr=unnest.expressions[0], value=expression.this 3160 ) 3161 ) 3162 return super().in_sql(expression) 3163 3164 def join_sql(self, expression: exp.Join) -> str: 3165 if ( 3166 not expression.args.get("using") 3167 and not expression.args.get("on") 3168 and not expression.method 3169 and (expression.kind in ("", "INNER", "OUTER")) 3170 ): 3171 # Some dialects support `LEFT/INNER JOIN UNNEST(...)` without an explicit ON clause 3172 # DuckDB doesn't, but we can just add a dummy ON clause that is always true 3173 if isinstance(expression.this, exp.Unnest): 3174 return super().join_sql(expression.on(exp.true())) 3175 3176 expression.set("side", None) 3177 expression.set("kind", None) 3178 3179 return super().join_sql(expression) 3180 3181 def countif_sql(self, expression: exp.CountIf) -> str: 3182 if self.dialect.version >= (1, 2): 3183 this = expression.this 3184 if expression.args.get("zero_on_all_null") and not isinstance(this, exp.Distinct): 3185 # DuckDB >= 1.2's COUNT_IF returns NULL when the condition is NULL on all rows, 3186 # so we wrap the condition in IS TRUE to preserve count-like semantics 3187 expression = exp.CountIf(this=exp.paren(this).is_(exp.true())) 3188 return self.function_fallback_sql(expression) 3189 3190 # https://github.com/tobymao/sqlglot/pull/4749 3191 return count_if_to_sum(self, expression) 3192 3193 def bracket_sql(self, expression: exp.Bracket) -> str: 3194 if self.dialect.version >= (1, 2): 3195 return super().bracket_sql(expression) 3196 3197 # https://duckdb.org/2025/02/05/announcing-duckdb-120.html#breaking-changes 3198 this = expression.this 3199 if isinstance(this, exp.Array): 3200 this.replace(exp.paren(this)) 3201 3202 bracket = super().bracket_sql(expression) 3203 3204 if not expression.args.get("returns_list_for_maps"): 3205 if not this.type: 3206 from sqlglot.optimizer.annotate_types import annotate_types 3207 3208 this = annotate_types(this, dialect=self.dialect) 3209 3210 if this.is_type(exp.DType.MAP): 3211 bracket = f"({bracket})[1]" 3212 3213 return bracket 3214 3215 def withingroup_sql(self, expression: exp.WithinGroup) -> str: 3216 func = expression.this 3217 3218 # For ARRAY_AGG, DuckDB requires ORDER BY inside the function, not in WITHIN GROUP 3219 # Transform: ARRAY_AGG(x) WITHIN GROUP (ORDER BY y) -> ARRAY_AGG(x ORDER BY y) 3220 if isinstance(func, exp.ArrayAgg): 3221 if not isinstance(order := expression.expression, exp.Order): 3222 return self.sql(func) 3223 3224 # Save the original column for FILTER clause (before wrapping with Order) 3225 original_this = func.this 3226 3227 # Move ORDER BY inside ARRAY_AGG by wrapping its argument with Order 3228 # ArrayAgg.this should become Order(this=ArrayAgg.this, expressions=order.expressions) 3229 func.set( 3230 "this", 3231 exp.Order( 3232 this=func.this.copy(), 3233 expressions=order.expressions, 3234 ), 3235 ) 3236 3237 # Generate the ARRAY_AGG function with ORDER BY and add FILTER clause if needed 3238 # Use original_this (not the Order-wrapped version) for the FILTER condition 3239 array_agg_sql = self.function_fallback_sql(func) 3240 return self._add_arrayagg_null_filter(array_agg_sql, func, original_this) 3241 3242 # For other functions (like PERCENTILES), use existing logic 3243 expression_sql = self.sql(expression, "expression") 3244 3245 if isinstance(func, exp.PERCENTILES): 3246 # Make the order key the first arg and slide the fraction to the right 3247 # https://duckdb.org/docs/sql/aggregates#ordered-set-aggregate-functions 3248 order_col = expression.find(exp.Ordered) 3249 if order_col: 3250 func.set("expression", func.this) 3251 func.set("this", order_col.this) 3252 3253 this = self.sql(expression, "this").rstrip(")") 3254 3255 return f"{this}{expression_sql})" 3256 3257 def length_sql(self, expression: exp.Length) -> str: 3258 arg = expression.this 3259 3260 # Dialects like BQ and Snowflake also accept binary values as args, so 3261 # DDB will attempt to infer the type or resort to case/when resolution 3262 if not expression.args.get("binary") or arg.is_string: 3263 return self.func("LENGTH", arg) 3264 3265 if not arg.type: 3266 from sqlglot.optimizer.annotate_types import annotate_types 3267 3268 arg = annotate_types(arg, dialect=self.dialect) 3269 3270 if arg.is_type(*exp.DataType.TEXT_TYPES): 3271 return self.func("LENGTH", arg) 3272 3273 # We need these casts to make duckdb's static type checker happy 3274 blob = exp.cast(arg, exp.DType.VARBINARY) 3275 varchar = exp.cast(arg, exp.DType.VARCHAR) 3276 3277 case = ( 3278 exp.case(exp.Anonymous(this="TYPEOF", expressions=[arg])) 3279 .when(exp.Literal.string("BLOB"), exp.ByteLength(this=blob)) 3280 .else_(exp.Anonymous(this="LENGTH", expressions=[varchar])) 3281 ) 3282 return self.sql(case) 3283 3284 def bitlength_sql(self, expression: exp.BitLength) -> str: 3285 if not _is_binary(arg := expression.this): 3286 return self.func("BIT_LENGTH", arg) 3287 3288 blob = exp.cast(arg, exp.DataType.Type.VARBINARY) 3289 return self.sql(exp.ByteLength(this=blob) * exp.Literal.number(8)) 3290 3291 def chr_sql(self, expression: exp.Chr, name: str = "CHR") -> str: 3292 arg = expression.expressions[0] 3293 if arg.is_type(*exp.DataType.REAL_TYPES): 3294 arg = exp.cast(arg, exp.DType.INT) 3295 return self.func("CHR", arg) 3296 3297 def collation_sql(self, expression: exp.Collation) -> str: 3298 self.unsupported("COLLATION function is not supported by DuckDB") 3299 return self.function_fallback_sql(expression) 3300 3301 def collate_sql(self, expression: exp.Collate) -> str: 3302 if not expression.expression.is_string: 3303 return super().collate_sql(expression) 3304 3305 raw = expression.expression.name 3306 if not raw: 3307 return self.sql(expression.this) 3308 3309 parts = [] 3310 for part in raw.split("-"): 3311 lower = part.lower() 3312 if lower not in _SNOWFLAKE_COLLATION_DEFAULTS: 3313 if lower in _SNOWFLAKE_COLLATION_UNSUPPORTED: 3314 self.unsupported( 3315 f"Snowflake collation specifier '{part}' has no DuckDB equivalent" 3316 ) 3317 parts.append(lower) 3318 3319 if not parts: 3320 return self.sql(expression.this) 3321 return super().collate_sql( 3322 exp.Collate(this=expression.this, expression=exp.var(".".join(parts))) 3323 ) 3324 3325 def _validate_regexp_flags(self, flags: exp.Expr | None, supported_flags: str) -> str | None: 3326 """ 3327 Validate and filter regexp flags for DuckDB compatibility. 3328 3329 Args: 3330 flags: The flags expression to validate 3331 supported_flags: String of supported flags (e.g., "ims", "cims"). 3332 Only these flags will be returned. 3333 3334 Returns: 3335 Validated/filtered flag string, or None if no valid flags remain 3336 """ 3337 if not isinstance(flags, exp.Expr): 3338 return None 3339 3340 if not flags.is_string: 3341 self.unsupported("Non-literal regexp flags are not fully supported in DuckDB") 3342 return None 3343 3344 flag_str = flags.this 3345 unsupported = set(flag_str) - set(supported_flags) 3346 3347 if unsupported: 3348 self.unsupported( 3349 f"Regexp flags {sorted(unsupported)} are not supported in this context" 3350 ) 3351 3352 flag_str = "".join(f for f in flag_str if f in supported_flags) 3353 return flag_str if flag_str else None 3354 3355 def regexpcount_sql(self, expression: exp.RegexpCount) -> str: 3356 this = expression.this 3357 pattern = expression.expression 3358 position = expression.args.get("position") 3359 parameters = expression.args.get("parameters") 3360 3361 # Validate flags - only "ims" flags are supported for embedded patterns 3362 validated_flags = self._validate_regexp_flags(parameters, supported_flags="ims") 3363 3364 if position: 3365 this = exp.Substring(this=this, start=position) 3366 3367 # Handle empty pattern: Snowflake returns 0, DuckDB would match between every character. 3368 # Checked before the flags are embedded, which would make the pattern non-empty 3369 is_empty = exp.EQ(this=pattern.copy(), expression=exp.Literal.string("")) 3370 3371 # Embed flags in pattern (REGEXP_EXTRACT_ALL doesn't support flags argument) 3372 if validated_flags: 3373 pattern = exp.Concat(expressions=[exp.Literal.string(f"(?{validated_flags})"), pattern]) 3374 3375 result = ( 3376 exp.case() 3377 .when(is_empty, exp.Literal.number(0)) 3378 .else_( 3379 exp.Length( 3380 this=exp.Anonymous(this="REGEXP_EXTRACT_ALL", expressions=[this, pattern]) 3381 ) 3382 ) 3383 ) 3384 3385 return self.sql(result) 3386 3387 def regexpreplace_sql(self, expression: exp.RegexpReplace) -> str: 3388 subject = expression.this 3389 pattern = expression.expression 3390 replacement = expression.args.get("replacement") or exp.Literal.string("") 3391 position = expression.args.get("position") 3392 occurrence = expression.args.get("occurrence") 3393 modifiers = expression.args.get("modifiers") 3394 3395 validated_flags = self._validate_regexp_flags(modifiers, supported_flags="cimsg") or "" 3396 3397 # Handle occurrence (only literals supported) 3398 if occurrence and not occurrence.is_int: 3399 self.unsupported("REGEXP_REPLACE with non-literal occurrence") 3400 else: 3401 occurrence = occurrence.to_py() if occurrence and occurrence.is_int else 0 3402 if occurrence > 1: 3403 self.unsupported(f"REGEXP_REPLACE occurrence={occurrence} not supported") 3404 # flag duckdb to do either all or none, single_replace check is for duckdb round trip 3405 elif ( 3406 occurrence == 0 3407 and "g" not in validated_flags 3408 and not expression.args.get("single_replace") 3409 ): 3410 validated_flags += "g" 3411 3412 # Handle position (only literals supported) 3413 prefix = None 3414 if position and not position.is_int: 3415 self.unsupported("REGEXP_REPLACE with non-literal position") 3416 elif position and position.is_int and position.to_py() > 1: 3417 pos = position.to_py() 3418 prefix = exp.Substring( 3419 this=subject, start=exp.Literal.number(1), length=exp.Literal.number(pos - 1) 3420 ) 3421 subject = exp.Substring(this=subject, start=exp.Literal.number(pos)) 3422 3423 result: exp.Expr = exp.Anonymous( 3424 this="REGEXP_REPLACE", 3425 expressions=[ 3426 subject, 3427 pattern, 3428 replacement, 3429 exp.Literal.string(validated_flags) if validated_flags else None, 3430 ], 3431 ) 3432 3433 if prefix: 3434 result = exp.Concat(expressions=[prefix, result]) 3435 3436 return self.sql(result) 3437 3438 def regexplike_sql(self, expression: exp.RegexpLike) -> str: 3439 this = expression.this 3440 pattern = expression.expression 3441 flag = expression.args.get("flag") 3442 3443 if expression.args.get("full_match"): 3444 validated_flags = self._validate_regexp_flags(flag, supported_flags="cims") 3445 flag = exp.Literal.string(validated_flags) if validated_flags else None 3446 return self.func("REGEXP_FULL_MATCH", this, pattern, flag) 3447 3448 return self.func("REGEXP_MATCHES", this, pattern, flag) 3449 3450 @unsupported_args("ins_cost", "del_cost", "sub_cost") 3451 def levenshtein_sql(self, expression: exp.Levenshtein) -> str: 3452 this = expression.this 3453 expr = expression.expression 3454 max_dist = expression.args.get("max_dist") 3455 3456 if max_dist is None: 3457 return self.func("LEVENSHTEIN", this, expr) 3458 3459 # Emulate Snowflake semantics: if distance > max_dist, return max_dist 3460 levenshtein = exp.Levenshtein(this=this, expression=expr) 3461 return self.sql(exp.Least(this=levenshtein, expressions=[max_dist])) 3462 3463 def pad_sql(self, expression: exp.Pad) -> str: 3464 """ 3465 Handle RPAD/LPAD for VARCHAR and BINARY types. 3466 3467 For VARCHAR: Delegate to parent class 3468 For BINARY: Lower to: input || REPEAT(pad, GREATEST(0, target_len - OCTET_LENGTH(input))) 3469 """ 3470 string_arg = expression.this 3471 fill_arg = expression.args.get("fill_pattern") or exp.Literal.string(" ") 3472 3473 if _is_binary(string_arg) or _is_binary(fill_arg): 3474 length_arg = expression.expression 3475 is_left = expression.args.get("is_left") 3476 3477 input_len = exp.ByteLength(this=string_arg) 3478 chars_needed = length_arg - input_len 3479 pad_count = exp.Greatest( 3480 this=exp.Literal.number(0), expressions=[chars_needed], ignore_nulls=True 3481 ) 3482 repeat_expr = exp.Repeat(this=fill_arg, times=pad_count) 3483 3484 left, right = string_arg, repeat_expr 3485 if is_left: 3486 left, right = right, left 3487 3488 result = exp.DPipe(this=left, expression=right) 3489 return self.sql(result) 3490 3491 # For VARCHAR: Delegate to parent class (handles PAD_FILL_PATTERN_IS_REQUIRED) 3492 return super().pad_sql(expression) 3493 3494 def minhash_sql(self, expression: exp.Minhash) -> str: 3495 k = expression.this 3496 exprs = expression.expressions 3497 3498 if len(exprs) != 1 or isinstance(exprs[0], exp.Star): 3499 self.unsupported( 3500 "MINHASH with multiple expressions or * requires manual query restructuring" 3501 ) 3502 return self.func("MINHASH", k, *exprs) 3503 3504 expr = exprs[0] 3505 result = exp.replace_placeholders(self.MINHASH_TEMPLATE.copy(), expr=expr, k=k) 3506 return f"({self.sql(result)})" 3507 3508 def minhashcombine_sql(self, expression: exp.MinhashCombine) -> str: 3509 expr = expression.this 3510 result = exp.replace_placeholders(self.MINHASH_COMBINE_TEMPLATE.copy(), expr=expr) 3511 return f"({self.sql(result)})" 3512 3513 def approximatesimilarity_sql(self, expression: exp.ApproximateSimilarity) -> str: 3514 expr = expression.this 3515 result = exp.replace_placeholders(self.APPROXIMATE_SIMILARITY_TEMPLATE.copy(), expr=expr) 3516 return f"({self.sql(result)})" 3517 3518 def arrayuniqueagg_sql(self, expression: exp.ArrayUniqueAgg) -> str: 3519 return self.sql( 3520 exp.Filter( 3521 this=exp.func("LIST", exp.Distinct(expressions=[expression.this])), 3522 expression=exp.Where(this=expression.this.copy().is_(exp.null()).not_()), 3523 ) 3524 ) 3525 3526 def arrayconcatagg_sql(self, expression: exp.ArrayConcatAgg) -> str: 3527 this = expression.this 3528 3529 if isinstance(this, exp.Limit): 3530 self.unsupported("LIMIT in ARRAY_CONCAT_AGG cannot be transpiled to DuckDB") 3531 this = this.this 3532 3533 inner = this.this if isinstance(this, exp.Order) else this 3534 3535 return self.func( 3536 "FLATTEN", 3537 exp.Filter( 3538 this=exp.ArrayAgg(this=this), 3539 expression=exp.Where(this=inner.copy().is_(exp.null()).not_()), 3540 ), 3541 ) 3542 3543 def arrayunionagg_sql(self, expression: exp.ArrayUnionAgg) -> str: 3544 self.unsupported("ARRAY_UNION_AGG is not supported in DuckDB") 3545 return self.function_fallback_sql(expression) 3546 3547 def arraydistinct_sql(self, expression: exp.ArrayDistinct) -> str: 3548 arr = expression.this 3549 func = self.func("LIST_DISTINCT", arr) 3550 3551 if expression.args.get("check_null"): 3552 add_null_to_array = exp.func( 3553 "LIST_APPEND", exp.func("LIST_DISTINCT", exp.ArrayCompact(this=arr)), exp.Null() 3554 ) 3555 return self.sql( 3556 exp.If( 3557 this=exp.NEQ( 3558 this=exp.ArraySize(this=arr), expression=exp.func("LIST_COUNT", arr) 3559 ), 3560 true=add_null_to_array, 3561 false=func, 3562 ) 3563 ) 3564 3565 return func 3566 3567 def arrayintersect_sql(self, expression: exp.ArrayIntersect) -> str: 3568 if expression.args.get("is_multiset") and len(expression.expressions) == 2: 3569 return self._array_bag_sql( 3570 self.ARRAY_INTERSECTION_CONDITION, 3571 expression.expressions[0], 3572 expression.expressions[1], 3573 ) 3574 return self.function_fallback_sql(expression) 3575 3576 def arrayexcept_sql(self, expression: exp.ArrayExcept) -> str: 3577 arr1, arr2 = expression.this, expression.expression 3578 if expression.args.get("is_multiset"): 3579 return self._array_bag_sql(self.ARRAY_EXCEPT_CONDITION, arr1, arr2) 3580 return self.sql( 3581 exp.replace_placeholders(self.ARRAY_EXCEPT_SET_TEMPLATE, arr1=arr1, arr2=arr2) 3582 ) 3583 3584 def arrayslice_sql(self, expression: exp.ArraySlice) -> str: 3585 """ 3586 Spark's SLICE uses a length, so normalize negative starts before computing 3587 the inclusive end index. Starts before the array produce an empty slice. 3588 3589 Transpiles Snowflake's ARRAY_SLICE (0-indexed, exclusive end) to DuckDB's 3590 ARRAY_SLICE (1-indexed, inclusive end) by wrapping start and end in CASE 3591 expressions that adjust the index at query time: 3592 - start: CASE WHEN start >= 0 THEN start + 1 ELSE start END 3593 - end: CASE WHEN end < 0 THEN end - 1 ELSE end END 3594 """ 3595 start, end = expression.args.get("start"), expression.args.get("end") 3596 3597 if expression.args.get("zero_based"): 3598 if start is not None: 3599 start = ( 3600 exp.case() 3601 .when( 3602 exp.GTE(this=start.copy(), expression=exp.Literal.number(0)), 3603 exp.Add(this=start.copy(), expression=exp.Literal.number(1)), 3604 ) 3605 .else_(start) 3606 ) 3607 if end is not None and not expression.args.get("is_length"): 3608 end = ( 3609 exp.case() 3610 .when( 3611 exp.LT(this=end.copy(), expression=exp.Literal.number(0)), 3612 exp.Sub(this=end.copy(), expression=exp.Literal.number(1)), 3613 ) 3614 .else_(end) 3615 ) 3616 3617 if expression.args.get("is_length") and start is not None and end is not None: 3618 if not start.is_int or start.to_py() < 0: 3619 size = exp.ArraySize(this=expression.this.copy()) 3620 start = ( 3621 exp.case() 3622 .when(start < -size, size + 1) 3623 .when(start < 0, size + start + 1) 3624 .else_(start) 3625 ) 3626 3627 if start.is_int and end.is_int: 3628 end = exp.Literal.number(start.to_py() + end.to_py() - 1) 3629 else: 3630 end = start + exp.cast(end, "BIGINT") - 1 3631 return self.func("ARRAY_SLICE", expression.this, start, end, expression.args.get("step")) 3632 3633 def arrayszip_sql(self, expression: exp.ArraysZip) -> str: 3634 args = expression.expressions 3635 3636 if not args: 3637 # Return [{}] - using MAP([], []) since DuckDB can't represent empty structs 3638 return self.sql(exp.array(exp.Map(keys=exp.array(), values=exp.array()))) 3639 3640 # Build placeholder values for template 3641 lengths = [exp.Length(this=arg) for arg in args] 3642 max_len = ( 3643 lengths[0] 3644 if len(lengths) == 1 3645 else exp.Greatest(this=lengths[0], expressions=lengths[1:]) 3646 ) 3647 3648 # Empty struct with same schema: {'$1': NULL, '$2': NULL, ...} 3649 empty_struct = exp.func( 3650 "STRUCT", 3651 *[ 3652 exp.PropertyEQ(this=exp.Literal.string(f"${i + 1}"), expression=exp.Null()) 3653 for i in range(len(args)) 3654 ], 3655 ) 3656 3657 # Struct for transform: {'$1': COALESCE(arr1, [])[__i + 1], ...} 3658 # COALESCE wrapping handles NULL arrays - prevents invalid NULL[i] syntax 3659 index = exp.column("__i") + 1 3660 transform_struct = exp.func( 3661 "STRUCT", 3662 *[ 3663 exp.PropertyEQ( 3664 this=exp.Literal.string(f"${i + 1}"), 3665 expression=exp.func("COALESCE", arg, exp.array())[index], 3666 ) 3667 for i, arg in enumerate(args) 3668 ], 3669 ) 3670 3671 result = exp.replace_placeholders( 3672 self.ARRAYS_ZIP_TEMPLATE.copy(), 3673 null_check=exp.or_(*[arg.is_(exp.Null()) for arg in args]), 3674 all_empty_check=exp.and_( 3675 *[ 3676 exp.EQ(this=exp.Length(this=arg), expression=exp.Literal.number(0)) 3677 for arg in args 3678 ] 3679 ), 3680 empty_struct=empty_struct, 3681 max_len=max_len, 3682 transform_struct=transform_struct, 3683 ) 3684 return self.sql(result) 3685 3686 def lower_sql(self, expression: exp.Lower) -> str: 3687 result_sql = self.func("LOWER", _cast_to_varchar(expression.this)) 3688 return _gen_with_cast_to_blob(self, expression, result_sql) 3689 3690 def upper_sql(self, expression: exp.Upper) -> str: 3691 result_sql = self.func("UPPER", _cast_to_varchar(expression.this)) 3692 return _gen_with_cast_to_blob(self, expression, result_sql) 3693 3694 def reverse_sql(self, expression: exp.Reverse) -> str: 3695 result_sql = self.func("REVERSE", _cast_to_varchar(expression.this)) 3696 return _gen_with_cast_to_blob(self, expression, result_sql) 3697 3698 def _left_right_sql(self, expression: exp.Left | exp.Right, func_name: str) -> str: 3699 arg = expression.this 3700 length = expression.expression 3701 is_binary = _is_binary(arg) 3702 3703 if is_binary: 3704 # LEFT/RIGHT(blob, n) becomes UNHEX(LEFT/RIGHT(HEX(blob), n * 2)) 3705 # Each byte becomes 2 hex chars, so multiply length by 2 3706 hex_arg = exp.Hex(this=arg) 3707 hex_length = exp.Mul(this=length, expression=exp.Literal.number(2)) 3708 result: exp.Expression = exp.Unhex( 3709 this=exp.Anonymous(this=func_name, expressions=[hex_arg, hex_length]) 3710 ) 3711 else: 3712 result = exp.Anonymous(this=func_name, expressions=[arg, length]) 3713 3714 if expression.args.get("negative_length_returns_empty"): 3715 empty: exp.Expression = exp.Literal.string("") 3716 if is_binary: 3717 empty = exp.Unhex(this=empty) 3718 result = exp.case().when(length < exp.Literal.number(0), empty).else_(result) 3719 3720 return self.sql(result) 3721 3722 def left_sql(self, expression: exp.Left) -> str: 3723 return self._left_right_sql(expression, "LEFT") 3724 3725 def right_sql(self, expression: exp.Right) -> str: 3726 return self._left_right_sql(expression, "RIGHT") 3727 3728 def rtrimmedlength_sql(self, expression: exp.RtrimmedLength) -> str: 3729 return self.func("LENGTH", exp.Trim(this=expression.this, position="TRAILING")) 3730 3731 def stuff_sql(self, expression: exp.Stuff) -> str: 3732 base = expression.this 3733 start = expression.args["start"] 3734 length = expression.args["length"] 3735 insertion = expression.expression 3736 is_binary = _is_binary(base) 3737 3738 if is_binary: 3739 # DuckDB's SUBSTRING doesn't accept BLOB; operate on the HEX string instead 3740 # (each byte = 2 hex chars), then UNHEX back to BLOB 3741 base = exp.Hex(this=base) 3742 insertion = exp.Hex(this=insertion) 3743 left = exp.Substring( 3744 this=base.copy(), 3745 start=exp.Literal.number(1), 3746 length=(start.copy() - exp.Literal.number(1)) * exp.Literal.number(2), 3747 ) 3748 right = exp.Substring( 3749 this=base.copy(), 3750 start=((start + length) - exp.Literal.number(1)) * exp.Literal.number(2) 3751 + exp.Literal.number(1), 3752 ) 3753 else: 3754 left = exp.Substring( 3755 this=base.copy(), 3756 start=exp.Literal.number(1), 3757 length=start.copy() - exp.Literal.number(1), 3758 ) 3759 right = exp.Substring(this=base.copy(), start=start + length) 3760 result: exp.Expr = exp.DPipe( 3761 this=exp.DPipe(this=left, expression=insertion), expression=right 3762 ) 3763 3764 if is_binary: 3765 result = exp.Unhex(this=result) 3766 3767 return self.sql(result) 3768 3769 def rand_sql(self, expression: exp.Rand) -> str: 3770 seed = expression.this 3771 if seed is not None: 3772 self.unsupported("RANDOM with seed is not supported in DuckDB") 3773 3774 lower = expression.args.get("lower") 3775 upper = expression.args.get("upper") 3776 3777 if lower and upper: 3778 # scale DuckDB's [0,1) to the specified range 3779 range_size = exp.paren(upper - lower) 3780 scaled = exp.Add(this=lower, expression=exp.func("random") * range_size) 3781 3782 # For now we assume that if bounds are set, return type is BIGINT. Snowflake/Teradata 3783 result = exp.cast(scaled, exp.DType.BIGINT) 3784 return self.sql(result) 3785 3786 # Default DuckDB behavior - just return RANDOM() as float 3787 return "RANDOM()" 3788 3789 def bytelength_sql(self, expression: exp.ByteLength) -> str: 3790 arg = expression.this 3791 3792 # Check if it's a text type (handles both literals and annotated expressions) 3793 if arg.is_type(*exp.DataType.TEXT_TYPES): 3794 return self.func("OCTET_LENGTH", exp.Encode(this=arg)) 3795 3796 # Default: pass through as-is (conservative for DuckDB, handles binary and unannotated) 3797 return self.func("OCTET_LENGTH", arg) 3798 3799 def base64encode_sql(self, expression: exp.Base64Encode) -> str: 3800 # DuckDB TO_BASE64 requires BLOB input 3801 # Snowflake BASE64_ENCODE accepts both VARCHAR and BINARY - for VARCHAR it implicitly 3802 # encodes UTF-8 bytes. We add ENCODE unless the input is a binary type. 3803 result = expression.this 3804 3805 # Check if input is a string type - ENCODE only accepts VARCHAR 3806 if result.is_type(*exp.DataType.TEXT_TYPES): 3807 result = exp.Encode(this=result) 3808 3809 result = exp.ToBase64(this=result) 3810 3811 max_line_length = expression.args.get("max_line_length") 3812 alphabet = expression.args.get("alphabet") 3813 3814 # Handle custom alphabet by replacing standard chars with custom ones 3815 result = _apply_base64_alphabet_replacements(result, alphabet) 3816 3817 # Handle max_line_length by inserting newlines every N characters 3818 line_length = ( 3819 t.cast(int, max_line_length.to_py()) 3820 if isinstance(max_line_length, exp.Literal) and max_line_length.is_number 3821 else 0 3822 ) 3823 if line_length > 0: 3824 newline = exp.Chr(expressions=[exp.Literal.number(10)]) 3825 result = exp.Trim( 3826 this=exp.RegexpReplace( 3827 this=result, 3828 expression=exp.Literal.string(f"(.{{{line_length}}})"), 3829 replacement=exp.Concat(expressions=[exp.Literal.string("\\1"), newline.copy()]), 3830 ), 3831 expression=newline, 3832 position="TRAILING", 3833 ) 3834 3835 return self.sql(result) 3836 3837 def hex_sql(self, expression: exp.Hex) -> str: 3838 case = expression.args.get("case") 3839 3840 if not case: 3841 return self.func("HEX", expression.this) 3842 3843 hex_expr = exp.Hex(this=expression.this) 3844 return self.sql( 3845 exp.case() 3846 .when(case.is_(exp.null()), exp.null()) 3847 .when(case.copy().eq(0), exp.Lower(this=hex_expr.copy())) 3848 .else_(hex_expr) 3849 ) 3850 3851 def replace_sql(self, expression: exp.Replace) -> str: 3852 result_sql = self.func( 3853 "REPLACE", 3854 _cast_to_varchar(expression.this), 3855 _cast_to_varchar(expression.expression), 3856 _cast_to_varchar(expression.args.get("replacement")), 3857 ) 3858 return _gen_with_cast_to_blob(self, expression, result_sql) 3859 3860 def _bitwise_op(self, expression: exp.Binary, op: str) -> str: 3861 _prepare_binary_bitwise_args(expression) 3862 result_sql = self.binary(expression, op) 3863 return _gen_with_cast_to_blob(self, expression, result_sql) 3864 3865 def bitwisexor_sql(self, expression: exp.BitwiseXor) -> str: 3866 _prepare_binary_bitwise_args(expression) 3867 result_sql = self.func("XOR", expression.this, expression.expression) 3868 return _gen_with_cast_to_blob(self, expression, result_sql) 3869 3870 def objectinsert_sql(self, expression: exp.ObjectInsert) -> str: 3871 this = expression.this 3872 key = expression.args.get("key") 3873 key_sql = key.name if isinstance(key, exp.Expr) else "" 3874 value_sql = self.sql(expression, "value") 3875 3876 kv_sql = f"{key_sql} := {value_sql}" 3877 3878 # If the input struct is empty e.g. transpiling OBJECT_INSERT(OBJECT_CONSTRUCT(), key, value) from Snowflake 3879 # then we can generate STRUCT_PACK which will build it since STRUCT_INSERT({}, key := value) is not valid DuckDB 3880 if isinstance(this, exp.Struct) and not this.expressions: 3881 return self.func("STRUCT_PACK", kv_sql) 3882 3883 return self.func("STRUCT_INSERT", this, kv_sql) 3884 3885 def mapcat_sql(self, expression: exp.MapCat) -> str: 3886 result = exp.replace_placeholders( 3887 self.MAPCAT_TEMPLATE.copy(), 3888 map1=expression.this, 3889 map2=expression.expression, 3890 ) 3891 return self.sql(result) 3892 3893 def mapcontainskey_sql(self, expression: exp.MapContainsKey) -> str: 3894 return self.func( 3895 "ARRAY_CONTAINS", exp.func("MAP_KEYS", expression.args["key"]), expression.this 3896 ) 3897 3898 def mapdelete_sql(self, expression: exp.MapDelete) -> str: 3899 map_arg = expression.this 3900 keys_to_delete = expression.expressions 3901 3902 x_dot_key = exp.Dot(this=exp.to_identifier("x"), expression=exp.to_identifier("key")) 3903 3904 lambda_expr = exp.Lambda( 3905 this=exp.In(this=x_dot_key, expressions=keys_to_delete).not_(), 3906 expressions=[exp.to_identifier("x")], 3907 ) 3908 result = exp.func( 3909 "MAP_FROM_ENTRIES", 3910 exp.ArrayFilter(this=exp.func("MAP_ENTRIES", map_arg), expression=lambda_expr), 3911 ) 3912 return self.sql(result) 3913 3914 def mappick_sql(self, expression: exp.MapPick) -> str: 3915 map_arg = expression.this 3916 keys_to_pick = expression.expressions 3917 3918 x_dot_key = exp.Dot(this=exp.to_identifier("x"), expression=exp.to_identifier("key")) 3919 3920 if len(keys_to_pick) == 1 and keys_to_pick[0].is_type(exp.DType.ARRAY): 3921 lambda_expr = exp.Lambda( 3922 this=exp.func("ARRAY_CONTAINS", keys_to_pick[0], x_dot_key), 3923 expressions=[exp.to_identifier("x")], 3924 ) 3925 else: 3926 lambda_expr = exp.Lambda( 3927 this=exp.In(this=x_dot_key, expressions=keys_to_pick), 3928 expressions=[exp.to_identifier("x")], 3929 ) 3930 3931 result = exp.func( 3932 "MAP_FROM_ENTRIES", 3933 exp.func("LIST_FILTER", exp.func("MAP_ENTRIES", map_arg), lambda_expr), 3934 ) 3935 return self.sql(result) 3936 3937 def mapsize_sql(self, expression: exp.MapSize) -> str: 3938 return self.func("CARDINALITY", expression.this) 3939 3940 @unsupported_args("update_flag") 3941 def mapinsert_sql(self, expression: exp.MapInsert) -> str: 3942 map_arg = expression.this 3943 key = expression.args.get("key") 3944 value = expression.args.get("value") 3945 3946 map_type = map_arg.type 3947 3948 if value is not None: 3949 if map_type and map_type.expressions and len(map_type.expressions) > 1: 3950 # Extract the value type from MAP(key_type, value_type) 3951 value_type = map_type.expressions[1] 3952 # Cast value to match the map's value type to avoid type conflicts 3953 value = exp.cast(value, value_type) 3954 # else: polymorphic MAP case - no type parameters available, use value as-is 3955 3956 # Create a single-entry map for the new key-value pair 3957 new_entry_struct = exp.Struct(expressions=[exp.PropertyEQ(this=key, expression=value)]) 3958 new_entry: exp.Expression = exp.ToMap(this=new_entry_struct) 3959 3960 # Use MAP_CONCAT to merge the original map with the new entry 3961 # This automatically handles both insert and update cases 3962 result = exp.func("MAP_CONCAT", map_arg, new_entry) 3963 3964 return self.sql(result) 3965 3966 def startswith_sql(self, expression: exp.StartsWith) -> str: 3967 return self.func( 3968 "STARTS_WITH", 3969 _cast_to_varchar(expression.this), 3970 _cast_to_varchar(expression.expression), 3971 ) 3972 3973 def space_sql(self, expression: exp.Space) -> str: 3974 # DuckDB's REPEAT requires BIGINT for the count parameter 3975 return self.sql( 3976 exp.Repeat( 3977 this=exp.Literal.string(" "), 3978 times=exp.cast(expression.this, exp.DType.BIGINT), 3979 ) 3980 ) 3981 3982 def tablefromrows_sql(self, expression: exp.TableFromRows) -> str: 3983 # For GENERATOR, unwrap TABLE() - just emit the Generator (becomes RANGE) 3984 if isinstance(expression.this, exp.Generator): 3985 # Preserve alias, joins, and other table-level args 3986 table = exp.Table( 3987 this=expression.this, 3988 alias=expression.args.get("alias"), 3989 joins=expression.args.get("joins"), 3990 ) 3991 return self.sql(table) 3992 3993 return super().tablefromrows_sql(expression) 3994 3995 def unnest_sql(self, expression: exp.Unnest) -> str: 3996 explode_array = expression.args.get("explode_array") 3997 if explode_array: 3998 # In BigQuery, UNNESTing a nested array leads to explosion of the top-level array & struct 3999 # This is transpiled to DDB by transforming "FROM UNNEST(...)" to "FROM (SELECT UNNEST(..., max_depth => 2))" 4000 expression.expressions.append( 4001 exp.Kwarg(this=exp.var("max_depth"), expression=exp.Literal.number(2)) 4002 ) 4003 4004 # If BQ's UNNEST is aliased, we transform it from a column alias to a table alias in DDB 4005 alias = expression.args.get("alias") 4006 if isinstance(alias, exp.TableAlias): 4007 expression.set("alias", None) 4008 if alias.columns: 4009 alias = exp.TableAlias(this=seq_get(alias.columns, 0)) 4010 4011 unnest_sql = super().unnest_sql(expression) 4012 select = exp.Select(expressions=[unnest_sql]).subquery(alias) 4013 return self.sql(select) 4014 4015 return super().unnest_sql(expression) 4016 4017 def arrayagg_sql(self, expression: exp.ArrayAgg) -> str: 4018 if isinstance(expression.this, exp.Limit): 4019 self.unsupported("LIMIT inside ARRAY_AGG is not supported in DuckDB") 4020 4021 return super().arrayagg_sql(expression) 4022 4023 def ignorenulls_sql(self, expression: exp.IgnoreNulls) -> str: 4024 this = expression.this 4025 4026 if isinstance(this, self.IGNORE_RESPECT_NULLS_WINDOW_FUNCTIONS): 4027 # DuckDB should render IGNORE NULLS only for the general-purpose 4028 # window functions that accept it e.g. FIRST_VALUE(... IGNORE NULLS) OVER (...) 4029 return super().ignorenulls_sql(expression) 4030 4031 # For ARRAY_AGG(expr IGNORE NULLS ...), convert IGNORE NULLS to a 4032 # FILTER(WHERE expr IS NOT NULL) clause by setting nulls_excluded on 4033 # the ArrayAgg. The existing _add_arrayagg_null_filter method will 4034 # emit the FILTER clause during arrayagg_sql / withingroup_sql. 4035 if isinstance(this, exp.ArrayAgg): 4036 this.set("nulls_excluded", True) 4037 return self.sql(this) 4038 4039 if isinstance(this, exp.First): 4040 this = exp.AnyValue(this=this.this) 4041 4042 if not isinstance(this, (exp.AnyValue, exp.ApproxQuantiles)): 4043 self.unsupported("IGNORE NULLS is not supported for non-window functions.") 4044 4045 return self.sql(this) 4046 4047 def split_sql(self, expression: exp.Split) -> str: 4048 base_func = exp.func("STR_SPLIT", expression.this, expression.expression) 4049 4050 case_expr = exp.case().else_(base_func) 4051 needs_case = False 4052 4053 if expression.args.get("null_returns_null"): 4054 case_expr = case_expr.when(expression.expression.is_(exp.null()), exp.null()) 4055 needs_case = True 4056 4057 if expression.args.get("empty_delimiter_returns_whole"): 4058 # When delimiter is empty string, return input string as single array element 4059 array_with_input = exp.array(expression.this) 4060 case_expr = case_expr.when( 4061 expression.expression.eq(exp.Literal.string("")), array_with_input 4062 ) 4063 needs_case = True 4064 4065 return self.sql(case_expr if needs_case else base_func) 4066 4067 def splitpart_sql(self, expression: exp.SplitPart) -> str: 4068 string_arg = expression.this 4069 delimiter_arg = expression.args.get("delimiter") 4070 part_index_arg = expression.args.get("part_index") 4071 4072 if delimiter_arg and part_index_arg: 4073 # Handle Snowflake's "index 0 and 1 both return first element" behavior 4074 if expression.args.get("part_index_zero_as_one"): 4075 # Convert 0 to 1 for compatibility 4076 4077 part_index_arg = exp.Paren( 4078 this=exp.case() 4079 .when(part_index_arg.eq(exp.Literal.number("0")), exp.Literal.number("1")) 4080 .else_(part_index_arg) 4081 ) 4082 4083 # Use Anonymous to avoid recursion 4084 base_func_expr: exp.Expr = exp.Anonymous( 4085 this="SPLIT_PART", expressions=[string_arg, delimiter_arg, part_index_arg] 4086 ) 4087 needs_case_transform = False 4088 case_expr = exp.case().else_(base_func_expr) 4089 4090 if expression.args.get("empty_delimiter_returns_whole"): 4091 # When delimiter is empty string: 4092 # - Return whole string if part_index is 1 or -1 4093 # - Return empty string otherwise 4094 empty_case = exp.Paren( 4095 this=exp.case() 4096 .when( 4097 exp.or_( 4098 part_index_arg.eq(exp.Literal.number("1")), 4099 part_index_arg.eq(exp.Literal.number("-1")), 4100 ), 4101 string_arg, 4102 ) 4103 .else_(exp.Literal.string("")) 4104 ) 4105 4106 case_expr = case_expr.when(delimiter_arg.eq(exp.Literal.string("")), empty_case) 4107 needs_case_transform = True 4108 4109 """ 4110 Output looks something like this: 4111 4112 CASE 4113 WHEN delimiter is '' THEN 4114 ( 4115 CASE 4116 WHEN adjusted_part_index = 1 OR adjusted_part_index = -1 THEN input 4117 ELSE '' END 4118 ) 4119 ELSE SPLIT_PART(input, delimiter, adjusted_part_index) 4120 END 4121 4122 """ 4123 return self.sql(case_expr if needs_case_transform else base_func_expr) 4124 4125 return self.function_fallback_sql(expression) 4126 4127 def respectnulls_sql(self, expression: exp.RespectNulls) -> str: 4128 if isinstance(expression.this, self.IGNORE_RESPECT_NULLS_WINDOW_FUNCTIONS): 4129 # DuckDB should render RESPECT NULLS only for the general-purpose 4130 # window functions that accept it e.g. FIRST_VALUE(... RESPECT NULLS) OVER (...) 4131 return super().respectnulls_sql(expression) 4132 4133 self.unsupported("RESPECT NULLS is not supported for non-window functions.") 4134 return self.sql(expression, "this") 4135 4136 def arraytostring_sql(self, expression: exp.ArrayToString) -> str: 4137 null = expression.args.get("null") 4138 4139 if expression.args.get("null_is_empty"): 4140 x = exp.to_identifier("x") 4141 list_transform = exp.Transform( 4142 this=expression.this.copy(), 4143 expression=exp.Lambda( 4144 this=exp.Coalesce( 4145 this=exp.cast(x, "TEXT"), expressions=[exp.Literal.string("")] 4146 ), 4147 expressions=[x], 4148 ), 4149 ) 4150 array_to_string = exp.ArrayToString( 4151 this=list_transform, expression=expression.expression 4152 ) 4153 if expression.args.get("null_delim_is_null"): 4154 return self.sql( 4155 exp.case() 4156 .when(expression.expression.copy().is_(exp.null()), exp.null()) 4157 .else_(array_to_string) 4158 ) 4159 return self.sql(array_to_string) 4160 4161 if null: 4162 x = exp.to_identifier("x") 4163 return self.sql( 4164 exp.ArrayToString( 4165 this=exp.Transform( 4166 this=expression.this, 4167 expression=exp.Lambda( 4168 this=exp.Coalesce(this=x, expressions=[null]), 4169 expressions=[x], 4170 ), 4171 ), 4172 expression=expression.expression, 4173 ) 4174 ) 4175 4176 return self.func("ARRAY_TO_STRING", expression.this, expression.expression) 4177 4178 def concatws_sql(self, expression: exp.ConcatWs) -> str: 4179 # DuckDB-specific: handle binary types using DPipe (||) operator 4180 separator = seq_get(expression.expressions, 0) 4181 args = expression.expressions[1:] 4182 4183 if any(_is_binary(arg) for arg in [separator, *args]): 4184 result = args[0] 4185 for arg in args[1:]: 4186 result = exp.DPipe( 4187 this=exp.DPipe(this=result, expression=separator), expression=arg 4188 ) 4189 return self.sql(result) 4190 4191 if expression.args.get("flatten") and any( 4192 isinstance(arg, exp.Array) or arg.is_type(exp.DType.ARRAY) for arg in args 4193 ): 4194 arrays = [] 4195 for arg in args: 4196 if isinstance(arg, exp.Array) or arg.is_type(exp.DType.ARRAY): 4197 arg = exp.cast(arg, "TEXT[]") 4198 else: 4199 if (not arg.type or arg.is_type(exp.DType.UNKNOWN)) and not isinstance( 4200 arg, exp.CONSTANTS 4201 ): 4202 self.unsupported( 4203 "Cannot transpile CONCAT_WS with unknown argument types to DuckDB." 4204 ) 4205 arg = exp.array(exp.cast(arg, exp.DType.TEXT)) 4206 4207 arrays.append(arg) 4208 4209 array = ( 4210 exp.ArrayConcat(this=arrays[0], expressions=arrays[1:]) 4211 if len(arrays) > 1 4212 else arrays[0] 4213 ) 4214 return self.func("CONCAT_WS", separator, self.func("ARRAY_TO_STRING", array, separator)) 4215 4216 return super().concatws_sql(expression) 4217 4218 def _regexp_extract_sql(self, expression: exp.RegexpExtract | exp.RegexpExtractAll) -> str: 4219 this = expression.this 4220 group = expression.args.get("group") 4221 params = expression.args.get("parameters") 4222 position = expression.args.get("position") 4223 occurrence = expression.args.get("occurrence") 4224 null_if_pos_overflow = expression.args.get("null_if_pos_overflow") 4225 4226 # Handle Snowflake's 'e' flag: it enables capture group extraction 4227 # In DuckDB, this is controlled by the group parameter directly 4228 if params and params.is_string and "e" in params.name: 4229 params = exp.Literal.string(params.name.replace("e", "")) 4230 4231 validated_flags = self._validate_regexp_flags(params, supported_flags="cims") 4232 4233 # Strip default group when no following params (DuckDB default is same as group=0) 4234 if ( 4235 not validated_flags 4236 and group 4237 and group.name == str(self.dialect.REGEXP_EXTRACT_DEFAULT_GROUP) 4238 ): 4239 group = None 4240 4241 flags_expr = exp.Literal.string(validated_flags) if validated_flags else None 4242 4243 # use substring to handle position argument 4244 if position and (not position.is_int or position.to_py() > 1): 4245 this = exp.Substring(this=this, start=position) 4246 4247 if null_if_pos_overflow: 4248 this = exp.Nullif(this=this, expression=exp.Literal.string("")) 4249 4250 is_extract_all = isinstance(expression, exp.RegexpExtractAll) 4251 non_single_occurrence = occurrence and (not occurrence.is_int or occurrence.to_py() > 1) 4252 4253 if is_extract_all or non_single_occurrence: 4254 name = "REGEXP_EXTRACT_ALL" 4255 else: 4256 name = "REGEXP_EXTRACT" 4257 4258 result: exp.Expr = exp.Anonymous( 4259 this=name, expressions=[this, expression.expression, group, flags_expr] 4260 ) 4261 4262 # Array slicing for REGEXP_EXTRACT_ALL with occurrence 4263 if is_extract_all and non_single_occurrence: 4264 result = exp.Bracket(this=result, expressions=[exp.Slice(this=occurrence)]) 4265 # ARRAY_EXTRACT for REGEXP_EXTRACT with occurrence > 1 4266 elif non_single_occurrence: 4267 result = exp.Anonymous(this="ARRAY_EXTRACT", expressions=[result, occurrence]) 4268 4269 return self.sql(result) 4270 4271 def regexpextract_sql(self, expression: exp.RegexpExtract) -> str: 4272 return self._regexp_extract_sql(expression) 4273 4274 def regexpextractall_sql(self, expression: exp.RegexpExtractAll) -> str: 4275 return self._regexp_extract_sql(expression) 4276 4277 def regexpinstr_sql(self, expression: exp.RegexpInstr) -> str: 4278 this = expression.this 4279 pattern = expression.expression 4280 position = expression.args.get("position") 4281 orig_occ = expression.args.get("occurrence") 4282 occurrence = orig_occ or exp.Literal.number(1) 4283 option = expression.args.get("option") 4284 parameters = expression.args.get("parameters") 4285 4286 validated_flags = self._validate_regexp_flags(parameters, supported_flags="ims") 4287 # Checked before the flags are embedded, which would make the pattern non-empty 4288 is_empty = pattern.copy().eq(exp.Literal.string("")) 4289 if validated_flags: 4290 pattern = exp.Concat(expressions=[exp.Literal.string(f"(?{validated_flags})"), pattern]) 4291 4292 # Handle starting position offset 4293 pos_offset: exp.Expr = exp.Literal.number(0) 4294 if position and (not position.is_int or position.to_py() > 1): 4295 this = exp.Substring(this=this, start=position) 4296 pos_offset = position - exp.Literal.number(1) 4297 4298 # Helper: LIST_SUM(LIST_TRANSFORM(list[1:end], x -> LENGTH(x))) 4299 def sum_lengths(func_name: str, end: exp.Expr) -> exp.Expr: 4300 lst = exp.Bracket( 4301 this=exp.Anonymous(this=func_name, expressions=[this, pattern]), 4302 expressions=[exp.Slice(this=exp.Literal.number(1), expression=end)], 4303 offset=1, 4304 ) 4305 transform = exp.Anonymous( 4306 this="LIST_TRANSFORM", 4307 expressions=[ 4308 lst, 4309 exp.Lambda( 4310 this=exp.Length(this=exp.to_identifier("x")), 4311 expressions=[exp.to_identifier("x")], 4312 ), 4313 ], 4314 ) 4315 return exp.Coalesce( 4316 this=exp.Anonymous(this="LIST_SUM", expressions=[transform]), 4317 expressions=[exp.Literal.number(0)], 4318 ) 4319 4320 # Position = 1 + sum(split_lengths[1:occ]) + sum(match_lengths[1:occ-1]) + offset 4321 base_pos: exp.Expr = ( 4322 exp.Literal.number(1) 4323 + sum_lengths("STRING_SPLIT_REGEX", occurrence) 4324 + sum_lengths("REGEXP_EXTRACT_ALL", occurrence - exp.Literal.number(1)) 4325 + pos_offset 4326 ) 4327 4328 # option=1: add match length for end position 4329 if option and option.is_int and option.to_py() == 1: 4330 match_at_occ = exp.Bracket( 4331 this=exp.Anonymous(this="REGEXP_EXTRACT_ALL", expressions=[this, pattern]), 4332 expressions=[occurrence], 4333 offset=1, 4334 ) 4335 base_pos = base_pos + exp.Coalesce( 4336 this=exp.Length(this=match_at_occ), expressions=[exp.Literal.number(0)] 4337 ) 4338 4339 # NULL checks for all provided arguments 4340 # .copy() is used strictly because .is_() alters the node's parent pointer, mutating the parsed AST 4341 null_args = [ 4342 expression.this, 4343 expression.expression, 4344 position, 4345 orig_occ, 4346 option, 4347 parameters, 4348 ] 4349 null_checks = [arg.copy().is_(exp.Null()) for arg in null_args if arg] 4350 4351 matches = exp.Anonymous(this="REGEXP_EXTRACT_ALL", expressions=[this, pattern]) 4352 4353 return self.sql( 4354 exp.case() 4355 .when(exp.or_(*null_checks), exp.Null()) 4356 .when(is_empty, exp.Literal.number(0)) 4357 .when(exp.Length(this=matches) < occurrence, exp.Literal.number(0)) 4358 .else_(base_pos) 4359 ) 4360 4361 @unsupported_args("culture") 4362 def numbertostr_sql(self, expression: exp.NumberToStr) -> str: 4363 fmt = expression.args.get("format") 4364 if fmt and fmt.is_int: 4365 return self.func("FORMAT", f"'{{:,.{fmt.name}f}}'", expression.this) 4366 4367 self.unsupported("Only integer formats are supported by NumberToStr") 4368 return self.function_fallback_sql(expression) 4369 4370 def autoincrementcolumnconstraint_sql(self, _) -> str: 4371 self.unsupported("The AUTOINCREMENT column constraint is not supported by DuckDB") 4372 return "" 4373 4374 def aliases_sql(self, expression: exp.Aliases) -> str: 4375 this = expression.this 4376 if isinstance(this, exp.Posexplode): 4377 return self.posexplode_sql(this) 4378 4379 return super().aliases_sql(expression) 4380 4381 def posexplode_sql(self, expression: exp.Posexplode) -> str: 4382 this = expression.this 4383 parent = expression.parent 4384 4385 # The default Spark aliases are "pos" and "col", unless specified otherwise 4386 pos, col = exp.to_identifier("pos"), exp.to_identifier("col") 4387 4388 if isinstance(parent, exp.Aliases): 4389 # Column case: SELECT POSEXPLODE(col) [AS (a, b)] 4390 pos, col = parent.expressions 4391 elif isinstance(parent, exp.Table): 4392 # Table case: SELECT * FROM POSEXPLODE(col) [AS (a, b)] 4393 alias = parent.args.get("alias") 4394 if alias: 4395 pos, col = alias.columns or [pos, col] 4396 alias.pop() 4397 4398 # Translate POSEXPLODE to UNNEST + GENERATE_SUBSCRIPTS 4399 # Note: In Spark pos is 0-indexed, but in DuckDB it's 1-indexed, so we subtract 1 from GENERATE_SUBSCRIPTS 4400 unnest_sql = self.sql(exp.Unnest(expressions=[this], alias=col)) 4401 gen_subscripts = self.sql( 4402 exp.Alias( 4403 this=exp.Anonymous( 4404 this="GENERATE_SUBSCRIPTS", expressions=[this, exp.Literal.number(1)] 4405 ) 4406 - exp.Literal.number(1), 4407 alias=pos, 4408 ) 4409 ) 4410 4411 posexplode_sql = self.format_args(gen_subscripts, unnest_sql) 4412 4413 if isinstance(parent, exp.From) or (parent and isinstance(parent.parent, exp.From)): 4414 # SELECT * FROM POSEXPLODE(col) -> SELECT * FROM (SELECT GENERATE_SUBSCRIPTS(...), UNNEST(...)) 4415 return self.sql(exp.Subquery(this=exp.Select(expressions=[posexplode_sql]))) 4416 4417 return posexplode_sql 4418 4419 def addmonths_sql(self, expression: exp.AddMonths) -> str: 4420 """ 4421 Handles three key issues: 4422 1. Float/decimal months: e.g., Snowflake rounds, whereas DuckDB INTERVAL requires integers 4423 2. End-of-month preservation: If input is last day of month, result is last day of result month 4424 3. Type preservation: Maintains DATE/TIMESTAMPTZ types (DuckDB defaults to TIMESTAMP) 4425 """ 4426 from sqlglot.optimizer.annotate_types import annotate_types 4427 4428 this = expression.this 4429 if not this.type: 4430 this = annotate_types(this, dialect=self.dialect) 4431 4432 if this.is_type(*exp.DataType.TEXT_TYPES): 4433 this = exp.Cast(this=this, to=exp.DataType(this=exp.DType.TIMESTAMP)) 4434 4435 # Detect float/decimal months to apply rounding (Snowflake behavior) 4436 # DuckDB INTERVAL syntax doesn't support non-integer expressions, so use TO_MONTHS 4437 months_expr = expression.expression 4438 if not months_expr.type: 4439 months_expr = annotate_types(months_expr, dialect=self.dialect) 4440 4441 # Build interval or to_months expression based on type 4442 # Float/decimal case: Round and use TO_MONTHS(CAST(ROUND(value) AS INT)) 4443 interval_or_to_months = ( 4444 exp.func("TO_MONTHS", exp.cast(exp.func("ROUND", months_expr), "INT")) 4445 if months_expr.is_type( 4446 exp.DType.FLOAT, 4447 exp.DType.DOUBLE, 4448 exp.DType.DECIMAL, 4449 ) 4450 # Integer case: standard INTERVAL N MONTH syntax 4451 else exp.Interval(this=months_expr, unit=exp.var("MONTH")) 4452 ) 4453 4454 date_add_expr = exp.Add(this=this, expression=interval_or_to_months) 4455 4456 # Apply end-of-month preservation if Snowflake flag is set 4457 # CASE WHEN LAST_DAY(date) = date THEN LAST_DAY(result) ELSE result END 4458 preserve_eom = expression.args.get("preserve_end_of_month") 4459 result_expr = ( 4460 exp.case() 4461 .when( 4462 exp.EQ(this=exp.func("LAST_DAY", this), expression=this), 4463 exp.func("LAST_DAY", date_add_expr), 4464 ) 4465 .else_(date_add_expr) 4466 if preserve_eom 4467 else date_add_expr 4468 ) 4469 4470 # DuckDB's DATE_ADD function returns TIMESTAMP/DATETIME by default, even when the input is DATE 4471 # To match for example Snowflake's ADD_MONTHS behavior (which preserves the input type) 4472 # We need to cast the result back to the original type when the input is DATE or TIMESTAMPTZ 4473 # Example: ADD_MONTHS('2023-01-31'::date, 1) should return DATE, not TIMESTAMP 4474 if this.is_type(exp.DType.DATE, exp.DType.TIMESTAMPTZ): 4475 return self.sql(exp.Cast(this=result_expr, to=this.type)) 4476 return self.sql(result_expr) 4477 4478 def format_sql(self, expression: exp.Format) -> str: 4479 if expression.name.lower() == "%s" and len(expression.expressions) == 1: 4480 return self.func("FORMAT", "'{}'", expression.expressions[0]) 4481 4482 return self.function_fallback_sql(expression) 4483 4484 def hexstring_sql( 4485 self, expression: exp.HexString, binary_function_repr: str | None = None 4486 ) -> str: 4487 # UNHEX('FF') correctly produces blob \xFF in DuckDB 4488 return super().hexstring_sql(expression, binary_function_repr="UNHEX") 4489 4490 def datetrunc_sql(self, expression: exp.DateTrunc) -> str: 4491 unit = expression.args.get("unit") 4492 date = expression.this 4493 4494 week_start = _week_trunc_start_dow(unit) 4495 unit = unit_to_str(expression) 4496 4497 if week_start: 4498 result = self.sql( 4499 _build_week_trunc_expression(date, week_start, preserve_start_day=True) 4500 ) 4501 else: 4502 result = self.func("DATE_TRUNC", unit, date) 4503 4504 if ( 4505 expression.args.get("input_type_preserved") 4506 and date.is_type(*exp.DataType.TEMPORAL_TYPES) 4507 and not (is_date_unit(unit) and date.is_type(exp.DType.DATE)) 4508 ): 4509 return self.sql(exp.Cast(this=result, to=date.type)) 4510 4511 return result 4512 4513 def datetimetrunc_sql(self, expression: exp.DatetimeTrunc) -> str: 4514 this = exp.cast(expression.this, exp.DType.DATETIME) 4515 week_start = _week_trunc_start_dow(expression.args.get("unit")) 4516 if week_start: 4517 return self.sql( 4518 _build_week_trunc_expression( 4519 this, week_start, preserve_start_day=True, cast_to_date=False 4520 ) 4521 ) 4522 4523 return self.func("DATE_TRUNC", unit_to_str(expression), this) 4524 4525 def timestamptrunc_sql(self, expression: exp.TimestampTrunc) -> str: 4526 zone = expression.args.get("zone") 4527 timestamp = expression.this 4528 week_start = _week_trunc_start_dow(expression.args.get("unit")) 4529 4530 # The week start emulation below is exact, so avoid weekstart_unit_to_str's degrade warning 4531 unit = unit_to_str(expression) if week_start else weekstart_unit_to_str(self, expression) 4532 date_unit = is_date_unit(unit) or bool(week_start) 4533 4534 def _trunc_expr(this: exp.Expr) -> exp.Expr: 4535 if week_start: 4536 return _build_week_trunc_expression( 4537 this, week_start, preserve_start_day=True, cast_to_date=False 4538 ) 4539 return exp.func("DATE_TRUNC", unit, this) 4540 4541 if date_unit and zone: 4542 # BigQuery's TIMESTAMP_TRUNC with timezone truncates in the target timezone and returns as UTC. 4543 # Double AT TIME ZONE needed for BigQuery compatibility: 4544 # 1. First AT TIME ZONE: ensures truncation happens in the target timezone 4545 # 2. Second AT TIME ZONE: converts the DATE result back to TIMESTAMPTZ (preserving time component) 4546 timestamp = exp.AtTimeZone(this=timestamp, zone=zone) 4547 trunced = _trunc_expr(timestamp) 4548 if isinstance(trunced, exp.DateAdd): 4549 # Parenthesize so the trailing AT TIME ZONE binds to the whole shifted expression 4550 trunced = exp.Paren(this=trunced) 4551 return self.sql(exp.AtTimeZone(this=trunced, zone=zone)) 4552 4553 result = self.sql(_trunc_expr(timestamp)) 4554 if expression.args.get("input_type_preserved"): 4555 if timestamp.type and timestamp.is_type(exp.DType.TIME, exp.DType.TIMETZ): 4556 dummy_date = exp.Cast( 4557 this=exp.Literal.string("1970-01-01"), 4558 to=exp.DataType(this=exp.DType.DATE), 4559 ) 4560 date_time = exp.Add(this=dummy_date, expression=timestamp) 4561 result = self.func("DATE_TRUNC", unit, date_time) 4562 return self.sql(exp.Cast(this=result, to=timestamp.type)) 4563 4564 if timestamp.is_type(*exp.DataType.TEMPORAL_TYPES) and not ( 4565 date_unit and timestamp.is_type(exp.DType.DATE) 4566 ): 4567 return self.sql(exp.Cast(this=result, to=timestamp.type)) 4568 4569 return result 4570 4571 def trim_sql(self, expression: exp.Trim) -> str: 4572 expression.this.replace(_cast_to_varchar(expression.this)) 4573 if expression.expression: 4574 expression.expression.replace(_cast_to_varchar(expression.expression)) 4575 4576 result_sql = super().trim_sql(expression) 4577 return _gen_with_cast_to_blob(self, expression, result_sql) 4578 4579 def round_sql(self, expression: exp.Round) -> str: 4580 this = expression.this 4581 decimals = expression.args.get("decimals") 4582 truncate = expression.args.get("truncate") 4583 4584 # DuckDB requires the scale (decimals) argument to be an INT 4585 # Some dialects (e.g., Snowflake) allow non-integer scales and cast to an integer internally 4586 if decimals is not None and expression.args.get("casts_non_integer_decimals"): 4587 if not (decimals.is_int or decimals.is_type(*exp.DataType.INTEGER_TYPES)): 4588 decimals = exp.cast(decimals, exp.DType.INT) 4589 4590 func = "ROUND" 4591 if truncate: 4592 # BigQuery uses ROUND_HALF_EVEN; Snowflake uses HALF_TO_EVEN 4593 if truncate.this in ("ROUND_HALF_EVEN", "HALF_TO_EVEN"): 4594 func = "ROUND_EVEN" 4595 truncate = None 4596 # BigQuery uses ROUND_HALF_AWAY_FROM_ZERO; Snowflake uses HALF_AWAY_FROM_ZERO 4597 elif truncate.this in ("ROUND_HALF_AWAY_FROM_ZERO", "HALF_AWAY_FROM_ZERO"): 4598 truncate = None 4599 4600 return self.func(func, this, decimals, truncate) 4601 4602 def trycast_sql(self, expression: exp.TryCast) -> str: 4603 to = expression.to 4604 to_type = to.this 4605 src = expression.this 4606 4607 if ( 4608 expression.args.get("null_on_text_overflow") 4609 and to_type in exp.DataType.TEXT_TYPES 4610 and to.expressions 4611 ): 4612 return self.sql( 4613 exp.case() 4614 .when( 4615 exp.LTE(this=exp.func("LENGTH", src), expression=to.expressions[0].this), 4616 exp.cast(src, "TEXT"), 4617 ) 4618 .else_(exp.Null()) 4619 ) 4620 elif to_type == exp.DType.DATE and expression.args.get("probe_date_format"): 4621 slash_strptime = exp.cast( 4622 exp.func("TRY_STRPTIME", src, exp.Literal.string(self._TRYCAST_DATE_SLASH_FMT)), 4623 "DATE", 4624 ) 4625 mon_strptime = exp.cast( 4626 exp.func("TRY_STRPTIME", src, exp.Literal.string(self._TRYCAST_DATE_MON_FMT)), 4627 "DATE", 4628 ) 4629 return self.sql( 4630 exp.case() 4631 .when(exp.func("CONTAINS", src, exp.Literal.string("/")), slash_strptime) 4632 .when( 4633 exp.RegexpLike(this=src, expression=exp.Literal.string("[A-Za-z]")), 4634 mon_strptime, 4635 ) 4636 .else_(exp.TryCast(this=src, to=to)) 4637 ) 4638 elif ( 4639 isinstance(to_type, exp.Interval) 4640 and (unit := to_type.unit) 4641 and expression.args.get("requires_string") 4642 ): 4643 interval_type = exp.DataType.build("INTERVAL") 4644 if isinstance(unit, exp.IntervalSpan): 4645 self.unsupported( 4646 "TRY_CAST to INTERVAL with span (e.g. HOUR TO MINUTE) is not supported in DuckDB" 4647 ) 4648 return self.sql(exp.TryCast(this=src, to=interval_type)) 4649 return self.sql( 4650 exp.TryCast( 4651 this=exp.DPipe(this=src, expression=exp.Literal.string(f" {unit.name}")), 4652 to=interval_type, 4653 ) 4654 ) 4655 4656 return super().trycast_sql(expression) 4657 4658 def strtok_sql(self, expression: exp.Strtok) -> str: 4659 string_arg = expression.this 4660 delimiter_arg = expression.args.get("delimiter") 4661 part_index_arg = expression.args.get("part_index") 4662 4663 if delimiter_arg and part_index_arg: 4664 # Escape regex chars and build character class at runtime using REGEXP_REPLACE 4665 escaped_delimiter = exp.Anonymous( 4666 this="REGEXP_REPLACE", 4667 expressions=[ 4668 delimiter_arg, 4669 exp.Literal.string( 4670 r"([\[\]^.\-*+?(){}|$\\])" 4671 ), # Escape problematic regex chars 4672 exp.Literal.string( 4673 r"\\\1" 4674 ), # Replace with escaped version using $1 backreference 4675 exp.Literal.string("g"), # Global flag 4676 ], 4677 ) 4678 # CASE WHEN delimiter = '' THEN '' ELSE CONCAT('[', escaped_delimiter, ']') END 4679 regex_pattern = ( 4680 exp.case() 4681 .when(delimiter_arg.eq(exp.Literal.string("")), exp.Literal.string("")) 4682 .else_( 4683 exp.func( 4684 "CONCAT", 4685 exp.Literal.string("["), 4686 escaped_delimiter, 4687 exp.Literal.string("]"), 4688 ) 4689 ) 4690 ) 4691 4692 # STRTOK skips empty strings, so we need to filter them out 4693 # LIST_FILTER(REGEXP_SPLIT_TO_ARRAY(string, pattern), x -> x != '')[index] 4694 split_array = exp.func("REGEXP_SPLIT_TO_ARRAY", string_arg, regex_pattern) 4695 x = exp.to_identifier("x") 4696 is_empty = x.eq(exp.Literal.string("")) 4697 filtered_array = exp.func( 4698 "LIST_FILTER", 4699 split_array, 4700 exp.Lambda(this=exp.not_(is_empty.copy()), expressions=[x.copy()]), 4701 ) 4702 base_func = exp.Bracket( 4703 this=filtered_array, 4704 expressions=[part_index_arg], 4705 offset=1, 4706 ) 4707 4708 # Use template with the built regex pattern 4709 result = exp.replace_placeholders( 4710 self.STRTOK_TEMPLATE.copy(), 4711 string=string_arg, 4712 delimiter=delimiter_arg, 4713 part_index=part_index_arg, 4714 base_func=base_func, 4715 ) 4716 4717 return self.sql(result) 4718 4719 return self.function_fallback_sql(expression) 4720 4721 def strtoktoarray_sql(self, expression: exp.StrtokToArray) -> str: 4722 string_arg = expression.this 4723 delimiter_arg = expression.args.get("expression") or exp.Literal.string(" ") 4724 4725 escaped = exp.RegexpReplace( 4726 this=delimiter_arg.copy(), 4727 expression=exp.Literal.string(r"([\[\]^.\-*+?(){}|$\\])"), 4728 replacement=exp.Literal.string(r"\\\1"), 4729 modifiers=exp.Literal.string("g"), 4730 ) 4731 return self.sql( 4732 exp.replace_placeholders( 4733 self.STRTOK_TO_ARRAY_TEMPLATE.copy(), 4734 string=string_arg, 4735 delimiter=delimiter_arg, 4736 escaped=escaped, 4737 ) 4738 ) 4739 4740 def approxquantile_sql(self, expression: exp.ApproxQuantile) -> str: 4741 result = self.func("APPROX_QUANTILE", expression.this, expression.args.get("quantile")) 4742 4743 # DuckDB returns integers for APPROX_QUANTILE, cast to DOUBLE if the expected type is a real type 4744 if expression.is_type(*exp.DataType.REAL_TYPES): 4745 result = f"CAST({result} AS DOUBLE)" 4746 4747 return result 4748 4749 def approxquantiles_sql(self, expression: exp.ApproxQuantiles) -> str: 4750 """ 4751 BigQuery's APPROX_QUANTILES(expr, n) returns an array of n+1 approximate quantile values 4752 dividing the input distribution into n equal-sized buckets. 4753 4754 Both BigQuery and DuckDB use approximate algorithms for quantile estimation, but BigQuery 4755 does not document the specific algorithm used so results may differ. DuckDB does not 4756 support RESPECT NULLS. 4757 """ 4758 this = expression.this 4759 if isinstance(this, exp.Distinct): 4760 # APPROX_QUANTILES requires 2 args and DISTINCT node grabs both 4761 if len(this.expressions) < 2: 4762 self.unsupported("APPROX_QUANTILES requires a bucket count argument") 4763 return self.function_fallback_sql(expression) 4764 num_quantiles_expr = this.expressions[1].pop() 4765 else: 4766 num_quantiles_expr = expression.expression 4767 4768 if not isinstance(num_quantiles_expr, exp.Literal) or not num_quantiles_expr.is_int: 4769 self.unsupported("APPROX_QUANTILES bucket count must be a positive integer") 4770 return self.function_fallback_sql(expression) 4771 4772 num_quantiles = t.cast(int, num_quantiles_expr.to_py()) 4773 if num_quantiles <= 0: 4774 self.unsupported("APPROX_QUANTILES bucket count must be a positive integer") 4775 return self.function_fallback_sql(expression) 4776 4777 quantiles = [ 4778 exp.Literal.number(Decimal(i) / Decimal(num_quantiles)) 4779 for i in range(num_quantiles + 1) 4780 ] 4781 4782 return self.sql(exp.ApproxQuantile(this=this, quantile=exp.Array(expressions=quantiles))) 4783 4784 def jsonextractscalar_sql(self, expression: exp.JSONExtractScalar) -> str: 4785 if expression.args.get("scalar_only"): 4786 json_value = exp.JSONExtractScalar( 4787 this=rename_func("JSON_VALUE")(self, expression), expression="'$'" 4788 ) 4789 4790 # `->>` binds looser than most operators, so the wrap logic needs the parent 4791 json_value.parent = expression.parent 4792 expression = json_value 4793 4794 return _arrow_json_extract_sql(self, expression) 4795 4796 def bitwisenot_sql(self, expression: exp.BitwiseNot) -> str: 4797 this = expression.this 4798 4799 if _is_binary(this): 4800 expression.type = exp.DType.BINARY.into_expr() 4801 4802 arg = _cast_to_bit(this) 4803 4804 if isinstance(this, exp.Neg): 4805 arg = exp.Paren(this=arg) 4806 4807 expression.set("this", arg) 4808 4809 result_sql = f"~{self.sql(expression, 'this')}" 4810 4811 return _gen_with_cast_to_blob(self, expression, result_sql) 4812 4813 def window_sql(self, expression: exp.Window) -> str: 4814 this = expression.this 4815 if isinstance(this, exp.Corr) or ( 4816 isinstance(this, exp.Filter) and isinstance(this.this, exp.Corr) 4817 ): 4818 return self._corr_sql(expression) 4819 4820 return super().window_sql(expression) 4821 4822 def filter_sql(self, expression: exp.Filter) -> str: 4823 if isinstance(expression.this, exp.Corr): 4824 return self._corr_sql(expression) 4825 4826 return super().filter_sql(expression) 4827 4828 def _corr_sql( 4829 self, 4830 expression: exp.Filter | exp.Window | exp.Corr, 4831 ) -> str: 4832 if isinstance(expression, exp.Corr) and not expression.args.get("null_on_zero_variance"): 4833 return self.func("CORR", expression.this, expression.expression) 4834 4835 corr_expr = _maybe_corr_null_to_false(expression) 4836 if corr_expr is None: 4837 if isinstance(expression, exp.Window): 4838 return super().window_sql(expression) 4839 if isinstance(expression, exp.Filter): 4840 return super().filter_sql(expression) 4841 corr_expr = expression # make mypy happy 4842 4843 return self.sql(exp.case().when(exp.IsNan(this=corr_expr), exp.null()).else_(corr_expr)) 4844 4845 def uuid_sql(self, expression: exp.Uuid) -> str: 4846 namespace = expression.this 4847 name = expression.args.get("name") 4848 4849 # UUID v5 (namespace + name) - Emulate using SHA1 4850 if namespace and name: 4851 result = exp.replace_placeholders( 4852 self.UUID_V5_TEMPLATE.copy(), 4853 namespace=namespace, 4854 name=name, 4855 ) 4856 return self.sql(result) 4857 4858 return super().uuid_sql(expression)
Generator converts a given syntax tree to the corresponding SQL string.
Arguments:
- pretty: Whether to format the produced SQL string. Default: False.
- identify: Determines when an identifier should be quoted. Possible values are: False (default): Never quote, except in cases where it's mandatory by the dialect. True: Always quote except for specials cases. 'safe': Only quote identifiers that are case insensitive.
- normalize: Whether to normalize identifiers to lowercase. Default: False.
- pad: The pad size in a formatted string. For example, this affects the indentation of a projection in a query, relative to its nesting level. Default: 2.
- indent: The indentation size in a formatted string. For example, this affects the
indentation of subqueries and filters under a
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
2307 def datatype_sql(self, expression: exp.DataType) -> str: 2308 if expression.is_type(exp.DType.ARRAY): 2309 return f"{self.expressions(expression, flat=True)}[{self.expressions(expression, key='values', flat=True)}]" 2310 2311 # Modifiers are not supported for TIME, [TIME | TIMESTAMP] WITH TIME ZONE 2312 if expression.is_type( 2313 exp.DType.TIME, exp.DType.TIMETZ, exp.DType.TIMESTAMPTZ, exp.DType.TIMESTAMPLTZ 2314 ): 2315 expression.set("expressions", None) 2316 2317 return super().datatype_sql(expression)
2325 def timeslice_sql(self, expression: exp.TimeSlice) -> str: 2326 """ 2327 Transform Snowflake's TIME_SLICE to DuckDB's time_bucket. 2328 2329 Snowflake: TIME_SLICE(date_expr, slice_length, 'UNIT' [, 'START'|'END']) 2330 DuckDB: time_bucket(INTERVAL 'slice_length' UNIT, date_expr) 2331 2332 For 'END' kind, add the interval to get the end of the slice. 2333 For DATE type with 'END', cast result back to DATE to preserve type. 2334 """ 2335 date_expr = expression.this 2336 slice_length = expression.expression 2337 unit = expression.unit 2338 kind = expression.text("kind").upper() 2339 2340 # Create INTERVAL expression: INTERVAL 'N' UNIT 2341 interval_expr = exp.Interval(this=slice_length, unit=unit) 2342 2343 # Create base time_bucket expression 2344 time_bucket_expr = exp.func("time_bucket", interval_expr, date_expr) 2345 2346 # Check if we need the end of the slice (default is start) 2347 if not kind == "END": 2348 # For 'START', return time_bucket directly 2349 return self.sql(time_bucket_expr) 2350 2351 # For 'END', add the interval to get end of slice 2352 add_expr = exp.Add(this=time_bucket_expr, expression=interval_expr.copy()) 2353 2354 # If input is DATE type, cast result back to DATE to preserve type 2355 # DuckDB converts DATE to TIMESTAMP when adding intervals 2356 if date_expr.is_type(exp.DType.DATE): 2357 return self.sql(exp.cast(add_expr, exp.DType.DATE)) 2358 2359 return self.sql(add_expr)
Transform Snowflake's TIME_SLICE to DuckDB's time_bucket.
Snowflake: TIME_SLICE(date_expr, slice_length, 'UNIT' [, 'START'|'END']) DuckDB: time_bucket(INTERVAL 'slice_length' UNIT, date_expr)
For 'END' kind, add the interval to get the end of the slice. For DATE type with 'END', cast result back to DATE to preserve type.
2361 def bytestring_sql(self, expression: exp.ByteString) -> str: 2362 if not expression.args.get("is_bytes"): 2363 return super().bytestring_sql(expression) 2364 2365 # Each char is a byte. Casting to BLOB decodes \xHH, so everything but printable ASCII 2366 # (including the backslash itself) is written as such 2367 text = "".join( 2368 ch if ch.isascii() and ch.isprintable() and ch != "\\" else f"\\x{ord(ch):02x}" 2369 for ch in expression.this 2370 ) 2371 return self.sql(exp.cast(exp.Literal.string(text), exp.DType.BINARY))
2373 def bitmapbucketnumber_sql(self, expression: exp.BitmapBucketNumber) -> str: 2374 """ 2375 Transpile BITMAP_BUCKET_NUMBER function from Snowflake to DuckDB equivalent. 2376 2377 Snowflake's BITMAP_BUCKET_NUMBER returns a 1-based bucket identifier where: 2378 - Each bucket covers 32,768 values 2379 - Bucket numbering starts at 1 2380 - Formula: ((value - 1) // 32768) + 1 for positive values 2381 2382 For non-positive values (0 and negative), we use value // 32768 to avoid 2383 producing bucket 0 or positive bucket IDs for negative inputs. 2384 """ 2385 value = expression.this 2386 2387 positive_formula = ((value - 1) // 32768) + 1 2388 non_positive_formula = value // 32768 2389 2390 # CASE WHEN value > 0 THEN ((value - 1) // 32768) + 1 ELSE value // 32768 END 2391 case_expr = ( 2392 exp.case() 2393 .when(exp.GT(this=value, expression=exp.Literal.number(0)), positive_formula) 2394 .else_(non_positive_formula) 2395 ) 2396 return self.sql(case_expr)
Transpile BITMAP_BUCKET_NUMBER function from Snowflake to DuckDB equivalent.
Snowflake's BITMAP_BUCKET_NUMBER returns a 1-based bucket identifier where:
- Each bucket covers 32,768 values
- Bucket numbering starts at 1
- Formula: ((value - 1) // 32768) + 1 for positive values
For non-positive values (0 and negative), we use value // 32768 to avoid producing bucket 0 or positive bucket IDs for negative inputs.
2398 def bitmapbitposition_sql(self, expression: exp.BitmapBitPosition) -> str: 2399 """ 2400 Transpile Snowflake's BITMAP_BIT_POSITION to DuckDB CASE expression. 2401 2402 Snowflake's BITMAP_BIT_POSITION behavior: 2403 - For n <= 0: returns ABS(n) % 32768 2404 - For n > 0: returns (n - 1) % 32768 (maximum return value is 32767) 2405 """ 2406 this = expression.this 2407 2408 return self.sql( 2409 exp.Mod( 2410 this=exp.Paren( 2411 this=exp.If( 2412 this=exp.GT(this=this, expression=exp.Literal.number(0)), 2413 true=this - exp.Literal.number(1), 2414 false=exp.Abs(this=this), 2415 ) 2416 ), 2417 expression=MAX_BIT_POSITION, 2418 ) 2419 )
Transpile Snowflake's BITMAP_BIT_POSITION to DuckDB CASE expression.
Snowflake's BITMAP_BIT_POSITION behavior:
- For n <= 0: returns ABS(n) % 32768
- For n > 0: returns (n - 1) % 32768 (maximum return value is 32767)
2421 def bitmapconstructagg_sql(self, expression: exp.BitmapConstructAgg) -> str: 2422 """ 2423 Transpile Snowflake's BITMAP_CONSTRUCT_AGG to DuckDB equivalent. 2424 Uses a pre-parsed template with placeholders replaced by expression nodes. 2425 2426 Snowflake bitmap format: 2427 - Small (< 5 unique values): 2-byte count (big-endian) + values (little-endian) + padding to 10 bytes 2428 - Large (>= 5 unique values): 10-byte header (0x08 + 9 zeros) + values (little-endian) 2429 """ 2430 arg = expression.this 2431 return ( 2432 f"({self.sql(exp.replace_placeholders(self.BITMAP_CONSTRUCT_AGG_TEMPLATE, arg=arg))})" 2433 )
Transpile Snowflake's BITMAP_CONSTRUCT_AGG to DuckDB equivalent. Uses a pre-parsed template with placeholders replaced by expression nodes.
Snowflake bitmap format:
- Small (< 5 unique values): 2-byte count (big-endian) + values (little-endian) + padding to 10 bytes
- Large (>= 5 unique values): 10-byte header (0x08 + 9 zeros) + values (little-endian)
2477 def jarowinklersimilarity_sql(self, expression: exp.JarowinklerSimilarity) -> str: 2478 this = expression.this 2479 expr = expression.expression 2480 2481 if expression.args.get("case_insensitive"): 2482 this = exp.Upper(this=this) 2483 expr = exp.Upper(this=expr) 2484 2485 result = exp.func("JARO_WINKLER_SIMILARITY", this, expr) 2486 2487 if expression.args.get("integer_scale"): 2488 result = exp.cast(result * 100, "INTEGER") 2489 2490 return self.sql(result)
2492 def randstr_sql(self, expression: exp.Randstr) -> str: 2493 """ 2494 Transpile Snowflake's RANDSTR to DuckDB equivalent using deterministic hash-based random. 2495 Uses a pre-parsed template with placeholders replaced by expression nodes. 2496 2497 RANDSTR(length, generator) generates a random string of specified length. 2498 - With numeric seed: Use HASH(i + seed) for deterministic output (same seed = same result) 2499 - With RANDOM(): Use RANDOM() in the hash for non-deterministic output 2500 - No generator: Use default seed value 2501 """ 2502 length = expression.this 2503 generator = expression.args.get("generator") 2504 2505 if generator: 2506 if isinstance(generator, exp.Rand): 2507 # If it's RANDOM(), use its seed if available, otherwise use RANDOM() itself 2508 seed_value = generator.this or generator 2509 else: 2510 # Const/int or other expression - use as seed directly 2511 seed_value = generator 2512 else: 2513 # No generator specified, use default seed (arbitrary but deterministic) 2514 seed_value = exp.Literal.number(RANDSTR_SEED) 2515 2516 replacements = {"seed": seed_value, "length": length} 2517 return f"({self.sql(exp.replace_placeholders(self.RANDSTR_TEMPLATE, **replacements))})"
Transpile Snowflake's RANDSTR to DuckDB equivalent using deterministic hash-based random. Uses a pre-parsed template with placeholders replaced by expression nodes.
RANDSTR(length, generator) generates a random string of specified length.
- With numeric seed: Use HASH(i + seed) for deterministic output (same seed = same result)
- With RANDOM(): Use RANDOM() in the hash for non-deterministic output
- No generator: Use default seed value
2519 @unsupported_args("finish") 2520 def reduce_sql(self, expression: exp.Reduce) -> str: 2521 array_arg = expression.this 2522 initial_value = expression.args.get("initial") 2523 merge_lambda = expression.args.get("merge") 2524 2525 if merge_lambda: 2526 merge_lambda.set("colon", True) 2527 2528 return self.func("list_reduce", array_arg, merge_lambda, initial_value)
2530 def zipf_sql(self, expression: exp.Zipf) -> str: 2531 """ 2532 Transpile Snowflake's ZIPF to DuckDB using CDF-based inverse sampling. 2533 Uses a pre-parsed template with placeholders replaced by expression nodes. 2534 """ 2535 s = expression.this 2536 n = expression.args["elementcount"] 2537 gen = expression.args["gen"] 2538 2539 if not isinstance(gen, exp.Rand): 2540 # (ABS(HASH(seed)) % 1000000) / 1000000.0 2541 random_expr: exp.Expr = exp.Div( 2542 this=exp.Paren( 2543 this=exp.Mod( 2544 this=exp.Abs(this=exp.Anonymous(this="HASH", expressions=[gen.copy()])), 2545 expression=exp.Literal.number(1000000), 2546 ) 2547 ), 2548 expression=exp.Literal.number(1000000.0), 2549 ) 2550 else: 2551 # Use RANDOM() for non-deterministic output 2552 random_expr = exp.Rand() 2553 2554 replacements = {"s": s, "n": n, "random_expr": random_expr} 2555 return f"({self.sql(exp.replace_placeholders(self.ZIPF_TEMPLATE, **replacements))})"
Transpile Snowflake's ZIPF to DuckDB using CDF-based inverse sampling. Uses a pre-parsed template with placeholders replaced by expression nodes.
2557 def tobinary_sql(self, expression: exp.ToBinary) -> str: 2558 """ 2559 TO_BINARY and TRY_TO_BINARY transpilation: 2560 - 'HEX': TO_BINARY('48454C50', 'HEX') -> UNHEX('48454C50') 2561 - 'UTF-8': TO_BINARY('TEST', 'UTF-8') -> ENCODE('TEST') 2562 - 'BASE64': TO_BINARY('SEVMUA==', 'BASE64') -> FROM_BASE64('SEVMUA==') 2563 2564 For TRY_TO_BINARY (safe=True), wrap with TRY(): 2565 - 'HEX': TRY_TO_BINARY('invalid', 'HEX') -> TRY(UNHEX('invalid')) 2566 """ 2567 value = expression.this 2568 format_arg = expression.args.get("format") 2569 is_safe = expression.args.get("safe") 2570 is_binary = _is_binary(expression) 2571 2572 if not format_arg and not is_binary: 2573 func_name = "TRY_TO_BINARY" if is_safe else "TO_BINARY" 2574 return self.func(func_name, value) 2575 2576 # Snowflake defaults to HEX encoding when no format is specified 2577 fmt = format_arg.name.upper() if format_arg else "HEX" 2578 2579 if fmt in ("UTF-8", "UTF8"): 2580 # DuckDB ENCODE always uses UTF-8, no charset parameter needed 2581 result = self.func("ENCODE", value) 2582 elif fmt == "BASE64": 2583 result = self.func("FROM_BASE64", value) 2584 elif fmt == "HEX": 2585 result = self.func("UNHEX", value) 2586 else: 2587 if is_safe: 2588 return self.sql(exp.null()) 2589 else: 2590 self.unsupported(f"format {fmt} is not supported") 2591 result = self.func("TO_BINARY", value) 2592 return f"TRY({result})" if is_safe else result
TO_BINARY and TRY_TO_BINARY transpilation:
- 'HEX': TO_BINARY('48454C50', 'HEX') -> UNHEX('48454C50')
- 'UTF-8': TO_BINARY('TEST', 'UTF-8') -> ENCODE('TEST')
- 'BASE64': TO_BINARY('SEVMUA==', 'BASE64') -> FROM_BASE64('SEVMUA==')
For TRY_TO_BINARY (safe=True), wrap with TRY():
- 'HEX': TRY_TO_BINARY('invalid', 'HEX') -> TRY(UNHEX('invalid'))
2594 def tonumber_sql(self, expression: exp.ToNumber) -> str: 2595 fmt = expression.args.get("format") 2596 precision = expression.args.get("precision") 2597 scale = expression.args.get("scale") 2598 2599 if not fmt and precision and scale: 2600 to = exp.DataType.build(f"DECIMAL({precision.name}, {scale.name})", dialect="duckdb") 2601 if expression.args.get("safe"): 2602 return self.sql(exp.TryCast(this=expression.this, to=to)) 2603 return self.sql(exp.cast(expression.this, to, dialect="duckdb")) 2604 2605 return super().tonumber_sql(expression)
2631 def generator_sql(self, expression: exp.Generator) -> str: 2632 # Transpile Snowflake GENERATOR to DuckDB range() 2633 rowcount = expression.args.get("rowcount") 2634 time_limit = expression.args.get("time_limit") 2635 2636 if time_limit: 2637 self.unsupported("GENERATOR TIMELIMIT parameter is not supported in DuckDB") 2638 2639 if not rowcount: 2640 self.unsupported("GENERATOR without ROWCOUNT is not supported in DuckDB") 2641 return self.func("range", exp.Literal.number(0)) 2642 2643 return self.func("range", rowcount)
2651 def lambda_sql(self, expression: exp.Lambda, arrow_sep: str = "->", wrap: bool = True) -> str: 2652 if expression.args.get("colon"): 2653 prefix = "LAMBDA " 2654 arrow_sep = ":" 2655 wrap = False 2656 else: 2657 prefix = "" 2658 2659 lambda_sql = super().lambda_sql(expression, arrow_sep=arrow_sep, wrap=wrap) 2660 return f"{prefix}{lambda_sql}"
2671 def sortarray_sql(self, expression: exp.SortArray) -> str: 2672 arr = expression.this 2673 asc = expression.args.get("asc") 2674 nulls_first = expression.args.get("nulls_first") 2675 2676 if not isinstance(asc, exp.Boolean) and not isinstance(nulls_first, exp.Boolean): 2677 return self.func("LIST_SORT", arr, asc, nulls_first) 2678 2679 nulls_are_first = nulls_first == exp.true() 2680 nulls_first_sql = exp.Literal.string("NULLS FIRST") if nulls_are_first else None 2681 2682 if not isinstance(asc, exp.Boolean): 2683 return self.func("LIST_SORT", arr, asc, nulls_first_sql) 2684 2685 descending = asc == exp.false() 2686 2687 if not descending and not nulls_are_first: 2688 return self.func("LIST_SORT", arr) 2689 if not nulls_are_first: 2690 return self.func("ARRAY_REVERSE_SORT", arr) 2691 return self.func( 2692 "LIST_SORT", 2693 arr, 2694 exp.Literal.string("DESC" if descending else "ASC"), 2695 exp.Literal.string("NULLS FIRST"), 2696 )
2698 def install_sql(self, expression: exp.Install) -> str: 2699 force = "FORCE " if expression.args.get("force") else "" 2700 this = self.sql(expression, "this") 2701 from_clause = expression.args.get("from_") 2702 from_clause = f" FROM {from_clause}" if from_clause else "" 2703 return f"{force}INSTALL {this}{from_clause}"
2711 def strposition_sql(self, expression: exp.StrPosition) -> str: 2712 this = expression.this 2713 substr = expression.args.get("substr") 2714 position = expression.args.get("position") 2715 2716 # For BINARY/BLOB: DuckDB's STRPOS doesn't support BLOB types 2717 # Convert to HEX strings, use STRPOS, then convert hex position to byte position 2718 if _is_binary(this): 2719 # Build expression: STRPOS(HEX(haystack), HEX(needle)) 2720 hex_strpos = exp.StrPosition( 2721 this=exp.Hex(this=this), 2722 substr=exp.Hex(this=substr), 2723 ) 2724 2725 return self.sql(exp.cast((hex_strpos + 1) / 2, exp.DType.INT)) 2726 2727 # For VARCHAR: handle clamp_position 2728 if expression.args.get("clamp_position") and position: 2729 expression = expression.copy() 2730 expression.set( 2731 "position", 2732 exp.If( 2733 this=exp.LTE(this=position, expression=exp.Literal.number(0)), 2734 true=exp.Literal.number(1), 2735 false=position.copy(), 2736 ), 2737 ) 2738 2739 return strposition_sql(self, expression)
2741 def substring_sql(self, expression: exp.Substring) -> str: 2742 if expression.args.get("zero_start"): 2743 start = expression.args.get("start") 2744 length = expression.args.get("length") 2745 2746 if start := expression.args.get("start"): 2747 start = exp.If(this=start.eq(0), true=exp.Literal.number(1), false=start) 2748 if length := expression.args.get("length"): 2749 length = exp.If(this=length < 0, true=exp.Literal.number(0), false=length) 2750 2751 return self.func("SUBSTRING", expression.this, start, length) 2752 2753 return self.function_fallback_sql(expression)
2755 def strtotime_sql(self, expression: exp.StrToTime) -> str: 2756 # Check if target_type requires TIMESTAMPTZ (for LTZ/TZ variants) 2757 target_type = expression.args.get("target_type") 2758 needs_tz = target_type and target_type.this in ( 2759 exp.DType.TIMESTAMPLTZ, 2760 exp.DType.TIMESTAMPTZ, 2761 ) 2762 2763 value, formatted_time = self._strptime_default_year(expression) 2764 2765 if expression.args.get("safe"): 2766 cast_type = exp.DType.TIMESTAMPTZ if needs_tz else exp.DType.TIMESTAMP 2767 return self.sql(exp.cast(self.func("TRY_STRPTIME", value, formatted_time), cast_type)) 2768 2769 base_sql = self.func("STRPTIME", value, formatted_time) 2770 if needs_tz: 2771 return self.sql( 2772 exp.cast( 2773 base_sql, 2774 exp.DataType(this=exp.DType.TIMESTAMPTZ), 2775 ) 2776 ) 2777 return base_sql
2779 def strtodate_sql(self, expression: exp.StrToDate) -> str: 2780 value, formatted_time = self._strptime_default_year(expression) 2781 function_name = "STRPTIME" if not expression.args.get("safe") else "TRY_STRPTIME" 2782 return self.sql( 2783 exp.cast( 2784 self.func(function_name, value, formatted_time), 2785 exp.DataType(this=exp.DType.DATE), 2786 ) 2787 )
2814 def tsordstotime_sql(self, expression: exp.TsOrDsToTime) -> str: 2815 this = expression.this 2816 time_format = self.format_time(expression) 2817 safe = expression.args.get("safe") 2818 time_type = exp.DataType.from_str("TIME", dialect="duckdb") 2819 cast_expr = exp.TryCast if safe else exp.Cast 2820 2821 if time_format: 2822 func_name = "TRY_STRPTIME" if safe else "STRPTIME" 2823 strptime = exp.Anonymous(this=func_name, expressions=[this, time_format]) 2824 return self.sql(cast_expr(this=strptime, to=time_type)) 2825 2826 if isinstance(this, exp.TsOrDsToTime) or this.is_type(exp.DType.TIME): 2827 return self.sql(this) 2828 2829 return self.sql(cast_expr(this=this, to=time_type))
2831 def currentdate_sql(self, expression: exp.CurrentDate) -> str: 2832 if not expression.this: 2833 return "CURRENT_DATE" 2834 2835 expr = exp.Cast( 2836 this=exp.AtTimeZone(this=exp.CurrentTimestamp(), zone=expression.this), 2837 to=exp.DataType(this=exp.DType.DATE), 2838 ) 2839 return self.sql(expr)
2852 def parsejson_sql(self, expression: exp.ParseJSON) -> str: 2853 arg = expression.this 2854 if expression.args.get("safe"): 2855 return self.sql( 2856 exp.case() 2857 .when(exp.func("json_valid", arg), exp.cast(arg.copy(), "JSON")) 2858 .else_(exp.null()) 2859 ) 2860 return self.func("JSON", arg)
2862 def unicode_sql(self, expression: exp.Unicode) -> str: 2863 if expression.args.get("empty_is_zero"): 2864 return self.sql( 2865 exp.case() 2866 .when(expression.this.eq(exp.Literal.string("")), exp.Literal.number(0)) 2867 .else_(exp.Anonymous(this="UNICODE", expressions=[expression.this])) 2868 ) 2869 2870 return self.func("UNICODE", expression.this)
2879 def trunc_sql(self, expression: exp.Trunc) -> str: 2880 decimals = expression.args.get("decimals") 2881 if ( 2882 expression.args.get("fractions_supported") 2883 and decimals 2884 and not decimals.is_type(exp.DType.INT) 2885 ): 2886 decimals = exp.cast(decimals, exp.DType.INT, dialect="duckdb") 2887 2888 return self.func("TRUNC", expression.this, decimals)
2890 def normal_sql(self, expression: exp.Normal) -> str: 2891 """ 2892 Transpile Snowflake's NORMAL(mean, stddev, gen) to DuckDB. 2893 2894 Uses the Box-Muller transform via NORMAL_TEMPLATE. 2895 """ 2896 mean = expression.this 2897 stddev = expression.args["stddev"] 2898 gen: exp.Expr = expression.args["gen"] 2899 2900 # Build two uniform random values [0, 1) for Box-Muller transform 2901 if isinstance(gen, exp.Rand) and gen.this is None: 2902 u1: exp.Expr = exp.Rand() 2903 u2: exp.Expr = exp.Rand() 2904 else: 2905 # Seeded: derive two values using HASH with different inputs 2906 seed = gen.this if isinstance(gen, exp.Rand) else gen 2907 u1 = exp.replace_placeholders(self.SEEDED_RANDOM_TEMPLATE, seed=seed) 2908 u2 = exp.replace_placeholders( 2909 self.SEEDED_RANDOM_TEMPLATE, 2910 seed=exp.Add(this=seed.copy(), expression=exp.Literal.number(1)), 2911 ) 2912 2913 replacements = {"mean": mean, "stddev": stddev, "u1": u1, "u2": u2} 2914 return self.sql(exp.replace_placeholders(self.NORMAL_TEMPLATE, **replacements))
Transpile Snowflake's NORMAL(mean, stddev, gen) to DuckDB.
Uses the Box-Muller transform via NORMAL_TEMPLATE.
2916 def uniform_sql(self, expression: exp.Uniform) -> str: 2917 """ 2918 Transpile Snowflake's UNIFORM(min, max, gen) to DuckDB. 2919 2920 UNIFORM returns a random value in [min, max]: 2921 - Integer result if both min and max are integers 2922 - Float result if either min or max is a float 2923 """ 2924 min_val = expression.this 2925 max_val = expression.expression 2926 gen = expression.args.get("gen") or expression.args.get("seed") or exp.Rand() 2927 2928 # Determine if result should be integer (both bounds are integers). 2929 # We do this to emulate Snowflake's behavior, INT -> INT, FLOAT -> FLOAT 2930 is_int_result = min_val.is_int and max_val.is_int 2931 2932 # Build the random value expression [0, 1) 2933 if not isinstance(gen, exp.Rand): 2934 # Seed value: (ABS(HASH(seed)) % 1000000) / 1000000.0 2935 random_expr: exp.Expr = exp.Div( 2936 this=exp.Paren( 2937 this=exp.Mod( 2938 this=exp.Abs(this=exp.Anonymous(this="HASH", expressions=[gen])), 2939 expression=exp.Literal.number(1000000), 2940 ) 2941 ), 2942 expression=exp.Literal.number(1000000.0), 2943 ) 2944 else: 2945 random_expr = exp.Rand() 2946 2947 # Build: min + random * (max - min [+ 1 for int]) 2948 range_expr: exp.Expr = exp.Sub(this=max_val, expression=min_val) 2949 if is_int_result: 2950 range_expr = exp.Add(this=range_expr, expression=exp.Literal.number(1)) 2951 2952 result: exp.Expr = exp.Add( 2953 this=min_val, 2954 expression=exp.Mul(this=random_expr, expression=exp.Paren(this=range_expr)), 2955 ) 2956 2957 if is_int_result: 2958 result = exp.Cast(this=exp.Floor(this=result), to=exp.DType.BIGINT.into_expr()) 2959 2960 return self.sql(result)
Transpile Snowflake's UNIFORM(min, max, gen) to DuckDB.
UNIFORM returns a random value in [min, max]:
- Integer result if both min and max are integers
- Float result if either min or max is a float
2962 def timefromparts_sql(self, expression: exp.TimeFromParts) -> str: 2963 nano = expression.args.get("nano") 2964 overflow = expression.args.get("overflow") 2965 2966 # Snowflake's TIME_FROM_PARTS supports overflow 2967 if overflow: 2968 hour = expression.args["hour"] 2969 minute = expression.args["min"] 2970 sec = expression.args["sec"] 2971 2972 # Check if values are within normal ranges - use MAKE_TIME for efficiency 2973 if not nano and all(arg.is_int for arg in [hour, minute, sec]): 2974 try: 2975 h_val = hour.to_py() 2976 m_val = minute.to_py() 2977 s_val = sec.to_py() 2978 if 0 <= h_val <= 23 and 0 <= m_val <= 59 and 0 <= s_val <= 59: 2979 return rename_func("MAKE_TIME")(self, expression) 2980 except ValueError: 2981 pass 2982 2983 # Overflow or nanoseconds detected - use INTERVAL arithmetic 2984 if nano: 2985 sec = sec + nano.pop() / exp.Literal.number(1000000000.0) 2986 2987 total_seconds = hour * exp.Literal.number(3600) + minute * exp.Literal.number(60) + sec 2988 2989 return self.sql( 2990 exp.Add( 2991 this=exp.Cast( 2992 this=exp.Literal.string("00:00:00"), to=exp.DType.TIME.into_expr() 2993 ), 2994 expression=exp.Interval(this=total_seconds, unit=exp.var("SECOND")), 2995 ) 2996 ) 2997 2998 # Default: MAKE_TIME 2999 if nano: 3000 expression.set( 3001 "sec", expression.args["sec"] + nano.pop() / exp.Literal.number(1000000000.0) 3002 ) 3003 3004 return rename_func("MAKE_TIME")(self, expression)
3006 def extract_sql(self, expression: exp.Extract) -> str: 3007 """ 3008 Transpile EXTRACT/DATE_PART for DuckDB, handling specifiers not natively supported. 3009 3010 DuckDB doesn't support: WEEKISO, YEAROFWEEK, YEAROFWEEKISO, NANOSECOND, 3011 EPOCH_SECOND (as integer), EPOCH_MILLISECOND, EPOCH_MICROSECOND, EPOCH_NANOSECOND 3012 """ 3013 this = expression.this 3014 datetime_expr = expression.expression 3015 3016 # TIMESTAMPTZ extractions may produce different results between Snowflake and DuckDB 3017 # because Snowflake applies server timezone while DuckDB uses local timezone 3018 if datetime_expr.is_type(exp.DType.TIMESTAMPTZ, exp.DType.TIMESTAMPLTZ): 3019 self.unsupported( 3020 "EXTRACT from TIMESTAMPTZ / TIMESTAMPLTZ may produce different results due to timezone handling differences" 3021 ) 3022 3023 part_name = this.name.upper() 3024 3025 if part_name in self.EXTRACT_STRFTIME_MAPPINGS: 3026 fmt, cast_type = self.EXTRACT_STRFTIME_MAPPINGS[part_name] 3027 3028 # Problem: strftime doesn't accept TIME and there's no NANOSECOND function 3029 # So, for NANOSECOND with TIME, fallback to MICROSECOND * 1000 3030 is_nano_time = part_name == "NANOSECOND" and datetime_expr.is_type( 3031 exp.DType.TIME, exp.DType.TIMETZ 3032 ) 3033 3034 if is_nano_time: 3035 self.unsupported("Parameter NANOSECOND is not supported with TIME type in DuckDB") 3036 return self.sql( 3037 exp.cast( 3038 exp.Mul( 3039 this=exp.Extract(this=exp.var("MICROSECOND"), expression=datetime_expr), 3040 expression=exp.Literal.number(1000), 3041 ), 3042 exp.DataType.from_str(cast_type, dialect="duckdb"), 3043 ) 3044 ) 3045 3046 # For NANOSECOND, cast to TIMESTAMP_NS to preserve nanosecond precision 3047 strftime_input = datetime_expr 3048 if part_name == "NANOSECOND": 3049 strftime_input = exp.cast(datetime_expr, exp.DType.TIMESTAMP_NS) 3050 3051 return self.sql( 3052 exp.cast( 3053 exp.Anonymous( 3054 this="STRFTIME", 3055 expressions=[strftime_input, exp.Literal.string(fmt)], 3056 ), 3057 exp.DataType.from_str(cast_type, dialect="duckdb"), 3058 ) 3059 ) 3060 3061 if part_name in self.EXTRACT_EPOCH_MAPPINGS: 3062 func_name = self.EXTRACT_EPOCH_MAPPINGS[part_name] 3063 result: exp.Expr = exp.Anonymous(this=func_name, expressions=[datetime_expr]) 3064 # EPOCH returns float, cast to BIGINT for integer result 3065 if part_name == "EPOCH_SECOND": 3066 result = exp.cast(result, exp.DataType.from_str("BIGINT", dialect="duckdb")) 3067 return self.sql(result) 3068 3069 return super().extract_sql(expression)
Transpile EXTRACT/DATE_PART for DuckDB, handling specifiers not natively supported.
DuckDB doesn't support: WEEKISO, YEAROFWEEK, YEAROFWEEKISO, NANOSECOND, EPOCH_SECOND (as integer), EPOCH_MILLISECOND, EPOCH_MICROSECOND, EPOCH_NANOSECOND
3071 def timestampfromparts_sql(self, expression: exp.TimestampFromParts) -> str: 3072 # Check if this is the date/time expression form: TIMESTAMP_FROM_PARTS(date_expr, time_expr) 3073 date_expr = expression.this 3074 time_expr = expression.expression 3075 3076 if date_expr is not None and time_expr is not None: 3077 # In DuckDB, DATE + TIME produces TIMESTAMP 3078 return self.sql(exp.Add(this=date_expr, expression=time_expr)) 3079 3080 # Component-based form: TIMESTAMP_FROM_PARTS(year, month, day, hour, minute, second, ...) 3081 sec = expression.args.get("sec") 3082 if sec is None: 3083 # This shouldn't happen with valid input, but handle gracefully 3084 return rename_func("MAKE_TIMESTAMP")(self, expression) 3085 3086 milli = expression.args.get("milli") 3087 if milli is not None: 3088 sec += milli.pop() / exp.Literal.number(1000.0) 3089 3090 nano = expression.args.get("nano") 3091 if nano is not None: 3092 sec += nano.pop() / exp.Literal.number(1000000000.0) 3093 3094 if milli or nano: 3095 expression.set("sec", sec) 3096 3097 if zone := expression.args.get("zone"): 3098 return self.sql(exp.AtTimeZone(this=expression, zone=zone.pop())) 3099 3100 return rename_func("MAKE_TIMESTAMP")(self, expression)
3102 @unsupported_args("nano") 3103 def timestampltzfromparts_sql(self, expression: exp.TimestampLtzFromParts) -> str: 3104 # Pop nano so rename_func only passes args that MAKE_TIMESTAMP accepts 3105 if nano := expression.args.get("nano"): 3106 nano.pop() 3107 3108 timestamp = rename_func("MAKE_TIMESTAMP")(self, expression) 3109 return f"CAST({timestamp} AS TIMESTAMPTZ)"
3111 @unsupported_args("nano") 3112 def timestamptzfromparts_sql(self, expression: exp.TimestampTzFromParts) -> str: 3113 # Extract zone before popping 3114 zone = expression.args.get("zone") 3115 # Pop zone and nano so rename_func only passes args that MAKE_TIMESTAMP accepts 3116 if zone: 3117 zone = zone.pop() 3118 3119 if nano := expression.args.get("nano"): 3120 nano.pop() 3121 3122 timestamp = rename_func("MAKE_TIMESTAMP")(self, expression) 3123 3124 if zone: 3125 # Use AT TIME ZONE to apply the explicit timezone 3126 return f"{timestamp} AT TIME ZONE {self.sql(zone)}" 3127 3128 return timestamp
3134 def tablesample_sql( 3135 self, 3136 expression: exp.TableSample, 3137 tablesample_keyword: str | None = None, 3138 ) -> str: 3139 if not isinstance(expression.parent, exp.Select): 3140 # This sample clause only applies to a single source, not the entire resulting relation 3141 tablesample_keyword = "TABLESAMPLE" 3142 3143 if expression.args.get("size"): 3144 method = expression.args.get("method") 3145 if method and method.name.upper() != "RESERVOIR": 3146 self.unsupported( 3147 f"Sampling method {method} is not supported with a discrete sample count, " 3148 "defaulting to reservoir sampling" 3149 ) 3150 expression.set("method", exp.var("RESERVOIR")) 3151 3152 return super().tablesample_sql(expression, tablesample_keyword=tablesample_keyword)
3164 def join_sql(self, expression: exp.Join) -> str: 3165 if ( 3166 not expression.args.get("using") 3167 and not expression.args.get("on") 3168 and not expression.method 3169 and (expression.kind in ("", "INNER", "OUTER")) 3170 ): 3171 # Some dialects support `LEFT/INNER JOIN UNNEST(...)` without an explicit ON clause 3172 # DuckDB doesn't, but we can just add a dummy ON clause that is always true 3173 if isinstance(expression.this, exp.Unnest): 3174 return super().join_sql(expression.on(exp.true())) 3175 3176 expression.set("side", None) 3177 expression.set("kind", None) 3178 3179 return super().join_sql(expression)
3181 def countif_sql(self, expression: exp.CountIf) -> str: 3182 if self.dialect.version >= (1, 2): 3183 this = expression.this 3184 if expression.args.get("zero_on_all_null") and not isinstance(this, exp.Distinct): 3185 # DuckDB >= 1.2's COUNT_IF returns NULL when the condition is NULL on all rows, 3186 # so we wrap the condition in IS TRUE to preserve count-like semantics 3187 expression = exp.CountIf(this=exp.paren(this).is_(exp.true())) 3188 return self.function_fallback_sql(expression) 3189 3190 # https://github.com/tobymao/sqlglot/pull/4749 3191 return count_if_to_sum(self, expression)
3193 def bracket_sql(self, expression: exp.Bracket) -> str: 3194 if self.dialect.version >= (1, 2): 3195 return super().bracket_sql(expression) 3196 3197 # https://duckdb.org/2025/02/05/announcing-duckdb-120.html#breaking-changes 3198 this = expression.this 3199 if isinstance(this, exp.Array): 3200 this.replace(exp.paren(this)) 3201 3202 bracket = super().bracket_sql(expression) 3203 3204 if not expression.args.get("returns_list_for_maps"): 3205 if not this.type: 3206 from sqlglot.optimizer.annotate_types import annotate_types 3207 3208 this = annotate_types(this, dialect=self.dialect) 3209 3210 if this.is_type(exp.DType.MAP): 3211 bracket = f"({bracket})[1]" 3212 3213 return bracket
3215 def withingroup_sql(self, expression: exp.WithinGroup) -> str: 3216 func = expression.this 3217 3218 # For ARRAY_AGG, DuckDB requires ORDER BY inside the function, not in WITHIN GROUP 3219 # Transform: ARRAY_AGG(x) WITHIN GROUP (ORDER BY y) -> ARRAY_AGG(x ORDER BY y) 3220 if isinstance(func, exp.ArrayAgg): 3221 if not isinstance(order := expression.expression, exp.Order): 3222 return self.sql(func) 3223 3224 # Save the original column for FILTER clause (before wrapping with Order) 3225 original_this = func.this 3226 3227 # Move ORDER BY inside ARRAY_AGG by wrapping its argument with Order 3228 # ArrayAgg.this should become Order(this=ArrayAgg.this, expressions=order.expressions) 3229 func.set( 3230 "this", 3231 exp.Order( 3232 this=func.this.copy(), 3233 expressions=order.expressions, 3234 ), 3235 ) 3236 3237 # Generate the ARRAY_AGG function with ORDER BY and add FILTER clause if needed 3238 # Use original_this (not the Order-wrapped version) for the FILTER condition 3239 array_agg_sql = self.function_fallback_sql(func) 3240 return self._add_arrayagg_null_filter(array_agg_sql, func, original_this) 3241 3242 # For other functions (like PERCENTILES), use existing logic 3243 expression_sql = self.sql(expression, "expression") 3244 3245 if isinstance(func, exp.PERCENTILES): 3246 # Make the order key the first arg and slide the fraction to the right 3247 # https://duckdb.org/docs/sql/aggregates#ordered-set-aggregate-functions 3248 order_col = expression.find(exp.Ordered) 3249 if order_col: 3250 func.set("expression", func.this) 3251 func.set("this", order_col.this) 3252 3253 this = self.sql(expression, "this").rstrip(")") 3254 3255 return f"{this}{expression_sql})"
3257 def length_sql(self, expression: exp.Length) -> str: 3258 arg = expression.this 3259 3260 # Dialects like BQ and Snowflake also accept binary values as args, so 3261 # DDB will attempt to infer the type or resort to case/when resolution 3262 if not expression.args.get("binary") or arg.is_string: 3263 return self.func("LENGTH", arg) 3264 3265 if not arg.type: 3266 from sqlglot.optimizer.annotate_types import annotate_types 3267 3268 arg = annotate_types(arg, dialect=self.dialect) 3269 3270 if arg.is_type(*exp.DataType.TEXT_TYPES): 3271 return self.func("LENGTH", arg) 3272 3273 # We need these casts to make duckdb's static type checker happy 3274 blob = exp.cast(arg, exp.DType.VARBINARY) 3275 varchar = exp.cast(arg, exp.DType.VARCHAR) 3276 3277 case = ( 3278 exp.case(exp.Anonymous(this="TYPEOF", expressions=[arg])) 3279 .when(exp.Literal.string("BLOB"), exp.ByteLength(this=blob)) 3280 .else_(exp.Anonymous(this="LENGTH", expressions=[varchar])) 3281 ) 3282 return self.sql(case)
3301 def collate_sql(self, expression: exp.Collate) -> str: 3302 if not expression.expression.is_string: 3303 return super().collate_sql(expression) 3304 3305 raw = expression.expression.name 3306 if not raw: 3307 return self.sql(expression.this) 3308 3309 parts = [] 3310 for part in raw.split("-"): 3311 lower = part.lower() 3312 if lower not in _SNOWFLAKE_COLLATION_DEFAULTS: 3313 if lower in _SNOWFLAKE_COLLATION_UNSUPPORTED: 3314 self.unsupported( 3315 f"Snowflake collation specifier '{part}' has no DuckDB equivalent" 3316 ) 3317 parts.append(lower) 3318 3319 if not parts: 3320 return self.sql(expression.this) 3321 return super().collate_sql( 3322 exp.Collate(this=expression.this, expression=exp.var(".".join(parts))) 3323 )
3355 def regexpcount_sql(self, expression: exp.RegexpCount) -> str: 3356 this = expression.this 3357 pattern = expression.expression 3358 position = expression.args.get("position") 3359 parameters = expression.args.get("parameters") 3360 3361 # Validate flags - only "ims" flags are supported for embedded patterns 3362 validated_flags = self._validate_regexp_flags(parameters, supported_flags="ims") 3363 3364 if position: 3365 this = exp.Substring(this=this, start=position) 3366 3367 # Handle empty pattern: Snowflake returns 0, DuckDB would match between every character. 3368 # Checked before the flags are embedded, which would make the pattern non-empty 3369 is_empty = exp.EQ(this=pattern.copy(), expression=exp.Literal.string("")) 3370 3371 # Embed flags in pattern (REGEXP_EXTRACT_ALL doesn't support flags argument) 3372 if validated_flags: 3373 pattern = exp.Concat(expressions=[exp.Literal.string(f"(?{validated_flags})"), pattern]) 3374 3375 result = ( 3376 exp.case() 3377 .when(is_empty, exp.Literal.number(0)) 3378 .else_( 3379 exp.Length( 3380 this=exp.Anonymous(this="REGEXP_EXTRACT_ALL", expressions=[this, pattern]) 3381 ) 3382 ) 3383 ) 3384 3385 return self.sql(result)
3387 def regexpreplace_sql(self, expression: exp.RegexpReplace) -> str: 3388 subject = expression.this 3389 pattern = expression.expression 3390 replacement = expression.args.get("replacement") or exp.Literal.string("") 3391 position = expression.args.get("position") 3392 occurrence = expression.args.get("occurrence") 3393 modifiers = expression.args.get("modifiers") 3394 3395 validated_flags = self._validate_regexp_flags(modifiers, supported_flags="cimsg") or "" 3396 3397 # Handle occurrence (only literals supported) 3398 if occurrence and not occurrence.is_int: 3399 self.unsupported("REGEXP_REPLACE with non-literal occurrence") 3400 else: 3401 occurrence = occurrence.to_py() if occurrence and occurrence.is_int else 0 3402 if occurrence > 1: 3403 self.unsupported(f"REGEXP_REPLACE occurrence={occurrence} not supported") 3404 # flag duckdb to do either all or none, single_replace check is for duckdb round trip 3405 elif ( 3406 occurrence == 0 3407 and "g" not in validated_flags 3408 and not expression.args.get("single_replace") 3409 ): 3410 validated_flags += "g" 3411 3412 # Handle position (only literals supported) 3413 prefix = None 3414 if position and not position.is_int: 3415 self.unsupported("REGEXP_REPLACE with non-literal position") 3416 elif position and position.is_int and position.to_py() > 1: 3417 pos = position.to_py() 3418 prefix = exp.Substring( 3419 this=subject, start=exp.Literal.number(1), length=exp.Literal.number(pos - 1) 3420 ) 3421 subject = exp.Substring(this=subject, start=exp.Literal.number(pos)) 3422 3423 result: exp.Expr = exp.Anonymous( 3424 this="REGEXP_REPLACE", 3425 expressions=[ 3426 subject, 3427 pattern, 3428 replacement, 3429 exp.Literal.string(validated_flags) if validated_flags else None, 3430 ], 3431 ) 3432 3433 if prefix: 3434 result = exp.Concat(expressions=[prefix, result]) 3435 3436 return self.sql(result)
3438 def regexplike_sql(self, expression: exp.RegexpLike) -> str: 3439 this = expression.this 3440 pattern = expression.expression 3441 flag = expression.args.get("flag") 3442 3443 if expression.args.get("full_match"): 3444 validated_flags = self._validate_regexp_flags(flag, supported_flags="cims") 3445 flag = exp.Literal.string(validated_flags) if validated_flags else None 3446 return self.func("REGEXP_FULL_MATCH", this, pattern, flag) 3447 3448 return self.func("REGEXP_MATCHES", this, pattern, flag)
3450 @unsupported_args("ins_cost", "del_cost", "sub_cost") 3451 def levenshtein_sql(self, expression: exp.Levenshtein) -> str: 3452 this = expression.this 3453 expr = expression.expression 3454 max_dist = expression.args.get("max_dist") 3455 3456 if max_dist is None: 3457 return self.func("LEVENSHTEIN", this, expr) 3458 3459 # Emulate Snowflake semantics: if distance > max_dist, return max_dist 3460 levenshtein = exp.Levenshtein(this=this, expression=expr) 3461 return self.sql(exp.Least(this=levenshtein, expressions=[max_dist]))
3463 def pad_sql(self, expression: exp.Pad) -> str: 3464 """ 3465 Handle RPAD/LPAD for VARCHAR and BINARY types. 3466 3467 For VARCHAR: Delegate to parent class 3468 For BINARY: Lower to: input || REPEAT(pad, GREATEST(0, target_len - OCTET_LENGTH(input))) 3469 """ 3470 string_arg = expression.this 3471 fill_arg = expression.args.get("fill_pattern") or exp.Literal.string(" ") 3472 3473 if _is_binary(string_arg) or _is_binary(fill_arg): 3474 length_arg = expression.expression 3475 is_left = expression.args.get("is_left") 3476 3477 input_len = exp.ByteLength(this=string_arg) 3478 chars_needed = length_arg - input_len 3479 pad_count = exp.Greatest( 3480 this=exp.Literal.number(0), expressions=[chars_needed], ignore_nulls=True 3481 ) 3482 repeat_expr = exp.Repeat(this=fill_arg, times=pad_count) 3483 3484 left, right = string_arg, repeat_expr 3485 if is_left: 3486 left, right = right, left 3487 3488 result = exp.DPipe(this=left, expression=right) 3489 return self.sql(result) 3490 3491 # For VARCHAR: Delegate to parent class (handles PAD_FILL_PATTERN_IS_REQUIRED) 3492 return super().pad_sql(expression)
Handle RPAD/LPAD for VARCHAR and BINARY types.
For VARCHAR: Delegate to parent class For BINARY: Lower to: input || REPEAT(pad, GREATEST(0, target_len - OCTET_LENGTH(input)))
3494 def minhash_sql(self, expression: exp.Minhash) -> str: 3495 k = expression.this 3496 exprs = expression.expressions 3497 3498 if len(exprs) != 1 or isinstance(exprs[0], exp.Star): 3499 self.unsupported( 3500 "MINHASH with multiple expressions or * requires manual query restructuring" 3501 ) 3502 return self.func("MINHASH", k, *exprs) 3503 3504 expr = exprs[0] 3505 result = exp.replace_placeholders(self.MINHASH_TEMPLATE.copy(), expr=expr, k=k) 3506 return f"({self.sql(result)})"
3526 def arrayconcatagg_sql(self, expression: exp.ArrayConcatAgg) -> str: 3527 this = expression.this 3528 3529 if isinstance(this, exp.Limit): 3530 self.unsupported("LIMIT in ARRAY_CONCAT_AGG cannot be transpiled to DuckDB") 3531 this = this.this 3532 3533 inner = this.this if isinstance(this, exp.Order) else this 3534 3535 return self.func( 3536 "FLATTEN", 3537 exp.Filter( 3538 this=exp.ArrayAgg(this=this), 3539 expression=exp.Where(this=inner.copy().is_(exp.null()).not_()), 3540 ), 3541 )
3547 def arraydistinct_sql(self, expression: exp.ArrayDistinct) -> str: 3548 arr = expression.this 3549 func = self.func("LIST_DISTINCT", arr) 3550 3551 if expression.args.get("check_null"): 3552 add_null_to_array = exp.func( 3553 "LIST_APPEND", exp.func("LIST_DISTINCT", exp.ArrayCompact(this=arr)), exp.Null() 3554 ) 3555 return self.sql( 3556 exp.If( 3557 this=exp.NEQ( 3558 this=exp.ArraySize(this=arr), expression=exp.func("LIST_COUNT", arr) 3559 ), 3560 true=add_null_to_array, 3561 false=func, 3562 ) 3563 ) 3564 3565 return func
3567 def arrayintersect_sql(self, expression: exp.ArrayIntersect) -> str: 3568 if expression.args.get("is_multiset") and len(expression.expressions) == 2: 3569 return self._array_bag_sql( 3570 self.ARRAY_INTERSECTION_CONDITION, 3571 expression.expressions[0], 3572 expression.expressions[1], 3573 ) 3574 return self.function_fallback_sql(expression)
3576 def arrayexcept_sql(self, expression: exp.ArrayExcept) -> str: 3577 arr1, arr2 = expression.this, expression.expression 3578 if expression.args.get("is_multiset"): 3579 return self._array_bag_sql(self.ARRAY_EXCEPT_CONDITION, arr1, arr2) 3580 return self.sql( 3581 exp.replace_placeholders(self.ARRAY_EXCEPT_SET_TEMPLATE, arr1=arr1, arr2=arr2) 3582 )
3584 def arrayslice_sql(self, expression: exp.ArraySlice) -> str: 3585 """ 3586 Spark's SLICE uses a length, so normalize negative starts before computing 3587 the inclusive end index. Starts before the array produce an empty slice. 3588 3589 Transpiles Snowflake's ARRAY_SLICE (0-indexed, exclusive end) to DuckDB's 3590 ARRAY_SLICE (1-indexed, inclusive end) by wrapping start and end in CASE 3591 expressions that adjust the index at query time: 3592 - start: CASE WHEN start >= 0 THEN start + 1 ELSE start END 3593 - end: CASE WHEN end < 0 THEN end - 1 ELSE end END 3594 """ 3595 start, end = expression.args.get("start"), expression.args.get("end") 3596 3597 if expression.args.get("zero_based"): 3598 if start is not None: 3599 start = ( 3600 exp.case() 3601 .when( 3602 exp.GTE(this=start.copy(), expression=exp.Literal.number(0)), 3603 exp.Add(this=start.copy(), expression=exp.Literal.number(1)), 3604 ) 3605 .else_(start) 3606 ) 3607 if end is not None and not expression.args.get("is_length"): 3608 end = ( 3609 exp.case() 3610 .when( 3611 exp.LT(this=end.copy(), expression=exp.Literal.number(0)), 3612 exp.Sub(this=end.copy(), expression=exp.Literal.number(1)), 3613 ) 3614 .else_(end) 3615 ) 3616 3617 if expression.args.get("is_length") and start is not None and end is not None: 3618 if not start.is_int or start.to_py() < 0: 3619 size = exp.ArraySize(this=expression.this.copy()) 3620 start = ( 3621 exp.case() 3622 .when(start < -size, size + 1) 3623 .when(start < 0, size + start + 1) 3624 .else_(start) 3625 ) 3626 3627 if start.is_int and end.is_int: 3628 end = exp.Literal.number(start.to_py() + end.to_py() - 1) 3629 else: 3630 end = start + exp.cast(end, "BIGINT") - 1 3631 return self.func("ARRAY_SLICE", expression.this, start, end, expression.args.get("step"))
Spark's SLICE uses a length, so normalize negative starts before computing the inclusive end index. Starts before the array produce an empty slice.
Transpiles Snowflake's ARRAY_SLICE (0-indexed, exclusive end) to DuckDB's ARRAY_SLICE (1-indexed, inclusive end) by wrapping start and end in CASE expressions that adjust the index at query time:
- start: CASE WHEN start >= 0 THEN start + 1 ELSE start END
- end: CASE WHEN end < 0 THEN end - 1 ELSE end END
3633 def arrayszip_sql(self, expression: exp.ArraysZip) -> str: 3634 args = expression.expressions 3635 3636 if not args: 3637 # Return [{}] - using MAP([], []) since DuckDB can't represent empty structs 3638 return self.sql(exp.array(exp.Map(keys=exp.array(), values=exp.array()))) 3639 3640 # Build placeholder values for template 3641 lengths = [exp.Length(this=arg) for arg in args] 3642 max_len = ( 3643 lengths[0] 3644 if len(lengths) == 1 3645 else exp.Greatest(this=lengths[0], expressions=lengths[1:]) 3646 ) 3647 3648 # Empty struct with same schema: {'$1': NULL, '$2': NULL, ...} 3649 empty_struct = exp.func( 3650 "STRUCT", 3651 *[ 3652 exp.PropertyEQ(this=exp.Literal.string(f"${i + 1}"), expression=exp.Null()) 3653 for i in range(len(args)) 3654 ], 3655 ) 3656 3657 # Struct for transform: {'$1': COALESCE(arr1, [])[__i + 1], ...} 3658 # COALESCE wrapping handles NULL arrays - prevents invalid NULL[i] syntax 3659 index = exp.column("__i") + 1 3660 transform_struct = exp.func( 3661 "STRUCT", 3662 *[ 3663 exp.PropertyEQ( 3664 this=exp.Literal.string(f"${i + 1}"), 3665 expression=exp.func("COALESCE", arg, exp.array())[index], 3666 ) 3667 for i, arg in enumerate(args) 3668 ], 3669 ) 3670 3671 result = exp.replace_placeholders( 3672 self.ARRAYS_ZIP_TEMPLATE.copy(), 3673 null_check=exp.or_(*[arg.is_(exp.Null()) for arg in args]), 3674 all_empty_check=exp.and_( 3675 *[ 3676 exp.EQ(this=exp.Length(this=arg), expression=exp.Literal.number(0)) 3677 for arg in args 3678 ] 3679 ), 3680 empty_struct=empty_struct, 3681 max_len=max_len, 3682 transform_struct=transform_struct, 3683 ) 3684 return self.sql(result)
3731 def stuff_sql(self, expression: exp.Stuff) -> str: 3732 base = expression.this 3733 start = expression.args["start"] 3734 length = expression.args["length"] 3735 insertion = expression.expression 3736 is_binary = _is_binary(base) 3737 3738 if is_binary: 3739 # DuckDB's SUBSTRING doesn't accept BLOB; operate on the HEX string instead 3740 # (each byte = 2 hex chars), then UNHEX back to BLOB 3741 base = exp.Hex(this=base) 3742 insertion = exp.Hex(this=insertion) 3743 left = exp.Substring( 3744 this=base.copy(), 3745 start=exp.Literal.number(1), 3746 length=(start.copy() - exp.Literal.number(1)) * exp.Literal.number(2), 3747 ) 3748 right = exp.Substring( 3749 this=base.copy(), 3750 start=((start + length) - exp.Literal.number(1)) * exp.Literal.number(2) 3751 + exp.Literal.number(1), 3752 ) 3753 else: 3754 left = exp.Substring( 3755 this=base.copy(), 3756 start=exp.Literal.number(1), 3757 length=start.copy() - exp.Literal.number(1), 3758 ) 3759 right = exp.Substring(this=base.copy(), start=start + length) 3760 result: exp.Expr = exp.DPipe( 3761 this=exp.DPipe(this=left, expression=insertion), expression=right 3762 ) 3763 3764 if is_binary: 3765 result = exp.Unhex(this=result) 3766 3767 return self.sql(result)
3769 def rand_sql(self, expression: exp.Rand) -> str: 3770 seed = expression.this 3771 if seed is not None: 3772 self.unsupported("RANDOM with seed is not supported in DuckDB") 3773 3774 lower = expression.args.get("lower") 3775 upper = expression.args.get("upper") 3776 3777 if lower and upper: 3778 # scale DuckDB's [0,1) to the specified range 3779 range_size = exp.paren(upper - lower) 3780 scaled = exp.Add(this=lower, expression=exp.func("random") * range_size) 3781 3782 # For now we assume that if bounds are set, return type is BIGINT. Snowflake/Teradata 3783 result = exp.cast(scaled, exp.DType.BIGINT) 3784 return self.sql(result) 3785 3786 # Default DuckDB behavior - just return RANDOM() as float 3787 return "RANDOM()"
3789 def bytelength_sql(self, expression: exp.ByteLength) -> str: 3790 arg = expression.this 3791 3792 # Check if it's a text type (handles both literals and annotated expressions) 3793 if arg.is_type(*exp.DataType.TEXT_TYPES): 3794 return self.func("OCTET_LENGTH", exp.Encode(this=arg)) 3795 3796 # Default: pass through as-is (conservative for DuckDB, handles binary and unannotated) 3797 return self.func("OCTET_LENGTH", arg)
3799 def base64encode_sql(self, expression: exp.Base64Encode) -> str: 3800 # DuckDB TO_BASE64 requires BLOB input 3801 # Snowflake BASE64_ENCODE accepts both VARCHAR and BINARY - for VARCHAR it implicitly 3802 # encodes UTF-8 bytes. We add ENCODE unless the input is a binary type. 3803 result = expression.this 3804 3805 # Check if input is a string type - ENCODE only accepts VARCHAR 3806 if result.is_type(*exp.DataType.TEXT_TYPES): 3807 result = exp.Encode(this=result) 3808 3809 result = exp.ToBase64(this=result) 3810 3811 max_line_length = expression.args.get("max_line_length") 3812 alphabet = expression.args.get("alphabet") 3813 3814 # Handle custom alphabet by replacing standard chars with custom ones 3815 result = _apply_base64_alphabet_replacements(result, alphabet) 3816 3817 # Handle max_line_length by inserting newlines every N characters 3818 line_length = ( 3819 t.cast(int, max_line_length.to_py()) 3820 if isinstance(max_line_length, exp.Literal) and max_line_length.is_number 3821 else 0 3822 ) 3823 if line_length > 0: 3824 newline = exp.Chr(expressions=[exp.Literal.number(10)]) 3825 result = exp.Trim( 3826 this=exp.RegexpReplace( 3827 this=result, 3828 expression=exp.Literal.string(f"(.{{{line_length}}})"), 3829 replacement=exp.Concat(expressions=[exp.Literal.string("\\1"), newline.copy()]), 3830 ), 3831 expression=newline, 3832 position="TRAILING", 3833 ) 3834 3835 return self.sql(result)
3837 def hex_sql(self, expression: exp.Hex) -> str: 3838 case = expression.args.get("case") 3839 3840 if not case: 3841 return self.func("HEX", expression.this) 3842 3843 hex_expr = exp.Hex(this=expression.this) 3844 return self.sql( 3845 exp.case() 3846 .when(case.is_(exp.null()), exp.null()) 3847 .when(case.copy().eq(0), exp.Lower(this=hex_expr.copy())) 3848 .else_(hex_expr) 3849 )
3851 def replace_sql(self, expression: exp.Replace) -> str: 3852 result_sql = self.func( 3853 "REPLACE", 3854 _cast_to_varchar(expression.this), 3855 _cast_to_varchar(expression.expression), 3856 _cast_to_varchar(expression.args.get("replacement")), 3857 ) 3858 return _gen_with_cast_to_blob(self, expression, result_sql)
3870 def objectinsert_sql(self, expression: exp.ObjectInsert) -> str: 3871 this = expression.this 3872 key = expression.args.get("key") 3873 key_sql = key.name if isinstance(key, exp.Expr) else "" 3874 value_sql = self.sql(expression, "value") 3875 3876 kv_sql = f"{key_sql} := {value_sql}" 3877 3878 # If the input struct is empty e.g. transpiling OBJECT_INSERT(OBJECT_CONSTRUCT(), key, value) from Snowflake 3879 # then we can generate STRUCT_PACK which will build it since STRUCT_INSERT({}, key := value) is not valid DuckDB 3880 if isinstance(this, exp.Struct) and not this.expressions: 3881 return self.func("STRUCT_PACK", kv_sql) 3882 3883 return self.func("STRUCT_INSERT", this, kv_sql)
3898 def mapdelete_sql(self, expression: exp.MapDelete) -> str: 3899 map_arg = expression.this 3900 keys_to_delete = expression.expressions 3901 3902 x_dot_key = exp.Dot(this=exp.to_identifier("x"), expression=exp.to_identifier("key")) 3903 3904 lambda_expr = exp.Lambda( 3905 this=exp.In(this=x_dot_key, expressions=keys_to_delete).not_(), 3906 expressions=[exp.to_identifier("x")], 3907 ) 3908 result = exp.func( 3909 "MAP_FROM_ENTRIES", 3910 exp.ArrayFilter(this=exp.func("MAP_ENTRIES", map_arg), expression=lambda_expr), 3911 ) 3912 return self.sql(result)
3914 def mappick_sql(self, expression: exp.MapPick) -> str: 3915 map_arg = expression.this 3916 keys_to_pick = expression.expressions 3917 3918 x_dot_key = exp.Dot(this=exp.to_identifier("x"), expression=exp.to_identifier("key")) 3919 3920 if len(keys_to_pick) == 1 and keys_to_pick[0].is_type(exp.DType.ARRAY): 3921 lambda_expr = exp.Lambda( 3922 this=exp.func("ARRAY_CONTAINS", keys_to_pick[0], x_dot_key), 3923 expressions=[exp.to_identifier("x")], 3924 ) 3925 else: 3926 lambda_expr = exp.Lambda( 3927 this=exp.In(this=x_dot_key, expressions=keys_to_pick), 3928 expressions=[exp.to_identifier("x")], 3929 ) 3930 3931 result = exp.func( 3932 "MAP_FROM_ENTRIES", 3933 exp.func("LIST_FILTER", exp.func("MAP_ENTRIES", map_arg), lambda_expr), 3934 ) 3935 return self.sql(result)
3940 @unsupported_args("update_flag") 3941 def mapinsert_sql(self, expression: exp.MapInsert) -> str: 3942 map_arg = expression.this 3943 key = expression.args.get("key") 3944 value = expression.args.get("value") 3945 3946 map_type = map_arg.type 3947 3948 if value is not None: 3949 if map_type and map_type.expressions and len(map_type.expressions) > 1: 3950 # Extract the value type from MAP(key_type, value_type) 3951 value_type = map_type.expressions[1] 3952 # Cast value to match the map's value type to avoid type conflicts 3953 value = exp.cast(value, value_type) 3954 # else: polymorphic MAP case - no type parameters available, use value as-is 3955 3956 # Create a single-entry map for the new key-value pair 3957 new_entry_struct = exp.Struct(expressions=[exp.PropertyEQ(this=key, expression=value)]) 3958 new_entry: exp.Expression = exp.ToMap(this=new_entry_struct) 3959 3960 # Use MAP_CONCAT to merge the original map with the new entry 3961 # This automatically handles both insert and update cases 3962 result = exp.func("MAP_CONCAT", map_arg, new_entry) 3963 3964 return self.sql(result)
3982 def tablefromrows_sql(self, expression: exp.TableFromRows) -> str: 3983 # For GENERATOR, unwrap TABLE() - just emit the Generator (becomes RANGE) 3984 if isinstance(expression.this, exp.Generator): 3985 # Preserve alias, joins, and other table-level args 3986 table = exp.Table( 3987 this=expression.this, 3988 alias=expression.args.get("alias"), 3989 joins=expression.args.get("joins"), 3990 ) 3991 return self.sql(table) 3992 3993 return super().tablefromrows_sql(expression)
3995 def unnest_sql(self, expression: exp.Unnest) -> str: 3996 explode_array = expression.args.get("explode_array") 3997 if explode_array: 3998 # In BigQuery, UNNESTing a nested array leads to explosion of the top-level array & struct 3999 # This is transpiled to DDB by transforming "FROM UNNEST(...)" to "FROM (SELECT UNNEST(..., max_depth => 2))" 4000 expression.expressions.append( 4001 exp.Kwarg(this=exp.var("max_depth"), expression=exp.Literal.number(2)) 4002 ) 4003 4004 # If BQ's UNNEST is aliased, we transform it from a column alias to a table alias in DDB 4005 alias = expression.args.get("alias") 4006 if isinstance(alias, exp.TableAlias): 4007 expression.set("alias", None) 4008 if alias.columns: 4009 alias = exp.TableAlias(this=seq_get(alias.columns, 0)) 4010 4011 unnest_sql = super().unnest_sql(expression) 4012 select = exp.Select(expressions=[unnest_sql]).subquery(alias) 4013 return self.sql(select) 4014 4015 return super().unnest_sql(expression)
4023 def ignorenulls_sql(self, expression: exp.IgnoreNulls) -> str: 4024 this = expression.this 4025 4026 if isinstance(this, self.IGNORE_RESPECT_NULLS_WINDOW_FUNCTIONS): 4027 # DuckDB should render IGNORE NULLS only for the general-purpose 4028 # window functions that accept it e.g. FIRST_VALUE(... IGNORE NULLS) OVER (...) 4029 return super().ignorenulls_sql(expression) 4030 4031 # For ARRAY_AGG(expr IGNORE NULLS ...), convert IGNORE NULLS to a 4032 # FILTER(WHERE expr IS NOT NULL) clause by setting nulls_excluded on 4033 # the ArrayAgg. The existing _add_arrayagg_null_filter method will 4034 # emit the FILTER clause during arrayagg_sql / withingroup_sql. 4035 if isinstance(this, exp.ArrayAgg): 4036 this.set("nulls_excluded", True) 4037 return self.sql(this) 4038 4039 if isinstance(this, exp.First): 4040 this = exp.AnyValue(this=this.this) 4041 4042 if not isinstance(this, (exp.AnyValue, exp.ApproxQuantiles)): 4043 self.unsupported("IGNORE NULLS is not supported for non-window functions.") 4044 4045 return self.sql(this)
4047 def split_sql(self, expression: exp.Split) -> str: 4048 base_func = exp.func("STR_SPLIT", expression.this, expression.expression) 4049 4050 case_expr = exp.case().else_(base_func) 4051 needs_case = False 4052 4053 if expression.args.get("null_returns_null"): 4054 case_expr = case_expr.when(expression.expression.is_(exp.null()), exp.null()) 4055 needs_case = True 4056 4057 if expression.args.get("empty_delimiter_returns_whole"): 4058 # When delimiter is empty string, return input string as single array element 4059 array_with_input = exp.array(expression.this) 4060 case_expr = case_expr.when( 4061 expression.expression.eq(exp.Literal.string("")), array_with_input 4062 ) 4063 needs_case = True 4064 4065 return self.sql(case_expr if needs_case else base_func)
4067 def splitpart_sql(self, expression: exp.SplitPart) -> str: 4068 string_arg = expression.this 4069 delimiter_arg = expression.args.get("delimiter") 4070 part_index_arg = expression.args.get("part_index") 4071 4072 if delimiter_arg and part_index_arg: 4073 # Handle Snowflake's "index 0 and 1 both return first element" behavior 4074 if expression.args.get("part_index_zero_as_one"): 4075 # Convert 0 to 1 for compatibility 4076 4077 part_index_arg = exp.Paren( 4078 this=exp.case() 4079 .when(part_index_arg.eq(exp.Literal.number("0")), exp.Literal.number("1")) 4080 .else_(part_index_arg) 4081 ) 4082 4083 # Use Anonymous to avoid recursion 4084 base_func_expr: exp.Expr = exp.Anonymous( 4085 this="SPLIT_PART", expressions=[string_arg, delimiter_arg, part_index_arg] 4086 ) 4087 needs_case_transform = False 4088 case_expr = exp.case().else_(base_func_expr) 4089 4090 if expression.args.get("empty_delimiter_returns_whole"): 4091 # When delimiter is empty string: 4092 # - Return whole string if part_index is 1 or -1 4093 # - Return empty string otherwise 4094 empty_case = exp.Paren( 4095 this=exp.case() 4096 .when( 4097 exp.or_( 4098 part_index_arg.eq(exp.Literal.number("1")), 4099 part_index_arg.eq(exp.Literal.number("-1")), 4100 ), 4101 string_arg, 4102 ) 4103 .else_(exp.Literal.string("")) 4104 ) 4105 4106 case_expr = case_expr.when(delimiter_arg.eq(exp.Literal.string("")), empty_case) 4107 needs_case_transform = True 4108 4109 """ 4110 Output looks something like this: 4111 4112 CASE 4113 WHEN delimiter is '' THEN 4114 ( 4115 CASE 4116 WHEN adjusted_part_index = 1 OR adjusted_part_index = -1 THEN input 4117 ELSE '' END 4118 ) 4119 ELSE SPLIT_PART(input, delimiter, adjusted_part_index) 4120 END 4121 4122 """ 4123 return self.sql(case_expr if needs_case_transform else base_func_expr) 4124 4125 return self.function_fallback_sql(expression)
4127 def respectnulls_sql(self, expression: exp.RespectNulls) -> str: 4128 if isinstance(expression.this, self.IGNORE_RESPECT_NULLS_WINDOW_FUNCTIONS): 4129 # DuckDB should render RESPECT NULLS only for the general-purpose 4130 # window functions that accept it e.g. FIRST_VALUE(... RESPECT NULLS) OVER (...) 4131 return super().respectnulls_sql(expression) 4132 4133 self.unsupported("RESPECT NULLS is not supported for non-window functions.") 4134 return self.sql(expression, "this")
4136 def arraytostring_sql(self, expression: exp.ArrayToString) -> str: 4137 null = expression.args.get("null") 4138 4139 if expression.args.get("null_is_empty"): 4140 x = exp.to_identifier("x") 4141 list_transform = exp.Transform( 4142 this=expression.this.copy(), 4143 expression=exp.Lambda( 4144 this=exp.Coalesce( 4145 this=exp.cast(x, "TEXT"), expressions=[exp.Literal.string("")] 4146 ), 4147 expressions=[x], 4148 ), 4149 ) 4150 array_to_string = exp.ArrayToString( 4151 this=list_transform, expression=expression.expression 4152 ) 4153 if expression.args.get("null_delim_is_null"): 4154 return self.sql( 4155 exp.case() 4156 .when(expression.expression.copy().is_(exp.null()), exp.null()) 4157 .else_(array_to_string) 4158 ) 4159 return self.sql(array_to_string) 4160 4161 if null: 4162 x = exp.to_identifier("x") 4163 return self.sql( 4164 exp.ArrayToString( 4165 this=exp.Transform( 4166 this=expression.this, 4167 expression=exp.Lambda( 4168 this=exp.Coalesce(this=x, expressions=[null]), 4169 expressions=[x], 4170 ), 4171 ), 4172 expression=expression.expression, 4173 ) 4174 ) 4175 4176 return self.func("ARRAY_TO_STRING", expression.this, expression.expression)
4178 def concatws_sql(self, expression: exp.ConcatWs) -> str: 4179 # DuckDB-specific: handle binary types using DPipe (||) operator 4180 separator = seq_get(expression.expressions, 0) 4181 args = expression.expressions[1:] 4182 4183 if any(_is_binary(arg) for arg in [separator, *args]): 4184 result = args[0] 4185 for arg in args[1:]: 4186 result = exp.DPipe( 4187 this=exp.DPipe(this=result, expression=separator), expression=arg 4188 ) 4189 return self.sql(result) 4190 4191 if expression.args.get("flatten") and any( 4192 isinstance(arg, exp.Array) or arg.is_type(exp.DType.ARRAY) for arg in args 4193 ): 4194 arrays = [] 4195 for arg in args: 4196 if isinstance(arg, exp.Array) or arg.is_type(exp.DType.ARRAY): 4197 arg = exp.cast(arg, "TEXT[]") 4198 else: 4199 if (not arg.type or arg.is_type(exp.DType.UNKNOWN)) and not isinstance( 4200 arg, exp.CONSTANTS 4201 ): 4202 self.unsupported( 4203 "Cannot transpile CONCAT_WS with unknown argument types to DuckDB." 4204 ) 4205 arg = exp.array(exp.cast(arg, exp.DType.TEXT)) 4206 4207 arrays.append(arg) 4208 4209 array = ( 4210 exp.ArrayConcat(this=arrays[0], expressions=arrays[1:]) 4211 if len(arrays) > 1 4212 else arrays[0] 4213 ) 4214 return self.func("CONCAT_WS", separator, self.func("ARRAY_TO_STRING", array, separator)) 4215 4216 return super().concatws_sql(expression)
4277 def regexpinstr_sql(self, expression: exp.RegexpInstr) -> str: 4278 this = expression.this 4279 pattern = expression.expression 4280 position = expression.args.get("position") 4281 orig_occ = expression.args.get("occurrence") 4282 occurrence = orig_occ or exp.Literal.number(1) 4283 option = expression.args.get("option") 4284 parameters = expression.args.get("parameters") 4285 4286 validated_flags = self._validate_regexp_flags(parameters, supported_flags="ims") 4287 # Checked before the flags are embedded, which would make the pattern non-empty 4288 is_empty = pattern.copy().eq(exp.Literal.string("")) 4289 if validated_flags: 4290 pattern = exp.Concat(expressions=[exp.Literal.string(f"(?{validated_flags})"), pattern]) 4291 4292 # Handle starting position offset 4293 pos_offset: exp.Expr = exp.Literal.number(0) 4294 if position and (not position.is_int or position.to_py() > 1): 4295 this = exp.Substring(this=this, start=position) 4296 pos_offset = position - exp.Literal.number(1) 4297 4298 # Helper: LIST_SUM(LIST_TRANSFORM(list[1:end], x -> LENGTH(x))) 4299 def sum_lengths(func_name: str, end: exp.Expr) -> exp.Expr: 4300 lst = exp.Bracket( 4301 this=exp.Anonymous(this=func_name, expressions=[this, pattern]), 4302 expressions=[exp.Slice(this=exp.Literal.number(1), expression=end)], 4303 offset=1, 4304 ) 4305 transform = exp.Anonymous( 4306 this="LIST_TRANSFORM", 4307 expressions=[ 4308 lst, 4309 exp.Lambda( 4310 this=exp.Length(this=exp.to_identifier("x")), 4311 expressions=[exp.to_identifier("x")], 4312 ), 4313 ], 4314 ) 4315 return exp.Coalesce( 4316 this=exp.Anonymous(this="LIST_SUM", expressions=[transform]), 4317 expressions=[exp.Literal.number(0)], 4318 ) 4319 4320 # Position = 1 + sum(split_lengths[1:occ]) + sum(match_lengths[1:occ-1]) + offset 4321 base_pos: exp.Expr = ( 4322 exp.Literal.number(1) 4323 + sum_lengths("STRING_SPLIT_REGEX", occurrence) 4324 + sum_lengths("REGEXP_EXTRACT_ALL", occurrence - exp.Literal.number(1)) 4325 + pos_offset 4326 ) 4327 4328 # option=1: add match length for end position 4329 if option and option.is_int and option.to_py() == 1: 4330 match_at_occ = exp.Bracket( 4331 this=exp.Anonymous(this="REGEXP_EXTRACT_ALL", expressions=[this, pattern]), 4332 expressions=[occurrence], 4333 offset=1, 4334 ) 4335 base_pos = base_pos + exp.Coalesce( 4336 this=exp.Length(this=match_at_occ), expressions=[exp.Literal.number(0)] 4337 ) 4338 4339 # NULL checks for all provided arguments 4340 # .copy() is used strictly because .is_() alters the node's parent pointer, mutating the parsed AST 4341 null_args = [ 4342 expression.this, 4343 expression.expression, 4344 position, 4345 orig_occ, 4346 option, 4347 parameters, 4348 ] 4349 null_checks = [arg.copy().is_(exp.Null()) for arg in null_args if arg] 4350 4351 matches = exp.Anonymous(this="REGEXP_EXTRACT_ALL", expressions=[this, pattern]) 4352 4353 return self.sql( 4354 exp.case() 4355 .when(exp.or_(*null_checks), exp.Null()) 4356 .when(is_empty, exp.Literal.number(0)) 4357 .when(exp.Length(this=matches) < occurrence, exp.Literal.number(0)) 4358 .else_(base_pos) 4359 )
4361 @unsupported_args("culture") 4362 def numbertostr_sql(self, expression: exp.NumberToStr) -> str: 4363 fmt = expression.args.get("format") 4364 if fmt and fmt.is_int: 4365 return self.func("FORMAT", f"'{{:,.{fmt.name}f}}'", expression.this) 4366 4367 self.unsupported("Only integer formats are supported by NumberToStr") 4368 return self.function_fallback_sql(expression)
4381 def posexplode_sql(self, expression: exp.Posexplode) -> str: 4382 this = expression.this 4383 parent = expression.parent 4384 4385 # The default Spark aliases are "pos" and "col", unless specified otherwise 4386 pos, col = exp.to_identifier("pos"), exp.to_identifier("col") 4387 4388 if isinstance(parent, exp.Aliases): 4389 # Column case: SELECT POSEXPLODE(col) [AS (a, b)] 4390 pos, col = parent.expressions 4391 elif isinstance(parent, exp.Table): 4392 # Table case: SELECT * FROM POSEXPLODE(col) [AS (a, b)] 4393 alias = parent.args.get("alias") 4394 if alias: 4395 pos, col = alias.columns or [pos, col] 4396 alias.pop() 4397 4398 # Translate POSEXPLODE to UNNEST + GENERATE_SUBSCRIPTS 4399 # Note: In Spark pos is 0-indexed, but in DuckDB it's 1-indexed, so we subtract 1 from GENERATE_SUBSCRIPTS 4400 unnest_sql = self.sql(exp.Unnest(expressions=[this], alias=col)) 4401 gen_subscripts = self.sql( 4402 exp.Alias( 4403 this=exp.Anonymous( 4404 this="GENERATE_SUBSCRIPTS", expressions=[this, exp.Literal.number(1)] 4405 ) 4406 - exp.Literal.number(1), 4407 alias=pos, 4408 ) 4409 ) 4410 4411 posexplode_sql = self.format_args(gen_subscripts, unnest_sql) 4412 4413 if isinstance(parent, exp.From) or (parent and isinstance(parent.parent, exp.From)): 4414 # SELECT * FROM POSEXPLODE(col) -> SELECT * FROM (SELECT GENERATE_SUBSCRIPTS(...), UNNEST(...)) 4415 return self.sql(exp.Subquery(this=exp.Select(expressions=[posexplode_sql]))) 4416 4417 return posexplode_sql
4419 def addmonths_sql(self, expression: exp.AddMonths) -> str: 4420 """ 4421 Handles three key issues: 4422 1. Float/decimal months: e.g., Snowflake rounds, whereas DuckDB INTERVAL requires integers 4423 2. End-of-month preservation: If input is last day of month, result is last day of result month 4424 3. Type preservation: Maintains DATE/TIMESTAMPTZ types (DuckDB defaults to TIMESTAMP) 4425 """ 4426 from sqlglot.optimizer.annotate_types import annotate_types 4427 4428 this = expression.this 4429 if not this.type: 4430 this = annotate_types(this, dialect=self.dialect) 4431 4432 if this.is_type(*exp.DataType.TEXT_TYPES): 4433 this = exp.Cast(this=this, to=exp.DataType(this=exp.DType.TIMESTAMP)) 4434 4435 # Detect float/decimal months to apply rounding (Snowflake behavior) 4436 # DuckDB INTERVAL syntax doesn't support non-integer expressions, so use TO_MONTHS 4437 months_expr = expression.expression 4438 if not months_expr.type: 4439 months_expr = annotate_types(months_expr, dialect=self.dialect) 4440 4441 # Build interval or to_months expression based on type 4442 # Float/decimal case: Round and use TO_MONTHS(CAST(ROUND(value) AS INT)) 4443 interval_or_to_months = ( 4444 exp.func("TO_MONTHS", exp.cast(exp.func("ROUND", months_expr), "INT")) 4445 if months_expr.is_type( 4446 exp.DType.FLOAT, 4447 exp.DType.DOUBLE, 4448 exp.DType.DECIMAL, 4449 ) 4450 # Integer case: standard INTERVAL N MONTH syntax 4451 else exp.Interval(this=months_expr, unit=exp.var("MONTH")) 4452 ) 4453 4454 date_add_expr = exp.Add(this=this, expression=interval_or_to_months) 4455 4456 # Apply end-of-month preservation if Snowflake flag is set 4457 # CASE WHEN LAST_DAY(date) = date THEN LAST_DAY(result) ELSE result END 4458 preserve_eom = expression.args.get("preserve_end_of_month") 4459 result_expr = ( 4460 exp.case() 4461 .when( 4462 exp.EQ(this=exp.func("LAST_DAY", this), expression=this), 4463 exp.func("LAST_DAY", date_add_expr), 4464 ) 4465 .else_(date_add_expr) 4466 if preserve_eom 4467 else date_add_expr 4468 ) 4469 4470 # DuckDB's DATE_ADD function returns TIMESTAMP/DATETIME by default, even when the input is DATE 4471 # To match for example Snowflake's ADD_MONTHS behavior (which preserves the input type) 4472 # We need to cast the result back to the original type when the input is DATE or TIMESTAMPTZ 4473 # Example: ADD_MONTHS('2023-01-31'::date, 1) should return DATE, not TIMESTAMP 4474 if this.is_type(exp.DType.DATE, exp.DType.TIMESTAMPTZ): 4475 return self.sql(exp.Cast(this=result_expr, to=this.type)) 4476 return self.sql(result_expr)
Handles three key issues:
- 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)
4490 def datetrunc_sql(self, expression: exp.DateTrunc) -> str: 4491 unit = expression.args.get("unit") 4492 date = expression.this 4493 4494 week_start = _week_trunc_start_dow(unit) 4495 unit = unit_to_str(expression) 4496 4497 if week_start: 4498 result = self.sql( 4499 _build_week_trunc_expression(date, week_start, preserve_start_day=True) 4500 ) 4501 else: 4502 result = self.func("DATE_TRUNC", unit, date) 4503 4504 if ( 4505 expression.args.get("input_type_preserved") 4506 and date.is_type(*exp.DataType.TEMPORAL_TYPES) 4507 and not (is_date_unit(unit) and date.is_type(exp.DType.DATE)) 4508 ): 4509 return self.sql(exp.Cast(this=result, to=date.type)) 4510 4511 return result
4513 def datetimetrunc_sql(self, expression: exp.DatetimeTrunc) -> str: 4514 this = exp.cast(expression.this, exp.DType.DATETIME) 4515 week_start = _week_trunc_start_dow(expression.args.get("unit")) 4516 if week_start: 4517 return self.sql( 4518 _build_week_trunc_expression( 4519 this, week_start, preserve_start_day=True, cast_to_date=False 4520 ) 4521 ) 4522 4523 return self.func("DATE_TRUNC", unit_to_str(expression), this)
4525 def timestamptrunc_sql(self, expression: exp.TimestampTrunc) -> str: 4526 zone = expression.args.get("zone") 4527 timestamp = expression.this 4528 week_start = _week_trunc_start_dow(expression.args.get("unit")) 4529 4530 # The week start emulation below is exact, so avoid weekstart_unit_to_str's degrade warning 4531 unit = unit_to_str(expression) if week_start else weekstart_unit_to_str(self, expression) 4532 date_unit = is_date_unit(unit) or bool(week_start) 4533 4534 def _trunc_expr(this: exp.Expr) -> exp.Expr: 4535 if week_start: 4536 return _build_week_trunc_expression( 4537 this, week_start, preserve_start_day=True, cast_to_date=False 4538 ) 4539 return exp.func("DATE_TRUNC", unit, this) 4540 4541 if date_unit and zone: 4542 # BigQuery's TIMESTAMP_TRUNC with timezone truncates in the target timezone and returns as UTC. 4543 # Double AT TIME ZONE needed for BigQuery compatibility: 4544 # 1. First AT TIME ZONE: ensures truncation happens in the target timezone 4545 # 2. Second AT TIME ZONE: converts the DATE result back to TIMESTAMPTZ (preserving time component) 4546 timestamp = exp.AtTimeZone(this=timestamp, zone=zone) 4547 trunced = _trunc_expr(timestamp) 4548 if isinstance(trunced, exp.DateAdd): 4549 # Parenthesize so the trailing AT TIME ZONE binds to the whole shifted expression 4550 trunced = exp.Paren(this=trunced) 4551 return self.sql(exp.AtTimeZone(this=trunced, zone=zone)) 4552 4553 result = self.sql(_trunc_expr(timestamp)) 4554 if expression.args.get("input_type_preserved"): 4555 if timestamp.type and timestamp.is_type(exp.DType.TIME, exp.DType.TIMETZ): 4556 dummy_date = exp.Cast( 4557 this=exp.Literal.string("1970-01-01"), 4558 to=exp.DataType(this=exp.DType.DATE), 4559 ) 4560 date_time = exp.Add(this=dummy_date, expression=timestamp) 4561 result = self.func("DATE_TRUNC", unit, date_time) 4562 return self.sql(exp.Cast(this=result, to=timestamp.type)) 4563 4564 if timestamp.is_type(*exp.DataType.TEMPORAL_TYPES) and not ( 4565 date_unit and timestamp.is_type(exp.DType.DATE) 4566 ): 4567 return self.sql(exp.Cast(this=result, to=timestamp.type)) 4568 4569 return result
4571 def trim_sql(self, expression: exp.Trim) -> str: 4572 expression.this.replace(_cast_to_varchar(expression.this)) 4573 if expression.expression: 4574 expression.expression.replace(_cast_to_varchar(expression.expression)) 4575 4576 result_sql = super().trim_sql(expression) 4577 return _gen_with_cast_to_blob(self, expression, result_sql)
4579 def round_sql(self, expression: exp.Round) -> str: 4580 this = expression.this 4581 decimals = expression.args.get("decimals") 4582 truncate = expression.args.get("truncate") 4583 4584 # DuckDB requires the scale (decimals) argument to be an INT 4585 # Some dialects (e.g., Snowflake) allow non-integer scales and cast to an integer internally 4586 if decimals is not None and expression.args.get("casts_non_integer_decimals"): 4587 if not (decimals.is_int or decimals.is_type(*exp.DataType.INTEGER_TYPES)): 4588 decimals = exp.cast(decimals, exp.DType.INT) 4589 4590 func = "ROUND" 4591 if truncate: 4592 # BigQuery uses ROUND_HALF_EVEN; Snowflake uses HALF_TO_EVEN 4593 if truncate.this in ("ROUND_HALF_EVEN", "HALF_TO_EVEN"): 4594 func = "ROUND_EVEN" 4595 truncate = None 4596 # BigQuery uses ROUND_HALF_AWAY_FROM_ZERO; Snowflake uses HALF_AWAY_FROM_ZERO 4597 elif truncate.this in ("ROUND_HALF_AWAY_FROM_ZERO", "HALF_AWAY_FROM_ZERO"): 4598 truncate = None 4599 4600 return self.func(func, this, decimals, truncate)
4602 def trycast_sql(self, expression: exp.TryCast) -> str: 4603 to = expression.to 4604 to_type = to.this 4605 src = expression.this 4606 4607 if ( 4608 expression.args.get("null_on_text_overflow") 4609 and to_type in exp.DataType.TEXT_TYPES 4610 and to.expressions 4611 ): 4612 return self.sql( 4613 exp.case() 4614 .when( 4615 exp.LTE(this=exp.func("LENGTH", src), expression=to.expressions[0].this), 4616 exp.cast(src, "TEXT"), 4617 ) 4618 .else_(exp.Null()) 4619 ) 4620 elif to_type == exp.DType.DATE and expression.args.get("probe_date_format"): 4621 slash_strptime = exp.cast( 4622 exp.func("TRY_STRPTIME", src, exp.Literal.string(self._TRYCAST_DATE_SLASH_FMT)), 4623 "DATE", 4624 ) 4625 mon_strptime = exp.cast( 4626 exp.func("TRY_STRPTIME", src, exp.Literal.string(self._TRYCAST_DATE_MON_FMT)), 4627 "DATE", 4628 ) 4629 return self.sql( 4630 exp.case() 4631 .when(exp.func("CONTAINS", src, exp.Literal.string("/")), slash_strptime) 4632 .when( 4633 exp.RegexpLike(this=src, expression=exp.Literal.string("[A-Za-z]")), 4634 mon_strptime, 4635 ) 4636 .else_(exp.TryCast(this=src, to=to)) 4637 ) 4638 elif ( 4639 isinstance(to_type, exp.Interval) 4640 and (unit := to_type.unit) 4641 and expression.args.get("requires_string") 4642 ): 4643 interval_type = exp.DataType.build("INTERVAL") 4644 if isinstance(unit, exp.IntervalSpan): 4645 self.unsupported( 4646 "TRY_CAST to INTERVAL with span (e.g. HOUR TO MINUTE) is not supported in DuckDB" 4647 ) 4648 return self.sql(exp.TryCast(this=src, to=interval_type)) 4649 return self.sql( 4650 exp.TryCast( 4651 this=exp.DPipe(this=src, expression=exp.Literal.string(f" {unit.name}")), 4652 to=interval_type, 4653 ) 4654 ) 4655 4656 return super().trycast_sql(expression)
4658 def strtok_sql(self, expression: exp.Strtok) -> str: 4659 string_arg = expression.this 4660 delimiter_arg = expression.args.get("delimiter") 4661 part_index_arg = expression.args.get("part_index") 4662 4663 if delimiter_arg and part_index_arg: 4664 # Escape regex chars and build character class at runtime using REGEXP_REPLACE 4665 escaped_delimiter = exp.Anonymous( 4666 this="REGEXP_REPLACE", 4667 expressions=[ 4668 delimiter_arg, 4669 exp.Literal.string( 4670 r"([\[\]^.\-*+?(){}|$\\])" 4671 ), # Escape problematic regex chars 4672 exp.Literal.string( 4673 r"\\\1" 4674 ), # Replace with escaped version using $1 backreference 4675 exp.Literal.string("g"), # Global flag 4676 ], 4677 ) 4678 # CASE WHEN delimiter = '' THEN '' ELSE CONCAT('[', escaped_delimiter, ']') END 4679 regex_pattern = ( 4680 exp.case() 4681 .when(delimiter_arg.eq(exp.Literal.string("")), exp.Literal.string("")) 4682 .else_( 4683 exp.func( 4684 "CONCAT", 4685 exp.Literal.string("["), 4686 escaped_delimiter, 4687 exp.Literal.string("]"), 4688 ) 4689 ) 4690 ) 4691 4692 # STRTOK skips empty strings, so we need to filter them out 4693 # LIST_FILTER(REGEXP_SPLIT_TO_ARRAY(string, pattern), x -> x != '')[index] 4694 split_array = exp.func("REGEXP_SPLIT_TO_ARRAY", string_arg, regex_pattern) 4695 x = exp.to_identifier("x") 4696 is_empty = x.eq(exp.Literal.string("")) 4697 filtered_array = exp.func( 4698 "LIST_FILTER", 4699 split_array, 4700 exp.Lambda(this=exp.not_(is_empty.copy()), expressions=[x.copy()]), 4701 ) 4702 base_func = exp.Bracket( 4703 this=filtered_array, 4704 expressions=[part_index_arg], 4705 offset=1, 4706 ) 4707 4708 # Use template with the built regex pattern 4709 result = exp.replace_placeholders( 4710 self.STRTOK_TEMPLATE.copy(), 4711 string=string_arg, 4712 delimiter=delimiter_arg, 4713 part_index=part_index_arg, 4714 base_func=base_func, 4715 ) 4716 4717 return self.sql(result) 4718 4719 return self.function_fallback_sql(expression)
4721 def strtoktoarray_sql(self, expression: exp.StrtokToArray) -> str: 4722 string_arg = expression.this 4723 delimiter_arg = expression.args.get("expression") or exp.Literal.string(" ") 4724 4725 escaped = exp.RegexpReplace( 4726 this=delimiter_arg.copy(), 4727 expression=exp.Literal.string(r"([\[\]^.\-*+?(){}|$\\])"), 4728 replacement=exp.Literal.string(r"\\\1"), 4729 modifiers=exp.Literal.string("g"), 4730 ) 4731 return self.sql( 4732 exp.replace_placeholders( 4733 self.STRTOK_TO_ARRAY_TEMPLATE.copy(), 4734 string=string_arg, 4735 delimiter=delimiter_arg, 4736 escaped=escaped, 4737 ) 4738 )
4740 def approxquantile_sql(self, expression: exp.ApproxQuantile) -> str: 4741 result = self.func("APPROX_QUANTILE", expression.this, expression.args.get("quantile")) 4742 4743 # DuckDB returns integers for APPROX_QUANTILE, cast to DOUBLE if the expected type is a real type 4744 if expression.is_type(*exp.DataType.REAL_TYPES): 4745 result = f"CAST({result} AS DOUBLE)" 4746 4747 return result
4749 def approxquantiles_sql(self, expression: exp.ApproxQuantiles) -> str: 4750 """ 4751 BigQuery's APPROX_QUANTILES(expr, n) returns an array of n+1 approximate quantile values 4752 dividing the input distribution into n equal-sized buckets. 4753 4754 Both BigQuery and DuckDB use approximate algorithms for quantile estimation, but BigQuery 4755 does not document the specific algorithm used so results may differ. DuckDB does not 4756 support RESPECT NULLS. 4757 """ 4758 this = expression.this 4759 if isinstance(this, exp.Distinct): 4760 # APPROX_QUANTILES requires 2 args and DISTINCT node grabs both 4761 if len(this.expressions) < 2: 4762 self.unsupported("APPROX_QUANTILES requires a bucket count argument") 4763 return self.function_fallback_sql(expression) 4764 num_quantiles_expr = this.expressions[1].pop() 4765 else: 4766 num_quantiles_expr = expression.expression 4767 4768 if not isinstance(num_quantiles_expr, exp.Literal) or not num_quantiles_expr.is_int: 4769 self.unsupported("APPROX_QUANTILES bucket count must be a positive integer") 4770 return self.function_fallback_sql(expression) 4771 4772 num_quantiles = t.cast(int, num_quantiles_expr.to_py()) 4773 if num_quantiles <= 0: 4774 self.unsupported("APPROX_QUANTILES bucket count must be a positive integer") 4775 return self.function_fallback_sql(expression) 4776 4777 quantiles = [ 4778 exp.Literal.number(Decimal(i) / Decimal(num_quantiles)) 4779 for i in range(num_quantiles + 1) 4780 ] 4781 4782 return self.sql(exp.ApproxQuantile(this=this, quantile=exp.Array(expressions=quantiles)))
BigQuery's APPROX_QUANTILES(expr, n) returns an array of n+1 approximate quantile values dividing the input distribution into n equal-sized buckets.
Both BigQuery and DuckDB use approximate algorithms for quantile estimation, but BigQuery does not document the specific algorithm used so results may differ. DuckDB does not support RESPECT NULLS.
4784 def jsonextractscalar_sql(self, expression: exp.JSONExtractScalar) -> str: 4785 if expression.args.get("scalar_only"): 4786 json_value = exp.JSONExtractScalar( 4787 this=rename_func("JSON_VALUE")(self, expression), expression="'$'" 4788 ) 4789 4790 # `->>` binds looser than most operators, so the wrap logic needs the parent 4791 json_value.parent = expression.parent 4792 expression = json_value 4793 4794 return _arrow_json_extract_sql(self, expression)
4796 def bitwisenot_sql(self, expression: exp.BitwiseNot) -> str: 4797 this = expression.this 4798 4799 if _is_binary(this): 4800 expression.type = exp.DType.BINARY.into_expr() 4801 4802 arg = _cast_to_bit(this) 4803 4804 if isinstance(this, exp.Neg): 4805 arg = exp.Paren(this=arg) 4806 4807 expression.set("this", arg) 4808 4809 result_sql = f"~{self.sql(expression, 'this')}" 4810 4811 return _gen_with_cast_to_blob(self, expression, result_sql)
4845 def uuid_sql(self, expression: exp.Uuid) -> str: 4846 namespace = expression.this 4847 name = expression.args.get("name") 4848 4849 # UUID v5 (namespace + name) - Emulate using SHA1 4850 if namespace and name: 4851 result = exp.replace_placeholders( 4852 self.UUID_V5_TEMPLATE.copy(), 4853 namespace=namespace, 4854 name=name, 4855 ) 4856 return self.sql(result) 4857 4858 return super().uuid_sql(expression)
Inherited Members
- sqlglot.generator.Generator
- Generator
- NULL_ORDERING_SUPPORTED
- WINDOW_FUNCS_WITH_NULL_ORDERING
- LOCKING_READS_SUPPORTED
- EXCEPT_INTERSECT_SUPPORT_ALL_CLAUSE
- WRAP_DERIVED_VALUES
- CREATE_FUNCTION_RETURN_AS
- MATCHED_BY_SOURCE
- SUPPORTS_MERGE_WHERE
- SINGLE_STRING_INTERVAL
- INTERVAL_ALLOWS_PLURAL_FORM
- AUTO_REFRESH_BARE_INTERVALS
- LIMIT_ONLY_LITERALS
- GROUPINGS_SEP
- SUPPORTS_GROUPING_SETS_AS_SUFFIX
- INDEX_ON
- INOUT_SEPARATOR
- DIRECTED_JOINS
- QUERY_HINT_SEP
- IS_BOOL_ALLOWED
- DUPLICATE_KEY_UPDATE_WITH_SET
- LIMIT_IS_TOP
- RETURNING_END
- EXTRACT_ALLOWS_QUOTES
- TZ_TO_WITH_TIME_ZONE
- VALUES_AS_TABLE
- ALTER_TABLE_INCLUDE_COLUMN_KEYWORD
- UNNEST_WITH_ORDINALITY
- COMPUTED_COLUMN_WITH_TYPE
- SUPPORTS_TABLE_COPY
- TABLESAMPLE_REQUIRES_PARENS
- TABLESAMPLE_SIZE_IS_ROWS
- TABLESAMPLE_WITH_METHOD
- COLLATE_IS_FUNC
- DATA_TYPE_SPECIFIERS_ALLOWED
- ENSURE_BOOLS
- CTE_RECURSIVE_KEYWORD_REQUIRED
- SUPPORTS_SINGLE_ARG_CONCAT
- SUPPORTS_TABLE_ALIAS_COLUMNS
- SUPPORTS_NAMED_CTE_COLUMNS
- UNPIVOT_ALIASES_ARE_IDENTIFIERS
- PIVOT_ALIAS_WITH_AS
- INSERT_OVERWRITE
- SUPPORTS_SELECT_INTO
- SUPPORTS_UNLOGGED_TABLES
- SUPPORTS_MODIFY_COLUMN
- SUPPORTS_CHANGE_COLUMN
- SUPPORTS_ALTER_COLUMN_NULLABILITY
- SUPPORTS_ALTER_COLUMN_IF_EXISTS
- LIKE_PROPERTY_INSIDE_SCHEMA
- JSON_TYPE_REQUIRED_FOR_EXTRACTION
- JSON_PATH_SINGLE_QUOTE_ESCAPE
- JSON_PATH_KEY_QUOTED_FORCES_BRACKETS
- SET_OP_MODIFIERS
- SET_OP_LIMITS
- SET_OP_PARENTHESIZED_OPERANDS
- COPY_PARAMS_ARE_WRAPPED
- COPY_PARAMS_EQ_REQUIRED
- TRY_SUPPORTED
- SUPPORTS_UESCAPE
- UNICODE_SUBSTITUTE
- HEX_FUNC
- BYTE_STRING_FUNCTION
- WITH_PROPERTIES_PREFIX
- QUOTE_JSON_PATH
- SUPPORTS_EXPLODING_PROJECTIONS
- ARRAY_CONCAT_IS_VAR_LEN
- SUPPORTS_CONVERT_TIMEZONE
- SUPPORTS_MEDIAN
- SUPPORTS_UNIX_SECONDS
- ALTER_SET_WRAPPED
- PARSE_JSON_NAME
- ARRAY_SIZE_NAME
- ALTER_SET_TYPE
- SUPPORTS_BETWEEN_FLAGS
- MATCH_AGAINST_TABLE_PREFIX
- DECLARE_DEFAULT_ASSIGNMENT
- UPDATE_STATEMENT_SUPPORTS_FROM
- STAR_EXCLUDE_REQUIRES_DERIVED_TABLE
- UNSUPPORTED_TYPES
- TIME_PART_SINGULARS
- TOKEN_MAPPING
- EXPRESSION_PRECEDES_PROPERTIES_CREATABLES
- WITH_SEPARATED_COMMENTS
- EXCLUDE_COMMENTS
- PARAMETERIZABLE_TEXT_TYPES
- EXPRESSIONS_WITHOUT_NESTED_CTES
- RESPECT_IGNORE_NULLS_UNSUPPORTED_EXPRESSIONS
- MOD_OPERATOR
- MOD_PAREN_PARENT_TYPES
- ALTER_PARTITION_OPTIONS
- SAFE_JSON_PATH_KEY_RE
- SENTINEL_LINE_BREAK
- pretty
- identify
- normalize
- pad
- unsupported_level
- max_unsupported
- leading_comma
- max_text_width
- comments
- dialect
- normalize_functions
- unsupported_messages
- generate
- preprocess
- unsupported
- sep
- seg
- sanitize_comment
- maybe_comment
- wrap
- no_identify
- normalize_func
- indent
- sql
- uncache_sql
- cache_sql
- characterset_sql
- column_parts
- column_sql
- pseudocolumn_sql
- columnposition_sql
- columndef_sql
- columnconstraint_sql
- computedcolumnconstraint_sql
- compresscolumnconstraint_sql
- generatedasidentitycolumnconstraint_sql
- generatedasrowcolumnconstraint_sql
- periodforsystemtimeconstraint_sql
- notnullcolumnconstraint_sql
- primarykeycolumnconstraint_sql
- uniquecolumnconstraint_sql
- inoutcolumnconstraint_sql
- createable_sql
- create_sql
- sequenceproperties_sql
- triggerproperties_sql
- triggerreferencing_sql
- triggerevent_sql
- clone_sql
- describe_sql
- heredoc_sql
- prepend_ctes
- with_sql
- cte_sql
- tablealias_sql
- bitstring_sql
- unicodestring_sql
- rawstring_sql
- datatypeparam_sql
- datatype_param_bound_limiter
- directory_sql
- delete_sql
- drop_sql
- set_operation
- set_operations
- fetch_sql
- limitoptions_sql
- hint_sql
- indexparameters_sql
- index_sql
- dynamicidentifier_sql
- identifier_sql
- lowerhex_sql
- inputoutputformat_sql
- national_sql
- partition_sql
- properties_sql
- root_properties
- properties
- with_properties
- locate_properties
- property_name
- property_sql
- uuidproperty_sql
- likeproperty_sql
- fallbackproperty_sql
- journalproperty_sql
- freespaceproperty_sql
- checksumproperty_sql
- mergeblockratioproperty_sql
- moduleproperty_sql
- datablocksizeproperty_sql
- blockcompressionproperty_sql
- isolatedloadingproperty_sql
- partitionboundspec_sql
- partitionedofproperty_sql
- lockingproperty_sql
- withdataproperty_sql
- withsystemversioningproperty_sql
- insert_sql
- introducer_sql
- kill_sql
- pseudotype_sql
- objectidentifier_sql
- onconflict_sql
- returning_sql
- rowformatdelimitedproperty_sql
- withtablehint_sql
- indextablehint_sql
- historicaldata_sql
- table_parts
- table_sql
- pivot_sql
- version_sql
- tuple_sql
- update_sql
- values_sql
- var_sql
- into_sql
- from_sql
- groupingsets_sql
- rollup_sql
- rollupindex_sql
- rollupproperty_sql
- cube_sql
- group_sql
- having_sql
- connect_sql
- prior_sql
- lateral_op
- lateral_sql
- limit_sql
- offset_sql
- setitem_sql
- set_sql
- queryband_sql
- pragma_sql
- lock_sql
- literal_sql
- escape_str
- loaddata_sql
- null_sql
- boolean_sql
- booland_sql
- boolor_sql
- order_sql
- withfill_sql
- cluster_sql
- clusterproperty_sql
- distribute_sql
- sort_sql
- ordered_sql
- matchrecognizemeasure_sql
- matchrecognize_sql
- query_modifiers
- options_modifier
- forclause_sql
- queryoption_sql
- offset_limit_modifiers
- select_sql
- schema_sql
- schema_columns_sql
- star_sql
- parameter_sql
- sessionparameter_sql
- placeholder_sql
- subquery_sql
- qualify_sql
- prewhere_sql
- where_sql
- partition_by_sql
- windowspec_sql
- between_sql
- bracket_offset_expressions
- all_sql
- any_sql
- exists_sql
- case_sql
- constraint_sql
- nextvaluefor_sql
- convert_concat_args
- concat_sql
- check_sql
- foreignkey_sql
- primarykey_sql
- timeserieskey_sql
- if_sql
- matchagainst_sql
- jsonkeyvalue_sql
- jsonpath_sql
- json_path_part
- formatjson_sql
- formatphrase_sql
- jsonarray_sql
- jsonarrayagg_sql
- jsoncolumndef_sql
- jsonschema_sql
- jsontable_sql
- openjsoncolumndef_sql
- openjson_sql
- in_unnest_op
- interval_sql
- return_sql
- reference_sql
- anonymous_sql
- paren_sql
- neg_sql
- not_sql
- alias_sql
- pivotalias_sql
- atindex_sql
- attimezone_sql
- fromtimezone_sql
- fromiso8601date_sql
- fromiso8601timestamp_sql
- fromiso8601timestampnanos_sql
- add_sql
- and_sql
- or_sql
- xor_sql
- connector_sql
- bitwiseand_sql
- bitwiseleftshift_sql
- bitwiseor_sql
- bitwiserightshift_sql
- cast_sql
- command_sql
- comment_sql
- mergetreettlaction_sql
- mergetreettl_sql
- transaction_sql
- commit_sql
- rollback_sql
- altercolumn_sql
- modifycolumn_sql
- alterindex_sql
- alterdiststyle_sql
- altersortkey_sql
- alterrename_sql
- renamecolumn_sql
- alterset_sql
- alter_sql
- altersession_sql
- add_column_sql
- droppartition_sql
- dropprimarykey_sql
- addconstraint_sql
- addpartition_sql
- distinct_sql
- havingmax_sql
- intdiv_sql
- dpipe_sql
- div_sql
- safedivide_sql
- overlaps_sql
- distance_sql
- distancend_sql
- dot_sql
- eq_sql
- propertyeq_sql
- escape_sql
- glob_sql
- gt_sql
- gte_sql
- is_sql
- like_sql
- ilike_sql
- match_sql
- similarto_sql
- lt_sql
- lte_sql
- mod_sql
- mul_sql
- neq_sql
- nullsafeeq_sql
- nullsafeneq_sql
- sub_sql
- jsoncast_sql
- try_sql
- log_sql
- use_sql
- binary
- ceil_floor
- function_fallback_sql
- function_name
- func
- format_args
- too_wide
- format_time
- expressions
- op_expressions
- naked_property
- tag_sql
- token_sql
- userdefinedfunction_sql
- macrooverloads_sql
- macrooverload_sql
- joinhint_sql
- kwarg_sql
- when_sql
- whens_sql
- merge_sql
- tochar_sql
- dictproperty_sql
- dictrange_sql
- dictsubproperty_sql
- duplicatekeyproperty_sql
- uniquekeyproperty_sql
- distributedbyproperty_sql
- oncluster_sql
- clusteredbyproperty_sql
- anyvalue_sql
- querytransform_sql
- indexconstraintoption_sql
- checkcolumnconstraint_sql
- indexcolumnconstraint_sql
- nvl2_sql
- nthvalue_sql
- comprehension_sql
- columnprefix_sql
- opclass_sql
- predict_sql
- generateembedding_sql
- generatetext_sql
- generatetable_sql
- generatebool_sql
- generateint_sql
- generatedouble_sql
- mltranslate_sql
- mlforecast_sql
- aiforecast_sql
- featuresattime_sql
- vectorsearch_sql
- forin_sql
- refresh_sql
- toarray_sql
- tsordstotimestamp_sql
- tsordstodatetime_sql
- tsordstodate_sql
- unixdate_sql
- lastday_sql
- dateadd_sql
- arrayinsert_sql
- arrayany_sql
- struct_sql
- partitionrange_sql
- truncatetable_sql
- convert_sql
- copyparameter_sql
- credentials_sql
- copy_sql
- semicolon_sql
- datadeletionproperty_sql
- maskingpolicycolumnconstraint_sql
- gapfill_sql
- scope_resolution
- scoperesolution_sql
- changes_sql
- summarize_sql
- explodinggenerateseries_sql
- converttimezone_sql
- json_sql
- jsonvalue_sql
- skipjsoncolumn_sql
- conditionalinsert_sql
- multitableinserts_sql
- oncondition_sql
- jsonextractquote_sql
- jsonexists_sql
- slice_sql
- apply_sql
- grant_sql
- revoke_sql
- grantprivilege_sql
- grantprincipal_sql
- columns_sql
- overlay_sql
- todouble_sql
- string_sql
- median_sql
- overflowtruncatebehavior_sql
- unixseconds_sql
- arraysize_sql
- attach_sql
- detach_sql
- attachoption_sql
- watermarkcolumnconstraint_sql
- encodeproperty_sql
- includeproperty_sql
- xmlelement_sql
- xmlkeyvalueoption_sql
- partitionbyrangeproperty_sql
- partitionbyrangepropertydynamic_sql
- unpivotcolumns_sql
- analyzesample_sql
- analyzestatistics_sql
- analyzehistogram_sql
- analyzedelete_sql
- analyzelistchainedrows_sql
- analyzevalidate_sql
- analyze_sql
- xmltable_sql
- xmlnamespace_sql
- export_sql
- declare_sql
- declareitem_sql
- recursivewithsearch_sql
- parameterizedagg_sql
- anonymousaggfunc_sql
- combinedaggfunc_sql
- combinedparameterizedagg_sql
- liststage_sql
- get_put_sql
- translatecharacters_sql
- decodecase_sql
- semanticview_sql
- getextract_sql
- datefromunixdate_sql
- buildproperty_sql
- refreshtriggerproperty_sql
- modelattribute_sql
- directorystage_sql
- initcap_sql
- localtime_sql
- localtimestamp_sql
- weekstart_name
- weekstart_sql
- weightstring_sql
- block_sql
- functionspecification_sql
- storedprocedure_sql
- ifblock_sql
- casestatement_sql
- whileblock_sql
- loopblock_sql
- repeatblock_sql
- leave_sql
- iterate_sql
- execute_sql
- executesql_sql
- altermodifysqlsecurity_sql
- usingproperty_sql
- renameindex_sql