From ee4e279c7f62520565a3f29190bfb6b8e2f84b72 Mon Sep 17 00:00:00 2001 From: Shuowei Li Date: Wed, 11 Mar 2026 18:46:48 +0000 Subject: [PATCH 1/2] 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/2] 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