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
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 sql/walrus_migration_0014*.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 sql/walrus_migration_0015*.sql -f test/fixtures.sql -d contrib_regression

# Run tests
${REGRESS} --use-existing --dbname=contrib_regression --inputdir=${TESTDIR} ${TESTS}
218 changes: 218 additions & 0 deletions sql/walrus_migration_0015_like_ilike_is_not_ops.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,218 @@
-- Fix 1: Drop old 4-arg overload and its dependent before creating the 5-arg version
drop function if exists realtime.is_visible_through_filters(realtime.wal_column[], realtime.user_defined_filter[]);
drop function if exists realtime.check_equality_op(realtime.equality_op, regtype, text, text);

alter type realtime.equality_op add value 'like';
alter type realtime.equality_op add value 'ilike';
alter type realtime.equality_op add value 'is';
alter type realtime.equality_op add value 'match';
alter type realtime.equality_op add value 'imatch';
alter type realtime.equality_op add value 'isdistinct';

alter type realtime.user_defined_filter add attribute negate boolean;

drop function if exists realtime.check_equality_op(realtime.equality_op, regtype, text, text);

create or replace function realtime.check_equality_op(
Comment thread
filipecabaco marked this conversation as resolved.
op realtime.equality_op,
type_ regtype,
val_1 text,
val_2 text,
negate boolean DEFAULT false
)
returns bool
stable -- Fix 2: was immutable, uses EXECUTE so must be stable
language plpgsql
as $$
declare
op_symbol text;
res boolean;
begin
-- IS DISTINCT FROM / IS NOT DISTINCT FROM: infix, both sides typed literals
if op = 'isdistinct' then
execute format(
'select %L::%s %s %L::%s',
val_1,
type_::text,
case when negate then 'IS NOT DISTINCT FROM' else 'IS DISTINCT FROM' end,
val_2,
type_::text
) into res;
return res;
end if;

-- IS requires a keyword RHS (NULL, TRUE, FALSE, UNKNOWN), not a typed literal
if op = 'is' then
if val_2 not in ('null', 'true', 'false', 'unknown') then
raise exception 'invalid value for is filter: must be null, true, false, or unknown';
end if;
execute format(
'select %L::%s %s %s',
val_1,
type_::text,
case when negate then 'IS NOT' else 'IS' end,
upper(val_2)
) into res;
return res;
end if;

op_symbol = case
when op = 'eq' then '='
when op = 'neq' then '!='
when op = 'lt' then '<'
when op = 'lte' then '<='
when op = 'gt' then '>'
when op = 'gte' then '>='
when op = 'in' then '= any'
when op = 'like' then 'LIKE'
when op = 'ilike' then 'ILIKE'
when op = 'match' then '~'
when op = 'imatch' then '~*'
else null
end;

if op_symbol is null then
raise exception 'unsupported equality operator: %', op::text;
end if;

execute format(
'select %L::%s %s (%L::%s)',
val_1,
type_::text,
op_symbol,
val_2,
case when op = 'in' then type_::text || '[]' else type_::text end
) into res;

return case when negate then not res else res end;
end;
$$;


create or replace function realtime.is_visible_through_filters(columns realtime.wal_column[], filters realtime.user_defined_filter[])
returns bool
language sql
stable -- Fix 2: was immutable, calls stable function so must be stable
as $$
select
Comment thread
filipecabaco marked this conversation as resolved.
filters is null
or array_length(filters, 1) is null
or coalesce(
count(col.name) = count(1)
and sum(
realtime.check_equality_op(
op:=f.op,
type_:=coalesce(col.type_oid::regtype, col.type_name::regtype),
val_1:=col.value #>> '{}',
val_2:=f.value,
negate:=coalesce(f.negate, false)
)::int
) filter (where col.name is not null) = count(col.name),
false
)
from
unnest(filters) f
Comment thread
filipecabaco marked this conversation as resolved.
left join unnest(columns) col
on f.column_name = col.name;
$$;


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'
);
-- Fix 3: removed unused table_col_names declaration
filter realtime.user_defined_filter;
col_type regtype;
in_val jsonb;
selected_col text;
begin
for filter in select * from unnest(new.filters) loop
if not filter.column_name = any(col_names) then
raise exception 'invalid column for filter %', filter.column_name;
end if;

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;
elsif filter.op = 'is'::realtime.equality_op then
if filter.value not in ('null', 'true', 'false', 'unknown') then
raise exception 'invalid value for is filter: must be null, true, false, or unknown';
end if;
-- Fix 4: validate like/ilike only applies to text-compatible columns
elsif filter.op in ('like'::realtime.equality_op, 'ilike'::realtime.equality_op) then
if not exists (
select 1 from pg_catalog.pg_operator
where oprname = '~~' and oprleft = col_type
) then
raise exception 'operator % requires a text-compatible column type, got %', filter.op::text, col_type::text;
end if;
-- Fix 5: validate match/imatch regex patterns eagerly
elsif filter.op in ('match'::realtime.equality_op, 'imatch'::realtime.equality_op) then
begin
perform '' ~ filter.value;
exception when others then
raise exception 'invalid regular expression for % filter: %', filter.op::text, sqlerrm;
end;
else
perform realtime.cast(filter.value, col_type);
end if;
end loop;

