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