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