if new.selected_columns = '{}' then
raise exception 'selected_columns cannot be empty. Remove the select parameter to capture all columns.';
end if;

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;

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;

-- Fix 6: include negate in the ORDER BY for deterministic normalization
new.filters = coalesce(
array_agg(f order by f.column_name, f.op, f.value, f.negate),
'{}'
) from unnest(new.filters) f;

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/issue_40_quoted_regtype.out
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ select
'role', 'authenticated',
'sub', seed_uuid(2)::text
),
array[('primary_color', 'eq', 'RED')::realtime.user_defined_filter];
array[('primary_color', 'eq', 'RED', null)::realtime.user_defined_filter];
insert into public.notes(id, primary_color)
values
(1, 'RED'), -- matches filter
Expand Down
68 changes: 63 additions & 5 deletions test/expected/issue_50_delete_filters.out
Original file line number Diff line number Diff line change
Expand Up @@ -17,18 +17,18 @@ select
'email', 'example@example.com',
'sub', seed_uuid(id)::text
),
array[(column_name, op, value)::realtime.user_defined_filter]
array[(column_name, op, value, null)::realtime.user_defined_filter]
from
(
values
(1 , 'body', 'eq', 'bbb'),
(2 , 'id', 'eq', '2')
) f(id, column_name, op, value);
select subscription_id, filters from realtime.subscription;
subscription_id | filters
--------------------------------------+-------------------
f4539ebe-c779-5788-bbc1-2421ffaa8954 | {"(body,eq,bbb)"}
5211e8ec-8c25-5c7f-9b03-6ff1eac0159e | {"(id,eq,2)"}
subscription_id | filters
--------------------------------------+--------------------
f4539ebe-c779-5788-bbc1-2421ffaa8954 | {"(body,eq,bbb,)"}
5211e8ec-8c25-5c7f-9b03-6ff1eac0159e | {"(id,eq,2,)"}
(2 rows)

----------------------------------------------------------------------------------------
Expand Down Expand Up @@ -160,6 +160,64 @@ from
} | | |
(2 rows)

----------------------------------------------------------------------------------------
-- Multi-filter: missing column must not allow subscription to pass --
----------------------------------------------------------------------------------------
alter table public.notes replica identity default;
truncate table realtime.subscription;
insert into realtime.subscription(subscription_id, entity, claims, filters)
select
seed_uuid(3),
'public.notes',
jsonb_build_object(
'role', 'authenticated',
'email', 'example@example.com',
'sub', seed_uuid(3)::text
),
array[
('id', 'eq', '1', null)::realtime.user_defined_filter,
('body', 'eq', 'bbb', null)::realtime.user_defined_filter
];
insert into public.notes(id, body) values (1, 'bbb');
select clear_wal();
clear_wal
-----------

(1 row)

