Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion bin/installcheck
Original file line number Diff line number Diff line change
Expand Up @@ -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}
88 changes: 88 additions & 0 deletions sql/walrus_migration_0014_empty_select_error.sql
Original file line number Diff line number Diff line change
@@ -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;
$$;
2 changes: 1 addition & 1 deletion test/expected/test_integration_in_filter.out
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
21 changes: 21 additions & 0 deletions test/expected/test_select_columns_empty.out
Original file line number Diff line number Diff line change
@@ -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;
2 changes: 1 addition & 1 deletion test/expected/test_select_columns_invalid.out
Original file line number Diff line number Diff line change
Expand Up @@ -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;
21 changes: 21 additions & 0 deletions test/expected/test_select_columns_null_element.out
Original file line number Diff line number Diff line change
@@ -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;
23 changes: 23 additions & 0 deletions test/sql/test_select_columns_empty.sql
Original file line number Diff line number Diff line change
@@ -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;
23 changes: 23 additions & 0 deletions test/sql/test_select_columns_null_element.sql
Original file line number Diff line number Diff line change
@@ -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;
Loading