From 381ab5fa0a65e2be90e43f204126e896d5e99beb Mon Sep 17 00:00:00 2001 From: SauravYadav12 Date: Tue, 10 Nov 2026 22:06:19 +0530 Subject: [PATCH 1/2] feat(console): function and trigger dialogs (point 12.3, first part) New, Edit, Rename and Drop for functions, procedures, trigger functions and triggers. Each dialog shows the SQL as it is built and runs it as the person. An edit keeps what it has no field for. The catalog gives each function's attributes and each trigger's parts, and marks trigger functions in the list. Also: a late reply about the branch just left no longer replaces the console's catalog of the branch switched to. --- cmd/fox/schemacmd.go | 4 +- cmd/fox/schemacmd_test.go | 2 +- internal/controlplane/catalog.go | 140 +++++++++++- internal/controlplane/catalog_params_test.go | 25 +++ internal/controlplane/openapi.yaml | 4 +- scripts/integration_test.sh | 86 +++++++ web/src/api.ts | 12 +- web/src/components/ObjectBrowser.tsx | 111 ++++++++- web/src/components/RoutineDialogs.tsx | 225 +++++++++++++++++++ web/src/ddl.test.ts | 67 ++++++ web/src/ddl.ts | 100 +++++++++ web/src/pages/Console.tsx | 33 ++- web/src/styles.css | 8 + web/tests/routines.spec.ts | 139 ++++++++++++ 14 files changed, 927 insertions(+), 29 deletions(-) create mode 100644 web/src/components/RoutineDialogs.tsx create mode 100644 web/src/ddl.test.ts create mode 100644 web/src/ddl.ts create mode 100644 web/tests/routines.spec.ts diff --git a/cmd/fox/schemacmd.go b/cmd/fox/schemacmd.go index b43d93a..9f15947 100644 --- a/cmd/fox/schemacmd.go +++ b/cmd/fox/schemacmd.go @@ -183,7 +183,9 @@ func displayName(o controlplane.CatalogObject) string { if o.Kind == "function" || o.Kind == "procedure" { n += "(" + o.Args + ")" } - if o.Sub != "" && o.Kind != "extension" { + // A function's sub says it is a trigger function, for the console's dialogs; + // the listing names functions by their arguments alone. + if o.Sub != "" && o.Kind != "extension" && o.Kind != "function" { n += " [" + o.Sub + "]" } return n diff --git a/cmd/fox/schemacmd_test.go b/cmd/fox/schemacmd_test.go index 7e61430..57d3aca 100644 --- a/cmd/fox/schemacmd_test.go +++ b/cmd/fox/schemacmd_test.go @@ -28,7 +28,7 @@ func TestPrintCatalog(t *testing.T) { {Kind: "schema", Name: "empty"}, {Kind: "table", Schema: "shop", Name: "orders"}, {Kind: "table", Schema: "shop", Name: "events", Sub: "partitioned"}, - {Kind: "function", Schema: "shop", Name: "total", Args: "o_id integer"}, + {Kind: "function", Schema: "shop", Name: "total", Args: "o_id integer", Sub: "trigger"}, {Kind: "extension", Schema: "public", Name: "dblink", Sub: "1.2"}, } var b bytes.Buffer diff --git a/internal/controlplane/catalog.go b/internal/controlplane/catalog.go index 830f106..b1cfeca 100644 --- a/internal/controlplane/catalog.go +++ b/internal/controlplane/catalog.go @@ -71,6 +71,30 @@ type ObjectDetail struct { // Params are a function's or a procedure's arguments, in order, for a // form that calls it (point 12.4). Params []FuncParam `json:"params,omitempty"` + // Routine is what an edit dialog needs to write a function or procedure + // again without losing anything it was not asked to change (point 12.3). + Routine *RoutineInfo `json:"routine,omitempty"` +} + +// RoutineInfo is a function or a procedure, attribute by attribute. +type RoutineInfo struct { + Arguments string `json:"arguments"` // as declared, defaults included + Identity string `json:"identity"` // as ALTER and DROP name it + Returns string `json:"returns,omitempty"` + Language string `json:"language"` + Body string `json:"body"` + // Atomic: written with BEGIN ATOMIC, which has no body as text to edit. + Atomic bool `json:"atomic,omitempty"` + Volatility string `json:"volatility"` // immutable, stable, volatile + Strict bool `json:"strict,omitempty"` + Definer bool `json:"definer,omitempty"` + Leakproof bool `json:"leakproof,omitempty"` + Window bool `json:"window,omitempty"` + Parallel string `json:"parallel"` // safe, restricted, unsafe + Cost float64 `json:"cost"` + Rows float64 `json:"rows,omitempty"` + Set []string `json:"set,omitempty"` // each SET clause, as Postgres writes it + Trigger bool `json:"trigger,omitempty"` } // FuncParam is one argument of a function or a procedure. @@ -106,6 +130,91 @@ type CatalogPart struct { // System marks a trigger FoxByte installed (the Blackbox's), as opposed to one of the user's. System bool `json:"system,omitempty"` Disabled bool `json:"disabled,omitempty"` + // Trigger is a trigger's parts, for the dialog that edits it (point 12.3). + Trigger *TriggerInfo `json:"trigger,omitempty"` +} + +// TriggerInfo is a trigger, part by part. +type TriggerInfo struct { + Timing string `json:"timing"` // BEFORE, AFTER, INSTEAD OF + Events []string `json:"events"` // INSERT, UPDATE, DELETE, TRUNCATE + Columns []string `json:"columns,omitempty"` + ForEach string `json:"forEach"` // ROW or STATEMENT + When string `json:"when,omitempty"` + Function string `json:"function"` // schema-qualified and quoted + Args string `json:"args,omitempty"` // as written between the parentheses + Referencing string `json:"referencing,omitempty"` + // Constraint: a CONSTRAINT TRIGGER, which CREATE OR REPLACE cannot replace. + Constraint bool `json:"constraint,omitempty"` +} + +// The bits of pg_trigger.tgtype. +const ( + tgRow = 1 << 0 + tgBefore = 1 << 1 + tgInsert = 1 << 2 + tgDelete = 1 << 3 + tgUpdate = 1 << 4 + tgTruncate = 1 << 5 + tgInstead = 1 << 6 +) + +// triggerInfo reads a trigger's parts from its type bits, and what only its +// definition holds (the WHEN condition, the arguments, REFERENCING) from the +// text pg_get_triggerdef writes. +func triggerInfo(tgtype int, columns []string, function string, constraint bool, def string) *TriggerInfo { + t := &TriggerInfo{Timing: "AFTER", ForEach: "STATEMENT", Columns: columns, Function: function, Constraint: constraint, Events: []string{}} + switch { + case tgtype&tgBefore != 0: + t.Timing = "BEFORE" + case tgtype&tgInstead != 0: + t.Timing = "INSTEAD OF" + } + if tgtype&tgRow != 0 { + t.ForEach = "ROW" + } + for _, e := range []struct { + bit int + name string + }{{tgInsert, "INSERT"}, {tgUpdate, "UPDATE"}, {tgDelete, "DELETE"}, {tgTruncate, "TRUNCATE"}} { + if tgtype&e.bit != 0 { + t.Events = append(t.Events, e.name) + } + } + exec := strings.LastIndex(def, " EXECUTE FUNCTION ") + if exec < 0 { + return t + } + head := def[:exec] + if w := strings.Index(head, " WHEN ("); w >= 0 && strings.HasSuffix(head, ")") { + t.When = head[w+len(" WHEN (") : len(head)-1] + head = head[:w] + } + if r := strings.Index(head, " REFERENCING "); r >= 0 { + if f := strings.Index(head[r:], " FOR EACH "); f >= 0 { + t.Referencing = head[r+len(" REFERENCING ") : r+f] + } + } + call := def[exec+len(" EXECUTE FUNCTION "):] + if o := strings.Index(call, "("); o >= 0 && strings.HasSuffix(call, ")") { + t.Args = call[o+1 : len(call)-1] + } + return t +} + +// setClauses are the SET lines of a pg_get_functiondef text, as Postgres +// writes them: search_path's list quoting is its own to get right. +func setClauses(def string) []string { + var out []string + for _, l := range strings.Split(def, "\n") { + if strings.HasPrefix(l, "AS ") || strings.HasPrefix(l, "BEGIN ATOMIC") { + break + } + if t := strings.TrimSpace(l); strings.HasPrefix(t, "SET ") { + out = append(out, t) + } + } + return out } // CatalogMember is something an extension installed. @@ -189,7 +298,7 @@ func readCatalog(ctx context.Context, conn *pgx.Conn) (Catalog, error) { }); err != nil { return err } - if err := add(`SELECT p.oid, n.nspname, p.proname, pg_get_function_identity_arguments(p.oid), p.prokind::text FROM pg_proc p + if err := add(`SELECT p.oid, n.nspname, p.proname, pg_get_function_identity_arguments(p.oid), p.prokind::text, p.prorettype = 'trigger'::regtype FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace WHERE `+userSchemas+` AND p.prokind IN ('f','p') AND `+fmt.Sprintf(notExtensionMember, `'pg_proc'::regclass`, `p.oid`)+` @@ -197,8 +306,12 @@ func readCatalog(ctx context.Context, conn *pgx.Conn) (Catalog, error) { func(r pgx.Rows) (CatalogObject, error) { var o CatalogObject var k string - err := r.Scan(&o.OID, &o.Schema, &o.Name, &o.Args, &k) + var trig bool + err := r.Scan(&o.OID, &o.Schema, &o.Name, &o.Args, &k, &trig) o.Kind = map[string]string{"f": "function", "p": "procedure"}[k] + if trig { + o.Sub = "trigger" // what a trigger's dialog offers (point 12.3) + } return o, err }); err != nil { return err @@ -398,7 +511,10 @@ func readRelation(ctx context.Context, tx pgx.Tx, d *ObjectDetail) error { return err } rows, err = tx.Query(ctx, `SELECT t.tgname, pg_get_triggerdef(t.oid, true), t.tgenabled = 'D', - pn.nspname = 'bb' OR pn.nspname LIKE 'bb\_%' + pn.nspname = 'bb' OR pn.nspname LIKE 'bb\_%', t.tgtype::int, t.tgconstraint <> 0, + format('%I.%I', pn.nspname, p.proname), + coalesce((SELECT array_agg(a.attname::text ORDER BY k.o) FROM unnest(t.tgattr::int2[]) WITH ORDINALITY AS k(n, o) + JOIN pg_attribute a ON a.attrelid = t.tgrelid AND a.attnum = k.n), '{}') FROM pg_trigger t JOIN pg_proc p ON p.oid = t.tgfoid JOIN pg_namespace pn ON pn.oid = p.pronamespace WHERE t.tgrelid = $1::oid AND NOT t.tgisinternal ORDER BY t.tgname`, d.OID) if err != nil { @@ -406,10 +522,15 @@ func readRelation(ctx context.Context, tx pgx.Tx, d *ObjectDetail) error { } for rows.Next() { var p CatalogPart - if err := rows.Scan(&p.Name, &p.Def, &p.Disabled, &p.System); err != nil { + var tgtype int + var constraint bool + var fn string + var cols []string + if err := rows.Scan(&p.Name, &p.Def, &p.Disabled, &p.System, &tgtype, &constraint, &fn, &cols); err != nil { rows.Close() return err } + p.Trigger = triggerInfo(tgtype, cols, fn, constraint, p.Def) d.Triggers = append(d.Triggers, p) } rows.Close() @@ -580,6 +701,17 @@ func readFunction(ctx context.Context, tx pgx.Tx, d *ObjectDetail) error { } d.Facts = append(d.Facts, [2]string{"language", lang}) d.DDL = strings.TrimRight(def, "\n") + ";\n" + r := &RoutineInfo{Arguments: args, Returns: result, Language: lang, Set: setClauses(def), + Volatility: map[string]string{"i": "immutable", "s": "stable", "v": "volatile"}[vol]} + var parallel string + if err := tx.QueryRow(ctx, `SELECT pg_get_function_identity_arguments(p.oid), p.prosrc, p.prosqlbody IS NOT NULL, p.proisstrict, p.prosecdef, + p.proleakproof, p.prokind = 'w', p.proparallel::text, p.procost::float8, p.prorows::float8, p.prorettype = 'trigger'::regtype + FROM pg_proc p WHERE p.oid = $1::oid`, d.OID).Scan(&r.Identity, &r.Body, &r.Atomic, &r.Strict, &r.Definer, + &r.Leakproof, &r.Window, ¶llel, &r.Cost, &r.Rows, &r.Trigger); err != nil { + return err + } + r.Parallel = map[string]string{"s": "safe", "r": "restricted", "u": "unsafe"}[parallel] + d.Routine = r return readParams(ctx, tx, d) } diff --git a/internal/controlplane/catalog_params_test.go b/internal/controlplane/catalog_params_test.go index b64ccd2..2e097b2 100644 --- a/internal/controlplane/catalog_params_test.go +++ b/internal/controlplane/catalog_params_test.go @@ -36,3 +36,28 @@ func TestParamsSQL(t *testing.T) { } } } + +// A trigger's parts, from its type bits and its definition (point 12.3). +func TestTriggerInfo(t *testing.T) { + def := `CREATE TRIGGER trg BEFORE INSERT OR UPDATE OF v, w ON public.t FOR EACH ROW WHEN (old.v IS DISTINCT FROM new.v) EXECUTE FUNCTION public.f('a', 'b)')` + got := triggerInfo(tgRow|tgBefore|tgInsert|tgUpdate, []string{"v", "w"}, "public.f", false, def) + if got.Timing != "BEFORE" || got.ForEach != "ROW" || strings.Join(got.Events, ",") != "INSERT,UPDATE" || got.When != "old.v IS DISTINCT FROM new.v" || got.Args != "'a', 'b)'" || strings.Join(got.Columns, ",") != "v,w" { + t.Errorf("%+v", got) + } + ref := `CREATE TRIGGER s AFTER DELETE ON t REFERENCING OLD TABLE AS gone FOR EACH STATEMENT EXECUTE FUNCTION f()` + got = triggerInfo(tgDelete, nil, "public.f", false, ref) + if got.Timing != "AFTER" || got.ForEach != "STATEMENT" || got.Referencing != "OLD TABLE AS gone" || got.When != "" || got.Args != "" { + t.Errorf("%+v", got) + } + if got := triggerInfo(tgInstead|tgRow|tgInsert, nil, "f", false, "x"); got.Timing != "INSTEAD OF" { + t.Errorf("%+v", got) + } +} + +// The SET clauses of a function's definition, and none from its body. +func TestSetClauses(t *testing.T) { + def := "CREATE OR REPLACE FUNCTION public.f()\n RETURNS integer\n LANGUAGE sql\n SET search_path TO 'public', 'pg_temp'\n SET work_mem TO '1MB'\nAS $function$\nSET x TO 1;\n$function$\n" + if got := setClauses(def); strings.Join(got, "|") != "SET search_path TO 'public', 'pg_temp'|SET work_mem TO '1MB'" { + t.Errorf("%q", got) + } +} diff --git a/internal/controlplane/openapi.yaml b/internal/controlplane/openapi.yaml index 643c17f..51191fb 100644 --- a/internal/controlplane/openapi.yaml +++ b/internal/controlplane/openapi.yaml @@ -670,7 +670,9 @@ paths: a function's arguments, result and language, and for a function or a procedure params, each argument's name, type, mode (in, out, inout, variadic, table) and whether it has a default, for the console's Run - form (point 12.4); a sequence's range; a + form (point 12.4), and routine, its attributes for the Edit dialog, and + for each of a table's triggers trigger, its parts (point 12.3); a + sequence's range; a type's values or fields; what an extension installed. ddl is what it would take to make the object again, without grants or ownership. An object the caller's list does not hold answers 404. diff --git a/scripts/integration_test.sh b/scripts/integration_test.sh index da0799a..9628995 100644 --- a/scripts/integration_test.sh +++ b/scripts/integration_test.sh @@ -3893,6 +3893,92 @@ assert_eq "…with Analyse it runs, measured, inside a transaction rolled back: "$(python3 -c 'import json,sys; d=json.load(sys.stdin); r=[x for x in d["results"] if x.get("columns")==["QUERY PLAN"]][0]; v=r["rows"][0][0]; doc=json.loads(v) if isinstance(v, str) else v; p=doc[0]; print(p["Plan"]["Node Type"], p["Plan"]["Actual Rows"], "Execution Time" in p)' <<<"$EXA")|$(pg pg-main "SELECT count(*) FROM r_items")" "ModifyTable 0 True|2" pg pg-main "SET bb.allow_destructive = on; DROP TABLE IF EXISTS r_items CASCADE; DROP FUNCTION IF EXISTS r_add(integer, integer), r_series(integer), r_none(), r_sum(VARIADIC integer[]), r_upper(); DROP PROCEDURE IF EXISTS r_bump(integer, integer, text)" >/dev/null +echo "### 11ap. create and edit dialogs for functions and triggers (point 12.3)" +# The dialogs write SQL the person runs, so what is checked here is what the +# engine gives an edit dialog, and that what the dialogs write runs and keeps +# what it was not asked to change; the dialogs themselves are routines.spec.ts. +pg pg-main "SET bb.allow_destructive = on; DROP TABLE IF EXISTS rd_items CASCADE; DROP FUNCTION IF EXISTS rd_full(integer), rd_atomic(), rd_full2(integer), rd_tf(); DROP PROCEDURE IF EXISTS rd_proc(integer)" >/dev/null +printf 'password123\n' | $S user create dlg-other@foxbyte.dev >/dev/null 2>&1 +DO="$($S apikey create dlg-other@foxbyte.dev t 2>/dev/null | grep -o 'key_[A-Za-z0-9_-]*')" +rd() { curl -sk -H "$AUTH" -H 'Content-Type: application/json' -X POST -d "$(python3 -c 'import json,sys; print(json.dumps({"sql": sys.argv[1]}))' "$1")" https://localhost:8080/api/branches/main/query; } +rderr() { python3 -c 'import json,sys; d=json.load(sys.stdin); print(d.get("error") or next((r["error"] for r in d.get("results",[]) if r.get("error")), ""))'; } +assert_eq "what the New dialog writes makes the function, the procedure and the trigger function" \ + "$(rd 'CREATE FUNCTION "public"."rd_full"(a integer DEFAULT 1) + RETURNS integer + LANGUAGE sql + STABLE + STRICT + SECURITY DEFINER + COST 50 + SET search_path TO '"'"'public'"'"', '"'"'pg_temp'"'"' +AS $body$ +SELECT a +$body$' | rderr)|$(rd 'CREATE PROCEDURE "public"."rd_proc"(a integer) + LANGUAGE plpgsql +AS $body$ +BEGIN NULL; END; +$body$' | rderr)|$(rd 'CREATE FUNCTION "public"."rd_tf"() + RETURNS trigger + LANGUAGE plpgsql +AS $body$ +BEGIN RETURN NEW; END; +$body$' | rderr)" "||" +rd 'CREATE FUNCTION rd_atomic() RETURNS integer BEGIN ATOMIC SELECT 1; END' >/dev/null +RCAT="$(curl -sk -H "$AUTH" https://localhost:8080/api/branches/main/schema)" +rdoid() { hjson "print([o['oid'] for o in d['objects'] if o['name']=='$1' and o['kind']=='$2'][0])" <<<"$RCAT"; } +rout() { curl -sk -H "$AUTH" "https://localhost:8080/api/branches/main/schema/$2/$(rdoid "$1" "$2")"; } +assert_eq "an edit dialog is given everything: arguments, identity, returns, language, body, and each attribute" \ + "$(rout rd_full function | hjson "r=d['routine']; print(r['arguments'], '/', r['identity'], '/', r['returns'], r['language'], r['body'].strip(), r['volatility'], r.get('strict'), r.get('definer'), r['cost'], r['set'])")" \ + "a integer DEFAULT 1 / a integer / integer sql SELECT a stable True True 50 [\"SET search_path TO 'public', 'pg_temp'\"]" +assert_eq "…a trigger function is told apart in the list; BEGIN ATOMIC is said to have no body to edit" \ + "$(hjson "print([o.get('sub') for o in d['objects'] if o['name']=='rd_tf'][0])" <<<"$RCAT")|$(rout rd_tf function | hjson "print(d['routine'].get('trigger'))")|$(rout rd_atomic function | hjson "print(d['routine'].get('atomic'))")" "trigger|True|True" +assert_eq "what the Edit dialog writes changes the body and keeps every attribute" \ + "$(rd 'CREATE OR REPLACE FUNCTION "public"."rd_full"(a integer DEFAULT 1) + RETURNS integer + LANGUAGE sql + STABLE + STRICT + SECURITY DEFINER + COST 50 + SET search_path TO '"'"'public'"'"', '"'"'pg_temp'"'"' +AS $body$ +SELECT a + 1 +$body$' | rderr)|$(rout rd_full function | hjson "r=d['routine']; print(r['body'].strip(), r.get('definer'), r['cost'], len(r['set']))")" "|SELECT a + 1 True 50 1" +# log_ddl_drop does not record a procedure's or a trigger's drop; the catalog +# scan does, as OBSERVED, so one is run before the drop and one after. It names +# what it drops as it last saw it, so a trigger renamed between scans keeps its old name. +$S ledger scan main >/dev/null 2>&1 +assert_eq "rename and drop, as written; each in the Blackbox, the procedure's drop by the catalog scan" \ + "$(rd 'ALTER FUNCTION "public"."rd_full"(a integer) RENAME TO "rd_full2"' | rderr)|$(pg pg-main "SELECT to_regproc('rd_full2') IS NOT NULL, to_regproc('rd_full') IS NULL")|$(rd 'DROP PROCEDURE "public"."rd_proc"(a integer)' | rderr)|$($S ledger scan main >/dev/null 2>&1; pg pg-main "SELECT to_regproc('rd_proc') IS NULL, (SELECT count(*) FROM bb.schema_ledger WHERE object_identity LIKE '%rd_full%' AND command_tag = 'ALTER FUNCTION'), (SELECT count(*) FROM bb.schema_ledger WHERE object_identity = 'public.rd_proc(integer)' AND command_tag = 'DROP PROCEDURE' AND status = 'OBSERVED')")" \ + "|t|t||t|1|1" +# Accounts reach a branch through one database role, so what any of them made +# is that role's; what it does not own, Postgres refuses, from a dialog as anywhere. +pg pg-main "CREATE OR REPLACE FUNCTION rd_admin(a integer) RETURNS integer LANGUAGE sql AS 'SELECT a'" >/dev/null +assert_eq "a dialog cannot change what the account's database role does not own" \ + "$(curl -sk -H "Authorization: Bearer $DO" -H 'Content-Type: application/json' -X POST -d '{"sql":"DROP FUNCTION \"public\".\"rd_admin\"(a integer)"}' https://localhost:8080/api/branches/main/query | rderr | grep -c 'must be owner')|$(pg pg-main "SELECT to_regproc('rd_admin') IS NOT NULL")" "1|t" +# Triggers. +rd 'CREATE TABLE rd_items (id int PRIMARY KEY, v text, w int)' >/dev/null +assert_eq "what the New trigger dialog writes makes it, UPDATE OF, a condition and arguments included" \ + "$(rd 'CREATE TRIGGER "rd_t" BEFORE INSERT OR UPDATE OF "v", "w" ON "public"."rd_items" + FOR EACH ROW + WHEN (new.w > 0) + EXECUTE FUNCTION "public"."rd_tf"('"'"'a'"'"', '"'"'b'"'"')' | rderr)" "" +RCAT="$(curl -sk -H "$AUTH" https://localhost:8080/api/branches/main/schema)" +trinfo() { rout rd_items table | hjson "t=[x for x in d['triggers'] if not x.get('system')]; print([(x['name'], x['trigger']['timing'], x['trigger']['events'], x['trigger'].get('columns'), x['trigger']['forEach'], x['trigger'].get('when'), x['trigger']['function'], x['trigger'].get('args'), x.get('disabled', False)) for x in t])"; } +assert_eq "an edit dialog is given each part of a trigger" \ + "$(trinfo)" "[('rd_t', 'BEFORE', ['INSERT', 'UPDATE'], ['v', 'w'], 'ROW', 'new.w > 0', 'public.rd_tf', \"'a', 'b'\", False)]" +rd 'ALTER TABLE "public"."rd_items" DISABLE TRIGGER "rd_t"' >/dev/null +assert_eq "what the Edit dialog writes replaces it, and one that was disabled stays so" \ + "$(rd 'CREATE OR REPLACE TRIGGER "rd_t" AFTER UPDATE ON "public"."rd_items" + FOR EACH ROW + EXECUTE FUNCTION "public"."rd_tf"(); +ALTER TABLE "public"."rd_items" DISABLE TRIGGER "rd_t"' | rderr)|$(trinfo)" "|[('rd_t', 'AFTER', ['UPDATE'], None, 'ROW', None, 'public.rd_tf', None, True)]" +$S ledger scan main >/dev/null 2>&1 +assert_eq "a trigger renamed and dropped, as written; the drop in the Blackbox by the catalog scan" \ + "$(rd 'ALTER TRIGGER "rd_t" ON "public"."rd_items" RENAME TO "rd_t2"' | rderr)|$(trinfo | grep -c rd_t2)|$(rd 'DROP TRIGGER "rd_t2" ON "public"."rd_items"' | rderr)|$(trinfo)|$($S ledger scan main >/dev/null 2>&1; pg pg-main "SELECT count(*) FROM bb.schema_ledger WHERE object_identity LIKE 'rd_t% on public.rd_items' AND command_tag = 'DROP TRIGGER' AND status = 'OBSERVED'")" "|1||[]|1" +pg pg-main "SET bb.allow_destructive = on; DROP TABLE IF EXISTS rd_items CASCADE; DROP FUNCTION IF EXISTS rd_full2(integer), rd_atomic(), rd_tf(), rd_admin(integer)" >/dev/null +$S user delete dlg-other@foxbyte.dev >/dev/null 2>&1 + echo "### 12. fox uninstall (B1)" # Removal used to be a list of commands to run by hand. This runs last: it takes # the stack apart, so nothing after it has a stack to use. diff --git a/web/src/api.ts b/web/src/api.ts index 1806f96..176aefc 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -440,13 +440,23 @@ export type CatalogKind = 'schema' | 'table' | 'view' | 'sequence' | 'function' export type CatalogObject = { kind: CatalogKind; oid: number; schema?: string; name: string; args?: string; sub?: string; system?: boolean } -export type CatalogPart = { name: string; kind?: string; def: string; constraint?: boolean; system?: boolean; disabled?: boolean } +export type TriggerInfo = { + timing: string; events: string[]; columns?: string[]; forEach: string; when?: string; function: string; args?: string; referencing?: string; constraint?: boolean +} +export type CatalogPart = { name: string; kind?: string; def: string; constraint?: boolean; system?: boolean; disabled?: boolean; trigger?: TriggerInfo } +// A function or procedure, attribute by attribute (point 12.3). +export type RoutineInfo = { + arguments: string; identity: string; returns?: string; language: string; body: string; atomic?: boolean + volatility: string; strict?: boolean; definer?: boolean; leakproof?: boolean; window?: boolean + parallel: string; cost: number; rows?: number; set?: string[]; trigger?: boolean +} export type CatalogCol = { name: string; type: string; not_null?: boolean; default?: string; identity?: string; generated?: string; comment?: string } export type ObjectDetail = CatalogObject & { comment?: string; columns?: CatalogCol[]; constraints?: CatalogPart[]; indexes?: CatalogPart[]; triggers?: CatalogPart[] facts?: [string, string][]; values?: string[]; members?: { Kind: string; Name: string }[]; ddl: string // A function's or a procedure's arguments, in order (point 12.4). params?: FuncParam[] + routine?: RoutineInfo } export type FuncParam = { name?: string; type: string; mode: 'in' | 'out' | 'inout' | 'variadic' | 'table'; default?: boolean } export const getCatalog = (branch: string) => diff --git a/web/src/components/ObjectBrowser.tsx b/web/src/components/ObjectBrowser.tsx index 126bd83..54bb5b5 100644 --- a/web/src/components/ObjectBrowser.tsx +++ b/web/src/components/ObjectBrowser.tsx @@ -1,15 +1,18 @@ // SPDX-License-Identifier: AGPL-3.0-or-later -import { useEffect, useState } from 'react' +import { useEffect, useState, type ReactNode } from 'react' import { getObject, runQuery, type CatalogKind, type CatalogObject, type CatalogPart, type FuncParam, type ObjectDetail } from '../api' import { useConfirm } from '../confirm' import { buildCall, inputsOf, quoteIdent, type ArgInput } from '../runcall' +import { dropRoutine, dropTrigger, renameRoutine, renameTrigger } from '../ddl' +import { RenameDialog, RoutineDialog, runDDL, TriggerDialog } from './RoutineDialogs' // The object browser (point 12.1). The schema rail already lists tables and // views; these are the rest of what a branch holds (functions, procedures, // sequences, types, schemas, extensions) and, for any object, what it is made // of and its CREATE script. The engine reads the catalog as you. A function // or a procedure can be run from a form, and a table's own triggers switched -// off and on (point 12.4): both as SQL the person runs, through the console. +// off and on (point 12.4), and functions, procedures and triggers made, edited, +// renamed and dropped (point 12.3): all as SQL the person runs, through the console. const GROUPS: { kind: CatalogKind; title: string; icon: string }[] = [ { kind: 'function', title: 'Functions', icon: 'ƒ' }, @@ -30,17 +33,19 @@ export function objectLabel(o: CatalogObject): string { const objKey = (o: CatalogObject) => `${o.kind}:${o.oid}` /** The rail's groups beyond tables and views, folded until opened or searched. */ -export function CatalogGroups({ objects, filter, showSystem, active, onOpen }: { +export function CatalogGroups({ objects, filter, showSystem, active, onOpen, onNew }: { objects: CatalogObject[] filter: string showSystem: boolean active: CatalogObject | null onOpen: (o: CatalogObject) => void + onNew?: () => void }) { const [open, setOpen] = useState>({}) const f = filter.trim().toLowerCase() return ( <> + {onNew && } {GROUPS.map(g => { const list = objects.filter(o => o.kind === g.kind && (showSystem || !o.system) && (!f || objectLabel(o).toLowerCase().includes(f) || (o.schema ?? '').toLowerCase().includes(f))) @@ -99,7 +104,9 @@ export function DDL({ text, onOpenSql }: { text: string; onOpenSql: (sql: string ) } -function Parts({ title, parts, empty, onToggle }: { title: string; parts?: CatalogPart[]; empty: string; onToggle?: (p: CatalogPart) => void }) { +function Parts({ title, parts, empty, onToggle, actions }: { + title: string; parts?: CatalogPart[]; empty: string; onToggle?: (p: CatalogPart) => void; actions?: (p: CatalogPart) => ReactNode +}) { return (
{!parts?.length ?

{empty}

: ( @@ -118,7 +125,7 @@ function Parts({ title, parts, empty, onToggle }: { title: string; parts?: Catal {p.system ? FoxByte’s own - : } + : <>{actions?.(p)}} )} @@ -131,10 +138,27 @@ function Parts({ title, parts, empty, onToggle }: { title: string; parts?: Catal } /** Triggers, each of the user's own switched off and on (point 12.4), as the person, through the console. */ -function TriggerParts({ branch, obj, d, reload }: { branch: string; obj: CatalogObject; d: ObjectDetail; reload: () => void }) { +function TriggerParts({ branch, obj, d, reload, catalog, onOpenSql }: { + branch: string; obj: CatalogObject; d: ObjectDetail; reload: () => void; catalog: CatalogObject[]; onOpenSql: (sql: string) => void +}) { const confirm = useConfirm() const [err, setErr] = useState('') const [note, setNote] = useState('') + const [dialog, setDialog] = useState<{ type: 'new' } | { type: 'edit' | 'rename'; part: CatalogPart } | null>(null) + const schema = obj.schema ?? 'public' + const functions = catalog.filter(o => o.kind === 'function' && o.sub === 'trigger' && !o.system && !o.args) + .map(o => `${quoteIdent(o.schema ?? 'public')}.${quoteIdent(o.name)}`) + const done = (msg: string) => { setDialog(null); setErr(''); setNote(msg); reload() } + const drop = async (p: CatalogPart) => { + if (!await confirm({ + title: 'Drop trigger', + message: <>This drops {p.name} from {obj.name}. The change is recorded in the Blackbox., + confirmText: 'Drop it', danger: true, + })) return + setNote('') + const e = await runDDL(branch, dropTrigger(schema, obj.name, p.name)) + if (e) setErr(e); else done(`${p.name} dropped.`) + } const toggle = async (p: CatalogPart) => { const on = p.disabled if (!on && !await confirm({ @@ -154,34 +178,76 @@ function TriggerParts({ branch, obj, d, reload }: { branch: string; obj: Catalog } return ( <> - +
+ +
+ ( + <> + {' '}{!p.trigger?.constraint && } + {' '} + {' '} + + )} /> {note &&
{note}
} {err &&
{err}
} + {dialog?.type === 'new' && ( + setDialog(null)} onDone={() => done('Trigger created.')} /> + )} + {dialog?.type === 'edit' && ( + setDialog(null)} onDone={() => done(`${dialog.part.name} saved.`)} /> + )} + {dialog?.type === 'rename' && ( + renameTrigger(schema, obj.name, dialog.part.name, to)} + onClose={() => setDialog(null)} onDone={() => done('Trigger renamed.')} /> + )} ) } /** The table tabs the catalog fills: constraints, triggers and the CREATE script. */ -export function TableParts({ branch, obj, tab, onOpenSql }: { +export function TableParts({ branch, obj, tab, onOpenSql, catalog }: { branch: string obj: CatalogObject | null tab: 'constraints' | 'triggers' | 'ddl' onOpenSql: (sql: string) => void + catalog: CatalogObject[] }) { const { d, err, reload } = useObject(branch, obj) if (!obj) return

Not in this branch's catalog for you.

if (err) return
{err}
if (!d) return
Loading…
if (tab === 'constraints') return - if (tab === 'triggers') return + if (tab === 'triggers') return return } /** ObjectView is any object that is not a table or a view: what it is, and its CREATE script. */ -export function ObjectView({ branch, obj, onOpenSql, onRunSql }: { +export function ObjectView({ branch, obj, onOpenSql, onRunSql, onChanged }: { branch: string; obj: CatalogObject; onOpenSql: (sql: string) => void; onRunSql?: (sql: string) => void + onChanged?: (gone: boolean) => void }) { - const { d, err } = useObject(branch, obj) + const { d, err, reload } = useObject(branch, obj) + const confirm = useConfirm() + const [dialog, setDialog] = useState<'edit' | 'rename' | null>(null) + const [actErr, setActErr] = useState('') + const [dropSql, setDropSql] = useState('') + const routine = (obj.kind === 'function' || obj.kind === 'procedure') && !obj.system && onChanged ? d?.routine : undefined + const schema = obj.schema ?? 'public' + const editable = routine && !routine.atomic && (routine.language === 'plpgsql' || routine.language === 'sql') + const drop = async () => { + if (!routine || !await confirm({ + title: `Drop ${obj.kind}`, + message: <>This drops {objectLabel(obj)}. Anything that calls it stops working. The change is recorded in the Blackbox., + confirmText: 'Drop it', danger: true, + })) return + const sql = dropRoutine(obj.kind, schema, obj.name, routine.identity) + setActErr(''); setDropSql('') + const e = await runDDL(branch, sql) + if (e) { setActErr(e); setDropSql(sql) } else onChanged?.(true) + } return (
@@ -205,6 +271,29 @@ export function ObjectView({ branch, obj, onOpenSql, onRunSql }: { )} {!!d.constraints?.length && } + {routine && ( +
+ {editable + ? + : Written in a way the dialog cannot edit: change its CREATE script in the editor.} + + +
+ )} + {actErr && ( +
{actErr} + {dropSql && <> } +
+ )} + {dialog === 'edit' && routine && ( + setDialog(null)} onDone={() => { setDialog(null); reload(); onChanged?.(false) }} /> + )} + {dialog === 'rename' && routine && ( + renameRoutine(obj.kind, schema, obj.name, routine.identity, to)} + onClose={() => setDialog(null)} onDone={() => { setDialog(null); onChanged?.(true) }} /> + )} {(obj.kind === 'function' || obj.kind === 'procedure') && d.params && onRunSql && ( diff --git a/web/src/components/RoutineDialogs.tsx b/web/src/components/RoutineDialogs.tsx new file mode 100644 index 0000000..daf408e --- /dev/null +++ b/web/src/components/RoutineDialogs.tsx @@ -0,0 +1,225 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +import { useId, useState, type ReactNode } from 'react' +import { createPortal } from 'react-dom' +import { runQuery, type CatalogObject, type CatalogPart, type RoutineInfo } from '../api' +import { buildRoutine, buildTrigger, TRIGGER_BODY } from '../ddl' + +// The create and edit dialogs (point 12.3): each shows the SQL it will run, +// and runs it as the person, through the console. "Put it in the editor" is +// there for what a guardrail refuses, where the Console can ask to override. + +/** runDDL runs a statement as the person and returns what went wrong, or "". */ +export async function runDDL(branch: string, sql: string): Promise { + try { + const r = await runQuery(branch, sql) + return r.error ?? r.results?.find(x => x.error)?.error ?? '' + } catch (e) { return (e as Error).message } +} + +function Shell({ title, sql, error, busy, save, onClose, onOpenSql, children }: { + title: string; sql?: string; error: string; busy: boolean; save: string + onClose: () => void; onOpenSql: (sql: string) => void; children: ReactNode +}) { + const id = useId() + // At the top of the page: a pane around it could otherwise clip it. + return createPortal( +
+
+

{title}

+ {children} + {sql &&
{sql}
} + {error &&
{error}
} +
+ + + +
+
+
, + document.body, + ) +} + +const Field = ({ label, children }: { label: string; children: ReactNode }) => + +type Kind = 'function' | 'procedure' | 'trigger' + +/** RoutineDialog makes a function, procedure or trigger function, or edits one. */ +export function RoutineDialog({ branch, schemas, edit, onClose, onDone, onOpenSql }: { + branch: string; schemas: string[] + edit?: { obj: CatalogObject; routine: RoutineInfo } + onClose: () => void; onDone: () => void; onOpenSql: (sql: string) => void +}) { + const r = edit?.routine + const [kind, setKind] = useState(r?.trigger ? 'trigger' : edit?.obj.kind === 'procedure' ? 'procedure' : 'function') + const [schema, setSchema] = useState(edit?.obj.schema ?? 'public') + const [name, setName] = useState(edit?.obj.name ?? '') + const [args, setArgs] = useState(r?.arguments ?? '') + const [returns, setReturns] = useState(r?.returns ?? '') + const [language, setLanguage] = useState(r?.language ?? 'plpgsql') + const [volatility, setVolatility] = useState(r?.volatility ?? 'volatile') + const [strict, setStrict] = useState(!!r?.strict) + const [definer, setDefiner] = useState(!!r?.definer) + const [body, setBody] = useState(r?.body?.replace(/^\n/, '') ?? '') + const [err, setErr] = useState('') + const [busy, setBusy] = useState(false) + + const isTrigger = kind === 'trigger' + const text = !edit && isTrigger && !body.trim() ? TRIGGER_BODY : body + const built = buildRoutine({ + kind: kind === 'procedure' ? 'procedure' : 'function', schema, name, + arguments: isTrigger ? '' : args, returns: isTrigger ? 'trigger' : returns, language, body: text, + volatility, strict, definer, replace: !!edit, + // What the dialog has no field for is kept as it was. + leakproof: r?.leakproof, window: r?.window, parallel: r?.parallel, cost: r?.cost, rows: r?.rows, set: r?.set, + }) + const save = async () => { + if (!built.sql) return + setBusy(true); setErr('') + const e = await runDDL(branch, built.sql) + setBusy(false) + if (e) setErr(e); else onDone() + } + const noun = kind === 'trigger' ? 'trigger function' : kind + return ( + +
{ e.preventDefault(); void save() }}> + {!edit && ( + + + + )} + + + + setName(e.target.value)} /> + {!isTrigger && ( + + setArgs(e.target.value)} /> + + )} + {kind === 'function' && ( + setReturns(e.target.value)} /> + )} + + + + {kind === 'function' && ( + + + + )} +
+ {kind === 'function' && } + +
+ {definer &&

It runs with its owner’s rights, not the caller’s, and what it does is not in the audit log.

} + +