-- Non-full replica identity DELETE: only PK in WAL, body filter cannot be evaluated
-- Expect 0 subscriptions: body='bbb' filter is unverifiable, must fail closed
delete from public.notes;
select
rec,
is_rls_enabled,
subscription_ids,
errors
from
walrus;
rec | is_rls_enabled | subscription_ids | errors
----------------------------------------------------+----------------+------------------+--------
{ +| f | {} | {}
"type": "DELETE", +| | |
"table": "notes", +| | |
"schema": "public", +| | |
"columns": [ +| | |
{ +| | |
"name": "id", +| | |
"type": "int4" +| | |
}, +| | |
{ +| | |
"name": "body", +| | |
"type": "text" +| | |
} +| | |
], +| | |
"old_record": { +| | |
"id": 1 +| | |
}, +| | |
"commit_timestamp": "2000-01-01T08:01:01.000Z"+| | |
} | | |
(1 row)

drop table public.notes;
select pg_drop_replication_slot('realtime');
pg_drop_replication_slot
Expand Down
2 changes: 1 addition & 1 deletion test/expected/issue_55_null_passes_filters.out
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ select
'email', 'example@example.com',
'sub', seed_uuid(1)::text
),
array[('page_id', 'eq', '5')::realtime.user_defined_filter];
array[('page_id', 'eq', '5', null)::realtime.user_defined_filter];
select clear_wal();
clear_wal
-----------
Expand Down
12 changes: 6 additions & 6 deletions test/expected/test_integration_filters.out
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ select
'email', 'example@example.com',
'sub', seed_uuid(id)::text
),
array[(column_name, op, value)::realtime.user_defined_filter]
array[(column_name, op, value, null)::realtime.user_defined_filter]
from
(
values
Expand All @@ -35,11 +35,11 @@ select clear_wal();
insert into public.notes(id, body) values (1, 'bbb');
delete from public.notes;
select subscription_id, filters from realtime.subscription;
subscription_id | filters
--------------------------------------+--------------------
f4539ebe-c779-5788-bbc1-2421ffaa8954 | {"(body,eq,bbb)"}
5211e8ec-8c25-5c7f-9b03-6ff1eac0159e | {"(body,eq,aaaa)"}
11955172-4e1d-5836-925f-2bcb7a287b87 | {"(body,eq,cc)"}
subscription_id | filters
--------------------------------------+---------------------
f4539ebe-c779-5788-bbc1-2421ffaa8954 | {"(body,eq,bbb,)"}
5211e8ec-8c25-5c7f-9b03-6ff1eac0159e | {"(body,eq,aaaa,)"}
11955172-4e1d-5836-925f-2bcb7a287b87 | {"(body,eq,cc,)"}
(3 rows)

select
Expand Down
14 changes: 7 additions & 7 deletions test/expected/test_integration_in_filter.out
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ select
'email', 'example@example.com',
'sub', seed_uuid(id)::text
),
array[(column_name, op, value)::realtime.user_defined_filter]
array[(column_name, op, value, null)::realtime.user_defined_filter]
from
(
values
Expand All @@ -35,11 +35,11 @@ select clear_wal();
insert into public.notes(id, body) values (1, 'bbb');
delete from public.notes;
select subscription_id, filters from realtime.subscription;
subscription_id | filters
--------------------------------------+---------------------------------
f4539ebe-c779-5788-bbc1-2421ffaa8954 | {"(body,in,\"{aaa,bbb,ccc}\")"}
5211e8ec-8c25-5c7f-9b03-6ff1eac0159e | {"(body,in,\"{aaa,ccc}\")"}
11955172-4e1d-5836-925f-2bcb7a287b87 | {"(body,in,{})"}
subscription_id | filters
--------------------------------------+----------------------------------
f4539ebe-c779-5788-bbc1-2421ffaa8954 | {"(body,in,\"{aaa,bbb,ccc}\",)"}
5211e8ec-8c25-5c7f-9b03-6ff1eac0159e | {"(body,in,\"{aaa,ccc}\",)"}
11955172-4e1d-5836-925f-2bcb7a287b87 | {"(body,in,{},)"}
(3 rows)

select
Expand Down Expand Up @@ -103,7 +103,7 @@ select
'email', 'example@example.com',
'sub', seed_uuid(6)::text
),
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];
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], null)::realtime.user_defined_filter];
ERROR: too many values for `in` filter. Maximum 100
CONTEXT: PL/pgSQL function realtime.subscription_check_filters() line 41 at RAISE
drop table public.notes;
Expand Down
Loading
Loading