From ee4e279c7f62520565a3f29190bfb6b8e2f84b72 Mon Sep 17 00:00:00 2001 From: Shuowei Li Date: Wed, 11 Mar 2026 18:46:48 +0000 Subject: [PATCH 1/5] fix: inject SELECT 1 fallback for empty agg projections --- .../ibis/backends/sql/compilers/base.py | 14 +++++++++++--- .../backends/sql/compilers/bigquery/__init__.py | 9 +++++++++ 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/third_party/bigframes_vendored/ibis/backends/sql/compilers/base.py b/third_party/bigframes_vendored/ibis/backends/sql/compilers/base.py index b95e428053..341b25ca1c 100644 --- a/third_party/bigframes_vendored/ibis/backends/sql/compilers/base.py +++ b/third_party/bigframes_vendored/ibis/backends/sql/compilers/base.py @@ -1394,9 +1394,17 @@ def _generate_groups(groups): return map(sge.convert, range(1, len(groups) + 1)) def visit_Aggregate(self, op, *, parent, groups, metrics): - sel = sg.select( - *self._cleanup_names(groups), *self._cleanup_names(metrics), copy=False - ).from_(parent, copy=False) + exprs = [] + if groups: + exprs.extend(self._cleanup_names(groups)) + if metrics: + exprs.extend(self._cleanup_names(metrics)) + + if not exprs: + # Empty aggregated projections are invalid in BigQuery + exprs = [sge.Literal.number(1)] + + sel = sg.select(*exprs, copy=False).from_(parent, copy=False) if groups: sel = sel.group_by(*self._generate_groups(groups.values()), copy=False) diff --git a/third_party/bigframes_vendored/ibis/backends/sql/compilers/bigquery/__init__.py b/third_party/bigframes_vendored/ibis/backends/sql/compilers/bigquery/__init__.py index 1fa5432a16..cd462f9e8f 100644 --- a/third_party/bigframes_vendored/ibis/backends/sql/compilers/bigquery/__init__.py +++ b/third_party/bigframes_vendored/ibis/backends/sql/compilers/bigquery/__init__.py @@ -540,6 +540,15 @@ def visit_TimestampFromUNIX(self, op, *, arg, unit): def visit_Cast(self, op, *, arg, to): from_ = op.arg.dtype + if to.is_null(): + return sge.Null() + if arg is NULL or ( + isinstance(arg, sge.Cast) + and getattr(arg, "to", None) is not None + and str(arg.to).upper() == "NULL" + ): + if to.is_struct() or to.is_array(): + return sge.Cast(this=NULL, to=self.type_mapper.from_ibis(to)) if from_.is_timestamp() and to.is_integer(): return self.f.unix_micros(arg) elif from_.is_integer() and to.is_timestamp(): From cbf27feb1c7e8af722b9d65b78c0600595a133ea Mon Sep 17 00:00:00 2001 From: Shuowei Li Date: Wed, 11 Mar 2026 20:14:58 +0000 Subject: [PATCH 2/5] test: add verification for empty selection aggregate projections --- tests/system/small/test_dataframe.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tests/system/small/test_dataframe.py b/tests/system/small/test_dataframe.py index 9683a8bc52..f2d58f33c0 100644 --- a/tests/system/small/test_dataframe.py +++ b/tests/system/small/test_dataframe.py @@ -6283,3 +6283,11 @@ def test_agg_with_dict_containing_non_existing_col_raise_key_error(scalars_dfs): with pytest.raises(KeyError): bf_df.agg(agg_funcs) + + +def test_dataframe_count_empty_selection_succeeds(session): + # Tests that aggregate ops on empty selections don't trigger invalid empty SELECT syntax + df = session.read_gbq("SELECT 1 AS int_col") + empty_df = df[[]] + count_series = empty_df.count().to_pandas() + assert len(count_series) == 0 From d4ac7660364efef175b082aef64a287b9dd90925 Mon Sep 17 00:00:00 2001 From: Shuowei Li Date: Tue, 24 Mar 2026 02:26:10 +0000 Subject: [PATCH 3/5] test: rewrite empty aggregate projection test to test Ibis compiler directly --- tests/system/small/test_dataframe.py | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/tests/system/small/test_dataframe.py b/tests/system/small/test_dataframe.py index f2d58f33c0..dd74aadae2 100644 --- a/tests/system/small/test_dataframe.py +++ b/tests/system/small/test_dataframe.py @@ -6285,9 +6285,14 @@ def test_agg_with_dict_containing_non_existing_col_raise_key_error(scalars_dfs): bf_df.agg(agg_funcs) -def test_dataframe_count_empty_selection_succeeds(session): - # Tests that aggregate ops on empty selections don't trigger invalid empty SELECT syntax - df = session.read_gbq("SELECT 1 AS int_col") - empty_df = df[[]] - count_series = empty_df.count().to_pandas() - assert len(count_series) == 0 +def test_empty_agg_projection_succeeds(): + # Tests that the compiler generates a SELECT 1 fallback for empty aggregations, + # protecting against BigQuery syntax errors when both groups and metrics are empty. + import third_party.bigframes_vendored.ibis.backends.sql.compilers.bigquery as bq + import third_party.bigframes_vendored.sqlglot as sg + + compiler = bq.BigQueryCompiler() + res = compiler.visit_Aggregate( + "op", parent=sg.table("parent_table"), groups=[], metrics=[] + ) + assert "SELECT 1" in res.sql() From f34aba935b74992a56f3dbeb258381e95232c7e2 Mon Sep 17 00:00:00 2001 From: Shuowei Li Date: Tue, 24 Mar 2026 18:48:17 +0000 Subject: [PATCH 4/5] test: use dynamic imports for third_party tests --- tests/system/small/test_dataframe.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/tests/system/small/test_dataframe.py b/tests/system/small/test_dataframe.py index 2d7fb2c886..744cea24f5 100644 --- a/tests/system/small/test_dataframe.py +++ b/tests/system/small/test_dataframe.py @@ -6301,8 +6301,12 @@ def test_agg_with_dict_containing_non_existing_col_raise_key_error(scalars_dfs): def test_empty_agg_projection_succeeds(): # Tests that the compiler generates a SELECT 1 fallback for empty aggregations, # protecting against BigQuery syntax errors when both groups and metrics are empty. - import third_party.bigframes_vendored.ibis.backends.sql.compilers.bigquery as bq - import third_party.bigframes_vendored.sqlglot as sg + import importlib + + bq = importlib.import_module( + "third_party.bigframes_vendored.ibis.backends.sql.compilers.bigquery" + ) + sg = importlib.import_module("third_party.bigframes_vendored.sqlglot") compiler = bq.BigQueryCompiler() res = compiler.visit_Aggregate( From 9acd05e9b10d11c4fbf9f8b96e1128d9819e00cf Mon Sep 17 00:00:00 2001 From: Shuowei Li Date: Tue, 31 Mar 2026 18:36:42 +0000 Subject: [PATCH 5/5] fix: fix testcase import --- tests/system/small/test_dataframe.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/system/small/test_dataframe.py b/tests/system/small/test_dataframe.py index 744cea24f5..db8842bd32 100644 --- a/tests/system/small/test_dataframe.py +++ b/tests/system/small/test_dataframe.py @@ -6304,9 +6304,9 @@ def test_empty_agg_projection_succeeds(): import importlib bq = importlib.import_module( - "third_party.bigframes_vendored.ibis.backends.sql.compilers.bigquery" + "bigframes_vendored.ibis.backends.sql.compilers.bigquery" ) - sg = importlib.import_module("third_party.bigframes_vendored.sqlglot") + sg = importlib.import_module("bigframes_vendored.sqlglot") compiler = bq.BigQueryCompiler() res = compiler.visit_Aggregate(