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 map_date_part, 28 months_between_sql, 29 no_datetime_sql, 30 no_comment_column_constraint_sql, 31 no_make_interval_sql, 32 no_time_sql, 33 no_timestamp_sql, 34 rename_func, 35 remove_from_array_using_filter, 36 strposition_sql, 37 timestrtotime_sql, 38 unit_to_str, 39 week_unit_to_dow, 40 weekstart_unit_to_str, 41 WEEK_START_DAY_TO_DOW, 42) 43from sqlglot.generator import unsupported_args 44from sqlglot.helper import find_new_name, is_date_unit, seq_get 45from sqlglot.optimizer.scope import find_all_in_scope 46from builtins import type as Type 47 48_CONNECT_BY_ARGS_TO_SKIP = frozenset({"connect", "where", "from_", "with_", "expressions"}) 49 50# Regex to detect time zones in timestamps of the form [+|-]TT[:tt] 51# The pattern matches timezone offsets that appear after the time portion 52TIMEZONE_PATTERN = re.compile(r":\d{2}.*?[+\-]\d{2}(?::\d{2})?") 53 54# Characters that must be escaped when building regex expressions in INITCAP 55REGEX_ESCAPE_REPLACEMENTS = { 56 "\\": "\\\\", 57 "-": r"\-", 58 "^": r"\^", 59 "[": r"\[", 60 "]": r"\]", 61} 62 63# Used to in RANDSTR transpilation 64RANDSTR_CHAR_POOL = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" 65RANDSTR_SEED = 123456 66 67# Whitespace control characters that DuckDB must process with `CHR({val})` calls 68WS_CONTROL_CHARS_TO_DUCK = { 69 "\u000b": 11, 70 "\u001c": 28, 71 "\u001d": 29, 72 "\u001e": 30, 73 "\u001f": 31, 74} 75 76MAX_BIT_POSITION = exp.Literal.number(32768) 77 78# cs/as/ps are Snowflake defaults; DuckDB already behaves the same way, so they are safe to drop. 79# Note: "as" is also a reserved keyword in DuckDB, making it impossible to pass through. 80_SNOWFLAKE_COLLATION_DEFAULTS = frozenset({"cs", "as", "ps"}) 81_SNOWFLAKE_COLLATION_UNSUPPORTED = frozenset( 82 {"ci", "ai", "upper", "lower", "utf8", "bin", "pi", "fl", "fu", "trim", "ltrim", "rtrim"} 83) 84 85# Window functions that support IGNORE/RESPECT NULLS in DuckDB 86_IGNORE_RESPECT_NULLS_WINDOW_FUNCTIONS = ( 87 exp.FirstValue, 88 exp.Lag, 89 exp.LastValue, 90 exp.Lead, 91 exp.NthValue, 92) 93 94# SEQ function constants 95_SEQ_BASE: exp.Expr = exp.maybe_parse("(ROW_NUMBER() OVER (ORDER BY 1) - 1)") 96_SEQ_RESTRICTED = (exp.Where, exp.Having, exp.AggFunc, exp.Order, exp.Select) 97# Maps SEQ expression types to their byte width (suffix indicates bytes: SEQ1=1, SEQ2=2, etc.) 98_SEQ_BYTE_WIDTH = {exp.Seq1: 1, exp.Seq2: 2, exp.Seq4: 4, exp.Seq8: 8} 99 100# Template for generating signed and unsigned SEQ values within a specified range 101_SEQ_UNSIGNED: exp.Expr = exp.maybe_parse(":base % :max_val") 102_SEQ_SIGNED: exp.Expr = exp.maybe_parse( 103 "(CASE WHEN :base % :max_val >= :half " 104 "THEN :base % :max_val - :max_val " 105 "ELSE :base % :max_val END)" 106) 107 108 109def _apply_base64_alphabet_replacements( 110 result: exp.Expr, 111 alphabet: exp.Expr | None, 112 reverse: bool = False, 113) -> exp.Expr: 114 """ 115 Translate base64 alphabet characters without cascading replacements. 116 117 Base64 alphabet can be 1-3 chars: 1st = index 62 ('+'), 2nd = index 63 ('/'), 3rd = padding ('='). 118 zip truncates to the shorter string, so 1-char alphabet only replaces '+', 2-char replaces '+/', etc. 119 120 Args: 121 result: The expression to apply replacements to 122 alphabet: Custom alphabet literal (expected chars for +/=) 123 reverse: If False, replace default with custom (encode) 124 If True, replace custom with default (decode) 125 """ 126 if isinstance(alphabet, exp.Literal) and alphabet.is_string: 127 source, target = "", "" 128 for default_char, new_char in zip("+/=", alphabet.this): 129 if new_char != default_char: 130 source += default_char 131 target += new_char 132 if source: 133 if reverse: 134 source, target = target, source 135 result = exp.Translate( 136 this=result, 137 from_=exp.Literal.string(source), 138 to=exp.Literal.string(target), 139 ) 140 return result 141 142 143def _base64_decode_sql(self: DuckDBGenerator, expression: exp.Expr, to_string: bool) -> str: 144 """ 145 Transpile Snowflake BASE64_DECODE_STRING/BINARY to DuckDB. 146 147 DuckDB uses FROM_BASE64() which returns BLOB. For string output, wrap with DECODE(). 148 Custom alphabets require TRANSLATE() to convert to standard base64. 149 """ 150 input_expr = expression.this 151 alphabet = expression.args.get("alphabet") 152 153 # Handle custom alphabet by replacing non-standard chars with standard ones 154 input_expr = _apply_base64_alphabet_replacements(input_expr, alphabet, reverse=True) 155 156 # FROM_BASE64 returns BLOB 157 input_expr = exp.FromBase64(this=input_expr) 158 159 if to_string: 160 input_expr = exp.Decode(this=input_expr) 161 162 return self.sql(input_expr) 163 164 165def _last_day_sql(self: DuckDBGenerator, expression: exp.LastDay) -> str: 166 """ 167 DuckDB's LAST_DAY only supports finding the last day of a month. 168 For other date parts (year, quarter, week), we need to implement equivalent logic. 169 """ 170 date_expr = expression.this 171 unit_expr = expression.args.get("unit") 172 173 week_start = week_unit_to_dow(unit_expr) 174 if week_start: 175 # The week's last day precedes its start day; DuckDB DAYOFWEEK: Sunday=0, ..., Saturday=6 176 last_dow = week_start - 1 177 dow = exp.func("EXTRACT", "DAYOFWEEK", date_expr) 178 179 # Days to the last day of week: (last_dow + 7 - dayofweek) % 7 180 days_to_last_expr = exp.Mod( 181 this=exp.Paren(this=exp.Sub(this=exp.Literal.number(last_dow + 7), expression=dow)), 182 expression=exp.Literal.number(7), 183 ) 184 interval_expr = exp.Interval(this=days_to_last_expr, unit=exp.var("DAY")) 185 add_expr = exp.Add(this=date_expr, expression=interval_expr) 186 187 return self.sql(exp.cast(add_expr, exp.DType.DATE)) 188 189 unit = expression.text("unit") 190 191 if not unit or unit.upper() == "MONTH": 192 # Default behavior - use DuckDB's native LAST_DAY 193 return self.func("LAST_DAY", date_expr) 194 195 if unit.upper() == "YEAR": 196 # Last day of year: December 31st of the same year 197 year_expr = exp.func("EXTRACT", "YEAR", date_expr) 198 make_date_expr = exp.func( 199 "MAKE_DATE", year_expr, exp.Literal.number(12), exp.Literal.number(31) 200 ) 201 return self.sql(make_date_expr) 202 203 if unit.upper() == "QUARTER": 204 # Last day of quarter 205 year_expr = exp.func("EXTRACT", "YEAR", date_expr) 206 quarter_expr = exp.func("EXTRACT", "QUARTER", date_expr) 207 208 # Calculate last month of quarter: quarter * 3. Quarter can be 1 to 4 209 last_month_expr = exp.Mul(this=quarter_expr, expression=exp.Literal.number(3)) 210 first_day_last_month_expr = exp.func( 211 "MAKE_DATE", year_expr, last_month_expr, exp.Literal.number(1) 212 ) 213 214 # Last day of the last month of the quarter 215 last_day_expr = exp.func("LAST_DAY", first_day_last_month_expr) 216 return self.sql(last_day_expr) 217 218 self.unsupported(f"Unsupported date part '{unit}' in LAST_DAY function") 219 return self.function_fallback_sql(expression) 220 221 222def _is_nanosecond_unit(unit: exp.Expr | None) -> bool: 223 return isinstance(unit, (exp.Var, exp.Literal)) and unit.name.upper() == "NANOSECOND" 224 225 226def _handle_nanosecond_diff( 227 self: DuckDBGenerator, 228 end_time: exp.Expr, 229 start_time: exp.Expr, 230) -> str: 231 """Generate NANOSECOND diff using EPOCH_NS since DATE_DIFF doesn't support it.""" 232 end_ns = exp.cast(end_time, exp.DType.TIMESTAMP_NS) 233 start_ns = exp.cast(start_time, exp.DType.TIMESTAMP_NS) 234 235 # Build expression tree: EPOCH_NS(end) - EPOCH_NS(start) 236 return self.sql( 237 exp.Sub(this=exp.func("EPOCH_NS", end_ns), expression=exp.func("EPOCH_NS", start_ns)) 238 ) 239 240 241def _to_boolean_sql(self: DuckDBGenerator, expression: exp.ToBoolean) -> str: 242 """ 243 Transpile TO_BOOLEAN and TRY_TO_BOOLEAN functions from Snowflake to DuckDB equivalent. 244 245 DuckDB's CAST to BOOLEAN supports most of Snowflake's TO_BOOLEAN strings except 'on'/'off'. 246 We need to handle the 'on'/'off' cases explicitly. 247 248 For TO_BOOLEAN (safe=False): NaN and INF values cause errors. We use DuckDB's native ERROR() 249 function to replicate this behavior with a clear error message. 250 251 For TRY_TO_BOOLEAN (safe=True): Use DuckDB's TRY_CAST for conversion, which returns NULL 252 for invalid inputs instead of throwing errors. 253 """ 254 arg = expression.this 255 is_safe = expression.args.get("safe", False) 256 257 base_case_expr = ( 258 exp.case() 259 .when( 260 # Handle 'on' -> TRUE (case insensitive) 261 exp.Upper(this=exp.cast(arg, exp.DType.VARCHAR)).eq(exp.Literal.string("ON")), 262 exp.true(), 263 ) 264 .when( 265 # Handle 'off' -> FALSE (case insensitive) 266 exp.Upper(this=exp.cast(arg, exp.DType.VARCHAR)).eq(exp.Literal.string("OFF")), 267 exp.false(), 268 ) 269 ) 270 271 if is_safe: 272 # TRY_TO_BOOLEAN: handle 'on'/'off' and use TRY_CAST for everything else 273 case_expr = base_case_expr.else_(exp.func("TRY_CAST", arg, exp.DType.BOOLEAN.into_expr())) 274 else: 275 # TO_BOOLEAN: handle NaN/INF errors, 'on'/'off', and use regular CAST 276 cast_to_real = exp.func("TRY_CAST", arg, exp.DType.FLOAT.into_expr()) 277 278 # Check for NaN and INF values 279 nan_inf_check = exp.Or( 280 this=exp.func("ISNAN", cast_to_real), expression=exp.func("ISINF", cast_to_real) 281 ) 282 283 case_expr = base_case_expr.when( 284 nan_inf_check, 285 exp.func( 286 "ERROR", 287 exp.Literal.string("TO_BOOLEAN: Non-numeric values NaN and INF are not supported"), 288 ), 289 ).else_(exp.cast(arg, exp.DType.BOOLEAN)) 290 291 return self.sql(case_expr) 292 293 294# BigQuery -> DuckDB conversion for the DATE function 295def _date_sql(self: DuckDBGenerator, expression: exp.Date) -> str: 296 this = expression.this 297 zone = self.sql(expression, "zone") 298 299 if zone: 300 # BigQuery considers "this" at UTC, converts it to the specified 301 # time zone and then keeps only the DATE part 302 # To micmic that, we: 303 # (1) Cast to TIMESTAMP to remove DuckDB's local tz 304 # (2) Apply consecutive AtTimeZone calls for UTC -> zone conversion 305 this = exp.cast(this, exp.DType.TIMESTAMP) 306 at_utc = exp.AtTimeZone(this=this, zone=exp.Literal.string("UTC")) 307 this = exp.AtTimeZone(this=at_utc, zone=zone) 308 309 return self.sql(exp.cast(expression=this, to=exp.DType.DATE)) 310 311 312# BigQuery -> DuckDB conversion for the TIME_DIFF function 313def _timediff_sql(self: DuckDBGenerator, expression: exp.TimeDiff) -> str: 314 unit = expression.unit 315 316 if _is_nanosecond_unit(unit): 317 return _handle_nanosecond_diff(self, expression.expression, expression.this) 318 319 this = exp.cast(expression.this, exp.DType.TIME) 320 expr = exp.cast(expression.expression, exp.DType.TIME) 321 322 # Although the 2 dialects share similar signatures, BQ seems to inverse 323 # the sign of the result so the start/end time operands are flipped 324 return self.func("DATE_DIFF", unit_to_str(expression), expr, this) 325 326 327def _date_delta_to_binary_interval_op( 328 cast: bool = True, 329) -> t.Callable[[DuckDBGenerator, DATETIME_DELTA], str]: 330 """ 331 DuckDB override to handle: 332 1. NANOSECOND operations (DuckDB doesn't support INTERVAL ... NANOSECOND) 333 2. Float/decimal interval values (DuckDB INTERVAL requires integers) 334 """ 335 base_impl = date_delta_to_binary_interval_op(cast=cast) 336 337 def _duckdb_date_delta_sql(self: DuckDBGenerator, expression: DATETIME_DELTA) -> str: 338 unit = expression.unit 339 interval_value = expression.expression 340 341 # Handle NANOSECOND unit (DuckDB doesn't support INTERVAL ... NANOSECOND) 342 if _is_nanosecond_unit(unit): 343 if isinstance(interval_value, exp.Interval): 344 interval_value = interval_value.this 345 346 timestamp_ns = exp.cast(expression.this, exp.DType.TIMESTAMP_NS) 347 348 return self.sql( 349 exp.func( 350 "MAKE_TIMESTAMP_NS", 351 exp.Add(this=exp.func("EPOCH_NS", timestamp_ns), expression=interval_value), 352 ) 353 ) 354 355 # Handle float/decimal interval values as duckDB INTERVAL requires integer expressions 356 if not interval_value or isinstance(interval_value, exp.Interval): 357 return base_impl(self, expression) 358 359 if interval_value.is_type(*exp.DataType.REAL_TYPES): 360 expression.set("expression", exp.cast(exp.func("ROUND", interval_value), "INT")) 361 362 return base_impl(self, expression) 363 364 return _duckdb_date_delta_sql 365 366 367def _array_insert_sql(self: DuckDBGenerator, expression: exp.ArrayInsert) -> str: 368 """ 369 Transpile ARRAY_INSERT to DuckDB using LIST_CONCAT and slicing. 370 371 Handles: 372 - 0-based and 1-based indexing (normalizes to 0-based for calculations) 373 - Negative position conversion (requires array length) 374 - NULL propagation (source dialects return NULL, DuckDB creates single-element array) 375 - Assumes position is within bounds per user constraint 376 377 Note: All dialects that support ARRAY_INSERT (Snowflake, Spark, Databricks) have 378 ARRAY_FUNCS_PROPAGATES_NULLS=True, so we always assume source propagates NULLs. 379 380 Args: 381 expression: The ArrayInsert expression to transpile. 382 383 Returns: 384 SQL string implementing ARRAY_INSERT behavior. 385 """ 386 this = expression.this 387 position = expression.args.get("position") 388 element = expression.expression 389 element_array = exp.Array(expressions=[element]) 390 index_offset = expression.args.get("offset", 0) 391 392 if not position or not position.is_int: 393 self.unsupported("ARRAY_INSERT can only be transpiled with a literal position") 394 return self.func("ARRAY_INSERT", this, position, element) 395 396 pos_value = position.to_py() 397 398 # 1-based -1 appends (Spark/Databricks); the 0-based normalization below 399 # would otherwise map it onto the prepend branch. 400 append = index_offset == 1 and pos_value == -1 401 402 # Normalize one-based indexing to zero-based for slice calculations 403 # Spark (1-based) -> Snowflake (0-based): 404 # Positive: pos=1 -> pos=0 (subtract 1) 405 # Negative: pos=-2 -> pos=-1 (add 1) 406 # Example: Spark array_insert([a,b,c], -2, d) -> [a,b,d,c] is same as Snowflake pos=-1 407 if pos_value > 0: 408 pos_value = pos_value - index_offset 409 elif pos_value < 0: 410 pos_value = pos_value + index_offset 411 412 # Build the appropriate list_concat expression based on position 413 if append: 414 # append at end 415 concat_exprs = [this, element_array] 416 elif pos_value == 0: 417 # insert at beginning 418 concat_exprs = [element_array, this] 419 elif pos_value > 0: 420 # Positive position: LIST_CONCAT(arr[1:pos], [elem], arr[pos+1:]) 421 # 0-based -> DuckDB 1-based slicing 422 423 # left slice: arr[1:pos] 424 slice_start = exp.Bracket( 425 this=this, 426 expressions=[ 427 exp.Slice(this=exp.Literal.number(1), expression=exp.Literal.number(pos_value)) 428 ], 429 ) 430 431 # right slice: arr[pos+1:] 432 slice_end = exp.Bracket( 433 this=this, expressions=[exp.Slice(this=exp.Literal.number(pos_value + 1))] 434 ) 435 436 concat_exprs = [slice_start, element_array, slice_end] 437 else: 438 # Negative position: arr[1:LEN(arr)+pos], [elem], arr[LEN(arr)+pos+1:] 439 # pos=-1 means insert before last element 440 arr_len = exp.Length(this=this) 441 442 # Calculate slice position: LEN(arr) + pos (e.g., LEN(arr) + (-1) = LEN(arr) - 1) 443 slice_end_pos = arr_len + exp.Literal.number(pos_value) 444 slice_start_pos = slice_end_pos + exp.Literal.number(1) 445 446 # left slice: arr[1:LEN(arr)+pos] 447 slice_start = exp.Bracket( 448 this=this, 449 expressions=[exp.Slice(this=exp.Literal.number(1), expression=slice_end_pos)], 450 ) 451 452 # right slice: arr[LEN(arr)+pos+1:] 453 slice_end = exp.Bracket(this=this, expressions=[exp.Slice(this=slice_start_pos)]) 454 455 concat_exprs = [slice_start, element_array, slice_end] 456 457 # All dialects that support ARRAY_INSERT propagate NULLs (Snowflake/Spark/Databricks) 458 # Wrap in CASE WHEN array IS NULL THEN NULL ELSE func_expr END 459 return self.sql( 460 exp.If( 461 this=exp.Is(this=this, expression=exp.Null()), 462 true=exp.Null(), 463 false=self.func("LIST_CONCAT", *concat_exprs), 464 ) 465 ) 466 467 468def _array_remove_at_sql(self: DuckDBGenerator, expression: exp.ArrayRemoveAt) -> str: 469 """ 470 Transpile ARRAY_REMOVE_AT to DuckDB using LIST_CONCAT and slicing. 471 472 Handles: 473 - Positive positions (0-based indexing) 474 - Negative positions (from end of array) 475 - NULL propagation (Snowflake returns NULL for NULL array, DuckDB doesn't auto-propagate) 476 - Only supports literal integer positions (non-literals remain untranspiled) 477 478 Transpilation patterns: 479 - pos=0 (first): arr[2:] 480 - pos>0 (middle): LIST_CONCAT(arr[1:p], arr[p+2:]) 481 - pos=-1 (last): arr[1:LEN(arr)-1] 482 - pos<-1: LIST_CONCAT(arr[1:LEN(arr)+p], arr[LEN(arr)+p+2:]) 483 484 All wrapped in: CASE WHEN arr IS NULL THEN NULL ELSE ... END 485 486 Args: 487 expression: The ArrayRemoveAt expression to transpile. 488 489 Returns: 490 SQL string implementing ARRAY_REMOVE_AT behavior. 491 """ 492 this = expression.this 493 position = expression.args.get("position") 494 495 if not position or not position.is_int: 496 self.unsupported("ARRAY_REMOVE_AT can only be transpiled with a literal position") 497 return self.func("ARRAY_REMOVE_AT", this, position) 498 499 pos_value = position.to_py() 500 501 # Build the appropriate expression based on position 502 if pos_value == 0: 503 # Remove first element: arr[2:] 504 result_expr: exp.Expr | str = exp.Bracket( 505 this=this, 506 expressions=[exp.Slice(this=exp.Literal.number(2))], 507 ) 508 elif pos_value > 0: 509 # Remove at positive position: LIST_CONCAT(arr[1:pos], arr[pos+2:]) 510 # DuckDB uses 1-based slicing 511 left_slice = exp.Bracket( 512 this=this, 513 expressions=[ 514 exp.Slice(this=exp.Literal.number(1), expression=exp.Literal.number(pos_value)) 515 ], 516 ) 517 right_slice = exp.Bracket( 518 this=this, 519 expressions=[exp.Slice(this=exp.Literal.number(pos_value + 2))], 520 ) 521 result_expr = self.func("LIST_CONCAT", left_slice, right_slice) 522 elif pos_value == -1: 523 # Remove last element: arr[1:LEN(arr)-1] 524 # Optimization: simpler than general negative case 525 arr_len = exp.Length(this=this) 526 slice_end = arr_len + exp.Literal.number(-1) 527 result_expr = exp.Bracket( 528 this=this, 529 expressions=[exp.Slice(this=exp.Literal.number(1), expression=slice_end)], 530 ) 531 else: 532 # Remove at negative position: LIST_CONCAT(arr[1:LEN(arr)+pos], arr[LEN(arr)+pos+2:]) 533 arr_len = exp.Length(this=this) 534 slice_end_pos = arr_len + exp.Literal.number(pos_value) 535 slice_start_pos = slice_end_pos + exp.Literal.number(2) 536 537 left_slice = exp.Bracket( 538 this=this, 539 expressions=[exp.Slice(this=exp.Literal.number(1), expression=slice_end_pos)], 540 ) 541 right_slice = exp.Bracket( 542 this=this, 543 expressions=[exp.Slice(this=slice_start_pos)], 544 ) 545 result_expr = self.func("LIST_CONCAT", left_slice, right_slice) 546 547 # Snowflake ARRAY_FUNCS_PROPAGATES_NULLS=True, so wrap in NULL check 548 # CASE WHEN array IS NULL THEN NULL ELSE result_expr END 549 return self.sql( 550 exp.If( 551 this=exp.Is(this=this, expression=exp.Null()), 552 true=exp.Null(), 553 false=result_expr, 554 ) 555 ) 556 557 558@unsupported_args(("expression", "DuckDB's LIST_SORT does not support a comparator.")) 559def _array_sort_sql(self: DuckDBGenerator, expression: exp.ArraySort) -> str: 560 order = array_sort_order(expression) 561 if order is None: 562 self.unsupported("LIST_SORT only takes literal orders") 563 return self.func("LIST_SORT", expression.this) 564 565 asc, nulls_first = order 566 if not nulls_first: 567 return self.func("LIST_SORT" if asc else "ARRAY_REVERSE_SORT", expression.this) 568 return self.func( 569 "LIST_SORT", 570 expression.this, 571 exp.Literal.string("ASC" if asc else "DESC"), 572 exp.Literal.string("NULLS FIRST"), 573 ) 574 575 576def _array_contains_sql(self: DuckDBGenerator, expression: exp.ArrayContains) -> str: 577 this = expression.this 578 expr = expression.expression 579 580 func = self.func("ARRAY_CONTAINS", this, expr) 581 582 if expression.args.get("check_null"): 583 check_null_in_array = exp.Nullif( 584 this=exp.NEQ(this=exp.ArraySize(this=this), expression=exp.func("LIST_COUNT", this)), 585 expression=exp.false(), 586 ) 587 return self.sql(exp.If(this=expr.is_(exp.Null()), true=check_null_in_array, false=func)) 588 589 return func 590 591 592def _array_overlaps_sql(self: DuckDBGenerator, expression: exp.ArrayOverlaps) -> str: 593 """ 594 Translates Snowflake's NULL-safe ARRAYS_OVERLAP to DuckDB. 595 596 DuckDB's native && operator is not NULL-safe: [1,NULL,3] && [NULL,4,5] returns FALSE. 597 Snowflake returns TRUE when both arrays contain NULL (NULLs are treated as known values). 598 599 Generated SQL: (arr1 && arr2) OR (ARRAY_LENGTH(arr1) <> LIST_COUNT(arr1) AND ARRAY_LENGTH(arr2) <> LIST_COUNT(arr2)) 600 601 ARRAY_LENGTH counts all elements (including NULLs); LIST_COUNT counts only non-NULLs. 602 When they differ, the array contains at least one NULL, matching Snowflake's NULL-safe semantics. 603 """ 604 if not expression.args.get("null_safe"): 605 return self.binary(expression, "&&") 606 607 arr1 = expression.this 608 arr2 = expression.expression 609 610 check_nulls = exp.and_( 611 exp.NEQ( 612 this=exp.ArraySize(this=arr1.copy()), 613 expression=exp.func("LIST_COUNT", arr1.copy()), 614 ), 615 exp.NEQ( 616 this=exp.ArraySize(this=arr2.copy()), 617 expression=exp.func("LIST_COUNT", arr2.copy()), 618 ), 619 copy=False, 620 ) 621 622 overlap = exp.ArrayOverlaps(this=arr1.copy(), expression=arr2.copy()) 623 624 return self.sql( 625 exp.or_( 626 exp.paren(overlap, copy=False), 627 exp.paren(check_nulls, copy=False), 628 copy=False, 629 wrap=False, 630 ) 631 ) 632 633 634def _struct_sql(self: DuckDBGenerator, expression: exp.Struct) -> str: 635 ancestor_cast = expression.find_ancestor(exp.Cast, exp.Select) 636 ancestor_cast = None if isinstance(ancestor_cast, exp.Select) else ancestor_cast 637 638 # Empty struct cast works with MAP() since DuckDB can't parse {} 639 if not expression.expressions: 640 if isinstance(ancestor_cast, exp.Cast) and ancestor_cast.to.is_type(exp.DType.MAP): 641 return "MAP()" 642 643 args: list[str] = [] 644 645 # BigQuery allows inline construction such as "STRUCT<a STRING, b INTEGER>('str', 1)" which is 646 # canonicalized to "ROW('str', 1) AS STRUCT(a TEXT, b INT)" in DuckDB 647 # The transformation to ROW will take place if: 648 # 1. The STRUCT itself does not have proper fields (key := value) as a "proper" STRUCT would 649 # 2. A cast to STRUCT / ARRAY of STRUCTs is found 650 is_bq_inline_struct = ( 651 (expression.find(exp.PropertyEQ) is None) 652 and ancestor_cast 653 and any( 654 casted_type.is_type(exp.DType.STRUCT) 655 for casted_type in ancestor_cast.find_all(exp.DataType) 656 ) 657 ) 658 659 for i, expr in enumerate(expression.expressions): 660 is_property_eq = isinstance(expr, exp.PropertyEQ) 661 this = expr.this 662 value = expr.expression if is_property_eq else expr 663 664 if is_bq_inline_struct: 665 args.append(self.sql(value)) 666 else: 667 if isinstance(this, exp.Identifier): 668 key = self.sql(exp.Literal.string(expr.name)) 669 elif is_property_eq: 670 key = self.sql(this) 671 else: 672 key = self.sql(exp.Literal.string(f"_{i}")) 673 674 args.append(f"{key}: {self.sql(value)}") 675 676 csv_args = ", ".join(args) 677 678 return f"ROW({csv_args})" if is_bq_inline_struct else f"{{{csv_args}}}" 679 680 681@unsupported_args("unique_keys", "return_type", "encoding", "strict") 682def _json_constructor_sql(self: DuckDBGenerator, expression: exp.JSONArray | exp.JSONObject) -> str: 683 if expression.args.get("null_handling") == "ABSENT ON NULL": 684 self.unsupported("ABSENT ON NULL is not supported in DuckDB") 685 686 name = "JSON_ARRAY" if isinstance(expression, exp.JSONArray) else "JSON_OBJECT" 687 return self.func(name, *expression.expressions) 688 689 690def _json_format_sql(self: DuckDBGenerator, expression: exp.JSONFormat) -> str: 691 sql = self.func("TO_JSON", expression.this, expression.args.get("options")) 692 return f"CAST({sql} AS TEXT)" 693 694 695def _build_seq_expression(base: exp.Expr, byte_width: int, signed: bool) -> exp.Expr: 696 """Build a SEQ expression with the given base, byte width, and signedness.""" 697 bits = byte_width * 8 698 max_val = exp.Literal.number(2**bits) 699 700 if signed: 701 half = exp.Literal.number(2 ** (bits - 1)) 702 return exp.replace_placeholders(_SEQ_SIGNED.copy(), base=base, max_val=max_val, half=half) 703 return exp.replace_placeholders(_SEQ_UNSIGNED.copy(), base=base, max_val=max_val) 704 705 706def _seq_to_range_in_generator(expression: exp.Expr) -> exp.Expr: 707 """ 708 Transform SEQ functions to `range` column references when inside a GENERATOR context. 709 710 When GENERATOR(ROWCOUNT => N) becomes RANGE(N) in DuckDB, it produces a column 711 named `range` with values 0, 1, ..., N-1. SEQ functions produce the same sequence, 712 so we replace them with `range % max_val` to avoid nested window function issues. 713 """ 714 if not isinstance(expression, exp.Select): 715 return expression 716 717 from_ = expression.args.get("from_") 718 if not ( 719 from_ 720 and isinstance(from_.this, exp.TableFromRows) 721 and isinstance(from_.this.this, exp.Generator) 722 ): 723 return expression 724 725 def replace_seq(node: exp.Expr) -> exp.Expr: 726 if isinstance(node, (exp.Seq1, exp.Seq2, exp.Seq4, exp.Seq8)): 727 byte_width = _SEQ_BYTE_WIDTH[type(node)] 728 return _build_seq_expression(exp.column("range"), byte_width, signed=node.name == "1") 729 return node 730 731 return expression.transform(replace_seq, copy=False) 732 733 734def connect_by_to_recursive_cte(expression: exp.Expr) -> exp.Expr: 735 # Rewrites START WITH ... CONNECT BY PRIOR into WITH RECURSIVE 736 # Falls through unchanged if there are no PRIORs. 737 if not isinstance(expression, exp.Select) or not expression.args.get("connect"): 738 return expression 739 740 connect = expression.args["connect"] 741 connect_pred = connect.args["connect"] 742 743 priors = list(connect_pred.find_all(exp.Prior)) 744 if not priors: 745 return expression 746 747 from_ = expression.args.get("from_") 748 if not from_ or expression.args.get("joins"): 749 return expression 750 751 source_table = from_.this 752 base_select_exprs = expression.expressions 753 base_where = expression.args.get("where") 754 base_with = expression.args.get("with_") 755 756 # LEVEL is a Snowflake pseudo-column: it's always computed as a depth counter in the CTE. 757 has_level = any( 758 isinstance(col, exp.Column) and col.name.upper() == "LEVEL" 759 for e in base_select_exprs 760 for col in e.find_all(exp.Column) 761 ) 762 has_star = expression.is_star 763 764 # CONNECT_BY_ROOT col yields the value of `col` from the START WITH row that begins each 765 # branch. Each one is threaded through the CTE as an extra column: the anchor binds it to the 766 # row's own value, the recursive arm forwards the parent's value unchanged. 767 root_col_names: list[str] = [] 768 anchor_root_cols: list[exp.Expr] = [] 769 inner_root_cols: list[exp.Expr] = [] 770 roots = [root for e in base_select_exprs for root in e.find_all(exp.ConnectByRoot)] 771 772 for i, root in enumerate(roots): 773 name = f"_connect_by_root_{i}" 774 root_col_names.append(name) 775 anchor_root_cols.append(exp.alias_(root.this, name)) 776 inner_root_cols.append(exp.alias_(exp.column(name, "_parent_row"), name)) 777 root.replace(exp.column(name)) 778 779 # Build the join condition from the full CONNECT BY predicate: 780 # PRIOR(col) → _parent_row.col, unqualified cols → _child_row.col. 781 def _qualify_connect_pred(node: exp.Expression) -> exp.Expression: 782 for col in find_all_in_scope(node, exp.Column): 783 col.set( 784 "table", 785 exp.to_identifier( 786 "_parent_row" if isinstance(col.parent, exp.Prior) else "_child_row" 787 ), 788 ) 789 for prior in find_all_in_scope(node, exp.Prior): 790 prior.replace(prior.this) 791 return node 792 793 # Avoid colliding with any CTE names already on the query. 794 cte_name = find_new_name( 795 {cte.alias for cte in (base_with.expressions if base_with else [])}, "_rootcte" 796 ) 797 798 # Anchor: project all source columns + seed LEVEL at 1 + bind each root column to its own value. 799 anchor = exp.select( 800 exp.Star(), exp.alias_(exp.Literal.number(1), "level"), *anchor_root_cols 801 ).from_(source_table) 802 if connect.args.get("start"): 803 anchor = anchor.where(connect.args["start"]) 804 805 # Recursive arm: carry all child columns + increment level + forward each root value. 806 # SELECT * in both arms means WHERE/PRIOR columns are always available without explicit tracking. 807 inner_query = ( 808 exp.select( 809 exp.Column(this=exp.Star(), table=exp.to_identifier("_child_row")), 810 exp.alias_(exp.column("level", "_parent_row") + 1, "level"), 811 *inner_root_cols, 812 ) 813 .from_(source_table.as_("_child_row")) 814 .join(exp.to_table(cte_name).as_("_parent_row"), on=_qualify_connect_pred(connect_pred)) 815 ) 816 817 # Outer SELECT re-projects from the CTE. Synthetic level/root columns are excluded from any 818 # star expansion (level only when not referenced) but kept where explicitly projected. 819 if has_star: 820 except_cols = [] if has_level else [exp.column("level")] 821 except_cols.extend(exp.column(name) for name in root_col_names) 822 star = exp.Star(except_=except_cols) if except_cols else exp.Star() 823 outer_select_exprs: list[exp.Expr] = [ 824 star, 825 *(e for e in base_select_exprs if not e.is_star), 826 ] 827 else: 828 outer_select_exprs = base_select_exprs 829 outer_query = exp.select(*outer_select_exprs).from_(cte_name) 830 if base_where: 831 outer_query = outer_query.where(base_where.this) 832 833 # Attach the CTE, marking the WITH clause recursive. 834 if base_with: 835 outer_query.set("with_", base_with) 836 outer_query = outer_query.with_( 837 cte_name, as_=anchor.union(inner_query, distinct=False), recursive=True, copy=False 838 ) 839 840 for arg, val in expression.args.items(): 841 if val and arg not in _CONNECT_BY_ARGS_TO_SKIP: 842 outer_query.set(arg, val) 843 844 # Strip stale source table qualifiers in one pass; CTEs are child scopes so 845 # find_all_in_scope stays within the outer query only. 846 for col in find_all_in_scope(outer_query, exp.Column): 847 col.set("table", None) 848 849 return outer_query 850 851 852def _seq_sql(self: DuckDBGenerator, expression: exp.Func, byte_width: int) -> str: 853 """ 854 Transpile Snowflake SEQ1/SEQ2/SEQ4/SEQ8 to DuckDB. 855 856 Generates monotonically increasing integers starting from 0. 857 The signed parameter (0 or 1) affects wrap-around behavior: 858 - Unsigned (0): wraps at 2^(bits) - 1 859 - Signed (1): wraps at 2^(bits-1) - 1, then goes negative 860 """ 861 # Warn if SEQ is in a restricted context (Select stops search at current scope) 862 ancestor = expression.find_ancestor(*_SEQ_RESTRICTED) 863 if ancestor and ( 864 (not isinstance(ancestor, (exp.Order, exp.Select))) 865 or (isinstance(ancestor, exp.Order) and isinstance(ancestor.parent, exp.Window)) 866 ): 867 self.unsupported("SEQ in restricted context is not supported - use CTE or subquery") 868 869 result = _build_seq_expression(_SEQ_BASE.copy(), byte_width, signed=expression.name == "1") 870 return self.sql(result) 871 872 873def _unix_to_time_sql(self: DuckDBGenerator, expression: exp.UnixToTime) -> str: 874 scale = expression.args.get("scale") 875 timestamp = expression.this 876 target_type = expression.args.get("target_type") 877 878 # Check if we need NTZ (naive timestamp in UTC) 879 is_ntz = target_type and target_type.this in ( 880 exp.DType.TIMESTAMP, 881 exp.DType.TIMESTAMPNTZ, 882 ) 883 884 if scale == exp.UnixToTime.MILLIS: 885 # EPOCH_MS already returns TIMESTAMP (naive, UTC) 886 return self.func("EPOCH_MS", timestamp) 887 if scale == exp.UnixToTime.MICROS: 888 # MAKE_TIMESTAMP already returns TIMESTAMP (naive, UTC) 889 return self.func("MAKE_TIMESTAMP", timestamp) 890 891 # Other scales: divide and use TO_TIMESTAMP 892 if scale not in (None, exp.UnixToTime.SECONDS): 893 timestamp = exp.Div(this=timestamp, expression=exp.func("POW", 10, scale)) 894 895 to_timestamp: exp.Expr = exp.Anonymous(this="TO_TIMESTAMP", expressions=[timestamp]) 896 897 if is_ntz: 898 to_timestamp = exp.AtTimeZone(this=to_timestamp, zone=exp.Literal.string("UTC")) 899 900 return self.sql(to_timestamp) 901 902 903WRAPPED_JSON_EXTRACT_EXPRESSIONS = (exp.Binary, exp.Bracket, exp.In, exp.Not) 904 905 906def _arrow_json_extract_sql(self: DuckDBGenerator, expression: JSON_EXTRACT_TYPE) -> str: 907 paths = expression.expressions 908 if isinstance(expression, exp.JSONExtract) and paths: 909 # JSON_EXTRACT with several paths (SQLite, MySQL) returns a JSON array of the results, 910 # which DuckDB builds from a list of paths 911 return self.func( 912 "TO_JSON", 913 self.func( 914 "JSON_EXTRACT", 915 expression.this, 916 exp.Array(expressions=[expression.expression, *paths]), 917 ), 918 ) 919 920 arrow_sql = arrow_json_extract_sql(self, expression) 921 if not expression.same_parent and isinstance( 922 expression.parent, WRAPPED_JSON_EXTRACT_EXPRESSIONS 923 ): 924 arrow_sql = self.wrap(arrow_sql) 925 return arrow_sql 926 927 928def _implicit_datetime_cast( 929 arg: exp.Expr | None, type: exp.DType = exp.DType.DATE 930) -> exp.Expr | None: 931 if isinstance(arg, exp.Literal) and arg.is_string: 932 ts = arg.name 933 if type == exp.DType.DATE and ":" in ts: 934 type = exp.DType.TIMESTAMPTZ if TIMEZONE_PATTERN.search(ts) else exp.DType.TIMESTAMP 935 936 arg = exp.cast(arg, type) 937 938 return arg 939 940 941def _week_trunc_start_dow(unit: exp.Expr | None) -> int | None: 942 # DuckDB's weeks are ISO 8601, so ISOWEEK maps to its plain WEEK unit 943 if isinstance(unit, exp.Literal) and unit.name.upper() == "ISOWEEK": 944 return 1 945 return week_unit_to_dow(unit) 946 947 948def _build_week_trunc_expression( 949 date_expr: exp.Expr, 950 start_dow: int, 951 preserve_start_day: bool = False, 952 cast_to_date: bool = True, 953) -> exp.Expr: 954 """ 955 Build DATE_TRUNC expression for week boundaries with custom start day. 956 957 DuckDB's DATE_TRUNC('WEEK', ...) always returns Monday. To align to a different 958 start day, we shift the date before truncating. 959 960 Args: 961 date_expr: The date expression to truncate. 962 start_dow: ISO 8601 day-of-week number (Monday=1, ..., Sunday=7). 963 preserve_start_day: If True, reverse the shift after truncating so the result lands on the 964 correct week start day. Needed for DATE_TRUNC (absolute result matters) but 965 not for DATE_DIFF (only relative alignment matters). 966 cast_to_date: If True, cast the shifted result back to DATE; set to False for 967 timestamp-valued inputs, where the result must remain a timestamp. 968 969 Shift formula: Sunday (7) gets +1, others get (1 - start_dow). 970 """ 971 shift_days = 1 if start_dow == 7 else 1 - start_dow 972 truncated = exp.func("DATE_TRUNC", unit=exp.var("WEEK"), this=date_expr) 973 974 if shift_days == 0: 975 return truncated 976 977 shift = exp.Interval(this=exp.Literal.string(str(shift_days)), unit=exp.var("DAY")) 978 shifted_date = exp.DateAdd(this=date_expr, expression=shift) 979 truncated.set("this", shifted_date) 980 981 if preserve_start_day: 982 interval = exp.Interval(this=exp.Literal.string(str(-shift_days)), unit=exp.var("DAY")) 983 shifted_back: exp.Expr = exp.DateAdd(this=truncated, expression=interval) 984 if cast_to_date: 985 return exp.cast(shifted_back, to=exp.DType.DATE, copy=False) 986 return shifted_back 987 988 return truncated 989 990 991def _date_diff_sql(self: DuckDBGenerator, expression: exp.DateDiff | exp.DatetimeDiff) -> str: 992 unit = expression.unit 993 994 if _is_nanosecond_unit(unit): 995 return _handle_nanosecond_diff(self, expression.this, expression.expression) 996 997 this = _implicit_datetime_cast(expression.this) 998 expr = _implicit_datetime_cast(expression.expression) 999 1000 # DuckDB's WEEK diff does not respect Monday crossing (week boundaries), it checks (end_day - start_day) / 7: 1001 # SELECT DATE_DIFF('WEEK', CAST('2024-12-13' AS DATE), CAST('2024-12-17' AS DATE)) --> 0 (Monday crossed) 1002 # SELECT DATE_DIFF('WEEK', CAST('2024-12-13' AS DATE), CAST('2024-12-20' AS DATE)) --> 1 (7 days difference) 1003 # Whereas for other units such as MONTH it does respect month boundaries: 1004 # SELECT DATE_DIFF('MONTH', CAST('2024-11-30' AS DATE), CAST('2024-12-01' AS DATE)) --> 1 (Month crossed) 1005 date_part_boundary = expression.args.get("date_part_boundary") 1006 1007 # Extract week start day; returns None if day is dynamic (column/placeholder) 1008 week_start = week_unit_to_dow(unit) 1009 if date_part_boundary and week_start and this and expr: 1010 expression.set("unit", exp.Literal.string("WEEK")) 1011 1012 # Truncate both dates to week boundaries to respect input dialect semantics 1013 this = _build_week_trunc_expression(this, week_start) 1014 expr = _build_week_trunc_expression(expr, week_start) 1015 1016 return self.func("DATE_DIFF", unit_to_str(expression), expr, this) 1017 1018 1019def _generate_datetime_array_sql( 1020 self: DuckDBGenerator, expression: exp.GenerateDateArray | exp.GenerateTimestampArray 1021) -> str: 1022 is_generate_date_array = isinstance(expression, exp.GenerateDateArray) 1023 1024 type = exp.DType.DATE if is_generate_date_array else exp.DType.TIMESTAMP 1025 start = _implicit_datetime_cast(expression.args.get("start"), type=type) 1026 end = _implicit_datetime_cast(expression.args.get("end"), type=type) 1027 1028 # BQ's GENERATE_DATE_ARRAY & GENERATE_TIMESTAMP_ARRAY are transformed to DuckDB'S GENERATE_SERIES 1029 gen_series: exp.GenerateSeries | exp.Cast = exp.GenerateSeries( 1030 start=start, end=end, step=expression.args.get("step") 1031 ) 1032 1033 if is_generate_date_array: 1034 # The GENERATE_SERIES result type is TIMESTAMP array, so to match BQ's semantics for 1035 # GENERATE_DATE_ARRAY we must cast it back to DATE array 1036 gen_series = exp.cast(gen_series, exp.DataType.from_str("ARRAY<DATE>")) 1037 1038 return self.sql(gen_series) 1039 1040 1041def _json_extract_value_array_sql( 1042 self: DuckDBGenerator, expression: exp.JSONValueArray | exp.JSONExtractArray 1043) -> str: 1044 json_extract = exp.JSONExtract(this=expression.this, expression=expression.expression) 1045 data_type = "ARRAY<STRING>" if isinstance(expression, exp.JSONValueArray) else "ARRAY<JSON>" 1046 return self.sql(exp.cast(json_extract, to=exp.DataType.from_str(data_type))) 1047 1048 1049def _cast_to_varchar(arg: exp.Expr | None) -> exp.Expr | None: 1050 if arg and arg.type and not arg.is_type(*exp.DataType.TEXT_TYPES, exp.DType.UNKNOWN): 1051 return exp.cast(arg, exp.DType.VARCHAR) 1052 return arg 1053 1054 1055def _cast_to_boolean(arg: exp.Expr | None) -> exp.Expr | None: 1056 if arg and not arg.is_type(exp.DType.BOOLEAN): 1057 return exp.cast(arg, exp.DType.BOOLEAN) 1058 return arg 1059 1060 1061def _is_binary(arg: exp.Expr) -> bool: 1062 return arg.is_type( 1063 exp.DType.BINARY, 1064 exp.DType.VARBINARY, 1065 exp.DType.BLOB, 1066 ) 1067 1068 1069def _gen_with_cast_to_blob(self: DuckDBGenerator, expression: exp.Expr, result_sql: str) -> str: 1070 if _is_binary(expression): 1071 blob = exp.DataType.from_str("BLOB", dialect="duckdb") 1072 result_sql = self.sql(exp.Cast(this=result_sql, to=blob)) 1073 return result_sql 1074 1075 1076def _cast_to_bit(arg: exp.Expr) -> exp.Expr: 1077 if not _is_binary(arg): 1078 return arg 1079 1080 if isinstance(arg, exp.HexString): 1081 arg = exp.Unhex(this=exp.Literal.string(arg.this)) 1082 1083 return exp.cast(arg, exp.DType.BIT) 1084 1085 1086def _prepare_binary_bitwise_args(expression: exp.Binary) -> None: 1087 if _is_binary(expression.this): 1088 expression.set("this", _cast_to_bit(expression.this)) 1089 if _is_binary(expression.expression): 1090 expression.set("expression", _cast_to_bit(expression.expression)) 1091 1092 1093def _day_navigation_sql(self: DuckDBGenerator, expression: exp.NextDay | exp.PreviousDay) -> str: 1094 """ 1095 Transpile Snowflake's NEXT_DAY / PREVIOUS_DAY to DuckDB using date arithmetic. 1096 1097 Returns the DATE of the next/previous occurrence of the specified weekday. 1098 1099 Formulas: 1100 - NEXT_DAY: (target_dow - current_dow + 6) % 7 + 1 1101 - PREVIOUS_DAY: (current_dow - target_dow + 6) % 7 + 1 1102 1103 Supports both literal and non-literal day names: 1104 - Literal: Direct lookup (e.g., 'Monday' -> 1) 1105 - Non-literal: CASE statement for runtime evaluation 1106 1107 Examples: 1108 NEXT_DAY('2024-01-01' (Monday), 'Monday') 1109 -> (1 - 1 + 6) % 7 + 1 = 6 % 7 + 1 = 7 days -> 2024-01-08 1110 1111 PREVIOUS_DAY('2024-01-15' (Monday), 'Friday') 1112 -> (1 - 5 + 6) % 7 + 1 = 2 % 7 + 1 = 3 days -> 2024-01-12 1113 """ 1114 date_expr = expression.this 1115 day_name_expr = expression.expression 1116 1117 # Build ISODOW call for current day of week 1118 isodow_call = exp.func("ISODOW", date_expr) 1119 1120 # Determine target day of week 1121 if isinstance(day_name_expr, exp.Literal): 1122 # Literal day name: lookup target_dow directly 1123 day_name_str = day_name_expr.name.upper() 1124 matching_day = next( 1125 (day for day in WEEK_START_DAY_TO_DOW if day.startswith(day_name_str)), None 1126 ) 1127 if matching_day: 1128 target_dow: exp.Expr = exp.Literal.number(WEEK_START_DAY_TO_DOW[matching_day]) 1129 else: 1130 # Unrecognized day name, use fallback 1131 return self.function_fallback_sql(expression) 1132 else: 1133 # Non-literal day name: build CASE statement for runtime mapping 1134 upper_day_name = exp.Upper(this=day_name_expr) 1135 target_dow = exp.Case( 1136 ifs=[ 1137 exp.If( 1138 this=exp.func( 1139 "STARTS_WITH", upper_day_name.copy(), exp.Literal.string(day[:2]) 1140 ), 1141 true=exp.Literal.number(dow_num), 1142 ) 1143 for day, dow_num in WEEK_START_DAY_TO_DOW.items() 1144 ] 1145 ) 1146 1147 # Calculate days offset and apply interval based on direction 1148 if isinstance(expression, exp.NextDay): 1149 # NEXT_DAY: (target_dow - current_dow + 6) % 7 + 1 1150 days_offset = exp.paren(target_dow - isodow_call + 6, copy=False) % 7 + 1 1151 date_with_offset = date_expr + exp.Interval(this=days_offset, unit=exp.var("DAY")) 1152 else: # exp.PreviousDay 1153 # PREVIOUS_DAY: (current_dow - target_dow + 6) % 7 + 1 1154 days_offset = exp.paren(isodow_call - target_dow + 6, copy=False) % 7 + 1 1155 date_with_offset = date_expr - exp.Interval(this=days_offset, unit=exp.var("DAY")) 1156 1157 # Build final: CAST(date_with_offset AS DATE) 1158 return self.sql(exp.cast(date_with_offset, exp.DType.DATE)) 1159 1160 1161def _anyvalue_sql(self: DuckDBGenerator, expression: exp.AnyValue) -> str: 1162 # Transform ANY_VALUE(expr HAVING MAX/MIN having_expr) to ARG_MAX_NULL/ARG_MIN_NULL 1163 having = expression.this 1164 if isinstance(having, exp.HavingMax): 1165 func_name = "ARG_MAX_NULL" if having.args.get("max") else "ARG_MIN_NULL" 1166 return self.func(func_name, having.this, having.expression) 1167 return self.function_fallback_sql(expression) 1168 1169 1170def _bitwise_agg_sql( 1171 self: DuckDBGenerator, 1172 expression: exp.BitwiseOrAgg | exp.BitwiseAndAgg | exp.BitwiseXorAgg, 1173) -> str: 1174 """ 1175 DuckDB's bitwise aggregate functions only accept integer types. For other types: 1176 - DECIMAL/STRING: Use CAST(arg AS INT) to convert directly, will round to nearest int 1177 - FLOAT/DOUBLE: Use ROUND(arg)::INT to round to nearest integer, required due to float precision loss 1178 """ 1179 if isinstance(expression, exp.BitwiseOrAgg): 1180 func_name = "BIT_OR" 1181 elif isinstance(expression, exp.BitwiseAndAgg): 1182 func_name = "BIT_AND" 1183 else: # exp.BitwiseXorAgg 1184 func_name = "BIT_XOR" 1185 1186 arg = expression.this 1187 1188 if not arg.type: 1189 from sqlglot.optimizer.annotate_types import annotate_types 1190 1191 arg = annotate_types(arg, dialect=self.dialect) 1192 1193 if arg.is_type(*exp.DataType.REAL_TYPES, *exp.DataType.TEXT_TYPES): 1194 if arg.is_type(*exp.DataType.FLOAT_TYPES): 1195 # float types need to be rounded first due to precision loss 1196 arg = exp.func("ROUND", arg) 1197 1198 arg = exp.cast(arg, exp.DType.INT) 1199 1200 return self.func(func_name, arg) 1201 1202 1203def _literal_sql_with_ws_chr(self: DuckDBGenerator, literal: str) -> str: 1204 # DuckDB does not support \uXXXX escapes, so we must use CHR() instead of replacing them directly 1205 if not any(ch in WS_CONTROL_CHARS_TO_DUCK for ch in literal): 1206 return self.sql(exp.Literal.string(literal)) 1207 1208 sql_segments: list[str] = [] 1209 for is_ws_control, group in groupby(literal, key=lambda ch: ch in WS_CONTROL_CHARS_TO_DUCK): 1210 if is_ws_control: 1211 for ch in group: 1212 duckdb_char_code = WS_CONTROL_CHARS_TO_DUCK[ch] 1213 sql_segments.append(self.func("CHR", exp.Literal.number(str(duckdb_char_code)))) 1214 else: 1215 sql_segments.append(self.sql(exp.Literal.string("".join(group)))) 1216 1217 sql = " || ".join(sql_segments) 1218 return sql if len(sql_segments) == 1 else f"({sql})" 1219 1220 1221def _escape_regex_metachars( 1222 self: DuckDBGenerator, delimiters: exp.Expr | None, delimiters_sql: str 1223) -> str: 1224 r""" 1225 Escapes regex metacharacters \ - ^ [ ] for use in character classes regex expressions. 1226 1227 Literal strings are escaped at transpile time, expressions handled with REPLACE() calls. 1228 """ 1229 if not delimiters: 1230 return delimiters_sql 1231 1232 if delimiters.is_string: 1233 literal_value = delimiters.this 1234 escaped_literal = "".join(REGEX_ESCAPE_REPLACEMENTS.get(ch, ch) for ch in literal_value) 1235 return _literal_sql_with_ws_chr(self, escaped_literal) 1236 1237 escaped_sql = delimiters_sql 1238 for raw, escaped in REGEX_ESCAPE_REPLACEMENTS.items(): 1239 escaped_sql = self.func( 1240 "REPLACE", 1241 escaped_sql, 1242 self.sql(exp.Literal.string(raw)), 1243 self.sql(exp.Literal.string(escaped)), 1244 ) 1245 1246 return escaped_sql 1247 1248 1249def _build_capitalization_sql( 1250 self: DuckDBGenerator, 1251 value_to_split: str, 1252 delimiters_sql: str, 1253) -> str: 1254 # empty string delimiter --> treat value as one word, no need to split 1255 if delimiters_sql == "''": 1256 return f"UPPER(LEFT({value_to_split}, 1)) || LOWER(SUBSTRING({value_to_split}, 2))" 1257 1258 delim_regex_sql = f"'[' || {delimiters_sql} || ']'" 1259 split_regex_sql = f"'([' || {delimiters_sql} || ']+|[^' || {delimiters_sql} || ']+)'" 1260 1261 # REGEXP_EXTRACT_ALL produces a list of string segments, alternating between delimiter and non-delimiter segments. 1262 # We do not know whether the first segment is a delimiter or not, so we check the first character of the string 1263 # with REGEXP_MATCHES. If the first char is a delimiter, we capitalize even list indexes, otherwise capitalize odd. 1264 return self.func( 1265 "ARRAY_TO_STRING", 1266 exp.case() 1267 .when( 1268 f"REGEXP_MATCHES(LEFT({value_to_split}, 1), {delim_regex_sql})", 1269 self.func( 1270 "LIST_TRANSFORM", 1271 self.func("REGEXP_EXTRACT_ALL", value_to_split, split_regex_sql), 1272 "LAMBDA seg, idx : CASE WHEN idx % 2 = 0 THEN UPPER(LEFT(seg, 1)) || LOWER(SUBSTRING(seg, 2)) ELSE seg END", 1273 ), 1274 dialect=self.dialect, 1275 ) 1276 .else_( 1277 self.func( 1278 "LIST_TRANSFORM", 1279 self.func("REGEXP_EXTRACT_ALL", value_to_split, split_regex_sql), 1280 "LAMBDA seg, idx : CASE WHEN idx % 2 = 1 THEN UPPER(LEFT(seg, 1)) || LOWER(SUBSTRING(seg, 2)) ELSE seg END", 1281 ), 1282 dialect=self.dialect, 1283 ), 1284 "''", 1285 ) 1286 1287 1288def _initcap_sql(self: DuckDBGenerator, expression: exp.Initcap) -> str: 1289 this_sql = self.sql(expression, "this") 1290 delimiters = expression.args.get("expression") 1291 if delimiters is None: 1292 # fallback for manually created exp.Initcap w/o delimiters arg 1293 delimiters = exp.Literal.string(self.dialect.INITCAP_DEFAULT_DELIMITER_CHARS) 1294 delimiters_sql = self.sql(delimiters) 1295 1296 escaped_delimiters_sql = _escape_regex_metachars(self, delimiters, delimiters_sql) 1297 1298 return _build_capitalization_sql(self, this_sql, escaped_delimiters_sql) 1299 1300 1301def _boolxor_agg_sql(self: DuckDBGenerator, expression: exp.BoolxorAgg) -> str: 1302 """ 1303 Snowflake's `BOOLXOR_AGG(col)` returns TRUE if exactly one input in `col` is TRUE, FALSE otherwise; 1304 Since DuckDB does not have a mapping function, we mimic the behavior by generating `COUNT_IF(col) = 1`. 1305 1306 DuckDB's COUNT_IF strictly requires boolean inputs, so cast if not already boolean. 1307 """ 1308 return self.sql( 1309 exp.EQ( 1310 this=exp.CountIf(this=_cast_to_boolean(expression.this)), 1311 expression=exp.Literal.number(1), 1312 ) 1313 ) 1314 1315 1316def _bitshift_sql( 1317 self: DuckDBGenerator, expression: exp.BitwiseLeftShift | exp.BitwiseRightShift 1318) -> str: 1319 """ 1320 Transform bitshift expressions for DuckDB by injecting BIT/INT128 casts. 1321 1322 DuckDB's bitwise shift operators don't work with BLOB/BINARY types, so we cast 1323 them to BIT for the operation, then cast the result back to the original type. 1324 1325 Note: Assumes type annotation has been applied with the source dialect. 1326 """ 1327 operator = "<<" if isinstance(expression, exp.BitwiseLeftShift) else ">>" 1328 result_is_blob = False 1329 this = expression.this 1330 1331 if _is_binary(this): 1332 result_is_blob = True 1333 expression.set("this", exp.cast(this, exp.DType.BIT)) 1334 elif expression.args.get("requires_int128"): 1335 this.replace(exp.cast(this, exp.DType.INT128)) 1336 1337 result_sql = self.binary(expression, operator) 1338 1339 # Wrap in parentheses if parent is a bitwise operator to "fix" DuckDB precedence issue 1340 # DuckDB parses: a << b | c << d as (a << b | c) << d 1341 if isinstance(expression.parent, exp.Binary): 1342 result_sql = self.sql(exp.Paren(this=result_sql)) 1343 1344 if result_is_blob: 1345 result_sql = self.sql( 1346 exp.Cast(this=result_sql, to=exp.DataType.from_str("BLOB", dialect="duckdb")) 1347 ) 1348 1349 return result_sql 1350 1351 1352def _scale_rounding_sql( 1353 self: DuckDBGenerator, 1354 expression: exp.Expr, 1355 rounding_func: Type[exp.Expr], 1356) -> str | None: 1357 """ 1358 Handle scale parameter transformation for rounding functions. 1359 1360 DuckDB doesn't support the scale parameter for certain functions (e.g., FLOOR, CEIL), 1361 so we transform: FUNC(x, n) to ROUND(FUNC(x * 10^n) / 10^n, n) 1362 1363 Args: 1364 self: The DuckDB generator instance 1365 expression: The expression to transform (must have 'this', 'decimals', and 'to' args) 1366 rounding_func: The rounding function class to use in the transformation 1367 1368 Returns: 1369 The transformed SQL string if decimals parameter exists, None otherwise 1370 """ 1371 decimals = expression.args.get("decimals") 1372 1373 if decimals is None or expression.args.get("to") is not None: 1374 return None 1375 1376 this = expression.this 1377 if isinstance(this, exp.Binary): 1378 this = exp.Paren(this=this) 1379 1380 n_int = decimals 1381 if not (decimals.is_int or decimals.is_type(*exp.DataType.INTEGER_TYPES)): 1382 n_int = exp.cast(decimals, exp.DType.INT) 1383 1384 pow_ = exp.Pow(this=exp.Literal.number("10"), expression=n_int) 1385 rounded = rounding_func(this=exp.Mul(this=this, expression=pow_)) 1386 result = exp.Div(this=rounded, expression=pow_.copy()) 1387 1388 return self.round_sql( 1389 exp.Round(this=result, decimals=decimals, casts_non_integer_decimals=True) 1390 ) 1391 1392 1393def _ceil_floor(self: DuckDBGenerator, expression: exp.Floor | exp.Ceil) -> str: 1394 scaled_sql = _scale_rounding_sql(self, expression, type(expression)) 1395 if scaled_sql is not None: 1396 return scaled_sql 1397 return self.ceil_floor(expression) 1398 1399 1400def _regr_val_sql( 1401 self: DuckDBGenerator, 1402 expression: exp.RegrValx | exp.RegrValy, 1403) -> str: 1404 """ 1405 Transpile Snowflake's REGR_VALX/REGR_VALY to DuckDB equivalent. 1406 1407 REGR_VALX(y, x) returns NULL if y is NULL; otherwise returns x. 1408 REGR_VALY(y, x) returns NULL if x is NULL; otherwise returns y. 1409 """ 1410 from sqlglot.optimizer.annotate_types import annotate_types 1411 1412 y = expression.this 1413 x = expression.expression 1414 1415 # Determine which argument to check for NULL and which to return based on expression type 1416 if isinstance(expression, exp.RegrValx): 1417 # REGR_VALX: check y for NULL, return x 1418 check_for_null = y 1419 return_value = x 1420 return_value_attr = "expression" 1421 else: 1422 # REGR_VALY: check x for NULL, return y 1423 check_for_null = x 1424 return_value = y 1425 return_value_attr = "this" 1426 1427 # Get the type from the return argument 1428 result_type = return_value.type 1429 1430 # If no type info, annotate the expression to infer types 1431 if not result_type or result_type.this == exp.DType.UNKNOWN: 1432 try: 1433 annotated = annotate_types(expression.copy(), dialect=self.dialect) 1434 result_type = getattr(annotated, return_value_attr).type 1435 except Exception: 1436 pass 1437 1438 # Default to DOUBLE for regression functions if type still unknown 1439 if not result_type or result_type.this == exp.DType.UNKNOWN: 1440 result_type = exp.DType.DOUBLE.into_expr() 1441 1442 # Cast NULL to the same type as return_value to avoid DuckDB type inference issues 1443 typed_null = exp.Cast(this=exp.Null(), to=result_type) 1444 1445 return self.sql( 1446 exp.If( 1447 this=exp.Is(this=check_for_null.copy(), expression=exp.Null()), 1448 true=typed_null, 1449 false=return_value.copy(), 1450 ) 1451 ) 1452 1453 1454def _maybe_corr_null_to_false( 1455 expression: exp.Filter | exp.Window | exp.Corr, 1456) -> exp.Filter | exp.Window | exp.Corr | None: 1457 corr = expression 1458 while isinstance(corr, (exp.Window, exp.Filter)): 1459 corr = corr.this 1460 1461 if not isinstance(corr, exp.Corr) or not corr.args.get("null_on_zero_variance"): 1462 return None 1463 1464 corr.set("null_on_zero_variance", False) 1465 return expression 1466 1467 1468def _date_from_parts_sql(self, expression: exp.DateFromParts) -> str: 1469 """ 1470 Snowflake's DATE_FROM_PARTS allows out-of-range values for the month and day input. 1471 E.g., larger values (month=13, day=100), zero-values (month=0, day=0), negative values (month=-13, day=-100). 1472 1473 DuckDB's MAKE_DATE does not support out-of-range values, but DuckDB's INTERVAL type does. 1474 1475 We convert to date arithmetic: 1476 DATE_FROM_PARTS(year, month, day) 1477 - MAKE_DATE(year, 1, 1) + INTERVAL (month-1) MONTH + INTERVAL (day-1) DAY 1478 """ 1479 year_expr = expression.args.get("year") 1480 month_expr = expression.args.get("month") 1481 day_expr = expression.args.get("day") 1482 1483 if expression.args.get("allow_overflow"): 1484 base_date: exp.Expr = exp.func( 1485 "MAKE_DATE", year_expr, exp.Literal.number(1), exp.Literal.number(1) 1486 ) 1487 1488 if month_expr: 1489 base_date = base_date + exp.Interval(this=month_expr - 1, unit=exp.var("MONTH")) 1490 1491 if day_expr: 1492 base_date = base_date + exp.Interval(this=day_expr - 1, unit=exp.var("DAY")) 1493 1494 return self.sql(exp.cast(expression=base_date, to=exp.DType.DATE)) 1495 1496 return self.func("MAKE_DATE", year_expr, month_expr, day_expr) 1497 1498 1499def _round_arg(arg: exp.Expr, round_input: bool | None = None) -> exp.Expr: 1500 if round_input: 1501 return exp.func("ROUND", arg, exp.Literal.number(0)) 1502 return arg 1503 1504 1505def _boolnot_sql(self: DuckDBGenerator, expression: exp.Boolnot) -> str: 1506 arg = _round_arg(expression.this, expression.args.get("round_input")) 1507 return self.sql(exp.not_(exp.paren(arg))) 1508 1509 1510def _booland_sql(self: DuckDBGenerator, expression: exp.Booland) -> str: 1511 round_input = expression.args.get("round_input") 1512 left = _round_arg(expression.this, round_input) 1513 right = _round_arg(expression.expression, round_input) 1514 return self.sql(exp.paren(exp.and_(exp.paren(left), exp.paren(right), wrap=False))) 1515 1516 1517def _boolor_sql(self: DuckDBGenerator, expression: exp.Boolor) -> str: 1518 round_input = expression.args.get("round_input") 1519 left = _round_arg(expression.this, round_input) 1520 right = _round_arg(expression.expression, round_input) 1521 return self.sql(exp.paren(exp.or_(exp.paren(left), exp.paren(right), wrap=False))) 1522 1523 1524def _xor_sql(self: DuckDBGenerator, expression: exp.Xor) -> str: 1525 round_input = expression.args.get("round_input") 1526 left = _round_arg(expression.this, round_input) 1527 right = _round_arg(expression.expression, round_input) 1528 return self.sql( 1529 exp.or_( 1530 exp.paren(exp.and_(left.copy(), exp.paren(right.not_()), wrap=False)), 1531 exp.paren(exp.and_(exp.paren(left.not_()), right.copy(), wrap=False)), 1532 wrap=False, 1533 ) 1534 ) 1535 1536 1537def _explode_to_unnest_sql(self: DuckDBGenerator, expression: exp.Lateral) -> str: 1538 """Handle LATERAL VIEW EXPLODE/INLINE conversion to UNNEST for DuckDB.""" 1539 explode = expression.this 1540 1541 if isinstance(explode, exp.Inline): 1542 # For INLINE, create CROSS JOIN LATERAL (SELECT UNNEST(..., max_depth => 2)) 1543 # Build the UNNEST call with DuckDB-style named parameter 1544 unnest_expr = exp.Unnest( 1545 expressions=[ 1546 explode.this, 1547 exp.Kwarg(this=exp.var("max_depth"), expression=exp.Literal.number(2)), 1548 ] 1549 ) 1550 select_expr = exp.Select(expressions=[unnest_expr]).subquery() 1551 1552 alias_expr = expression.args.get("alias") 1553 if alias_expr and not alias_expr.this: 1554 # we need to provide a table name if not present 1555 alias_expr.set("this", exp.to_identifier(f"_u_{expression.index}")) 1556 1557 transformed_lateral_expr = exp.Lateral(this=select_expr, alias=alias_expr) 1558 cross_join_lateral_expr = exp.Join(this=transformed_lateral_expr, kind="CROSS") 1559 1560 return self.sql(cross_join_lateral_expr) 1561 1562 # For other cases, use the standard conversion 1563 return explode_to_unnest_sql(self, expression) 1564 1565 1566def _sha_sql( 1567 self: DuckDBGenerator, 1568 expression: exp.Expr, 1569 hash_func: str, 1570 is_binary: bool = False, 1571) -> str: 1572 arg = expression.this 1573 1574 # For SHA2 variants, check digest length (DuckDB only supports SHA256) 1575 if hash_func == "SHA256": 1576 length = expression.text("length") or "256" 1577 if length != "256": 1578 self.unsupported("DuckDB only supports SHA256 hashing algorithm.") 1579 1580 # Cast if type is incompatible with DuckDB 1581 if ( 1582 arg.type 1583 and arg.type.this != exp.DType.UNKNOWN 1584 and not arg.is_type(*exp.DataType.TEXT_TYPES) 1585 and not _is_binary(arg) 1586 ): 1587 arg = exp.cast(arg, exp.DType.VARCHAR) 1588 1589 result = self.func(hash_func, arg) 1590 return self.func("UNHEX", result) if is_binary else result 1591 1592 1593class DuckDBGenerator(generator.Generator): 1594 PARAMETER_TOKEN = "$" 1595 NAMED_PLACEHOLDER_TOKEN = "$" 1596 JOIN_HINTS = False 1597 TABLE_HINTS = False 1598 QUERY_HINTS = False 1599 LIMIT_FETCH = "LIMIT" 1600 SUPPORTS_NEAREST_JOIN = True 1601 STRUCT_DELIMITER = ("(", ")") 1602 RENAME_TABLE_WITH_DB = False 1603 NVL2_SUPPORTED = False 1604 SEMI_ANTI_JOIN_WITH_SIDE = False 1605 CAST_TO_INT_ROUNDS = True 1606 SAFE_INT_DIVISION = True 1607 TABLESAMPLE_KEYWORDS = "USING SAMPLE" 1608 TABLESAMPLE_SEED_KEYWORD = "REPEATABLE" 1609 LAST_DAY_SUPPORTS_DATE_PART = False 1610 JSON_KEY_VALUE_PAIR_SEP = "," 1611 IGNORE_NULLS_IN_FUNC = True 1612 IGNORE_NULLS_BEFORE_ORDER = False 1613 JSON_PATH_BRACKETED_KEY_SUPPORTED = False 1614 SUPPORTS_CREATE_TABLE_LIKE = False 1615 MULTI_ARG_DISTINCT = False 1616 CAN_IMPLEMENT_ARRAY_ANY = True 1617 SUPPORTS_TO_NUMBER = False 1618 SELECT_KINDS: tuple[str, ...] = () 1619 SUPPORTS_DECODE_CASE = False 1620 SUPPORTS_DROP_ALTER_ICEBERG_PROPERTY = False 1621 1622 # USING SAMPLE goes after QUALIFY and before ORDER BY / LIMIT 1623 # https://duckdb.org/docs/preview/sql/query_syntax/sample 1624 AFTER_HAVING_MODIFIER_TRANSFORMS = { 1625 **generator.AFTER_HAVING_MODIFIER_TRANSFORMS, 1626 "sample": lambda self, e: self.sql(e, "sample"), 1627 } 1628 SUPPORTS_WINDOW_EXCLUDE = True 1629 COPY_HAS_INTO_KEYWORD = False 1630 STAR_EXCEPT = "EXCLUDE" 1631 PAD_FILL_PATTERN_IS_REQUIRED = True 1632 ARRAY_SIZE_DIM_REQUIRED: bool | None = False 1633 NORMALIZE_EXTRACT_DATE_PARTS = True 1634 SUPPORTS_LIKE_QUANTIFIERS = False 1635 HISTORICAL_DATA_POST_ALIAS = True 1636 SET_ASSIGNMENT_REQUIRES_VARIABLE_KEYWORD = True 1637 1638 TRANSFORMS = { 1639 **generator.Generator.TRANSFORMS, 1640 exp.AnyValue: _anyvalue_sql, 1641 exp.ApproxDistinct: approx_count_distinct_sql, 1642 exp.Boolnot: _boolnot_sql, 1643 exp.Booland: _booland_sql, 1644 exp.Boolor: _boolor_sql, 1645 exp.Array: transforms.preprocess( 1646 [transforms.inherit_struct_field_names], 1647 generator=inline_array_unless_query, 1648 ), 1649 exp.ArrayAppend: array_append_sql("LIST_APPEND"), 1650 exp.ArrayCompact: array_compact_sql, 1651 exp.ArrayConstructCompact: lambda self, e: self.sql( 1652 exp.ArrayCompact(this=exp.Array(expressions=e.expressions)) 1653 ), 1654 exp.ArrayConcat: array_concat_sql("LIST_CONCAT"), 1655 exp.ArrayContains: _array_contains_sql, 1656 exp.ArrayOverlaps: _array_overlaps_sql, 1657 exp.ArrayFilter: rename_func("LIST_FILTER"), 1658 exp.ArrayInsert: _array_insert_sql, 1659 exp.ArrayPosition: lambda self, e: ( 1660 self.sql( 1661 exp.Sub( 1662 this=exp.ArrayPosition(this=e.this, expression=e.expression), 1663 expression=exp.Literal.number(1), 1664 ) 1665 ) 1666 if e.args.get("zero_based") 1667 else self.func("ARRAY_POSITION", e.this, e.expression) 1668 ), 1669 exp.ArrayRemoveAt: _array_remove_at_sql, 1670 exp.ArrayRemove: remove_from_array_using_filter, 1671 exp.ArraySort: _array_sort_sql, 1672 exp.ArrayPrepend: array_append_sql("LIST_PREPEND", swap_params=True), 1673 exp.ArraySum: rename_func("LIST_SUM"), 1674 exp.ArrayMax: rename_func("LIST_MAX"), 1675 exp.ArrayMin: rename_func("LIST_MIN"), 1676 exp.Base64DecodeBinary: lambda self, e: _base64_decode_sql(self, e, to_string=False), 1677 exp.Base64DecodeString: lambda self, e: _base64_decode_sql(self, e, to_string=True), 1678 exp.BitwiseAnd: lambda self, e: self._bitwise_op(e, "&"), 1679 exp.BitwiseAndAgg: _bitwise_agg_sql, 1680 exp.BitwiseCount: rename_func("BIT_COUNT"), 1681 exp.BitwiseLeftShift: _bitshift_sql, 1682 exp.BitwiseOr: lambda self, e: self._bitwise_op(e, "|"), 1683 exp.BitwiseOrAgg: _bitwise_agg_sql, 1684 exp.BitwiseRightShift: _bitshift_sql, 1685 exp.BitwiseXorAgg: _bitwise_agg_sql, 1686 exp.CommentColumnConstraint: no_comment_column_constraint_sql, 1687 exp.Corr: lambda self, e: self._corr_sql(e), 1688 exp.CosineDistance: rename_func("LIST_COSINE_DISTANCE"), 1689 exp.CurrentTime: lambda *_: "CURRENT_TIME", 1690 exp.CurrentSchemas: lambda self, e: self.func( 1691 "current_schemas", e.this if e.this else exp.true() 1692 ), 1693 exp.CurrentTimestamp: lambda self, e: ( 1694 self.sql( 1695 exp.AtTimeZone(this=exp.var("CURRENT_TIMESTAMP"), zone=exp.Literal.string("UTC")) 1696 ) 1697 if e.args.get("sysdate") 1698 else "CURRENT_TIMESTAMP" 1699 ), 1700 exp.CurrentVersion: rename_func("version"), 1701 exp.Localtime: unsupported_args("this")(lambda *_: "LOCALTIME"), 1702 exp.DayOfMonth: rename_func("DAYOFMONTH"), 1703 exp.DayOfWeek: rename_func("DAYOFWEEK"), 1704 exp.DayOfWeekIso: rename_func("ISODOW"), 1705 exp.DayOfYear: rename_func("DAYOFYEAR"), 1706 exp.Dayname: lambda self, e: ( 1707 self.func("STRFTIME", e.this, exp.Literal.string("%a")) 1708 if e.args.get("abbreviated") 1709 else self.func("DAYNAME", e.this) 1710 ), 1711 exp.Monthname: lambda self, e: ( 1712 self.func("STRFTIME", e.this, exp.Literal.string("%b")) 1713 if e.args.get("abbreviated") 1714 else self.func("MONTHNAME", e.this) 1715 ), 1716 exp.Date: _date_sql, 1717 exp.DateAdd: _date_delta_to_binary_interval_op(), 1718 exp.DateFromParts: _date_from_parts_sql, 1719 exp.DateSub: _date_delta_to_binary_interval_op(), 1720 exp.DateDiff: _date_diff_sql, 1721 exp.DateStrToDate: datestrtodate_sql, 1722 exp.Datetime: no_datetime_sql, 1723 exp.DatetimeDiff: _date_diff_sql, 1724 exp.DatetimeSub: _date_delta_to_binary_interval_op(), 1725 exp.DatetimeAdd: _date_delta_to_binary_interval_op(), 1726 exp.DateToDi: lambda self, e: ( 1727 f"CAST(STRFTIME({self.sql(e, 'this')}, {self.dialect.DATEINT_FORMAT}) AS INT)" 1728 ), 1729 exp.Decode: lambda self, e: encode_decode_sql(self, e, "DECODE", replace=False), 1730 exp.HexDecodeString: lambda self, e: self.sql(exp.Decode(this=exp.Unhex(this=e.this))), 1731 exp.DiToDate: lambda self, e: ( 1732 f"CAST(STRPTIME(CAST({self.sql(e, 'this')} AS TEXT), {self.dialect.DATEINT_FORMAT}) AS DATE)" 1733 ), 1734 exp.Encode: lambda self, e: encode_decode_sql(self, e, "ENCODE", replace=False), 1735 exp.EqualNull: lambda self, e: self.sql( 1736 exp.NullSafeEQ(this=e.this, expression=e.expression) 1737 ), 1738 exp.EuclideanDistance: rename_func("LIST_DISTANCE"), 1739 exp.GenerateDateArray: _generate_datetime_array_sql, 1740 exp.GenerateSeries: generate_series_sql("GENERATE_SERIES", "RANGE"), 1741 exp.GenerateTimestampArray: _generate_datetime_array_sql, 1742 exp.Getbit: getbit_sql, 1743 exp.GroupConcat: lambda self, e: groupconcat_sql(self, e, within_group=False), 1744 exp.Explode: rename_func("UNNEST"), 1745 exp.IcebergProperty: lambda *_: "", 1746 exp.IntDiv: lambda self, e: self.binary(e, "//"), 1747 exp.IsInf: rename_func("ISINF"), 1748 exp.IsNan: rename_func("ISNAN"), 1749 exp.IsNullValue: lambda self, e: self.sql( 1750 exp.func("JSON_TYPE", e.this).eq(exp.Literal.string("NULL")) 1751 ), 1752 exp.IsArray: lambda self, e: self.sql( 1753 exp.func("JSON_TYPE", e.this).eq(exp.Literal.string("ARRAY")) 1754 ), 1755 exp.Ceil: _ceil_floor, 1756 exp.Floor: _ceil_floor, 1757 exp.JSONBExists: rename_func("JSON_EXISTS"), 1758 exp.JSONExtract: _arrow_json_extract_sql, 1759 exp.JSONExtractArray: _json_extract_value_array_sql, 1760 exp.JSONFormat: _json_format_sql, 1761 exp.JSONValueArray: _json_extract_value_array_sql, 1762 exp.Lateral: _explode_to_unnest_sql, 1763 exp.LogicalOr: lambda self, e: self.func("BOOL_OR", _cast_to_boolean(e.this)), 1764 exp.LogicalAnd: lambda self, e: self.func("BOOL_AND", _cast_to_boolean(e.this)), 1765 exp.Select: transforms.preprocess( 1766 [connect_by_to_recursive_cte, _seq_to_range_in_generator] 1767 ), 1768 exp.Seq1: lambda self, e: _seq_sql(self, e, 1), 1769 exp.Seq2: lambda self, e: _seq_sql(self, e, 2), 1770 exp.Seq4: lambda self, e: _seq_sql(self, e, 4), 1771 exp.Seq8: lambda self, e: _seq_sql(self, e, 8), 1772 exp.BoolxorAgg: _boolxor_agg_sql, 1773 exp.MakeInterval: lambda self, e: no_make_interval_sql(self, e, sep=" "), 1774 exp.Initcap: _initcap_sql, 1775 exp.MD5Digest: lambda self, e: self.func("UNHEX", self.func("MD5", e.this)), 1776 exp.Max: rename_func("MAX"), 1777 exp.Min: rename_func("MIN"), 1778 exp.SHA: lambda self, e: _sha_sql(self, e, "SHA1"), 1779 exp.SHA1Digest: lambda self, e: _sha_sql(self, e, "SHA1", is_binary=True), 1780 exp.SHA2: lambda self, e: _sha_sql(self, e, "SHA256"), 1781 exp.SHA2Digest: lambda self, e: _sha_sql(self, e, "SHA256", is_binary=True), 1782 exp.MonthsBetween: months_between_sql, 1783 exp.NextDay: _day_navigation_sql, 1784 exp.PercentileCont: rename_func("QUANTILE_CONT"), 1785 exp.PercentileDisc: rename_func("QUANTILE_DISC"), 1786 # DuckDB doesn't allow qualified columns inside of PIVOT expressions. 1787 # See: https://github.com/duckdb/duckdb/blob/671faf92411182f81dce42ac43de8bfb05d9909e/src/planner/binder/tableref/bind_pivot.cpp#L61-L62 1788 exp.Pivot: transforms.preprocess([transforms.unqualify_columns]), 1789 exp.PreviousDay: _day_navigation_sql, 1790 exp.RegexpILike: lambda self, e: self.func( 1791 "REGEXP_MATCHES", e.this, e.expression, exp.Literal.string("i") 1792 ), 1793 exp.RegexpSplit: rename_func("STR_SPLIT_REGEX"), 1794 exp.RegrValx: _regr_val_sql, 1795 exp.RegrValy: _regr_val_sql, 1796 exp.Return: lambda self, e: self.sql(e, "this"), 1797 exp.ReturnsProperty: lambda self, e: "TABLE" if isinstance(e.this, exp.Schema) else "", 1798 exp.StrToUnix: lambda self, e: self.func( 1799 "EPOCH", self.func("STRPTIME", e.this, self.format_time(e)) 1800 ), 1801 exp.Struct: _struct_sql, 1802 exp.Transform: rename_func("LIST_TRANSFORM"), 1803 exp.TimeAdd: _date_delta_to_binary_interval_op(), 1804 exp.TimeSub: _date_delta_to_binary_interval_op(), 1805 exp.Time: no_time_sql, 1806 exp.TimeDiff: _timediff_sql, 1807 exp.Timestamp: no_timestamp_sql, 1808 exp.TimestampAdd: _date_delta_to_binary_interval_op(), 1809 exp.TimestampDiff: lambda self, e: self.func( 1810 "DATE_DIFF", exp.Literal.string(e.unit), e.expression, e.this 1811 ), 1812 exp.TimestampSub: _date_delta_to_binary_interval_op(), 1813 exp.TimeStrToDate: lambda self, e: self.sql(exp.cast(e.this, exp.DType.DATE)), 1814 exp.TimeStrToTime: timestrtotime_sql, 1815 exp.TimeStrToUnix: lambda self, e: self.func( 1816 "EPOCH", exp.cast(e.this, exp.DType.TIMESTAMP) 1817 ), 1818 exp.TimeToStr: lambda self, e: self.func("STRFTIME", e.this, self.format_time(e)), 1819 exp.ToBoolean: _to_boolean_sql, 1820 exp.ToVariant: lambda self, e: self.sql( 1821 exp.cast(e.this, exp.DataType.from_str("VARIANT", dialect="duckdb")) 1822 ), 1823 exp.TimeToUnix: rename_func("EPOCH"), 1824 exp.TsOrDiToDi: lambda self, e: ( 1825 f"CAST(SUBSTR(REPLACE(CAST({self.sql(e, 'this')} AS TEXT), '-', ''), 1, 8) AS INT)" 1826 ), 1827 exp.TsOrDsAdd: _date_delta_to_binary_interval_op(), 1828 exp.TsOrDsDiff: lambda self, e: self.func( 1829 "DATE_DIFF", 1830 exp.Literal.string(e.unit or "DAY"), 1831 exp.cast(e.expression, exp.DType.TIMESTAMP), 1832 exp.cast(e.this, exp.DType.TIMESTAMP), 1833 ), 1834 exp.UnixMicros: lambda self, e: self.func("EPOCH_US", _implicit_datetime_cast(e.this)), 1835 exp.UnixMillis: lambda self, e: self.func("EPOCH_MS", _implicit_datetime_cast(e.this)), 1836 exp.UnixSeconds: lambda self, e: self.sql( 1837 exp.cast(self.func("EPOCH", _implicit_datetime_cast(e.this)), exp.DType.BIGINT) 1838 ), 1839 exp.UnixToStr: lambda self, e: self.func( 1840 "STRFTIME", self.func("TO_TIMESTAMP", e.this), self.format_time(e) 1841 ), 1842 exp.UnixToTime: _unix_to_time_sql, 1843 exp.UnixToTimeStr: lambda self, e: f"CAST(TO_TIMESTAMP({self.sql(e, 'this')}) AS TEXT)", 1844 exp.VariancePop: rename_func("VAR_POP"), 1845 exp.WeekOfYear: rename_func("WEEKOFYEAR"), 1846 exp.YearOfWeek: lambda self, e: self.sql( 1847 exp.Extract( 1848 this=exp.Var(this="ISOYEAR"), 1849 expression=e.this, 1850 ) 1851 ), 1852 exp.YearOfWeekIso: lambda self, e: self.sql( 1853 exp.Extract( 1854 this=exp.Var(this="ISOYEAR"), 1855 expression=e.this, 1856 ) 1857 ), 1858 exp.Xor: _xor_sql, 1859 exp.JSONObjectAgg: rename_func("JSON_GROUP_OBJECT"), 1860 exp.JSONArray: _json_constructor_sql, 1861 exp.JSONObject: _json_constructor_sql, 1862 exp.JSONBObjectAgg: rename_func("JSON_GROUP_OBJECT"), 1863 exp.DateBin: rename_func("TIME_BUCKET"), 1864 exp.LastDay: _last_day_sql, 1865 } 1866 1867 SUPPORTED_JSON_PATH_PARTS = { 1868 exp.JSONPathKey, 1869 exp.JSONPathRoot, 1870 exp.JSONPathSubscript, 1871 exp.JSONPathWildcard, 1872 } 1873 1874 TYPE_MAPPING = { 1875 **generator.Generator.TYPE_MAPPING, 1876 exp.DType.BINARY: "BLOB", 1877 exp.DType.BPCHAR: "TEXT", 1878 exp.DType.CHAR: "TEXT", 1879 exp.DType.DATETIME: "TIMESTAMP", 1880 exp.DType.DECFLOAT: "DECIMAL", 1881 exp.DType.FLOAT: "REAL", 1882 exp.DType.JSONB: "JSON", 1883 exp.DType.NCHAR: "TEXT", 1884 exp.DType.NVARCHAR: "TEXT", 1885 exp.DType.UINT: "UINTEGER", 1886 exp.DType.VARBINARY: "BLOB", 1887 exp.DType.ROWVERSION: "BLOB", 1888 exp.DType.VARCHAR: "TEXT", 1889 exp.DType.TIMESTAMPLTZ: "TIMESTAMPTZ", 1890 exp.DType.TIMESTAMPNTZ: "TIMESTAMP", 1891 exp.DType.TIMESTAMP_S: "TIMESTAMP_S", 1892 exp.DType.TIMESTAMP_MS: "TIMESTAMP_MS", 1893 exp.DType.TIMESTAMP_NS: "TIMESTAMP_NS", 1894 exp.DType.BIGDECIMAL: "DECIMAL", 1895 } 1896 1897 TYPE_PARAM_SETTINGS = { 1898 **generator.Generator.TYPE_PARAM_SETTINGS, 1899 exp.DType.BIGDECIMAL: ((38, 5), (38, 38)), 1900 exp.DType.DECFLOAT: ((38, 5), (38, 38)), 1901 } 1902 1903 # https://github.com/duckdb/duckdb/blob/ff7f24fd8e3128d94371827523dae85ebaf58713/third_party/libpg_query/grammar/keywords/reserved_keywords.list#L1-L77 1904 RESERVED_KEYWORDS = { 1905 "array", 1906 "analyse", 1907 "union", 1908 "all", 1909 "when", 1910 "in", 1911 "default", 1912 "create", 1913 "window", 1914 "asymmetric", 1915 "to", 1916 "else", 1917 "localtime", 1918 "from", 1919 "end", 1920 "select", 1921 "current_date", 1922 "foreign", 1923 "with", 1924 "grant", 1925 "session_user", 1926 "or", 1927 "except", 1928 "references", 1929 "fetch", 1930 "limit", 1931 "group", 1932 "leading", 1933 "into", 1934 "collate", 1935 "offset", 1936 "do", 1937 "then", 1938 "localtimestamp", 1939 "check", 1940 "lateral", 1941 "current_role", 1942 "where", 1943 "asc", 1944 "placing", 1945 "desc", 1946 "user", 1947 "unique", 1948 "initially", 1949 "column", 1950 "both", 1951 "some", 1952 "as", 1953 "any", 1954 "only", 1955 "deferrable", 1956 "null", 1957 "current_time", 1958 "true", 1959 "table", 1960 "case", 1961 "trailing", 1962 "variadic", 1963 "for", 1964 "on", 1965 "distinct", 1966 "false", 1967 "not", 1968 "constraint", 1969 "current_timestamp", 1970 "returning", 1971 "primary", 1972 "intersect", 1973 "having", 1974 "analyze", 1975 "current_user", 1976 "and", 1977 "cast", 1978 "symmetric", 1979 "using", 1980 "order", 1981 "current_catalog", 1982 "describe", 1983 "lambda", 1984 "pivot", 1985 "pivot_longer", 1986 "pivot_wider", 1987 "qualify", 1988 "show", 1989 "summarize", 1990 "unpivot", 1991 "anti", 1992 "asof", 1993 "at", 1994 "authorization", 1995 "binary", 1996 "by", 1997 "collation", 1998 "columns", 1999 "concurrently", 2000 "cross", 2001 "freeze", 2002 "full", 2003 "generated", 2004 "glob", 2005 "ilike", 2006 "inner", 2007 "is", 2008 "isnull", 2009 "join", 2010 "left", 2011 "like", 2012 "map", 2013 "natural", 2014 "notnull", 2015 "outer", 2016 "overlaps", 2017 "positional", 2018 "right", 2019 "semi", 2020 "similar", 2021 "struct", 2022 "tablesample", 2023 "try_cast", 2024 "tuple", 2025 "unpack", 2026 "verbose", 2027 } 2028 2029 UNWRAPPED_INTERVAL_VALUES = (exp.Literal, exp.Paren) 2030 2031 # DuckDB doesn't generally support CREATE TABLE .. properties 2032 # https://duckdb.org/docs/sql/statements/create_table.html 2033 # There are a few exceptions (e.g. temporary tables) which are supported or 2034 # can be transpiled to DuckDB, so we explicitly override them accordingly 2035 PROPERTIES_LOCATION = { 2036 **{ 2037 prop: exp.Properties.Location.UNSUPPORTED 2038 for prop in generator.Generator.PROPERTIES_LOCATION 2039 }, 2040 exp.LikeProperty: exp.Properties.Location.POST_SCHEMA, 2041 exp.TemporaryProperty: exp.Properties.Location.POST_CREATE, 2042 exp.ReturnsProperty: exp.Properties.Location.POST_ALIAS, 2043 exp.SequenceProperties: exp.Properties.Location.POST_EXPRESSION, 2044 exp.TriggerProperties: exp.Properties.Location.POST_EXPRESSION, 2045 exp.IcebergProperty: exp.Properties.Location.POST_CREATE, 2046 } 2047 2048 IGNORE_RESPECT_NULLS_WINDOW_FUNCTIONS: t.ClassVar = _IGNORE_RESPECT_NULLS_WINDOW_FUNCTIONS 2049 2050 # Template for ZIPF transpilation - placeholders get replaced with actual parameters 2051 ZIPF_TEMPLATE: t.ClassVar[exp.Expr] = exp.maybe_parse( 2052 """ 2053 WITH rand AS (SELECT :random_expr AS r), 2054 weights AS ( 2055 SELECT i, 1.0 / POWER(i, :s) AS w 2056 FROM RANGE(1, :n + 1) AS t(i) 2057 ), 2058 cdf AS ( 2059 SELECT i, SUM(w) OVER (ORDER BY i) / SUM(w) OVER () AS p 2060 FROM weights 2061 ) 2062 SELECT MIN(i) 2063 FROM cdf 2064 WHERE p >= (SELECT r FROM rand) 2065 """ 2066 ) 2067 2068 # Template for NORMAL transpilation using Box-Muller transform 2069 # mean + (stddev * sqrt(-2 * ln(u1)) * cos(2 * pi * u2)) 2070 NORMAL_TEMPLATE: t.ClassVar[exp.Expr] = exp.maybe_parse( 2071 ":mean + (:stddev * SQRT(-2 * LN(GREATEST(:u1, 1e-10))) * COS(2 * PI() * :u2))" 2072 ) 2073 2074 # Template for generating a seeded pseudo-random value in [0, 1) from a hash 2075 SEEDED_RANDOM_TEMPLATE: t.ClassVar[exp.Expr] = exp.maybe_parse( 2076 "(ABS(HASH(:seed)) % 1000000) / 1000000.0" 2077 ) 2078 2079 # Template for generating signed and unsigned SEQ values within a specified range 2080 SEQ_UNSIGNED: t.ClassVar[exp.Expr] = _SEQ_UNSIGNED 2081 SEQ_SIGNED: t.ClassVar[exp.Expr] = _SEQ_SIGNED 2082 2083 # Template for MAP_CAT transpilation - Snowflake semantics: 2084 # 1. Returns NULL if either input is NULL 2085 # 2. For duplicate keys, prefers non-NULL value (COALESCE(m2[k], m1[k])) 2086 # 3. Filters out entries with NULL values from the result 2087 MAPCAT_TEMPLATE: t.ClassVar[exp.Expr] = exp.maybe_parse( 2088 """ 2089 CASE 2090 WHEN :map1 IS NULL OR :map2 IS NULL THEN NULL 2091 ELSE MAP_FROM_ENTRIES(LIST_FILTER(LIST_TRANSFORM( 2092 LIST_DISTINCT(LIST_CONCAT(MAP_KEYS(:map1), MAP_KEYS(:map2))), 2093 __k -> STRUCT_PACK(key := __k, value := COALESCE(:map2[__k], :map1[__k])) 2094 ), __x -> __x.value IS NOT NULL)) 2095 END 2096 """ 2097 ) 2098 2099 # Mappings for EXTRACT/DATE_PART transpilation 2100 # Maps Snowflake specifiers unsupported in DuckDB to strftime format codes 2101 EXTRACT_STRFTIME_MAPPINGS: t.ClassVar[dict[str, tuple[str, str]]] = { 2102 "WEEKISO": ("%V", "INTEGER"), 2103 "YEAROFWEEK": ("%G", "INTEGER"), 2104 "YEAROFWEEKISO": ("%G", "INTEGER"), 2105 "NANOSECOND": ("%n", "BIGINT"), 2106 } 2107 2108 # Maps epoch-based specifiers to DuckDB epoch functions 2109 EXTRACT_EPOCH_MAPPINGS: t.ClassVar[dict[str, str]] = { 2110 "EPOCH_SECOND": "EPOCH", 2111 "EPOCH_MILLISECOND": "EPOCH_MS", 2112 "EPOCH_MICROSECOND": "EPOCH_US", 2113 "EPOCH_NANOSECOND": "EPOCH_NS", 2114 } 2115 2116 # Template for BITMAP_CONSTRUCT_AGG transpilation 2117 # 2118 # BACKGROUND: 2119 # Snowflake's BITMAP_CONSTRUCT_AGG aggregates integers into a compact binary bitmap. 2120 # Supports values in range 0-32767, this version returns NULL if any value is out of range 2121 # See: https://docs.snowflake.com/en/sql-reference/functions/bitmap_construct_agg 2122 # See: https://docs.snowflake.com/en/user-guide/querying-bitmaps-for-distinct-counts 2123 # 2124 # Snowflake uses two different formats based on the number of unique values: 2125 # 2126 # Format 1 - Small bitmap (< 5 unique values): Length of 10 bytes 2127 # Bytes 0-1: Count of values as 2-byte big-endian integer (e.g., 3 values = 0x0003) 2128 # Bytes 2-9: Up to 4 values, each as 2-byte little-endian integers, zero-padded to 8 bytes 2129 # Example: Values [1, 2, 3] -> 0x0003 0100 0200 0300 0000 (hex) 2130 # count v1 v2 v3 pad 2131 # 2132 # Format 2 - Large bitmap (>= 5 unique values): Length of 10 + (2 * count) bytes 2133 # Bytes 0-9: Fixed header 0x08 followed by 9 zero bytes 2134 # Bytes 10+: Each value as 2-byte little-endian integer (no padding) 2135 # Example: Values [1,2,3,4,5] -> 0x08 00000000 00000000 00 0100 0200 0300 0400 0500 2136 # hdr ----9 zero bytes---- v1 v2 v3 v4 v5 2137 # 2138 # TEMPLATE STRUCTURE 2139 # 2140 # Phase 1 - Innermost subquery: Data preparation 2141 # SELECT LIST_SORT(...) AS l 2142 # - Aggregates all input values into a list, remove NULLs, duplicates and sorts 2143 # Result: Clean, sorted list of unique non-null integers stored as 'l' 2144 # 2145 # Phase 2 - Middle subquery: Hex string construction 2146 # LIST_TRANSFORM(...) 2147 # - Converts each integer to 2-byte little-endian hex representation 2148 # - & 255 extracts low byte, >> 8 extracts high byte 2149 # - LIST_REDUCE: Concatenates all hex pairs into single string 'h' 2150 # Result: Hex string of all values 2151 # 2152 # Phase 3 - Outer SELECT: Final bitmap assembly 2153 # LENGTH(l) < 5: 2154 # - Small format: 2-byte count (big-endian via %04X) + values + zero padding 2155 # LENGTH(l) >= 5: 2156 # - Large format: Fixed 10-byte header + values (no padding needed) 2157 # Result: Complete binary bitmap as BLOB 2158 # 2159 BITMAP_CONSTRUCT_AGG_TEMPLATE: t.ClassVar[exp.Expr] = exp.maybe_parse( 2160 """ 2161 SELECT CASE 2162 WHEN l IS NULL OR LENGTH(l) = 0 THEN NULL 2163 WHEN LENGTH(l) != LENGTH(LIST_FILTER(l, __v -> __v BETWEEN 0 AND 32767)) THEN NULL 2164 WHEN LENGTH(l) < 5 THEN UNHEX(PRINTF('%04X', LENGTH(l)) || h || REPEAT('00', GREATEST(0, 4 - LENGTH(l)) * 2)) 2165 ELSE UNHEX('08000000000000000000' || h) 2166 END 2167 FROM ( 2168 SELECT l, COALESCE(LIST_REDUCE( 2169 LIST_TRANSFORM(l, __x -> PRINTF('%02X%02X', CAST(__x AS INT) & 255, (CAST(__x AS INT) >> 8) & 255)), 2170 (__a, __b) -> __a || __b, '' 2171 ), '') AS h 2172 FROM (SELECT LIST_SORT(LIST_DISTINCT(LIST(:arg) FILTER(NOT :arg IS NULL))) AS l) 2173 ) 2174 """ 2175 ) 2176 2177 # Template for RANDSTR transpilation - placeholders get replaced with actual parameters 2178 RANDSTR_TEMPLATE: t.ClassVar[exp.Expr] = exp.maybe_parse( 2179 f""" 2180 SELECT LISTAGG( 2181 SUBSTRING( 2182 '{RANDSTR_CHAR_POOL}', 2183 1 + CAST(FLOOR(random_value * 62) AS INT), 2184 1 2185 ), 2186 '' 2187 ) 2188 FROM ( 2189 SELECT (ABS(HASH(i + :seed)) % 1000) / 1000.0 AS random_value 2190 FROM RANGE(:length) AS t(i) 2191 ) 2192 """, 2193 ) 2194 2195 # Template for MINHASH transpilation 2196 # Computes k minimum hash values across aggregated data using DuckDB list functions 2197 # Returns JSON matching Snowflake format: {"state": [...], "type": "minhash", "version": 1} 2198 MINHASH_TEMPLATE: t.ClassVar[exp.Expr] = exp.maybe_parse( 2199 """ 2200 SELECT JSON_OBJECT('state', LIST(min_h ORDER BY seed), 'type', 'minhash', 'version', 1) 2201 FROM ( 2202 SELECT seed, LIST_MIN(LIST_TRANSFORM(vals, __v -> HASH(CAST(__v AS VARCHAR) || CAST(seed AS VARCHAR)))) AS min_h 2203 FROM (SELECT LIST(:expr) AS vals), RANGE(0, :k) AS t(seed) 2204 ) 2205 """, 2206 ) 2207 2208 # Template for MINHASH_COMBINE transpilation 2209 # Combines multiple minhash signatures by taking element-wise minimum 2210 MINHASH_COMBINE_TEMPLATE: t.ClassVar[exp.Expr] = exp.maybe_parse( 2211 """ 2212 SELECT JSON_OBJECT('state', LIST(min_h ORDER BY idx), 'type', 'minhash', 'version', 1) 2213 FROM ( 2214 SELECT 2215 pos AS idx, 2216 MIN(val) AS min_h 2217 FROM 2218 UNNEST(LIST(:expr)) AS _(sig), 2219 UNNEST(CAST(sig -> 'state' AS UBIGINT[])) WITH ORDINALITY AS t(val, pos) 2220 GROUP BY pos 2221 ) 2222 """, 2223 ) 2224 2225 # Template for APPROXIMATE_SIMILARITY transpilation 2226 # Computes multi-way Jaccard similarity: fraction of positions where ALL signatures agree 2227 APPROXIMATE_SIMILARITY_TEMPLATE: t.ClassVar[exp.Expr] = exp.maybe_parse( 2228 """ 2229 SELECT CAST(SUM(CASE WHEN num_distinct = 1 THEN 1 ELSE 0 END) AS DOUBLE) / COUNT(*) 2230 FROM ( 2231 SELECT pos, COUNT(DISTINCT h) AS num_distinct 2232 FROM ( 2233 SELECT h, pos 2234 FROM UNNEST(LIST(:expr)) AS _(sig), 2235 UNNEST(CAST(sig -> 'state' AS UBIGINT[])) WITH ORDINALITY AS s(h, pos) 2236 ) 2237 GROUP BY pos 2238 ) 2239 """, 2240 ) 2241 2242 # Template for ARRAYS_ZIP transpilation 2243 # Snowflake pads to longest array; DuckDB LIST_ZIP truncates to shortest 2244 # Uses RANGE + indexing to match Snowflake behavior 2245 ARRAYS_ZIP_TEMPLATE: t.ClassVar[exp.Expr] = exp.maybe_parse( 2246 """ 2247 CASE WHEN :null_check THEN NULL 2248 WHEN :all_empty_check THEN [:empty_struct] 2249 ELSE LIST_TRANSFORM(RANGE(0, :max_len), __i -> :transform_struct) 2250 END 2251 """, 2252 ) 2253 2254 UUID_V5_TEMPLATE: t.ClassVar[exp.Expr] = exp.maybe_parse( 2255 """ 2256 (SELECT 2257 LOWER( 2258 SUBSTR(h, 1, 8) || '-' || 2259 SUBSTR(h, 9, 4) || '-' || 2260 '5' || SUBSTR(h, 14, 3) || '-' || 2261 FORMAT('{:02x}', CAST('0x' || SUBSTR(h, 17, 2) AS INT) & 63 | 128) || SUBSTR(h, 19, 2) || '-' || 2262 SUBSTR(h, 21, 12) 2263 ) 2264 FROM ( 2265 SELECT SUBSTR(SHA1(UNHEX(REPLACE(:namespace, '-', '')) || ENCODE(:name, 'utf8')), 1, 32) AS h 2266 )) 2267 """ 2268 ) 2269 2270 # Shared bag semantics outer frame for ARRAY_EXCEPT and ARRAY_INTERSECTION. 2271 # Each element is paired with its 1-based position via LIST_ZIP, then filtered 2272 # by a comparison operator (supplied via :cond) that determines the operation: 2273 # EXCEPT (>): keep the N-th occurrence only if N > count in arr2 2274 # e.g. [2,2,2] EXCEPT [2,2] -> [2] 2275 # INTERSECTION (<=): keep the N-th occurrence only if N <= count in arr2 2276 # e.g. [2,2,2] INTERSECT [2,2] -> [2,2] 2277 # IS NOT DISTINCT FROM is used for NULL-safe element comparison. 2278 ARRAY_BAG_TEMPLATE: t.ClassVar[exp.Expr] = exp.maybe_parse( 2279 """ 2280 CASE 2281 WHEN :arr1 IS NULL OR :arr2 IS NULL THEN NULL 2282 ELSE LIST_TRANSFORM( 2283 LIST_FILTER( 2284 LIST_ZIP(:arr1, GENERATE_SERIES(1, LEN(:arr1))), 2285 pair -> :cond 2286 ), 2287 pair -> pair[0] 2288 ) 2289 END 2290 """ 2291 ) 2292 2293 ARRAY_EXCEPT_CONDITION: t.ClassVar[exp.Expr] = exp.maybe_parse( 2294 "LEN(LIST_FILTER(:arr1[1:pair[1]], e -> e IS NOT DISTINCT FROM pair[0]))" 2295 " > LEN(LIST_FILTER(:arr2, e -> e IS NOT DISTINCT FROM pair[0]))" 2296 ) 2297 2298 ARRAY_INTERSECTION_CONDITION: t.ClassVar[exp.Expr] = exp.maybe_parse( 2299 "LEN(LIST_FILTER(:arr1[1:pair[1]], e -> e IS NOT DISTINCT FROM pair[0]))" 2300 " <= LEN(LIST_FILTER(:arr2, e -> e IS NOT DISTINCT FROM pair[0]))" 2301 ) 2302 2303 # Set semantics for ARRAY_EXCEPT. Deduplicates arr1 via LIST_DISTINCT, then 2304 # filters out any element that appears at least once in arr2. 2305 # e.g. [1,1,2,3] EXCEPT [1] -> [2,3] 2306 # IS NOT DISTINCT FROM is used for NULL-safe element comparison. 2307 ARRAY_EXCEPT_SET_TEMPLATE: t.ClassVar[exp.Expr] = exp.maybe_parse( 2308 """ 2309 CASE 2310 WHEN :arr1 IS NULL OR :arr2 IS NULL THEN NULL 2311 ELSE LIST_FILTER( 2312 LIST_DISTINCT(:arr1), 2313 e -> LEN(LIST_FILTER(:arr2, x -> x IS NOT DISTINCT FROM e)) = 0 2314 ) 2315 END 2316 """ 2317 ) 2318 2319 # BigQuery's `x IN UNNEST(arr)` NULL semantics: 2320 # NULL IN UNNEST([1, 2]) -> NULL 2321 # 3 IN UNNEST([1, NULL]) -> NULL 2322 # 3 IN UNNEST([1, 2]) -> FALSE 2323 # 1 IN UNNEST(NULL) -> FALSE (not NULL) 2324 # 1 IN UNNEST([]) -> FALSE 2325 # The default `IN (SELECT UNNEST(...))` rewrite creates a correlated subquery 2326 # that DuckDB rejects inside non-inner joins, so a CASE expression is used instead. 2327 IN_UNNEST_TEMPLATE: t.ClassVar[exp.Expr] = exp.maybe_parse( 2328 """ 2329 CASE 2330 WHEN :arr IS NULL OR ARRAY_LENGTH(:arr) = 0 THEN FALSE 2331 WHEN ARRAY_CONTAINS(:arr, :value) THEN TRUE 2332 WHEN :value IS NULL OR ARRAY_LENGTH(:arr) <> LIST_COUNT(:arr) THEN NULL 2333 ELSE FALSE 2334 END 2335 """ 2336 ) 2337 2338 STRTOK_TO_ARRAY_TEMPLATE: t.ClassVar[exp.Expr] = exp.maybe_parse( 2339 """ 2340 CASE WHEN :delimiter IS NULL THEN NULL 2341 ELSE LIST_FILTER( 2342 REGEXP_SPLIT_TO_ARRAY(:string, CASE WHEN :delimiter = '' THEN '.^' ELSE CONCAT('[', :escaped, ']') END), 2343 x -> NOT x = '' 2344 ) END 2345 """ 2346 ) 2347 2348 # Template for STRTOK function transpilation 2349 # 2350 # DuckDB itself doesn't have a strtok function. This handles the transpilation from Snowflake to DuckDB. 2351 # We may need to adjust this if we want to support transpilation from other dialects 2352 # 2353 # CASE 2354 # -- Snowflake: empty delimiter + empty input string -> NULL 2355 # WHEN delimiter = '' AND input_str = '' THEN NULL 2356 # 2357 # -- Snowflake: empty delimiter + non-empty input string -> treats whole input as 1 token -> return input string if index is 1 2358 # WHEN delimiter = '' AND index = 1 THEN input_str 2359 # 2360 # -- Snowflake: empty delimiter + non-empty input string -> treats whole input as 1 token -> return NULL if index is not 1 2361 # WHEN delimiter = '' THEN NULL 2362 # 2363 # -- Snowflake: negative indices return NULL 2364 # WHEN index < 0 THEN NULL 2365 # 2366 # -- Snowflake: return NULL if any argument is NULL 2367 # WHEN input_str IS NULL OR delimiter IS NULL OR index IS NULL THEN NULL 2368 # 2369 # 2370 # ELSE LIST_FILTER( 2371 # REGEXP_SPLIT_TO_ARRAY( 2372 # input_str, 2373 # CASE 2374 # -- if delimiter is '', we don't want to surround it with '[' and ']' as '[]' is invalid for DuckDB 2375 # WHEN delimiter = '' THEN '' 2376 # 2377 # -- handle problematic regex characters in delimiter with REGEXP_REPLACE 2378 # -- turn delimiter into a regex char set, otherwise DuckDB will match in order, which we don't want 2379 # ELSE '[' || REGEXP_REPLACE(delimiter, problematic_char_set, '\\\1', 'g') || ']' 2380 # END 2381 # ), 2382 # 2383 # -- Snowflake: don't return empty strings 2384 # x -> NOT x = '' 2385 # )[index] 2386 # END 2387 STRTOK_TEMPLATE: t.ClassVar[exp.Expr] = exp.maybe_parse( 2388 """ 2389 CASE 2390 WHEN :delimiter = '' AND :string = '' THEN NULL 2391 WHEN :delimiter = '' AND :part_index = 1 THEN :string 2392 WHEN :delimiter = '' THEN NULL 2393 WHEN :part_index < 0 THEN NULL 2394 WHEN :string IS NULL OR :delimiter IS NULL OR :part_index IS NULL THEN NULL 2395 ELSE :base_func 2396 END 2397 """ 2398 ) 2399 2400 # Snowflake AUTO detects 3 DATE formats: YYYY-MM-DD (ISO-8601), MM/DD/YYYY, DD-MON-YYYY. 2401 # DuckDB TRY_CAST handles ISO-8601 natively. For the other two formats we use CONTAINS('/') 2402 # and REGEXP_MATCHES('[A-Za-z]') as heuristics — these correctly handle single-digit months 2403 # and days (e.g. 1/5/2020, 5-JAN-2020) where a positional char check would fail. 2404 # Ref: https://docs.snowflake.com/en/sql-reference/date-time-input-output#date-formats 2405 _TRYCAST_DATE_SLASH_FMT = "%m/%d/%Y" 2406 _TRYCAST_DATE_MON_FMT = "%d-%b-%Y" 2407 2408 def datatype_sql(self, expression: exp.DataType) -> str: 2409 if expression.is_type(exp.DType.ARRAY): 2410 return f"{self.expressions(expression, flat=True)}[{self.expressions(expression, key='values', flat=True)}]" 2411 2412 # Modifiers are not supported for TIME, [TIME | TIMESTAMP] WITH TIME ZONE 2413 if expression.is_type( 2414 exp.DType.TIME, exp.DType.TIMETZ, exp.DType.TIMESTAMPTZ, exp.DType.TIMESTAMPLTZ 2415 ): 2416 expression.set("expressions", None) 2417 2418 return super().datatype_sql(expression) 2419 2420 def _array_bag_sql(self, condition: exp.Expr, arr1: exp.Expr, arr2: exp.Expr) -> str: 2421 cond = exp.Paren(this=exp.replace_placeholders(condition, arr1=arr1, arr2=arr2)) 2422 return self.sql( 2423 exp.replace_placeholders(self.ARRAY_BAG_TEMPLATE, arr1=arr1, arr2=arr2, cond=cond) 2424 ) 2425 2426 def timeslice_sql(self, expression: exp.TimeSlice) -> str: 2427 """ 2428 Transform Snowflake's TIME_SLICE to DuckDB's time_bucket. 2429 2430 Snowflake: TIME_SLICE(date_expr, slice_length, 'UNIT' [, 'START'|'END']) 2431 DuckDB: time_bucket(INTERVAL 'slice_length' UNIT, date_expr, origin) 2432 2433 Snowflake aligns slices to the 1970-01-01 epoch, so an explicit origin is 2434 required because DuckDB's default origin differs. For 'END' kind, add the 2435 interval to get the next slice's start; for DATE input, cast back to DATE. 2436 """ 2437 date_expr = expression.this 2438 slice_length = expression.expression 2439 # Normalize unit aliases (e.g. W/WK/WOY -> WEEK) so the interval and origin agree 2440 unit = map_date_part(expression.unit, self.dialect) 2441 kind = expression.text("kind").upper() 2442 2443 interval_expr = exp.Interval(this=slice_length, unit=unit) 2444 2445 origin = expression.args.get("origin") 2446 if origin is None: 2447 # Snowflake aligns to 1970-01-01; WEEK to the Monday of the epoch week (WEEK_START=0) 2448 origin = exp.Literal.string( 2449 "1969-12-29" if unit and unit.name.upper() == "WEEK" else "1970-01-01" 2450 ) 2451 # A DATE origin keeps a DATE input a DATE; DuckDB widens it for TIMESTAMP inputs 2452 time_bucket_expr = exp.func( 2453 "time_bucket", interval_expr, date_expr, exp.cast(origin, exp.DType.DATE) 2454 ) 2455 2456 if kind != "END": 2457 return self.sql(time_bucket_expr) 2458 2459 add_expr = exp.Add(this=time_bucket_expr, expression=interval_expr.copy()) 2460 2461 # DuckDB widens DATE to TIMESTAMP when adding intervals, so cast back 2462 if date_expr.is_type(exp.DType.DATE): 2463 return self.sql(exp.cast(add_expr, exp.DType.DATE)) 2464 2465 return self.sql(add_expr) 2466 2467 def unicodestring_sql(self, expression: exp.UnicodeString) -> str: 2468 # DuckDB has no Unicode escape strings and doesn't read \u in a string, so the escapes 2469 # are decoded into the characters, e.g. U&'d\0061t\0061' and U&'d!+000061ta' UESCAPE '!' 2470 # are both 'data' 2471 escape = expression.args.get("escape") 2472 escape_char = re.escape(escape.name if isinstance(escape, exp.Expr) else "\\") 2473 pattern = re.compile( 2474 rf"{escape_char}(?:\+([0-9a-fA-F]{{6}})|([0-9a-fA-F]{{4}})|({escape_char}))" 2475 ) 2476 2477 def decode(match: re.Match[str]) -> str: 2478 if match.group(3): 2479 return match.group(3) 2480 return chr(int(match.group(1) or match.group(2), 16)) 2481 2482 # A character outside the BMP can be written as a UTF-16 surrogate pair 2483 text = ( 2484 pattern.sub(decode, expression.name).encode("utf-16", "surrogatepass").decode("utf-16") 2485 ) 2486 return self.sql(exp.Literal.string(text)) 2487 2488 def bytestring_sql(self, expression: exp.ByteString) -> str: 2489 if not expression.args.get("is_bytes"): 2490 return super().bytestring_sql(expression) 2491 2492 # Each char is a byte. Casting to BLOB decodes \xHH, so everything but printable ASCII 2493 # (including the backslash itself) is written as such 2494 text = "".join( 2495 ch if ch.isascii() and ch.isprintable() and ch != "\\" else f"\\x{ord(ch):02x}" 2496 for ch in expression.this 2497 ) 2498 return self.sql(exp.cast(exp.Literal.string(text), exp.DType.BINARY)) 2499 2500 def bitmapbucketnumber_sql(self, expression: exp.BitmapBucketNumber) -> str: 2501 """ 2502 Transpile BITMAP_BUCKET_NUMBER function from Snowflake to DuckDB equivalent. 2503 2504 Snowflake's BITMAP_BUCKET_NUMBER returns a 1-based bucket identifier where: 2505 - Each bucket covers 32,768 values 2506 - Bucket numbering starts at 1 2507 - Formula: ((value - 1) // 32768) + 1 for positive values 2508 2509 For non-positive values (0 and negative), we use value // 32768 to avoid 2510 producing bucket 0 or positive bucket IDs for negative inputs. 2511 """ 2512 value = expression.this 2513 2514 positive_formula = ((value - 1) // 32768) + 1 2515 non_positive_formula = value // 32768 2516 2517 # CASE WHEN value > 0 THEN ((value - 1) // 32768) + 1 ELSE value // 32768 END 2518 case_expr = ( 2519 exp.case() 2520 .when(exp.GT(this=value, expression=exp.Literal.number(0)), positive_formula) 2521 .else_(non_positive_formula) 2522 ) 2523 return self.sql(case_expr) 2524 2525 def bitmapbitposition_sql(self, expression: exp.BitmapBitPosition) -> str: 2526 """ 2527 Transpile Snowflake's BITMAP_BIT_POSITION to DuckDB CASE expression. 2528 2529 Snowflake's BITMAP_BIT_POSITION behavior: 2530 - For n <= 0: returns ABS(n) % 32768 2531 - For n > 0: returns (n - 1) % 32768 (maximum return value is 32767) 2532 """ 2533 this = expression.this 2534 2535 return self.sql( 2536 exp.Mod( 2537 this=exp.Paren( 2538 this=exp.If( 2539 this=exp.GT(this=this, expression=exp.Literal.number(0)), 2540 true=this - exp.Literal.number(1), 2541 false=exp.Abs(this=this), 2542 ) 2543 ), 2544 expression=MAX_BIT_POSITION, 2545 ) 2546 ) 2547 2548 def bitmapconstructagg_sql(self, expression: exp.BitmapConstructAgg) -> str: 2549 """ 2550 Transpile Snowflake's BITMAP_CONSTRUCT_AGG to DuckDB equivalent. 2551 Uses a pre-parsed template with placeholders replaced by expression nodes. 2552 2553 Snowflake bitmap format: 2554 - Small (< 5 unique values): 2-byte count (big-endian) + values (little-endian) + padding to 10 bytes 2555 - Large (>= 5 unique values): 10-byte header (0x08 + 9 zeros) + values (little-endian) 2556 """ 2557 arg = expression.this 2558 return ( 2559 f"({self.sql(exp.replace_placeholders(self.BITMAP_CONSTRUCT_AGG_TEMPLATE, arg=arg))})" 2560 ) 2561 2562 def getignorecase_sql(self, expression: exp.GetIgnoreCase) -> str: 2563 self.unsupported("DuckDB does not support the GET_IGNORE_CASE() function") 2564 return self.function_fallback_sql(expression) 2565 2566 def compress_sql(self, expression: exp.Compress) -> str: 2567 self.unsupported("DuckDB does not support the COMPRESS() function") 2568 return self.function_fallback_sql(expression) 2569 2570 def encrypt_sql(self, expression: exp.Encrypt) -> str: 2571 self.unsupported("ENCRYPT is not supported in DuckDB") 2572 return self.function_fallback_sql(expression) 2573 2574 def decrypt_sql(self, expression: exp.Decrypt) -> str: 2575 func_name = "TRY_DECRYPT" if expression.args.get("safe") else "DECRYPT" 2576 self.unsupported(f"{func_name} is not supported in DuckDB") 2577 return self.function_fallback_sql(expression) 2578 2579 def decryptraw_sql(self, expression: exp.DecryptRaw) -> str: 2580 func_name = "TRY_DECRYPT_RAW" if expression.args.get("safe") else "DECRYPT_RAW" 2581 self.unsupported(f"{func_name} is not supported in DuckDB") 2582 return self.function_fallback_sql(expression) 2583 2584 def encryptraw_sql(self, expression: exp.EncryptRaw) -> str: 2585 self.unsupported("ENCRYPT_RAW is not supported in DuckDB") 2586 return self.function_fallback_sql(expression) 2587 2588 def parseurl_sql(self, expression: exp.ParseUrl) -> str: 2589 self.unsupported("PARSE_URL is not supported in DuckDB") 2590 return self.function_fallback_sql(expression) 2591 2592 def parseip_sql(self, expression: exp.ParseIp) -> str: 2593 self.unsupported("PARSE_IP is not supported in DuckDB") 2594 return self.function_fallback_sql(expression) 2595 2596 def decompressstring_sql(self, expression: exp.DecompressString) -> str: 2597 self.unsupported("DECOMPRESS_STRING is not supported in DuckDB") 2598 return self.function_fallback_sql(expression) 2599 2600 def decompressbinary_sql(self, expression: exp.DecompressBinary) -> str: 2601 self.unsupported("DECOMPRESS_BINARY is not supported in DuckDB") 2602 return self.function_fallback_sql(expression) 2603 2604 def jarowinklersimilarity_sql(self, expression: exp.JarowinklerSimilarity) -> str: 2605 this = expression.this 2606 expr = expression.expression 2607 2608 if expression.args.get("case_insensitive"): 2609 this = exp.Upper(this=this) 2610 expr = exp.Upper(this=expr) 2611 2612 result = exp.func("JARO_WINKLER_SIMILARITY", this, expr) 2613 2614 if expression.args.get("integer_scale"): 2615 result = exp.cast(result * 100, "INTEGER") 2616 2617 return self.sql(result) 2618 2619 def randstr_sql(self, expression: exp.Randstr) -> str: 2620 """ 2621 Transpile Snowflake's RANDSTR to DuckDB equivalent using deterministic hash-based random. 2622 Uses a pre-parsed template with placeholders replaced by expression nodes. 2623 2624 RANDSTR(length, generator) generates a random string of specified length. 2625 - With numeric seed: Use HASH(i + seed) for deterministic output (same seed = same result) 2626 - With RANDOM(): Use RANDOM() in the hash for non-deterministic output 2627 - No generator: Use default seed value 2628 """ 2629 length = expression.this 2630 generator = expression.args.get("generator") 2631 2632 if generator: 2633 if isinstance(generator, exp.Rand): 2634 # If it's RANDOM(), use its seed if available, otherwise use RANDOM() itself 2635 seed_value = generator.this or generator 2636 else: 2637 # Const/int or other expression - use as seed directly 2638 seed_value = generator 2639 else: 2640 # No generator specified, use default seed (arbitrary but deterministic) 2641 seed_value = exp.Literal.number(RANDSTR_SEED) 2642 2643 replacements = {"seed": seed_value, "length": length} 2644 return f"({self.sql(exp.replace_placeholders(self.RANDSTR_TEMPLATE, **replacements))})" 2645 2646 @unsupported_args("finish") 2647 def reduce_sql(self, expression: exp.Reduce) -> str: 2648 array_arg = expression.this 2649 initial_value = expression.args.get("initial") 2650 merge_lambda = expression.args.get("merge") 2651 2652 return self.func("list_reduce", array_arg, merge_lambda, initial_value) 2653 2654 def zipf_sql(self, expression: exp.Zipf) -> str: 2655 """ 2656 Transpile Snowflake's ZIPF to DuckDB using CDF-based inverse sampling. 2657 Uses a pre-parsed template with placeholders replaced by expression nodes. 2658 """ 2659 s = expression.this 2660 n = expression.args["elementcount"] 2661 gen = expression.args["gen"] 2662 2663 if not isinstance(gen, exp.Rand): 2664 # (ABS(HASH(seed)) % 1000000) / 1000000.0 2665 random_expr: exp.Expr = exp.Div( 2666 this=exp.Paren( 2667 this=exp.Mod( 2668 this=exp.Abs(this=exp.Anonymous(this="HASH", expressions=[gen.copy()])), 2669 expression=exp.Literal.number(1000000), 2670 ) 2671 ), 2672 expression=exp.Literal.number(1000000.0), 2673 ) 2674 else: 2675 # Use RANDOM() for non-deterministic output 2676 random_expr = exp.Rand() 2677 2678 replacements = {"s": s, "n": n, "random_expr": random_expr} 2679 return f"({self.sql(exp.replace_placeholders(self.ZIPF_TEMPLATE, **replacements))})" 2680 2681 def tobinary_sql(self, expression: exp.ToBinary) -> str: 2682 """ 2683 TO_BINARY and TRY_TO_BINARY transpilation: 2684 - 'HEX': TO_BINARY('48454C50', 'HEX') -> UNHEX('48454C50') 2685 - 'UTF-8': TO_BINARY('TEST', 'UTF-8') -> ENCODE('TEST') 2686 - 'BASE64': TO_BINARY('SEVMUA==', 'BASE64') -> FROM_BASE64('SEVMUA==') 2687 2688 For TRY_TO_BINARY (safe=True), wrap with TRY(): 2689 - 'HEX': TRY_TO_BINARY('invalid', 'HEX') -> TRY(UNHEX('invalid')) 2690 """ 2691 value = expression.this 2692 format_arg = expression.args.get("format") 2693 is_safe = expression.args.get("safe") 2694 is_binary = _is_binary(expression) 2695 2696 if not format_arg and not is_binary: 2697 func_name = "TRY_TO_BINARY" if is_safe else "TO_BINARY" 2698 return self.func(func_name, value) 2699 2700 # Snowflake defaults to HEX encoding when no format is specified 2701 fmt = format_arg.name.upper() if format_arg else "HEX" 2702 2703 if fmt in ("UTF-8", "UTF8"): 2704 # DuckDB ENCODE always uses UTF-8, no charset parameter needed 2705 result = self.func("ENCODE", value) 2706 elif fmt == "BASE64": 2707 result = self.func("FROM_BASE64", value) 2708 elif fmt == "HEX": 2709 result = self.func("UNHEX", value) 2710 else: 2711 if is_safe: 2712 return self.sql(exp.null()) 2713 else: 2714 self.unsupported(f"format {fmt} is not supported") 2715 result = self.func("TO_BINARY", value) 2716 return f"TRY({result})" if is_safe else result 2717 2718 def tonumber_sql(self, expression: exp.ToNumber) -> str: 2719 fmt = expression.args.get("format") 2720 precision = expression.args.get("precision") 2721 scale = expression.args.get("scale") 2722 2723 if not fmt and precision and scale: 2724 to = exp.DataType.build(f"DECIMAL({precision.name}, {scale.name})", dialect="duckdb") 2725 if expression.args.get("safe"): 2726 return self.sql(exp.TryCast(this=expression.this, to=to)) 2727 return self.sql(exp.cast(expression.this, to, dialect="duckdb")) 2728 2729 return super().tonumber_sql(expression) 2730 2731 def _greatest_least_sql(self, expression: exp.Greatest | exp.Least) -> str: 2732 """ 2733 Handle GREATEST/LEAST functions with dialect-aware NULL behavior. 2734 2735 - If ignore_nulls=False (BigQuery-style): return NULL if any argument is NULL 2736 - If ignore_nulls=True (DuckDB/PostgreSQL-style): ignore NULLs, return greatest/least non-NULL value 2737 """ 2738 # Get all arguments 2739 all_args = [expression.this, *expression.expressions] 2740 fallback_sql = self.function_fallback_sql(expression) 2741 2742 if expression.args.get("ignore_nulls"): 2743 # DuckDB/PostgreSQL behavior: use native GREATEST/LEAST (ignores NULLs) 2744 return self.sql(fallback_sql) 2745 2746 # return NULL if any argument is NULL 2747 case_expr = exp.case().when( 2748 exp.or_(*[arg.is_(exp.null()) for arg in all_args], copy=False), 2749 exp.null(), 2750 copy=False, 2751 ) 2752 case_expr.set("default", fallback_sql) 2753 return self.sql(case_expr) 2754 2755 def generator_sql(self, expression: exp.Generator) -> str: 2756 # Transpile Snowflake GENERATOR to DuckDB range() 2757 rowcount = expression.args.get("rowcount") 2758 time_limit = expression.args.get("time_limit") 2759 2760 if time_limit: 2761 self.unsupported("GENERATOR TIMELIMIT parameter is not supported in DuckDB") 2762 2763 if not rowcount: 2764 self.unsupported("GENERATOR without ROWCOUNT is not supported in DuckDB") 2765 return self.func("range", exp.Literal.number(0)) 2766 2767 return self.func("range", rowcount) 2768 2769 def greatest_sql(self, expression: exp.Greatest) -> str: 2770 return self._greatest_least_sql(expression) 2771 2772 def least_sql(self, expression: exp.Least) -> str: 2773 return self._greatest_least_sql(expression) 2774 2775 def lambda_sql(self, expression: exp.Lambda, arrow_sep: str = "->", wrap: bool = True) -> str: 2776 if self.dialect.version >= (1, 3): 2777 prefix = "LAMBDA " 2778 arrow_sep = ":" 2779 wrap = False 2780 else: 2781 prefix = "" 2782 2783 lambda_sql = super().lambda_sql(expression, arrow_sep=arrow_sep, wrap=wrap) 2784 return f"{prefix}{lambda_sql}" 2785 2786 def show_sql(self, expression: exp.Show) -> str: 2787 from_ = self.sql(expression, "from_") 2788 from_ = f" FROM {from_}" if from_ else "" 2789 return f"SHOW {expression.name}{from_}" 2790 2791 def soundex_sql(self, expression: exp.Soundex) -> str: 2792 self.unsupported("SOUNDEX is not supported in DuckDB") 2793 return self.func("SOUNDEX", expression.this) 2794 2795 def placeholder_sql(self, expression: exp.Placeholder) -> str: 2796 if isinstance(expression.this, exp.Identifier): 2797 return f"${self.sql(expression, 'this')}" 2798 return super().placeholder_sql(expression) 2799 2800 def install_sql(self, expression: exp.Install) -> str: 2801 force = "FORCE " if expression.args.get("force") else "" 2802 this = self.sql(expression, "this") 2803 from_clause = expression.args.get("from_") 2804 from_clause = f" FROM {from_clause}" if from_clause else "" 2805 return f"{force}INSTALL {this}{from_clause}" 2806 2807 def approxtopk_sql(self, expression: exp.ApproxTopK) -> str: 2808 self.unsupported( 2809 "APPROX_TOP_K cannot be transpiled to DuckDB due to incompatible return types. " 2810 ) 2811 return self.function_fallback_sql(expression) 2812 2813 def strposition_sql(self, expression: exp.StrPosition) -> str: 2814 this = expression.this 2815 substr = expression.args.get("substr") 2816 position = expression.args.get("position") 2817 2818 # For BINARY/BLOB: DuckDB's STRPOS doesn't support BLOB types 2819 # Convert to HEX strings, use STRPOS, then convert hex position to byte position 2820 if _is_binary(this): 2821 # Build expression: STRPOS(HEX(haystack), HEX(needle)) 2822 hex_strpos = exp.StrPosition( 2823 this=exp.Hex(this=this), 2824 substr=exp.Hex(this=substr), 2825 ) 2826 2827 return self.sql(exp.cast((hex_strpos + 1) / 2, exp.DType.INT)) 2828 2829 # For VARCHAR: handle clamp_position 2830 if expression.args.get("clamp_position") and position: 2831 expression = expression.copy() 2832 expression.set( 2833 "position", 2834 exp.If( 2835 this=exp.LTE(this=position, expression=exp.Literal.number(0)), 2836 true=exp.Literal.number(1), 2837 false=position.copy(), 2838 ), 2839 ) 2840 2841 return strposition_sql(self, expression) 2842 2843 def substring_sql(self, expression: exp.Substring) -> str: 2844 if expression.args.get("zero_start"): 2845 start = expression.args.get("start") 2846 length = expression.args.get("length") 2847 2848 if start := expression.args.get("start"): 2849 start = exp.If(this=start.eq(0), true=exp.Literal.number(1), false=start) 2850 if length := expression.args.get("length"): 2851 length = exp.If(this=length < 0, true=exp.Literal.number(0), false=length) 2852 2853 return self.func("SUBSTRING", expression.this, start, length) 2854 2855 return self.function_fallback_sql(expression) 2856 2857 def strtotime_sql(self, expression: exp.StrToTime) -> str: 2858 # Check if target_type requires TIMESTAMPTZ (for LTZ/TZ variants) 2859 target_type = expression.args.get("target_type") 2860 needs_tz = target_type and target_type.this in ( 2861 exp.DType.TIMESTAMPLTZ, 2862 exp.DType.TIMESTAMPTZ, 2863 ) 2864 2865 value, formatted_time = self._strptime_default_year(expression) 2866 2867 if expression.args.get("safe"): 2868 cast_type = exp.DType.TIMESTAMPTZ if needs_tz else exp.DType.TIMESTAMP 2869 return self.sql(exp.cast(self.func("TRY_STRPTIME", value, formatted_time), cast_type)) 2870 2871 base_sql = self.func("STRPTIME", value, formatted_time) 2872 if needs_tz: 2873 return self.sql( 2874 exp.cast( 2875 base_sql, 2876 exp.DataType(this=exp.DType.TIMESTAMPTZ), 2877 ) 2878 ) 2879 return base_sql 2880 2881 def strtodate_sql(self, expression: exp.StrToDate) -> str: 2882 value, formatted_time = self._strptime_default_year(expression) 2883 function_name = "STRPTIME" if not expression.args.get("safe") else "TRY_STRPTIME" 2884 return self.sql( 2885 exp.cast( 2886 self.func(function_name, value, formatted_time), 2887 exp.DataType(this=exp.DType.DATE), 2888 ) 2889 ) 2890 2891 def _strptime_default_year( 2892 self, expression: exp.StrToTime | exp.StrToDate | exp.ParseDatetime 2893 ) -> tuple[exp.ExpOrStr, exp.ExpOrStr | None]: 2894 value: exp.ExpOrStr = expression.this 2895 formatted_time: exp.ExpOrStr | None = self.format_time(expression) 2896 2897 if default_year := expression.args.get("default_year"): 2898 value = exp.DPipe(this=exp.Literal.string(f"{default_year.name} "), expression=value) 2899 formatted_time = exp.DPipe(this=exp.Literal.string("%Y "), expression=formatted_time) 2900 2901 return value, formatted_time 2902 2903 def parsedatetime_sql(self, expression: exp.ParseDatetime) -> str: 2904 value, formatted_time = self._strptime_default_year(expression) 2905 return self.func("STRPTIME", value, formatted_time) 2906 2907 def parsetime_sql(self, expression: exp.ParseTime) -> str: 2908 formatted_time = self.format_time(expression) 2909 return self.sql( 2910 exp.cast( 2911 self.func("STRPTIME", expression.this, formatted_time), 2912 exp.DataType(this=exp.DType.TIME), 2913 ) 2914 ) 2915 2916 def tsordstotime_sql(self, expression: exp.TsOrDsToTime) -> str: 2917 this = expression.this 2918 time_format = self.format_time(expression) 2919 safe = expression.args.get("safe") 2920 time_type = exp.DataType.from_str("TIME", dialect="duckdb") 2921 cast_expr = exp.TryCast if safe else exp.Cast 2922 2923 if time_format: 2924 func_name = "TRY_STRPTIME" if safe else "STRPTIME" 2925 strptime = exp.Anonymous(this=func_name, expressions=[this, time_format]) 2926 return self.sql(cast_expr(this=strptime, to=time_type)) 2927 2928 if isinstance(this, exp.TsOrDsToTime) or this.is_type(exp.DType.TIME): 2929 return self.sql(this) 2930 2931 return self.sql(cast_expr(this=this, to=time_type)) 2932 2933 def currentdate_sql(self, expression: exp.CurrentDate) -> str: 2934 if not expression.this: 2935 return "CURRENT_DATE" 2936 2937 expr = exp.Cast( 2938 this=exp.AtTimeZone(this=exp.CurrentTimestamp(), zone=expression.this), 2939 to=exp.DataType(this=exp.DType.DATE), 2940 ) 2941 return self.sql(expr) 2942 2943 def checkjson_sql(self, expression: exp.CheckJson) -> str: 2944 arg = expression.this 2945 return self.sql( 2946 exp.case() 2947 .when( 2948 exp.or_(arg.is_(exp.Null()), arg.eq(""), exp.func("json_valid", arg)), 2949 exp.null(), 2950 ) 2951 .else_(exp.Literal.string("Invalid JSON")) 2952 ) 2953 2954 def parsejson_sql(self, expression: exp.ParseJSON) -> str: 2955 arg = expression.this 2956 if expression.args.get("safe"): 2957 return self.sql( 2958 exp.case() 2959 .when(exp.func("json_valid", arg), exp.cast(arg.copy(), "JSON")) 2960 .else_(exp.null()) 2961 ) 2962 return self.func("JSON", arg) 2963 2964 def unicode_sql(self, expression: exp.Unicode) -> str: 2965 if expression.args.get("empty_is_zero"): 2966 return self.sql( 2967 exp.case() 2968 .when(expression.this.eq(exp.Literal.string("")), exp.Literal.number(0)) 2969 .else_(exp.Anonymous(this="UNICODE", expressions=[expression.this])) 2970 ) 2971 2972 return self.func("UNICODE", expression.this) 2973 2974 def stripnullvalue_sql(self, expression: exp.StripNullValue) -> str: 2975 return self.sql( 2976 exp.case() 2977 .when(exp.func("json_type", expression.this).eq("NULL"), exp.null()) 2978 .else_(expression.this) 2979 ) 2980 2981 def trunc_sql(self, expression: exp.Trunc) -> str: 2982 decimals = expression.args.get("decimals") 2983 if ( 2984 expression.args.get("fractions_supported") 2985 and decimals 2986 and not decimals.is_type(exp.DType.INT) 2987 ): 2988 decimals = exp.cast(decimals, exp.DType.INT, dialect="duckdb") 2989 2990 return self.func("TRUNC", expression.this, decimals) 2991 2992 def normal_sql(self, expression: exp.Normal) -> str: 2993 """ 2994 Transpile Snowflake's NORMAL(mean, stddev, gen) to DuckDB. 2995 2996 Uses the Box-Muller transform via NORMAL_TEMPLATE. 2997 """ 2998 mean = expression.this 2999 stddev = expression.args["stddev"] 3000 gen: exp.Expr = expression.args["gen"] 3001 3002 # Build two uniform random values [0, 1) for Box-Muller transform 3003 if isinstance(gen, exp.Rand) and gen.this is None: 3004 u1: exp.Expr = exp.Rand() 3005 u2: exp.Expr = exp.Rand() 3006 else: 3007 # Seeded: derive two values using HASH with different inputs 3008 seed = gen.this if isinstance(gen, exp.Rand) else gen 3009 u1 = exp.replace_placeholders(self.SEEDED_RANDOM_TEMPLATE, seed=seed) 3010 u2 = exp.replace_placeholders( 3011 self.SEEDED_RANDOM_TEMPLATE, 3012 seed=exp.Add(this=seed.copy(), expression=exp.Literal.number(1)), 3013 ) 3014 3015 replacements = {"mean": mean, "stddev": stddev, "u1": u1, "u2": u2} 3016 return self.sql(exp.replace_placeholders(self.NORMAL_TEMPLATE, **replacements)) 3017 3018 def uniform_sql(self, expression: exp.Uniform) -> str: 3019 """ 3020 Transpile Snowflake's UNIFORM(min, max, gen) to DuckDB. 3021 3022 UNIFORM returns a random value in [min, max]: 3023 - Integer result if both min and max are integers 3024 - Float result if either min or max is a float 3025 """ 3026 min_val = expression.this 3027 max_val = expression.expression 3028 gen = expression.args.get("gen") or expression.args.get("seed") or exp.Rand() 3029 3030 # Determine if result should be integer (both bounds are integers). 3031 # We do this to emulate Snowflake's behavior, INT -> INT, FLOAT -> FLOAT 3032 is_int_result = min_val.is_int and max_val.is_int 3033 3034 # Build the random value expression [0, 1) 3035 if not isinstance(gen, exp.Rand): 3036 # Seed value: (ABS(HASH(seed)) % 1000000) / 1000000.0 3037 random_expr: exp.Expr = exp.Div( 3038 this=exp.Paren( 3039 this=exp.Mod( 3040 this=exp.Abs(this=exp.Anonymous(this="HASH", expressions=[gen])), 3041 expression=exp.Literal.number(1000000), 3042 ) 3043 ), 3044 expression=exp.Literal.number(1000000.0), 3045 ) 3046 else: 3047 random_expr = exp.Rand() 3048 3049 # Build: min + random * (max - min [+ 1 for int]) 3050 range_expr: exp.Expr = exp.Sub(this=max_val, expression=min_val) 3051 if is_int_result: 3052 range_expr = exp.Add(this=range_expr, expression=exp.Literal.number(1)) 3053 3054 result: exp.Expr = exp.Add( 3055 this=min_val, 3056 expression=exp.Mul(this=random_expr, expression=exp.Paren(this=range_expr)), 3057 ) 3058 3059 if is_int_result: 3060 result = exp.Cast(this=exp.Floor(this=result), to=exp.DType.BIGINT.into_expr()) 3061 3062 return self.sql(result) 3063 3064 def timefromparts_sql(self, expression: exp.TimeFromParts) -> str: 3065 nano = expression.args.get("nano") 3066 overflow = expression.args.get("overflow") 3067 3068 # Snowflake's TIME_FROM_PARTS supports overflow 3069 if overflow: 3070 hour = expression.args["hour"] 3071 minute = expression.args["min"] 3072 sec = expression.args["sec"] 3073 3074 # Check if values are within normal ranges - use MAKE_TIME for efficiency 3075 if not nano and all(arg.is_int for arg in [hour, minute, sec]): 3076 try: 3077 h_val = hour.to_py() 3078 m_val = minute.to_py() 3079 s_val = sec.to_py() 3080 if 0 <= h_val <= 23 and 0 <= m_val <= 59 and 0 <= s_val <= 59: 3081 return rename_func("MAKE_TIME")(self, expression) 3082 except ValueError: 3083 pass 3084 3085 # Overflow or nanoseconds detected - use INTERVAL arithmetic 3086 if nano: 3087 sec = sec + nano.pop() / exp.Literal.number(1000000000.0) 3088 3089 total_seconds = hour * exp.Literal.number(3600) + minute * exp.Literal.number(60) + sec 3090 3091 return self.sql( 3092 exp.Add( 3093 this=exp.Cast( 3094 this=exp.Literal.string("00:00:00"), to=exp.DType.TIME.into_expr() 3095 ), 3096 expression=exp.Interval(this=total_seconds, unit=exp.var("SECOND")), 3097 ) 3098 ) 3099 3100 # Default: MAKE_TIME 3101 if nano: 3102 expression.set( 3103 "sec", expression.args["sec"] + nano.pop() / exp.Literal.number(1000000000.0) 3104 ) 3105 3106 return rename_func("MAKE_TIME")(self, expression) 3107 3108 def extract_sql(self, expression: exp.Extract) -> str: 3109 """ 3110 Transpile EXTRACT/DATE_PART for DuckDB, handling specifiers not natively supported. 3111 3112 DuckDB doesn't support: WEEKISO, YEAROFWEEK, YEAROFWEEKISO, NANOSECOND, 3113 EPOCH_SECOND (as integer), EPOCH_MILLISECOND, EPOCH_MICROSECOND, EPOCH_NANOSECOND 3114 """ 3115 this = expression.this 3116 datetime_expr = expression.expression 3117 3118 # TIMESTAMPTZ extractions may produce different results between Snowflake and DuckDB 3119 # because Snowflake applies server timezone while DuckDB uses local timezone 3120 if datetime_expr.is_type(exp.DType.TIMESTAMPTZ, exp.DType.TIMESTAMPLTZ): 3121 self.unsupported( 3122 "EXTRACT from TIMESTAMPTZ / TIMESTAMPLTZ may produce different results due to timezone handling differences" 3123 ) 3124 3125 part_name = this.name.upper() 3126 3127 if part_name in self.EXTRACT_STRFTIME_MAPPINGS: 3128 fmt, cast_type = self.EXTRACT_STRFTIME_MAPPINGS[part_name] 3129 3130 # Problem: strftime doesn't accept TIME and there's no NANOSECOND function 3131 # So, for NANOSECOND with TIME, fallback to MICROSECOND * 1000 3132 is_nano_time = part_name == "NANOSECOND" and datetime_expr.is_type( 3133 exp.DType.TIME, exp.DType.TIMETZ 3134 ) 3135 3136 if is_nano_time: 3137 self.unsupported("Parameter NANOSECOND is not supported with TIME type in DuckDB") 3138 return self.sql( 3139 exp.cast( 3140 exp.Mul( 3141 this=exp.Extract(this=exp.var("MICROSECOND"), expression=datetime_expr), 3142 expression=exp.Literal.number(1000), 3143 ), 3144 exp.DataType.from_str(cast_type, dialect="duckdb"), 3145 ) 3146 ) 3147 3148 # For NANOSECOND, cast to TIMESTAMP_NS to preserve nanosecond precision 3149 strftime_input = datetime_expr 3150 if part_name == "NANOSECOND": 3151 strftime_input = exp.cast(datetime_expr, exp.DType.TIMESTAMP_NS) 3152 3153 return self.sql( 3154 exp.cast( 3155 exp.Anonymous( 3156 this="STRFTIME", 3157 expressions=[strftime_input, exp.Literal.string(fmt)], 3158 ), 3159 exp.DataType.from_str(cast_type, dialect="duckdb"), 3160 ) 3161 ) 3162 3163 if part_name in self.EXTRACT_EPOCH_MAPPINGS: 3164 func_name = self.EXTRACT_EPOCH_MAPPINGS[part_name] 3165 result: exp.Expr = exp.Anonymous(this=func_name, expressions=[datetime_expr]) 3166 # EPOCH returns float, cast to BIGINT for integer result 3167 if part_name == "EPOCH_SECOND": 3168 result = exp.cast(result, exp.DataType.from_str("BIGINT", dialect="duckdb")) 3169 return self.sql(result) 3170 3171 return super().extract_sql(expression) 3172 3173 def timestampfromparts_sql(self, expression: exp.TimestampFromParts) -> str: 3174 # Check if this is the date/time expression form: TIMESTAMP_FROM_PARTS(date_expr, time_expr) 3175 date_expr = expression.this 3176 time_expr = expression.expression 3177 3178 if date_expr is not None and time_expr is not None: 3179 # In DuckDB, DATE + TIME produces TIMESTAMP 3180 return self.sql(exp.Add(this=date_expr, expression=time_expr)) 3181 3182 # Component-based form: TIMESTAMP_FROM_PARTS(year, month, day, hour, minute, second, ...) 3183 sec = expression.args.get("sec") 3184 if sec is None: 3185 # This shouldn't happen with valid input, but handle gracefully 3186 return rename_func("MAKE_TIMESTAMP")(self, expression) 3187 3188 milli = expression.args.get("milli") 3189 if milli is not None: 3190 sec += milli.pop() / exp.Literal.number(1000.0) 3191 3192 nano = expression.args.get("nano") 3193 if nano is not None: 3194 sec += nano.pop() / exp.Literal.number(1000000000.0) 3195 3196 if milli or nano: 3197 expression.set("sec", sec) 3198 3199 if zone := expression.args.get("zone"): 3200 return self.sql(exp.AtTimeZone(this=expression, zone=zone.pop())) 3201 3202 return rename_func("MAKE_TIMESTAMP")(self, expression) 3203 3204 @unsupported_args("nano") 3205 def timestampltzfromparts_sql(self, expression: exp.TimestampLtzFromParts) -> str: 3206 # Pop nano so rename_func only passes args that MAKE_TIMESTAMP accepts 3207 if nano := expression.args.get("nano"): 3208 nano.pop() 3209 3210 timestamp = rename_func("MAKE_TIMESTAMP")(self, expression) 3211 return f"CAST({timestamp} AS TIMESTAMPTZ)" 3212 3213 @unsupported_args("nano") 3214 def timestamptzfromparts_sql(self, expression: exp.TimestampTzFromParts) -> str: 3215 # Extract zone before popping 3216 zone = expression.args.get("zone") 3217 # Pop zone and nano so rename_func only passes args that MAKE_TIMESTAMP accepts 3218 if zone: 3219 zone = zone.pop() 3220 3221 if nano := expression.args.get("nano"): 3222 nano.pop() 3223 3224 timestamp = rename_func("MAKE_TIMESTAMP")(self, expression) 3225 3226 if zone: 3227 # Use AT TIME ZONE to apply the explicit timezone 3228 return f"{timestamp} AT TIME ZONE {self.sql(zone)}" 3229 3230 return timestamp 3231 3232 def after_limit_modifiers(self, expression: exp.Expr) -> list[str]: 3233 locks = self.expressions(expression, key="locks", sep=" ") 3234 return [f" {locks}" if locks else ""] 3235 3236 def tablesample_sql( 3237 self, 3238 expression: exp.TableSample, 3239 tablesample_keyword: str | None = None, 3240 ) -> str: 3241 if not isinstance(expression.parent, exp.Select): 3242 # This sample clause only applies to a single source, not the entire resulting relation 3243 tablesample_keyword = "TABLESAMPLE" 3244 3245 if expression.args.get("size"): 3246 method = expression.args.get("method") 3247 if method and method.name.upper() != "RESERVOIR": 3248 self.unsupported( 3249 f"Sampling method {method} is not supported with a discrete sample count, " 3250 "defaulting to reservoir sampling" 3251 ) 3252 expression.set("method", exp.var("RESERVOIR")) 3253 3254 return super().tablesample_sql(expression, tablesample_keyword=tablesample_keyword) 3255 3256 def in_sql(self, expression: exp.In) -> str: 3257 unnest = expression.args.get("unnest") 3258 if unnest: 3259 return self.sql( 3260 exp.replace_placeholders( 3261 self.IN_UNNEST_TEMPLATE, arr=unnest.expressions[0], value=expression.this 3262 ) 3263 ) 3264 return super().in_sql(expression) 3265 3266 def interval_sql(self, expression: exp.Interval) -> str: 3267 # DuckDB truncates INTERVAL '1.5' DAY to 1 day, but keeps the fraction in INTERVAL '1.5 DAY' 3268 this = expression.this 3269 unit = expression.unit 3270 if expression.args.get("fraction_in_string") and this and isinstance(unit, exp.Var): 3271 return f"INTERVAL '{this.name} {self.sql(unit)}'" 3272 3273 return super().interval_sql(expression) 3274 3275 def join_sql(self, expression: exp.Join, join_op: str | None = None) -> str: 3276 if expression.kind in ("MARK", "SINGLE") or ( 3277 expression.side == "RIGHT" and expression.is_semi_or_anti_join 3278 ): 3279 kind = "_".join(part for part in (expression.side, expression.kind) if part) 3280 return super().join_sql(expression, join_op=f"JOIN BY (TYPE {kind})") 3281 3282 if ( 3283 not expression.args.get("using") 3284 and not expression.args.get("on") 3285 and not expression.args.get("nearest") 3286 and not expression.method 3287 and (expression.kind in ("", "INNER", "OUTER")) 3288 ): 3289 # Some dialects support `LEFT/INNER JOIN UNNEST(...)` without an explicit ON clause 3290 # DuckDB doesn't, but we can just add a dummy ON clause that is always true 3291 if isinstance(expression.this, exp.Unnest): 3292 return super().join_sql(expression.on(exp.true())) 3293 3294 expression.set("side", None) 3295 expression.set("kind", None) 3296 3297 return super().join_sql(expression, join_op=join_op) 3298 3299 def countif_sql(self, expression: exp.CountIf) -> str: 3300 if self.dialect.version >= (1, 2): 3301 this = expression.this 3302 if expression.args.get("zero_on_all_null") and not isinstance(this, exp.Distinct): 3303 # DuckDB >= 1.2's COUNT_IF returns NULL when the condition is NULL on all rows, 3304 # so we wrap the condition in IS TRUE to preserve count-like semantics 3305 expression = exp.CountIf(this=exp.paren(this).is_(exp.true())) 3306 return self.function_fallback_sql(expression) 3307 3308 # https://github.com/tobymao/sqlglot/pull/4749 3309 return count_if_to_sum(self, expression) 3310 3311 def bracket_sql(self, expression: exp.Bracket) -> str: 3312 if self.dialect.version >= (1, 2): 3313 return super().bracket_sql(expression) 3314 3315 # https://duckdb.org/2025/02/05/announcing-duckdb-120.html#breaking-changes 3316 this = expression.this 3317 if isinstance(this, exp.Array): 3318 this.replace(exp.paren(this)) 3319 3320 bracket = super().bracket_sql(expression) 3321 3322 if not expression.args.get("returns_list_for_maps"): 3323 if not this.type: 3324 from sqlglot.optimizer.annotate_types import annotate_types 3325 3326 this = annotate_types(this, dialect=self.dialect) 3327 3328 if this.is_type(exp.DType.MAP): 3329 bracket = f"({bracket})[1]" 3330 3331 return bracket 3332 3333 def withingroup_sql(self, expression: exp.WithinGroup) -> str: 3334 func = expression.this 3335 3336 # For ARRAY_AGG, DuckDB requires ORDER BY inside the function, not in WITHIN GROUP 3337 # Transform: ARRAY_AGG(x) WITHIN GROUP (ORDER BY y) -> ARRAY_AGG(x ORDER BY y) 3338 if isinstance(func, exp.ArrayAgg): 3339 if not isinstance(order := expression.expression, exp.Order): 3340 return self.sql(func) 3341 3342 # Save the original column for FILTER clause (before wrapping with Order) 3343 original_this = func.this 3344 3345 # Move ORDER BY inside ARRAY_AGG by wrapping its argument with Order 3346 # ArrayAgg.this should become Order(this=ArrayAgg.this, expressions=order.expressions) 3347 func.set( 3348 "this", 3349 exp.Order( 3350 this=func.this.copy(), 3351 expressions=order.expressions, 3352 ), 3353 ) 3354 3355 # Generate the ARRAY_AGG function with ORDER BY and add FILTER clause if needed 3356 # Use original_this (not the Order-wrapped version) for the FILTER condition 3357 array_agg_sql = self.function_fallback_sql(func) 3358 return self._add_arrayagg_null_filter(array_agg_sql, func, original_this) 3359 3360 # For other functions (like PERCENTILES), use existing logic 3361 expression_sql = self.sql(expression, "expression") 3362 3363 if isinstance(func, exp.PERCENTILES): 3364 # Make the order key the first arg and slide the fraction to the right 3365 # https://duckdb.org/docs/sql/aggregates#ordered-set-aggregate-functions 3366 order_col = expression.find(exp.Ordered) 3367 if order_col: 3368 func.set("expression", func.this) 3369 func.set("this", order_col.this) 3370 3371 this = self.sql(expression, "this").rstrip(")") 3372 3373 return f"{this}{expression_sql})" 3374 3375 def length_sql(self, expression: exp.Length) -> str: 3376 arg = expression.this 3377 3378 # Dialects like BQ and Snowflake also accept binary values as args, so 3379 # DDB will attempt to infer the type or resort to case/when resolution 3380 if not expression.args.get("binary") or arg.is_string: 3381 return self.func("LENGTH", arg) 3382 3383 if not arg.type: 3384 from sqlglot.optimizer.annotate_types import annotate_types 3385 3386 arg = annotate_types(arg, dialect=self.dialect) 3387 3388 if arg.is_type(*exp.DataType.TEXT_TYPES): 3389 return self.func("LENGTH", arg) 3390 3391 # We need these casts to make duckdb's static type checker happy 3392 blob = exp.cast(arg, exp.DType.VARBINARY) 3393 varchar = exp.cast(arg, exp.DType.VARCHAR) 3394 3395 case = ( 3396 exp.case(exp.Anonymous(this="TYPEOF", expressions=[arg])) 3397 .when(exp.Literal.string("BLOB"), exp.ByteLength(this=blob)) 3398 .else_(exp.Anonymous(this="LENGTH", expressions=[varchar])) 3399 ) 3400 return self.sql(case) 3401 3402 def bitlength_sql(self, expression: exp.BitLength) -> str: 3403 if not _is_binary(arg := expression.this): 3404 return self.func("BIT_LENGTH", arg) 3405 3406 blob = exp.cast(arg, exp.DataType.Type.VARBINARY) 3407 return self.sql(exp.ByteLength(this=blob) * exp.Literal.number(8)) 3408 3409 def chr_sql(self, expression: exp.Chr, name: str = "CHR") -> str: 3410 arg = expression.expressions[0] 3411 if arg.is_type(*exp.DataType.REAL_TYPES): 3412 arg = exp.cast(arg, exp.DType.INT) 3413 return self.func("CHR", arg) 3414 3415 def collation_sql(self, expression: exp.Collation) -> str: 3416 self.unsupported("COLLATION function is not supported by DuckDB") 3417 return self.function_fallback_sql(expression) 3418 3419 def collate_sql(self, expression: exp.Collate) -> str: 3420 if not expression.expression.is_string: 3421 return super().collate_sql(expression) 3422 3423 raw = expression.expression.name 3424 if not raw: 3425 return self.sql(expression.this) 3426 3427 parts = [] 3428 for part in raw.split("-"): 3429 lower = part.lower() 3430 if lower not in _SNOWFLAKE_COLLATION_DEFAULTS: 3431 if lower in _SNOWFLAKE_COLLATION_UNSUPPORTED: 3432 self.unsupported( 3433 f"Snowflake collation specifier '{part}' has no DuckDB equivalent" 3434 ) 3435 parts.append(lower) 3436 3437 if not parts: 3438 return self.sql(expression.this) 3439 return super().collate_sql( 3440 exp.Collate(this=expression.this, expression=exp.var(".".join(parts))) 3441 ) 3442 3443 def _validate_regexp_flags(self, flags: exp.Expr | None, supported_flags: str) -> str | None: 3444 """ 3445 Validate and filter regexp flags for DuckDB compatibility. 3446 3447 Args: 3448 flags: The flags expression to validate 3449 supported_flags: String of supported flags (e.g., "ims", "cims"). 3450 Only these flags will be returned. 3451 3452 Returns: 3453 Validated/filtered flag string, or None if no valid flags remain 3454 """ 3455 if not isinstance(flags, exp.Expr): 3456 return None 3457 3458 if not flags.is_string: 3459 self.unsupported("Non-literal regexp flags are not fully supported in DuckDB") 3460 return None 3461 3462 flag_str = flags.this 3463 unsupported = set(flag_str) - set(supported_flags) 3464 3465 if unsupported: 3466 self.unsupported( 3467 f"Regexp flags {sorted(unsupported)} are not supported in this context" 3468 ) 3469 3470 flag_str = "".join(f for f in flag_str if f in supported_flags) 3471 return flag_str if flag_str else None 3472 3473 def regexpcount_sql(self, expression: exp.RegexpCount) -> str: 3474 this = expression.this 3475 pattern = expression.expression 3476 position = expression.args.get("position") 3477 parameters = expression.args.get("parameters") 3478 3479 # Validate flags - only "ims" flags are supported for embedded patterns 3480 validated_flags = self._validate_regexp_flags(parameters, supported_flags="ims") 3481 3482 if position: 3483 this = exp.Substring(this=this, start=position) 3484 3485 # Handle empty pattern: Snowflake returns 0, DuckDB would match between every character. 3486 # Checked before the flags are embedded, which would make the pattern non-empty 3487 is_empty = exp.EQ(this=pattern.copy(), expression=exp.Literal.string("")) 3488 3489 # Embed flags in pattern (REGEXP_EXTRACT_ALL doesn't support flags argument) 3490 if validated_flags: 3491 pattern = exp.Concat(expressions=[exp.Literal.string(f"(?{validated_flags})"), pattern]) 3492 3493 result = ( 3494 exp.case() 3495 .when(is_empty, exp.Literal.number(0)) 3496 .else_( 3497 exp.Length( 3498 this=exp.Anonymous(this="REGEXP_EXTRACT_ALL", expressions=[this, pattern]) 3499 ) 3500 ) 3501 ) 3502 3503 return self.sql(result) 3504 3505 def regexpreplace_sql(self, expression: exp.RegexpReplace) -> str: 3506 subject = expression.this 3507 pattern = expression.expression 3508 replacement = expression.args.get("replacement") or exp.Literal.string("") 3509 position = expression.args.get("position") 3510 occurrence = expression.args.get("occurrence") 3511 modifiers = expression.args.get("modifiers") 3512 3513 validated_flags = self._validate_regexp_flags(modifiers, supported_flags="cimsg") or "" 3514 3515 # Handle occurrence (only literals supported) 3516 if occurrence and not occurrence.is_int: 3517 self.unsupported("REGEXP_REPLACE with non-literal occurrence") 3518 else: 3519 occurrence = occurrence.to_py() if occurrence and occurrence.is_int else 0 3520 if occurrence > 1: 3521 self.unsupported(f"REGEXP_REPLACE occurrence={occurrence} not supported") 3522 # flag duckdb to do either all or none, single_replace check is for duckdb round trip 3523 elif ( 3524 occurrence == 0 3525 and "g" not in validated_flags 3526 and not expression.args.get("single_replace") 3527 ): 3528 validated_flags += "g" 3529 3530 # Handle position (only literals supported) 3531 prefix = None 3532 if position and not position.is_int: 3533 self.unsupported("REGEXP_REPLACE with non-literal position") 3534 elif position and position.is_int and position.to_py() > 1: 3535 pos = position.to_py() 3536 prefix = exp.Substring( 3537 this=subject, start=exp.Literal.number(1), length=exp.Literal.number(pos - 1) 3538 ) 3539 subject = exp.Substring(this=subject, start=exp.Literal.number(pos)) 3540 3541 result: exp.Expr = exp.Anonymous( 3542 this="REGEXP_REPLACE", 3543 expressions=[ 3544 subject, 3545 pattern, 3546 replacement, 3547 exp.Literal.string(validated_flags) if validated_flags else None, 3548 ], 3549 ) 3550 3551 if prefix: 3552 result = exp.Concat(expressions=[prefix, result]) 3553 3554 return self.sql(result) 3555 3556 def regexplike_sql(self, expression: exp.RegexpLike) -> str: 3557 this = expression.this 3558 pattern = expression.expression 3559 flag = expression.args.get("flag") 3560 3561 if expression.args.get("full_match"): 3562 validated_flags = self._validate_regexp_flags(flag, supported_flags="cims") 3563 flag = exp.Literal.string(validated_flags) if validated_flags else None 3564 return self.func("REGEXP_FULL_MATCH", this, pattern, flag) 3565 3566 return self.func("REGEXP_MATCHES", this, pattern, flag) 3567 3568 @unsupported_args("ins_cost", "del_cost", "sub_cost") 3569 def levenshtein_sql(self, expression: exp.Levenshtein) -> str: 3570 this = expression.this 3571 expr = expression.expression 3572 max_dist = expression.args.get("max_dist") 3573 3574 if max_dist is None: 3575 return self.func("LEVENSHTEIN", this, expr) 3576 3577 # Emulate Snowflake semantics: if distance > max_dist, return max_dist 3578 levenshtein = exp.Levenshtein(this=this, expression=expr) 3579 return self.sql(exp.Least(this=levenshtein, expressions=[max_dist])) 3580 3581 def pad_sql(self, expression: exp.Pad) -> str: 3582 """ 3583 Handle RPAD/LPAD for VARCHAR and BINARY types. 3584 3585 For VARCHAR: Delegate to parent class 3586 For BINARY: Lower to: input || REPEAT(pad, GREATEST(0, target_len - OCTET_LENGTH(input))) 3587 """ 3588 string_arg = expression.this 3589 fill_arg = expression.args.get("fill_pattern") or exp.Literal.string(" ") 3590 3591 if _is_binary(string_arg) or _is_binary(fill_arg): 3592 length_arg = expression.expression 3593 is_left = expression.args.get("is_left") 3594 3595 input_len = exp.ByteLength(this=string_arg) 3596 chars_needed = length_arg - input_len 3597 pad_count = exp.Greatest( 3598 this=exp.Literal.number(0), expressions=[chars_needed], ignore_nulls=True 3599 ) 3600 repeat_expr = exp.Repeat(this=fill_arg, times=pad_count) 3601 3602 left, right = string_arg, repeat_expr 3603 if is_left: 3604 left, right = right, left 3605 3606 result = exp.DPipe(this=left, expression=right) 3607 return self.sql(result) 3608 3609 # For VARCHAR: Delegate to parent class (handles PAD_FILL_PATTERN_IS_REQUIRED) 3610 return super().pad_sql(expression) 3611 3612 def minhash_sql(self, expression: exp.Minhash) -> str: 3613 k = expression.this 3614 exprs = expression.expressions 3615 3616 if len(exprs) != 1 or isinstance(exprs[0], exp.Star): 3617 self.unsupported( 3618 "MINHASH with multiple expressions or * requires manual query restructuring" 3619 ) 3620 return self.func("MINHASH", k, *exprs) 3621 3622 expr = exprs[0] 3623 result = exp.replace_placeholders(self.MINHASH_TEMPLATE.copy(), expr=expr, k=k) 3624 return f"({self.sql(result)})" 3625 3626 def minhashcombine_sql(self, expression: exp.MinhashCombine) -> str: 3627 expr = expression.this 3628 result = exp.replace_placeholders(self.MINHASH_COMBINE_TEMPLATE.copy(), expr=expr) 3629 return f"({self.sql(result)})" 3630 3631 def approximatesimilarity_sql(self, expression: exp.ApproximateSimilarity) -> str: 3632 expr = expression.this 3633 result = exp.replace_placeholders(self.APPROXIMATE_SIMILARITY_TEMPLATE.copy(), expr=expr) 3634 return f"({self.sql(result)})" 3635 3636 def arrayuniqueagg_sql(self, expression: exp.ArrayUniqueAgg) -> str: 3637 return self.sql( 3638 exp.Filter( 3639 this=exp.func("LIST", exp.Distinct(expressions=[expression.this])), 3640 expression=exp.Where(this=expression.this.copy().is_(exp.null()).not_()), 3641 ) 3642 ) 3643 3644 def arrayconcatagg_sql(self, expression: exp.ArrayConcatAgg) -> str: 3645 this = expression.this 3646 3647 if isinstance(this, exp.Limit): 3648 self.unsupported("LIMIT in ARRAY_CONCAT_AGG cannot be transpiled to DuckDB") 3649 this = this.this 3650 3651 inner = this.this if isinstance(this, exp.Order) else this 3652 3653 return self.func( 3654 "FLATTEN", 3655 exp.Filter( 3656 this=exp.ArrayAgg(this=this), 3657 expression=exp.Where(this=inner.copy().is_(exp.null()).not_()), 3658 ), 3659 ) 3660 3661 def arrayunionagg_sql(self, expression: exp.ArrayUnionAgg) -> str: 3662 self.unsupported("ARRAY_UNION_AGG is not supported in DuckDB") 3663 return self.function_fallback_sql(expression) 3664 3665 def arraydistinct_sql(self, expression: exp.ArrayDistinct) -> str: 3666 arr = expression.this 3667 func = self.func("LIST_DISTINCT", arr) 3668 3669 if expression.args.get("check_null"): 3670 add_null_to_array = exp.func( 3671 "LIST_APPEND", exp.func("LIST_DISTINCT", exp.ArrayCompact(this=arr)), exp.Null() 3672 ) 3673 return self.sql( 3674 exp.If( 3675 this=exp.NEQ( 3676 this=exp.ArraySize(this=arr), expression=exp.func("LIST_COUNT", arr) 3677 ), 3678 true=add_null_to_array, 3679 false=func, 3680 ) 3681 ) 3682 3683 return func 3684 3685 def arrayintersect_sql(self, expression: exp.ArrayIntersect) -> str: 3686 if expression.args.get("is_multiset") and len(expression.expressions) == 2: 3687 return self._array_bag_sql( 3688 self.ARRAY_INTERSECTION_CONDITION, 3689 expression.expressions[0], 3690 expression.expressions[1], 3691 ) 3692 return self.function_fallback_sql(expression) 3693 3694 def arrayexcept_sql(self, expression: exp.ArrayExcept) -> str: 3695 arr1, arr2 = expression.this, expression.expression 3696 if expression.args.get("is_multiset"): 3697 return self._array_bag_sql(self.ARRAY_EXCEPT_CONDITION, arr1, arr2) 3698 return self.sql( 3699 exp.replace_placeholders(self.ARRAY_EXCEPT_SET_TEMPLATE, arr1=arr1, arr2=arr2) 3700 ) 3701 3702 def arrayslice_sql(self, expression: exp.ArraySlice) -> str: 3703 """ 3704 Spark's SLICE uses a length, so normalize negative starts before computing 3705 the inclusive end index. Starts before the array produce an empty slice. 3706 3707 Transpiles Snowflake's ARRAY_SLICE (0-indexed, exclusive end) to DuckDB's 3708 ARRAY_SLICE (1-indexed, inclusive end) by wrapping start and end in CASE 3709 expressions that adjust the index at query time: 3710 - start: CASE WHEN start >= 0 THEN start + 1 ELSE start END 3711 - end: CASE WHEN end < 0 THEN end - 1 ELSE end END 3712 """ 3713 start, end = expression.args.get("start"), expression.args.get("end") 3714 3715 if expression.args.get("zero_based"): 3716 if start is not None: 3717 start = ( 3718 exp.case() 3719 .when( 3720 exp.GTE(this=start.copy(), expression=exp.Literal.number(0)), 3721 exp.Add(this=start.copy(), expression=exp.Literal.number(1)), 3722 ) 3723 .else_(start) 3724 ) 3725 if end is not None and not expression.args.get("is_length"): 3726 end = ( 3727 exp.case() 3728 .when( 3729 exp.LT(this=end.copy(), expression=exp.Literal.number(0)), 3730 exp.Sub(this=end.copy(), expression=exp.Literal.number(1)), 3731 ) 3732 .else_(end) 3733 ) 3734 3735 if expression.args.get("is_length") and start is not None and end is not None: 3736 if not start.is_int or start.to_py() < 0: 3737 size = exp.ArraySize(this=expression.this.copy()) 3738 start = ( 3739 exp.case() 3740 .when(start < -size, size + 1) 3741 .when(start < 0, size + start + 1) 3742 .else_(start) 3743 ) 3744 3745 if start.is_int and end.is_int: 3746 end = exp.Literal.number(start.to_py() + end.to_py() - 1) 3747 else: 3748 end = start + exp.cast(end, "BIGINT") - 1 3749 return self.func("ARRAY_SLICE", expression.this, start, end, expression.args.get("step")) 3750 3751 def arrayszip_sql(self, expression: exp.ArraysZip) -> str: 3752 args = expression.expressions 3753 3754 if not args: 3755 # Return [{}] - using MAP([], []) since DuckDB can't represent empty structs 3756 return self.sql(exp.array(exp.Map(keys=exp.array(), values=exp.array()))) 3757 3758 # Build placeholder values for template 3759 lengths = [exp.Length(this=arg) for arg in args] 3760 max_len = ( 3761 lengths[0] 3762 if len(lengths) == 1 3763 else exp.Greatest(this=lengths[0], expressions=lengths[1:]) 3764 ) 3765 3766 # Empty struct with same schema: {'$1': NULL, '$2': NULL, ...} 3767 empty_struct = exp.func( 3768 "STRUCT", 3769 *[ 3770 exp.PropertyEQ(this=exp.Literal.string(f"${i + 1}"), expression=exp.Null()) 3771 for i in range(len(args)) 3772 ], 3773 ) 3774 3775 # Struct for transform: {'$1': COALESCE(arr1, [])[__i + 1], ...} 3776 # COALESCE wrapping handles NULL arrays - prevents invalid NULL[i] syntax 3777 index = exp.column("__i") + 1 3778 transform_struct = exp.func( 3779 "STRUCT", 3780 *[ 3781 exp.PropertyEQ( 3782 this=exp.Literal.string(f"${i + 1}"), 3783 expression=exp.func("COALESCE", arg, exp.array())[index], 3784 ) 3785 for i, arg in enumerate(args) 3786 ], 3787 ) 3788 3789 result = exp.replace_placeholders( 3790 self.ARRAYS_ZIP_TEMPLATE.copy(), 3791 null_check=exp.or_(*[arg.is_(exp.Null()) for arg in args]), 3792 all_empty_check=exp.and_( 3793 *[ 3794 exp.EQ(this=exp.Length(this=arg), expression=exp.Literal.number(0)) 3795 for arg in args 3796 ] 3797 ), 3798 empty_struct=empty_struct, 3799 max_len=max_len, 3800 transform_struct=transform_struct, 3801 ) 3802 return self.sql(result) 3803 3804 def lower_sql(self, expression: exp.Lower) -> str: 3805 result_sql = self.func("LOWER", _cast_to_varchar(expression.this)) 3806 return _gen_with_cast_to_blob(self, expression, result_sql) 3807 3808 def upper_sql(self, expression: exp.Upper) -> str: 3809 result_sql = self.func("UPPER", _cast_to_varchar(expression.this)) 3810 return _gen_with_cast_to_blob(self, expression, result_sql) 3811 3812 def reverse_sql(self, expression: exp.Reverse) -> str: 3813 result_sql = self.func("REVERSE", _cast_to_varchar(expression.this)) 3814 return _gen_with_cast_to_blob(self, expression, result_sql) 3815 3816 def _left_right_sql(self, expression: exp.Left | exp.Right, func_name: str) -> str: 3817 arg = expression.this 3818 length = expression.expression 3819 is_binary = _is_binary(arg) 3820 3821 if is_binary: 3822 # LEFT/RIGHT(blob, n) becomes UNHEX(LEFT/RIGHT(HEX(blob), n * 2)) 3823 # Each byte becomes 2 hex chars, so multiply length by 2 3824 hex_arg = exp.Hex(this=arg) 3825 hex_length = exp.Mul(this=length, expression=exp.Literal.number(2)) 3826 result: exp.Expression = exp.Unhex( 3827 this=exp.Anonymous(this=func_name, expressions=[hex_arg, hex_length]) 3828 ) 3829 else: 3830 result = exp.Anonymous(this=func_name, expressions=[arg, length]) 3831 3832 if expression.args.get("negative_length_returns_empty"): 3833 empty: exp.Expression = exp.Literal.string("") 3834 if is_binary: 3835 empty = exp.Unhex(this=empty) 3836 result = exp.case().when(length < exp.Literal.number(0), empty).else_(result) 3837 3838 return self.sql(result) 3839 3840 def left_sql(self, expression: exp.Left) -> str: 3841 return self._left_right_sql(expression, "LEFT") 3842 3843 def right_sql(self, expression: exp.Right) -> str: 3844 return self._left_right_sql(expression, "RIGHT") 3845 3846 def rtrimmedlength_sql(self, expression: exp.RtrimmedLength) -> str: 3847 return self.func("LENGTH", exp.Trim(this=expression.this, position="TRAILING")) 3848 3849 def stuff_sql(self, expression: exp.Stuff) -> str: 3850 base = expression.this 3851 start = expression.args["start"] 3852 length = expression.args["length"] 3853 insertion = expression.expression 3854 is_binary = _is_binary(base) 3855 3856 if is_binary: 3857 # DuckDB's SUBSTRING doesn't accept BLOB; operate on the HEX string instead 3858 # (each byte = 2 hex chars), then UNHEX back to BLOB 3859 base = exp.Hex(this=base) 3860 insertion = exp.Hex(this=insertion) 3861 left = exp.Substring( 3862 this=base.copy(), 3863 start=exp.Literal.number(1), 3864 length=(start.copy() - exp.Literal.number(1)) * exp.Literal.number(2), 3865 ) 3866 right = exp.Substring( 3867 this=base.copy(), 3868 start=((start + length) - exp.Literal.number(1)) * exp.Literal.number(2) 3869 + exp.Literal.number(1), 3870 ) 3871 else: 3872 left = exp.Substring( 3873 this=base.copy(), 3874 start=exp.Literal.number(1), 3875 length=start.copy() - exp.Literal.number(1), 3876 ) 3877 right = exp.Substring(this=base.copy(), start=start + length) 3878 result: exp.Expr = exp.DPipe( 3879 this=exp.DPipe(this=left, expression=insertion), expression=right 3880 ) 3881 3882 if is_binary: 3883 result = exp.Unhex(this=result) 3884 3885 return self.sql(result) 3886 3887 def rand_sql(self, expression: exp.Rand) -> str: 3888 seed = expression.this 3889 if seed is not None: 3890 self.unsupported("RANDOM with seed is not supported in DuckDB") 3891 3892 lower = expression.args.get("lower") 3893 upper = expression.args.get("upper") 3894 3895 if lower and upper: 3896 # scale DuckDB's [0,1) to the specified range 3897 range_size = exp.paren(upper - lower) 3898 scaled = exp.Add(this=lower, expression=exp.func("random") * range_size) 3899 3900 # For now we assume that if bounds are set, return type is BIGINT. Snowflake/Teradata 3901 result = exp.cast(scaled, exp.DType.BIGINT) 3902 return self.sql(result) 3903 3904 # Default DuckDB behavior - just return RANDOM() as float 3905 return "RANDOM()" 3906 3907 def bytelength_sql(self, expression: exp.ByteLength) -> str: 3908 arg = expression.this 3909 3910 # Check if it's a text type (handles both literals and annotated expressions) 3911 if arg.is_type(*exp.DataType.TEXT_TYPES): 3912 return self.func("OCTET_LENGTH", exp.Encode(this=arg)) 3913 3914 # Default: pass through as-is (conservative for DuckDB, handles binary and unannotated) 3915 return self.func("OCTET_LENGTH", arg) 3916 3917 def base64encode_sql(self, expression: exp.Base64Encode) -> str: 3918 # DuckDB TO_BASE64 requires BLOB input 3919 # Snowflake BASE64_ENCODE accepts both VARCHAR and BINARY - for VARCHAR it implicitly 3920 # encodes UTF-8 bytes. We add ENCODE unless the input is a binary type. 3921 result = expression.this 3922 3923 # Check if input is a string type - ENCODE only accepts VARCHAR 3924 if result.is_type(*exp.DataType.TEXT_TYPES): 3925 result = exp.Encode(this=result) 3926 3927 result = exp.ToBase64(this=result) 3928 3929 max_line_length = expression.args.get("max_line_length") 3930 alphabet = expression.args.get("alphabet") 3931 3932 # Handle custom alphabet by replacing standard chars with custom ones 3933 result = _apply_base64_alphabet_replacements(result, alphabet) 3934 3935 # Handle max_line_length by inserting newlines every N characters 3936 line_length = ( 3937 t.cast(int, max_line_length.to_py()) 3938 if isinstance(max_line_length, exp.Literal) and max_line_length.is_number 3939 else 0 3940 ) 3941 if line_length > 0: 3942 newline = exp.Chr(expressions=[exp.Literal.number(10)]) 3943 result = exp.Trim( 3944 this=exp.RegexpReplace( 3945 this=result, 3946 expression=exp.Literal.string(f"(.{{{line_length}}})"), 3947 replacement=exp.Concat(expressions=[exp.Literal.string("\\1"), newline.copy()]), 3948 ), 3949 expression=newline, 3950 position="TRAILING", 3951 ) 3952 3953 return self.sql(result) 3954 3955 def hex_sql(self, expression: exp.Hex) -> str: 3956 case = expression.args.get("case") 3957 3958 if not case: 3959 return self.func("HEX", expression.this) 3960 3961 hex_expr = exp.Hex(this=expression.this) 3962 return self.sql( 3963 exp.case() 3964 .when(case.is_(exp.null()), exp.null()) 3965 .when(case.copy().eq(0), exp.Lower(this=hex_expr.copy())) 3966 .else_(hex_expr) 3967 ) 3968 3969 def replace_sql(self, expression: exp.Replace) -> str: 3970 result_sql = self.func( 3971 "REPLACE", 3972 _cast_to_varchar(expression.this), 3973 _cast_to_varchar(expression.expression), 3974 _cast_to_varchar(expression.args.get("replacement")), 3975 ) 3976 return _gen_with_cast_to_blob(self, expression, result_sql) 3977 3978 def _bitwise_op(self, expression: exp.Binary, op: str) -> str: 3979 _prepare_binary_bitwise_args(expression) 3980 result_sql = self.binary(expression, op) 3981 return _gen_with_cast_to_blob(self, expression, result_sql) 3982 3983 def bitwisexor_sql(self, expression: exp.BitwiseXor) -> str: 3984 _prepare_binary_bitwise_args(expression) 3985 result_sql = self.func("XOR", expression.this, expression.expression) 3986 return _gen_with_cast_to_blob(self, expression, result_sql) 3987 3988 def objectinsert_sql(self, expression: exp.ObjectInsert) -> str: 3989 this = expression.this 3990 key = expression.args.get("key") 3991 key_sql = key.name if isinstance(key, exp.Expr) else "" 3992 value_sql = self.sql(expression, "value") 3993 3994 kv_sql = f"{key_sql} := {value_sql}" 3995 3996 # If the input struct is empty e.g. transpiling OBJECT_INSERT(OBJECT_CONSTRUCT(), key, value) from Snowflake 3997 # then we can generate STRUCT_PACK which will build it since STRUCT_INSERT({}, key := value) is not valid DuckDB 3998 if isinstance(this, exp.Struct) and not this.expressions: 3999 return self.func("STRUCT_PACK", kv_sql) 4000 4001 return self.func("STRUCT_INSERT", this, kv_sql) 4002 4003 def mapcat_sql(self, expression: exp.MapCat) -> str: 4004 result = exp.replace_placeholders( 4005 self.MAPCAT_TEMPLATE.copy(), 4006 map1=expression.this, 4007 map2=expression.expression, 4008 ) 4009 return self.sql(result) 4010 4011 def mapcontainskey_sql(self, expression: exp.MapContainsKey) -> str: 4012 return self.func( 4013 "ARRAY_CONTAINS", exp.func("MAP_KEYS", expression.args["key"]), expression.this 4014 ) 4015 4016 def mapdelete_sql(self, expression: exp.MapDelete) -> str: 4017 map_arg = expression.this 4018 keys_to_delete = expression.expressions 4019 4020 x_dot_key = exp.Dot(this=exp.to_identifier("x"), expression=exp.to_identifier("key")) 4021 4022 lambda_expr = exp.Lambda( 4023 this=exp.In(this=x_dot_key, expressions=keys_to_delete).not_(), 4024 expressions=[exp.to_identifier("x")], 4025 ) 4026 result = exp.func( 4027 "MAP_FROM_ENTRIES", 4028 exp.ArrayFilter(this=exp.func("MAP_ENTRIES", map_arg), expression=lambda_expr), 4029 ) 4030 return self.sql(result) 4031 4032 def mappick_sql(self, expression: exp.MapPick) -> str: 4033 map_arg = expression.this 4034 keys_to_pick = expression.expressions 4035 4036 x_dot_key = exp.Dot(this=exp.to_identifier("x"), expression=exp.to_identifier("key")) 4037 4038 if len(keys_to_pick) == 1 and keys_to_pick[0].is_type(exp.DType.ARRAY): 4039 lambda_expr = exp.Lambda( 4040 this=exp.func("ARRAY_CONTAINS", keys_to_pick[0], x_dot_key), 4041 expressions=[exp.to_identifier("x")], 4042 ) 4043 else: 4044 lambda_expr = exp.Lambda( 4045 this=exp.In(this=x_dot_key, expressions=keys_to_pick), 4046 expressions=[exp.to_identifier("x")], 4047 ) 4048 4049 result = exp.func( 4050 "MAP_FROM_ENTRIES", 4051 exp.func("LIST_FILTER", exp.func("MAP_ENTRIES", map_arg), lambda_expr), 4052 ) 4053 return self.sql(result) 4054 4055 def mapsize_sql(self, expression: exp.MapSize) -> str: 4056 return self.func("CARDINALITY", expression.this) 4057 4058 @unsupported_args("update_flag") 4059 def mapinsert_sql(self, expression: exp.MapInsert) -> str: 4060 map_arg = expression.this 4061 key = expression.args.get("key") 4062 value = expression.args.get("value") 4063 4064 map_type = map_arg.type 4065 4066 if value is not None: 4067 if map_type and map_type.expressions and len(map_type.expressions) > 1: 4068 # Extract the value type from MAP(key_type, value_type) 4069 value_type = map_type.expressions[1] 4070 # Cast value to match the map's value type to avoid type conflicts 4071 value = exp.cast(value, value_type) 4072 # else: polymorphic MAP case - no type parameters available, use value as-is 4073 4074 # Create a single-entry map for the new key-value pair 4075 new_entry_struct = exp.Struct(expressions=[exp.PropertyEQ(this=key, expression=value)]) 4076 new_entry: exp.Expression = exp.ToMap(this=new_entry_struct) 4077 4078 # Use MAP_CONCAT to merge the original map with the new entry 4079 # This automatically handles both insert and update cases 4080 result = exp.func("MAP_CONCAT", map_arg, new_entry) 4081 4082 return self.sql(result) 4083 4084 def startswith_sql(self, expression: exp.StartsWith) -> str: 4085 return self.func( 4086 "STARTS_WITH", 4087 _cast_to_varchar(expression.this), 4088 _cast_to_varchar(expression.expression), 4089 ) 4090 4091 def space_sql(self, expression: exp.Space) -> str: 4092 # DuckDB's REPEAT requires BIGINT for the count parameter 4093 return self.sql( 4094 exp.Repeat( 4095 this=exp.Literal.string(" "), 4096 times=exp.cast(expression.this, exp.DType.BIGINT), 4097 ) 4098 ) 4099 4100 def tablefromrows_sql(self, expression: exp.TableFromRows) -> str: 4101 # For GENERATOR, unwrap TABLE() - just emit the Generator (becomes RANGE) 4102 if isinstance(expression.this, exp.Generator): 4103 # Preserve alias, joins, and other table-level args 4104 table = exp.Table( 4105 this=expression.this, 4106 alias=expression.args.get("alias"), 4107 joins=expression.args.get("joins"), 4108 ) 4109 return self.sql(table) 4110 4111 return super().tablefromrows_sql(expression) 4112 4113 def unnest_sql(self, expression: exp.Unnest) -> str: 4114 explode_array = expression.args.get("explode_array") 4115 if explode_array: 4116 # In BigQuery, UNNESTing a nested array leads to explosion of the top-level array & struct 4117 # This is transpiled to DDB by transforming "FROM UNNEST(...)" to "FROM (SELECT UNNEST(..., max_depth => 2))" 4118 expression.expressions.append( 4119 exp.Kwarg(this=exp.var("max_depth"), expression=exp.Literal.number(2)) 4120 ) 4121 4122 # If BQ's UNNEST is aliased, we transform it from a column alias to a table alias in DDB 4123 alias = expression.args.get("alias") 4124 if isinstance(alias, exp.TableAlias): 4125 expression.set("alias", None) 4126 if alias.columns: 4127 alias = exp.TableAlias(this=seq_get(alias.columns, 0)) 4128 4129 unnest_sql = super().unnest_sql(expression) 4130 select = exp.Select(expressions=[unnest_sql]).subquery(alias) 4131 return self.sql(select) 4132 4133 return super().unnest_sql(expression) 4134 4135 def arrayagg_sql(self, expression: exp.ArrayAgg) -> str: 4136 if isinstance(expression.this, exp.Limit): 4137 self.unsupported("LIMIT inside ARRAY_AGG is not supported in DuckDB") 4138 4139 return super().arrayagg_sql(expression) 4140 4141 def ignorenulls_sql(self, expression: exp.IgnoreNulls) -> str: 4142 this = expression.this 4143 4144 if isinstance(this, self.IGNORE_RESPECT_NULLS_WINDOW_FUNCTIONS): 4145 # DuckDB should render IGNORE NULLS only for the general-purpose 4146 # window functions that accept it e.g. FIRST_VALUE(... IGNORE NULLS) OVER (...) 4147 return super().ignorenulls_sql(expression) 4148 4149 # For ARRAY_AGG(expr IGNORE NULLS ...), convert IGNORE NULLS to a 4150 # FILTER(WHERE expr IS NOT NULL) clause by setting nulls_excluded on 4151 # the ArrayAgg. The existing _add_arrayagg_null_filter method will 4152 # emit the FILTER clause during arrayagg_sql / withingroup_sql. 4153 if isinstance(this, exp.ArrayAgg): 4154 this.set("nulls_excluded", True) 4155 return self.sql(this) 4156 4157 if isinstance(this, exp.First): 4158 this = exp.AnyValue(this=this.this) 4159 4160 if not isinstance(this, (exp.AnyValue, exp.ApproxQuantiles)): 4161 self.unsupported("IGNORE NULLS is not supported for non-window functions.") 4162 4163 return self.sql(this) 4164 4165 def split_sql(self, expression: exp.Split) -> str: 4166 base_func = exp.func("STR_SPLIT", expression.this, expression.expression) 4167 4168 case_expr = exp.case().else_(base_func) 4169 needs_case = False 4170 4171 if expression.args.get("null_returns_null"): 4172 case_expr = case_expr.when(expression.expression.is_(exp.null()), exp.null()) 4173 needs_case = True 4174 4175 if expression.args.get("empty_delimiter_returns_whole"): 4176 # When delimiter is empty string, return input string as single array element 4177 array_with_input = exp.array(expression.this) 4178 case_expr = case_expr.when( 4179 expression.expression.eq(exp.Literal.string("")), array_with_input 4180 ) 4181 needs_case = True 4182 4183 return self.sql(case_expr if needs_case else base_func) 4184 4185 def splitpart_sql(self, expression: exp.SplitPart) -> str: 4186 string_arg = expression.this 4187 delimiter_arg = expression.args.get("delimiter") 4188 part_index_arg = expression.args.get("part_index") 4189 4190 if delimiter_arg and part_index_arg: 4191 # Handle Snowflake's "index 0 and 1 both return first element" behavior 4192 if expression.args.get("part_index_zero_as_one"): 4193 # Convert 0 to 1 for compatibility 4194 4195 part_index_arg = exp.Paren( 4196 this=exp.case() 4197 .when(part_index_arg.eq(exp.Literal.number("0")), exp.Literal.number("1")) 4198 .else_(part_index_arg) 4199 ) 4200 4201 # Use Anonymous to avoid recursion 4202 base_func_expr: exp.Expr = exp.Anonymous( 4203 this="SPLIT_PART", expressions=[string_arg, delimiter_arg, part_index_arg] 4204 ) 4205 needs_case_transform = False 4206 case_expr = exp.case().else_(base_func_expr) 4207 4208 if expression.args.get("empty_delimiter_returns_whole"): 4209 # When delimiter is empty string: 4210 # - Return whole string if part_index is 1 or -1 4211 # - Return empty string otherwise 4212 empty_case = exp.Paren( 4213 this=exp.case() 4214 .when( 4215 exp.or_( 4216 part_index_arg.eq(exp.Literal.number("1")), 4217 part_index_arg.eq(exp.Literal.number("-1")), 4218 ), 4219 string_arg, 4220 ) 4221 .else_(exp.Literal.string("")) 4222 ) 4223 4224 case_expr = case_expr.when(delimiter_arg.eq(exp.Literal.string("")), empty_case) 4225 needs_case_transform = True 4226 4227 """ 4228 Output looks something like this: 4229 4230 CASE 4231 WHEN delimiter is '' THEN 4232 ( 4233 CASE 4234 WHEN adjusted_part_index = 1 OR adjusted_part_index = -1 THEN input 4235 ELSE '' END 4236 ) 4237 ELSE SPLIT_PART(input, delimiter, adjusted_part_index) 4238 END 4239 4240 """ 4241 return self.sql(case_expr if needs_case_transform else base_func_expr) 4242 4243 return self.function_fallback_sql(expression) 4244 4245 def respectnulls_sql(self, expression: exp.RespectNulls) -> str: 4246 if isinstance(expression.this, self.IGNORE_RESPECT_NULLS_WINDOW_FUNCTIONS): 4247 # DuckDB should render RESPECT NULLS only for the general-purpose 4248 # window functions that accept it e.g. FIRST_VALUE(... RESPECT NULLS) OVER (...) 4249 return super().respectnulls_sql(expression) 4250 4251 self.unsupported("RESPECT NULLS is not supported for non-window functions.") 4252 return self.sql(expression, "this") 4253 4254 def arraytostring_sql(self, expression: exp.ArrayToString) -> str: 4255 null = expression.args.get("null") 4256 4257 if expression.args.get("null_is_empty"): 4258 x = exp.to_identifier("x") 4259 list_transform = exp.Transform( 4260 this=expression.this.copy(), 4261 expression=exp.Lambda( 4262 this=exp.Coalesce( 4263 this=exp.cast(x, "TEXT"), expressions=[exp.Literal.string("")] 4264 ), 4265 expressions=[x], 4266 ), 4267 ) 4268 array_to_string = exp.ArrayToString( 4269 this=list_transform, expression=expression.expression 4270 ) 4271 if expression.args.get("null_delim_is_null"): 4272 return self.sql( 4273 exp.case() 4274 .when(expression.expression.copy().is_(exp.null()), exp.null()) 4275 .else_(array_to_string) 4276 ) 4277 return self.sql(array_to_string) 4278 4279 if null: 4280 x = exp.to_identifier("x") 4281 return self.sql( 4282 exp.ArrayToString( 4283 this=exp.Transform( 4284 this=expression.this, 4285 expression=exp.Lambda( 4286 this=exp.Coalesce(this=exp.cast(x, "TEXT"), expressions=[null]), 4287 expressions=[x], 4288 ), 4289 ), 4290 expression=expression.expression, 4291 ) 4292 ) 4293 4294 return self.func("ARRAY_TO_STRING", expression.this, expression.expression) 4295 4296 def concatws_sql(self, expression: exp.ConcatWs) -> str: 4297 # DuckDB-specific: handle binary types using DPipe (||) operator 4298 separator = seq_get(expression.expressions, 0) 4299 args = expression.expressions[1:] 4300 4301 if any(_is_binary(arg) for arg in [separator, *args]): 4302 result = args[0] 4303 for arg in args[1:]: 4304 result = exp.DPipe( 4305 this=exp.DPipe(this=result, expression=separator), expression=arg 4306 ) 4307 return self.sql(result) 4308 4309 if expression.args.get("flatten") and any( 4310 isinstance(arg, exp.Array) or arg.is_type(exp.DType.ARRAY) for arg in args 4311 ): 4312 arrays = [] 4313 for arg in args: 4314 if isinstance(arg, exp.Array) or arg.is_type(exp.DType.ARRAY): 4315 arg = exp.cast(arg, "TEXT[]") 4316 else: 4317 if (not arg.type or arg.is_type(exp.DType.UNKNOWN)) and not isinstance( 4318 arg, exp.CONSTANTS 4319 ): 4320 self.unsupported( 4321 "Cannot transpile CONCAT_WS with unknown argument types to DuckDB." 4322 ) 4323 arg = exp.array(exp.cast(arg, exp.DType.TEXT)) 4324 4325 arrays.append(arg) 4326 4327 array = ( 4328 exp.ArrayConcat(this=arrays[0], expressions=arrays[1:]) 4329 if len(arrays) > 1 4330 else arrays[0] 4331 ) 4332 return self.func("CONCAT_WS", separator, self.func("ARRAY_TO_STRING", array, separator)) 4333 4334 return super().concatws_sql(expression) 4335 4336 def _regexp_extract_sql(self, expression: exp.RegexpExtract | exp.RegexpExtractAll) -> str: 4337 this = expression.this 4338 group = expression.args.get("group") 4339 params = expression.args.get("parameters") 4340 position = expression.args.get("position") 4341 occurrence = expression.args.get("occurrence") 4342 null_if_pos_overflow = expression.args.get("null_if_pos_overflow") 4343 null_if_no_match = expression.args.get("null_if_no_match") 4344 4345 # Handle Snowflake's 'e' flag: it enables capture group extraction 4346 # In DuckDB, this is controlled by the group parameter directly 4347 if params and params.is_string and "e" in params.name: 4348 params = exp.Literal.string(params.name.replace("e", "")) 4349 4350 validated_flags = self._validate_regexp_flags(params, supported_flags="cims") 4351 4352 # Strip default group when no following params (DuckDB default is same as group=0) 4353 if ( 4354 not validated_flags 4355 and group 4356 and group.name == str(self.dialect.REGEXP_EXTRACT_DEFAULT_GROUP) 4357 ): 4358 group = None 4359 4360 flags_expr = exp.Literal.string(validated_flags) if validated_flags else None 4361 4362 # use substring to handle position argument 4363 if position and (not position.is_int or position.to_py() > 1): 4364 this = exp.Substring(this=this, start=position) 4365 4366 if null_if_pos_overflow: 4367 this = exp.Nullif(this=this, expression=exp.Literal.string("")) 4368 4369 is_extract_all = isinstance(expression, exp.RegexpExtractAll) 4370 non_single_occurrence = occurrence and (not occurrence.is_int or occurrence.to_py() > 1) 4371 4372 if is_extract_all or non_single_occurrence or null_if_no_match: 4373 name = "REGEXP_EXTRACT_ALL" 4374 else: 4375 name = "REGEXP_EXTRACT" 4376 4377 result: exp.Expr = exp.Anonymous( 4378 this=name, expressions=[this, expression.expression, group, flags_expr] 4379 ) 4380 4381 # Array slicing for REGEXP_EXTRACT_ALL with occurrence 4382 if is_extract_all and non_single_occurrence: 4383 result = exp.Bracket(this=result, expressions=[exp.Slice(this=occurrence)]) 4384 elif not is_extract_all and (non_single_occurrence or null_if_no_match): 4385 # Non-participating capture group yields [NULL] in DuckDB; Snowflake returns '' on a match 4386 if ( 4387 expression.args.get("empty_if_group_missing") 4388 and group 4389 and not (group.is_int and group.to_py() == 0) 4390 ): 4391 result = exp.Transform( 4392 this=result, 4393 expression=exp.Lambda( 4394 this=exp.Coalesce( 4395 this=exp.column("x"), expressions=[exp.Literal.string("")] 4396 ), 4397 expressions=[exp.to_identifier("x")], 4398 ), 4399 ) 4400 return self.func( 4401 "ARRAY_EXTRACT", 4402 result, 4403 occurrence if non_single_occurrence else exp.Literal.number(1), 4404 ) 4405 4406 return self.sql(result) 4407 4408 def regexpextract_sql(self, expression: exp.RegexpExtract) -> str: 4409 return self._regexp_extract_sql(expression) 4410 4411 def regexpextractall_sql(self, expression: exp.RegexpExtractAll) -> str: 4412 return self._regexp_extract_sql(expression) 4413 4414 def regexpinstr_sql(self, expression: exp.RegexpInstr) -> str: 4415 this = expression.this 4416 pattern = expression.expression 4417 position = expression.args.get("position") 4418 orig_occ = expression.args.get("occurrence") 4419 occurrence = orig_occ or exp.Literal.number(1) 4420 option = expression.args.get("option") 4421 parameters = expression.args.get("parameters") 4422 4423 validated_flags = self._validate_regexp_flags(parameters, supported_flags="ims") 4424 # Checked before the flags are embedded, which would make the pattern non-empty 4425 is_empty = pattern.copy().eq(exp.Literal.string("")) 4426 if validated_flags: 4427 pattern = exp.Concat(expressions=[exp.Literal.string(f"(?{validated_flags})"), pattern]) 4428 4429 # Handle starting position offset 4430 pos_offset: exp.Expr = exp.Literal.number(0) 4431 if position and (not position.is_int or position.to_py() > 1): 4432 this = exp.Substring(this=this, start=position) 4433 pos_offset = position - exp.Literal.number(1) 4434 4435 # Helper: LIST_SUM(LIST_TRANSFORM(list[1:end], x -> LENGTH(x))) 4436 def sum_lengths(func_name: str, end: exp.Expr) -> exp.Expr: 4437 lst = exp.Bracket( 4438 this=exp.Anonymous(this=func_name, expressions=[this, pattern]), 4439 expressions=[exp.Slice(this=exp.Literal.number(1), expression=end)], 4440 offset=1, 4441 ) 4442 transform = exp.Anonymous( 4443 this="LIST_TRANSFORM", 4444 expressions=[ 4445 lst, 4446 exp.Lambda( 4447 this=exp.Length(this=exp.to_identifier("x")), 4448 expressions=[exp.to_identifier("x")], 4449 ), 4450 ], 4451 ) 4452 return exp.Coalesce( 4453 this=exp.Anonymous(this="LIST_SUM", expressions=[transform]), 4454 expressions=[exp.Literal.number(0)], 4455 ) 4456 4457 # Position = 1 + sum(split_lengths[1:occ]) + sum(match_lengths[1:occ-1]) + offset 4458 base_pos: exp.Expr = ( 4459 exp.Literal.number(1) 4460 + sum_lengths("STRING_SPLIT_REGEX", occurrence) 4461 + sum_lengths("REGEXP_EXTRACT_ALL", occurrence - exp.Literal.number(1)) 4462 + pos_offset 4463 ) 4464 4465 # option=1: add match length for end position 4466 if option and option.is_int and option.to_py() == 1: 4467 match_at_occ = exp.Bracket( 4468 this=exp.Anonymous(this="REGEXP_EXTRACT_ALL", expressions=[this, pattern]), 4469 expressions=[occurrence], 4470 offset=1, 4471 ) 4472 base_pos = base_pos + exp.Coalesce( 4473 this=exp.Length(this=match_at_occ), expressions=[exp.Literal.number(0)] 4474 ) 4475 4476 # NULL checks for all provided arguments 4477 # .copy() is used strictly because .is_() alters the node's parent pointer, mutating the parsed AST 4478 null_args = [ 4479 expression.this, 4480 expression.expression, 4481 position, 4482 orig_occ, 4483 option, 4484 parameters, 4485 ] 4486 null_checks = [arg.copy().is_(exp.Null()) for arg in null_args if arg] 4487 4488 matches = exp.Anonymous(this="REGEXP_EXTRACT_ALL", expressions=[this, pattern]) 4489 4490 return self.sql( 4491 exp.case() 4492 .when(exp.or_(*null_checks), exp.Null()) 4493 .when(is_empty, exp.Literal.number(0)) 4494 .when(exp.Length(this=matches) < occurrence, exp.Literal.number(0)) 4495 .else_(base_pos) 4496 ) 4497 4498 @unsupported_args("culture") 4499 def numbertostr_sql(self, expression: exp.NumberToStr) -> str: 4500 fmt = expression.args.get("format") 4501 if fmt and fmt.is_int: 4502 return self.func("FORMAT", f"'{{:,.{fmt.name}f}}'", expression.this) 4503 4504 self.unsupported("Only integer formats are supported by NumberToStr") 4505 return self.function_fallback_sql(expression) 4506 4507 def autoincrementcolumnconstraint_sql(self, _) -> str: 4508 self.unsupported("The AUTOINCREMENT column constraint is not supported by DuckDB") 4509 return "" 4510 4511 def aliases_sql(self, expression: exp.Aliases) -> str: 4512 this = expression.this 4513 if isinstance(this, exp.Posexplode): 4514 return self.posexplode_sql(this) 4515 4516 return super().aliases_sql(expression) 4517 4518 def posexplode_sql(self, expression: exp.Posexplode) -> str: 4519 this = expression.this 4520 parent = expression.parent 4521 4522 # The default Spark aliases are "pos" and "col", unless specified otherwise 4523 pos, col = exp.to_identifier("pos"), exp.to_identifier("col") 4524 4525 if isinstance(parent, exp.Aliases): 4526 # Column case: SELECT POSEXPLODE(col) [AS (a, b)] 4527 pos, col = parent.expressions 4528 elif isinstance(parent, exp.Table): 4529 # Table case: SELECT * FROM POSEXPLODE(col) [AS (a, b)] 4530 alias = parent.args.get("alias") 4531 if alias: 4532 pos, col = alias.columns or [pos, col] 4533 alias.pop() 4534 4535 # Translate POSEXPLODE to UNNEST + GENERATE_SUBSCRIPTS 4536 # Note: In Spark pos is 0-indexed, but in DuckDB it's 1-indexed, so we subtract 1 from GENERATE_SUBSCRIPTS 4537 unnest_sql = self.sql(exp.Unnest(expressions=[this], alias=col)) 4538 gen_subscripts = self.sql( 4539 exp.Alias( 4540 this=exp.Anonymous( 4541 this="GENERATE_SUBSCRIPTS", expressions=[this, exp.Literal.number(1)] 4542 ) 4543 - exp.Literal.number(1), 4544 alias=pos, 4545 ) 4546 ) 4547 4548 posexplode_sql = self.format_args(gen_subscripts, unnest_sql) 4549 4550 if isinstance(parent, exp.From) or (parent and isinstance(parent.parent, exp.From)): 4551 # SELECT * FROM POSEXPLODE(col) -> SELECT * FROM (SELECT GENERATE_SUBSCRIPTS(...), UNNEST(...)) 4552 return self.sql(exp.Subquery(this=exp.Select(expressions=[posexplode_sql]))) 4553 4554 return posexplode_sql 4555 4556 def addmonths_sql(self, expression: exp.AddMonths) -> str: 4557 """ 4558 Handles three key issues: 4559 1. Float/decimal months: e.g., Snowflake rounds, whereas DuckDB INTERVAL requires integers 4560 2. End-of-month preservation: If input is last day of month, result is last day of result month 4561 3. Type preservation: Maintains DATE/TIMESTAMPTZ types (DuckDB defaults to TIMESTAMP) 4562 """ 4563 from sqlglot.optimizer.annotate_types import annotate_types 4564 4565 this = expression.this 4566 if not this.type: 4567 this = annotate_types(this, dialect=self.dialect) 4568 4569 if this.is_type(*exp.DataType.TEXT_TYPES): 4570 this = exp.Cast(this=this, to=exp.DataType(this=exp.DType.TIMESTAMP)) 4571 4572 # Detect float/decimal months to apply rounding (Snowflake behavior) 4573 # DuckDB INTERVAL syntax doesn't support non-integer expressions, so use TO_MONTHS 4574 months_expr = expression.expression 4575 if not months_expr.type: 4576 months_expr = annotate_types(months_expr, dialect=self.dialect) 4577 4578 # Build interval or to_months expression based on type 4579 # Float/decimal case: Round and use TO_MONTHS(CAST(ROUND(value) AS INT)) 4580 interval_or_to_months = ( 4581 exp.func("TO_MONTHS", exp.cast(exp.func("ROUND", months_expr), "INT")) 4582 if months_expr.is_type( 4583 exp.DType.FLOAT, 4584 exp.DType.DOUBLE, 4585 exp.DType.DECIMAL, 4586 ) 4587 # Integer case: standard INTERVAL N MONTH syntax 4588 else exp.Interval(this=months_expr, unit=exp.var("MONTH")) 4589 ) 4590 4591 date_add_expr = exp.Add(this=this, expression=interval_or_to_months) 4592 4593 # Apply end-of-month preservation if Snowflake flag is set 4594 # CASE WHEN LAST_DAY(date) = date THEN LAST_DAY(result) ELSE result END 4595 preserve_eom = expression.args.get("preserve_end_of_month") 4596 result_expr = ( 4597 exp.case() 4598 .when( 4599 exp.EQ(this=exp.func("LAST_DAY", this), expression=this), 4600 exp.func("LAST_DAY", date_add_expr), 4601 ) 4602 .else_(date_add_expr) 4603 if preserve_eom 4604 else date_add_expr 4605 ) 4606 4607 # DuckDB's DATE_ADD function returns TIMESTAMP/DATETIME by default, even when the input is DATE 4608 # To match for example Snowflake's ADD_MONTHS behavior (which preserves the input type) 4609 # We need to cast the result back to the original type when the input is DATE or TIMESTAMPTZ 4610 # Example: ADD_MONTHS('2023-01-31'::date, 1) should return DATE, not TIMESTAMP 4611 if this.is_type(exp.DType.DATE, exp.DType.TIMESTAMPTZ): 4612 return self.sql(exp.Cast(this=result_expr, to=this.type)) 4613 return self.sql(result_expr) 4614 4615 def format_sql(self, expression: exp.Format) -> str: 4616 if expression.name.lower() == "%s" and len(expression.expressions) == 1: 4617 return self.func("FORMAT", "'{}'", expression.expressions[0]) 4618 4619 return self.function_fallback_sql(expression) 4620 4621 def hexstring_sql( 4622 self, expression: exp.HexString, binary_function_repr: str | None = None 4623 ) -> str: 4624 # UNHEX('FF') correctly produces blob \xFF in DuckDB 4625 return super().hexstring_sql(expression, binary_function_repr="UNHEX") 4626 4627 def datetrunc_sql(self, expression: exp.DateTrunc) -> str: 4628 unit = expression.args.get("unit") 4629 date = expression.this 4630 4631 week_start = _week_trunc_start_dow(unit) 4632 unit = unit_to_str(expression) 4633 4634 if week_start: 4635 result = self.sql( 4636 _build_week_trunc_expression(date, week_start, preserve_start_day=True) 4637 ) 4638 else: 4639 result = self.func("DATE_TRUNC", unit, date) 4640 4641 if expression.args.get("input_type_preserved") and date.is_type( 4642 *exp.DataType.TEMPORAL_TYPES 4643 ): 4644 return self.sql(exp.Cast(this=result, to=date.type)) 4645 4646 return result 4647 4648 def datetimetrunc_sql(self, expression: exp.DatetimeTrunc) -> str: 4649 this = exp.cast(expression.this, exp.DType.DATETIME) 4650 week_start = _week_trunc_start_dow(expression.args.get("unit")) 4651 if week_start: 4652 return self.sql( 4653 _build_week_trunc_expression( 4654 this, week_start, preserve_start_day=True, cast_to_date=False 4655 ) 4656 ) 4657 4658 return self.func("DATE_TRUNC", unit_to_str(expression), this) 4659 4660 def timestamptrunc_sql(self, expression: exp.TimestampTrunc) -> str: 4661 zone = expression.args.get("zone") 4662 timestamp = expression.this 4663 week_start = _week_trunc_start_dow(expression.args.get("unit")) 4664 4665 # The week start emulation below is exact, so avoid weekstart_unit_to_str's degrade warning 4666 unit = unit_to_str(expression) if week_start else weekstart_unit_to_str(self, expression) 4667 date_unit = is_date_unit(unit) or bool(week_start) 4668 4669 def _trunc_expr(this: exp.Expr) -> exp.Expr: 4670 if week_start: 4671 return _build_week_trunc_expression( 4672 this, week_start, preserve_start_day=True, cast_to_date=False 4673 ) 4674 return exp.func("DATE_TRUNC", unit, this) 4675 4676 if date_unit and zone: 4677 # BigQuery's TIMESTAMP_TRUNC with timezone truncates in the target timezone and returns as UTC. 4678 # Double AT TIME ZONE needed for BigQuery compatibility: 4679 # 1. First AT TIME ZONE: ensures truncation happens in the target timezone 4680 # 2. Second AT TIME ZONE: converts the DATE result back to TIMESTAMPTZ (preserving time component) 4681 timestamp = exp.AtTimeZone(this=timestamp, zone=zone) 4682 trunced = _trunc_expr(timestamp) 4683 if isinstance(trunced, exp.DateAdd): 4684 # Parenthesize so the trailing AT TIME ZONE binds to the whole shifted expression 4685 trunced = exp.Paren(this=trunced) 4686 return self.sql(exp.AtTimeZone(this=trunced, zone=zone)) 4687 4688 result = self.sql(_trunc_expr(timestamp)) 4689 if expression.args.get("input_type_preserved"): 4690 if timestamp.type and timestamp.is_type(exp.DType.TIME, exp.DType.TIMETZ): 4691 dummy_date = exp.Cast( 4692 this=exp.Literal.string("1970-01-01"), 4693 to=exp.DataType(this=exp.DType.DATE), 4694 ) 4695 date_time = exp.Add(this=dummy_date, expression=timestamp) 4696 result = self.func("DATE_TRUNC", unit, date_time) 4697 return self.sql(exp.Cast(this=result, to=timestamp.type)) 4698 4699 if timestamp.is_type(*exp.DataType.TEMPORAL_TYPES): 4700 return self.sql(exp.Cast(this=result, to=timestamp.type)) 4701 4702 return result 4703 4704 def trim_sql(self, expression: exp.Trim) -> str: 4705 expression.this.replace(_cast_to_varchar(expression.this)) 4706 if expression.expression: 4707 expression.expression.replace(_cast_to_varchar(expression.expression)) 4708 4709 result_sql = super().trim_sql(expression) 4710 return _gen_with_cast_to_blob(self, expression, result_sql) 4711 4712 def round_sql(self, expression: exp.Round) -> str: 4713 this = expression.this 4714 decimals = expression.args.get("decimals") 4715 truncate = expression.args.get("truncate") 4716 4717 # DuckDB requires the scale (decimals) argument to be an INT 4718 # Some dialects (e.g., Snowflake) allow non-integer scales and cast to an integer internally 4719 if decimals is not None and expression.args.get("casts_non_integer_decimals"): 4720 if not (decimals.is_int or decimals.is_type(*exp.DataType.INTEGER_TYPES)): 4721 decimals = exp.cast(decimals, exp.DType.INT) 4722 4723 func = "ROUND" 4724 if truncate: 4725 # BigQuery uses ROUND_HALF_EVEN; Snowflake uses HALF_TO_EVEN 4726 if truncate.this in ("ROUND_HALF_EVEN", "HALF_TO_EVEN"): 4727 func = "ROUND_EVEN" 4728 truncate = None 4729 # BigQuery uses ROUND_HALF_AWAY_FROM_ZERO; Snowflake uses HALF_AWAY_FROM_ZERO 4730 elif truncate.this in ("ROUND_HALF_AWAY_FROM_ZERO", "HALF_AWAY_FROM_ZERO"): 4731 truncate = None 4732 4733 return self.func(func, this, decimals, truncate) 4734 4735 def trycast_sql(self, expression: exp.TryCast) -> str: 4736 to = expression.to 4737 to_type = to.this 4738 src = expression.this 4739 4740 if ( 4741 expression.args.get("null_on_text_overflow") 4742 and to_type in exp.DataType.TEXT_TYPES 4743 and to.expressions 4744 ): 4745 return self.sql( 4746 exp.case() 4747 .when( 4748 exp.LTE(this=exp.func("LENGTH", src), expression=to.expressions[0].this), 4749 exp.cast(src, "TEXT"), 4750 ) 4751 .else_(exp.Null()) 4752 ) 4753 elif to_type == exp.DType.DATE and expression.args.get("probe_date_format"): 4754 slash_strptime = exp.cast( 4755 exp.func("TRY_STRPTIME", src, exp.Literal.string(self._TRYCAST_DATE_SLASH_FMT)), 4756 "DATE", 4757 ) 4758 mon_strptime = exp.cast( 4759 exp.func("TRY_STRPTIME", src, exp.Literal.string(self._TRYCAST_DATE_MON_FMT)), 4760 "DATE", 4761 ) 4762 return self.sql( 4763 exp.case() 4764 .when(exp.func("CONTAINS", src, exp.Literal.string("/")), slash_strptime) 4765 .when( 4766 exp.RegexpLike(this=src, expression=exp.Literal.string("[A-Za-z]")), 4767 mon_strptime, 4768 ) 4769 .else_(exp.TryCast(this=src, to=to)) 4770 ) 4771 elif ( 4772 isinstance(to_type, exp.Interval) 4773 and (unit := to_type.unit) 4774 and expression.args.get("requires_string") 4775 ): 4776 interval_type = exp.DataType.build("INTERVAL") 4777 if isinstance(unit, exp.IntervalSpan): 4778 self.unsupported( 4779 "TRY_CAST to INTERVAL with span (e.g. HOUR TO MINUTE) is not supported in DuckDB" 4780 ) 4781 return self.sql(exp.TryCast(this=src, to=interval_type)) 4782 return self.sql( 4783 exp.TryCast( 4784 this=exp.DPipe(this=src, expression=exp.Literal.string(f" {unit.name}")), 4785 to=interval_type, 4786 ) 4787 ) 4788 4789 return super().trycast_sql(expression) 4790 4791 def strtok_sql(self, expression: exp.Strtok) -> str: 4792 string_arg = expression.this 4793 delimiter_arg = expression.args.get("delimiter") 4794 part_index_arg = expression.args.get("part_index") 4795 4796 if delimiter_arg and part_index_arg: 4797 # Escape regex chars and build character class at runtime using REGEXP_REPLACE 4798 escaped_delimiter = exp.Anonymous( 4799 this="REGEXP_REPLACE", 4800 expressions=[ 4801 delimiter_arg, 4802 exp.Literal.string( 4803 r"([\[\]^.\-*+?(){}|$\\])" 4804 ), # Escape problematic regex chars 4805 exp.Literal.string( 4806 r"\\\1" 4807 ), # Replace with escaped version using $1 backreference 4808 exp.Literal.string("g"), # Global flag 4809 ], 4810 ) 4811 # CASE WHEN delimiter = '' THEN '' ELSE CONCAT('[', escaped_delimiter, ']') END 4812 regex_pattern = ( 4813 exp.case() 4814 .when(delimiter_arg.eq(exp.Literal.string("")), exp.Literal.string("")) 4815 .else_( 4816 exp.func( 4817 "CONCAT", 4818 exp.Literal.string("["), 4819 escaped_delimiter, 4820 exp.Literal.string("]"), 4821 ) 4822 ) 4823 ) 4824 4825 # STRTOK skips empty strings, so we need to filter them out 4826 # LIST_FILTER(REGEXP_SPLIT_TO_ARRAY(string, pattern), x -> x != '')[index] 4827 split_array = exp.func("REGEXP_SPLIT_TO_ARRAY", string_arg, regex_pattern) 4828 x = exp.to_identifier("x") 4829 is_empty = x.eq(exp.Literal.string("")) 4830 filtered_array = exp.func( 4831 "LIST_FILTER", 4832 split_array, 4833 exp.Lambda(this=exp.not_(is_empty.copy()), expressions=[x.copy()]), 4834 ) 4835 base_func = exp.Bracket( 4836 this=filtered_array, 4837 expressions=[part_index_arg], 4838 offset=1, 4839 ) 4840 4841 # Use template with the built regex pattern 4842 result = exp.replace_placeholders( 4843 self.STRTOK_TEMPLATE.copy(), 4844 string=string_arg, 4845 delimiter=delimiter_arg, 4846 part_index=part_index_arg, 4847 base_func=base_func, 4848 ) 4849 4850 return self.sql(result) 4851 4852 return self.function_fallback_sql(expression) 4853 4854 def strtoktoarray_sql(self, expression: exp.StrtokToArray) -> str: 4855 string_arg = expression.this 4856 delimiter_arg = expression.args.get("expression") or exp.Literal.string(" ") 4857 4858 escaped = exp.RegexpReplace( 4859 this=delimiter_arg.copy(), 4860 expression=exp.Literal.string(r"([\[\]^.\-*+?(){}|$\\])"), 4861 replacement=exp.Literal.string(r"\\\1"), 4862 modifiers=exp.Literal.string("g"), 4863 ) 4864 return self.sql( 4865 exp.replace_placeholders( 4866 self.STRTOK_TO_ARRAY_TEMPLATE.copy(), 4867 string=string_arg, 4868 delimiter=delimiter_arg, 4869 escaped=escaped, 4870 ) 4871 ) 4872 4873 def approxquantile_sql(self, expression: exp.ApproxQuantile) -> str: 4874 result = self.func("APPROX_QUANTILE", expression.this, expression.args.get("quantile")) 4875 4876 # DuckDB returns integers for APPROX_QUANTILE, cast to DOUBLE if the expected type is a real type 4877 if expression.is_type(*exp.DataType.REAL_TYPES): 4878 result = f"CAST({result} AS DOUBLE)" 4879 4880 return result 4881 4882 def approxquantiles_sql(self, expression: exp.ApproxQuantiles) -> str: 4883 """ 4884 BigQuery's APPROX_QUANTILES(expr, n) returns an array of n+1 approximate quantile values 4885 dividing the input distribution into n equal-sized buckets. 4886 4887 Both BigQuery and DuckDB use approximate algorithms for quantile estimation, but BigQuery 4888 does not document the specific algorithm used so results may differ. DuckDB does not 4889 support RESPECT NULLS. 4890 """ 4891 this = expression.this 4892 if isinstance(this, exp.Distinct): 4893 # APPROX_QUANTILES requires 2 args and DISTINCT node grabs both 4894 if len(this.expressions) < 2: 4895 self.unsupported("APPROX_QUANTILES requires a bucket count argument") 4896 return self.function_fallback_sql(expression) 4897 num_quantiles_expr = this.expressions[1].pop() 4898 else: 4899 num_quantiles_expr = expression.expression 4900 4901 if not isinstance(num_quantiles_expr, exp.Literal) or not num_quantiles_expr.is_int: 4902 self.unsupported("APPROX_QUANTILES bucket count must be a positive integer") 4903 return self.function_fallback_sql(expression) 4904 4905 num_quantiles = t.cast(int, num_quantiles_expr.to_py()) 4906 if num_quantiles <= 0: 4907 self.unsupported("APPROX_QUANTILES bucket count must be a positive integer") 4908 return self.function_fallback_sql(expression) 4909 4910 quantiles = [ 4911 exp.Literal.number(Decimal(i) / Decimal(num_quantiles)) 4912 for i in range(num_quantiles + 1) 4913 ] 4914 4915 return self.sql(exp.ApproxQuantile(this=this, quantile=exp.Array(expressions=quantiles))) 4916 4917 def jsonextractscalar_sql(self, expression: exp.JSONExtractScalar) -> str: 4918 if expression.args.get("scalar_only"): 4919 json_value = exp.JSONExtractScalar( 4920 this=rename_func("JSON_VALUE")(self, expression), expression="'$'" 4921 ) 4922 4923 # `->>` binds looser than most operators, so the wrap logic needs the parent 4924 json_value.parent = expression.parent 4925 expression = json_value 4926 4927 return _arrow_json_extract_sql(self, expression) 4928 4929 def bitwisenot_sql(self, expression: exp.BitwiseNot) -> str: 4930 this = expression.this 4931 4932 if _is_binary(this): 4933 expression.type = exp.DType.BINARY.into_expr() 4934 4935 arg = _cast_to_bit(this) 4936 4937 if isinstance(this, exp.Neg): 4938 arg = exp.Paren(this=arg) 4939 4940 expression.set("this", arg) 4941 4942 result_sql = f"~{self.sql(expression, 'this')}" 4943 4944 return _gen_with_cast_to_blob(self, expression, result_sql) 4945 4946 def window_sql(self, expression: exp.Window) -> str: 4947 this = expression.this 4948 if isinstance(this, exp.Corr) or ( 4949 isinstance(this, exp.Filter) and isinstance(this.this, exp.Corr) 4950 ): 4951 return self._corr_sql(expression) 4952 4953 return super().window_sql(expression) 4954 4955 def filter_sql(self, expression: exp.Filter) -> str: 4956 if isinstance(expression.this, exp.Corr): 4957 return self._corr_sql(expression) 4958 4959 return super().filter_sql(expression) 4960 4961 def _corr_sql( 4962 self, 4963 expression: exp.Filter | exp.Window | exp.Corr, 4964 ) -> str: 4965 if isinstance(expression, exp.Corr) and not expression.args.get("null_on_zero_variance"): 4966 return self.func("CORR", expression.this, expression.expression) 4967 4968 corr_expr = _maybe_corr_null_to_false(expression) 4969 if corr_expr is None: 4970 if isinstance(expression, exp.Window): 4971 return super().window_sql(expression) 4972 if isinstance(expression, exp.Filter): 4973 return super().filter_sql(expression) 4974 corr_expr = expression # make mypy happy 4975 4976 return self.sql(exp.case().when(exp.IsNan(this=corr_expr), exp.null()).else_(corr_expr)) 4977 4978 def uuid_sql(self, expression: exp.Uuid) -> str: 4979 namespace = expression.this 4980 name = expression.args.get("name") 4981 4982 # UUID v5 (namespace + name) - Emulate using SHA1 4983 if namespace and name: 4984 result = exp.replace_placeholders( 4985 self.UUID_V5_TEMPLATE.copy(), 4986 namespace=namespace, 4987 name=name, 4988 ) 4989 return self.sql(result) 4990 4991 return super().uuid_sql(expression)
735def connect_by_to_recursive_cte(expression: exp.Expr) -> exp.Expr: 736 # Rewrites START WITH ... CONNECT BY PRIOR into WITH RECURSIVE 737 # Falls through unchanged if there are no PRIORs. 738 if not isinstance(expression, exp.Select) or not expression.args.get("connect"): 739 return expression 740 741 connect = expression.args["connect"] 742 connect_pred = connect.args["connect"] 743 744 priors = list(connect_pred.find_all(exp.Prior)) 745 if not priors: 746 return expression 747 748 from_ = expression.args.get("from_") 749 if not from_ or expression.args.get("joins"): 750 return expression 751 752 source_table = from_.this 753 base_select_exprs = expression.expressions 754 base_where = expression.args.get("where") 755 base_with = expression.args.get("with_") 756 757 # LEVEL is a Snowflake pseudo-column: it's always computed as a depth counter in the CTE. 758 has_level = any( 759 isinstance(col, exp.Column) and col.name.upper() == "LEVEL" 760 for e in base_select_exprs 761 for col in e.find_all(exp.Column) 762 ) 763 has_star = expression.is_star 764 765 # CONNECT_BY_ROOT col yields the value of `col` from the START WITH row that begins each 766 # branch. Each one is threaded through the CTE as an extra column: the anchor binds it to the 767 # row's own value, the recursive arm forwards the parent's value unchanged. 768 root_col_names: list[str] = [] 769 anchor_root_cols: list[exp.Expr] = [] 770 inner_root_cols: list[exp.Expr] = [] 771 roots = [root for e in base_select_exprs for root in e.find_all(exp.ConnectByRoot)] 772 773 for i, root in enumerate(roots): 774 name = f"_connect_by_root_{i}" 775 root_col_names.append(name) 776 anchor_root_cols.append(exp.alias_(root.this, name)) 777 inner_root_cols.append(exp.alias_(exp.column(name, "_parent_row"), name)) 778 root.replace(exp.column(name)) 779 780 # Build the join condition from the full CONNECT BY predicate: 781 # PRIOR(col) → _parent_row.col, unqualified cols → _child_row.col. 782 def _qualify_connect_pred(node: exp.Expression) -> exp.Expression: 783 for col in find_all_in_scope(node, exp.Column): 784 col.set( 785 "table", 786 exp.to_identifier( 787 "_parent_row" if isinstance(col.parent, exp.Prior) else "_child_row" 788 ), 789 ) 790 for prior in find_all_in_scope(node, exp.Prior): 791 prior.replace(prior.this) 792 return node 793 794 # Avoid colliding with any CTE names already on the query. 795 cte_name = find_new_name( 796 {cte.alias for cte in (base_with.expressions if base_with else [])}, "_rootcte" 797 ) 798 799 # Anchor: project all source columns + seed LEVEL at 1 + bind each root column to its own value. 800 anchor = exp.select( 801 exp.Star(), exp.alias_(exp.Literal.number(1), "level"), *anchor_root_cols 802 ).from_(source_table) 803 if connect.args.get("start"): 804 anchor = anchor.where(connect.args["start"]) 805 806 # Recursive arm: carry all child columns + increment level + forward each root value. 807 # SELECT * in both arms means WHERE/PRIOR columns are always available without explicit tracking. 808 inner_query = ( 809 exp.select( 810 exp.Column(this=exp.Star(), table=exp.to_identifier("_child_row")), 811 exp.alias_(exp.column("level", "_parent_row") + 1, "level"), 812 *inner_root_cols, 813 ) 814 .from_(source_table.as_("_child_row")) 815 .join(exp.to_table(cte_name).as_("_parent_row"), on=_qualify_connect_pred(connect_pred)) 816 ) 817 818 # Outer SELECT re-projects from the CTE. Synthetic level/root columns are excluded from any 819 # star expansion (level only when not referenced) but kept where explicitly projected. 820 if has_star: 821 except_cols = [] if has_level else [exp.column("level")] 822 except_cols.extend(exp.column(name) for name in root_col_names) 823 star = exp.Star(except_=except_cols) if except_cols else exp.Star() 824 outer_select_exprs: list[exp.Expr] = [ 825 star, 826 *(e for e in base_select_exprs if not e.is_star), 827 ] 828 else: 829 outer_select_exprs = base_select_exprs 830 outer_query = exp.select(*outer_select_exprs).from_(cte_name) 831 if base_where: 832 outer_query = outer_query.where(base_where.this) 833 834 # Attach the CTE, marking the WITH clause recursive. 835 if base_with: 836 outer_query.set("with_", base_with) 837 outer_query = outer_query.with_( 838 cte_name, as_=anchor.union(inner_query, distinct=False), recursive=True, copy=False 839 ) 840 841 for arg, val in expression.args.items(): 842 if val and arg not in _CONNECT_BY_ARGS_TO_SKIP: 843 outer_query.set(arg, val) 844 845 # Strip stale source table qualifiers in one pass; CTEs are child scopes so 846 # find_all_in_scope stays within the outer query only. 847 for col in find_all_in_scope(outer_query, exp.Column): 848 col.set("table", None) 849 850 return outer_query
1594class DuckDBGenerator(generator.Generator): 1595 PARAMETER_TOKEN = "$" 1596 NAMED_PLACEHOLDER_TOKEN = "$" 1597 JOIN_HINTS = False 1598 TABLE_HINTS = False 1599 QUERY_HINTS = False 1600 LIMIT_FETCH = "LIMIT" 1601 SUPPORTS_NEAREST_JOIN = True 1602 STRUCT_DELIMITER = ("(", ")") 1603 RENAME_TABLE_WITH_DB = False 1604 NVL2_SUPPORTED = False 1605 SEMI_ANTI_JOIN_WITH_SIDE = False 1606 CAST_TO_INT_ROUNDS = True 1607 SAFE_INT_DIVISION = True 1608 TABLESAMPLE_KEYWORDS = "USING SAMPLE" 1609 TABLESAMPLE_SEED_KEYWORD = "REPEATABLE" 1610 LAST_DAY_SUPPORTS_DATE_PART = False 1611 JSON_KEY_VALUE_PAIR_SEP = "," 1612 IGNORE_NULLS_IN_FUNC = True 1613 IGNORE_NULLS_BEFORE_ORDER = False 1614 JSON_PATH_BRACKETED_KEY_SUPPORTED = False 1615 SUPPORTS_CREATE_TABLE_LIKE = False 1616 MULTI_ARG_DISTINCT = False 1617 CAN_IMPLEMENT_ARRAY_ANY = True 1618 SUPPORTS_TO_NUMBER = False 1619 SELECT_KINDS: tuple[str, ...] = () 1620 SUPPORTS_DECODE_CASE = False 1621 SUPPORTS_DROP_ALTER_ICEBERG_PROPERTY = False 1622 1623 # USING SAMPLE goes after QUALIFY and before ORDER BY / LIMIT 1624 # https://duckdb.org/docs/preview/sql/query_syntax/sample 1625 AFTER_HAVING_MODIFIER_TRANSFORMS = { 1626 **generator.AFTER_HAVING_MODIFIER_TRANSFORMS, 1627 "sample": lambda self, e: self.sql(e, "sample"), 1628 } 1629 SUPPORTS_WINDOW_EXCLUDE = True 1630 COPY_HAS_INTO_KEYWORD = False 1631 STAR_EXCEPT = "EXCLUDE" 1632 PAD_FILL_PATTERN_IS_REQUIRED = True 1633 ARRAY_SIZE_DIM_REQUIRED: bool | None = False 1634 NORMALIZE_EXTRACT_DATE_PARTS = True 1635 SUPPORTS_LIKE_QUANTIFIERS = False 1636 HISTORICAL_DATA_POST_ALIAS = True 1637 SET_ASSIGNMENT_REQUIRES_VARIABLE_KEYWORD = True 1638 1639 TRANSFORMS = { 1640 **generator.Generator.TRANSFORMS, 1641 exp.AnyValue: _anyvalue_sql, 1642 exp.ApproxDistinct: approx_count_distinct_sql, 1643 exp.Boolnot: _boolnot_sql, 1644 exp.Booland: _booland_sql, 1645 exp.Boolor: _boolor_sql, 1646 exp.Array: transforms.preprocess( 1647 [transforms.inherit_struct_field_names], 1648 generator=inline_array_unless_query, 1649 ), 1650 exp.ArrayAppend: array_append_sql("LIST_APPEND"), 1651 exp.ArrayCompact: array_compact_sql, 1652 exp.ArrayConstructCompact: lambda self, e: self.sql( 1653 exp.ArrayCompact(this=exp.Array(expressions=e.expressions)) 1654 ), 1655 exp.ArrayConcat: array_concat_sql("LIST_CONCAT"), 1656 exp.ArrayContains: _array_contains_sql, 1657 exp.ArrayOverlaps: _array_overlaps_sql, 1658 exp.ArrayFilter: rename_func("LIST_FILTER"), 1659 exp.ArrayInsert: _array_insert_sql, 1660 exp.ArrayPosition: lambda self, e: ( 1661 self.sql( 1662 exp.Sub( 1663 this=exp.ArrayPosition(this=e.this, expression=e.expression), 1664 expression=exp.Literal.number(1), 1665 ) 1666 ) 1667 if e.args.get("zero_based") 1668 else self.func("ARRAY_POSITION", e.this, e.expression) 1669 ), 1670 exp.ArrayRemoveAt: _array_remove_at_sql, 1671 exp.ArrayRemove: remove_from_array_using_filter, 1672 exp.ArraySort: _array_sort_sql, 1673 exp.ArrayPrepend: array_append_sql("LIST_PREPEND", swap_params=True), 1674 exp.ArraySum: rename_func("LIST_SUM"), 1675 exp.ArrayMax: rename_func("LIST_MAX"), 1676 exp.ArrayMin: rename_func("LIST_MIN"), 1677 exp.Base64DecodeBinary: lambda self, e: _base64_decode_sql(self, e, to_string=False), 1678 exp.Base64DecodeString: lambda self, e: _base64_decode_sql(self, e, to_string=True), 1679 exp.BitwiseAnd: lambda self, e: self._bitwise_op(e, "&"), 1680 exp.BitwiseAndAgg: _bitwise_agg_sql, 1681 exp.BitwiseCount: rename_func("BIT_COUNT"), 1682 exp.BitwiseLeftShift: _bitshift_sql, 1683 exp.BitwiseOr: lambda self, e: self._bitwise_op(e, "|"), 1684 exp.BitwiseOrAgg: _bitwise_agg_sql, 1685 exp.BitwiseRightShift: _bitshift_sql, 1686 exp.BitwiseXorAgg: _bitwise_agg_sql, 1687 exp.CommentColumnConstraint: no_comment_column_constraint_sql, 1688 exp.Corr: lambda self, e: self._corr_sql(e), 1689 exp.CosineDistance: rename_func("LIST_COSINE_DISTANCE"), 1690 exp.CurrentTime: lambda *_: "CURRENT_TIME", 1691 exp.CurrentSchemas: lambda self, e: self.func( 1692 "current_schemas", e.this if e.this else exp.true() 1693 ), 1694 exp.CurrentTimestamp: lambda self, e: ( 1695 self.sql( 1696 exp.AtTimeZone(this=exp.var("CURRENT_TIMESTAMP"), zone=exp.Literal.string("UTC")) 1697 ) 1698 if e.args.get("sysdate") 1699 else "CURRENT_TIMESTAMP" 1700 ), 1701 exp.CurrentVersion: rename_func("version"), 1702 exp.Localtime: unsupported_args("this")(lambda *_: "LOCALTIME"), 1703 exp.DayOfMonth: rename_func("DAYOFMONTH"), 1704 exp.DayOfWeek: rename_func("DAYOFWEEK"), 1705 exp.DayOfWeekIso: rename_func("ISODOW"), 1706 exp.DayOfYear: rename_func("DAYOFYEAR"), 1707 exp.Dayname: lambda self, e: ( 1708 self.func("STRFTIME", e.this, exp.Literal.string("%a")) 1709 if e.args.get("abbreviated") 1710 else self.func("DAYNAME", e.this) 1711 ), 1712 exp.Monthname: lambda self, e: ( 1713 self.func("STRFTIME", e.this, exp.Literal.string("%b")) 1714 if e.args.get("abbreviated") 1715 else self.func("MONTHNAME", e.this) 1716 ), 1717 exp.Date: _date_sql, 1718 exp.DateAdd: _date_delta_to_binary_interval_op(), 1719 exp.DateFromParts: _date_from_parts_sql, 1720 exp.DateSub: _date_delta_to_binary_interval_op(), 1721 exp.DateDiff: _date_diff_sql, 1722 exp.DateStrToDate: datestrtodate_sql, 1723 exp.Datetime: no_datetime_sql, 1724 exp.DatetimeDiff: _date_diff_sql, 1725 exp.DatetimeSub: _date_delta_to_binary_interval_op(), 1726 exp.DatetimeAdd: _date_delta_to_binary_interval_op(), 1727 exp.DateToDi: lambda self, e: ( 1728 f"CAST(STRFTIME({self.sql(e, 'this')}, {self.dialect.DATEINT_FORMAT}) AS INT)" 1729 ), 1730 exp.Decode: lambda self, e: encode_decode_sql(self, e, "DECODE", replace=False), 1731 exp.HexDecodeString: lambda self, e: self.sql(exp.Decode(this=exp.Unhex(this=e.this))), 1732 exp.DiToDate: lambda self, e: ( 1733 f"CAST(STRPTIME(CAST({self.sql(e, 'this')} AS TEXT), {self.dialect.DATEINT_FORMAT}) AS DATE)" 1734 ), 1735 exp.Encode: lambda self, e: encode_decode_sql(self, e, "ENCODE", replace=False), 1736 exp.EqualNull: lambda self, e: self.sql( 1737 exp.NullSafeEQ(this=e.this, expression=e.expression) 1738 ), 1739 exp.EuclideanDistance: rename_func("LIST_DISTANCE"), 1740 exp.GenerateDateArray: _generate_datetime_array_sql, 1741 exp.GenerateSeries: generate_series_sql("GENERATE_SERIES", "RANGE"), 1742 exp.GenerateTimestampArray: _generate_datetime_array_sql, 1743 exp.Getbit: getbit_sql, 1744 exp.GroupConcat: lambda self, e: groupconcat_sql(self, e, within_group=False), 1745 exp.Explode: rename_func("UNNEST"), 1746 exp.IcebergProperty: lambda *_: "", 1747 exp.IntDiv: lambda self, e: self.binary(e, "//"), 1748 exp.IsInf: rename_func("ISINF"), 1749 exp.IsNan: rename_func("ISNAN"), 1750 exp.IsNullValue: lambda self, e: self.sql( 1751 exp.func("JSON_TYPE", e.this).eq(exp.Literal.string("NULL")) 1752 ), 1753 exp.IsArray: lambda self, e: self.sql( 1754 exp.func("JSON_TYPE", e.this).eq(exp.Literal.string("ARRAY")) 1755 ), 1756 exp.Ceil: _ceil_floor, 1757 exp.Floor: _ceil_floor, 1758 exp.JSONBExists: rename_func("JSON_EXISTS"), 1759 exp.JSONExtract: _arrow_json_extract_sql, 1760 exp.JSONExtractArray: _json_extract_value_array_sql, 1761 exp.JSONFormat: _json_format_sql, 1762 exp.JSONValueArray: _json_extract_value_array_sql, 1763 exp.Lateral: _explode_to_unnest_sql, 1764 exp.LogicalOr: lambda self, e: self.func("BOOL_OR", _cast_to_boolean(e.this)), 1765 exp.LogicalAnd: lambda self, e: self.func("BOOL_AND", _cast_to_boolean(e.this)), 1766 exp.Select: transforms.preprocess( 1767 [connect_by_to_recursive_cte, _seq_to_range_in_generator] 1768 ), 1769 exp.Seq1: lambda self, e: _seq_sql(self, e, 1), 1770 exp.Seq2: lambda self, e: _seq_sql(self, e, 2), 1771 exp.Seq4: lambda self, e: _seq_sql(self, e, 4), 1772 exp.Seq8: lambda self, e: _seq_sql(self, e, 8), 1773 exp.BoolxorAgg: _boolxor_agg_sql, 1774 exp.MakeInterval: lambda self, e: no_make_interval_sql(self, e, sep=" "), 1775 exp.Initcap: _initcap_sql, 1776 exp.MD5Digest: lambda self, e: self.func("UNHEX", self.func("MD5", e.this)), 1777 exp.Max: rename_func("MAX"), 1778 exp.Min: rename_func("MIN"), 1779 exp.SHA: lambda self, e: _sha_sql(self, e, "SHA1"), 1780 exp.SHA1Digest: lambda self, e: _sha_sql(self, e, "SHA1", is_binary=True), 1781 exp.SHA2: lambda self, e: _sha_sql(self, e, "SHA256"), 1782 exp.SHA2Digest: lambda self, e: _sha_sql(self, e, "SHA256", is_binary=True), 1783 exp.MonthsBetween: months_between_sql, 1784 exp.NextDay: _day_navigation_sql, 1785 exp.PercentileCont: rename_func("QUANTILE_CONT"), 1786 exp.PercentileDisc: rename_func("QUANTILE_DISC"), 1787 # DuckDB doesn't allow qualified columns inside of PIVOT expressions. 1788 # See: https://github.com/duckdb/duckdb/blob/671faf92411182f81dce42ac43de8bfb05d9909e/src/planner/binder/tableref/bind_pivot.cpp#L61-L62 1789 exp.Pivot: transforms.preprocess([transforms.unqualify_columns]), 1790 exp.PreviousDay: _day_navigation_sql, 1791 exp.RegexpILike: lambda self, e: self.func( 1792 "REGEXP_MATCHES", e.this, e.expression, exp.Literal.string("i") 1793 ), 1794 exp.RegexpSplit: rename_func("STR_SPLIT_REGEX"), 1795 exp.RegrValx: _regr_val_sql, 1796 exp.RegrValy: _regr_val_sql, 1797 exp.Return: lambda self, e: self.sql(e, "this"), 1798 exp.ReturnsProperty: lambda self, e: "TABLE" if isinstance(e.this, exp.Schema) else "", 1799 exp.StrToUnix: lambda self, e: self.func( 1800 "EPOCH", self.func("STRPTIME", e.this, self.format_time(e)) 1801 ), 1802 exp.Struct: _struct_sql, 1803 exp.Transform: rename_func("LIST_TRANSFORM"), 1804 exp.TimeAdd: _date_delta_to_binary_interval_op(), 1805 exp.TimeSub: _date_delta_to_binary_interval_op(), 1806 exp.Time: no_time_sql, 1807 exp.TimeDiff: _timediff_sql, 1808 exp.Timestamp: no_timestamp_sql, 1809 exp.TimestampAdd: _date_delta_to_binary_interval_op(), 1810 exp.TimestampDiff: lambda self, e: self.func( 1811 "DATE_DIFF", exp.Literal.string(e.unit), e.expression, e.this 1812 ), 1813 exp.TimestampSub: _date_delta_to_binary_interval_op(), 1814 exp.TimeStrToDate: lambda self, e: self.sql(exp.cast(e.this, exp.DType.DATE)), 1815 exp.TimeStrToTime: timestrtotime_sql, 1816 exp.TimeStrToUnix: lambda self, e: self.func( 1817 "EPOCH", exp.cast(e.this, exp.DType.TIMESTAMP) 1818 ), 1819 exp.TimeToStr: lambda self, e: self.func("STRFTIME", e.this, self.format_time(e)), 1820 exp.ToBoolean: _to_boolean_sql, 1821 exp.ToVariant: lambda self, e: self.sql( 1822 exp.cast(e.this, exp.DataType.from_str("VARIANT", dialect="duckdb")) 1823 ), 1824 exp.TimeToUnix: rename_func("EPOCH"), 1825 exp.TsOrDiToDi: lambda self, e: ( 1826 f"CAST(SUBSTR(REPLACE(CAST({self.sql(e, 'this')} AS TEXT), '-', ''), 1, 8) AS INT)" 1827 ), 1828 exp.TsOrDsAdd: _date_delta_to_binary_interval_op(), 1829 exp.TsOrDsDiff: lambda self, e: self.func( 1830 "DATE_DIFF", 1831 exp.Literal.string(e.unit or "DAY"), 1832 exp.cast(e.expression, exp.DType.TIMESTAMP), 1833 exp.cast(e.this, exp.DType.TIMESTAMP), 1834 ), 1835 exp.UnixMicros: lambda self, e: self.func("EPOCH_US", _implicit_datetime_cast(e.this)), 1836 exp.UnixMillis: lambda self, e: self.func("EPOCH_MS", _implicit_datetime_cast(e.this)), 1837 exp.UnixSeconds: lambda self, e: self.sql( 1838 exp.cast(self.func("EPOCH", _implicit_datetime_cast(e.this)), exp.DType.BIGINT) 1839 ), 1840 exp.UnixToStr: lambda self, e: self.func( 1841 "STRFTIME", self.func("TO_TIMESTAMP", e.this), self.format_time(e) 1842 ), 1843 exp.UnixToTime: _unix_to_time_sql, 1844 exp.UnixToTimeStr: lambda self, e: f"CAST(TO_TIMESTAMP({self.sql(e, 'this')}) AS TEXT)", 1845 exp.VariancePop: rename_func("VAR_POP"), 1846 exp.WeekOfYear: rename_func("WEEKOFYEAR"), 1847 exp.YearOfWeek: lambda self, e: self.sql( 1848 exp.Extract( 1849 this=exp.Var(this="ISOYEAR"), 1850 expression=e.this, 1851 ) 1852 ), 1853 exp.YearOfWeekIso: lambda self, e: self.sql( 1854 exp.Extract( 1855 this=exp.Var(this="ISOYEAR"), 1856 expression=e.this, 1857 ) 1858 ), 1859 exp.Xor: _xor_sql, 1860 exp.JSONObjectAgg: rename_func("JSON_GROUP_OBJECT"), 1861 exp.JSONArray: _json_constructor_sql, 1862 exp.JSONObject: _json_constructor_sql, 1863 exp.JSONBObjectAgg: rename_func("JSON_GROUP_OBJECT"), 1864 exp.DateBin: rename_func("TIME_BUCKET"), 1865 exp.LastDay: _last_day_sql, 1866 } 1867 1868 SUPPORTED_JSON_PATH_PARTS = { 1869 exp.JSONPathKey, 1870 exp.JSONPathRoot, 1871 exp.JSONPathSubscript, 1872 exp.JSONPathWildcard, 1873 } 1874 1875 TYPE_MAPPING = { 1876 **generator.Generator.TYPE_MAPPING, 1877 exp.DType.BINARY: "BLOB", 1878 exp.DType.BPCHAR: "TEXT", 1879 exp.DType.CHAR: "TEXT", 1880 exp.DType.DATETIME: "TIMESTAMP", 1881 exp.DType.DECFLOAT: "DECIMAL", 1882 exp.DType.FLOAT: "REAL", 1883 exp.DType.JSONB: "JSON", 1884 exp.DType.NCHAR: "TEXT", 1885 exp.DType.NVARCHAR: "TEXT", 1886 exp.DType.UINT: "UINTEGER", 1887 exp.DType.VARBINARY: "BLOB", 1888 exp.DType.ROWVERSION: "BLOB", 1889 exp.DType.VARCHAR: "TEXT", 1890 exp.DType.TIMESTAMPLTZ: "TIMESTAMPTZ", 1891 exp.DType.TIMESTAMPNTZ: "TIMESTAMP", 1892 exp.DType.TIMESTAMP_S: "TIMESTAMP_S", 1893 exp.DType.TIMESTAMP_MS: "TIMESTAMP_MS", 1894 exp.DType.TIMESTAMP_NS: "TIMESTAMP_NS", 1895 exp.DType.BIGDECIMAL: "DECIMAL", 1896 } 1897 1898 TYPE_PARAM_SETTINGS = { 1899 **generator.Generator.TYPE_PARAM_SETTINGS, 1900 exp.DType.BIGDECIMAL: ((38, 5), (38, 38)), 1901 exp.DType.DECFLOAT: ((38, 5), (38, 38)), 1902 } 1903 1904 # https://github.com/duckdb/duckdb/blob/ff7f24fd8e3128d94371827523dae85ebaf58713/third_party/libpg_query/grammar/keywords/reserved_keywords.list#L1-L77 1905 RESERVED_KEYWORDS = { 1906 "array", 1907 "analyse", 1908 "union", 1909 "all", 1910 "when", 1911 "in", 1912 "default", 1913 "create", 1914 "window", 1915 "asymmetric", 1916 "to", 1917 "else", 1918 "localtime", 1919 "from", 1920 "end", 1921 "select", 1922 "current_date", 1923 "foreign", 1924 "with", 1925 "grant", 1926 "session_user", 1927 "or", 1928 "except", 1929 "references", 1930 "fetch", 1931 "limit", 1932 "group", 1933 "leading", 1934 "into", 1935 "collate", 1936 "offset", 1937 "do", 1938 "then", 1939 "localtimestamp", 1940 "check", 1941 "lateral", 1942 "current_role", 1943 "where", 1944 "asc", 1945 "placing", 1946 "desc", 1947 "user", 1948 "unique", 1949 "initially", 1950 "column", 1951 "both", 1952 "some", 1953 "as", 1954 "any", 1955 "only", 1956 "deferrable", 1957 "null", 1958 "current_time", 1959 "true", 1960 "table", 1961 "case", 1962 "trailing", 1963 "variadic", 1964 "for", 1965 "on", 1966 "distinct", 1967 "false", 1968 "not", 1969 "constraint", 1970 "current_timestamp", 1971 "returning", 1972 "primary", 1973 "intersect", 1974 "having", 1975 "analyze", 1976 "current_user", 1977 "and", 1978 "cast", 1979 "symmetric", 1980 "using", 1981 "order", 1982 "current_catalog", 1983 "describe", 1984 "lambda", 1985 "pivot", 1986 "pivot_longer", 1987 "pivot_wider", 1988 "qualify", 1989 "show", 1990 "summarize", 1991 "unpivot", 1992 "anti", 1993 "asof", 1994 "at", 1995 "authorization", 1996 "binary", 1997 "by", 1998 "collation", 1999 "columns", 2000 "concurrently", 2001 "cross", 2002 "freeze", 2003 "full", 2004 "generated", 2005 "glob", 2006 "ilike", 2007 "inner", 2008 "is", 2009 "isnull", 2010 "join", 2011 "left", 2012 "like", 2013 "map", 2014 "natural", 2015 "notnull", 2016 "outer", 2017 "overlaps", 2018 "positional", 2019 "right", 2020 "semi", 2021 "similar", 2022 "struct", 2023 "tablesample", 2024 "try_cast", 2025 "tuple", 2026 "unpack", 2027 "verbose", 2028 } 2029 2030 UNWRAPPED_INTERVAL_VALUES = (exp.Literal, exp.Paren) 2031 2032 # DuckDB doesn't generally support CREATE TABLE .. properties 2033 # https://duckdb.org/docs/sql/statements/create_table.html 2034 # There are a few exceptions (e.g. temporary tables) which are supported or 2035 # can be transpiled to DuckDB, so we explicitly override them accordingly 2036 PROPERTIES_LOCATION = { 2037 **{ 2038 prop: exp.Properties.Location.UNSUPPORTED 2039 for prop in generator.Generator.PROPERTIES_LOCATION 2040 }, 2041 exp.LikeProperty: exp.Properties.Location.POST_SCHEMA, 2042 exp.TemporaryProperty: exp.Properties.Location.POST_CREATE, 2043 exp.ReturnsProperty: exp.Properties.Location.POST_ALIAS, 2044 exp.SequenceProperties: exp.Properties.Location.POST_EXPRESSION, 2045 exp.TriggerProperties: exp.Properties.Location.POST_EXPRESSION, 2046 exp.IcebergProperty: exp.Properties.Location.POST_CREATE, 2047 } 2048 2049 IGNORE_RESPECT_NULLS_WINDOW_FUNCTIONS: t.ClassVar = _IGNORE_RESPECT_NULLS_WINDOW_FUNCTIONS 2050 2051 # Template for ZIPF transpilation - placeholders get replaced with actual parameters 2052 ZIPF_TEMPLATE: t.ClassVar[exp.Expr] = exp.maybe_parse( 2053 """ 2054 WITH rand AS (SELECT :random_expr AS r), 2055 weights AS ( 2056 SELECT i, 1.0 / POWER(i, :s) AS w 2057 FROM RANGE(1, :n + 1) AS t(i) 2058 ), 2059 cdf AS ( 2060 SELECT i, SUM(w) OVER (ORDER BY i) / SUM(w) OVER () AS p 2061 FROM weights 2062 ) 2063 SELECT MIN(i) 2064 FROM cdf 2065 WHERE p >= (SELECT r FROM rand) 2066 """ 2067 ) 2068 2069 # Template for NORMAL transpilation using Box-Muller transform 2070 # mean + (stddev * sqrt(-2 * ln(u1)) * cos(2 * pi * u2)) 2071 NORMAL_TEMPLATE: t.ClassVar[exp.Expr] = exp.maybe_parse( 2072 ":mean + (:stddev * SQRT(-2 * LN(GREATEST(:u1, 1e-10))) * COS(2 * PI() * :u2))" 2073 ) 2074 2075 # Template for generating a seeded pseudo-random value in [0, 1) from a hash 2076 SEEDED_RANDOM_TEMPLATE: t.ClassVar[exp.Expr] = exp.maybe_parse( 2077 "(ABS(HASH(:seed)) % 1000000) / 1000000.0" 2078 ) 2079 2080 # Template for generating signed and unsigned SEQ values within a specified range 2081 SEQ_UNSIGNED: t.ClassVar[exp.Expr] = _SEQ_UNSIGNED 2082 SEQ_SIGNED: t.ClassVar[exp.Expr] = _SEQ_SIGNED 2083 2084 # Template for MAP_CAT transpilation - Snowflake semantics: 2085 # 1. Returns NULL if either input is NULL 2086 # 2. For duplicate keys, prefers non-NULL value (COALESCE(m2[k], m1[k])) 2087 # 3. Filters out entries with NULL values from the result 2088 MAPCAT_TEMPLATE: t.ClassVar[exp.Expr] = exp.maybe_parse( 2089 """ 2090 CASE 2091 WHEN :map1 IS NULL OR :map2 IS NULL THEN NULL 2092 ELSE MAP_FROM_ENTRIES(LIST_FILTER(LIST_TRANSFORM( 2093 LIST_DISTINCT(LIST_CONCAT(MAP_KEYS(:map1), MAP_KEYS(:map2))), 2094 __k -> STRUCT_PACK(key := __k, value := COALESCE(:map2[__k], :map1[__k])) 2095 ), __x -> __x.value IS NOT NULL)) 2096 END 2097 """ 2098 ) 2099 2100 # Mappings for EXTRACT/DATE_PART transpilation 2101 # Maps Snowflake specifiers unsupported in DuckDB to strftime format codes 2102 EXTRACT_STRFTIME_MAPPINGS: t.ClassVar[dict[str, tuple[str, str]]] = { 2103 "WEEKISO": ("%V", "INTEGER"), 2104 "YEAROFWEEK": ("%G", "INTEGER"), 2105 "YEAROFWEEKISO": ("%G", "INTEGER"), 2106 "NANOSECOND": ("%n", "BIGINT"), 2107 } 2108 2109 # Maps epoch-based specifiers to DuckDB epoch functions 2110 EXTRACT_EPOCH_MAPPINGS: t.ClassVar[dict[str, str]] = { 2111 "EPOCH_SECOND": "EPOCH", 2112 "EPOCH_MILLISECOND": "EPOCH_MS", 2113 "EPOCH_MICROSECOND": "EPOCH_US", 2114 "EPOCH_NANOSECOND": "EPOCH_NS", 2115 } 2116 2117 # Template for BITMAP_CONSTRUCT_AGG transpilation 2118 # 2119 # BACKGROUND: 2120 # Snowflake's BITMAP_CONSTRUCT_AGG aggregates integers into a compact binary bitmap. 2121 # Supports values in range 0-32767, this version returns NULL if any value is out of range 2122 # See: https://docs.snowflake.com/en/sql-reference/functions/bitmap_construct_agg 2123 # See: https://docs.snowflake.com/en/user-guide/querying-bitmaps-for-distinct-counts 2124 # 2125 # Snowflake uses two different formats based on the number of unique values: 2126 # 2127 # Format 1 - Small bitmap (< 5 unique values): Length of 10 bytes 2128 # Bytes 0-1: Count of values as 2-byte big-endian integer (e.g., 3 values = 0x0003) 2129 # Bytes 2-9: Up to 4 values, each as 2-byte little-endian integers, zero-padded to 8 bytes 2130 # Example: Values [1, 2, 3] -> 0x0003 0100 0200 0300 0000 (hex) 2131 # count v1 v2 v3 pad 2132 # 2133 # Format 2 - Large bitmap (>= 5 unique values): Length of 10 + (2 * count) bytes 2134 # Bytes 0-9: Fixed header 0x08 followed by 9 zero bytes 2135 # Bytes 10+: Each value as 2-byte little-endian integer (no padding) 2136 # Example: Values [1,2,3,4,5] -> 0x08 00000000 00000000 00 0100 0200 0300 0400 0500 2137 # hdr ----9 zero bytes---- v1 v2 v3 v4 v5 2138 # 2139 # TEMPLATE STRUCTURE 2140 # 2141 # Phase 1 - Innermost subquery: Data preparation 2142 # SELECT LIST_SORT(...) AS l 2143 # - Aggregates all input values into a list, remove NULLs, duplicates and sorts 2144 # Result: Clean, sorted list of unique non-null integers stored as 'l' 2145 # 2146 # Phase 2 - Middle subquery: Hex string construction 2147 # LIST_TRANSFORM(...) 2148 # - Converts each integer to 2-byte little-endian hex representation 2149 # - & 255 extracts low byte, >> 8 extracts high byte 2150 # - LIST_REDUCE: Concatenates all hex pairs into single string 'h' 2151 # Result: Hex string of all values 2152 # 2153 # Phase 3 - Outer SELECT: Final bitmap assembly 2154 # LENGTH(l) < 5: 2155 # - Small format: 2-byte count (big-endian via %04X) + values + zero padding 2156 # LENGTH(l) >= 5: 2157 # - Large format: Fixed 10-byte header + values (no padding needed) 2158 # Result: Complete binary bitmap as BLOB 2159 # 2160 BITMAP_CONSTRUCT_AGG_TEMPLATE: t.ClassVar[exp.Expr] = exp.maybe_parse( 2161 """ 2162 SELECT CASE 2163 WHEN l IS NULL OR LENGTH(l) = 0 THEN NULL 2164 WHEN LENGTH(l) != LENGTH(LIST_FILTER(l, __v -> __v BETWEEN 0 AND 32767)) THEN NULL 2165 WHEN LENGTH(l) < 5 THEN UNHEX(PRINTF('%04X', LENGTH(l)) || h || REPEAT('00', GREATEST(0, 4 - LENGTH(l)) * 2)) 2166 ELSE UNHEX('08000000000000000000' || h) 2167 END 2168 FROM ( 2169 SELECT l, COALESCE(LIST_REDUCE( 2170 LIST_TRANSFORM(l, __x -> PRINTF('%02X%02X', CAST(__x AS INT) & 255, (CAST(__x AS INT) >> 8) & 255)), 2171 (__a, __b) -> __a || __b, '' 2172 ), '') AS h 2173 FROM (SELECT LIST_SORT(LIST_DISTINCT(LIST(:arg) FILTER(NOT :arg IS NULL))) AS l) 2174 ) 2175 """ 2176 ) 2177 2178 # Template for RANDSTR transpilation - placeholders get replaced with actual parameters 2179 RANDSTR_TEMPLATE: t.ClassVar[exp.Expr] = exp.maybe_parse( 2180 f""" 2181 SELECT LISTAGG( 2182 SUBSTRING( 2183 '{RANDSTR_CHAR_POOL}', 2184 1 + CAST(FLOOR(random_value * 62) AS INT), 2185 1 2186 ), 2187 '' 2188 ) 2189 FROM ( 2190 SELECT (ABS(HASH(i + :seed)) % 1000) / 1000.0 AS random_value 2191 FROM RANGE(:length) AS t(i) 2192 ) 2193 """, 2194 ) 2195 2196 # Template for MINHASH transpilation 2197 # Computes k minimum hash values across aggregated data using DuckDB list functions 2198 # Returns JSON matching Snowflake format: {"state": [...], "type": "minhash", "version": 1} 2199 MINHASH_TEMPLATE: t.ClassVar[exp.Expr] = exp.maybe_parse( 2200 """ 2201 SELECT JSON_OBJECT('state', LIST(min_h ORDER BY seed), 'type', 'minhash', 'version', 1) 2202 FROM ( 2203 SELECT seed, LIST_MIN(LIST_TRANSFORM(vals, __v -> HASH(CAST(__v AS VARCHAR) || CAST(seed AS VARCHAR)))) AS min_h 2204 FROM (SELECT LIST(:expr) AS vals), RANGE(0, :k) AS t(seed) 2205 ) 2206 """, 2207 ) 2208 2209 # Template for MINHASH_COMBINE transpilation 2210 # Combines multiple minhash signatures by taking element-wise minimum 2211 MINHASH_COMBINE_TEMPLATE: t.ClassVar[exp.Expr] = exp.maybe_parse( 2212 """ 2213 SELECT JSON_OBJECT('state', LIST(min_h ORDER BY idx), 'type', 'minhash', 'version', 1) 2214 FROM ( 2215 SELECT 2216 pos AS idx, 2217 MIN(val) AS min_h 2218 FROM 2219 UNNEST(LIST(:expr)) AS _(sig), 2220 UNNEST(CAST(sig -> 'state' AS UBIGINT[])) WITH ORDINALITY AS t(val, pos) 2221 GROUP BY pos 2222 ) 2223 """, 2224 ) 2225 2226 # Template for APPROXIMATE_SIMILARITY transpilation 2227 # Computes multi-way Jaccard similarity: fraction of positions where ALL signatures agree 2228 APPROXIMATE_SIMILARITY_TEMPLATE: t.ClassVar[exp.Expr] = exp.maybe_parse( 2229 """ 2230 SELECT CAST(SUM(CASE WHEN num_distinct = 1 THEN 1 ELSE 0 END) AS DOUBLE) / COUNT(*) 2231 FROM ( 2232 SELECT pos, COUNT(DISTINCT h) AS num_distinct 2233 FROM ( 2234 SELECT h, pos 2235 FROM UNNEST(LIST(:expr)) AS _(sig), 2236 UNNEST(CAST(sig -> 'state' AS UBIGINT[])) WITH ORDINALITY AS s(h, pos) 2237 ) 2238 GROUP BY pos 2239 ) 2240 """, 2241 ) 2242 2243 # Template for ARRAYS_ZIP transpilation 2244 # Snowflake pads to longest array; DuckDB LIST_ZIP truncates to shortest 2245 # Uses RANGE + indexing to match Snowflake behavior 2246 ARRAYS_ZIP_TEMPLATE: t.ClassVar[exp.Expr] = exp.maybe_parse( 2247 """ 2248 CASE WHEN :null_check THEN NULL 2249 WHEN :all_empty_check THEN [:empty_struct] 2250 ELSE LIST_TRANSFORM(RANGE(0, :max_len), __i -> :transform_struct) 2251 END 2252 """, 2253 ) 2254 2255 UUID_V5_TEMPLATE: t.ClassVar[exp.Expr] = exp.maybe_parse( 2256 """ 2257 (SELECT 2258 LOWER( 2259 SUBSTR(h, 1, 8) || '-' || 2260 SUBSTR(h, 9, 4) || '-' || 2261 '5' || SUBSTR(h, 14, 3) || '-' || 2262 FORMAT('{:02x}', CAST('0x' || SUBSTR(h, 17, 2) AS INT) & 63 | 128) || SUBSTR(h, 19, 2) || '-' || 2263 SUBSTR(h, 21, 12) 2264 ) 2265 FROM ( 2266 SELECT SUBSTR(SHA1(UNHEX(REPLACE(:namespace, '-', '')) || ENCODE(:name, 'utf8')), 1, 32) AS h 2267 )) 2268 """ 2269 ) 2270 2271 # Shared bag semantics outer frame for ARRAY_EXCEPT and ARRAY_INTERSECTION. 2272 # Each element is paired with its 1-based position via LIST_ZIP, then filtered 2273 # by a comparison operator (supplied via :cond) that determines the operation: 2274 # EXCEPT (>): keep the N-th occurrence only if N > count in arr2 2275 # e.g. [2,2,2] EXCEPT [2,2] -> [2] 2276 # INTERSECTION (<=): keep the N-th occurrence only if N <= count in arr2 2277 # e.g. [2,2,2] INTERSECT [2,2] -> [2,2] 2278 # IS NOT DISTINCT FROM is used for NULL-safe element comparison. 2279 ARRAY_BAG_TEMPLATE: t.ClassVar[exp.Expr] = exp.maybe_parse( 2280 """ 2281 CASE 2282 WHEN :arr1 IS NULL OR :arr2 IS NULL THEN NULL 2283 ELSE LIST_TRANSFORM( 2284 LIST_FILTER( 2285 LIST_ZIP(:arr1, GENERATE_SERIES(1, LEN(:arr1))), 2286 pair -> :cond 2287 ), 2288 pair -> pair[0] 2289 ) 2290 END 2291 """ 2292 ) 2293 2294 ARRAY_EXCEPT_CONDITION: t.ClassVar[exp.Expr] = exp.maybe_parse( 2295 "LEN(LIST_FILTER(:arr1[1:pair[1]], e -> e IS NOT DISTINCT FROM pair[0]))" 2296 " > LEN(LIST_FILTER(:arr2, e -> e IS NOT DISTINCT FROM pair[0]))" 2297 ) 2298 2299 ARRAY_INTERSECTION_CONDITION: t.ClassVar[exp.Expr] = exp.maybe_parse( 2300 "LEN(LIST_FILTER(:arr1[1:pair[1]], e -> e IS NOT DISTINCT FROM pair[0]))" 2301 " <= LEN(LIST_FILTER(:arr2, e -> e IS NOT DISTINCT FROM pair[0]))" 2302 ) 2303 2304 # Set semantics for ARRAY_EXCEPT. Deduplicates arr1 via LIST_DISTINCT, then 2305 # filters out any element that appears at least once in arr2. 2306 # e.g. [1,1,2,3] EXCEPT [1] -> [2,3] 2307 # IS NOT DISTINCT FROM is used for NULL-safe element comparison. 2308 ARRAY_EXCEPT_SET_TEMPLATE: t.ClassVar[exp.Expr] = exp.maybe_parse( 2309 """ 2310 CASE 2311 WHEN :arr1 IS NULL OR :arr2 IS NULL THEN NULL 2312 ELSE LIST_FILTER( 2313 LIST_DISTINCT(:arr1), 2314 e -> LEN(LIST_FILTER(:arr2, x -> x IS NOT DISTINCT FROM e)) = 0 2315 ) 2316 END 2317 """ 2318 ) 2319 2320 # BigQuery's `x IN UNNEST(arr)` NULL semantics: 2321 # NULL IN UNNEST([1, 2]) -> NULL 2322 # 3 IN UNNEST([1, NULL]) -> NULL 2323 # 3 IN UNNEST([1, 2]) -> FALSE 2324 # 1 IN UNNEST(NULL) -> FALSE (not NULL) 2325 # 1 IN UNNEST([]) -> FALSE 2326 # The default `IN (SELECT UNNEST(...))` rewrite creates a correlated subquery 2327 # that DuckDB rejects inside non-inner joins, so a CASE expression is used instead. 2328 IN_UNNEST_TEMPLATE: t.ClassVar[exp.Expr] = exp.maybe_parse( 2329 """ 2330 CASE 2331 WHEN :arr IS NULL OR ARRAY_LENGTH(:arr) = 0 THEN FALSE 2332 WHEN ARRAY_CONTAINS(:arr, :value) THEN TRUE 2333 WHEN :value IS NULL OR ARRAY_LENGTH(:arr) <> LIST_COUNT(:arr) THEN NULL 2334 ELSE FALSE 2335 END 2336 """ 2337 ) 2338 2339 STRTOK_TO_ARRAY_TEMPLATE: t.ClassVar[exp.Expr] = exp.maybe_parse( 2340 """ 2341 CASE WHEN :delimiter IS NULL THEN NULL 2342 ELSE LIST_FILTER( 2343 REGEXP_SPLIT_TO_ARRAY(:string, CASE WHEN :delimiter = '' THEN '.^' ELSE CONCAT('[', :escaped, ']') END), 2344 x -> NOT x = '' 2345 ) END 2346 """ 2347 ) 2348 2349 # Template for STRTOK function transpilation 2350 # 2351 # DuckDB itself doesn't have a strtok function. This handles the transpilation from Snowflake to DuckDB. 2352 # We may need to adjust this if we want to support transpilation from other dialects 2353 # 2354 # CASE 2355 # -- Snowflake: empty delimiter + empty input string -> NULL 2356 # WHEN delimiter = '' AND input_str = '' THEN NULL 2357 # 2358 # -- Snowflake: empty delimiter + non-empty input string -> treats whole input as 1 token -> return input string if index is 1 2359 # WHEN delimiter = '' AND index = 1 THEN input_str 2360 # 2361 # -- Snowflake: empty delimiter + non-empty input string -> treats whole input as 1 token -> return NULL if index is not 1 2362 # WHEN delimiter = '' THEN NULL 2363 # 2364 # -- Snowflake: negative indices return NULL 2365 # WHEN index < 0 THEN NULL 2366 # 2367 # -- Snowflake: return NULL if any argument is NULL 2368 # WHEN input_str IS NULL OR delimiter IS NULL OR index IS NULL THEN NULL 2369 # 2370 # 2371 # ELSE LIST_FILTER( 2372 # REGEXP_SPLIT_TO_ARRAY( 2373 # input_str, 2374 # CASE 2375 # -- if delimiter is '', we don't want to surround it with '[' and ']' as '[]' is invalid for DuckDB 2376 # WHEN delimiter = '' THEN '' 2377 # 2378 # -- handle problematic regex characters in delimiter with REGEXP_REPLACE 2379 # -- turn delimiter into a regex char set, otherwise DuckDB will match in order, which we don't want 2380 # ELSE '[' || REGEXP_REPLACE(delimiter, problematic_char_set, '\\\1', 'g') || ']' 2381 # END 2382 # ), 2383 # 2384 # -- Snowflake: don't return empty strings 2385 # x -> NOT x = '' 2386 # )[index] 2387 # END 2388 STRTOK_TEMPLATE: t.ClassVar[exp.Expr] = exp.maybe_parse( 2389 """ 2390 CASE 2391 WHEN :delimiter = '' AND :string = '' THEN NULL 2392 WHEN :delimiter = '' AND :part_index = 1 THEN :string 2393 WHEN :delimiter = '' THEN NULL 2394 WHEN :part_index < 0 THEN NULL 2395 WHEN :string IS NULL OR :delimiter IS NULL OR :part_index IS NULL THEN NULL 2396 ELSE :base_func 2397 END 2398 """ 2399 ) 2400 2401 # Snowflake AUTO detects 3 DATE formats: YYYY-MM-DD (ISO-8601), MM/DD/YYYY, DD-MON-YYYY. 2402 # DuckDB TRY_CAST handles ISO-8601 natively. For the other two formats we use CONTAINS('/') 2403 # and REGEXP_MATCHES('[A-Za-z]') as heuristics — these correctly handle single-digit months 2404 # and days (e.g. 1/5/2020, 5-JAN-2020) where a positional char check would fail. 2405 # Ref: https://docs.snowflake.com/en/sql-reference/date-time-input-output#date-formats 2406 _TRYCAST_DATE_SLASH_FMT = "%m/%d/%Y" 2407 _TRYCAST_DATE_MON_FMT = "%d-%b-%Y" 2408 2409 def datatype_sql(self, expression: exp.DataType) -> str: 2410 if expression.is_type(exp.DType.ARRAY): 2411 return f"{self.expressions(expression, flat=True)}[{self.expressions(expression, key='values', flat=True)}]" 2412 2413 # Modifiers are not supported for TIME, [TIME | TIMESTAMP] WITH TIME ZONE 2414 if expression.is_type( 2415 exp.DType.TIME, exp.DType.TIMETZ, exp.DType.TIMESTAMPTZ, exp.DType.TIMESTAMPLTZ 2416 ): 2417 expression.set("expressions", None) 2418 2419 return super().datatype_sql(expression) 2420 2421 def _array_bag_sql(self, condition: exp.Expr, arr1: exp.Expr, arr2: exp.Expr) -> str: 2422 cond = exp.Paren(this=exp.replace_placeholders(condition, arr1=arr1, arr2=arr2)) 2423 return self.sql( 2424 exp.replace_placeholders(self.ARRAY_BAG_TEMPLATE, arr1=arr1, arr2=arr2, cond=cond) 2425 ) 2426 2427 def timeslice_sql(self, expression: exp.TimeSlice) -> str: 2428 """ 2429 Transform Snowflake's TIME_SLICE to DuckDB's time_bucket. 2430 2431 Snowflake: TIME_SLICE(date_expr, slice_length, 'UNIT' [, 'START'|'END']) 2432 DuckDB: time_bucket(INTERVAL 'slice_length' UNIT, date_expr, origin) 2433 2434 Snowflake aligns slices to the 1970-01-01 epoch, so an explicit origin is 2435 required because DuckDB's default origin differs. For 'END' kind, add the 2436 interval to get the next slice's start; for DATE input, cast back to DATE. 2437 """ 2438 date_expr = expression.this 2439 slice_length = expression.expression 2440 # Normalize unit aliases (e.g. W/WK/WOY -> WEEK) so the interval and origin agree 2441 unit = map_date_part(expression.unit, self.dialect) 2442 kind = expression.text("kind").upper() 2443 2444 interval_expr = exp.Interval(this=slice_length, unit=unit) 2445 2446 origin = expression.args.get("origin") 2447 if origin is None: 2448 # Snowflake aligns to 1970-01-01; WEEK to the Monday of the epoch week (WEEK_START=0) 2449 origin = exp.Literal.string( 2450 "1969-12-29" if unit and unit.name.upper() == "WEEK" else "1970-01-01" 2451 ) 2452 # A DATE origin keeps a DATE input a DATE; DuckDB widens it for TIMESTAMP inputs 2453 time_bucket_expr = exp.func( 2454 "time_bucket", interval_expr, date_expr, exp.cast(origin, exp.DType.DATE) 2455 ) 2456 2457 if kind != "END": 2458 return self.sql(time_bucket_expr) 2459 2460 add_expr = exp.Add(this=time_bucket_expr, expression=interval_expr.copy()) 2461 2462 # DuckDB widens DATE to TIMESTAMP when adding intervals, so cast back 2463 if date_expr.is_type(exp.DType.DATE): 2464 return self.sql(exp.cast(add_expr, exp.DType.DATE)) 2465 2466 return self.sql(add_expr) 2467 2468 def unicodestring_sql(self, expression: exp.UnicodeString) -> str: 2469 # DuckDB has no Unicode escape strings and doesn't read \u in a string, so the escapes 2470 # are decoded into the characters, e.g. U&'d\0061t\0061' and U&'d!+000061ta' UESCAPE '!' 2471 # are both 'data' 2472 escape = expression.args.get("escape") 2473 escape_char = re.escape(escape.name if isinstance(escape, exp.Expr) else "\\") 2474 pattern = re.compile( 2475 rf"{escape_char}(?:\+([0-9a-fA-F]{{6}})|([0-9a-fA-F]{{4}})|({escape_char}))" 2476 ) 2477 2478 def decode(match: re.Match[str]) -> str: 2479 if match.group(3): 2480 return match.group(3) 2481 return chr(int(match.group(1) or match.group(2), 16)) 2482 2483 # A character outside the BMP can be written as a UTF-16 surrogate pair 2484 text = ( 2485 pattern.sub(decode, expression.name).encode("utf-16", "surrogatepass").decode("utf-16") 2486 ) 2487 return self.sql(exp.Literal.string(text)) 2488 2489 def bytestring_sql(self, expression: exp.ByteString) -> str: 2490 if not expression.args.get("is_bytes"): 2491 return super().bytestring_sql(expression) 2492 2493 # Each char is a byte. Casting to BLOB decodes \xHH, so everything but printable ASCII 2494 # (including the backslash itself) is written as such 2495 text = "".join( 2496 ch if ch.isascii() and ch.isprintable() and ch != "\\" else f"\\x{ord(ch):02x}" 2497 for ch in expression.this 2498 ) 2499 return self.sql(exp.cast(exp.Literal.string(text), exp.DType.BINARY)) 2500 2501 def bitmapbucketnumber_sql(self, expression: exp.BitmapBucketNumber) -> str: 2502 """ 2503 Transpile BITMAP_BUCKET_NUMBER function from Snowflake to DuckDB equivalent. 2504 2505 Snowflake's BITMAP_BUCKET_NUMBER returns a 1-based bucket identifier where: 2506 - Each bucket covers 32,768 values 2507 - Bucket numbering starts at 1 2508 - Formula: ((value - 1) // 32768) + 1 for positive values 2509 2510 For non-positive values (0 and negative), we use value // 32768 to avoid 2511 producing bucket 0 or positive bucket IDs for negative inputs. 2512 """ 2513 value = expression.this 2514 2515 positive_formula = ((value - 1) // 32768) + 1 2516 non_positive_formula = value // 32768 2517 2518 # CASE WHEN value > 0 THEN ((value - 1) // 32768) + 1 ELSE value // 32768 END 2519 case_expr = ( 2520 exp.case() 2521 .when(exp.GT(this=value, expression=exp.Literal.number(0)), positive_formula) 2522 .else_(non_positive_formula) 2523 ) 2524 return self.sql(case_expr) 2525 2526 def bitmapbitposition_sql(self, expression: exp.BitmapBitPosition) -> str: 2527 """ 2528 Transpile Snowflake's BITMAP_BIT_POSITION to DuckDB CASE expression. 2529 2530 Snowflake's BITMAP_BIT_POSITION behavior: 2531 - For n <= 0: returns ABS(n) % 32768 2532 - For n > 0: returns (n - 1) % 32768 (maximum return value is 32767) 2533 """ 2534 this = expression.this 2535 2536 return self.sql( 2537 exp.Mod( 2538 this=exp.Paren( 2539 this=exp.If( 2540 this=exp.GT(this=this, expression=exp.Literal.number(0)), 2541 true=this - exp.Literal.number(1), 2542 false=exp.Abs(this=this), 2543 ) 2544 ), 2545 expression=MAX_BIT_POSITION, 2546 ) 2547 ) 2548 2549 def bitmapconstructagg_sql(self, expression: exp.BitmapConstructAgg) -> str: 2550 """ 2551 Transpile Snowflake's BITMAP_CONSTRUCT_AGG to DuckDB equivalent. 2552 Uses a pre-parsed template with placeholders replaced by expression nodes. 2553 2554 Snowflake bitmap format: 2555 - Small (< 5 unique values): 2-byte count (big-endian) + values (little-endian) + padding to 10 bytes 2556 - Large (>= 5 unique values): 10-byte header (0x08 + 9 zeros) + values (little-endian) 2557 """ 2558 arg = expression.this 2559 return ( 2560 f"({self.sql(exp.replace_placeholders(self.BITMAP_CONSTRUCT_AGG_TEMPLATE, arg=arg))})" 2561 ) 2562 2563 def getignorecase_sql(self, expression: exp.GetIgnoreCase) -> str: 2564 self.unsupported("DuckDB does not support the GET_IGNORE_CASE() function") 2565 return self.function_fallback_sql(expression) 2566 2567 def compress_sql(self, expression: exp.Compress) -> str: 2568 self.unsupported("DuckDB does not support the COMPRESS() function") 2569 return self.function_fallback_sql(expression) 2570 2571 def encrypt_sql(self, expression: exp.Encrypt) -> str: 2572 self.unsupported("ENCRYPT is not supported in DuckDB") 2573 return self.function_fallback_sql(expression) 2574 2575 def decrypt_sql(self, expression: exp.Decrypt) -> str: 2576 func_name = "TRY_DECRYPT" if expression.args.get("safe") else "DECRYPT" 2577 self.unsupported(f"{func_name} is not supported in DuckDB") 2578 return self.function_fallback_sql(expression) 2579 2580 def decryptraw_sql(self, expression: exp.DecryptRaw) -> str: 2581 func_name = "TRY_DECRYPT_RAW" if expression.args.get("safe") else "DECRYPT_RAW" 2582 self.unsupported(f"{func_name} is not supported in DuckDB") 2583 return self.function_fallback_sql(expression) 2584 2585 def encryptraw_sql(self, expression: exp.EncryptRaw) -> str: 2586 self.unsupported("ENCRYPT_RAW is not supported in DuckDB") 2587 return self.function_fallback_sql(expression) 2588 2589 def parseurl_sql(self, expression: exp.ParseUrl) -> str: 2590 self.unsupported("PARSE_URL is not supported in DuckDB") 2591 return self.function_fallback_sql(expression) 2592 2593 def parseip_sql(self, expression: exp.ParseIp) -> str: 2594 self.unsupported("PARSE_IP is not supported in DuckDB") 2595 return self.function_fallback_sql(expression) 2596 2597 def decompressstring_sql(self, expression: exp.DecompressString) -> str: 2598 self.unsupported("DECOMPRESS_STRING is not supported in DuckDB") 2599 return self.function_fallback_sql(expression) 2600 2601 def decompressbinary_sql(self, expression: exp.DecompressBinary) -> str: 2602 self.unsupported("DECOMPRESS_BINARY is not supported in DuckDB") 2603 return self.function_fallback_sql(expression) 2604 2605 def jarowinklersimilarity_sql(self, expression: exp.JarowinklerSimilarity) -> str: 2606 this = expression.this 2607 expr = expression.expression 2608 2609 if expression.args.get("case_insensitive"): 2610 this = exp.Upper(this=this) 2611 expr = exp.Upper(this=expr) 2612 2613 result = exp.func("JARO_WINKLER_SIMILARITY", this, expr) 2614 2615 if expression.args.get("integer_scale"): 2616 result = exp.cast(result * 100, "INTEGER") 2617 2618 return self.sql(result) 2619 2620 def randstr_sql(self, expression: exp.Randstr) -> str: 2621 """ 2622 Transpile Snowflake's RANDSTR to DuckDB equivalent using deterministic hash-based random. 2623 Uses a pre-parsed template with placeholders replaced by expression nodes. 2624 2625 RANDSTR(length, generator) generates a random string of specified length. 2626 - With numeric seed: Use HASH(i + seed) for deterministic output (same seed = same result) 2627 - With RANDOM(): Use RANDOM() in the hash for non-deterministic output 2628 - No generator: Use default seed value 2629 """ 2630 length = expression.this 2631 generator = expression.args.get("generator") 2632 2633 if generator: 2634 if isinstance(generator, exp.Rand): 2635 # If it's RANDOM(), use its seed if available, otherwise use RANDOM() itself 2636 seed_value = generator.this or generator 2637 else: 2638 # Const/int or other expression - use as seed directly 2639 seed_value = generator 2640 else: 2641 # No generator specified, use default seed (arbitrary but deterministic) 2642 seed_value = exp.Literal.number(RANDSTR_SEED) 2643 2644 replacements = {"seed": seed_value, "length": length} 2645 return f"({self.sql(exp.replace_placeholders(self.RANDSTR_TEMPLATE, **replacements))})" 2646 2647 @unsupported_args("finish") 2648 def reduce_sql(self, expression: exp.Reduce) -> str: 2649 array_arg = expression.this 2650 initial_value = expression.args.get("initial") 2651 merge_lambda = expression.args.get("merge") 2652 2653 return self.func("list_reduce", array_arg, merge_lambda, initial_value) 2654 2655 def zipf_sql(self, expression: exp.Zipf) -> str: 2656 """ 2657 Transpile Snowflake's ZIPF to DuckDB using CDF-based inverse sampling. 2658 Uses a pre-parsed template with placeholders replaced by expression nodes. 2659 """ 2660 s = expression.this 2661 n = expression.args["elementcount"] 2662 gen = expression.args["gen"] 2663 2664 if not isinstance(gen, exp.Rand): 2665 # (ABS(HASH(seed)) % 1000000) / 1000000.0 2666 random_expr: exp.Expr = exp.Div( 2667 this=exp.Paren( 2668 this=exp.Mod( 2669 this=exp.Abs(this=exp.Anonymous(this="HASH", expressions=[gen.copy()])), 2670 expression=exp.Literal.number(1000000), 2671 ) 2672 ), 2673 expression=exp.Literal.number(1000000.0), 2674 ) 2675 else: 2676 # Use RANDOM() for non-deterministic output 2677 random_expr = exp.Rand() 2678 2679 replacements = {"s": s, "n": n, "random_expr": random_expr} 2680 return f"({self.sql(exp.replace_placeholders(self.ZIPF_TEMPLATE, **replacements))})" 2681 2682 def tobinary_sql(self, expression: exp.ToBinary) -> str: 2683 """ 2684 TO_BINARY and TRY_TO_BINARY transpilation: 2685 - 'HEX': TO_BINARY('48454C50', 'HEX') -> UNHEX('48454C50') 2686 - 'UTF-8': TO_BINARY('TEST', 'UTF-8') -> ENCODE('TEST') 2687 - 'BASE64': TO_BINARY('SEVMUA==', 'BASE64') -> FROM_BASE64('SEVMUA==') 2688 2689 For TRY_TO_BINARY (safe=True), wrap with TRY(): 2690 - 'HEX': TRY_TO_BINARY('invalid', 'HEX') -> TRY(UNHEX('invalid')) 2691 """ 2692 value = expression.this 2693 format_arg = expression.args.get("format") 2694 is_safe = expression.args.get("safe") 2695 is_binary = _is_binary(expression) 2696 2697 if not format_arg and not is_binary: 2698 func_name = "TRY_TO_BINARY" if is_safe else "TO_BINARY" 2699 return self.func(func_name, value) 2700 2701 # Snowflake defaults to HEX encoding when no format is specified 2702 fmt = format_arg.name.upper() if format_arg else "HEX" 2703 2704 if fmt in ("UTF-8", "UTF8"): 2705 # DuckDB ENCODE always uses UTF-8, no charset parameter needed 2706 result = self.func("ENCODE", value) 2707 elif fmt == "BASE64": 2708 result = self.func("FROM_BASE64", value) 2709 elif fmt == "HEX": 2710 result = self.func("UNHEX", value) 2711 else: 2712 if is_safe: 2713 return self.sql(exp.null()) 2714 else: 2715 self.unsupported(f"format {fmt} is not supported") 2716 result = self.func("TO_BINARY", value) 2717 return f"TRY({result})" if is_safe else result 2718 2719 def tonumber_sql(self, expression: exp.ToNumber) -> str: 2720 fmt = expression.args.get("format") 2721 precision = expression.args.get("precision") 2722 scale = expression.args.get("scale") 2723 2724 if not fmt and precision and scale: 2725 to = exp.DataType.build(f"DECIMAL({precision.name}, {scale.name})", dialect="duckdb") 2726 if expression.args.get("safe"): 2727 return self.sql(exp.TryCast(this=expression.this, to=to)) 2728 return self.sql(exp.cast(expression.this, to, dialect="duckdb")) 2729 2730 return super().tonumber_sql(expression) 2731 2732 def _greatest_least_sql(self, expression: exp.Greatest | exp.Least) -> str: 2733 """ 2734 Handle GREATEST/LEAST functions with dialect-aware NULL behavior. 2735 2736 - If ignore_nulls=False (BigQuery-style): return NULL if any argument is NULL 2737 - If ignore_nulls=True (DuckDB/PostgreSQL-style): ignore NULLs, return greatest/least non-NULL value 2738 """ 2739 # Get all arguments 2740 all_args = [expression.this, *expression.expressions] 2741 fallback_sql = self.function_fallback_sql(expression) 2742 2743 if expression.args.get("ignore_nulls"): 2744 # DuckDB/PostgreSQL behavior: use native GREATEST/LEAST (ignores NULLs) 2745 return self.sql(fallback_sql) 2746 2747 # return NULL if any argument is NULL 2748 case_expr = exp.case().when( 2749 exp.or_(*[arg.is_(exp.null()) for arg in all_args], copy=False), 2750 exp.null(), 2751 copy=False, 2752 ) 2753 case_expr.set("default", fallback_sql) 2754 return self.sql(case_expr) 2755 2756 def generator_sql(self, expression: exp.Generator) -> str: 2757 # Transpile Snowflake GENERATOR to DuckDB range() 2758 rowcount = expression.args.get("rowcount") 2759 time_limit = expression.args.get("time_limit") 2760 2761 if time_limit: 2762 self.unsupported("GENERATOR TIMELIMIT parameter is not supported in DuckDB") 2763 2764 if not rowcount: 2765 self.unsupported("GENERATOR without ROWCOUNT is not supported in DuckDB") 2766 return self.func("range", exp.Literal.number(0)) 2767 2768 return self.func("range", rowcount) 2769 2770 def greatest_sql(self, expression: exp.Greatest) -> str: 2771 return self._greatest_least_sql(expression) 2772 2773 def least_sql(self, expression: exp.Least) -> str: 2774 return self._greatest_least_sql(expression) 2775 2776 def lambda_sql(self, expression: exp.Lambda, arrow_sep: str = "->", wrap: bool = True) -> str: 2777 if self.dialect.version >= (1, 3): 2778 prefix = "LAMBDA " 2779 arrow_sep = ":" 2780 wrap = False 2781 else: 2782 prefix = "" 2783 2784 lambda_sql = super().lambda_sql(expression, arrow_sep=arrow_sep, wrap=wrap) 2785 return f"{prefix}{lambda_sql}" 2786 2787 def show_sql(self, expression: exp.Show) -> str: 2788 from_ = self.sql(expression, "from_") 2789 from_ = f" FROM {from_}" if from_ else "" 2790 return f"SHOW {expression.name}{from_}" 2791 2792 def soundex_sql(self, expression: exp.Soundex) -> str: 2793 self.unsupported("SOUNDEX is not supported in DuckDB") 2794 return self.func("SOUNDEX", expression.this) 2795 2796 def placeholder_sql(self, expression: exp.Placeholder) -> str: 2797 if isinstance(expression.this, exp.Identifier): 2798 return f"${self.sql(expression, 'this')}" 2799 return super().placeholder_sql(expression) 2800 2801 def install_sql(self, expression: exp.Install) -> str: 2802 force = "FORCE " if expression.args.get("force") else "" 2803 this = self.sql(expression, "this") 2804 from_clause = expression.args.get("from_") 2805 from_clause = f" FROM {from_clause}" if from_clause else "" 2806 return f"{force}INSTALL {this}{from_clause}" 2807 2808 def approxtopk_sql(self, expression: exp.ApproxTopK) -> str: 2809 self.unsupported( 2810 "APPROX_TOP_K cannot be transpiled to DuckDB due to incompatible return types. " 2811 ) 2812 return self.function_fallback_sql(expression) 2813 2814 def strposition_sql(self, expression: exp.StrPosition) -> str: 2815 this = expression.this 2816 substr = expression.args.get("substr") 2817 position = expression.args.get("position") 2818 2819 # For BINARY/BLOB: DuckDB's STRPOS doesn't support BLOB types 2820 # Convert to HEX strings, use STRPOS, then convert hex position to byte position 2821 if _is_binary(this): 2822 # Build expression: STRPOS(HEX(haystack), HEX(needle)) 2823 hex_strpos = exp.StrPosition( 2824 this=exp.Hex(this=this), 2825 substr=exp.Hex(this=substr), 2826 ) 2827 2828 return self.sql(exp.cast((hex_strpos + 1) / 2, exp.DType.INT)) 2829 2830 # For VARCHAR: handle clamp_position 2831 if expression.args.get("clamp_position") and position: 2832 expression = expression.copy() 2833 expression.set( 2834 "position", 2835 exp.If( 2836 this=exp.LTE(this=position, expression=exp.Literal.number(0)), 2837 true=exp.Literal.number(1), 2838 false=position.copy(), 2839 ), 2840 ) 2841 2842 return strposition_sql(self, expression) 2843 2844 def substring_sql(self, expression: exp.Substring) -> str: 2845 if expression.args.get("zero_start"): 2846 start = expression.args.get("start") 2847 length = expression.args.get("length") 2848 2849 if start := expression.args.get("start"): 2850 start = exp.If(this=start.eq(0), true=exp.Literal.number(1), false=start) 2851 if length := expression.args.get("length"): 2852 length = exp.If(this=length < 0, true=exp.Literal.number(0), false=length) 2853 2854 return self.func("SUBSTRING", expression.this, start, length) 2855 2856 return self.function_fallback_sql(expression) 2857 2858 def strtotime_sql(self, expression: exp.StrToTime) -> str: 2859 # Check if target_type requires TIMESTAMPTZ (for LTZ/TZ variants) 2860 target_type = expression.args.get("target_type") 2861 needs_tz = target_type and target_type.this in ( 2862 exp.DType.TIMESTAMPLTZ, 2863 exp.DType.TIMESTAMPTZ, 2864 ) 2865 2866 value, formatted_time = self._strptime_default_year(expression) 2867 2868 if expression.args.get("safe"): 2869 cast_type = exp.DType.TIMESTAMPTZ if needs_tz else exp.DType.TIMESTAMP 2870 return self.sql(exp.cast(self.func("TRY_STRPTIME", value, formatted_time), cast_type)) 2871 2872 base_sql = self.func("STRPTIME", value, formatted_time) 2873 if needs_tz: 2874 return self.sql( 2875 exp.cast( 2876 base_sql, 2877 exp.DataType(this=exp.DType.TIMESTAMPTZ), 2878 ) 2879 ) 2880 return base_sql 2881 2882 def strtodate_sql(self, expression: exp.StrToDate) -> str: 2883 value, formatted_time = self._strptime_default_year(expression) 2884 function_name = "STRPTIME" if not expression.args.get("safe") else "TRY_STRPTIME" 2885 return self.sql( 2886 exp.cast( 2887 self.func(function_name, value, formatted_time), 2888 exp.DataType(this=exp.DType.DATE), 2889 ) 2890 ) 2891 2892 def _strptime_default_year( 2893 self, expression: exp.StrToTime | exp.StrToDate | exp.ParseDatetime 2894 ) -> tuple[exp.ExpOrStr, exp.ExpOrStr | None]: 2895 value: exp.ExpOrStr = expression.this 2896 formatted_time: exp.ExpOrStr | None = self.format_time(expression) 2897 2898 if default_year := expression.args.get("default_year"): 2899 value = exp.DPipe(this=exp.Literal.string(f"{default_year.name} "), expression=value) 2900 formatted_time = exp.DPipe(this=exp.Literal.string("%Y "), expression=formatted_time) 2901 2902 return value, formatted_time 2903 2904 def parsedatetime_sql(self, expression: exp.ParseDatetime) -> str: 2905 value, formatted_time = self._strptime_default_year(expression) 2906 return self.func("STRPTIME", value, formatted_time) 2907 2908 def parsetime_sql(self, expression: exp.ParseTime) -> str: 2909 formatted_time = self.format_time(expression) 2910 return self.sql( 2911 exp.cast( 2912 self.func("STRPTIME", expression.this, formatted_time), 2913 exp.DataType(this=exp.DType.TIME), 2914 ) 2915 ) 2916 2917 def tsordstotime_sql(self, expression: exp.TsOrDsToTime) -> str: 2918 this = expression.this 2919 time_format = self.format_time(expression) 2920 safe = expression.args.get("safe") 2921 time_type = exp.DataType.from_str("TIME", dialect="duckdb") 2922 cast_expr = exp.TryCast if safe else exp.Cast 2923 2924 if time_format: 2925 func_name = "TRY_STRPTIME" if safe else "STRPTIME" 2926 strptime = exp.Anonymous(this=func_name, expressions=[this, time_format]) 2927 return self.sql(cast_expr(this=strptime, to=time_type)) 2928 2929 if isinstance(this, exp.TsOrDsToTime) or this.is_type(exp.DType.TIME): 2930 return self.sql(this) 2931 2932 return self.sql(cast_expr(this=this, to=time_type)) 2933 2934 def currentdate_sql(self, expression: exp.CurrentDate) -> str: 2935 if not expression.this: 2936 return "CURRENT_DATE" 2937 2938 expr = exp.Cast( 2939 this=exp.AtTimeZone(this=exp.CurrentTimestamp(), zone=expression.this), 2940 to=exp.DataType(this=exp.DType.DATE), 2941 ) 2942 return self.sql(expr) 2943 2944 def checkjson_sql(self, expression: exp.CheckJson) -> str: 2945 arg = expression.this 2946 return self.sql( 2947 exp.case() 2948 .when( 2949 exp.or_(arg.is_(exp.Null()), arg.eq(""), exp.func("json_valid", arg)), 2950 exp.null(), 2951 ) 2952 .else_(exp.Literal.string("Invalid JSON")) 2953 ) 2954 2955 def parsejson_sql(self, expression: exp.ParseJSON) -> str: 2956 arg = expression.this 2957 if expression.args.get("safe"): 2958 return self.sql( 2959 exp.case() 2960 .when(exp.func("json_valid", arg), exp.cast(arg.copy(), "JSON")) 2961 .else_(exp.null()) 2962 ) 2963 return self.func("JSON", arg) 2964 2965 def unicode_sql(self, expression: exp.Unicode) -> str: 2966 if expression.args.get("empty_is_zero"): 2967 return self.sql( 2968 exp.case() 2969 .when(expression.this.eq(exp.Literal.string("")), exp.Literal.number(0)) 2970 .else_(exp.Anonymous(this="UNICODE", expressions=[expression.this])) 2971 ) 2972 2973 return self.func("UNICODE", expression.this) 2974 2975 def stripnullvalue_sql(self, expression: exp.StripNullValue) -> str: 2976 return self.sql( 2977 exp.case() 2978 .when(exp.func("json_type", expression.this).eq("NULL"), exp.null()) 2979 .else_(expression.this) 2980 ) 2981 2982 def trunc_sql(self, expression: exp.Trunc) -> str: 2983 decimals = expression.args.get("decimals") 2984 if ( 2985 expression.args.get("fractions_supported") 2986 and decimals 2987 and not decimals.is_type(exp.DType.INT) 2988 ): 2989 decimals = exp.cast(decimals, exp.DType.INT, dialect="duckdb") 2990 2991 return self.func("TRUNC", expression.this, decimals) 2992 2993 def normal_sql(self, expression: exp.Normal) -> str: 2994 """ 2995 Transpile Snowflake's NORMAL(mean, stddev, gen) to DuckDB. 2996 2997 Uses the Box-Muller transform via NORMAL_TEMPLATE. 2998 """ 2999 mean = expression.this 3000 stddev = expression.args["stddev"] 3001 gen: exp.Expr = expression.args["gen"] 3002 3003 # Build two uniform random values [0, 1) for Box-Muller transform 3004 if isinstance(gen, exp.Rand) and gen.this is None: 3005 u1: exp.Expr = exp.Rand() 3006 u2: exp.Expr = exp.Rand() 3007 else: 3008 # Seeded: derive two values using HASH with different inputs 3009 seed = gen.this if isinstance(gen, exp.Rand) else gen 3010 u1 = exp.replace_placeholders(self.SEEDED_RANDOM_TEMPLATE, seed=seed) 3011 u2 = exp.replace_placeholders( 3012 self.SEEDED_RANDOM_TEMPLATE, 3013 seed=exp.Add(this=seed.copy(), expression=exp.Literal.number(1)), 3014 ) 3015 3016 replacements = {"mean": mean, "stddev": stddev, "u1": u1, "u2": u2} 3017 return self.sql(exp.replace_placeholders(self.NORMAL_TEMPLATE, **replacements)) 3018 3019 def uniform_sql(self, expression: exp.Uniform) -> str: 3020 """ 3021 Transpile Snowflake's UNIFORM(min, max, gen) to DuckDB. 3022 3023 UNIFORM returns a random value in [min, max]: 3024 - Integer result if both min and max are integers 3025 - Float result if either min or max is a float 3026 """ 3027 min_val = expression.this 3028 max_val = expression.expression 3029 gen = expression.args.get("gen") or expression.args.get("seed") or exp.Rand() 3030 3031 # Determine if result should be integer (both bounds are integers). 3032 # We do this to emulate Snowflake's behavior, INT -> INT, FLOAT -> FLOAT 3033 is_int_result = min_val.is_int and max_val.is_int 3034 3035 # Build the random value expression [0, 1) 3036 if not isinstance(gen, exp.Rand): 3037 # Seed value: (ABS(HASH(seed)) % 1000000) / 1000000.0 3038 random_expr: exp.Expr = exp.Div( 3039 this=exp.Paren( 3040 this=exp.Mod( 3041 this=exp.Abs(this=exp.Anonymous(this="HASH", expressions=[gen])), 3042 expression=exp.Literal.number(1000000), 3043 ) 3044 ), 3045 expression=exp.Literal.number(1000000.0), 3046 ) 3047 else: 3048 random_expr = exp.Rand() 3049 3050 # Build: min + random * (max - min [+ 1 for int]) 3051 range_expr: exp.Expr = exp.Sub(this=max_val, expression=min_val) 3052 if is_int_result: 3053 range_expr = exp.Add(this=range_expr, expression=exp.Literal.number(1)) 3054 3055 result: exp.Expr = exp.Add( 3056 this=min_val, 3057 expression=exp.Mul(this=random_expr, expression=exp.Paren(this=range_expr)), 3058 ) 3059 3060 if is_int_result: 3061 result = exp.Cast(this=exp.Floor(this=result), to=exp.DType.BIGINT.into_expr()) 3062 3063 return self.sql(result) 3064 3065 def timefromparts_sql(self, expression: exp.TimeFromParts) -> str: 3066 nano = expression.args.get("nano") 3067 overflow = expression.args.get("overflow") 3068 3069 # Snowflake's TIME_FROM_PARTS supports overflow 3070 if overflow: 3071 hour = expression.args["hour"] 3072 minute = expression.args["min"] 3073 sec = expression.args["sec"] 3074 3075 # Check if values are within normal ranges - use MAKE_TIME for efficiency 3076 if not nano and all(arg.is_int for arg in [hour, minute, sec]): 3077 try: 3078 h_val = hour.to_py() 3079 m_val = minute.to_py() 3080 s_val = sec.to_py() 3081 if 0 <= h_val <= 23 and 0 <= m_val <= 59 and 0 <= s_val <= 59: 3082 return rename_func("MAKE_TIME")(self, expression) 3083 except ValueError: 3084 pass 3085 3086 # Overflow or nanoseconds detected - use INTERVAL arithmetic 3087 if nano: 3088 sec = sec + nano.pop() / exp.Literal.number(1000000000.0) 3089 3090 total_seconds = hour * exp.Literal.number(3600) + minute * exp.Literal.number(60) + sec 3091 3092 return self.sql( 3093 exp.Add( 3094 this=exp.Cast( 3095 this=exp.Literal.string("00:00:00"), to=exp.DType.TIME.into_expr() 3096 ), 3097 expression=exp.Interval(this=total_seconds, unit=exp.var("SECOND")), 3098 ) 3099 ) 3100 3101 # Default: MAKE_TIME 3102 if nano: 3103 expression.set( 3104 "sec", expression.args["sec"] + nano.pop() / exp.Literal.number(1000000000.0) 3105 ) 3106 3107 return rename_func("MAKE_TIME")(self, expression) 3108 3109 def extract_sql(self, expression: exp.Extract) -> str: 3110 """ 3111 Transpile EXTRACT/DATE_PART for DuckDB, handling specifiers not natively supported. 3112 3113 DuckDB doesn't support: WEEKISO, YEAROFWEEK, YEAROFWEEKISO, NANOSECOND, 3114 EPOCH_SECOND (as integer), EPOCH_MILLISECOND, EPOCH_MICROSECOND, EPOCH_NANOSECOND 3115 """ 3116 this = expression.this 3117 datetime_expr = expression.expression 3118 3119 # TIMESTAMPTZ extractions may produce different results between Snowflake and DuckDB 3120 # because Snowflake applies server timezone while DuckDB uses local timezone 3121 if datetime_expr.is_type(exp.DType.TIMESTAMPTZ, exp.DType.TIMESTAMPLTZ): 3122 self.unsupported( 3123 "EXTRACT from TIMESTAMPTZ / TIMESTAMPLTZ may produce different results due to timezone handling differences" 3124 ) 3125 3126 part_name = this.name.upper() 3127 3128 if part_name in self.EXTRACT_STRFTIME_MAPPINGS: 3129 fmt, cast_type = self.EXTRACT_STRFTIME_MAPPINGS[part_name] 3130 3131 # Problem: strftime doesn't accept TIME and there's no NANOSECOND function 3132 # So, for NANOSECOND with TIME, fallback to MICROSECOND * 1000 3133 is_nano_time = part_name == "NANOSECOND" and datetime_expr.is_type( 3134 exp.DType.TIME, exp.DType.TIMETZ 3135 ) 3136 3137 if is_nano_time: 3138 self.unsupported("Parameter NANOSECOND is not supported with TIME type in DuckDB") 3139 return self.sql( 3140 exp.cast( 3141 exp.Mul( 3142 this=exp.Extract(this=exp.var("MICROSECOND"), expression=datetime_expr), 3143 expression=exp.Literal.number(1000), 3144 ), 3145 exp.DataType.from_str(cast_type, dialect="duckdb"), 3146 ) 3147 ) 3148 3149 # For NANOSECOND, cast to TIMESTAMP_NS to preserve nanosecond precision 3150 strftime_input = datetime_expr 3151 if part_name == "NANOSECOND": 3152 strftime_input = exp.cast(datetime_expr, exp.DType.TIMESTAMP_NS) 3153 3154 return self.sql( 3155 exp.cast( 3156 exp.Anonymous( 3157 this="STRFTIME", 3158 expressions=[strftime_input, exp.Literal.string(fmt)], 3159 ), 3160 exp.DataType.from_str(cast_type, dialect="duckdb"), 3161 ) 3162 ) 3163 3164 if part_name in self.EXTRACT_EPOCH_MAPPINGS: 3165 func_name = self.EXTRACT_EPOCH_MAPPINGS[part_name] 3166 result: exp.Expr = exp.Anonymous(this=func_name, expressions=[datetime_expr]) 3167 # EPOCH returns float, cast to BIGINT for integer result 3168 if part_name == "EPOCH_SECOND": 3169 result = exp.cast(result, exp.DataType.from_str("BIGINT", dialect="duckdb")) 3170 return self.sql(result) 3171 3172 return super().extract_sql(expression) 3173 3174 def timestampfromparts_sql(self, expression: exp.TimestampFromParts) -> str: 3175 # Check if this is the date/time expression form: TIMESTAMP_FROM_PARTS(date_expr, time_expr) 3176 date_expr = expression.this 3177 time_expr = expression.expression 3178 3179 if date_expr is not None and time_expr is not None: 3180 # In DuckDB, DATE + TIME produces TIMESTAMP 3181 return self.sql(exp.Add(this=date_expr, expression=time_expr)) 3182 3183 # Component-based form: TIMESTAMP_FROM_PARTS(year, month, day, hour, minute, second, ...) 3184 sec = expression.args.get("sec") 3185 if sec is None: 3186 # This shouldn't happen with valid input, but handle gracefully 3187 return rename_func("MAKE_TIMESTAMP")(self, expression) 3188 3189 milli = expression.args.get("milli") 3190 if milli is not None: 3191 sec += milli.pop() / exp.Literal.number(1000.0) 3192 3193 nano = expression.args.get("nano") 3194 if nano is not None: 3195 sec += nano.pop() / exp.Literal.number(1000000000.0) 3196 3197 if milli or nano: 3198 expression.set("sec", sec) 3199 3200 if zone := expression.args.get("zone"): 3201 return self.sql(exp.AtTimeZone(this=expression, zone=zone.pop())) 3202 3203 return rename_func("MAKE_TIMESTAMP")(self, expression) 3204 3205 @unsupported_args("nano") 3206 def timestampltzfromparts_sql(self, expression: exp.TimestampLtzFromParts) -> str: 3207 # Pop nano so rename_func only passes args that MAKE_TIMESTAMP accepts 3208 if nano := expression.args.get("nano"): 3209 nano.pop() 3210 3211 timestamp = rename_func("MAKE_TIMESTAMP")(self, expression) 3212 return f"CAST({timestamp} AS TIMESTAMPTZ)" 3213 3214 @unsupported_args("nano") 3215 def timestamptzfromparts_sql(self, expression: exp.TimestampTzFromParts) -> str: 3216 # Extract zone before popping 3217 zone = expression.args.get("zone") 3218 # Pop zone and nano so rename_func only passes args that MAKE_TIMESTAMP accepts 3219 if zone: 3220 zone = zone.pop() 3221 3222 if nano := expression.args.get("nano"): 3223 nano.pop() 3224 3225 timestamp = rename_func("MAKE_TIMESTAMP")(self, expression) 3226 3227 if zone: 3228 # Use AT TIME ZONE to apply the explicit timezone 3229 return f"{timestamp} AT TIME ZONE {self.sql(zone)}" 3230 3231 return timestamp 3232 3233 def after_limit_modifiers(self, expression: exp.Expr) -> list[str]: 3234 locks = self.expressions(expression, key="locks", sep=" ") 3235 return [f" {locks}" if locks else ""] 3236 3237 def tablesample_sql( 3238 self, 3239 expression: exp.TableSample, 3240 tablesample_keyword: str | None = None, 3241 ) -> str: 3242 if not isinstance(expression.parent, exp.Select): 3243 # This sample clause only applies to a single source, not the entire resulting relation 3244 tablesample_keyword = "TABLESAMPLE" 3245 3246 if expression.args.get("size"): 3247 method = expression.args.get("method") 3248 if method and method.name.upper() != "RESERVOIR": 3249 self.unsupported( 3250 f"Sampling method {method} is not supported with a discrete sample count, " 3251 "defaulting to reservoir sampling" 3252 ) 3253 expression.set("method", exp.var("RESERVOIR")) 3254 3255 return super().tablesample_sql(expression, tablesample_keyword=tablesample_keyword) 3256 3257 def in_sql(self, expression: exp.In) -> str: 3258 unnest = expression.args.get("unnest") 3259 if unnest: 3260 return self.sql( 3261 exp.replace_placeholders( 3262 self.IN_UNNEST_TEMPLATE, arr=unnest.expressions[0], value=expression.this 3263 ) 3264 ) 3265 return super().in_sql(expression) 3266 3267 def interval_sql(self, expression: exp.Interval) -> str: 3268 # DuckDB truncates INTERVAL '1.5' DAY to 1 day, but keeps the fraction in INTERVAL '1.5 DAY' 3269 this = expression.this 3270 unit = expression.unit 3271 if expression.args.get("fraction_in_string") and this and isinstance(unit, exp.Var): 3272 return f"INTERVAL '{this.name} {self.sql(unit)}'" 3273 3274 return super().interval_sql(expression) 3275 3276 def join_sql(self, expression: exp.Join, join_op: str | None = None) -> str: 3277 if expression.kind in ("MARK", "SINGLE") or ( 3278 expression.side == "RIGHT" and expression.is_semi_or_anti_join 3279 ): 3280 kind = "_".join(part for part in (expression.side, expression.kind) if part) 3281 return super().join_sql(expression, join_op=f"JOIN BY (TYPE {kind})") 3282 3283 if ( 3284 not expression.args.get("using") 3285 and not expression.args.get("on") 3286 and not expression.args.get("nearest") 3287 and not expression.method 3288 and (expression.kind in ("", "INNER", "OUTER")) 3289 ): 3290 # Some dialects support `LEFT/INNER JOIN UNNEST(...)` without an explicit ON clause 3291 # DuckDB doesn't, but we can just add a dummy ON clause that is always true 3292 if isinstance(expression.this, exp.Unnest): 3293 return super().join_sql(expression.on(exp.true())) 3294 3295 expression.set("side", None) 3296 expression.set("kind", None) 3297 3298 return super().join_sql(expression, join_op=join_op) 3299 3300 def countif_sql(self, expression: exp.CountIf) -> str: 3301 if self.dialect.version >= (1, 2): 3302 this = expression.this 3303 if expression.args.get("zero_on_all_null") and not isinstance(this, exp.Distinct): 3304 # DuckDB >= 1.2's COUNT_IF returns NULL when the condition is NULL on all rows, 3305 # so we wrap the condition in IS TRUE to preserve count-like semantics 3306 expression = exp.CountIf(this=exp.paren(this).is_(exp.true())) 3307 return self.function_fallback_sql(expression) 3308 3309 # https://github.com/tobymao/sqlglot/pull/4749 3310 return count_if_to_sum(self, expression) 3311 3312 def bracket_sql(self, expression: exp.Bracket) -> str: 3313 if self.dialect.version >= (1, 2): 3314 return super().bracket_sql(expression) 3315 3316 # https://duckdb.org/2025/02/05/announcing-duckdb-120.html#breaking-changes 3317 this = expression.this 3318 if isinstance(this, exp.Array): 3319 this.replace(exp.paren(this)) 3320 3321 bracket = super().bracket_sql(expression) 3322 3323 if not expression.args.get("returns_list_for_maps"): 3324 if not this.type: 3325 from sqlglot.optimizer.annotate_types import annotate_types 3326 3327 this = annotate_types(this, dialect=self.dialect) 3328 3329 if this.is_type(exp.DType.MAP): 3330 bracket = f"({bracket})[1]" 3331 3332 return bracket 3333 3334 def withingroup_sql(self, expression: exp.WithinGroup) -> str: 3335 func = expression.this 3336 3337 # For ARRAY_AGG, DuckDB requires ORDER BY inside the function, not in WITHIN GROUP 3338 # Transform: ARRAY_AGG(x) WITHIN GROUP (ORDER BY y) -> ARRAY_AGG(x ORDER BY y) 3339 if isinstance(func, exp.ArrayAgg): 3340 if not isinstance(order := expression.expression, exp.Order): 3341 return self.sql(func) 3342 3343 # Save the original column for FILTER clause (before wrapping with Order) 3344 original_this = func.this 3345 3346 # Move ORDER BY inside ARRAY_AGG by wrapping its argument with Order 3347 # ArrayAgg.this should become Order(this=ArrayAgg.this, expressions=order.expressions) 3348 func.set( 3349 "this", 3350 exp.Order( 3351 this=func.this.copy(), 3352 expressions=order.expressions, 3353 ), 3354 ) 3355 3356 # Generate the ARRAY_AGG function with ORDER BY and add FILTER clause if needed 3357 # Use original_this (not the Order-wrapped version) for the FILTER condition 3358 array_agg_sql = self.function_fallback_sql(func) 3359 return self._add_arrayagg_null_filter(array_agg_sql, func, original_this) 3360 3361 # For other functions (like PERCENTILES), use existing logic 3362 expression_sql = self.sql(expression, "expression") 3363 3364 if isinstance(func, exp.PERCENTILES): 3365 # Make the order key the first arg and slide the fraction to the right 3366 # https://duckdb.org/docs/sql/aggregates#ordered-set-aggregate-functions 3367 order_col = expression.find(exp.Ordered) 3368 if order_col: 3369 func.set("expression", func.this) 3370 func.set("this", order_col.this) 3371 3372 this = self.sql(expression, "this").rstrip(")") 3373 3374 return f"{this}{expression_sql})" 3375 3376 def length_sql(self, expression: exp.Length) -> str: 3377 arg = expression.this 3378 3379 # Dialects like BQ and Snowflake also accept binary values as args, so 3380 # DDB will attempt to infer the type or resort to case/when resolution 3381 if not expression.args.get("binary") or arg.is_string: 3382 return self.func("LENGTH", arg) 3383 3384 if not arg.type: 3385 from sqlglot.optimizer.annotate_types import annotate_types 3386 3387 arg = annotate_types(arg, dialect=self.dialect) 3388 3389 if arg.is_type(*exp.DataType.TEXT_TYPES): 3390 return self.func("LENGTH", arg) 3391 3392 # We need these casts to make duckdb's static type checker happy 3393 blob = exp.cast(arg, exp.DType.VARBINARY) 3394 varchar = exp.cast(arg, exp.DType.VARCHAR) 3395 3396 case = ( 3397 exp.case(exp.Anonymous(this="TYPEOF", expressions=[arg])) 3398 .when(exp.Literal.string("BLOB"), exp.ByteLength(this=blob)) 3399 .else_(exp.Anonymous(this="LENGTH", expressions=[varchar])) 3400 ) 3401 return self.sql(case) 3402 3403 def bitlength_sql(self, expression: exp.BitLength) -> str: 3404 if not _is_binary(arg := expression.this): 3405 return self.func("BIT_LENGTH", arg) 3406 3407 blob = exp.cast(arg, exp.DataType.Type.VARBINARY) 3408 return self.sql(exp.ByteLength(this=blob) * exp.Literal.number(8)) 3409 3410 def chr_sql(self, expression: exp.Chr, name: str = "CHR") -> str: 3411 arg = expression.expressions[0] 3412 if arg.is_type(*exp.DataType.REAL_TYPES): 3413 arg = exp.cast(arg, exp.DType.INT) 3414 return self.func("CHR", arg) 3415 3416 def collation_sql(self, expression: exp.Collation) -> str: 3417 self.unsupported("COLLATION function is not supported by DuckDB") 3418 return self.function_fallback_sql(expression) 3419 3420 def collate_sql(self, expression: exp.Collate) -> str: 3421 if not expression.expression.is_string: 3422 return super().collate_sql(expression) 3423 3424 raw = expression.expression.name 3425 if not raw: 3426 return self.sql(expression.this) 3427 3428 parts = [] 3429 for part in raw.split("-"): 3430 lower = part.lower() 3431 if lower not in _SNOWFLAKE_COLLATION_DEFAULTS: 3432 if lower in _SNOWFLAKE_COLLATION_UNSUPPORTED: 3433 self.unsupported( 3434 f"Snowflake collation specifier '{part}' has no DuckDB equivalent" 3435 ) 3436 parts.append(lower) 3437 3438 if not parts: 3439 return self.sql(expression.this) 3440 return super().collate_sql( 3441 exp.Collate(this=expression.this, expression=exp.var(".".join(parts))) 3442 ) 3443 3444 def _validate_regexp_flags(self, flags: exp.Expr | None, supported_flags: str) -> str | None: 3445 """ 3446 Validate and filter regexp flags for DuckDB compatibility. 3447 3448 Args: 3449 flags: The flags expression to validate 3450 supported_flags: String of supported flags (e.g., "ims", "cims"). 3451 Only these flags will be returned. 3452 3453 Returns: 3454 Validated/filtered flag string, or None if no valid flags remain 3455 """ 3456 if not isinstance(flags, exp.Expr): 3457 return None 3458 3459 if not flags.is_string: 3460 self.unsupported("Non-literal regexp flags are not fully supported in DuckDB") 3461 return None 3462 3463 flag_str = flags.this 3464 unsupported = set(flag_str) - set(supported_flags) 3465 3466 if unsupported: 3467 self.unsupported( 3468 f"Regexp flags {sorted(unsupported)} are not supported in this context" 3469 ) 3470 3471 flag_str = "".join(f for f in flag_str if f in supported_flags) 3472 return flag_str if flag_str else None 3473 3474 def regexpcount_sql(self, expression: exp.RegexpCount) -> str: 3475 this = expression.this 3476 pattern = expression.expression 3477 position = expression.args.get("position") 3478 parameters = expression.args.get("parameters") 3479 3480 # Validate flags - only "ims" flags are supported for embedded patterns 3481 validated_flags = self._validate_regexp_flags(parameters, supported_flags="ims") 3482 3483 if position: 3484 this = exp.Substring(this=this, start=position) 3485 3486 # Handle empty pattern: Snowflake returns 0, DuckDB would match between every character. 3487 # Checked before the flags are embedded, which would make the pattern non-empty 3488 is_empty = exp.EQ(this=pattern.copy(), expression=exp.Literal.string("")) 3489 3490 # Embed flags in pattern (REGEXP_EXTRACT_ALL doesn't support flags argument) 3491 if validated_flags: 3492 pattern = exp.Concat(expressions=[exp.Literal.string(f"(?{validated_flags})"), pattern]) 3493 3494 result = ( 3495 exp.case() 3496 .when(is_empty, exp.Literal.number(0)) 3497 .else_( 3498 exp.Length( 3499 this=exp.Anonymous(this="REGEXP_EXTRACT_ALL", expressions=[this, pattern]) 3500 ) 3501 ) 3502 ) 3503 3504 return self.sql(result) 3505 3506 def regexpreplace_sql(self, expression: exp.RegexpReplace) -> str: 3507 subject = expression.this 3508 pattern = expression.expression 3509 replacement = expression.args.get("replacement") or exp.Literal.string("") 3510 position = expression.args.get("position") 3511 occurrence = expression.args.get("occurrence") 3512 modifiers = expression.args.get("modifiers") 3513 3514 validated_flags = self._validate_regexp_flags(modifiers, supported_flags="cimsg") or "" 3515 3516 # Handle occurrence (only literals supported) 3517 if occurrence and not occurrence.is_int: 3518 self.unsupported("REGEXP_REPLACE with non-literal occurrence") 3519 else: 3520 occurrence = occurrence.to_py() if occurrence and occurrence.is_int else 0 3521 if occurrence > 1: 3522 self.unsupported(f"REGEXP_REPLACE occurrence={occurrence} not supported") 3523 # flag duckdb to do either all or none, single_replace check is for duckdb round trip 3524 elif ( 3525 occurrence == 0 3526 and "g" not in validated_flags 3527 and not expression.args.get("single_replace") 3528 ): 3529 validated_flags += "g" 3530 3531 # Handle position (only literals supported) 3532 prefix = None 3533 if position and not position.is_int: 3534 self.unsupported("REGEXP_REPLACE with non-literal position") 3535 elif position and position.is_int and position.to_py() > 1: 3536 pos = position.to_py() 3537 prefix = exp.Substring( 3538 this=subject, start=exp.Literal.number(1), length=exp.Literal.number(pos - 1) 3539 ) 3540 subject = exp.Substring(this=subject, start=exp.Literal.number(pos)) 3541 3542 result: exp.Expr = exp.Anonymous( 3543 this="REGEXP_REPLACE", 3544 expressions=[ 3545 subject, 3546 pattern, 3547 replacement, 3548 exp.Literal.string(validated_flags) if validated_flags else None, 3549 ], 3550 ) 3551 3552 if prefix: 3553 result = exp.Concat(expressions=[prefix, result]) 3554 3555 return self.sql(result) 3556 3557 def regexplike_sql(self, expression: exp.RegexpLike) -> str: 3558 this = expression.this 3559 pattern = expression.expression 3560 flag = expression.args.get("flag") 3561 3562 if expression.args.get("full_match"): 3563 validated_flags = self._validate_regexp_flags(flag, supported_flags="cims") 3564 flag = exp.Literal.string(validated_flags) if validated_flags else None 3565 return self.func("REGEXP_FULL_MATCH", this, pattern, flag) 3566 3567 return self.func("REGEXP_MATCHES", this, pattern, flag) 3568 3569 @unsupported_args("ins_cost", "del_cost", "sub_cost") 3570 def levenshtein_sql(self, expression: exp.Levenshtein) -> str: 3571 this = expression.this 3572 expr = expression.expression 3573 max_dist = expression.args.get("max_dist") 3574 3575 if max_dist is None: 3576 return self.func("LEVENSHTEIN", this, expr) 3577 3578 # Emulate Snowflake semantics: if distance > max_dist, return max_dist 3579 levenshtein = exp.Levenshtein(this=this, expression=expr) 3580 return self.sql(exp.Least(this=levenshtein, expressions=[max_dist])) 3581 3582 def pad_sql(self, expression: exp.Pad) -> str: 3583 """ 3584 Handle RPAD/LPAD for VARCHAR and BINARY types. 3585 3586 For VARCHAR: Delegate to parent class 3587 For BINARY: Lower to: input || REPEAT(pad, GREATEST(0, target_len - OCTET_LENGTH(input))) 3588 """ 3589 string_arg = expression.this 3590 fill_arg = expression.args.get("fill_pattern") or exp.Literal.string(" ") 3591 3592 if _is_binary(string_arg) or _is_binary(fill_arg): 3593 length_arg = expression.expression 3594 is_left = expression.args.get("is_left") 3595 3596 input_len = exp.ByteLength(this=string_arg) 3597 chars_needed = length_arg - input_len 3598 pad_count = exp.Greatest( 3599 this=exp.Literal.number(0), expressions=[chars_needed], ignore_nulls=True 3600 ) 3601 repeat_expr = exp.Repeat(this=fill_arg, times=pad_count) 3602 3603 left, right = string_arg, repeat_expr 3604 if is_left: 3605 left, right = right, left 3606 3607 result = exp.DPipe(this=left, expression=right) 3608 return self.sql(result) 3609 3610 # For VARCHAR: Delegate to parent class (handles PAD_FILL_PATTERN_IS_REQUIRED) 3611 return super().pad_sql(expression) 3612 3613 def minhash_sql(self, expression: exp.Minhash) -> str: 3614 k = expression.this 3615 exprs = expression.expressions 3616 3617 if len(exprs) != 1 or isinstance(exprs[0], exp.Star): 3618 self.unsupported( 3619 "MINHASH with multiple expressions or * requires manual query restructuring" 3620 ) 3621 return self.func("MINHASH", k, *exprs) 3622 3623 expr = exprs[0] 3624 result = exp.replace_placeholders(self.MINHASH_TEMPLATE.copy(), expr=expr, k=k) 3625 return f"({self.sql(result)})" 3626 3627 def minhashcombine_sql(self, expression: exp.MinhashCombine) -> str: 3628 expr = expression.this 3629 result = exp.replace_placeholders(self.MINHASH_COMBINE_TEMPLATE.copy(), expr=expr) 3630 return f"({self.sql(result)})" 3631 3632 def approximatesimilarity_sql(self, expression: exp.ApproximateSimilarity) -> str: 3633 expr = expression.this 3634 result = exp.replace_placeholders(self.APPROXIMATE_SIMILARITY_TEMPLATE.copy(), expr=expr) 3635 return f"({self.sql(result)})" 3636 3637 def arrayuniqueagg_sql(self, expression: exp.ArrayUniqueAgg) -> str: 3638 return self.sql( 3639 exp.Filter( 3640 this=exp.func("LIST", exp.Distinct(expressions=[expression.this])), 3641 expression=exp.Where(this=expression.this.copy().is_(exp.null()).not_()), 3642 ) 3643 ) 3644 3645 def arrayconcatagg_sql(self, expression: exp.ArrayConcatAgg) -> str: 3646 this = expression.this 3647 3648 if isinstance(this, exp.Limit): 3649 self.unsupported("LIMIT in ARRAY_CONCAT_AGG cannot be transpiled to DuckDB") 3650 this = this.this 3651 3652 inner = this.this if isinstance(this, exp.Order) else this 3653 3654 return self.func( 3655 "FLATTEN", 3656 exp.Filter( 3657 this=exp.ArrayAgg(this=this), 3658 expression=exp.Where(this=inner.copy().is_(exp.null()).not_()), 3659 ), 3660 ) 3661 3662 def arrayunionagg_sql(self, expression: exp.ArrayUnionAgg) -> str: 3663 self.unsupported("ARRAY_UNION_AGG is not supported in DuckDB") 3664 return self.function_fallback_sql(expression) 3665 3666 def arraydistinct_sql(self, expression: exp.ArrayDistinct) -> str: 3667 arr = expression.this 3668 func = self.func("LIST_DISTINCT", arr) 3669 3670 if expression.args.get("check_null"): 3671 add_null_to_array = exp.func( 3672 "LIST_APPEND", exp.func("LIST_DISTINCT", exp.ArrayCompact(this=arr)), exp.Null() 3673 ) 3674 return self.sql( 3675 exp.If( 3676 this=exp.NEQ( 3677 this=exp.ArraySize(this=arr), expression=exp.func("LIST_COUNT", arr) 3678 ), 3679 true=add_null_to_array, 3680 false=func, 3681 ) 3682 ) 3683 3684 return func 3685 3686 def arrayintersect_sql(self, expression: exp.ArrayIntersect) -> str: 3687 if expression.args.get("is_multiset") and len(expression.expressions) == 2: 3688 return self._array_bag_sql( 3689 self.ARRAY_INTERSECTION_CONDITION, 3690 expression.expressions[0], 3691 expression.expressions[1], 3692 ) 3693 return self.function_fallback_sql(expression) 3694 3695 def arrayexcept_sql(self, expression: exp.ArrayExcept) -> str: 3696 arr1, arr2 = expression.this, expression.expression 3697 if expression.args.get("is_multiset"): 3698 return self._array_bag_sql(self.ARRAY_EXCEPT_CONDITION, arr1, arr2) 3699 return self.sql( 3700 exp.replace_placeholders(self.ARRAY_EXCEPT_SET_TEMPLATE, arr1=arr1, arr2=arr2) 3701 ) 3702 3703 def arrayslice_sql(self, expression: exp.ArraySlice) -> str: 3704 """ 3705 Spark's SLICE uses a length, so normalize negative starts before computing 3706 the inclusive end index. Starts before the array produce an empty slice. 3707 3708 Transpiles Snowflake's ARRAY_SLICE (0-indexed, exclusive end) to DuckDB's 3709 ARRAY_SLICE (1-indexed, inclusive end) by wrapping start and end in CASE 3710 expressions that adjust the index at query time: 3711 - start: CASE WHEN start >= 0 THEN start + 1 ELSE start END 3712 - end: CASE WHEN end < 0 THEN end - 1 ELSE end END 3713 """ 3714 start, end = expression.args.get("start"), expression.args.get("end") 3715 3716 if expression.args.get("zero_based"): 3717 if start is not None: 3718 start = ( 3719 exp.case() 3720 .when( 3721 exp.GTE(this=start.copy(), expression=exp.Literal.number(0)), 3722 exp.Add(this=start.copy(), expression=exp.Literal.number(1)), 3723 ) 3724 .else_(start) 3725 ) 3726 if end is not None and not expression.args.get("is_length"): 3727 end = ( 3728 exp.case() 3729 .when( 3730 exp.LT(this=end.copy(), expression=exp.Literal.number(0)), 3731 exp.Sub(this=end.copy(), expression=exp.Literal.number(1)), 3732 ) 3733 .else_(end) 3734 ) 3735 3736 if expression.args.get("is_length") and start is not None and end is not None: 3737 if not start.is_int or start.to_py() < 0: 3738 size = exp.ArraySize(this=expression.this.copy()) 3739 start = ( 3740 exp.case() 3741 .when(start < -size, size + 1) 3742 .when(start < 0, size + start + 1) 3743 .else_(start) 3744 ) 3745 3746 if start.is_int and end.is_int: 3747 end = exp.Literal.number(start.to_py() + end.to_py() - 1) 3748 else: 3749 end = start + exp.cast(end, "BIGINT") - 1 3750 return self.func("ARRAY_SLICE", expression.this, start, end, expression.args.get("step")) 3751 3752 def arrayszip_sql(self, expression: exp.ArraysZip) -> str: 3753 args = expression.expressions 3754 3755 if not args: 3756 # Return [{}] - using MAP([], []) since DuckDB can't represent empty structs 3757 return self.sql(exp.array(exp.Map(keys=exp.array(), values=exp.array()))) 3758 3759 # Build placeholder values for template 3760 lengths = [exp.Length(this=arg) for arg in args] 3761 max_len = ( 3762 lengths[0] 3763 if len(lengths) == 1 3764 else exp.Greatest(this=lengths[0], expressions=lengths[1:]) 3765 ) 3766 3767 # Empty struct with same schema: {'$1': NULL, '$2': NULL, ...} 3768 empty_struct = exp.func( 3769 "STRUCT", 3770 *[ 3771 exp.PropertyEQ(this=exp.Literal.string(f"${i + 1}"), expression=exp.Null()) 3772 for i in range(len(args)) 3773 ], 3774 ) 3775 3776 # Struct for transform: {'$1': COALESCE(arr1, [])[__i + 1], ...} 3777 # COALESCE wrapping handles NULL arrays - prevents invalid NULL[i] syntax 3778 index = exp.column("__i") + 1 3779 transform_struct = exp.func( 3780 "STRUCT", 3781 *[ 3782 exp.PropertyEQ( 3783 this=exp.Literal.string(f"${i + 1}"), 3784 expression=exp.func("COALESCE", arg, exp.array())[index], 3785 ) 3786 for i, arg in enumerate(args) 3787 ], 3788 ) 3789 3790 result = exp.replace_placeholders( 3791 self.ARRAYS_ZIP_TEMPLATE.copy(), 3792 null_check=exp.or_(*[arg.is_(exp.Null()) for arg in args]), 3793 all_empty_check=exp.and_( 3794 *[ 3795 exp.EQ(this=exp.Length(this=arg), expression=exp.Literal.number(0)) 3796 for arg in args 3797 ] 3798 ), 3799 empty_struct=empty_struct, 3800 max_len=max_len, 3801 transform_struct=transform_struct, 3802 ) 3803 return self.sql(result) 3804 3805 def lower_sql(self, expression: exp.Lower) -> str: 3806 result_sql = self.func("LOWER", _cast_to_varchar(expression.this)) 3807 return _gen_with_cast_to_blob(self, expression, result_sql) 3808 3809 def upper_sql(self, expression: exp.Upper) -> str: 3810 result_sql = self.func("UPPER", _cast_to_varchar(expression.this)) 3811 return _gen_with_cast_to_blob(self, expression, result_sql) 3812 3813 def reverse_sql(self, expression: exp.Reverse) -> str: 3814 result_sql = self.func("REVERSE", _cast_to_varchar(expression.this)) 3815 return _gen_with_cast_to_blob(self, expression, result_sql) 3816 3817 def _left_right_sql(self, expression: exp.Left | exp.Right, func_name: str) -> str: 3818 arg = expression.this 3819 length = expression.expression 3820 is_binary = _is_binary(arg) 3821 3822 if is_binary: 3823 # LEFT/RIGHT(blob, n) becomes UNHEX(LEFT/RIGHT(HEX(blob), n * 2)) 3824 # Each byte becomes 2 hex chars, so multiply length by 2 3825 hex_arg = exp.Hex(this=arg) 3826 hex_length = exp.Mul(this=length, expression=exp.Literal.number(2)) 3827 result: exp.Expression = exp.Unhex( 3828 this=exp.Anonymous(this=func_name, expressions=[hex_arg, hex_length]) 3829 ) 3830 else: 3831 result = exp.Anonymous(this=func_name, expressions=[arg, length]) 3832 3833 if expression.args.get("negative_length_returns_empty"): 3834 empty: exp.Expression = exp.Literal.string("") 3835 if is_binary: 3836 empty = exp.Unhex(this=empty) 3837 result = exp.case().when(length < exp.Literal.number(0), empty).else_(result) 3838 3839 return self.sql(result) 3840 3841 def left_sql(self, expression: exp.Left) -> str: 3842 return self._left_right_sql(expression, "LEFT") 3843 3844 def right_sql(self, expression: exp.Right) -> str: 3845 return self._left_right_sql(expression, "RIGHT") 3846 3847 def rtrimmedlength_sql(self, expression: exp.RtrimmedLength) -> str: 3848 return self.func("LENGTH", exp.Trim(this=expression.this, position="TRAILING")) 3849 3850 def stuff_sql(self, expression: exp.Stuff) -> str: 3851 base = expression.this 3852 start = expression.args["start"] 3853 length = expression.args["length"] 3854 insertion = expression.expression 3855 is_binary = _is_binary(base) 3856 3857 if is_binary: 3858 # DuckDB's SUBSTRING doesn't accept BLOB; operate on the HEX string instead 3859 # (each byte = 2 hex chars), then UNHEX back to BLOB 3860 base = exp.Hex(this=base) 3861 insertion = exp.Hex(this=insertion) 3862 left = exp.Substring( 3863 this=base.copy(), 3864 start=exp.Literal.number(1), 3865 length=(start.copy() - exp.Literal.number(1)) * exp.Literal.number(2), 3866 ) 3867 right = exp.Substring( 3868 this=base.copy(), 3869 start=((start + length) - exp.Literal.number(1)) * exp.Literal.number(2) 3870 + exp.Literal.number(1), 3871 ) 3872 else: 3873 left = exp.Substring( 3874 this=base.copy(), 3875 start=exp.Literal.number(1), 3876 length=start.copy() - exp.Literal.number(1), 3877 ) 3878 right = exp.Substring(this=base.copy(), start=start + length) 3879 result: exp.Expr = exp.DPipe( 3880 this=exp.DPipe(this=left, expression=insertion), expression=right 3881 ) 3882 3883 if is_binary: 3884 result = exp.Unhex(this=result) 3885 3886 return self.sql(result) 3887 3888 def rand_sql(self, expression: exp.Rand) -> str: 3889 seed = expression.this 3890 if seed is not None: 3891 self.unsupported("RANDOM with seed is not supported in DuckDB") 3892 3893 lower = expression.args.get("lower") 3894 upper = expression.args.get("upper") 3895 3896 if lower and upper: 3897 # scale DuckDB's [0,1) to the specified range 3898 range_size = exp.paren(upper - lower) 3899 scaled = exp.Add(this=lower, expression=exp.func("random") * range_size) 3900 3901 # For now we assume that if bounds are set, return type is BIGINT. Snowflake/Teradata 3902 result = exp.cast(scaled, exp.DType.BIGINT) 3903 return self.sql(result) 3904 3905 # Default DuckDB behavior - just return RANDOM() as float 3906 return "RANDOM()" 3907 3908 def bytelength_sql(self, expression: exp.ByteLength) -> str: 3909 arg = expression.this 3910 3911 # Check if it's a text type (handles both literals and annotated expressions) 3912 if arg.is_type(*exp.DataType.TEXT_TYPES): 3913 return self.func("OCTET_LENGTH", exp.Encode(this=arg)) 3914 3915 # Default: pass through as-is (conservative for DuckDB, handles binary and unannotated) 3916 return self.func("OCTET_LENGTH", arg) 3917 3918 def base64encode_sql(self, expression: exp.Base64Encode) -> str: 3919 # DuckDB TO_BASE64 requires BLOB input 3920 # Snowflake BASE64_ENCODE accepts both VARCHAR and BINARY - for VARCHAR it implicitly 3921 # encodes UTF-8 bytes. We add ENCODE unless the input is a binary type. 3922 result = expression.this 3923 3924 # Check if input is a string type - ENCODE only accepts VARCHAR 3925 if result.is_type(*exp.DataType.TEXT_TYPES): 3926 result = exp.Encode(this=result) 3927 3928 result = exp.ToBase64(this=result) 3929 3930 max_line_length = expression.args.get("max_line_length") 3931 alphabet = expression.args.get("alphabet") 3932 3933 # Handle custom alphabet by replacing standard chars with custom ones 3934 result = _apply_base64_alphabet_replacements(result, alphabet) 3935 3936 # Handle max_line_length by inserting newlines every N characters 3937 line_length = ( 3938 t.cast(int, max_line_length.to_py()) 3939 if isinstance(max_line_length, exp.Literal) and max_line_length.is_number 3940 else 0 3941 ) 3942 if line_length > 0: 3943 newline = exp.Chr(expressions=[exp.Literal.number(10)]) 3944 result = exp.Trim( 3945 this=exp.RegexpReplace( 3946 this=result, 3947 expression=exp.Literal.string(f"(.{{{line_length}}})"), 3948 replacement=exp.Concat(expressions=[exp.Literal.string("\\1"), newline.copy()]), 3949 ), 3950 expression=newline, 3951 position="TRAILING", 3952 ) 3953 3954 return self.sql(result) 3955 3956 def hex_sql(self, expression: exp.Hex) -> str: 3957 case = expression.args.get("case") 3958 3959 if not case: 3960 return self.func("HEX", expression.this) 3961 3962 hex_expr = exp.Hex(this=expression.this) 3963 return self.sql( 3964 exp.case() 3965 .when(case.is_(exp.null()), exp.null()) 3966 .when(case.copy().eq(0), exp.Lower(this=hex_expr.copy())) 3967 .else_(hex_expr) 3968 ) 3969 3970 def replace_sql(self, expression: exp.Replace) -> str: 3971 result_sql = self.func( 3972 "REPLACE", 3973 _cast_to_varchar(expression.this), 3974 _cast_to_varchar(expression.expression), 3975 _cast_to_varchar(expression.args.get("replacement")), 3976 ) 3977 return _gen_with_cast_to_blob(self, expression, result_sql) 3978 3979 def _bitwise_op(self, expression: exp.Binary, op: str) -> str: 3980 _prepare_binary_bitwise_args(expression) 3981 result_sql = self.binary(expression, op) 3982 return _gen_with_cast_to_blob(self, expression, result_sql) 3983 3984 def bitwisexor_sql(self, expression: exp.BitwiseXor) -> str: 3985 _prepare_binary_bitwise_args(expression) 3986 result_sql = self.func("XOR", expression.this, expression.expression) 3987 return _gen_with_cast_to_blob(self, expression, result_sql) 3988 3989 def objectinsert_sql(self, expression: exp.ObjectInsert) -> str: 3990 this = expression.this 3991 key = expression.args.get("key") 3992 key_sql = key.name if isinstance(key, exp.Expr) else "" 3993 value_sql = self.sql(expression, "value") 3994 3995 kv_sql = f"{key_sql} := {value_sql}" 3996 3997 # If the input struct is empty e.g. transpiling OBJECT_INSERT(OBJECT_CONSTRUCT(), key, value) from Snowflake 3998 # then we can generate STRUCT_PACK which will build it since STRUCT_INSERT({}, key := value) is not valid DuckDB 3999 if isinstance(this, exp.Struct) and not this.expressions: 4000 return self.func("STRUCT_PACK", kv_sql) 4001 4002 return self.func("STRUCT_INSERT", this, kv_sql) 4003 4004 def mapcat_sql(self, expression: exp.MapCat) -> str: 4005 result = exp.replace_placeholders( 4006 self.MAPCAT_TEMPLATE.copy(), 4007 map1=expression.this, 4008 map2=expression.expression, 4009 ) 4010 return self.sql(result) 4011 4012 def mapcontainskey_sql(self, expression: exp.MapContainsKey) -> str: 4013 return self.func( 4014 "ARRAY_CONTAINS", exp.func("MAP_KEYS", expression.args["key"]), expression.this 4015 ) 4016 4017 def mapdelete_sql(self, expression: exp.MapDelete) -> str: 4018 map_arg = expression.this 4019 keys_to_delete = expression.expressions 4020 4021 x_dot_key = exp.Dot(this=exp.to_identifier("x"), expression=exp.to_identifier("key")) 4022 4023 lambda_expr = exp.Lambda( 4024 this=exp.In(this=x_dot_key, expressions=keys_to_delete).not_(), 4025 expressions=[exp.to_identifier("x")], 4026 ) 4027 result = exp.func( 4028 "MAP_FROM_ENTRIES", 4029 exp.ArrayFilter(this=exp.func("MAP_ENTRIES", map_arg), expression=lambda_expr), 4030 ) 4031 return self.sql(result) 4032 4033 def mappick_sql(self, expression: exp.MapPick) -> str: 4034 map_arg = expression.this 4035 keys_to_pick = expression.expressions 4036 4037 x_dot_key = exp.Dot(this=exp.to_identifier("x"), expression=exp.to_identifier("key")) 4038 4039 if len(keys_to_pick) == 1 and keys_to_pick[0].is_type(exp.DType.ARRAY): 4040 lambda_expr = exp.Lambda( 4041 this=exp.func("ARRAY_CONTAINS", keys_to_pick[0], x_dot_key), 4042 expressions=[exp.to_identifier("x")], 4043 ) 4044 else: 4045 lambda_expr = exp.Lambda( 4046 this=exp.In(this=x_dot_key, expressions=keys_to_pick), 4047 expressions=[exp.to_identifier("x")], 4048 ) 4049 4050 result = exp.func( 4051 "MAP_FROM_ENTRIES", 4052 exp.func("LIST_FILTER", exp.func("MAP_ENTRIES", map_arg), lambda_expr), 4053 ) 4054 return self.sql(result) 4055 4056 def mapsize_sql(self, expression: exp.MapSize) -> str: 4057 return self.func("CARDINALITY", expression.this) 4058 4059 @unsupported_args("update_flag") 4060 def mapinsert_sql(self, expression: exp.MapInsert) -> str: 4061 map_arg = expression.this 4062 key = expression.args.get("key") 4063 value = expression.args.get("value") 4064 4065 map_type = map_arg.type 4066 4067 if value is not None: 4068 if map_type and map_type.expressions and len(map_type.expressions) > 1: 4069 # Extract the value type from MAP(key_type, value_type) 4070 value_type = map_type.expressions[1] 4071 # Cast value to match the map's value type to avoid type conflicts 4072 value = exp.cast(value, value_type) 4073 # else: polymorphic MAP case - no type parameters available, use value as-is 4074 4075 # Create a single-entry map for the new key-value pair 4076 new_entry_struct = exp.Struct(expressions=[exp.PropertyEQ(this=key, expression=value)]) 4077 new_entry: exp.Expression = exp.ToMap(this=new_entry_struct) 4078 4079 # Use MAP_CONCAT to merge the original map with the new entry 4080 # This automatically handles both insert and update cases 4081 result = exp.func("MAP_CONCAT", map_arg, new_entry) 4082 4083 return self.sql(result) 4084 4085 def startswith_sql(self, expression: exp.StartsWith) -> str: 4086 return self.func( 4087 "STARTS_WITH", 4088 _cast_to_varchar(expression.this), 4089 _cast_to_varchar(expression.expression), 4090 ) 4091 4092 def space_sql(self, expression: exp.Space) -> str: 4093 # DuckDB's REPEAT requires BIGINT for the count parameter 4094 return self.sql( 4095 exp.Repeat( 4096 this=exp.Literal.string(" "), 4097 times=exp.cast(expression.this, exp.DType.BIGINT), 4098 ) 4099 ) 4100 4101 def tablefromrows_sql(self, expression: exp.TableFromRows) -> str: 4102 # For GENERATOR, unwrap TABLE() - just emit the Generator (becomes RANGE) 4103 if isinstance(expression.this, exp.Generator): 4104 # Preserve alias, joins, and other table-level args 4105 table = exp.Table( 4106 this=expression.this, 4107 alias=expression.args.get("alias"), 4108 joins=expression.args.get("joins"), 4109 ) 4110 return self.sql(table) 4111 4112 return super().tablefromrows_sql(expression) 4113 4114 def unnest_sql(self, expression: exp.Unnest) -> str: 4115 explode_array = expression.args.get("explode_array") 4116 if explode_array: 4117 # In BigQuery, UNNESTing a nested array leads to explosion of the top-level array & struct 4118 # This is transpiled to DDB by transforming "FROM UNNEST(...)" to "FROM (SELECT UNNEST(..., max_depth => 2))" 4119 expression.expressions.append( 4120 exp.Kwarg(this=exp.var("max_depth"), expression=exp.Literal.number(2)) 4121 ) 4122 4123 # If BQ's UNNEST is aliased, we transform it from a column alias to a table alias in DDB 4124 alias = expression.args.get("alias") 4125 if isinstance(alias, exp.TableAlias): 4126 expression.set("alias", None) 4127 if alias.columns: 4128 alias = exp.TableAlias(this=seq_get(alias.columns, 0)) 4129 4130 unnest_sql = super().unnest_sql(expression) 4131 select = exp.Select(expressions=[unnest_sql]).subquery(alias) 4132 return self.sql(select) 4133 4134 return super().unnest_sql(expression) 4135 4136 def arrayagg_sql(self, expression: exp.ArrayAgg) -> str: 4137 if isinstance(expression.this, exp.Limit): 4138 self.unsupported("LIMIT inside ARRAY_AGG is not supported in DuckDB") 4139 4140 return super().arrayagg_sql(expression) 4141 4142 def ignorenulls_sql(self, expression: exp.IgnoreNulls) -> str: 4143 this = expression.this 4144 4145 if isinstance(this, self.IGNORE_RESPECT_NULLS_WINDOW_FUNCTIONS): 4146 # DuckDB should render IGNORE NULLS only for the general-purpose 4147 # window functions that accept it e.g. FIRST_VALUE(... IGNORE NULLS) OVER (...) 4148 return super().ignorenulls_sql(expression) 4149 4150 # For ARRAY_AGG(expr IGNORE NULLS ...), convert IGNORE NULLS to a 4151 # FILTER(WHERE expr IS NOT NULL) clause by setting nulls_excluded on 4152 # the ArrayAgg. The existing _add_arrayagg_null_filter method will 4153 # emit the FILTER clause during arrayagg_sql / withingroup_sql. 4154 if isinstance(this, exp.ArrayAgg): 4155 this.set("nulls_excluded", True) 4156 return self.sql(this) 4157 4158 if isinstance(this, exp.First): 4159 this = exp.AnyValue(this=this.this) 4160 4161 if not isinstance(this, (exp.AnyValue, exp.ApproxQuantiles)): 4162 self.unsupported("IGNORE NULLS is not supported for non-window functions.") 4163 4164 return self.sql(this) 4165 4166 def split_sql(self, expression: exp.Split) -> str: 4167 base_func = exp.func("STR_SPLIT", expression.this, expression.expression) 4168 4169 case_expr = exp.case().else_(base_func) 4170 needs_case = False 4171 4172 if expression.args.get("null_returns_null"): 4173 case_expr = case_expr.when(expression.expression.is_(exp.null()), exp.null()) 4174 needs_case = True 4175 4176 if expression.args.get("empty_delimiter_returns_whole"): 4177 # When delimiter is empty string, return input string as single array element 4178 array_with_input = exp.array(expression.this) 4179 case_expr = case_expr.when( 4180 expression.expression.eq(exp.Literal.string("")), array_with_input 4181 ) 4182 needs_case = True 4183 4184 return self.sql(case_expr if needs_case else base_func) 4185 4186 def splitpart_sql(self, expression: exp.SplitPart) -> str: 4187 string_arg = expression.this 4188 delimiter_arg = expression.args.get("delimiter") 4189 part_index_arg = expression.args.get("part_index") 4190 4191 if delimiter_arg and part_index_arg: 4192 # Handle Snowflake's "index 0 and 1 both return first element" behavior 4193 if expression.args.get("part_index_zero_as_one"): 4194 # Convert 0 to 1 for compatibility 4195 4196 part_index_arg = exp.Paren( 4197 this=exp.case() 4198 .when(part_index_arg.eq(exp.Literal.number("0")), exp.Literal.number("1")) 4199 .else_(part_index_arg) 4200 ) 4201 4202 # Use Anonymous to avoid recursion 4203 base_func_expr: exp.Expr = exp.Anonymous( 4204 this="SPLIT_PART", expressions=[string_arg, delimiter_arg, part_index_arg] 4205 ) 4206 needs_case_transform = False 4207 case_expr = exp.case().else_(base_func_expr) 4208 4209 if expression.args.get("empty_delimiter_returns_whole"): 4210 # When delimiter is empty string: 4211 # - Return whole string if part_index is 1 or -1 4212 # - Return empty string otherwise 4213 empty_case = exp.Paren( 4214 this=exp.case() 4215 .when( 4216 exp.or_( 4217 part_index_arg.eq(exp.Literal.number("1")), 4218 part_index_arg.eq(exp.Literal.number("-1")), 4219 ), 4220 string_arg, 4221 ) 4222 .else_(exp.Literal.string("")) 4223 ) 4224 4225 case_expr = case_expr.when(delimiter_arg.eq(exp.Literal.string("")), empty_case) 4226 needs_case_transform = True 4227 4228 """ 4229 Output looks something like this: 4230 4231 CASE 4232 WHEN delimiter is '' THEN 4233 ( 4234 CASE 4235 WHEN adjusted_part_index = 1 OR adjusted_part_index = -1 THEN input 4236 ELSE '' END 4237 ) 4238 ELSE SPLIT_PART(input, delimiter, adjusted_part_index) 4239 END 4240 4241 """ 4242 return self.sql(case_expr if needs_case_transform else base_func_expr) 4243 4244 return self.function_fallback_sql(expression) 4245 4246 def respectnulls_sql(self, expression: exp.RespectNulls) -> str: 4247 if isinstance(expression.this, self.IGNORE_RESPECT_NULLS_WINDOW_FUNCTIONS): 4248 # DuckDB should render RESPECT NULLS only for the general-purpose 4249 # window functions that accept it e.g. FIRST_VALUE(... RESPECT NULLS) OVER (...) 4250 return super().respectnulls_sql(expression) 4251 4252 self.unsupported("RESPECT NULLS is not supported for non-window functions.") 4253 return self.sql(expression, "this") 4254 4255 def arraytostring_sql(self, expression: exp.ArrayToString) -> str: 4256 null = expression.args.get("null") 4257 4258 if expression.args.get("null_is_empty"): 4259 x = exp.to_identifier("x") 4260 list_transform = exp.Transform( 4261 this=expression.this.copy(), 4262 expression=exp.Lambda( 4263 this=exp.Coalesce( 4264 this=exp.cast(x, "TEXT"), expressions=[exp.Literal.string("")] 4265 ), 4266 expressions=[x], 4267 ), 4268 ) 4269 array_to_string = exp.ArrayToString( 4270 this=list_transform, expression=expression.expression 4271 ) 4272 if expression.args.get("null_delim_is_null"): 4273 return self.sql( 4274 exp.case() 4275 .when(expression.expression.copy().is_(exp.null()), exp.null()) 4276 .else_(array_to_string) 4277 ) 4278 return self.sql(array_to_string) 4279 4280 if null: 4281 x = exp.to_identifier("x") 4282 return self.sql( 4283 exp.ArrayToString( 4284 this=exp.Transform( 4285 this=expression.this, 4286 expression=exp.Lambda( 4287 this=exp.Coalesce(this=exp.cast(x, "TEXT"), expressions=[null]), 4288 expressions=[x], 4289 ), 4290 ), 4291 expression=expression.expression, 4292 ) 4293 ) 4294 4295 return self.func("ARRAY_TO_STRING", expression.this, expression.expression) 4296 4297 def concatws_sql(self, expression: exp.ConcatWs) -> str: 4298 # DuckDB-specific: handle binary types using DPipe (||) operator 4299 separator = seq_get(expression.expressions, 0) 4300 args = expression.expressions[1:] 4301 4302 if any(_is_binary(arg) for arg in [separator, *args]): 4303 result = args[0] 4304 for arg in args[1:]: 4305 result = exp.DPipe( 4306 this=exp.DPipe(this=result, expression=separator), expression=arg 4307 ) 4308 return self.sql(result) 4309 4310 if expression.args.get("flatten") and any( 4311 isinstance(arg, exp.Array) or arg.is_type(exp.DType.ARRAY) for arg in args 4312 ): 4313 arrays = [] 4314 for arg in args: 4315 if isinstance(arg, exp.Array) or arg.is_type(exp.DType.ARRAY): 4316 arg = exp.cast(arg, "TEXT[]") 4317 else: 4318 if (not arg.type or arg.is_type(exp.DType.UNKNOWN)) and not isinstance( 4319 arg, exp.CONSTANTS 4320 ): 4321 self.unsupported( 4322 "Cannot transpile CONCAT_WS with unknown argument types to DuckDB." 4323 ) 4324 arg = exp.array(exp.cast(arg, exp.DType.TEXT)) 4325 4326 arrays.append(arg) 4327 4328 array = ( 4329 exp.ArrayConcat(this=arrays[0], expressions=arrays[1:]) 4330 if len(arrays) > 1 4331 else arrays[0] 4332 ) 4333 return self.func("CONCAT_WS", separator, self.func("ARRAY_TO_STRING", array, separator)) 4334 4335 return super().concatws_sql(expression) 4336 4337 def _regexp_extract_sql(self, expression: exp.RegexpExtract | exp.RegexpExtractAll) -> str: 4338 this = expression.this 4339 group = expression.args.get("group") 4340 params = expression.args.get("parameters") 4341 position = expression.args.get("position") 4342 occurrence = expression.args.get("occurrence") 4343 null_if_pos_overflow = expression.args.get("null_if_pos_overflow") 4344 null_if_no_match = expression.args.get("null_if_no_match") 4345 4346 # Handle Snowflake's 'e' flag: it enables capture group extraction 4347 # In DuckDB, this is controlled by the group parameter directly 4348 if params and params.is_string and "e" in params.name: 4349 params = exp.Literal.string(params.name.replace("e", "")) 4350 4351 validated_flags = self._validate_regexp_flags(params, supported_flags="cims") 4352 4353 # Strip default group when no following params (DuckDB default is same as group=0) 4354 if ( 4355 not validated_flags 4356 and group 4357 and group.name == str(self.dialect.REGEXP_EXTRACT_DEFAULT_GROUP) 4358 ): 4359 group = None 4360 4361 flags_expr = exp.Literal.string(validated_flags) if validated_flags else None 4362 4363 # use substring to handle position argument 4364 if position and (not position.is_int or position.to_py() > 1): 4365 this = exp.Substring(this=this, start=position) 4366 4367 if null_if_pos_overflow: 4368 this = exp.Nullif(this=this, expression=exp.Literal.string("")) 4369 4370 is_extract_all = isinstance(expression, exp.RegexpExtractAll) 4371 non_single_occurrence = occurrence and (not occurrence.is_int or occurrence.to_py() > 1) 4372 4373 if is_extract_all or non_single_occurrence or null_if_no_match: 4374 name = "REGEXP_EXTRACT_ALL" 4375 else: 4376 name = "REGEXP_EXTRACT" 4377 4378 result: exp.Expr = exp.Anonymous( 4379 this=name, expressions=[this, expression.expression, group, flags_expr] 4380 ) 4381 4382 # Array slicing for REGEXP_EXTRACT_ALL with occurrence 4383 if is_extract_all and non_single_occurrence: 4384 result = exp.Bracket(this=result, expressions=[exp.Slice(this=occurrence)]) 4385 elif not is_extract_all and (non_single_occurrence or null_if_no_match): 4386 # Non-participating capture group yields [NULL] in DuckDB; Snowflake returns '' on a match 4387 if ( 4388 expression.args.get("empty_if_group_missing") 4389 and group 4390 and not (group.is_int and group.to_py() == 0) 4391 ): 4392 result = exp.Transform( 4393 this=result, 4394 expression=exp.Lambda( 4395 this=exp.Coalesce( 4396 this=exp.column("x"), expressions=[exp.Literal.string("")] 4397 ), 4398 expressions=[exp.to_identifier("x")], 4399 ), 4400 ) 4401 return self.func( 4402 "ARRAY_EXTRACT", 4403 result, 4404 occurrence if non_single_occurrence else exp.Literal.number(1), 4405 ) 4406 4407 return self.sql(result) 4408 4409 def regexpextract_sql(self, expression: exp.RegexpExtract) -> str: 4410 return self._regexp_extract_sql(expression) 4411 4412 def regexpextractall_sql(self, expression: exp.RegexpExtractAll) -> str: 4413 return self._regexp_extract_sql(expression) 4414 4415 def regexpinstr_sql(self, expression: exp.RegexpInstr) -> str: 4416 this = expression.this 4417 pattern = expression.expression 4418 position = expression.args.get("position") 4419 orig_occ = expression.args.get("occurrence") 4420 occurrence = orig_occ or exp.Literal.number(1) 4421 option = expression.args.get("option") 4422 parameters = expression.args.get("parameters") 4423 4424 validated_flags = self._validate_regexp_flags(parameters, supported_flags="ims") 4425 # Checked before the flags are embedded, which would make the pattern non-empty 4426 is_empty = pattern.copy().eq(exp.Literal.string("")) 4427 if validated_flags: 4428 pattern = exp.Concat(expressions=[exp.Literal.string(f"(?{validated_flags})"), pattern]) 4429 4430 # Handle starting position offset 4431 pos_offset: exp.Expr = exp.Literal.number(0) 4432 if position and (not position.is_int or position.to_py() > 1): 4433 this = exp.Substring(this=this, start=position) 4434 pos_offset = position - exp.Literal.number(1) 4435 4436 # Helper: LIST_SUM(LIST_TRANSFORM(list[1:end], x -> LENGTH(x))) 4437 def sum_lengths(func_name: str, end: exp.Expr) -> exp.Expr: 4438 lst = exp.Bracket( 4439 this=exp.Anonymous(this=func_name, expressions=[this, pattern]), 4440 expressions=[exp.Slice(this=exp.Literal.number(1), expression=end)], 4441 offset=1, 4442 ) 4443 transform = exp.Anonymous( 4444 this="LIST_TRANSFORM", 4445 expressions=[ 4446 lst, 4447 exp.Lambda( 4448 this=exp.Length(this=exp.to_identifier("x")), 4449 expressions=[exp.to_identifier("x")], 4450 ), 4451 ], 4452 ) 4453 return exp.Coalesce( 4454 this=exp.Anonymous(this="LIST_SUM", expressions=[transform]), 4455 expressions=[exp.Literal.number(0)], 4456 ) 4457 4458 # Position = 1 + sum(split_lengths[1:occ]) + sum(match_lengths[1:occ-1]) + offset 4459 base_pos: exp.Expr = ( 4460 exp.Literal.number(1) 4461 + sum_lengths("STRING_SPLIT_REGEX", occurrence) 4462 + sum_lengths("REGEXP_EXTRACT_ALL", occurrence - exp.Literal.number(1)) 4463 + pos_offset 4464 ) 4465 4466 # option=1: add match length for end position 4467 if option and option.is_int and option.to_py() == 1: 4468 match_at_occ = exp.Bracket( 4469 this=exp.Anonymous(this="REGEXP_EXTRACT_ALL", expressions=[this, pattern]), 4470 expressions=[occurrence], 4471 offset=1, 4472 ) 4473 base_pos = base_pos + exp.Coalesce( 4474 this=exp.Length(this=match_at_occ), expressions=[exp.Literal.number(0)] 4475 ) 4476 4477 # NULL checks for all provided arguments 4478 # .copy() is used strictly because .is_() alters the node's parent pointer, mutating the parsed AST 4479 null_args = [ 4480 expression.this, 4481 expression.expression, 4482 position, 4483 orig_occ, 4484 option, 4485 parameters, 4486 ] 4487 null_checks = [arg.copy().is_(exp.Null()) for arg in null_args if arg] 4488 4489 matches = exp.Anonymous(this="REGEXP_EXTRACT_ALL", expressions=[this, pattern]) 4490 4491 return self.sql( 4492 exp.case() 4493 .when(exp.or_(*null_checks), exp.Null()) 4494 .when(is_empty, exp.Literal.number(0)) 4495 .when(exp.Length(this=matches) < occurrence, exp.Literal.number(0)) 4496 .else_(base_pos) 4497 ) 4498 4499 @unsupported_args("culture") 4500 def numbertostr_sql(self, expression: exp.NumberToStr) -> str: 4501 fmt = expression.args.get("format") 4502 if fmt and fmt.is_int: 4503 return self.func("FORMAT", f"'{{:,.{fmt.name}f}}'", expression.this) 4504 4505 self.unsupported("Only integer formats are supported by NumberToStr") 4506 return self.function_fallback_sql(expression) 4507 4508 def autoincrementcolumnconstraint_sql(self, _) -> str: 4509 self.unsupported("The AUTOINCREMENT column constraint is not supported by DuckDB") 4510 return "" 4511 4512 def aliases_sql(self, expression: exp.Aliases) -> str: 4513 this = expression.this 4514 if isinstance(this, exp.Posexplode): 4515 return self.posexplode_sql(this) 4516 4517 return super().aliases_sql(expression) 4518 4519 def posexplode_sql(self, expression: exp.Posexplode) -> str: 4520 this = expression.this 4521 parent = expression.parent 4522 4523 # The default Spark aliases are "pos" and "col", unless specified otherwise 4524 pos, col = exp.to_identifier("pos"), exp.to_identifier("col") 4525 4526 if isinstance(parent, exp.Aliases): 4527 # Column case: SELECT POSEXPLODE(col) [AS (a, b)] 4528 pos, col = parent.expressions 4529 elif isinstance(parent, exp.Table): 4530 # Table case: SELECT * FROM POSEXPLODE(col) [AS (a, b)] 4531 alias = parent.args.get("alias") 4532 if alias: 4533 pos, col = alias.columns or [pos, col] 4534 alias.pop() 4535 4536 # Translate POSEXPLODE to UNNEST + GENERATE_SUBSCRIPTS 4537 # Note: In Spark pos is 0-indexed, but in DuckDB it's 1-indexed, so we subtract 1 from GENERATE_SUBSCRIPTS 4538 unnest_sql = self.sql(exp.Unnest(expressions=[this], alias=col)) 4539 gen_subscripts = self.sql( 4540 exp.Alias( 4541 this=exp.Anonymous( 4542 this="GENERATE_SUBSCRIPTS", expressions=[this, exp.Literal.number(1)] 4543 ) 4544 - exp.Literal.number(1), 4545 alias=pos, 4546 ) 4547 ) 4548 4549 posexplode_sql = self.format_args(gen_subscripts, unnest_sql) 4550 4551 if isinstance(parent, exp.From) or (parent and isinstance(parent.parent, exp.From)): 4552 # SELECT * FROM POSEXPLODE(col) -> SELECT * FROM (SELECT GENERATE_SUBSCRIPTS(...), UNNEST(...)) 4553 return self.sql(exp.Subquery(this=exp.Select(expressions=[posexplode_sql]))) 4554 4555 return posexplode_sql 4556 4557 def addmonths_sql(self, expression: exp.AddMonths) -> str: 4558 """ 4559 Handles three key issues: 4560 1. Float/decimal months: e.g., Snowflake rounds, whereas DuckDB INTERVAL requires integers 4561 2. End-of-month preservation: If input is last day of month, result is last day of result month 4562 3. Type preservation: Maintains DATE/TIMESTAMPTZ types (DuckDB defaults to TIMESTAMP) 4563 """ 4564 from sqlglot.optimizer.annotate_types import annotate_types 4565 4566 this = expression.this 4567 if not this.type: 4568 this = annotate_types(this, dialect=self.dialect) 4569 4570 if this.is_type(*exp.DataType.TEXT_TYPES): 4571 this = exp.Cast(this=this, to=exp.DataType(this=exp.DType.TIMESTAMP)) 4572 4573 # Detect float/decimal months to apply rounding (Snowflake behavior) 4574 # DuckDB INTERVAL syntax doesn't support non-integer expressions, so use TO_MONTHS 4575 months_expr = expression.expression 4576 if not months_expr.type: 4577 months_expr = annotate_types(months_expr, dialect=self.dialect) 4578 4579 # Build interval or to_months expression based on type 4580 # Float/decimal case: Round and use TO_MONTHS(CAST(ROUND(value) AS INT)) 4581 interval_or_to_months = ( 4582 exp.func("TO_MONTHS", exp.cast(exp.func("ROUND", months_expr), "INT")) 4583 if months_expr.is_type( 4584 exp.DType.FLOAT, 4585 exp.DType.DOUBLE, 4586 exp.DType.DECIMAL, 4587 ) 4588 # Integer case: standard INTERVAL N MONTH syntax 4589 else exp.Interval(this=months_expr, unit=exp.var("MONTH")) 4590 ) 4591 4592 date_add_expr = exp.Add(this=this, expression=interval_or_to_months) 4593 4594 # Apply end-of-month preservation if Snowflake flag is set 4595 # CASE WHEN LAST_DAY(date) = date THEN LAST_DAY(result) ELSE result END 4596 preserve_eom = expression.args.get("preserve_end_of_month") 4597 result_expr = ( 4598 exp.case() 4599 .when( 4600 exp.EQ(this=exp.func("LAST_DAY", this), expression=this), 4601 exp.func("LAST_DAY", date_add_expr), 4602 ) 4603 .else_(date_add_expr) 4604 if preserve_eom 4605 else date_add_expr 4606 ) 4607 4608 # DuckDB's DATE_ADD function returns TIMESTAMP/DATETIME by default, even when the input is DATE 4609 # To match for example Snowflake's ADD_MONTHS behavior (which preserves the input type) 4610 # We need to cast the result back to the original type when the input is DATE or TIMESTAMPTZ 4611 # Example: ADD_MONTHS('2023-01-31'::date, 1) should return DATE, not TIMESTAMP 4612 if this.is_type(exp.DType.DATE, exp.DType.TIMESTAMPTZ): 4613 return self.sql(exp.Cast(this=result_expr, to=this.type)) 4614 return self.sql(result_expr) 4615 4616 def format_sql(self, expression: exp.Format) -> str: 4617 if expression.name.lower() == "%s" and len(expression.expressions) == 1: 4618 return self.func("FORMAT", "'{}'", expression.expressions[0]) 4619 4620 return self.function_fallback_sql(expression) 4621 4622 def hexstring_sql( 4623 self, expression: exp.HexString, binary_function_repr: str | None = None 4624 ) -> str: 4625 # UNHEX('FF') correctly produces blob \xFF in DuckDB 4626 return super().hexstring_sql(expression, binary_function_repr="UNHEX") 4627 4628 def datetrunc_sql(self, expression: exp.DateTrunc) -> str: 4629 unit = expression.args.get("unit") 4630 date = expression.this 4631 4632 week_start = _week_trunc_start_dow(unit) 4633 unit = unit_to_str(expression) 4634 4635 if week_start: 4636 result = self.sql( 4637 _build_week_trunc_expression(date, week_start, preserve_start_day=True) 4638 ) 4639 else: 4640 result = self.func("DATE_TRUNC", unit, date) 4641 4642 if expression.args.get("input_type_preserved") and date.is_type( 4643 *exp.DataType.TEMPORAL_TYPES 4644 ): 4645 return self.sql(exp.Cast(this=result, to=date.type)) 4646 4647 return result 4648 4649 def datetimetrunc_sql(self, expression: exp.DatetimeTrunc) -> str: 4650 this = exp.cast(expression.this, exp.DType.DATETIME) 4651 week_start = _week_trunc_start_dow(expression.args.get("unit")) 4652 if week_start: 4653 return self.sql( 4654 _build_week_trunc_expression( 4655 this, week_start, preserve_start_day=True, cast_to_date=False 4656 ) 4657 ) 4658 4659 return self.func("DATE_TRUNC", unit_to_str(expression), this) 4660 4661 def timestamptrunc_sql(self, expression: exp.TimestampTrunc) -> str: 4662 zone = expression.args.get("zone") 4663 timestamp = expression.this 4664 week_start = _week_trunc_start_dow(expression.args.get("unit")) 4665 4666 # The week start emulation below is exact, so avoid weekstart_unit_to_str's degrade warning 4667 unit = unit_to_str(expression) if week_start else weekstart_unit_to_str(self, expression) 4668 date_unit = is_date_unit(unit) or bool(week_start) 4669 4670 def _trunc_expr(this: exp.Expr) -> exp.Expr: 4671 if week_start: 4672 return _build_week_trunc_expression( 4673 this, week_start, preserve_start_day=True, cast_to_date=False 4674 ) 4675 return exp.func("DATE_TRUNC", unit, this) 4676 4677 if date_unit and zone: 4678 # BigQuery's TIMESTAMP_TRUNC with timezone truncates in the target timezone and returns as UTC. 4679 # Double AT TIME ZONE needed for BigQuery compatibility: 4680 # 1. First AT TIME ZONE: ensures truncation happens in the target timezone 4681 # 2. Second AT TIME ZONE: converts the DATE result back to TIMESTAMPTZ (preserving time component) 4682 timestamp = exp.AtTimeZone(this=timestamp, zone=zone) 4683 trunced = _trunc_expr(timestamp) 4684 if isinstance(trunced, exp.DateAdd): 4685 # Parenthesize so the trailing AT TIME ZONE binds to the whole shifted expression 4686 trunced = exp.Paren(this=trunced) 4687 return self.sql(exp.AtTimeZone(this=trunced, zone=zone)) 4688 4689 result = self.sql(_trunc_expr(timestamp)) 4690 if expression.args.get("input_type_preserved"): 4691 if timestamp.type and timestamp.is_type(exp.DType.TIME, exp.DType.TIMETZ): 4692 dummy_date = exp.Cast( 4693 this=exp.Literal.string("1970-01-01"), 4694 to=exp.DataType(this=exp.DType.DATE), 4695 ) 4696 date_time = exp.Add(this=dummy_date, expression=timestamp) 4697 result = self.func("DATE_TRUNC", unit, date_time) 4698 return self.sql(exp.Cast(this=result, to=timestamp.type)) 4699 4700 if timestamp.is_type(*exp.DataType.TEMPORAL_TYPES): 4701 return self.sql(exp.Cast(this=result, to=timestamp.type)) 4702 4703 return result 4704 4705 def trim_sql(self, expression: exp.Trim) -> str: 4706 expression.this.replace(_cast_to_varchar(expression.this)) 4707 if expression.expression: 4708 expression.expression.replace(_cast_to_varchar(expression.expression)) 4709 4710 result_sql = super().trim_sql(expression) 4711 return _gen_with_cast_to_blob(self, expression, result_sql) 4712 4713 def round_sql(self, expression: exp.Round) -> str: 4714 this = expression.this 4715 decimals = expression.args.get("decimals") 4716 truncate = expression.args.get("truncate") 4717 4718 # DuckDB requires the scale (decimals) argument to be an INT 4719 # Some dialects (e.g., Snowflake) allow non-integer scales and cast to an integer internally 4720 if decimals is not None and expression.args.get("casts_non_integer_decimals"): 4721 if not (decimals.is_int or decimals.is_type(*exp.DataType.INTEGER_TYPES)): 4722 decimals = exp.cast(decimals, exp.DType.INT) 4723 4724 func = "ROUND" 4725 if truncate: 4726 # BigQuery uses ROUND_HALF_EVEN; Snowflake uses HALF_TO_EVEN 4727 if truncate.this in ("ROUND_HALF_EVEN", "HALF_TO_EVEN"): 4728 func = "ROUND_EVEN" 4729 truncate = None 4730 # BigQuery uses ROUND_HALF_AWAY_FROM_ZERO; Snowflake uses HALF_AWAY_FROM_ZERO 4731 elif truncate.this in ("ROUND_HALF_AWAY_FROM_ZERO", "HALF_AWAY_FROM_ZERO"): 4732 truncate = None 4733 4734 return self.func(func, this, decimals, truncate) 4735 4736 def trycast_sql(self, expression: exp.TryCast) -> str: 4737 to = expression.to 4738 to_type = to.this 4739 src = expression.this 4740 4741 if ( 4742 expression.args.get("null_on_text_overflow") 4743 and to_type in exp.DataType.TEXT_TYPES 4744 and to.expressions 4745 ): 4746 return self.sql( 4747 exp.case() 4748 .when( 4749 exp.LTE(this=exp.func("LENGTH", src), expression=to.expressions[0].this), 4750 exp.cast(src, "TEXT"), 4751 ) 4752 .else_(exp.Null()) 4753 ) 4754 elif to_type == exp.DType.DATE and expression.args.get("probe_date_format"): 4755 slash_strptime = exp.cast( 4756 exp.func("TRY_STRPTIME", src, exp.Literal.string(self._TRYCAST_DATE_SLASH_FMT)), 4757 "DATE", 4758 ) 4759 mon_strptime = exp.cast( 4760 exp.func("TRY_STRPTIME", src, exp.Literal.string(self._TRYCAST_DATE_MON_FMT)), 4761 "DATE", 4762 ) 4763 return self.sql( 4764 exp.case() 4765 .when(exp.func("CONTAINS", src, exp.Literal.string("/")), slash_strptime) 4766 .when( 4767 exp.RegexpLike(this=src, expression=exp.Literal.string("[A-Za-z]")), 4768 mon_strptime, 4769 ) 4770 .else_(exp.TryCast(this=src, to=to)) 4771 ) 4772 elif ( 4773 isinstance(to_type, exp.Interval) 4774 and (unit := to_type.unit) 4775 and expression.args.get("requires_string") 4776 ): 4777 interval_type = exp.DataType.build("INTERVAL") 4778 if isinstance(unit, exp.IntervalSpan): 4779 self.unsupported( 4780 "TRY_CAST to INTERVAL with span (e.g. HOUR TO MINUTE) is not supported in DuckDB" 4781 ) 4782 return self.sql(exp.TryCast(this=src, to=interval_type)) 4783 return self.sql( 4784 exp.TryCast( 4785 this=exp.DPipe(this=src, expression=exp.Literal.string(f" {unit.name}")), 4786 to=interval_type, 4787 ) 4788 ) 4789 4790 return super().trycast_sql(expression) 4791 4792 def strtok_sql(self, expression: exp.Strtok) -> str: 4793 string_arg = expression.this 4794 delimiter_arg = expression.args.get("delimiter") 4795 part_index_arg = expression.args.get("part_index") 4796 4797 if delimiter_arg and part_index_arg: 4798 # Escape regex chars and build character class at runtime using REGEXP_REPLACE 4799 escaped_delimiter = exp.Anonymous( 4800 this="REGEXP_REPLACE", 4801 expressions=[ 4802 delimiter_arg, 4803 exp.Literal.string( 4804 r"([\[\]^.\-*+?(){}|$\\])" 4805 ), # Escape problematic regex chars 4806 exp.Literal.string( 4807 r"\\\1" 4808 ), # Replace with escaped version using $1 backreference 4809 exp.Literal.string("g"), # Global flag 4810 ], 4811 ) 4812 # CASE WHEN delimiter = '' THEN '' ELSE CONCAT('[', escaped_delimiter, ']') END 4813 regex_pattern = ( 4814 exp.case() 4815 .when(delimiter_arg.eq(exp.Literal.string("")), exp.Literal.string("")) 4816 .else_( 4817 exp.func( 4818 "CONCAT", 4819 exp.Literal.string("["), 4820 escaped_delimiter, 4821 exp.Literal.string("]"), 4822 ) 4823 ) 4824 ) 4825 4826 # STRTOK skips empty strings, so we need to filter them out 4827 # LIST_FILTER(REGEXP_SPLIT_TO_ARRAY(string, pattern), x -> x != '')[index] 4828 split_array = exp.func("REGEXP_SPLIT_TO_ARRAY", string_arg, regex_pattern) 4829 x = exp.to_identifier("x") 4830 is_empty = x.eq(exp.Literal.string("")) 4831 filtered_array = exp.func( 4832 "LIST_FILTER", 4833 split_array, 4834 exp.Lambda(this=exp.not_(is_empty.copy()), expressions=[x.copy()]), 4835 ) 4836 base_func = exp.Bracket( 4837 this=filtered_array, 4838 expressions=[part_index_arg], 4839 offset=1, 4840 ) 4841 4842 # Use template with the built regex pattern 4843 result = exp.replace_placeholders( 4844 self.STRTOK_TEMPLATE.copy(), 4845 string=string_arg, 4846 delimiter=delimiter_arg, 4847 part_index=part_index_arg, 4848 base_func=base_func, 4849 ) 4850 4851 return self.sql(result) 4852 4853 return self.function_fallback_sql(expression) 4854 4855 def strtoktoarray_sql(self, expression: exp.StrtokToArray) -> str: 4856 string_arg = expression.this 4857 delimiter_arg = expression.args.get("expression") or exp.Literal.string(" ") 4858 4859 escaped = exp.RegexpReplace( 4860 this=delimiter_arg.copy(), 4861 expression=exp.Literal.string(r"([\[\]^.\-*+?(){}|$\\])"), 4862 replacement=exp.Literal.string(r"\\\1"), 4863 modifiers=exp.Literal.string("g"), 4864 ) 4865 return self.sql( 4866 exp.replace_placeholders( 4867 self.STRTOK_TO_ARRAY_TEMPLATE.copy(), 4868 string=string_arg, 4869 delimiter=delimiter_arg, 4870 escaped=escaped, 4871 ) 4872 ) 4873 4874 def approxquantile_sql(self, expression: exp.ApproxQuantile) -> str: 4875 result = self.func("APPROX_QUANTILE", expression.this, expression.args.get("quantile")) 4876 4877 # DuckDB returns integers for APPROX_QUANTILE, cast to DOUBLE if the expected type is a real type 4878 if expression.is_type(*exp.DataType.REAL_TYPES): 4879 result = f"CAST({result} AS DOUBLE)" 4880 4881 return result 4882 4883 def approxquantiles_sql(self, expression: exp.ApproxQuantiles) -> str: 4884 """ 4885 BigQuery's APPROX_QUANTILES(expr, n) returns an array of n+1 approximate quantile values 4886 dividing the input distribution into n equal-sized buckets. 4887 4888 Both BigQuery and DuckDB use approximate algorithms for quantile estimation, but BigQuery 4889 does not document the specific algorithm used so results may differ. DuckDB does not 4890 support RESPECT NULLS. 4891 """ 4892 this = expression.this 4893 if isinstance(this, exp.Distinct): 4894 # APPROX_QUANTILES requires 2 args and DISTINCT node grabs both 4895 if len(this.expressions) < 2: 4896 self.unsupported("APPROX_QUANTILES requires a bucket count argument") 4897 return self.function_fallback_sql(expression) 4898 num_quantiles_expr = this.expressions[1].pop() 4899 else: 4900 num_quantiles_expr = expression.expression 4901 4902 if not isinstance(num_quantiles_expr, exp.Literal) or not num_quantiles_expr.is_int: 4903 self.unsupported("APPROX_QUANTILES bucket count must be a positive integer") 4904 return self.function_fallback_sql(expression) 4905 4906 num_quantiles = t.cast(int, num_quantiles_expr.to_py()) 4907 if num_quantiles <= 0: 4908 self.unsupported("APPROX_QUANTILES bucket count must be a positive integer") 4909 return self.function_fallback_sql(expression) 4910 4911 quantiles = [ 4912 exp.Literal.number(Decimal(i) / Decimal(num_quantiles)) 4913 for i in range(num_quantiles + 1) 4914 ] 4915 4916 return self.sql(exp.ApproxQuantile(this=this, quantile=exp.Array(expressions=quantiles))) 4917 4918 def jsonextractscalar_sql(self, expression: exp.JSONExtractScalar) -> str: 4919 if expression.args.get("scalar_only"): 4920 json_value = exp.JSONExtractScalar( 4921 this=rename_func("JSON_VALUE")(self, expression), expression="'$'" 4922 ) 4923 4924 # `->>` binds looser than most operators, so the wrap logic needs the parent 4925 json_value.parent = expression.parent 4926 expression = json_value 4927 4928 return _arrow_json_extract_sql(self, expression) 4929 4930 def bitwisenot_sql(self, expression: exp.BitwiseNot) -> str: 4931 this = expression.this 4932 4933 if _is_binary(this): 4934 expression.type = exp.DType.BINARY.into_expr() 4935 4936 arg = _cast_to_bit(this) 4937 4938 if isinstance(this, exp.Neg): 4939 arg = exp.Paren(this=arg) 4940 4941 expression.set("this", arg) 4942 4943 result_sql = f"~{self.sql(expression, 'this')}" 4944 4945 return _gen_with_cast_to_blob(self, expression, result_sql) 4946 4947 def window_sql(self, expression: exp.Window) -> str: 4948 this = expression.this 4949 if isinstance(this, exp.Corr) or ( 4950 isinstance(this, exp.Filter) and isinstance(this.this, exp.Corr) 4951 ): 4952 return self._corr_sql(expression) 4953 4954 return super().window_sql(expression) 4955 4956 def filter_sql(self, expression: exp.Filter) -> str: 4957 if isinstance(expression.this, exp.Corr): 4958 return self._corr_sql(expression) 4959 4960 return super().filter_sql(expression) 4961 4962 def _corr_sql( 4963 self, 4964 expression: exp.Filter | exp.Window | exp.Corr, 4965 ) -> str: 4966 if isinstance(expression, exp.Corr) and not expression.args.get("null_on_zero_variance"): 4967 return self.func("CORR", expression.this, expression.expression) 4968 4969 corr_expr = _maybe_corr_null_to_false(expression) 4970 if corr_expr is None: 4971 if isinstance(expression, exp.Window): 4972 return super().window_sql(expression) 4973 if isinstance(expression, exp.Filter): 4974 return super().filter_sql(expression) 4975 corr_expr = expression # make mypy happy 4976 4977 return self.sql(exp.case().when(exp.IsNan(this=corr_expr), exp.null()).else_(corr_expr)) 4978 4979 def uuid_sql(self, expression: exp.Uuid) -> str: 4980 namespace = expression.this 4981 name = expression.args.get("name") 4982 4983 # UUID v5 (namespace + name) - Emulate using SHA1 4984 if namespace and name: 4985 result = exp.replace_placeholders( 4986 self.UUID_V5_TEMPLATE.copy(), 4987 namespace=namespace, 4988 name=name, 4989 ) 4990 return self.sql(result) 4991 4992 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
2409 def datatype_sql(self, expression: exp.DataType) -> str: 2410 if expression.is_type(exp.DType.ARRAY): 2411 return f"{self.expressions(expression, flat=True)}[{self.expressions(expression, key='values', flat=True)}]" 2412 2413 # Modifiers are not supported for TIME, [TIME | TIMESTAMP] WITH TIME ZONE 2414 if expression.is_type( 2415 exp.DType.TIME, exp.DType.TIMETZ, exp.DType.TIMESTAMPTZ, exp.DType.TIMESTAMPLTZ 2416 ): 2417 expression.set("expressions", None) 2418 2419 return super().datatype_sql(expression)
2427 def timeslice_sql(self, expression: exp.TimeSlice) -> str: 2428 """ 2429 Transform Snowflake's TIME_SLICE to DuckDB's time_bucket. 2430 2431 Snowflake: TIME_SLICE(date_expr, slice_length, 'UNIT' [, 'START'|'END']) 2432 DuckDB: time_bucket(INTERVAL 'slice_length' UNIT, date_expr, origin) 2433 2434 Snowflake aligns slices to the 1970-01-01 epoch, so an explicit origin is 2435 required because DuckDB's default origin differs. For 'END' kind, add the 2436 interval to get the next slice's start; for DATE input, cast back to DATE. 2437 """ 2438 date_expr = expression.this 2439 slice_length = expression.expression 2440 # Normalize unit aliases (e.g. W/WK/WOY -> WEEK) so the interval and origin agree 2441 unit = map_date_part(expression.unit, self.dialect) 2442 kind = expression.text("kind").upper() 2443 2444 interval_expr = exp.Interval(this=slice_length, unit=unit) 2445 2446 origin = expression.args.get("origin") 2447 if origin is None: 2448 # Snowflake aligns to 1970-01-01; WEEK to the Monday of the epoch week (WEEK_START=0) 2449 origin = exp.Literal.string( 2450 "1969-12-29" if unit and unit.name.upper() == "WEEK" else "1970-01-01" 2451 ) 2452 # A DATE origin keeps a DATE input a DATE; DuckDB widens it for TIMESTAMP inputs 2453 time_bucket_expr = exp.func( 2454 "time_bucket", interval_expr, date_expr, exp.cast(origin, exp.DType.DATE) 2455 ) 2456 2457 if kind != "END": 2458 return self.sql(time_bucket_expr) 2459 2460 add_expr = exp.Add(this=time_bucket_expr, expression=interval_expr.copy()) 2461 2462 # DuckDB widens DATE to TIMESTAMP when adding intervals, so cast back 2463 if date_expr.is_type(exp.DType.DATE): 2464 return self.sql(exp.cast(add_expr, exp.DType.DATE)) 2465 2466 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, origin)
Snowflake aligns slices to the 1970-01-01 epoch, so an explicit origin is required because DuckDB's default origin differs. For 'END' kind, add the interval to get the next slice's start; for DATE input, cast back to DATE.
2468 def unicodestring_sql(self, expression: exp.UnicodeString) -> str: 2469 # DuckDB has no Unicode escape strings and doesn't read \u in a string, so the escapes 2470 # are decoded into the characters, e.g. U&'d\0061t\0061' and U&'d!+000061ta' UESCAPE '!' 2471 # are both 'data' 2472 escape = expression.args.get("escape") 2473 escape_char = re.escape(escape.name if isinstance(escape, exp.Expr) else "\\") 2474 pattern = re.compile( 2475 rf"{escape_char}(?:\+([0-9a-fA-F]{{6}})|([0-9a-fA-F]{{4}})|({escape_char}))" 2476 ) 2477 2478 def decode(match: re.Match[str]) -> str: 2479 if match.group(3): 2480 return match.group(3) 2481 return chr(int(match.group(1) or match.group(2), 16)) 2482 2483 # A character outside the BMP can be written as a UTF-16 surrogate pair 2484 text = ( 2485 pattern.sub(decode, expression.name).encode("utf-16", "surrogatepass").decode("utf-16") 2486 ) 2487 return self.sql(exp.Literal.string(text))
2489 def bytestring_sql(self, expression: exp.ByteString) -> str: 2490 if not expression.args.get("is_bytes"): 2491 return super().bytestring_sql(expression) 2492 2493 # Each char is a byte. Casting to BLOB decodes \xHH, so everything but printable ASCII 2494 # (including the backslash itself) is written as such 2495 text = "".join( 2496 ch if ch.isascii() and ch.isprintable() and ch != "\\" else f"\\x{ord(ch):02x}" 2497 for ch in expression.this 2498 ) 2499 return self.sql(exp.cast(exp.Literal.string(text), exp.DType.BINARY))
2501 def bitmapbucketnumber_sql(self, expression: exp.BitmapBucketNumber) -> str: 2502 """ 2503 Transpile BITMAP_BUCKET_NUMBER function from Snowflake to DuckDB equivalent. 2504 2505 Snowflake's BITMAP_BUCKET_NUMBER returns a 1-based bucket identifier where: 2506 - Each bucket covers 32,768 values 2507 - Bucket numbering starts at 1 2508 - Formula: ((value - 1) // 32768) + 1 for positive values 2509 2510 For non-positive values (0 and negative), we use value // 32768 to avoid 2511 producing bucket 0 or positive bucket IDs for negative inputs. 2512 """ 2513 value = expression.this 2514 2515 positive_formula = ((value - 1) // 32768) + 1 2516 non_positive_formula = value // 32768 2517 2518 # CASE WHEN value > 0 THEN ((value - 1) // 32768) + 1 ELSE value // 32768 END 2519 case_expr = ( 2520 exp.case() 2521 .when(exp.GT(this=value, expression=exp.Literal.number(0)), positive_formula) 2522 .else_(non_positive_formula) 2523 ) 2524 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.
2526 def bitmapbitposition_sql(self, expression: exp.BitmapBitPosition) -> str: 2527 """ 2528 Transpile Snowflake's BITMAP_BIT_POSITION to DuckDB CASE expression. 2529 2530 Snowflake's BITMAP_BIT_POSITION behavior: 2531 - For n <= 0: returns ABS(n) % 32768 2532 - For n > 0: returns (n - 1) % 32768 (maximum return value is 32767) 2533 """ 2534 this = expression.this 2535 2536 return self.sql( 2537 exp.Mod( 2538 this=exp.Paren( 2539 this=exp.If( 2540 this=exp.GT(this=this, expression=exp.Literal.number(0)), 2541 true=this - exp.Literal.number(1), 2542 false=exp.Abs(this=this), 2543 ) 2544 ), 2545 expression=MAX_BIT_POSITION, 2546 ) 2547 )
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)
2549 def bitmapconstructagg_sql(self, expression: exp.BitmapConstructAgg) -> str: 2550 """ 2551 Transpile Snowflake's BITMAP_CONSTRUCT_AGG to DuckDB equivalent. 2552 Uses a pre-parsed template with placeholders replaced by expression nodes. 2553 2554 Snowflake bitmap format: 2555 - Small (< 5 unique values): 2-byte count (big-endian) + values (little-endian) + padding to 10 bytes 2556 - Large (>= 5 unique values): 10-byte header (0x08 + 9 zeros) + values (little-endian) 2557 """ 2558 arg = expression.this 2559 return ( 2560 f"({self.sql(exp.replace_placeholders(self.BITMAP_CONSTRUCT_AGG_TEMPLATE, arg=arg))})" 2561 )
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)
2605 def jarowinklersimilarity_sql(self, expression: exp.JarowinklerSimilarity) -> str: 2606 this = expression.this 2607 expr = expression.expression 2608 2609 if expression.args.get("case_insensitive"): 2610 this = exp.Upper(this=this) 2611 expr = exp.Upper(this=expr) 2612 2613 result = exp.func("JARO_WINKLER_SIMILARITY", this, expr) 2614 2615 if expression.args.get("integer_scale"): 2616 result = exp.cast(result * 100, "INTEGER") 2617 2618 return self.sql(result)
2620 def randstr_sql(self, expression: exp.Randstr) -> str: 2621 """ 2622 Transpile Snowflake's RANDSTR to DuckDB equivalent using deterministic hash-based random. 2623 Uses a pre-parsed template with placeholders replaced by expression nodes. 2624 2625 RANDSTR(length, generator) generates a random string of specified length. 2626 - With numeric seed: Use HASH(i + seed) for deterministic output (same seed = same result) 2627 - With RANDOM(): Use RANDOM() in the hash for non-deterministic output 2628 - No generator: Use default seed value 2629 """ 2630 length = expression.this 2631 generator = expression.args.get("generator") 2632 2633 if generator: 2634 if isinstance(generator, exp.Rand): 2635 # If it's RANDOM(), use its seed if available, otherwise use RANDOM() itself 2636 seed_value = generator.this or generator 2637 else: 2638 # Const/int or other expression - use as seed directly 2639 seed_value = generator 2640 else: 2641 # No generator specified, use default seed (arbitrary but deterministic) 2642 seed_value = exp.Literal.number(RANDSTR_SEED) 2643 2644 replacements = {"seed": seed_value, "length": length} 2645 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
2647 @unsupported_args("finish") 2648 def reduce_sql(self, expression: exp.Reduce) -> str: 2649 array_arg = expression.this 2650 initial_value = expression.args.get("initial") 2651 merge_lambda = expression.args.get("merge") 2652 2653 return self.func("list_reduce", array_arg, merge_lambda, initial_value)
2655 def zipf_sql(self, expression: exp.Zipf) -> str: 2656 """ 2657 Transpile Snowflake's ZIPF to DuckDB using CDF-based inverse sampling. 2658 Uses a pre-parsed template with placeholders replaced by expression nodes. 2659 """ 2660 s = expression.this 2661 n = expression.args["elementcount"] 2662 gen = expression.args["gen"] 2663 2664 if not isinstance(gen, exp.Rand): 2665 # (ABS(HASH(seed)) % 1000000) / 1000000.0 2666 random_expr: exp.Expr = exp.Div( 2667 this=exp.Paren( 2668 this=exp.Mod( 2669 this=exp.Abs(this=exp.Anonymous(this="HASH", expressions=[gen.copy()])), 2670 expression=exp.Literal.number(1000000), 2671 ) 2672 ), 2673 expression=exp.Literal.number(1000000.0), 2674 ) 2675 else: 2676 # Use RANDOM() for non-deterministic output 2677 random_expr = exp.Rand() 2678 2679 replacements = {"s": s, "n": n, "random_expr": random_expr} 2680 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.
2682 def tobinary_sql(self, expression: exp.ToBinary) -> str: 2683 """ 2684 TO_BINARY and TRY_TO_BINARY transpilation: 2685 - 'HEX': TO_BINARY('48454C50', 'HEX') -> UNHEX('48454C50') 2686 - 'UTF-8': TO_BINARY('TEST', 'UTF-8') -> ENCODE('TEST') 2687 - 'BASE64': TO_BINARY('SEVMUA==', 'BASE64') -> FROM_BASE64('SEVMUA==') 2688 2689 For TRY_TO_BINARY (safe=True), wrap with TRY(): 2690 - 'HEX': TRY_TO_BINARY('invalid', 'HEX') -> TRY(UNHEX('invalid')) 2691 """ 2692 value = expression.this 2693 format_arg = expression.args.get("format") 2694 is_safe = expression.args.get("safe") 2695 is_binary = _is_binary(expression) 2696 2697 if not format_arg and not is_binary: 2698 func_name = "TRY_TO_BINARY" if is_safe else "TO_BINARY" 2699 return self.func(func_name, value) 2700 2701 # Snowflake defaults to HEX encoding when no format is specified 2702 fmt = format_arg.name.upper() if format_arg else "HEX" 2703 2704 if fmt in ("UTF-8", "UTF8"): 2705 # DuckDB ENCODE always uses UTF-8, no charset parameter needed 2706 result = self.func("ENCODE", value) 2707 elif fmt == "BASE64": 2708 result = self.func("FROM_BASE64", value) 2709 elif fmt == "HEX": 2710 result = self.func("UNHEX", value) 2711 else: 2712 if is_safe: 2713 return self.sql(exp.null()) 2714 else: 2715 self.unsupported(f"format {fmt} is not supported") 2716 result = self.func("TO_BINARY", value) 2717 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'))
2719 def tonumber_sql(self, expression: exp.ToNumber) -> str: 2720 fmt = expression.args.get("format") 2721 precision = expression.args.get("precision") 2722 scale = expression.args.get("scale") 2723 2724 if not fmt and precision and scale: 2725 to = exp.DataType.build(f"DECIMAL({precision.name}, {scale.name})", dialect="duckdb") 2726 if expression.args.get("safe"): 2727 return self.sql(exp.TryCast(this=expression.this, to=to)) 2728 return self.sql(exp.cast(expression.this, to, dialect="duckdb")) 2729 2730 return super().tonumber_sql(expression)
2756 def generator_sql(self, expression: exp.Generator) -> str: 2757 # Transpile Snowflake GENERATOR to DuckDB range() 2758 rowcount = expression.args.get("rowcount") 2759 time_limit = expression.args.get("time_limit") 2760 2761 if time_limit: 2762 self.unsupported("GENERATOR TIMELIMIT parameter is not supported in DuckDB") 2763 2764 if not rowcount: 2765 self.unsupported("GENERATOR without ROWCOUNT is not supported in DuckDB") 2766 return self.func("range", exp.Literal.number(0)) 2767 2768 return self.func("range", rowcount)
2776 def lambda_sql(self, expression: exp.Lambda, arrow_sep: str = "->", wrap: bool = True) -> str: 2777 if self.dialect.version >= (1, 3): 2778 prefix = "LAMBDA " 2779 arrow_sep = ":" 2780 wrap = False 2781 else: 2782 prefix = "" 2783 2784 lambda_sql = super().lambda_sql(expression, arrow_sep=arrow_sep, wrap=wrap) 2785 return f"{prefix}{lambda_sql}"
2801 def install_sql(self, expression: exp.Install) -> str: 2802 force = "FORCE " if expression.args.get("force") else "" 2803 this = self.sql(expression, "this") 2804 from_clause = expression.args.get("from_") 2805 from_clause = f" FROM {from_clause}" if from_clause else "" 2806 return f"{force}INSTALL {this}{from_clause}"
2814 def strposition_sql(self, expression: exp.StrPosition) -> str: 2815 this = expression.this 2816 substr = expression.args.get("substr") 2817 position = expression.args.get("position") 2818 2819 # For BINARY/BLOB: DuckDB's STRPOS doesn't support BLOB types 2820 # Convert to HEX strings, use STRPOS, then convert hex position to byte position 2821 if _is_binary(this): 2822 # Build expression: STRPOS(HEX(haystack), HEX(needle)) 2823 hex_strpos = exp.StrPosition( 2824 this=exp.Hex(this=this), 2825 substr=exp.Hex(this=substr), 2826 ) 2827 2828 return self.sql(exp.cast((hex_strpos + 1) / 2, exp.DType.INT)) 2829 2830 # For VARCHAR: handle clamp_position 2831 if expression.args.get("clamp_position") and position: 2832 expression = expression.copy() 2833 expression.set( 2834 "position", 2835 exp.If( 2836 this=exp.LTE(this=position, expression=exp.Literal.number(0)), 2837 true=exp.Literal.number(1), 2838 false=position.copy(), 2839 ), 2840 ) 2841 2842 return strposition_sql(self, expression)
2844 def substring_sql(self, expression: exp.Substring) -> str: 2845 if expression.args.get("zero_start"): 2846 start = expression.args.get("start") 2847 length = expression.args.get("length") 2848 2849 if start := expression.args.get("start"): 2850 start = exp.If(this=start.eq(0), true=exp.Literal.number(1), false=start) 2851 if length := expression.args.get("length"): 2852 length = exp.If(this=length < 0, true=exp.Literal.number(0), false=length) 2853 2854 return self.func("SUBSTRING", expression.this, start, length) 2855 2856 return self.function_fallback_sql(expression)
2858 def strtotime_sql(self, expression: exp.StrToTime) -> str: 2859 # Check if target_type requires TIMESTAMPTZ (for LTZ/TZ variants) 2860 target_type = expression.args.get("target_type") 2861 needs_tz = target_type and target_type.this in ( 2862 exp.DType.TIMESTAMPLTZ, 2863 exp.DType.TIMESTAMPTZ, 2864 ) 2865 2866 value, formatted_time = self._strptime_default_year(expression) 2867 2868 if expression.args.get("safe"): 2869 cast_type = exp.DType.TIMESTAMPTZ if needs_tz else exp.DType.TIMESTAMP 2870 return self.sql(exp.cast(self.func("TRY_STRPTIME", value, formatted_time), cast_type)) 2871 2872 base_sql = self.func("STRPTIME", value, formatted_time) 2873 if needs_tz: 2874 return self.sql( 2875 exp.cast( 2876 base_sql, 2877 exp.DataType(this=exp.DType.TIMESTAMPTZ), 2878 ) 2879 ) 2880 return base_sql
2882 def strtodate_sql(self, expression: exp.StrToDate) -> str: 2883 value, formatted_time = self._strptime_default_year(expression) 2884 function_name = "STRPTIME" if not expression.args.get("safe") else "TRY_STRPTIME" 2885 return self.sql( 2886 exp.cast( 2887 self.func(function_name, value, formatted_time), 2888 exp.DataType(this=exp.DType.DATE), 2889 ) 2890 )
2917 def tsordstotime_sql(self, expression: exp.TsOrDsToTime) -> str: 2918 this = expression.this 2919 time_format = self.format_time(expression) 2920 safe = expression.args.get("safe") 2921 time_type = exp.DataType.from_str("TIME", dialect="duckdb") 2922 cast_expr = exp.TryCast if safe else exp.Cast 2923 2924 if time_format: 2925 func_name = "TRY_STRPTIME" if safe else "STRPTIME" 2926 strptime = exp.Anonymous(this=func_name, expressions=[this, time_format]) 2927 return self.sql(cast_expr(this=strptime, to=time_type)) 2928 2929 if isinstance(this, exp.TsOrDsToTime) or this.is_type(exp.DType.TIME): 2930 return self.sql(this) 2931 2932 return self.sql(cast_expr(this=this, to=time_type))
2934 def currentdate_sql(self, expression: exp.CurrentDate) -> str: 2935 if not expression.this: 2936 return "CURRENT_DATE" 2937 2938 expr = exp.Cast( 2939 this=exp.AtTimeZone(this=exp.CurrentTimestamp(), zone=expression.this), 2940 to=exp.DataType(this=exp.DType.DATE), 2941 ) 2942 return self.sql(expr)
2955 def parsejson_sql(self, expression: exp.ParseJSON) -> str: 2956 arg = expression.this 2957 if expression.args.get("safe"): 2958 return self.sql( 2959 exp.case() 2960 .when(exp.func("json_valid", arg), exp.cast(arg.copy(), "JSON")) 2961 .else_(exp.null()) 2962 ) 2963 return self.func("JSON", arg)
2965 def unicode_sql(self, expression: exp.Unicode) -> str: 2966 if expression.args.get("empty_is_zero"): 2967 return self.sql( 2968 exp.case() 2969 .when(expression.this.eq(exp.Literal.string("")), exp.Literal.number(0)) 2970 .else_(exp.Anonymous(this="UNICODE", expressions=[expression.this])) 2971 ) 2972 2973 return self.func("UNICODE", expression.this)
2982 def trunc_sql(self, expression: exp.Trunc) -> str: 2983 decimals = expression.args.get("decimals") 2984 if ( 2985 expression.args.get("fractions_supported") 2986 and decimals 2987 and not decimals.is_type(exp.DType.INT) 2988 ): 2989 decimals = exp.cast(decimals, exp.DType.INT, dialect="duckdb") 2990 2991 return self.func("TRUNC", expression.this, decimals)
2993 def normal_sql(self, expression: exp.Normal) -> str: 2994 """ 2995 Transpile Snowflake's NORMAL(mean, stddev, gen) to DuckDB. 2996 2997 Uses the Box-Muller transform via NORMAL_TEMPLATE. 2998 """ 2999 mean = expression.this 3000 stddev = expression.args["stddev"] 3001 gen: exp.Expr = expression.args["gen"] 3002 3003 # Build two uniform random values [0, 1) for Box-Muller transform 3004 if isinstance(gen, exp.Rand) and gen.this is None: 3005 u1: exp.Expr = exp.Rand() 3006 u2: exp.Expr = exp.Rand() 3007 else: 3008 # Seeded: derive two values using HASH with different inputs 3009 seed = gen.this if isinstance(gen, exp.Rand) else gen 3010 u1 = exp.replace_placeholders(self.SEEDED_RANDOM_TEMPLATE, seed=seed) 3011 u2 = exp.replace_placeholders( 3012 self.SEEDED_RANDOM_TEMPLATE, 3013 seed=exp.Add(this=seed.copy(), expression=exp.Literal.number(1)), 3014 ) 3015 3016 replacements = {"mean": mean, "stddev": stddev, "u1": u1, "u2": u2} 3017 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.
3019 def uniform_sql(self, expression: exp.Uniform) -> str: 3020 """ 3021 Transpile Snowflake's UNIFORM(min, max, gen) to DuckDB. 3022 3023 UNIFORM returns a random value in [min, max]: 3024 - Integer result if both min and max are integers 3025 - Float result if either min or max is a float 3026 """ 3027 min_val = expression.this 3028 max_val = expression.expression 3029 gen = expression.args.get("gen") or expression.args.get("seed") or exp.Rand() 3030 3031 # Determine if result should be integer (both bounds are integers). 3032 # We do this to emulate Snowflake's behavior, INT -> INT, FLOAT -> FLOAT 3033 is_int_result = min_val.is_int and max_val.is_int 3034 3035 # Build the random value expression [0, 1) 3036 if not isinstance(gen, exp.Rand): 3037 # Seed value: (ABS(HASH(seed)) % 1000000) / 1000000.0 3038 random_expr: exp.Expr = exp.Div( 3039 this=exp.Paren( 3040 this=exp.Mod( 3041 this=exp.Abs(this=exp.Anonymous(this="HASH", expressions=[gen])), 3042 expression=exp.Literal.number(1000000), 3043 ) 3044 ), 3045 expression=exp.Literal.number(1000000.0), 3046 ) 3047 else: 3048 random_expr = exp.Rand() 3049 3050 # Build: min + random * (max - min [+ 1 for int]) 3051 range_expr: exp.Expr = exp.Sub(this=max_val, expression=min_val) 3052 if is_int_result: 3053 range_expr = exp.Add(this=range_expr, expression=exp.Literal.number(1)) 3054 3055 result: exp.Expr = exp.Add( 3056 this=min_val, 3057 expression=exp.Mul(this=random_expr, expression=exp.Paren(this=range_expr)), 3058 ) 3059 3060 if is_int_result: 3061 result = exp.Cast(this=exp.Floor(this=result), to=exp.DType.BIGINT.into_expr()) 3062 3063 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
3065 def timefromparts_sql(self, expression: exp.TimeFromParts) -> str: 3066 nano = expression.args.get("nano") 3067 overflow = expression.args.get("overflow") 3068 3069 # Snowflake's TIME_FROM_PARTS supports overflow 3070 if overflow: 3071 hour = expression.args["hour"] 3072 minute = expression.args["min"] 3073 sec = expression.args["sec"] 3074 3075 # Check if values are within normal ranges - use MAKE_TIME for efficiency 3076 if not nano and all(arg.is_int for arg in [hour, minute, sec]): 3077 try: 3078 h_val = hour.to_py() 3079 m_val = minute.to_py() 3080 s_val = sec.to_py() 3081 if 0 <= h_val <= 23 and 0 <= m_val <= 59 and 0 <= s_val <= 59: 3082 return rename_func("MAKE_TIME")(self, expression) 3083 except ValueError: 3084 pass 3085 3086 # Overflow or nanoseconds detected - use INTERVAL arithmetic 3087 if nano: 3088 sec = sec + nano.pop() / exp.Literal.number(1000000000.0) 3089 3090 total_seconds = hour * exp.Literal.number(3600) + minute * exp.Literal.number(60) + sec 3091 3092 return self.sql( 3093 exp.Add( 3094 this=exp.Cast( 3095 this=exp.Literal.string("00:00:00"), to=exp.DType.TIME.into_expr() 3096 ), 3097 expression=exp.Interval(this=total_seconds, unit=exp.var("SECOND")), 3098 ) 3099 ) 3100 3101 # Default: MAKE_TIME 3102 if nano: 3103 expression.set( 3104 "sec", expression.args["sec"] + nano.pop() / exp.Literal.number(1000000000.0) 3105 ) 3106 3107 return rename_func("MAKE_TIME")(self, expression)
3109 def extract_sql(self, expression: exp.Extract) -> str: 3110 """ 3111 Transpile EXTRACT/DATE_PART for DuckDB, handling specifiers not natively supported. 3112 3113 DuckDB doesn't support: WEEKISO, YEAROFWEEK, YEAROFWEEKISO, NANOSECOND, 3114 EPOCH_SECOND (as integer), EPOCH_MILLISECOND, EPOCH_MICROSECOND, EPOCH_NANOSECOND 3115 """ 3116 this = expression.this 3117 datetime_expr = expression.expression 3118 3119 # TIMESTAMPTZ extractions may produce different results between Snowflake and DuckDB 3120 # because Snowflake applies server timezone while DuckDB uses local timezone 3121 if datetime_expr.is_type(exp.DType.TIMESTAMPTZ, exp.DType.TIMESTAMPLTZ): 3122 self.unsupported( 3123 "EXTRACT from TIMESTAMPTZ / TIMESTAMPLTZ may produce different results due to timezone handling differences" 3124 ) 3125 3126 part_name = this.name.upper() 3127 3128 if part_name in self.EXTRACT_STRFTIME_MAPPINGS: 3129 fmt, cast_type = self.EXTRACT_STRFTIME_MAPPINGS[part_name] 3130 3131 # Problem: strftime doesn't accept TIME and there's no NANOSECOND function 3132 # So, for NANOSECOND with TIME, fallback to MICROSECOND * 1000 3133 is_nano_time = part_name == "NANOSECOND" and datetime_expr.is_type( 3134 exp.DType.TIME, exp.DType.TIMETZ 3135 ) 3136 3137 if is_nano_time: 3138 self.unsupported("Parameter NANOSECOND is not supported with TIME type in DuckDB") 3139 return self.sql( 3140 exp.cast( 3141 exp.Mul( 3142 this=exp.Extract(this=exp.var("MICROSECOND"), expression=datetime_expr), 3143 expression=exp.Literal.number(1000), 3144 ), 3145 exp.DataType.from_str(cast_type, dialect="duckdb"), 3146 ) 3147 ) 3148 3149 # For NANOSECOND, cast to TIMESTAMP_NS to preserve nanosecond precision 3150 strftime_input = datetime_expr 3151 if part_name == "NANOSECOND": 3152 strftime_input = exp.cast(datetime_expr, exp.DType.TIMESTAMP_NS) 3153 3154 return self.sql( 3155 exp.cast( 3156 exp.Anonymous( 3157 this="STRFTIME", 3158 expressions=[strftime_input, exp.Literal.string(fmt)], 3159 ), 3160 exp.DataType.from_str(cast_type, dialect="duckdb"), 3161 ) 3162 ) 3163 3164 if part_name in self.EXTRACT_EPOCH_MAPPINGS: 3165 func_name = self.EXTRACT_EPOCH_MAPPINGS[part_name] 3166 result: exp.Expr = exp.Anonymous(this=func_name, expressions=[datetime_expr]) 3167 # EPOCH returns float, cast to BIGINT for integer result 3168 if part_name == "EPOCH_SECOND": 3169 result = exp.cast(result, exp.DataType.from_str("BIGINT", dialect="duckdb")) 3170 return self.sql(result) 3171 3172 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
3174 def timestampfromparts_sql(self, expression: exp.TimestampFromParts) -> str: 3175 # Check if this is the date/time expression form: TIMESTAMP_FROM_PARTS(date_expr, time_expr) 3176 date_expr = expression.this 3177 time_expr = expression.expression 3178 3179 if date_expr is not None and time_expr is not None: 3180 # In DuckDB, DATE + TIME produces TIMESTAMP 3181 return self.sql(exp.Add(this=date_expr, expression=time_expr)) 3182 3183 # Component-based form: TIMESTAMP_FROM_PARTS(year, month, day, hour, minute, second, ...) 3184 sec = expression.args.get("sec") 3185 if sec is None: 3186 # This shouldn't happen with valid input, but handle gracefully 3187 return rename_func("MAKE_TIMESTAMP")(self, expression) 3188 3189 milli = expression.args.get("milli") 3190 if milli is not None: 3191 sec += milli.pop() / exp.Literal.number(1000.0) 3192 3193 nano = expression.args.get("nano") 3194 if nano is not None: 3195 sec += nano.pop() / exp.Literal.number(1000000000.0) 3196 3197 if milli or nano: 3198 expression.set("sec", sec) 3199 3200 if zone := expression.args.get("zone"): 3201 return self.sql(exp.AtTimeZone(this=expression, zone=zone.pop())) 3202 3203 return rename_func("MAKE_TIMESTAMP")(self, expression)
3205 @unsupported_args("nano") 3206 def timestampltzfromparts_sql(self, expression: exp.TimestampLtzFromParts) -> str: 3207 # Pop nano so rename_func only passes args that MAKE_TIMESTAMP accepts 3208 if nano := expression.args.get("nano"): 3209 nano.pop() 3210 3211 timestamp = rename_func("MAKE_TIMESTAMP")(self, expression) 3212 return f"CAST({timestamp} AS TIMESTAMPTZ)"
3214 @unsupported_args("nano") 3215 def timestamptzfromparts_sql(self, expression: exp.TimestampTzFromParts) -> str: 3216 # Extract zone before popping 3217 zone = expression.args.get("zone") 3218 # Pop zone and nano so rename_func only passes args that MAKE_TIMESTAMP accepts 3219 if zone: 3220 zone = zone.pop() 3221 3222 if nano := expression.args.get("nano"): 3223 nano.pop() 3224 3225 timestamp = rename_func("MAKE_TIMESTAMP")(self, expression) 3226 3227 if zone: 3228 # Use AT TIME ZONE to apply the explicit timezone 3229 return f"{timestamp} AT TIME ZONE {self.sql(zone)}" 3230 3231 return timestamp
3237 def tablesample_sql( 3238 self, 3239 expression: exp.TableSample, 3240 tablesample_keyword: str | None = None, 3241 ) -> str: 3242 if not isinstance(expression.parent, exp.Select): 3243 # This sample clause only applies to a single source, not the entire resulting relation 3244 tablesample_keyword = "TABLESAMPLE" 3245 3246 if expression.args.get("size"): 3247 method = expression.args.get("method") 3248 if method and method.name.upper() != "RESERVOIR": 3249 self.unsupported( 3250 f"Sampling method {method} is not supported with a discrete sample count, " 3251 "defaulting to reservoir sampling" 3252 ) 3253 expression.set("method", exp.var("RESERVOIR")) 3254 3255 return super().tablesample_sql(expression, tablesample_keyword=tablesample_keyword)
3267 def interval_sql(self, expression: exp.Interval) -> str: 3268 # DuckDB truncates INTERVAL '1.5' DAY to 1 day, but keeps the fraction in INTERVAL '1.5 DAY' 3269 this = expression.this 3270 unit = expression.unit 3271 if expression.args.get("fraction_in_string") and this and isinstance(unit, exp.Var): 3272 return f"INTERVAL '{this.name} {self.sql(unit)}'" 3273 3274 return super().interval_sql(expression)
3276 def join_sql(self, expression: exp.Join, join_op: str | None = None) -> str: 3277 if expression.kind in ("MARK", "SINGLE") or ( 3278 expression.side == "RIGHT" and expression.is_semi_or_anti_join 3279 ): 3280 kind = "_".join(part for part in (expression.side, expression.kind) if part) 3281 return super().join_sql(expression, join_op=f"JOIN BY (TYPE {kind})") 3282 3283 if ( 3284 not expression.args.get("using") 3285 and not expression.args.get("on") 3286 and not expression.args.get("nearest") 3287 and not expression.method 3288 and (expression.kind in ("", "INNER", "OUTER")) 3289 ): 3290 # Some dialects support `LEFT/INNER JOIN UNNEST(...)` without an explicit ON clause 3291 # DuckDB doesn't, but we can just add a dummy ON clause that is always true 3292 if isinstance(expression.this, exp.Unnest): 3293 return super().join_sql(expression.on(exp.true())) 3294 3295 expression.set("side", None) 3296 expression.set("kind", None) 3297 3298 return super().join_sql(expression, join_op=join_op)
3300 def countif_sql(self, expression: exp.CountIf) -> str: 3301 if self.dialect.version >= (1, 2): 3302 this = expression.this 3303 if expression.args.get("zero_on_all_null") and not isinstance(this, exp.Distinct): 3304 # DuckDB >= 1.2's COUNT_IF returns NULL when the condition is NULL on all rows, 3305 # so we wrap the condition in IS TRUE to preserve count-like semantics 3306 expression = exp.CountIf(this=exp.paren(this).is_(exp.true())) 3307 return self.function_fallback_sql(expression) 3308 3309 # https://github.com/tobymao/sqlglot/pull/4749 3310 return count_if_to_sum(self, expression)
3312 def bracket_sql(self, expression: exp.Bracket) -> str: 3313 if self.dialect.version >= (1, 2): 3314 return super().bracket_sql(expression) 3315 3316 # https://duckdb.org/2025/02/05/announcing-duckdb-120.html#breaking-changes 3317 this = expression.this 3318 if isinstance(this, exp.Array): 3319 this.replace(exp.paren(this)) 3320 3321 bracket = super().bracket_sql(expression) 3322 3323 if not expression.args.get("returns_list_for_maps"): 3324 if not this.type: 3325 from sqlglot.optimizer.annotate_types import annotate_types 3326 3327 this = annotate_types(this, dialect=self.dialect) 3328 3329 if this.is_type(exp.DType.MAP): 3330 bracket = f"({bracket})[1]" 3331 3332 return bracket
3334 def withingroup_sql(self, expression: exp.WithinGroup) -> str: 3335 func = expression.this 3336 3337 # For ARRAY_AGG, DuckDB requires ORDER BY inside the function, not in WITHIN GROUP 3338 # Transform: ARRAY_AGG(x) WITHIN GROUP (ORDER BY y) -> ARRAY_AGG(x ORDER BY y) 3339 if isinstance(func, exp.ArrayAgg): 3340 if not isinstance(order := expression.expression, exp.Order): 3341 return self.sql(func) 3342 3343 # Save the original column for FILTER clause (before wrapping with Order) 3344 original_this = func.this 3345 3346 # Move ORDER BY inside ARRAY_AGG by wrapping its argument with Order 3347 # ArrayAgg.this should become Order(this=ArrayAgg.this, expressions=order.expressions) 3348 func.set( 3349 "this", 3350 exp.Order( 3351 this=func.this.copy(), 3352 expressions=order.expressions, 3353 ), 3354 ) 3355 3356 # Generate the ARRAY_AGG function with ORDER BY and add FILTER clause if needed 3357 # Use original_this (not the Order-wrapped version) for the FILTER condition 3358 array_agg_sql = self.function_fallback_sql(func) 3359 return self._add_arrayagg_null_filter(array_agg_sql, func, original_this) 3360 3361 # For other functions (like PERCENTILES), use existing logic 3362 expression_sql = self.sql(expression, "expression") 3363 3364 if isinstance(func, exp.PERCENTILES): 3365 # Make the order key the first arg and slide the fraction to the right 3366 # https://duckdb.org/docs/sql/aggregates#ordered-set-aggregate-functions 3367 order_col = expression.find(exp.Ordered) 3368 if order_col: 3369 func.set("expression", func.this) 3370 func.set("this", order_col.this) 3371 3372 this = self.sql(expression, "this").rstrip(")") 3373 3374 return f"{this}{expression_sql})"
3376 def length_sql(self, expression: exp.Length) -> str: 3377 arg = expression.this 3378 3379 # Dialects like BQ and Snowflake also accept binary values as args, so 3380 # DDB will attempt to infer the type or resort to case/when resolution 3381 if not expression.args.get("binary") or arg.is_string: 3382 return self.func("LENGTH", arg) 3383 3384 if not arg.type: 3385 from sqlglot.optimizer.annotate_types import annotate_types 3386 3387 arg = annotate_types(arg, dialect=self.dialect) 3388 3389 if arg.is_type(*exp.DataType.TEXT_TYPES): 3390 return self.func("LENGTH", arg) 3391 3392 # We need these casts to make duckdb's static type checker happy 3393 blob = exp.cast(arg, exp.DType.VARBINARY) 3394 varchar = exp.cast(arg, exp.DType.VARCHAR) 3395 3396 case = ( 3397 exp.case(exp.Anonymous(this="TYPEOF", expressions=[arg])) 3398 .when(exp.Literal.string("BLOB"), exp.ByteLength(this=blob)) 3399 .else_(exp.Anonymous(this="LENGTH", expressions=[varchar])) 3400 ) 3401 return self.sql(case)
3420 def collate_sql(self, expression: exp.Collate) -> str: 3421 if not expression.expression.is_string: 3422 return super().collate_sql(expression) 3423 3424 raw = expression.expression.name 3425 if not raw: 3426 return self.sql(expression.this) 3427 3428 parts = [] 3429 for part in raw.split("-"): 3430 lower = part.lower() 3431 if lower not in _SNOWFLAKE_COLLATION_DEFAULTS: 3432 if lower in _SNOWFLAKE_COLLATION_UNSUPPORTED: 3433 self.unsupported( 3434 f"Snowflake collation specifier '{part}' has no DuckDB equivalent" 3435 ) 3436 parts.append(lower) 3437 3438 if not parts: 3439 return self.sql(expression.this) 3440 return super().collate_sql( 3441 exp.Collate(this=expression.this, expression=exp.var(".".join(parts))) 3442 )
3474 def regexpcount_sql(self, expression: exp.RegexpCount) -> str: 3475 this = expression.this 3476 pattern = expression.expression 3477 position = expression.args.get("position") 3478 parameters = expression.args.get("parameters") 3479 3480 # Validate flags - only "ims" flags are supported for embedded patterns 3481 validated_flags = self._validate_regexp_flags(parameters, supported_flags="ims") 3482 3483 if position: 3484 this = exp.Substring(this=this, start=position) 3485 3486 # Handle empty pattern: Snowflake returns 0, DuckDB would match between every character. 3487 # Checked before the flags are embedded, which would make the pattern non-empty 3488 is_empty = exp.EQ(this=pattern.copy(), expression=exp.Literal.string("")) 3489 3490 # Embed flags in pattern (REGEXP_EXTRACT_ALL doesn't support flags argument) 3491 if validated_flags: 3492 pattern = exp.Concat(expressions=[exp.Literal.string(f"(?{validated_flags})"), pattern]) 3493 3494 result = ( 3495 exp.case() 3496 .when(is_empty, exp.Literal.number(0)) 3497 .else_( 3498 exp.Length( 3499 this=exp.Anonymous(this="REGEXP_EXTRACT_ALL", expressions=[this, pattern]) 3500 ) 3501 ) 3502 ) 3503 3504 return self.sql(result)
3506 def regexpreplace_sql(self, expression: exp.RegexpReplace) -> str: 3507 subject = expression.this 3508 pattern = expression.expression 3509 replacement = expression.args.get("replacement") or exp.Literal.string("") 3510 position = expression.args.get("position") 3511 occurrence = expression.args.get("occurrence") 3512 modifiers = expression.args.get("modifiers") 3513 3514 validated_flags = self._validate_regexp_flags(modifiers, supported_flags="cimsg") or "" 3515 3516 # Handle occurrence (only literals supported) 3517 if occurrence and not occurrence.is_int: 3518 self.unsupported("REGEXP_REPLACE with non-literal occurrence") 3519 else: 3520 occurrence = occurrence.to_py() if occurrence and occurrence.is_int else 0 3521 if occurrence > 1: 3522 self.unsupported(f"REGEXP_REPLACE occurrence={occurrence} not supported") 3523 # flag duckdb to do either all or none, single_replace check is for duckdb round trip 3524 elif ( 3525 occurrence == 0 3526 and "g" not in validated_flags 3527 and not expression.args.get("single_replace") 3528 ): 3529 validated_flags += "g" 3530 3531 # Handle position (only literals supported) 3532 prefix = None 3533 if position and not position.is_int: 3534 self.unsupported("REGEXP_REPLACE with non-literal position") 3535 elif position and position.is_int and position.to_py() > 1: 3536 pos = position.to_py() 3537 prefix = exp.Substring( 3538 this=subject, start=exp.Literal.number(1), length=exp.Literal.number(pos - 1) 3539 ) 3540 subject = exp.Substring(this=subject, start=exp.Literal.number(pos)) 3541 3542 result: exp.Expr = exp.Anonymous( 3543 this="REGEXP_REPLACE", 3544 expressions=[ 3545 subject, 3546 pattern, 3547 replacement, 3548 exp.Literal.string(validated_flags) if validated_flags else None, 3549 ], 3550 ) 3551 3552 if prefix: 3553 result = exp.Concat(expressions=[prefix, result]) 3554 3555 return self.sql(result)
3557 def regexplike_sql(self, expression: exp.RegexpLike) -> str: 3558 this = expression.this 3559 pattern = expression.expression 3560 flag = expression.args.get("flag") 3561 3562 if expression.args.get("full_match"): 3563 validated_flags = self._validate_regexp_flags(flag, supported_flags="cims") 3564 flag = exp.Literal.string(validated_flags) if validated_flags else None 3565 return self.func("REGEXP_FULL_MATCH", this, pattern, flag) 3566 3567 return self.func("REGEXP_MATCHES", this, pattern, flag)
3569 @unsupported_args("ins_cost", "del_cost", "sub_cost") 3570 def levenshtein_sql(self, expression: exp.Levenshtein) -> str: 3571 this = expression.this 3572 expr = expression.expression 3573 max_dist = expression.args.get("max_dist") 3574 3575 if max_dist is None: 3576 return self.func("LEVENSHTEIN", this, expr) 3577 3578 # Emulate Snowflake semantics: if distance > max_dist, return max_dist 3579 levenshtein = exp.Levenshtein(this=this, expression=expr) 3580 return self.sql(exp.Least(this=levenshtein, expressions=[max_dist]))
3582 def pad_sql(self, expression: exp.Pad) -> str: 3583 """ 3584 Handle RPAD/LPAD for VARCHAR and BINARY types. 3585 3586 For VARCHAR: Delegate to parent class 3587 For BINARY: Lower to: input || REPEAT(pad, GREATEST(0, target_len - OCTET_LENGTH(input))) 3588 """ 3589 string_arg = expression.this 3590 fill_arg = expression.args.get("fill_pattern") or exp.Literal.string(" ") 3591 3592 if _is_binary(string_arg) or _is_binary(fill_arg): 3593 length_arg = expression.expression 3594 is_left = expression.args.get("is_left") 3595 3596 input_len = exp.ByteLength(this=string_arg) 3597 chars_needed = length_arg - input_len 3598 pad_count = exp.Greatest( 3599 this=exp.Literal.number(0), expressions=[chars_needed], ignore_nulls=True 3600 ) 3601 repeat_expr = exp.Repeat(this=fill_arg, times=pad_count) 3602 3603 left, right = string_arg, repeat_expr 3604 if is_left: 3605 left, right = right, left 3606 3607 result = exp.DPipe(this=left, expression=right) 3608 return self.sql(result) 3609 3610 # For VARCHAR: Delegate to parent class (handles PAD_FILL_PATTERN_IS_REQUIRED) 3611 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)))
3613 def minhash_sql(self, expression: exp.Minhash) -> str: 3614 k = expression.this 3615 exprs = expression.expressions 3616 3617 if len(exprs) != 1 or isinstance(exprs[0], exp.Star): 3618 self.unsupported( 3619 "MINHASH with multiple expressions or * requires manual query restructuring" 3620 ) 3621 return self.func("MINHASH", k, *exprs) 3622 3623 expr = exprs[0] 3624 result = exp.replace_placeholders(self.MINHASH_TEMPLATE.copy(), expr=expr, k=k) 3625 return f"({self.sql(result)})"
3645 def arrayconcatagg_sql(self, expression: exp.ArrayConcatAgg) -> str: 3646 this = expression.this 3647 3648 if isinstance(this, exp.Limit): 3649 self.unsupported("LIMIT in ARRAY_CONCAT_AGG cannot be transpiled to DuckDB") 3650 this = this.this 3651 3652 inner = this.this if isinstance(this, exp.Order) else this 3653 3654 return self.func( 3655 "FLATTEN", 3656 exp.Filter( 3657 this=exp.ArrayAgg(this=this), 3658 expression=exp.Where(this=inner.copy().is_(exp.null()).not_()), 3659 ), 3660 )
3666 def arraydistinct_sql(self, expression: exp.ArrayDistinct) -> str: 3667 arr = expression.this 3668 func = self.func("LIST_DISTINCT", arr) 3669 3670 if expression.args.get("check_null"): 3671 add_null_to_array = exp.func( 3672 "LIST_APPEND", exp.func("LIST_DISTINCT", exp.ArrayCompact(this=arr)), exp.Null() 3673 ) 3674 return self.sql( 3675 exp.If( 3676 this=exp.NEQ( 3677 this=exp.ArraySize(this=arr), expression=exp.func("LIST_COUNT", arr) 3678 ), 3679 true=add_null_to_array, 3680 false=func, 3681 ) 3682 ) 3683 3684 return func
3686 def arrayintersect_sql(self, expression: exp.ArrayIntersect) -> str: 3687 if expression.args.get("is_multiset") and len(expression.expressions) == 2: 3688 return self._array_bag_sql( 3689 self.ARRAY_INTERSECTION_CONDITION, 3690 expression.expressions[0], 3691 expression.expressions[1], 3692 ) 3693 return self.function_fallback_sql(expression)
3695 def arrayexcept_sql(self, expression: exp.ArrayExcept) -> str: 3696 arr1, arr2 = expression.this, expression.expression 3697 if expression.args.get("is_multiset"): 3698 return self._array_bag_sql(self.ARRAY_EXCEPT_CONDITION, arr1, arr2) 3699 return self.sql( 3700 exp.replace_placeholders(self.ARRAY_EXCEPT_SET_TEMPLATE, arr1=arr1, arr2=arr2) 3701 )
3703 def arrayslice_sql(self, expression: exp.ArraySlice) -> str: 3704 """ 3705 Spark's SLICE uses a length, so normalize negative starts before computing 3706 the inclusive end index. Starts before the array produce an empty slice. 3707 3708 Transpiles Snowflake's ARRAY_SLICE (0-indexed, exclusive end) to DuckDB's 3709 ARRAY_SLICE (1-indexed, inclusive end) by wrapping start and end in CASE 3710 expressions that adjust the index at query time: 3711 - start: CASE WHEN start >= 0 THEN start + 1 ELSE start END 3712 - end: CASE WHEN end < 0 THEN end - 1 ELSE end END 3713 """ 3714 start, end = expression.args.get("start"), expression.args.get("end") 3715 3716 if expression.args.get("zero_based"): 3717 if start is not None: 3718 start = ( 3719 exp.case() 3720 .when( 3721 exp.GTE(this=start.copy(), expression=exp.Literal.number(0)), 3722 exp.Add(this=start.copy(), expression=exp.Literal.number(1)), 3723 ) 3724 .else_(start) 3725 ) 3726 if end is not None and not expression.args.get("is_length"): 3727 end = ( 3728 exp.case() 3729 .when( 3730 exp.LT(this=end.copy(), expression=exp.Literal.number(0)), 3731 exp.Sub(this=end.copy(), expression=exp.Literal.number(1)), 3732 ) 3733 .else_(end) 3734 ) 3735 3736 if expression.args.get("is_length") and start is not None and end is not None: 3737 if not start.is_int or start.to_py() < 0: 3738 size = exp.ArraySize(this=expression.this.copy()) 3739 start = ( 3740 exp.case() 3741 .when(start < -size, size + 1) 3742 .when(start < 0, size + start + 1) 3743 .else_(start) 3744 ) 3745 3746 if start.is_int and end.is_int: 3747 end = exp.Literal.number(start.to_py() + end.to_py() - 1) 3748 else: 3749 end = start + exp.cast(end, "BIGINT") - 1 3750 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
3752 def arrayszip_sql(self, expression: exp.ArraysZip) -> str: 3753 args = expression.expressions 3754 3755 if not args: 3756 # Return [{}] - using MAP([], []) since DuckDB can't represent empty structs 3757 return self.sql(exp.array(exp.Map(keys=exp.array(), values=exp.array()))) 3758 3759 # Build placeholder values for template 3760 lengths = [exp.Length(this=arg) for arg in args] 3761 max_len = ( 3762 lengths[0] 3763 if len(lengths) == 1 3764 else exp.Greatest(this=lengths[0], expressions=lengths[1:]) 3765 ) 3766 3767 # Empty struct with same schema: {'$1': NULL, '$2': NULL, ...} 3768 empty_struct = exp.func( 3769 "STRUCT", 3770 *[ 3771 exp.PropertyEQ(this=exp.Literal.string(f"${i + 1}"), expression=exp.Null()) 3772 for i in range(len(args)) 3773 ], 3774 ) 3775 3776 # Struct for transform: {'$1': COALESCE(arr1, [])[__i + 1], ...} 3777 # COALESCE wrapping handles NULL arrays - prevents invalid NULL[i] syntax 3778 index = exp.column("__i") + 1 3779 transform_struct = exp.func( 3780 "STRUCT", 3781 *[ 3782 exp.PropertyEQ( 3783 this=exp.Literal.string(f"${i + 1}"), 3784 expression=exp.func("COALESCE", arg, exp.array())[index], 3785 ) 3786 for i, arg in enumerate(args) 3787 ], 3788 ) 3789 3790 result = exp.replace_placeholders( 3791 self.ARRAYS_ZIP_TEMPLATE.copy(), 3792 null_check=exp.or_(*[arg.is_(exp.Null()) for arg in args]), 3793 all_empty_check=exp.and_( 3794 *[ 3795 exp.EQ(this=exp.Length(this=arg), expression=exp.Literal.number(0)) 3796 for arg in args 3797 ] 3798 ), 3799 empty_struct=empty_struct, 3800 max_len=max_len, 3801 transform_struct=transform_struct, 3802 ) 3803 return self.sql(result)
3850 def stuff_sql(self, expression: exp.Stuff) -> str: 3851 base = expression.this 3852 start = expression.args["start"] 3853 length = expression.args["length"] 3854 insertion = expression.expression 3855 is_binary = _is_binary(base) 3856 3857 if is_binary: 3858 # DuckDB's SUBSTRING doesn't accept BLOB; operate on the HEX string instead 3859 # (each byte = 2 hex chars), then UNHEX back to BLOB 3860 base = exp.Hex(this=base) 3861 insertion = exp.Hex(this=insertion) 3862 left = exp.Substring( 3863 this=base.copy(), 3864 start=exp.Literal.number(1), 3865 length=(start.copy() - exp.Literal.number(1)) * exp.Literal.number(2), 3866 ) 3867 right = exp.Substring( 3868 this=base.copy(), 3869 start=((start + length) - exp.Literal.number(1)) * exp.Literal.number(2) 3870 + exp.Literal.number(1), 3871 ) 3872 else: 3873 left = exp.Substring( 3874 this=base.copy(), 3875 start=exp.Literal.number(1), 3876 length=start.copy() - exp.Literal.number(1), 3877 ) 3878 right = exp.Substring(this=base.copy(), start=start + length) 3879 result: exp.Expr = exp.DPipe( 3880 this=exp.DPipe(this=left, expression=insertion), expression=right 3881 ) 3882 3883 if is_binary: 3884 result = exp.Unhex(this=result) 3885 3886 return self.sql(result)
3888 def rand_sql(self, expression: exp.Rand) -> str: 3889 seed = expression.this 3890 if seed is not None: 3891 self.unsupported("RANDOM with seed is not supported in DuckDB") 3892 3893 lower = expression.args.get("lower") 3894 upper = expression.args.get("upper") 3895 3896 if lower and upper: 3897 # scale DuckDB's [0,1) to the specified range 3898 range_size = exp.paren(upper - lower) 3899 scaled = exp.Add(this=lower, expression=exp.func("random") * range_size) 3900 3901 # For now we assume that if bounds are set, return type is BIGINT. Snowflake/Teradata 3902 result = exp.cast(scaled, exp.DType.BIGINT) 3903 return self.sql(result) 3904 3905 # Default DuckDB behavior - just return RANDOM() as float 3906 return "RANDOM()"
3908 def bytelength_sql(self, expression: exp.ByteLength) -> str: 3909 arg = expression.this 3910 3911 # Check if it's a text type (handles both literals and annotated expressions) 3912 if arg.is_type(*exp.DataType.TEXT_TYPES): 3913 return self.func("OCTET_LENGTH", exp.Encode(this=arg)) 3914 3915 # Default: pass through as-is (conservative for DuckDB, handles binary and unannotated) 3916 return self.func("OCTET_LENGTH", arg)
3918 def base64encode_sql(self, expression: exp.Base64Encode) -> str: 3919 # DuckDB TO_BASE64 requires BLOB input 3920 # Snowflake BASE64_ENCODE accepts both VARCHAR and BINARY - for VARCHAR it implicitly 3921 # encodes UTF-8 bytes. We add ENCODE unless the input is a binary type. 3922 result = expression.this 3923 3924 # Check if input is a string type - ENCODE only accepts VARCHAR 3925 if result.is_type(*exp.DataType.TEXT_TYPES): 3926 result = exp.Encode(this=result) 3927 3928 result = exp.ToBase64(this=result) 3929 3930 max_line_length = expression.args.get("max_line_length") 3931 alphabet = expression.args.get("alphabet") 3932 3933 # Handle custom alphabet by replacing standard chars with custom ones 3934 result = _apply_base64_alphabet_replacements(result, alphabet) 3935 3936 # Handle max_line_length by inserting newlines every N characters 3937 line_length = ( 3938 t.cast(int, max_line_length.to_py()) 3939 if isinstance(max_line_length, exp.Literal) and max_line_length.is_number 3940 else 0 3941 ) 3942 if line_length > 0: 3943 newline = exp.Chr(expressions=[exp.Literal.number(10)]) 3944 result = exp.Trim( 3945 this=exp.RegexpReplace( 3946 this=result, 3947 expression=exp.Literal.string(f"(.{{{line_length}}})"), 3948 replacement=exp.Concat(expressions=[exp.Literal.string("\\1"), newline.copy()]), 3949 ), 3950 expression=newline, 3951 position="TRAILING", 3952 ) 3953 3954 return self.sql(result)
3956 def hex_sql(self, expression: exp.Hex) -> str: 3957 case = expression.args.get("case") 3958 3959 if not case: 3960 return self.func("HEX", expression.this) 3961 3962 hex_expr = exp.Hex(this=expression.this) 3963 return self.sql( 3964 exp.case() 3965 .when(case.is_(exp.null()), exp.null()) 3966 .when(case.copy().eq(0), exp.Lower(this=hex_expr.copy())) 3967 .else_(hex_expr) 3968 )
3970 def replace_sql(self, expression: exp.Replace) -> str: 3971 result_sql = self.func( 3972 "REPLACE", 3973 _cast_to_varchar(expression.this), 3974 _cast_to_varchar(expression.expression), 3975 _cast_to_varchar(expression.args.get("replacement")), 3976 ) 3977 return _gen_with_cast_to_blob(self, expression, result_sql)
3989 def objectinsert_sql(self, expression: exp.ObjectInsert) -> str: 3990 this = expression.this 3991 key = expression.args.get("key") 3992 key_sql = key.name if isinstance(key, exp.Expr) else "" 3993 value_sql = self.sql(expression, "value") 3994 3995 kv_sql = f"{key_sql} := {value_sql}" 3996 3997 # If the input struct is empty e.g. transpiling OBJECT_INSERT(OBJECT_CONSTRUCT(), key, value) from Snowflake 3998 # then we can generate STRUCT_PACK which will build it since STRUCT_INSERT({}, key := value) is not valid DuckDB 3999 if isinstance(this, exp.Struct) and not this.expressions: 4000 return self.func("STRUCT_PACK", kv_sql) 4001 4002 return self.func("STRUCT_INSERT", this, kv_sql)
4017 def mapdelete_sql(self, expression: exp.MapDelete) -> str: 4018 map_arg = expression.this 4019 keys_to_delete = expression.expressions 4020 4021 x_dot_key = exp.Dot(this=exp.to_identifier("x"), expression=exp.to_identifier("key")) 4022 4023 lambda_expr = exp.Lambda( 4024 this=exp.In(this=x_dot_key, expressions=keys_to_delete).not_(), 4025 expressions=[exp.to_identifier("x")], 4026 ) 4027 result = exp.func( 4028 "MAP_FROM_ENTRIES", 4029 exp.ArrayFilter(this=exp.func("MAP_ENTRIES", map_arg), expression=lambda_expr), 4030 ) 4031 return self.sql(result)
4033 def mappick_sql(self, expression: exp.MapPick) -> str: 4034 map_arg = expression.this 4035 keys_to_pick = expression.expressions 4036 4037 x_dot_key = exp.Dot(this=exp.to_identifier("x"), expression=exp.to_identifier("key")) 4038 4039 if len(keys_to_pick) == 1 and keys_to_pick[0].is_type(exp.DType.ARRAY): 4040 lambda_expr = exp.Lambda( 4041 this=exp.func("ARRAY_CONTAINS", keys_to_pick[0], x_dot_key), 4042 expressions=[exp.to_identifier("x")], 4043 ) 4044 else: 4045 lambda_expr = exp.Lambda( 4046 this=exp.In(this=x_dot_key, expressions=keys_to_pick), 4047 expressions=[exp.to_identifier("x")], 4048 ) 4049 4050 result = exp.func( 4051 "MAP_FROM_ENTRIES", 4052 exp.func("LIST_FILTER", exp.func("MAP_ENTRIES", map_arg), lambda_expr), 4053 ) 4054 return self.sql(result)
4059 @unsupported_args("update_flag") 4060 def mapinsert_sql(self, expression: exp.MapInsert) -> str: 4061 map_arg = expression.this 4062 key = expression.args.get("key") 4063 value = expression.args.get("value") 4064 4065 map_type = map_arg.type 4066 4067 if value is not None: 4068 if map_type and map_type.expressions and len(map_type.expressions) > 1: 4069 # Extract the value type from MAP(key_type, value_type) 4070 value_type = map_type.expressions[1] 4071 # Cast value to match the map's value type to avoid type conflicts 4072 value = exp.cast(value, value_type) 4073 # else: polymorphic MAP case - no type parameters available, use value as-is 4074 4075 # Create a single-entry map for the new key-value pair 4076 new_entry_struct = exp.Struct(expressions=[exp.PropertyEQ(this=key, expression=value)]) 4077 new_entry: exp.Expression = exp.ToMap(this=new_entry_struct) 4078 4079 # Use MAP_CONCAT to merge the original map with the new entry 4080 # This automatically handles both insert and update cases 4081 result = exp.func("MAP_CONCAT", map_arg, new_entry) 4082 4083 return self.sql(result)
4101 def tablefromrows_sql(self, expression: exp.TableFromRows) -> str: 4102 # For GENERATOR, unwrap TABLE() - just emit the Generator (becomes RANGE) 4103 if isinstance(expression.this, exp.Generator): 4104 # Preserve alias, joins, and other table-level args 4105 table = exp.Table( 4106 this=expression.this, 4107 alias=expression.args.get("alias"), 4108 joins=expression.args.get("joins"), 4109 ) 4110 return self.sql(table) 4111 4112 return super().tablefromrows_sql(expression)
4114 def unnest_sql(self, expression: exp.Unnest) -> str: 4115 explode_array = expression.args.get("explode_array") 4116 if explode_array: 4117 # In BigQuery, UNNESTing a nested array leads to explosion of the top-level array & struct 4118 # This is transpiled to DDB by transforming "FROM UNNEST(...)" to "FROM (SELECT UNNEST(..., max_depth => 2))" 4119 expression.expressions.append( 4120 exp.Kwarg(this=exp.var("max_depth"), expression=exp.Literal.number(2)) 4121 ) 4122 4123 # If BQ's UNNEST is aliased, we transform it from a column alias to a table alias in DDB 4124 alias = expression.args.get("alias") 4125 if isinstance(alias, exp.TableAlias): 4126 expression.set("alias", None) 4127 if alias.columns: 4128 alias = exp.TableAlias(this=seq_get(alias.columns, 0)) 4129 4130 unnest_sql = super().unnest_sql(expression) 4131 select = exp.Select(expressions=[unnest_sql]).subquery(alias) 4132 return self.sql(select) 4133 4134 return super().unnest_sql(expression)
4142 def ignorenulls_sql(self, expression: exp.IgnoreNulls) -> str: 4143 this = expression.this 4144 4145 if isinstance(this, self.IGNORE_RESPECT_NULLS_WINDOW_FUNCTIONS): 4146 # DuckDB should render IGNORE NULLS only for the general-purpose 4147 # window functions that accept it e.g. FIRST_VALUE(... IGNORE NULLS) OVER (...) 4148 return super().ignorenulls_sql(expression) 4149 4150 # For ARRAY_AGG(expr IGNORE NULLS ...), convert IGNORE NULLS to a 4151 # FILTER(WHERE expr IS NOT NULL) clause by setting nulls_excluded on 4152 # the ArrayAgg. The existing _add_arrayagg_null_filter method will 4153 # emit the FILTER clause during arrayagg_sql / withingroup_sql. 4154 if isinstance(this, exp.ArrayAgg): 4155 this.set("nulls_excluded", True) 4156 return self.sql(this) 4157 4158 if isinstance(this, exp.First): 4159 this = exp.AnyValue(this=this.this) 4160 4161 if not isinstance(this, (exp.AnyValue, exp.ApproxQuantiles)): 4162 self.unsupported("IGNORE NULLS is not supported for non-window functions.") 4163 4164 return self.sql(this)
4166 def split_sql(self, expression: exp.Split) -> str: 4167 base_func = exp.func("STR_SPLIT", expression.this, expression.expression) 4168 4169 case_expr = exp.case().else_(base_func) 4170 needs_case = False 4171 4172 if expression.args.get("null_returns_null"): 4173 case_expr = case_expr.when(expression.expression.is_(exp.null()), exp.null()) 4174 needs_case = True 4175 4176 if expression.args.get("empty_delimiter_returns_whole"): 4177 # When delimiter is empty string, return input string as single array element 4178 array_with_input = exp.array(expression.this) 4179 case_expr = case_expr.when( 4180 expression.expression.eq(exp.Literal.string("")), array_with_input 4181 ) 4182 needs_case = True 4183 4184 return self.sql(case_expr if needs_case else base_func)
4186 def splitpart_sql(self, expression: exp.SplitPart) -> str: 4187 string_arg = expression.this 4188 delimiter_arg = expression.args.get("delimiter") 4189 part_index_arg = expression.args.get("part_index") 4190 4191 if delimiter_arg and part_index_arg: 4192 # Handle Snowflake's "index 0 and 1 both return first element" behavior 4193 if expression.args.get("part_index_zero_as_one"): 4194 # Convert 0 to 1 for compatibility 4195 4196 part_index_arg = exp.Paren( 4197 this=exp.case() 4198 .when(part_index_arg.eq(exp.Literal.number("0")), exp.Literal.number("1")) 4199 .else_(part_index_arg) 4200 ) 4201 4202 # Use Anonymous to avoid recursion 4203 base_func_expr: exp.Expr = exp.Anonymous( 4204 this="SPLIT_PART", expressions=[string_arg, delimiter_arg, part_index_arg] 4205 ) 4206 needs_case_transform = False 4207 case_expr = exp.case().else_(base_func_expr) 4208 4209 if expression.args.get("empty_delimiter_returns_whole"): 4210 # When delimiter is empty string: 4211 # - Return whole string if part_index is 1 or -1 4212 # - Return empty string otherwise 4213 empty_case = exp.Paren( 4214 this=exp.case() 4215 .when( 4216 exp.or_( 4217 part_index_arg.eq(exp.Literal.number("1")), 4218 part_index_arg.eq(exp.Literal.number("-1")), 4219 ), 4220 string_arg, 4221 ) 4222 .else_(exp.Literal.string("")) 4223 ) 4224 4225 case_expr = case_expr.when(delimiter_arg.eq(exp.Literal.string("")), empty_case) 4226 needs_case_transform = True 4227 4228 """ 4229 Output looks something like this: 4230 4231 CASE 4232 WHEN delimiter is '' THEN 4233 ( 4234 CASE 4235 WHEN adjusted_part_index = 1 OR adjusted_part_index = -1 THEN input 4236 ELSE '' END 4237 ) 4238 ELSE SPLIT_PART(input, delimiter, adjusted_part_index) 4239 END 4240 4241 """ 4242 return self.sql(case_expr if needs_case_transform else base_func_expr) 4243 4244 return self.function_fallback_sql(expression)
4246 def respectnulls_sql(self, expression: exp.RespectNulls) -> str: 4247 if isinstance(expression.this, self.IGNORE_RESPECT_NULLS_WINDOW_FUNCTIONS): 4248 # DuckDB should render RESPECT NULLS only for the general-purpose 4249 # window functions that accept it e.g. FIRST_VALUE(... RESPECT NULLS) OVER (...) 4250 return super().respectnulls_sql(expression) 4251 4252 self.unsupported("RESPECT NULLS is not supported for non-window functions.") 4253 return self.sql(expression, "this")
4255 def arraytostring_sql(self, expression: exp.ArrayToString) -> str: 4256 null = expression.args.get("null") 4257 4258 if expression.args.get("null_is_empty"): 4259 x = exp.to_identifier("x") 4260 list_transform = exp.Transform( 4261 this=expression.this.copy(), 4262 expression=exp.Lambda( 4263 this=exp.Coalesce( 4264 this=exp.cast(x, "TEXT"), expressions=[exp.Literal.string("")] 4265 ), 4266 expressions=[x], 4267 ), 4268 ) 4269 array_to_string = exp.ArrayToString( 4270 this=list_transform, expression=expression.expression 4271 ) 4272 if expression.args.get("null_delim_is_null"): 4273 return self.sql( 4274 exp.case() 4275 .when(expression.expression.copy().is_(exp.null()), exp.null()) 4276 .else_(array_to_string) 4277 ) 4278 return self.sql(array_to_string) 4279 4280 if null: 4281 x = exp.to_identifier("x") 4282 return self.sql( 4283 exp.ArrayToString( 4284 this=exp.Transform( 4285 this=expression.this, 4286 expression=exp.Lambda( 4287 this=exp.Coalesce(this=exp.cast(x, "TEXT"), expressions=[null]), 4288 expressions=[x], 4289 ), 4290 ), 4291 expression=expression.expression, 4292 ) 4293 ) 4294 4295 return self.func("ARRAY_TO_STRING", expression.this, expression.expression)
4297 def concatws_sql(self, expression: exp.ConcatWs) -> str: 4298 # DuckDB-specific: handle binary types using DPipe (||) operator 4299 separator = seq_get(expression.expressions, 0) 4300 args = expression.expressions[1:] 4301 4302 if any(_is_binary(arg) for arg in [separator, *args]): 4303 result = args[0] 4304 for arg in args[1:]: 4305 result = exp.DPipe( 4306 this=exp.DPipe(this=result, expression=separator), expression=arg 4307 ) 4308 return self.sql(result) 4309 4310 if expression.args.get("flatten") and any( 4311 isinstance(arg, exp.Array) or arg.is_type(exp.DType.ARRAY) for arg in args 4312 ): 4313 arrays = [] 4314 for arg in args: 4315 if isinstance(arg, exp.Array) or arg.is_type(exp.DType.ARRAY): 4316 arg = exp.cast(arg, "TEXT[]") 4317 else: 4318 if (not arg.type or arg.is_type(exp.DType.UNKNOWN)) and not isinstance( 4319 arg, exp.CONSTANTS 4320 ): 4321 self.unsupported( 4322 "Cannot transpile CONCAT_WS with unknown argument types to DuckDB." 4323 ) 4324 arg = exp.array(exp.cast(arg, exp.DType.TEXT)) 4325 4326 arrays.append(arg) 4327 4328 array = ( 4329 exp.ArrayConcat(this=arrays[0], expressions=arrays[1:]) 4330 if len(arrays) > 1 4331 else arrays[0] 4332 ) 4333 return self.func("CONCAT_WS", separator, self.func("ARRAY_TO_STRING", array, separator)) 4334 4335 return super().concatws_sql(expression)
4415 def regexpinstr_sql(self, expression: exp.RegexpInstr) -> str: 4416 this = expression.this 4417 pattern = expression.expression 4418 position = expression.args.get("position") 4419 orig_occ = expression.args.get("occurrence") 4420 occurrence = orig_occ or exp.Literal.number(1) 4421 option = expression.args.get("option") 4422 parameters = expression.args.get("parameters") 4423 4424 validated_flags = self._validate_regexp_flags(parameters, supported_flags="ims") 4425 # Checked before the flags are embedded, which would make the pattern non-empty 4426 is_empty = pattern.copy().eq(exp.Literal.string("")) 4427 if validated_flags: 4428 pattern = exp.Concat(expressions=[exp.Literal.string(f"(?{validated_flags})"), pattern]) 4429 4430 # Handle starting position offset 4431 pos_offset: exp.Expr = exp.Literal.number(0) 4432 if position and (not position.is_int or position.to_py() > 1): 4433 this = exp.Substring(this=this, start=position) 4434 pos_offset = position - exp.Literal.number(1) 4435 4436 # Helper: LIST_SUM(LIST_TRANSFORM(list[1:end], x -> LENGTH(x))) 4437 def sum_lengths(func_name: str, end: exp.Expr) -> exp.Expr: 4438 lst = exp.Bracket( 4439 this=exp.Anonymous(this=func_name, expressions=[this, pattern]), 4440 expressions=[exp.Slice(this=exp.Literal.number(1), expression=end)], 4441 offset=1, 4442 ) 4443 transform = exp.Anonymous( 4444 this="LIST_TRANSFORM", 4445 expressions=[ 4446 lst, 4447 exp.Lambda( 4448 this=exp.Length(this=exp.to_identifier("x")), 4449 expressions=[exp.to_identifier("x")], 4450 ), 4451 ], 4452 ) 4453 return exp.Coalesce( 4454 this=exp.Anonymous(this="LIST_SUM", expressions=[transform]), 4455 expressions=[exp.Literal.number(0)], 4456 ) 4457 4458 # Position = 1 + sum(split_lengths[1:occ]) + sum(match_lengths[1:occ-1]) + offset 4459 base_pos: exp.Expr = ( 4460 exp.Literal.number(1) 4461 + sum_lengths("STRING_SPLIT_REGEX", occurrence) 4462 + sum_lengths("REGEXP_EXTRACT_ALL", occurrence - exp.Literal.number(1)) 4463 + pos_offset 4464 ) 4465 4466 # option=1: add match length for end position 4467 if option and option.is_int and option.to_py() == 1: 4468 match_at_occ = exp.Bracket( 4469 this=exp.Anonymous(this="REGEXP_EXTRACT_ALL", expressions=[this, pattern]), 4470 expressions=[occurrence], 4471 offset=1, 4472 ) 4473 base_pos = base_pos + exp.Coalesce( 4474 this=exp.Length(this=match_at_occ), expressions=[exp.Literal.number(0)] 4475 ) 4476 4477 # NULL checks for all provided arguments 4478 # .copy() is used strictly because .is_() alters the node's parent pointer, mutating the parsed AST 4479 null_args = [ 4480 expression.this, 4481 expression.expression, 4482 position, 4483 orig_occ, 4484 option, 4485 parameters, 4486 ] 4487 null_checks = [arg.copy().is_(exp.Null()) for arg in null_args if arg] 4488 4489 matches = exp.Anonymous(this="REGEXP_EXTRACT_ALL", expressions=[this, pattern]) 4490 4491 return self.sql( 4492 exp.case() 4493 .when(exp.or_(*null_checks), exp.Null()) 4494 .when(is_empty, exp.Literal.number(0)) 4495 .when(exp.Length(this=matches) < occurrence, exp.Literal.number(0)) 4496 .else_(base_pos) 4497 )
4499 @unsupported_args("culture") 4500 def numbertostr_sql(self, expression: exp.NumberToStr) -> str: 4501 fmt = expression.args.get("format") 4502 if fmt and fmt.is_int: 4503 return self.func("FORMAT", f"'{{:,.{fmt.name}f}}'", expression.this) 4504 4505 self.unsupported("Only integer formats are supported by NumberToStr") 4506 return self.function_fallback_sql(expression)
4519 def posexplode_sql(self, expression: exp.Posexplode) -> str: 4520 this = expression.this 4521 parent = expression.parent 4522 4523 # The default Spark aliases are "pos" and "col", unless specified otherwise 4524 pos, col = exp.to_identifier("pos"), exp.to_identifier("col") 4525 4526 if isinstance(parent, exp.Aliases): 4527 # Column case: SELECT POSEXPLODE(col) [AS (a, b)] 4528 pos, col = parent.expressions 4529 elif isinstance(parent, exp.Table): 4530 # Table case: SELECT * FROM POSEXPLODE(col) [AS (a, b)] 4531 alias = parent.args.get("alias") 4532 if alias: 4533 pos, col = alias.columns or [pos, col] 4534 alias.pop() 4535 4536 # Translate POSEXPLODE to UNNEST + GENERATE_SUBSCRIPTS 4537 # Note: In Spark pos is 0-indexed, but in DuckDB it's 1-indexed, so we subtract 1 from GENERATE_SUBSCRIPTS 4538 unnest_sql = self.sql(exp.Unnest(expressions=[this], alias=col)) 4539 gen_subscripts = self.sql( 4540 exp.Alias( 4541 this=exp.Anonymous( 4542 this="GENERATE_SUBSCRIPTS", expressions=[this, exp.Literal.number(1)] 4543 ) 4544 - exp.Literal.number(1), 4545 alias=pos, 4546 ) 4547 ) 4548 4549 posexplode_sql = self.format_args(gen_subscripts, unnest_sql) 4550 4551 if isinstance(parent, exp.From) or (parent and isinstance(parent.parent, exp.From)): 4552 # SELECT * FROM POSEXPLODE(col) -> SELECT * FROM (SELECT GENERATE_SUBSCRIPTS(...), UNNEST(...)) 4553 return self.sql(exp.Subquery(this=exp.Select(expressions=[posexplode_sql]))) 4554 4555 return posexplode_sql
4557 def addmonths_sql(self, expression: exp.AddMonths) -> str: 4558 """ 4559 Handles three key issues: 4560 1. Float/decimal months: e.g., Snowflake rounds, whereas DuckDB INTERVAL requires integers 4561 2. End-of-month preservation: If input is last day of month, result is last day of result month 4562 3. Type preservation: Maintains DATE/TIMESTAMPTZ types (DuckDB defaults to TIMESTAMP) 4563 """ 4564 from sqlglot.optimizer.annotate_types import annotate_types 4565 4566 this = expression.this 4567 if not this.type: 4568 this = annotate_types(this, dialect=self.dialect) 4569 4570 if this.is_type(*exp.DataType.TEXT_TYPES): 4571 this = exp.Cast(this=this, to=exp.DataType(this=exp.DType.TIMESTAMP)) 4572 4573 # Detect float/decimal months to apply rounding (Snowflake behavior) 4574 # DuckDB INTERVAL syntax doesn't support non-integer expressions, so use TO_MONTHS 4575 months_expr = expression.expression 4576 if not months_expr.type: 4577 months_expr = annotate_types(months_expr, dialect=self.dialect) 4578 4579 # Build interval or to_months expression based on type 4580 # Float/decimal case: Round and use TO_MONTHS(CAST(ROUND(value) AS INT)) 4581 interval_or_to_months = ( 4582 exp.func("TO_MONTHS", exp.cast(exp.func("ROUND", months_expr), "INT")) 4583 if months_expr.is_type( 4584 exp.DType.FLOAT, 4585 exp.DType.DOUBLE, 4586 exp.DType.DECIMAL, 4587 ) 4588 # Integer case: standard INTERVAL N MONTH syntax 4589 else exp.Interval(this=months_expr, unit=exp.var("MONTH")) 4590 ) 4591 4592 date_add_expr = exp.Add(this=this, expression=interval_or_to_months) 4593 4594 # Apply end-of-month preservation if Snowflake flag is set 4595 # CASE WHEN LAST_DAY(date) = date THEN LAST_DAY(result) ELSE result END 4596 preserve_eom = expression.args.get("preserve_end_of_month") 4597 result_expr = ( 4598 exp.case() 4599 .when( 4600 exp.EQ(this=exp.func("LAST_DAY", this), expression=this), 4601 exp.func("LAST_DAY", date_add_expr), 4602 ) 4603 .else_(date_add_expr) 4604 if preserve_eom 4605 else date_add_expr 4606 ) 4607 4608 # DuckDB's DATE_ADD function returns TIMESTAMP/DATETIME by default, even when the input is DATE 4609 # To match for example Snowflake's ADD_MONTHS behavior (which preserves the input type) 4610 # We need to cast the result back to the original type when the input is DATE or TIMESTAMPTZ 4611 # Example: ADD_MONTHS('2023-01-31'::date, 1) should return DATE, not TIMESTAMP 4612 if this.is_type(exp.DType.DATE, exp.DType.TIMESTAMPTZ): 4613 return self.sql(exp.Cast(this=result_expr, to=this.type)) 4614 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)
4628 def datetrunc_sql(self, expression: exp.DateTrunc) -> str: 4629 unit = expression.args.get("unit") 4630 date = expression.this 4631 4632 week_start = _week_trunc_start_dow(unit) 4633 unit = unit_to_str(expression) 4634 4635 if week_start: 4636 result = self.sql( 4637 _build_week_trunc_expression(date, week_start, preserve_start_day=True) 4638 ) 4639 else: 4640 result = self.func("DATE_TRUNC", unit, date) 4641 4642 if expression.args.get("input_type_preserved") and date.is_type( 4643 *exp.DataType.TEMPORAL_TYPES 4644 ): 4645 return self.sql(exp.Cast(this=result, to=date.type)) 4646 4647 return result
4649 def datetimetrunc_sql(self, expression: exp.DatetimeTrunc) -> str: 4650 this = exp.cast(expression.this, exp.DType.DATETIME) 4651 week_start = _week_trunc_start_dow(expression.args.get("unit")) 4652 if week_start: 4653 return self.sql( 4654 _build_week_trunc_expression( 4655 this, week_start, preserve_start_day=True, cast_to_date=False 4656 ) 4657 ) 4658 4659 return self.func("DATE_TRUNC", unit_to_str(expression), this)
4661 def timestamptrunc_sql(self, expression: exp.TimestampTrunc) -> str: 4662 zone = expression.args.get("zone") 4663 timestamp = expression.this 4664 week_start = _week_trunc_start_dow(expression.args.get("unit")) 4665 4666 # The week start emulation below is exact, so avoid weekstart_unit_to_str's degrade warning 4667 unit = unit_to_str(expression) if week_start else weekstart_unit_to_str(self, expression) 4668 date_unit = is_date_unit(unit) or bool(week_start) 4669 4670 def _trunc_expr(this: exp.Expr) -> exp.Expr: 4671 if week_start: 4672 return _build_week_trunc_expression( 4673 this, week_start, preserve_start_day=True, cast_to_date=False 4674 ) 4675 return exp.func("DATE_TRUNC", unit, this) 4676 4677 if date_unit and zone: 4678 # BigQuery's TIMESTAMP_TRUNC with timezone truncates in the target timezone and returns as UTC. 4679 # Double AT TIME ZONE needed for BigQuery compatibility: 4680 # 1. First AT TIME ZONE: ensures truncation happens in the target timezone 4681 # 2. Second AT TIME ZONE: converts the DATE result back to TIMESTAMPTZ (preserving time component) 4682 timestamp = exp.AtTimeZone(this=timestamp, zone=zone) 4683 trunced = _trunc_expr(timestamp) 4684 if isinstance(trunced, exp.DateAdd): 4685 # Parenthesize so the trailing AT TIME ZONE binds to the whole shifted expression 4686 trunced = exp.Paren(this=trunced) 4687 return self.sql(exp.AtTimeZone(this=trunced, zone=zone)) 4688 4689 result = self.sql(_trunc_expr(timestamp)) 4690 if expression.args.get("input_type_preserved"): 4691 if timestamp.type and timestamp.is_type(exp.DType.TIME, exp.DType.TIMETZ): 4692 dummy_date = exp.Cast( 4693 this=exp.Literal.string("1970-01-01"), 4694 to=exp.DataType(this=exp.DType.DATE), 4695 ) 4696 date_time = exp.Add(this=dummy_date, expression=timestamp) 4697 result = self.func("DATE_TRUNC", unit, date_time) 4698 return self.sql(exp.Cast(this=result, to=timestamp.type)) 4699 4700 if timestamp.is_type(*exp.DataType.TEMPORAL_TYPES): 4701 return self.sql(exp.Cast(this=result, to=timestamp.type)) 4702 4703 return result
4705 def trim_sql(self, expression: exp.Trim) -> str: 4706 expression.this.replace(_cast_to_varchar(expression.this)) 4707 if expression.expression: 4708 expression.expression.replace(_cast_to_varchar(expression.expression)) 4709 4710 result_sql = super().trim_sql(expression) 4711 return _gen_with_cast_to_blob(self, expression, result_sql)
4713 def round_sql(self, expression: exp.Round) -> str: 4714 this = expression.this 4715 decimals = expression.args.get("decimals") 4716 truncate = expression.args.get("truncate") 4717 4718 # DuckDB requires the scale (decimals) argument to be an INT 4719 # Some dialects (e.g., Snowflake) allow non-integer scales and cast to an integer internally 4720 if decimals is not None and expression.args.get("casts_non_integer_decimals"): 4721 if not (decimals.is_int or decimals.is_type(*exp.DataType.INTEGER_TYPES)): 4722 decimals = exp.cast(decimals, exp.DType.INT) 4723 4724 func = "ROUND" 4725 if truncate: 4726 # BigQuery uses ROUND_HALF_EVEN; Snowflake uses HALF_TO_EVEN 4727 if truncate.this in ("ROUND_HALF_EVEN", "HALF_TO_EVEN"): 4728 func = "ROUND_EVEN" 4729 truncate = None 4730 # BigQuery uses ROUND_HALF_AWAY_FROM_ZERO; Snowflake uses HALF_AWAY_FROM_ZERO 4731 elif truncate.this in ("ROUND_HALF_AWAY_FROM_ZERO", "HALF_AWAY_FROM_ZERO"): 4732 truncate = None 4733 4734 return self.func(func, this, decimals, truncate)
4736 def trycast_sql(self, expression: exp.TryCast) -> str: 4737 to = expression.to 4738 to_type = to.this 4739 src = expression.this 4740 4741 if ( 4742 expression.args.get("null_on_text_overflow") 4743 and to_type in exp.DataType.TEXT_TYPES 4744 and to.expressions 4745 ): 4746 return self.sql( 4747 exp.case() 4748 .when( 4749 exp.LTE(this=exp.func("LENGTH", src), expression=to.expressions[0].this), 4750 exp.cast(src, "TEXT"), 4751 ) 4752 .else_(exp.Null()) 4753 ) 4754 elif to_type == exp.DType.DATE and expression.args.get("probe_date_format"): 4755 slash_strptime = exp.cast( 4756 exp.func("TRY_STRPTIME", src, exp.Literal.string(self._TRYCAST_DATE_SLASH_FMT)), 4757 "DATE", 4758 ) 4759 mon_strptime = exp.cast( 4760 exp.func("TRY_STRPTIME", src, exp.Literal.string(self._TRYCAST_DATE_MON_FMT)), 4761 "DATE", 4762 ) 4763 return self.sql( 4764 exp.case() 4765 .when(exp.func("CONTAINS", src, exp.Literal.string("/")), slash_strptime) 4766 .when( 4767 exp.RegexpLike(this=src, expression=exp.Literal.string("[A-Za-z]")), 4768 mon_strptime, 4769 ) 4770 .else_(exp.TryCast(this=src, to=to)) 4771 ) 4772 elif ( 4773 isinstance(to_type, exp.Interval) 4774 and (unit := to_type.unit) 4775 and expression.args.get("requires_string") 4776 ): 4777 interval_type = exp.DataType.build("INTERVAL") 4778 if isinstance(unit, exp.IntervalSpan): 4779 self.unsupported( 4780 "TRY_CAST to INTERVAL with span (e.g. HOUR TO MINUTE) is not supported in DuckDB" 4781 ) 4782 return self.sql(exp.TryCast(this=src, to=interval_type)) 4783 return self.sql( 4784 exp.TryCast( 4785 this=exp.DPipe(this=src, expression=exp.Literal.string(f" {unit.name}")), 4786 to=interval_type, 4787 ) 4788 ) 4789 4790 return super().trycast_sql(expression)
4792 def strtok_sql(self, expression: exp.Strtok) -> str: 4793 string_arg = expression.this 4794 delimiter_arg = expression.args.get("delimiter") 4795 part_index_arg = expression.args.get("part_index") 4796 4797 if delimiter_arg and part_index_arg: 4798 # Escape regex chars and build character class at runtime using REGEXP_REPLACE 4799 escaped_delimiter = exp.Anonymous( 4800 this="REGEXP_REPLACE", 4801 expressions=[ 4802 delimiter_arg, 4803 exp.Literal.string( 4804 r"([\[\]^.\-*+?(){}|$\\])" 4805 ), # Escape problematic regex chars 4806 exp.Literal.string( 4807 r"\\\1" 4808 ), # Replace with escaped version using $1 backreference 4809 exp.Literal.string("g"), # Global flag 4810 ], 4811 ) 4812 # CASE WHEN delimiter = '' THEN '' ELSE CONCAT('[', escaped_delimiter, ']') END 4813 regex_pattern = ( 4814 exp.case() 4815 .when(delimiter_arg.eq(exp.Literal.string("")), exp.Literal.string("")) 4816 .else_( 4817 exp.func( 4818 "CONCAT", 4819 exp.Literal.string("["), 4820 escaped_delimiter, 4821 exp.Literal.string("]"), 4822 ) 4823 ) 4824 ) 4825 4826 # STRTOK skips empty strings, so we need to filter them out 4827 # LIST_FILTER(REGEXP_SPLIT_TO_ARRAY(string, pattern), x -> x != '')[index] 4828 split_array = exp.func("REGEXP_SPLIT_TO_ARRAY", string_arg, regex_pattern) 4829 x = exp.to_identifier("x") 4830 is_empty = x.eq(exp.Literal.string("")) 4831 filtered_array = exp.func( 4832 "LIST_FILTER", 4833 split_array, 4834 exp.Lambda(this=exp.not_(is_empty.copy()), expressions=[x.copy()]), 4835 ) 4836 base_func = exp.Bracket( 4837 this=filtered_array, 4838 expressions=[part_index_arg], 4839 offset=1, 4840 ) 4841 4842 # Use template with the built regex pattern 4843 result = exp.replace_placeholders( 4844 self.STRTOK_TEMPLATE.copy(), 4845 string=string_arg, 4846 delimiter=delimiter_arg, 4847 part_index=part_index_arg, 4848 base_func=base_func, 4849 ) 4850 4851 return self.sql(result) 4852 4853 return self.function_fallback_sql(expression)
4855 def strtoktoarray_sql(self, expression: exp.StrtokToArray) -> str: 4856 string_arg = expression.this 4857 delimiter_arg = expression.args.get("expression") or exp.Literal.string(" ") 4858 4859 escaped = exp.RegexpReplace( 4860 this=delimiter_arg.copy(), 4861 expression=exp.Literal.string(r"([\[\]^.\-*+?(){}|$\\])"), 4862 replacement=exp.Literal.string(r"\\\1"), 4863 modifiers=exp.Literal.string("g"), 4864 ) 4865 return self.sql( 4866 exp.replace_placeholders( 4867 self.STRTOK_TO_ARRAY_TEMPLATE.copy(), 4868 string=string_arg, 4869 delimiter=delimiter_arg, 4870 escaped=escaped, 4871 ) 4872 )
4874 def approxquantile_sql(self, expression: exp.ApproxQuantile) -> str: 4875 result = self.func("APPROX_QUANTILE", expression.this, expression.args.get("quantile")) 4876 4877 # DuckDB returns integers for APPROX_QUANTILE, cast to DOUBLE if the expected type is a real type 4878 if expression.is_type(*exp.DataType.REAL_TYPES): 4879 result = f"CAST({result} AS DOUBLE)" 4880 4881 return result
4883 def approxquantiles_sql(self, expression: exp.ApproxQuantiles) -> str: 4884 """ 4885 BigQuery's APPROX_QUANTILES(expr, n) returns an array of n+1 approximate quantile values 4886 dividing the input distribution into n equal-sized buckets. 4887 4888 Both BigQuery and DuckDB use approximate algorithms for quantile estimation, but BigQuery 4889 does not document the specific algorithm used so results may differ. DuckDB does not 4890 support RESPECT NULLS. 4891 """ 4892 this = expression.this 4893 if isinstance(this, exp.Distinct): 4894 # APPROX_QUANTILES requires 2 args and DISTINCT node grabs both 4895 if len(this.expressions) < 2: 4896 self.unsupported("APPROX_QUANTILES requires a bucket count argument") 4897 return self.function_fallback_sql(expression) 4898 num_quantiles_expr = this.expressions[1].pop() 4899 else: 4900 num_quantiles_expr = expression.expression 4901 4902 if not isinstance(num_quantiles_expr, exp.Literal) or not num_quantiles_expr.is_int: 4903 self.unsupported("APPROX_QUANTILES bucket count must be a positive integer") 4904 return self.function_fallback_sql(expression) 4905 4906 num_quantiles = t.cast(int, num_quantiles_expr.to_py()) 4907 if num_quantiles <= 0: 4908 self.unsupported("APPROX_QUANTILES bucket count must be a positive integer") 4909 return self.function_fallback_sql(expression) 4910 4911 quantiles = [ 4912 exp.Literal.number(Decimal(i) / Decimal(num_quantiles)) 4913 for i in range(num_quantiles + 1) 4914 ] 4915 4916 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.
4918 def jsonextractscalar_sql(self, expression: exp.JSONExtractScalar) -> str: 4919 if expression.args.get("scalar_only"): 4920 json_value = exp.JSONExtractScalar( 4921 this=rename_func("JSON_VALUE")(self, expression), expression="'$'" 4922 ) 4923 4924 # `->>` binds looser than most operators, so the wrap logic needs the parent 4925 json_value.parent = expression.parent 4926 expression = json_value 4927 4928 return _arrow_json_extract_sql(self, expression)
4930 def bitwisenot_sql(self, expression: exp.BitwiseNot) -> str: 4931 this = expression.this 4932 4933 if _is_binary(this): 4934 expression.type = exp.DType.BINARY.into_expr() 4935 4936 arg = _cast_to_bit(this) 4937 4938 if isinstance(this, exp.Neg): 4939 arg = exp.Paren(this=arg) 4940 4941 expression.set("this", arg) 4942 4943 result_sql = f"~{self.sql(expression, 'this')}" 4944 4945 return _gen_with_cast_to_blob(self, expression, result_sql)
4979 def uuid_sql(self, expression: exp.Uuid) -> str: 4980 namespace = expression.this 4981 name = expression.args.get("name") 4982 4983 # UUID v5 (namespace + name) - Emulate using SHA1 4984 if namespace and name: 4985 result = exp.replace_placeholders( 4986 self.UUID_V5_TEMPLATE.copy(), 4987 namespace=namespace, 4988 name=name, 4989 ) 4990 return self.sql(result) 4991 4992 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
- MERGE_ALL_BY_NAME
- 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
- ALTER_TABLE_ADD_COLUMNS_WRAPPED
- 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
- rawstring_sql
- datatypeparam_sql
- datatype_param_bound_limiter
- directory_sql
- delete_sql
- drop_sql
- set_operation
- set_operations
- fetch_sql
- limitoptions_sql
- per_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
- connectto_sql
- disconnect_sql
- connect_sql
- prior_sql
- nearest_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
- 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
- teradatatitle_sql
- jsonarray_sql
- jsonarrayagg_sql
- jsoncolumndef_sql
- jsonschema_sql
- jsontable_sql
- openjsoncolumndef_sql
- openjson_sql
- in_unnest_op
- 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
- nullif_zero_divisor
- 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
- min_sql
- max_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