cloudflare-next-intl 0.8.4 → 0.8.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,34 +1,149 @@
1
1
  -- Executes a statement generated by cloudflare-next-intl's `db` module and
2
- -- returns its rows as positional JSON arrays, which is the shape
3
- -- drizzle-orm/pg-proxy maps result columns from.
2
+ -- returns its rows/rowCount as jsonb, in the shape drizzle-orm/pg-proxy expects.
3
+ --
4
+ -- v2: statements arrive with parameters already inlined as literals by the
5
+ -- `db` transport (see `inline_params.ts`), so this function takes no
6
+ -- parameters and never binds anything itself. It also now supports
7
+ -- INSERT/UPDATE/DELETE, with or without RETURNING.
8
+ --
9
+ -- Each output row is cast with `r::text`, Postgres' own composite-literal
10
+ -- text format: `(field1,field2,...)`, fields comma-separated, quoted with
11
+ -- `"…"` (doubling embedded quotes) whenever a field's own text contains a
12
+ -- comma/quote/paren/backslash/whitespace, and empty-unquoted for NULL. Every
13
+ -- field is already in that column's normal `::text` form — arrays as
14
+ -- `{1,2}`, booleans as `t`/`f`, timestamps without a `T`, full `numeric`
15
+ -- precision — the same representation the `pg` wire protocol sends in text
16
+ -- mode, and what Drizzle's column mappers decode. Unlike `row_to_json`/
17
+ -- `hstore(record)`, this stays correct with duplicate column names (a common
18
+ -- shape after a join with `select a.*, b.*`), since it is purely positional
19
+ -- and never keys fields by name. The transport (`supabase_transport.ts`)
20
+ -- parses this literal back into a positional array with `parse_composite.ts`.
4
21
  --
5
22
  -- SECURITY INVOKER is load-bearing: the statement runs with the privileges of
