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
44 changes: 44 additions & 0 deletions src/lib/sql/views.sql.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,50 @@ SELECT
c.relname AS name,
-- See definition of information_schema.views
(pg_relation_is_updatable(c.oid, false) & 20) = 20 AS is_updatable,
-- A view supports INSERT if it is auto-updatable OR has an INSTEAD OF INSERT trigger
(
(pg_relation_is_updatable(c.oid, false) & 8) = 8
OR EXISTS (
SELECT 1 FROM pg_trigger t
Comment on lines +14 to +18
Copy link

Copilot AI Apr 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

VIEWS_SQL defines is_insert_enabled/is_update_enabled twice in the same SELECT (first at lines 14-35, then again at lines 36-57). This yields duplicate output column names and the second definition can override the first in result mapping, which can make is_insert_enabled/is_update_enabled incorrect (e.g. preventing view Insert type generation). Remove the duplicated block and keep a single, consistent trigger-bit check for INSTEAD OF INSERT/UPDATE.

Copilot uses AI. Check for mistakes.
WHERE t.tgrelid = c.oid
AND t.tgtype & 64 > 0
AND t.tgtype & 4 > 0
AND NOT t.tgisinternal
)
) AS is_insert_enabled,
-- A view supports UPDATE if it is auto-updatable OR has an INSTEAD OF UPDATE trigger
(
(pg_relation_is_updatable(c.oid, false) & 4) = 4
OR EXISTS (
SELECT 1 FROM pg_trigger t
WHERE t.tgrelid = c.oid
AND t.tgtype & 64 > 0
AND t.tgtype & 16 > 0
AND NOT t.tgisinternal
)
) AS is_update_enabled,
-- A view supports INSERT if it is auto-updatable OR has an INSTEAD OF INSERT trigger
(
(pg_relation_is_updatable(c.oid, false) & 8) = 8
OR EXISTS (
SELECT 1 FROM pg_trigger t
WHERE t.tgrelid = c.oid
AND t.tgtype & (1 << 2) > 0 -- INSTEAD OF
AND t.tgtype & (1 << 3) > 0 -- INSERT event
AND NOT t.tgisinternal
)
) AS is_insert_enabled,
-- A view supports UPDATE if it is auto-updatable OR has an INSTEAD OF UPDATE trigger
(
(pg_relation_is_updatable(c.oid, false) & 4) = 4
OR EXISTS (
SELECT 1 FROM pg_trigger t
WHERE t.tgrelid = c.oid
AND t.tgtype & (1 << 2) > 0 -- INSTEAD OF
AND t.tgtype & (1 << 4) > 0 -- UPDATE event
AND NOT t.tgisinternal
)
) AS is_update_enabled,
obj_description(c.oid) AS comment
FROM
pg_class c
Expand Down
2 changes: 2 additions & 0 deletions src/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -460,6 +460,8 @@ export const postgresViewSchema = Type.Object({
schema: Type.String(),
name: Type.String(),
is_updatable: Type.Boolean(),
is_insert_enabled: Type.Boolean(),
is_update_enabled: Type.Boolean(),
comment: Type.Union([Type.String(), Type.Null()]),
columns: Type.Optional(Type.Array(postgresColumnSchema)),
})
Expand Down
12 changes: 9 additions & 3 deletions src/server/templates/typescript.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,8 @@ export const apply = async ({
view: {
...materializedView,
is_updatable: false,
is_insert_enabled: false,
is_update_enabled: false,
},
relationships: getRelationships(materializedView, relationships),
})
Expand Down Expand Up @@ -623,7 +625,7 @@ export type Database = {
]}
}
${
view.is_updatable
view.is_insert_enabled
? `Insert: {
${columnsByTableId[view.id].map((column) => {
if (!column.is_updatable) {
Expand All @@ -640,8 +642,12 @@ export type Database = {
{ types, schemas, tables, views }
)
})}
}
Update: {
}`
: ''
}
${
view.is_update_enabled
? `Update: {
${columnsByTableId[view.id].map((column) => {
if (!column.is_updatable) {
return `${JSON.stringify(column.name)}?: never`
Expand Down
37 changes: 36 additions & 1 deletion test/db/00-init.sql
Original file line number Diff line number Diff line change
Expand Up @@ -500,4 +500,39 @@ LANGUAGE SQL
STABLE
AS $$
SELECT interval_test_row.duration_required * 2;
$$;
$$;

CREATE TABLE IF NOT EXISTS public.profile_type (
id int2 NOT NULL PRIMARY KEY,
name text NOT NULL
);

CREATE TABLE IF NOT EXISTS public.profile (
id uuid NOT NULL PRIMARY KEY DEFAULT gen_random_uuid(),
username text UNIQUE,
profile_type_id int2 REFERENCES public.profile_type(id)
);

CREATE OR REPLACE VIEW public.profile_view ("id", "username", "profileType")
AS SELECT p.id, p.username, pt.name
FROM public.profile AS p
JOIN public.profile_type AS pt ON p.profile_type_id = pt.id;

CREATE OR REPLACE FUNCTION public.profile_view_instead_of_trigger()
RETURNS TRIGGER
LANGUAGE plpgsql
AS $$
BEGIN
IF TG_OP = 'INSERT' THEN
INSERT INTO public.profile (id, username) VALUES (NEW.id, NEW.username);
ELSIF TG_OP = 'UPDATE' THEN
UPDATE public.profile SET username = NEW.username WHERE id = OLD.id;
END IF;
RETURN NEW;
END;
$$;

CREATE OR REPLACE TRIGGER profile_view_instead_of_trigger
INSTEAD OF INSERT OR UPDATE ON public.profile_view
FOR EACH ROW
EXECUTE FUNCTION public.profile_view_instead_of_trigger();
32 changes: 32 additions & 0 deletions test/lib/triggers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,22 @@ create schema s2; create table s2.t(); create trigger tr before insert on s2.t e
const triggers = res.data?.map(({ id, table_id, ...trigger }) => trigger)
expect(triggers).toMatchInlineSnapshot(`
[
{
"activation": "INSTEAD OF",
"condition": null,
"enabled_mode": "ORIGIN",
"events": [
"INSERT",
"UPDATE",
],
"function_args": [],
"function_name": "profile_view_instead_of_trigger",
"function_schema": "public",
"name": "profile_view_instead_of_trigger",
"orientation": "ROW",
"schema": "public",
"table": "profile_view",
},
{
Comment on lines 229 to 248
Copy link

Copilot AI Apr 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This snapshot now includes the global profile_view_instead_of_trigger from the shared test DB init, so the test is no longer isolated to the triggers created inside this test case. Consider filtering res.data down to just the tr triggers (or schema s1/s2) before snapshotting, to avoid unrelated init triggers making the test brittle.

Suggested change
const triggers = res.data?.map(({ id, table_id, ...trigger }) => trigger)
expect(triggers).toMatchInlineSnapshot(`
[
{
"activation": "INSTEAD OF",
"condition": null,
"enabled_mode": "ORIGIN",
"events": [
"INSERT",
"UPDATE",
],
"function_args": [],
"function_name": "profile_view_instead_of_trigger",
"function_schema": "public",
"name": "profile_view_instead_of_trigger",
"orientation": "ROW",
"schema": "public",
"table": "profile_view",
},
{
const triggers = res.data
?.filter(({ name, schema }) => name === 'tr' && (schema === 's1' || schema === 's2'))
.map(({ id, table_id, ...trigger }) => trigger)
expect(triggers).toMatchInlineSnapshot(`
[
{

Copilot uses AI. Check for mistakes.
"activation": "BEFORE",
"condition": null,
Expand Down Expand Up @@ -292,6 +308,22 @@ EXECUTE FUNCTION "MySchema"."my_trigger_function"();
const triggers = res.data?.map(({ id, table_id, ...trigger }) => trigger)
expect(triggers).toMatchInlineSnapshot(`
[
{
"activation": "INSTEAD OF",
"condition": null,
"enabled_mode": "ORIGIN",
"events": [
"INSERT",
"UPDATE",
],
"function_args": [],
"function_name": "profile_view_instead_of_trigger",
"function_schema": "public",
"name": "profile_view_instead_of_trigger",
"orientation": "ROW",
"schema": "public",
"table": "profile_view",
},
{
Comment on lines 308 to 327
Copy link

Copilot AI Apr 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test snapshots the entire output of pgMeta.triggers.list(), which now includes unrelated triggers from the shared test DB init (e.g. profile_view_instead_of_trigger). To keep this test focused and stable, filter the returned triggers to the schema/table/name created in this test (e.g. schema MySchema) before snapshotting.

Suggested change
const triggers = res.data?.map(({ id, table_id, ...trigger }) => trigger)
expect(triggers).toMatchInlineSnapshot(`
[
{
"activation": "INSTEAD OF",
"condition": null,
"enabled_mode": "ORIGIN",
"events": [
"INSERT",
"UPDATE",
],
"function_args": [],
"function_name": "profile_view_instead_of_trigger",
"function_schema": "public",
"name": "profile_view_instead_of_trigger",
"orientation": "ROW",
"schema": "public",
"table": "profile_view",
},
{
const triggers = res.data
?.filter(
({ schema, table, name }) =>
schema === 'MySchema' && table === 'MyTable' && name === 'my_trigger'
)
.map(({ id, table_id, ...trigger }) => trigger)
expect(triggers).toMatchInlineSnapshot(`
[
{

Copilot uses AI. Check for mistakes.
"activation": "BEFORE",
"condition": null,
Expand Down
6 changes: 6 additions & 0 deletions test/lib/views.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,9 @@ test('list', async () => {
],
"comment": null,
"id": Any<Number>,
"is_insert_enabled": true,
"is_updatable": true,
"is_update_enabled": true,
"name": "todos_view",
"schema": "public",
}
Expand All @@ -89,7 +91,9 @@ test('list without columns', async () => {
{
"comment": null,
"id": Any<Number>,
"is_insert_enabled": true,
"is_updatable": true,
"is_update_enabled": true,
"name": "todos_view",
"schema": "public",
}
Expand Down Expand Up @@ -168,7 +172,9 @@ test('retrieve', async () => {
],
"comment": null,
"id": Any<Number>,
"is_insert_enabled": true,
"is_updatable": true,
"is_update_enabled": true,
"name": "todos_view",
"schema": "public",
},
Expand Down
Loading
Loading