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