From 1f8285f1aa39ae55cbaf4234a6a7da00d30b7e76 Mon Sep 17 00:00:00 2001 From: Filipe Cabaco Date: Wed, 3 Jun 2026 10:45:12 +0100 Subject: [PATCH] fix: empty array on select yields error Fixes the behaviour where empty array would be handled as a NULL meaning the user could be mislead into thinking that they were properly filtering data when they weren't. The behaviour now is: * `NULL` returns all columns * `{}` errors out so we can inform the user https://linear.app/supabase/issue/REAL-843/treat-empty-pg-changes-select-as-an-error --- README.md | 5 ++ bin/installcheck | 2 +- ...lrus_migration_0014_empty_select_error.sql | 88 +++++++++++++++++++ test/expected/test_integration_in_filter.out | 2 +- test/expected/test_select_columns_empty.out | 21 +++++ test/expected/test_select_columns_invalid.out | 2 +- .../test_select_columns_null_element.out | 21 +++++ test/sql/test_select_columns_empty.sql | 23 +++++ test/sql/test_select_columns_null_element.sql | 23 +++++ 9 files changed, 184 insertions(+), 3 deletions(-) create mode 100644 sql/walrus_migration_0014_empty_select_error.sql create mode 100644 test/expected/test_select_columns_empty.out create mode 100644 test/expected/test_select_columns_null_element.out create mode 100644 test/sql/test_select_columns_empty.sql create mode 100644 test/sql/test_select_columns_null_element.sql diff --git a/README.md b/README.md index 26d76f9..d2583d5 100644 --- a/README.md +++ b/README.md @@ -69,6 +69,11 @@ insert into realtime.subscription(subscription_id, entity, filters, claims, sele values ('832bd278-dac7-4bef-96be-e21c8a0023c4', 'public.notes', '{}', '{"role": "authenticated"}', array['id', 'title']); ``` +`selected_columns` behaviour: +- `NULL` (default) — all columns are returned +- `array['col1', 'col2']` — only the listed columns are returned (plus primary keys) +- `'{}'` (empty array) — raises an error; use `NULL` to capture all columns + ### Reading WAL diff --git a/bin/installcheck b/bin/installcheck index e034282..7496065 100755 --- a/bin/installcheck +++ b/bin/installcheck @@ -41,7 +41,7 @@ REGRESS="${PGXS}/../test/regress/pg_regress" TESTS=$(ls ${TESTDIR}/sql | sed -e 's/\..*$//' | sort ) # Execute the test fixtures -psql -v ON_ERROR_STOP=1 -f sql/setup.sql -f sql/walrus--0.1.sql -f sql/walrus_migration_0001*.sql -f sql/walrus_migration_0002*.sql -f sql/walrus_migration_0003*.sql -f sql/walrus_migration_0004*.sql -f sql/walrus_migration_0005*.sql -f sql/walrus_migration_0006*.sql -f sql/walrus_migration_0007*.sql -f sql/walrus_migration_0008*.sql -f sql/walrus_migration_0009*.sql -f sql/walrus_migration_0010*.sql -f sql/walrus_migration_0011*.sql -f sql/walrus_migration_0012*.sql -f sql/walrus_migration_0013*.sql -f test/fixtures.sql -d contrib_regression +psql -v ON_ERROR_STOP=1 -f sql/setup.sql -f sql/walrus--0.1.sql -f sql/walrus_migration_0001*.sql -f sql/walrus_migration_0002*.sql -f sql/walrus_migration_0003*.sql -f sql/walrus_migration_0004*.sql -f sql/walrus_migration_0005*.sql -f sql/walrus_migration_0006*.sql -f sql/walrus_migration_0007*.sql -f sql/walrus_migration_0008*.sql -f sql/walrus_migration_0009*.sql -f sql/walrus_migration_0010*.sql -f sql/walrus_migration_0011*.sql -f sql/walrus_migration_0012*.sql -f sql/walrus_migration_0013*.sql -f sql/walrus_migration_0014*.sql -f test/fixtures.sql -d contrib_regression # Run tests ${REGRESS} --use-existing --dbname=contrib_regression --inputdir=${TESTDIR} ${TESTS} diff --git a/sql/walrus_migration_0014_empty_select_error.sql b/sql/walrus_migration_0014_empty_select_error.sql new file mode 100644 index 0000000..1064f01 --- /dev/null +++ b/sql/walrus_migration_0014_empty_select_error.sql @@ -0,0 +1,88 @@ +create or replace function realtime.subscription_check_filters() + returns trigger + language plpgsql +as $$ +declare + col_names text[] = coalesce( + array_agg(c.column_name order by c.ordinal_position), + '{}'::text[] + ) + from + information_schema.columns c + where + format('%I.%I', c.table_schema, c.table_name)::regclass = new.entity + and pg_catalog.has_column_privilege( + (new.claims ->> 'role'), + format('%I.%I', c.table_schema, c.table_name)::regclass, + c.column_name, + 'SELECT' + ); + filter realtime.user_defined_filter; + col_type regtype; + in_val jsonb; + selected_col text; +begin + for filter in select * from unnest(new.filters) loop + -- Filtered column is valid + if not filter.column_name = any(col_names) then + raise exception 'invalid column for filter %', filter.column_name; + end if; + + -- Type is sanitized and safe for string interpolation + col_type = ( + select atttypid::regtype + from pg_catalog.pg_attribute + where attrelid = new.entity + and attname = filter.column_name + ); + if col_type is null then + raise exception 'failed to lookup type for column %', filter.column_name; + end if; + if filter.op = 'in'::realtime.equality_op then + in_val = realtime.cast(filter.value, (col_type::text || '[]')::regtype); + if coalesce(jsonb_array_length(in_val), 0) > 100 then + raise exception 'too many values for `in` filter. Maximum 100'; + end if; + else + -- raises an exception if value is not coercable to type + perform realtime.cast(filter.value, col_type); + end if; + end loop; + + -- Reject empty selected_columns: array_agg would silently convert '{}' to NULL + -- during normalization, making it indistinguishable from "all columns" + if new.selected_columns = '{}' then + raise exception 'selected_columns cannot be empty. Remove the select parameter to capture all columns.'; + end if; + + -- Reject arrays with NULL elements which bypass column validation + if new.selected_columns is not null and array_position(new.selected_columns, null::text) is not null then + raise exception 'selected_columns cannot contain null values.'; + end if; + + -- Validate that selected_columns reference columns the role can SELECT + if new.selected_columns is not null then + for selected_col in select * from unnest(new.selected_columns) loop + if not selected_col = any(col_names) then + raise exception 'invalid column for select %', selected_col; + end if; + end loop; + end if; + + -- Apply consistent order to filters so the unique constraint on + -- (subscription_id, entity, filters) can't be tricked by a different filter order + new.filters = coalesce( + array_agg(f order by f.column_name, f.op, f.value), + '{}' + ) from unnest(new.filters) f; + + -- Normalize selected_columns order so ARRAY['a','b'] and ARRAY['b','a'] are + -- treated as the same subscription group in apply_rls + new.selected_columns = ( + select array_agg(c order by c) + from unnest(new.selected_columns) c + ); + + return new; +end; +$$; diff --git a/test/expected/test_integration_in_filter.out b/test/expected/test_integration_in_filter.out index bbab3d9..f2245d1 100644 --- a/test/expected/test_integration_in_filter.out +++ b/test/expected/test_integration_in_filter.out @@ -105,7 +105,7 @@ select ), array[('body', 'in', array[1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1])::realtime.user_defined_filter]; ERROR: too many values for `in` filter. Maximum 100 -CONTEXT: PL/pgSQL function realtime.subscription_check_filters() line 50 at RAISE +CONTEXT: PL/pgSQL function realtime.subscription_check_filters() line 41 at RAISE drop table public.notes; select pg_drop_replication_slot('realtime'); pg_drop_replication_slot diff --git a/test/expected/test_select_columns_empty.out b/test/expected/test_select_columns_empty.out new file mode 100644 index 0000000..20b8cf0 --- /dev/null +++ b/test/expected/test_select_columns_empty.out @@ -0,0 +1,21 @@ +/* +Tests that subscribing with an empty selected_columns raises an exception +*/ +create table public.notes( + id int primary key, + body text +); +insert into realtime.subscription(subscription_id, entity, claims, selected_columns) +select + seed_uuid(1), + 'public.notes', + jsonb_build_object( + 'role', 'authenticated', + 'email', 'example@example.com', + 'sub', seed_uuid(1)::text + ), + '{}'::text[]; +ERROR: selected_columns cannot be empty. Remove the select parameter to capture all columns. +CONTEXT: PL/pgSQL function realtime.subscription_check_filters() line 52 at RAISE +drop table public.notes; +truncate table realtime.subscription; diff --git a/test/expected/test_select_columns_invalid.out b/test/expected/test_select_columns_invalid.out index e91c6b1..5a3901c 100644 --- a/test/expected/test_select_columns_invalid.out +++ b/test/expected/test_select_columns_invalid.out @@ -16,6 +16,6 @@ select ), array['nonexistent_column']; ERROR: invalid column for select nonexistent_column -CONTEXT: PL/pgSQL function realtime.subscription_check_filters() line 62 at RAISE +CONTEXT: PL/pgSQL function realtime.subscription_check_filters() line 64 at RAISE drop table public.notes; truncate table realtime.subscription; diff --git a/test/expected/test_select_columns_null_element.out b/test/expected/test_select_columns_null_element.out new file mode 100644 index 0000000..42b0357 --- /dev/null +++ b/test/expected/test_select_columns_null_element.out @@ -0,0 +1,21 @@ +/* +Tests that subscribing with a NULL element in selected_columns raises an exception +*/ +create table public.notes( + id int primary key, + body text +); +insert into realtime.subscription(subscription_id, entity, claims, selected_columns) +select + seed_uuid(1), + 'public.notes', + jsonb_build_object( + 'role', 'authenticated', + 'email', 'example@example.com', + 'sub', seed_uuid(1)::text + ), + array[null]::text[]; +ERROR: selected_columns cannot contain null values. +CONTEXT: PL/pgSQL function realtime.subscription_check_filters() line 57 at RAISE +drop table public.notes; +truncate table realtime.subscription; diff --git a/test/sql/test_select_columns_empty.sql b/test/sql/test_select_columns_empty.sql new file mode 100644 index 0000000..439aa6d --- /dev/null +++ b/test/sql/test_select_columns_empty.sql @@ -0,0 +1,23 @@ +/* +Tests that subscribing with an empty selected_columns raises an exception +*/ + +create table public.notes( + id int primary key, + body text +); + +insert into realtime.subscription(subscription_id, entity, claims, selected_columns) +select + seed_uuid(1), + 'public.notes', + jsonb_build_object( + 'role', 'authenticated', + 'email', 'example@example.com', + 'sub', seed_uuid(1)::text + ), + '{}'::text[]; + + +drop table public.notes; +truncate table realtime.subscription; diff --git a/test/sql/test_select_columns_null_element.sql b/test/sql/test_select_columns_null_element.sql new file mode 100644 index 0000000..2d98b63 --- /dev/null +++ b/test/sql/test_select_columns_null_element.sql @@ -0,0 +1,23 @@ +/* +Tests that subscribing with a NULL element in selected_columns raises an exception +*/ + +create table public.notes( + id int primary key, + body text +); + +insert into realtime.subscription(subscription_id, entity, claims, selected_columns) +select + seed_uuid(1), + 'public.notes', + jsonb_build_object( + 'role', 'authenticated', + 'email', 'example@example.com', + 'sub', seed_uuid(1)::text + ), + array[null]::text[]; + + +drop table public.notes; +truncate table realtime.subscription;