6
23
  -- the caller (anon or authenticated, per the request's JWT), so row-level
7
24
  -- security still applies exactly as it does over the REST API. Do not change
8
25
  -- this to SECURITY DEFINER, and do not add a role parameter — either would
9
26
  -- turn this into a privilege-escalation primitive.
10
- create or replace function public.cfni_exec(statement text, params jsonb default '[]'::jsonb)
11
- returns json
27
+ --
28
+ -- The drop below is only needed when upgrading from the old `json`-returning
29
+ -- v1 of this function — `create or replace` cannot change a function's
30
+ -- return type, and is a no-op (safe to re-run) when this is a fresh install.
31
+ drop function if exists public.cfni_exec(text);
32
+
33
+ create or replace function public.cfni_exec(statement text)
34
+ returns jsonb
12
35
  language plpgsql
13
36
  security invoker
14
37
  as $$
15
38
  declare
16
- result json;
17
- args text[];
39
+ top_level_verb text;
40
+ has_returning boolean;
41
+ has_inner_writable_cte boolean;
42
+ rows_json jsonb := '[]'::jsonb;
43
+ row_count bigint := 0;
44
+ row_value record;
45
+ row_texts text[] := '{}';
46
+ begin
47
+ top_level_verb := public.cfni_top_level_verb(statement);
48
+ has_returning := statement ~* '\yreturning\y';
49
+
50
+ if top_level_verb in ('insert', 'update', 'delete') and not has_returning then
51
+ execute statement;
52
+ get diagnostics row_count = row_count;
53
+ return jsonb_build_object('rows', '[]'::jsonb, 'rowCount', row_count);
54
+ end if;
55
+
56
+ if top_level_verb in ('insert', 'update', 'delete') then
57
+ -- INSERT/UPDATE/DELETE with RETURNING cannot be wrapped as
58
+ -- `from (<stmt>) r` — Postgres rejects DML directly inside FROM —
59
+ -- so run it as a CTE instead and select its rows out of that.
60
+ execute format(
61
+ 'with cfni_dml_result as (%s) select coalesce(jsonb_agg(r::text), ''[]''::jsonb), count(*) from cfni_dml_result r',
62
+ statement
63
+ ) into rows_json, row_count;
64
+ return jsonb_build_object('rows', rows_json, 'rowCount', row_count);
65
+ end if;
66
+
67
+ -- Top-level statement is a SELECT (plain, or `with ... select ...`).
68
+ has_inner_writable_cte := statement ~* '\y(with|,)\s+[A-Za-z_][A-Za-z0-9_]*\s+as\s*\(\s*(insert|update|delete)\y';
69
+ if has_inner_writable_cte then
70
+ -- Postgres forbids nesting a WITH that contains a data-modifying
71
+ -- statement inside another query (`from (with u as (update ...
72
+ -- returning ...) select ... from u) r` is rejected even though the
73
+ -- statement is legal standalone) — so this shape must run exactly
74
+ -- as given, not wrapped in a further subquery. Row values are
75
+ -- collected by iterating the statement's own result set instead,
76
+ -- casting each row to its composite-literal text form directly.
77
+ for row_value in execute statement loop
78
+ row_texts := row_texts || (row_value::text);
79
+ row_count := row_count + 1;
80
+ end loop;
81
+ return jsonb_build_object('rows', to_jsonb(row_texts), 'rowCount', row_count);
82
+ end if;
83
+
84
+ -- Plain SELECT with no inner writable CTE: wrap as a subquery and emit
85
+ -- each row's composite-literal text form; the transport parses it back
86
+ -- into a positional array of column values.
87
+ execute format(
88
+ 'select coalesce(jsonb_agg(r::text), ''[]''::jsonb), count(*) from (%s) r',
89
+ statement
90
+ ) into rows_json, row_count;
91
+
92
+ return jsonb_build_object('rows', rows_json, 'rowCount', row_count);
93
+ end;
94
+ $$;
95
+
96
+ -- Finds the leading keyword of a statement's *outermost* clause, skipping
97
+ -- past any leading CTE chain (`with x as (...), y as (...) <verb> ...`) by
98
+ -- tracking paren depth rather than a single non-greedy regex match, which
99
+ -- breaks on multiple CTEs or parens nested inside a CTE body. Returns one of
100
+ -- 'select', 'insert', 'update', 'delete', or 'other' (lowercased).
101
+ create or replace function public.cfni_top_level_verb(statement text)
102
+ returns text
103
+ language plpgsql
104
+ immutable
105
+ as $$
106
+ declare
107
+ stripped text;
108
+ depth int := 0;
109
+ i int := 1;
110
+ len int;
111
+ ch text;
18
112
  begin
19
- select coalesce(array_agg(value #>> '{}' order by ordinality), '{}')
20
- into args
21
- from jsonb_array_elements(params) with ordinality as t(value, ordinality);
113
+ stripped := regexp_replace(statement, '^(\s*--[^\n]*\n|\s*/\*.*?\*/)*\s*', '', 'sn');
22
114
 
23
- execute format('select coalesce(json_agg(json_build_array(r.*)), ''[]''::json) from (%s) r', statement)
24
- into result
25
- using args;
115
+ if stripped !~* '^with\y' then
116
+ return lower((regexp_match(stripped, '^([A-Za-z]+)'))[1]);
117
+ end if;
26
118
 
27
- return result;
119
+ len := length(stripped);
120
+ while i <= len loop
121
+ ch := substr(stripped, i, 1);
122
+ if ch = '(' then
123
+ depth := depth + 1;
124
+ elsif ch = ')' then
125
+ depth := depth - 1;
126
+ elsif depth = 0 and ch = ',' then
127
+ -- Between CTE definitions at depth 0; keep scanning for the
128
+ -- statement that follows the whole `with ... ,` chain.
129
+ null;
130
+ elsif depth = 0 and substr(stripped, i) ~* '^(select|insert|update|delete)\y' then
131
+ return lower((regexp_match(substr(stripped, i), '^([A-Za-z]+)'))[1]);
132
+ end if;
133
+ i := i + 1;
134
+ end loop;
135
+
136
+ return 'other';
28
137
  end;
29
138
  $$;
30
139
 
31
- revoke all on function public.cfni_exec(text, jsonb) from public;
32
- grant execute on function public.cfni_exec(text, jsonb) to authenticated;
140
+ revoke all on function public.cfni_exec(text) from public;
141
+ grant execute on function public.cfni_exec(text) to authenticated;
33
142
  -- Grant to anon only if your app calls withPublicDb:
34
- grant execute on function public.cfni_exec(text, jsonb) to anon;
143
+ grant execute on function public.cfni_exec(text) to anon;
144
+ grant execute on function public.cfni_exec(text) to service_role;
145
+
146
+ revoke all on function public.cfni_top_level_verb(text) from public;
147
+ grant execute on function public.cfni_top_level_verb(text) to authenticated;
148
+ grant execute on function public.cfni_top_level_verb(text) to anon;
149
+ grant execute on function public.cfni_top_level_verb(text) to service_role;
@@ -0,0 +1,150 @@
1
+ -- pgTAP tests for cfni_exec.sql. Run with `supabase test db` (or
2
+ -- `pg_prove`) from a project that has installed both this file and
3
+ -- `cfni_exec.sql`. Mirrors the scenarios in
4
+ -- `src/db/cfni_exec.integration.test.ts` on the TypeScript side — this file
5
+ -- checks the SQL function itself and its RLS interaction directly in
6
+ -- Postgres, without going through the transport/JS parsing layer.
7
+ begin;
8
+ select plan(20);
9
+
10
+ do $$
11
+ begin
12
+ if not exists (select 1 from pg_roles where rolname = 'anon') then
13
+ create role anon;
14
+ end if;
15
+ if not exists (select 1 from pg_roles where rolname = 'authenticated') then
16
+ create role authenticated;
17
+ end if;
18
+ end
19
+ $$;
20
+
21
+ create table cfni_test_a (id int primary key, name text);
22
+ create table cfni_test_b (id int primary key, name text);
23
+ insert into cfni_test_a values (1, 'a1');
24
+ insert into cfni_test_b values (2, 'b2');
25
+
26
+ select is(pg_typeof(cfni_exec('select 1'))::text, 'jsonb', 'cfni_exec returns jsonb');
27
+
28
+ select is(
29
+ cfni_exec('select id, name from cfni_test_a where id = 1'),
30
+ '{"rows": ["(1,a1)"], "rowCount": 1}'::jsonb,
31
+ 'plain select returns rows and rowCount'
32
+ );
33
+
34
+ select is(
35
+ cfni_exec('select a.*, b.* from cfni_test_a a, cfni_test_b b'),
36
+ '{"rows": ["(1,a1,2,b2)"], "rowCount": 1}'::jsonb,
37
+ 'duplicate column names from a join are preserved positionally'
38
+ );
39
+
40
+ select is(
41
+ cfni_exec('select array[1,2,3] as arr, true as b, 1.10::numeric as n, null as z'),
42
+ '{"rows": ["(\"{1,2,3}\",t,1.10,)"], "rowCount": 1}'::jsonb,
43
+ 'array/boolean/numeric/null values use pg text form, not JSON re-encoding'
44
+ );
45
+
46
+ select is(
47
+ cfni_exec('insert into cfni_test_a (id, name) values (10, ''x'')'),
48
+ '{"rows": [], "rowCount": 1}'::jsonb,
49
+ 'insert without returning reports rowCount with no rows'
50
+ );
51
+
52
+ select is(
53
+ cfni_exec('insert into cfni_test_a (id, name) values (11, ''y'') returning id, name'),
54
+ '{"rows": ["(11,y)"], "rowCount": 1}'::jsonb,
55
+ 'insert with returning reports rows'
56
+ );
57
+
58
+ select is(
59
+ cfni_exec('update cfni_test_a set name = ''z'' where id = 1 returning id, name'),
60
+ '{"rows": ["(1,z)"], "rowCount": 1}'::jsonb,
61
+ 'update with returning reports rows'
62
+ );
63
+
64
+ select is(
65
+ cfni_exec('update cfni_test_a set name = ''z2'' where id = 1'),
66
+ '{"rows": [], "rowCount": 1}'::jsonb,
67
+ 'update without returning reports rowCount with no rows'
68
+ );
69
+
70
+ select is(
71
+ cfni_exec('delete from cfni_test_a where id = 10 returning id'),
72
+ '{"rows": ["(10)"], "rowCount": 1}'::jsonb,
73
+ 'delete with returning reports rows'
74
+ );
75
+
76
+ select is(
77
+ cfni_exec('delete from cfni_test_a where id = 11'),
78
+ '{"rows": [], "rowCount": 1}'::jsonb,
79
+ 'delete without returning reports rowCount with no rows'
80
+ );
81
+
82
+ alter table cfni_test_a add constraint cfni_test_a_id_uq unique (id);
83
+ select is(
84
+ cfni_exec('insert into cfni_test_a (id, name) values (1, ''dup'') on conflict (id) do update set name = excluded.name returning id, name'),
85
+ '{"rows": ["(1,dup)"], "rowCount": 1}'::jsonb,
86
+ 'on conflict do update via excluded works'
87
+ );
88
+
89
+ select is(
90
+ cfni_exec('with x as (select 1 as v) select v from x'),
91
+ '{"rows": ["(1)"], "rowCount": 1}'::jsonb,
92
+ 'plain with-select CTE works'
93
+ );
94
+
95
+ select is(
96
+ cfni_exec('with src as (select 20 as id, ''q'' as name) insert into cfni_test_a (id, name) select id, name from src returning id, name'),
97
+ '{"rows": ["(20,q)"], "rowCount": 1}'::jsonb,
98
+ 'writable CTE (insert ... select ... returning) at the top level works'
99
+ );
100
+
101
+ select is(
102
+ cfni_exec('with u as (update cfni_test_a set name = ''zz'' where id = 1 returning id) select id from u'),
103
+ '{"rows": ["(1)"], "rowCount": 1}'::jsonb,
104
+ 'writable CTE wrapped in an outer select works'
105
+ );
106
+
107
+ select is(
108
+ cfni_exec('select ''the returning hero'' as s'),
109
+ '{"rows": ["(\"the returning hero\")"], "rowCount": 1}'::jsonb,
110
+ 'a select whose literal text contains the word "returning" is not misclassified as DML'
111
+ );
112
+
113
+ select is(
114
+ cfni_exec('select 1 where false'),
115
+ '{"rows": [], "rowCount": 0}'::jsonb,
116
+ 'no matching rows returns an empty result set'
117
+ );
118
+
119
+ select is(pg_proc.prosecdef, false, 'cfni_exec is security invoker, not security definer')
120
+ from pg_proc where proname = 'cfni_exec';
121
+
122
+ -- Re-running the install file must not error (create-or-replace + a
123
+ -- return-type-changing drop guard) — exercised by re-declaring the function
124
+ -- with an identical body here, standing in for `\i cfni_exec.sql` a second
125
+ -- time, since re-sourcing the actual file mid-transaction isn't expressible
126
+ -- from within a single pgTAP script.
127
+ select isnt(cfni_exec('select 1'), null, 'cfni_exec remains callable after a hypothetical reinstall');
128
+
129
+ alter table cfni_test_a enable row level security;
130
+ create policy cfni_test_a_select on cfni_test_a for select to authenticated using (true);
131
+ grant select on cfni_test_a to anon, authenticated;
132
+
133
+ set role anon;
134
+ select is(
135
+ cfni_exec('select * from cfni_test_a'),
136
+ '{"rows": [], "rowCount": 0}'::jsonb,
137
+ 'anon sees no rows with no matching RLS policy'
138
+ );
139
+ reset role;
140
+
141
+ set role authenticated;
142
+ select is(
143
+ (cfni_exec('select * from cfni_test_a') ->> 'rowCount')::int,
144
+ (select count(*)::int from cfni_test_a),
145
+ 'authenticated sees all rows under its permissive select policy'
146
+ );
147
+ reset role;
148
+
149
+ select * from finish();
150
+ rollback;