Skip to content

feat(duckdb): transpile BigQuery IN UNNEST(array) to ARRAY_CONTAINS#7892

Open
sirockin wants to merge 2 commits into
tobymao:mainfrom
algolia:upstream-pr/in-unnest
Open

feat(duckdb): transpile BigQuery IN UNNEST(array) to ARRAY_CONTAINS#7892
sirockin wants to merge 2 commits into
tobymao:mainfrom
algolia:upstream-pr/in-unnest

Conversation

@sirockin

Copy link
Copy Markdown
Contributor

Summary

BigQuery uses value IN UNNEST(array_expr) to test array membership. DuckDB has no UNNEST in this position but provides ARRAY_CONTAINS.

Changes

  • Add IN UNNEST(arr)COALESCE(ARRAY_CONTAINS(arr, val), FALSE) in DuckDB generator via in_sql override
  • Wrap in COALESCE(..., FALSE) for null safety — ARRAY_CONTAINS(NULL, x) returns NULL in DuckDB, but BigQuery's IN UNNEST(NULL) returns FALSE
  • Add transpilation test for BigQuery → DuckDB

Motivation

We use sqlglot to transpile BigQuery SQL models to DuckDB for local testing via sqlmesh. IN UNNEST(...) is used in several of our production models for array membership checks but was not previously transpiled to DuckDB.

…TAINS

BigQuery uses `value IN UNNEST(array_expr)` to test array membership.
DuckDB has no `UNNEST` in this position but provides `ARRAY_CONTAINS`.

The transpilation wraps the result in `COALESCE(..., FALSE)` to handle
NULL arrays safely — `ARRAY_CONTAINS(NULL, x)` returns NULL in DuckDB,
but BigQuery's `IN UNNEST(NULL)` returns FALSE.

Adds:
- `IN UNNEST(arr)` → `COALESCE(ARRAY_CONTAINS(arr, val), FALSE)` in
  DuckDB generator via `in_sql` override
- Transpilation test for BigQuery → DuckDB
@geooo109 geooo109 self-assigned this Jul 17, 2026
Comment thread sqlglot/generators/duckdb.py Outdated
Comment thread sqlglot/generators/duckdb.py
Replace COALESCE(ARRAY_CONTAINS(arr, x), FALSE) with a CASE expression
that preserves BigQuery's three-valued NULL semantics:
- NULL IN UNNEST([1, 2]) → NULL (not FALSE)
- 3 IN UNNEST([1, NULL]) → NULL (not FALSE)
- 1 IN UNNEST(NULL) → FALSE
- 1 IN UNNEST([]) → FALSE

Use expression API instead of f-string interpolation per review
feedback. Add NOT IN UNNEST test case.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@georgesittas

Copy link
Copy Markdown
Collaborator

@sirockin is this still a draft? What's pending?

@geooo109
geooo109 marked this pull request as ready for review July 21, 2026 11:02

def in_sql(self, expression: exp.In) -> str:
unnest = expression.args.get("unnest")
if unnest:

@geooo109 geooo109 Jul 21, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This case only exists for bigquery (IN/NOT IN and an UNNEST) ? Can you check if it can occur for other dialects? @sirockin

edit: I asked this ^ question because we need to isolate the execution path whenever the AST originates from BigQuery, in case other dialects use similar syntax with different semantics

Comment on lines +3140 to +3183
# BigQuery's `x IN UNNEST(arr)` → DuckDB CASE expression that preserves
# NULL semantics. The default `IN (SELECT UNNEST(...))` creates a correlated
# subquery that DuckDB rejects inside non-inner joins.
#
# NULL semantics (matching BigQuery):
# NULL IN UNNEST([1, 2]) → NULL (not FALSE)
# 3 IN UNNEST([1, NULL]) → NULL (not FALSE)
# 3 IN UNNEST([1, 2]) → FALSE
# 2 IN UNNEST([1, 2]) → TRUE
# 1 IN UNNEST(NULL) → FALSE
# 1 IN UNNEST([]) → FALSE
#
# Generated SQL:
# CASE
# WHEN arr IS NULL OR ARRAY_LENGTH(arr) = 0 THEN FALSE
# WHEN ARRAY_CONTAINS(arr, x) THEN TRUE
# WHEN x IS NULL OR ARRAY_LENGTH(arr) <> LIST_COUNT(arr) THEN NULL
# ELSE FALSE
# END
arr = unnest.expressions[0]
value = expression.this

arr_is_null = exp.Is(this=arr.copy(), expression=exp.null())
arr_is_empty = exp.EQ(
this=exp.ArraySize(this=arr.copy()),
expression=exp.Literal.number(0),
)
contains = exp.ArrayContains(this=arr.copy(), expression=value.copy())
value_is_null = exp.Is(this=value.copy(), expression=exp.null())
arr_has_nulls = exp.NEQ(
this=exp.ArraySize(this=arr.copy()),
expression=exp.func("LIST_COUNT", arr.copy()),
)

case_expr = (
exp.Case()
.when(exp.or_(arr_is_null, arr_is_empty, copy=False), exp.false(), copy=False)
.when(contains, exp.true(), copy=False)
.when(exp.or_(value_is_null, arr_has_nulls, copy=False), exp.null(), copy=False)
.else_(exp.false(), copy=False)
)

return self.sql(case_expr)
return super().in_sql(expression)

@geooo109 geooo109 Jul 21, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In duckdb generator, we have a set of template variables to build these complex expressions.

These templates are in the form of *_TEMPLATE: exp.Expr = exp.maybe_parse(...). Let's leverage this in order to build this. Thus, creating a new one.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants