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/docs/FOX_Checklist.html b/docs/FOX_Checklist.html
index e1a14de..5301913 100644
--- a/docs/FOX_Checklist.html
+++ b/docs/FOX_Checklist.html
@@ -590,6 +590,7 @@
LWeb console
Data grid: sort, filter, page; add, change and delete rows on a table with a primary key, saved together in one transaction as the user (point 12.2)
Sessions and upkeep: the Sessions page and fox sessions list a branch’s client sessions (login, state, what each waits on and who blocks it, its statement); a running query cancelled or a session ended, in the security log; the owner and admins see and stop everyone’s, anyone else their own and their agent keys’; FoxByte’s own never ended from here. A table’s Upkeep tab and fox maintain: rows, dead row versions, size, last vacuum and analyse, and VACUUM, ANALYZE or both, the owner’s or an admin’s; VACUUM FULL not offered (point 12.5)
Run, toggle and plan: a function or procedure run from a form in the object browser, one field per argument (NULL, or its default), the call shown as it is built and run as the person; a table’s own triggers disabled (after asking) and enabled from its Triggers tab, FoxByte’s own not offered; Explain in the Console, the plan as a table with each step’s share and the most expensive marked, and with Analyse measured inside a transaction that is rolled back (point 12.4)
+
Functions, procedures, trigger functions and triggers made, edited, renamed and dropped from dialogs in the Console: each shows the SQL it will run and runs it as the person, recorded in the Blackbox (a dropped procedure or trigger by the next catalog scan, as OBSERVED, as any such drop is); an edit keeps what it has no field for (SECURITY DEFINER, COST, SET clauses, a disabled trigger stays disabled); FoxByte’s own are not offered; what a guardrail refuses can be put in the editor (point 12.3, first part)
Row tracking: created_at, updated_at, last_modified_by (the login) kept by a protected trigger; opt-in per table or for new tables; fox track (points 5.1, 6.1)
Row tracking’s last_modified_for: whom each row was changed for, asserted by an application or claimed, never a value set by hand; added in place to a table tracked before it (point 5.2)
Each result says which statement produced it; labelling is dropped rather than guessed when a split is unsafe
diff --git a/docs/FOX_Checklist.pdf b/docs/FOX_Checklist.pdf
index 27da8db..3d512f3 100644
Binary files a/docs/FOX_Checklist.pdf and b/docs/FOX_Checklist.pdf differ
diff --git a/docs/FOX_Feature_Implemented.html b/docs/FOX_Feature_Implemented.html
index 5550126..2485026 100644
--- a/docs/FOX_Feature_Implemented.html
+++ b/docs/FOX_Feature_Implemented.html
@@ -119,6 +119,7 @@
Change log
Newest first. Every change to FoxByte adds a row here.
Date
Feature(s)
What changed
Commit / PR
+
10 Nov 2026
L4
Function and trigger dialogs (point 12.3, first part, pgAdmin’s basics).+ New function… in the Console’s schema rail makes a function, a procedure or a trigger function; Edit… changes a body, language, volatility, strict and security definer and keeps what the dialog has no field for (LEAKPROOF, PARALLEL, COST, ROWS, each SET clause); Rename… and Drop… use its identity arguments. A table’s Triggers tab has + New trigger…, Edit, Rename and Drop: timing, events, columns for UPDATE, row or statement, WHEN, the function and its arguments; an edit keeps REFERENCING, and a disabled trigger stays disabled. Each dialog shows the SQL as it is built and runs it as the person, so their rights, the guardrails and the Blackbox apply; what a guardrail refuses can be put in the editor. FoxByte’s own functions and triggers are not offered. The engine gives each function’s attributes (routine) and each trigger’s parts (trigger) on GET …/schema/{kind}/{oid} and marks trigger functions in the list; fox schema’s output is unchanged. Fixed on the way: a slow reply about the branch just left could replace the Console’s catalog of the branch switched to, and a table then said it was not in the catalog; each load now ignores replies to an earlier one. Tests: integration 916/916 (§11ap new: what the dialogs write run, an edit keeping every attribute, a BEGIN ATOMIC function told apart, a disabled trigger staying so, a procedure’s and a trigger’s drop in the Blackbox by the catalog scan, what the role does not own refused), v2 403/403, UI 59/59 plus the upgrade test (a function made, edited, renamed and dropped from its dialogs; a trigger likewise; a late reply about the branch left held back and arriving), integration-f5 57/57, f1 70/70, f6 71/71, web unit 280/280, Go unit tests in both editions, vet for macOS, Linux and Windows.
feat/p12-routines
10 Nov 2026
L4
Run, toggle and plan (point 12.4, pgAdmin’s basics). A function or a procedure opened in the Console’s object browser has a Run form: one field per argument with its type, a NULL box and, where declared, its default; the call is shown as it is built (named arguments when every one has a name, so any default may be left out, otherwise only the last), each value a literal cast to its argument’s type, a procedure’s OUT arguments as NULL; Run puts it in the editor and runs it as the person. The engine now gives each argument’s name, type, mode and default (params on GET …/schema/{kind}/{oid}). A table’s Triggers tab disables (after asking) and enables each of its own triggers, an ALTER TABLE run as the person and recorded in the Blackbox; FoxByte’s own are marked and not offered. Explain, beside Run, shows the selected statement’s plan, or the editor’s only one’s, as a table: each step under the one that uses it, what it reads, its conditions, the estimate and cost, and a bar for its share of the whole, the most expensive marked; with Analyse it runs inside a transaction that is rolled back, adding actual rows, loops, each step’s own time and a warning where the estimate was ten times off. Fixed on the way: a json or jsonb array reached the console with each object inside it as text of its own (["{\"a\":1}"]), as an EXPLAIN plan did; it is now JSON whole. The f6 suite’s measured forensic round wrote its 3.6 GB a minute of audit log to the test VM’s 2 GB in-memory /tmp, and stopped without a word; it now writes to disk. Tests: integration 906/906 (§11ao new: each argument of a function, a set-returning, an argumentless and a variadic one, and a procedure; what the form writes run, with a default left out, NULL, a variadic and a procedure’s INOUT and OUT; a trigger disabled and enabled, firing or not, in the Blackbox, FoxByte’s own marked; a jsonb array whole; a plan read, and with ANALYZE rolled back), v2 403/403, UI 56/56 plus the upgrade test (a function run from its form; a trigger disabled and enabled; a statement explained, and with Analyse measured and rolled back), integration-f5 57/57, f1 70/70, f6 71/71, web unit 269/269, Go unit tests in both editions, vet for macOS, Linux and Windows.
feat/p12-run-plan
10 Nov 2026
L4
Sessions and upkeep (point 12.5, pgAdmin’s basics). The console’s Sessions page and fox sessions [--branch b] [--json] list a branch’s client sessions (login, application, state, what each waits on and which sessions block it, how long its query has run, its statement cut at 4,000 characters, and whether it is FoxByte’s own); Cancel stops a running query, End closes the session after asking (fox sessions cancel|end <pid> [--yes], POST /api/branches/{name}/sessions/{pid}/cancel|end), each in the security log. The branch’s owner and admins see and stop everyone’s; anyone else their own and their agent keys’, another’s answered as not found; FoxByte’s own sessions are never ended from here. A table’s new Upkeep tab in the Console, fox maintain [--table t] [--json] and GET …/maintenance show its rows, dead row versions, size and last vacuum and analyse, by hand and by autovacuum; fox maintain vacuum|analyze|vacuum-analyze <table>, the tab’s buttons and POST …/maintenance run one for the owner or an admin, in the security log; VACUUM FULL is not offered. Fixed on the way: a table’s tab row in the Console collapsed to nothing once its tabs did not fit the pane, as the seventh now did in a narrow window; it wraps instead. Tests: integration 895/895 (§11an new: an admin, the account and another account seeing a running query; the other refused its cancel; cancelled by an admin and by the account; ended from the CLI after a preview; FoxByte’s own refused; the security log; vacuum and analyse from the CLI and the API, refused to a user on main, allowed on its own branch), v2 403/403, UI 53/53 plus the upgrade test (a running query seen and cancelled on the Sessions page; a table analysed from the Upkeep tab), integration-f5 57/57, f1 70/70, f6 71/71, web unit 258/258, Go unit tests in both editions, vet for macOS, Linux and Windows.
feat/p12-sessions
9 Nov 2026
O3
Masking personal data, step 5 (F5 Gate 1, Enterprise): masked branches. Masking’s Gate 1 is complete.fox branch create <name> --masked [--from b], the Dashboard’s masked option, and POST /api/branches with masked (made in the background, 202) make a copy whose marked columns hold their masks, for people and agents who should never see the real values. It is recorded before it exists, so the Gateway (57P03), the console and the API (423), MCP and the agents refuse it until it is masked and verified, and it never mounts the key. As the engine, each marked column is overwritten with what a masked reader sees and checked row by row; the masks’ machinery is removed; the Blackbox starts afresh, numbering on from the source’s last entry, which the branch’s record keeps, so a change request from it carries only what was done on it (decision taken today); each masked table and the statistics are rewritten; the source’s replication slots are dropped and, with recycling off, the WAL from before deleted. It opens only when none of that is left and the personal-data scan finds no masked category, nor anything a reviewer confirmed, in a column without a rule (decided today; a finding rejected on the source is ignored); otherwise it stays closed with the reasons, and running it again starts over. On an open masked branch nobody is masked. A copy of one is one at once; reset makes it again; a masked account’s agent branch is made as one (decided today, in place of step 4’s refusal). fox mask branches, GET /api/masking/branches and the Masking page’s table say how far each making got and what it found; each made or refused is in the security log (mask.branch). Checked on a throwaway PostgreSQL 18 first: the Blackbox emptied past its append-only triggers with its numbering kept, an old slot dropped, only WAL from after left. Fixed on the way: the Masking and Audit log pages showed main’s state when the first load, for main, finished after the branch picked; the UI tests’ Branch picker is matched exactly, since the Console’s own picker was caught while a page changed. Tests: integration 878/878 (§11am new), v2 403/403, UI 51/51 plus the upgrade test (a masked branch made from the Dashboard, closed, then listed as masked and verified, its column read as a hash), integration-f5 57/57, f1 70/70, f6 71/71, web unit 256/256, Go unit tests in both editions, vet for macOS, Linux and Windows.
feat/f5-mask-step5
@@ -1378,9 +1379,10 @@
Feature index
Object browser
(19 Oct 2026, point 12.1.) Everything a branch holds, read-only. In the console the schema rail keeps its tables and views exactly as before and adds the rest, folded by kind with a count: Functions (with their arguments, so overloads are told apart), Procedures, Sequences, Types (enum, composite, domain, range), Schemas and Extensions (with their versions). The rail's filter searches them all and opens the groups that hold a match. An object opens to what it is: a function's arguments, result, volatility and language; a sequence's type, start, step, range, cache and the column that owns it; an enum's values, a composite's fields, a domain's base type, default and checks, a range's subtype; what an extension installed; and its CREATE script, with Copy and Open in editor. A table gains three tabs beside Rows, Structure and Indexes: Constraints (primary key, unique, foreign key, check, exclusion, each as Postgres writes it), Triggers (FoxByte's own, the Blackbox's and the guardrails', marked) and CREATE script; a view gets its CREATE script. At the CLI, fox schema [--branch b] prints the same tree, --search <text> and --kind find, and fox schema <object> (a name or schema.name; every overload or same-named object it matches) prints its columns, constraints, indexes, triggers and facts and its CREATE script, --ddl the script alone, --json the lot; --all includes FoxByte's own schema. What is read: the catalog, as the caller (the console's user's own role, the CLI's client role), in a read-only transaction, through GET /api/branches/{name}/schema and …/schema/{kind}/{oid}. Postgres shows its catalog to every role, so nothing is shown that a role could not ask for in SQL; tables and views are the ones the caller holds a privilege on, as the rail always listed. Left out: the system schemas; FoxByte's own (bb) unless asked; what an extension installed (dblink's functions are under dblink, not among yours); and what Postgres made as part of something else (an identity column's sequence, a range type's constructor functions). A partitioned table lists its partitions, which are not listed as tables of their own. An object the caller's list does not hold answers 404, so a guessed oid reaches no further than the list. CREATE scripts: Postgres writes a view's, a function's, a procedure's, an index's, a constraint's and a trigger's itself; a table's is put together as pg_dump does: columns with type, default, identity, generated expression and NOT NULL, then its constraints, then the indexes no constraint already made, its own triggers (not FoxByte's, which it puts on every table it records), its partitions, and its comments; a sequence, a type, a domain, an extension and a schema likewise. Grants and ownership are left out. Every script was checked by running it on a fresh branch and reading the object back: the same script, for all fifteen kinds of object in the test. Unit TestTableDDL, TestRelKind, TestCommentDDL, TestCatalogObjectRouteRefusesABadOID, TestObjectNamed, TestPrintCatalog, TestPrintObject; console Console.test.tsx (the groups folded and FoxByte's left out, a search opening them, an object and its script into the editor, a table's constraints, triggers and script); integration §11o (the tree; what is left out, including a table the caller cannot read; FoxByte's schema with --all; search and kind; a table's columns, constraints, indexes and triggers; its script without FoxByte's triggers; overloads; an unknown name; every script run on a fresh branch giving the same object back; the API's list and one object, and 404 for an object not listed and a branch out of reach); Playwright: a function, a type and a table found by search, their facts and scripts, and a script opened in the editor.
Sessions and upkeep
(9 Nov 2026, point 12.5.) Sessions: the console’s Sessions page (Data), read again every 3 seconds, and fox sessions [--branch b] [--json] list a branch’s client sessions: process id, login (an account, its agent key, a shared role, or FoxByte’s own), application, state, what it waits on and which sessions block it, how long its query has run, and its statement, cut at 4,000 characters. Cancel stops a running query and keeps the session; End closes it, rolling back what it had open, after asking (fox sessions cancel|end <pid> [--yes], POST /api/branches/{name}/sessions/{pid}/cancel|end); each is in the security log (session.cancel, session.end). The branch’s owner and an admin see and stop everyone’s sessions; anyone else their own and their agent keys’, and another’s is answered as not found, since a statement can hold values not theirs to read. FoxByte’s own sessions are listed and never ended from here; the server’s processes and a standby’s replication are not listed. Upkeep: a table’s Upkeep tab in the Console, fox maintain [--table t] [--json] and GET /api/branches/{name}/maintenance show its estimated rows, dead row versions, size, and when it was last vacuumed and analysed, by hand and by autovacuum (FoxByte’s own tables left out); fox maintain vacuum|analyze|vacuum-analyze <table>, the tab’s buttons and POST …/maintenance run one, as the engine, for the branch’s owner or an admin, in the security log (maintenance). VACUUM FULL is not offered: it locks the table against reads and writes for as long as it takes.
Run, toggle and plan
(10 Nov 2026, point 12.4.) Run: a function or a procedure opened in the object browser has a form, one field per argument with its type, a NULL box and, where it was declared with one, its default; the call is shown as it is built (named arguments when every one has a name, so any default may be left out; otherwise only the last may), each value a literal cast to its argument’s type, a procedure’s OUT arguments given as NULL; Run puts it in the editor and runs it as the person, Open in editor only puts it there. The engine gives each argument’s name, type, mode and default (params on GET …/schema/{kind}/{oid}). Toggle: a table’s Triggers tab disables (after saying rows written meanwhile are not seen by it) and enables each of its own triggers, as ALTER TABLE … DISABLE|ENABLE TRIGGER run as the person and recorded in the Blackbox; FoxByte’s own are marked and not offered. Plan: Explain, beside Run, shows the plan of the selected statement or the editor’s only one as a table: each step under the one that uses it, what it reads and how, its conditions, the estimate, its cost, and a bar for its share of the whole, the most expensive step marked; with Analyse the statement runs inside a transaction that is rolled back, and the table adds actual rows, loops and each step’s own time, with a warning where the estimate was ten times off; the JSON Postgres wrote is underneath.
+
Function and trigger dialogs
(10 Nov 2026, point 12.3, first part.) + New function… in the Console’s schema rail makes a function, a procedure or a trigger function: schema, name, arguments as declared, what it returns, language (plpgsql or sql), volatility, strict, security definer (saying what that means), and the body. Edit… on a function or procedure changes its body, language, volatility, strict and security definer, and keeps what the dialog has no field for (LEAKPROOF, PARALLEL, COST, ROWS, each SET clause); its name, arguments and return type stay, as changing those makes another function. One written with BEGIN ATOMIC, or in a language other than plpgsql or sql, is edited through its CREATE script in the editor. Rename… and Drop… (after asking) use its identity arguments. On a table’s Triggers tab, + New trigger…, Edit…, Rename… and Drop…: BEFORE or AFTER, its events, columns for UPDATE, for each row or statement, a WHEN condition, the trigger function and its arguments; an edit keeps REFERENCING, and a disabled trigger stays disabled, which CREATE OR REPLACE alone would undo. Every dialog shows the SQL as it is built and runs it as the person, so their rights, the guardrails and the Blackbox apply as to anything they write (the Blackbox records a dropped procedure or trigger at its next catalog scan, as OBSERVED, as it does for any such drop); Put it in the editor is there for what a guardrail refuses. FoxByte’s own functions and triggers are not offered. The engine gives each function’s attributes (routine) and each trigger’s parts (trigger) on GET …/schema/{kind}/{oid}, and marks trigger functions in the list.
Row tracking
(20 Oct 2026, points 5.1 and 6.1; 5.2 completed 23 Oct 2026.) A table can keep, on each row, when it was made (created_at), when it last changed (updated_at) and who changed it (last_modified_by), each a name you choose or a column you leave out. A row trigger, bb_track, writes them on every INSERT and UPDATE and overrides whatever the statement put there, so a client can neither backdate a row nor name someone else; created_at never changes after the insert. last_modified_by is session_user, the login, which a session cannot change (SET ROLE changes current_user; SET SESSION AUTHORIZATION needs a superuser): a console user is their own name, a gateway login its role, a shared login (fox sql's db_client) its role. Whom for (last_modified_for, point 5.2): who that login was acting for, where the transaction said so with application identity (H20): asserted:<application>:<user> when a registered application asserted its user with a signed token, claimed:<user> for a client’s own unchecked word (bb.claimed_actor), and NULL otherwise. Written by the same trigger from bb.app_actor_label(), which checks the assertion against its MAC, so a value set by hand never lands there as asserted, and a statement writing the column is overwritten like the others. Kept by default on tables tracked from now on (--no-for leaves it out); a table tracked before it gains it by being tracked again (fox track <table>, or Add whom for), which adds the column and replaces the trigger in place, so it is never dropped and nothing else changes. The trigger now builds its patch in one call rather than one per column: on a 10,000-row update in an asserted transaction (the fastest of nine runs, test VM) the three-column trigger went from 95–97 ms to 71–73, and keeping last_modified_for costs 76–81 ms: 7–11% over three columns, and still 15–21% less than tracking cost before this change, which is the 5% bound the F1 suite holds (it prints the fourth column’s own cost every run). Opt-in. Adding columns to a table that exists breaks an INSERT written without a column list and changes SELECT *, so it is never done unasked: fox track <table>, or Track changes… on a table's view, which says so before it acts. Rows already there are left empty, or labelled with the moment tracking began (--label-existing; a stable default, so the table is not rewritten). A column of the wrong type is refused with Postgres's reason. fox track --new on, or the switch in the same dialog, tracks every table made from now on as it is made (off by default; tables that exist are not changed; a partitioned table's partitions take its trigger). Protected: dropping, disabling, renaming or making the trigger replica-only is refused unless the guardrail override is in force, as for the Blackbox's own triggers, or anyone who can alter the table could switch it off, write a name and switch it back. fox track <table> --off (or Stop tracking) takes the override for that statement alone, drops the trigger, and keeps the columns and what they hold. Who decides: changing tracking is the branch owner's or an admin's (Manage), reading it anyone's who uses the branch; the change runs as the superuser in one transaction naming the person, so the Blackbox records the table's change (one ALTER TABLE and the trigger) under them, and the security log records it (tracking.changed). The data grid will not edit a tracked column and says why. Lives in internal/ledger/tracking.sql, installed with the guardrails in every edition and on every start, touching no user table by itself. Unit TestCheckTrackOptions, TestTrackSQL, TestTableRef, TestPsqlError, TestTrackingNeedsManage; console Tracking.test.tsx (the dialog and its warning, chosen columns and labelling, at least one column, a refusal shown, stopping after asking, new tables, whom-for offered to a table without it and not to one with it); TestTrackTableSQL, TestSetTrackNewSQL; make integration-f1 (whom-for asserted, claimed, for nobody, a value set by hand, another application’s login, a written value overwritten, a table tracked before it gaining it in place, a wrong type refused, new tables; and the cost bound); integration §11v (through the Gateway: asserted, claimed, nobody, a revoked key’s token, --no-for, tracking again adding it and nothing else, the listing, the API, new tables); integration §11q (tracking on and listed; earlier rows empty; an insert stamped whatever it wrote, with the login; an update moving updated_at, keeping created_at and naming the person; the Blackbox's record under who asked; the trigger protected from drop and disable; labelling; named and left-out columns; a wrong type refused; tracking off keeping the columns; new tables on and off; the security log; the API's list, its Manage rule and its reasons); Playwright: tracked from the console, a row stamped with the signed-in user and not the forged name, the grid's lock (whom-for included), and tracking stopped with the columns kept; a table tracked without whom-for gaining it in one click; a row changed through the Gateway for alice showing asserted:uishop:alice.
Data grid
(20 Oct 2026, point 12.2.) A table's Rows tab. Sort: a header click sorts ascending, again descending, again as it was (NULLs last, ties broken by the key). Filter: a column, a comparison (=, ≠, <, >, ≤, ≥, contains, starts with, is NULL, is not NULL) and a value, as many as wanted, all of them, each a chip that removes itself; the count follows. Page: a hundred rows at a time, in the table's key order when nothing is sorted, so a page stays the same page while it is edited. These are the console's own queries, as the signed-in user, with every name and value quoted (a value is compared untyped, so Postgres reads it as the column's type; "contains" matches LIKE's own wildcards literally), and kept out of query history. Edit rows (a table with a primary key; the button says why not otherwise) turns the page editable: click a cell to change it (Enter keeps, Esc leaves it, NULL sets NULL), × marks a row for deletion (↺ keeps it), Add row adds one whose untouched cells take their defaults; a column Postgres fills (generated, or identity GENERATED ALWAYS) is not editable and says why. The bar counts what a save would do, and nothing reaches the table until Save, which sends every change together; Discard forgets them. The save is the engine's (POST /api/branches/{name}/rows): one transaction, so a save that fails at its fifth change leaves the table as it was; the table, its columns and its key checked in the catalog; every value a parameter cast to its column's type, so nothing the browser sends becomes SQL; an update applies only while the columns it changes still hold what the grid showed, and an update or delete must find its row, so a row someone else changed or deleted since stops the save (409) rather than overwrite them; run as the signed-in user's own role, so grants, row-level security and the guardrails apply as to typed SQL. A refused save keeps the edits on screen and says which change and why. At most 1,000 changes a save. Masking (point 7) does not exist yet; the grid reads and writes as the user's own role through Postgres, so it will apply here as it does to typed SQL, and point 7.4 tests every path including this one. Unit TestRowStatementInsert, TestRowStatementUpdateAndDelete, TestRowStatementRefuses, TestHandleRowEditsRefusesABadBody; console gridquery.test.ts (14: key order, sort, filters and quoting, LIKE's wildcards, the changes a save sends), DataGrid.test.tsx (7: the filter bar, editing, NULL, delete and keep, add, a locked column, a refused save keeping the edits, discard) and Console.test.tsx (key order, a header sort, a filter and its count, an edit saved through the engine, a row named by its key when the catalog gives no columns); integration §11p (a save adding, changing and deleting together; values only as values; NULL; a generated column following; a stale row and a deleted row each stopping the whole save; a constraint naming its change with nothing applied; a generated column, an unknown column and a keyless table refused; row-level security letting a user change their row and not another's; a branch out of reach); Playwright: sorted both ways, filtered and cleared, a cell changed, a row deleted and one added, saved together and read back.
-
Added in
84741df; override, system group and reload on fix/console-permissions; value rendering on fix/console-uuid-cells; copying results on feat/copy-results; exporting on feat/export-screen; exporting a whole result on feat/export-full; query history on feat/query-history; the history panel and saved queries on feat/history-panel; query suggestions on feat/query-suggestions; the object browser on feat/object-browser; the data grid on feat/data-grid; row tracking on feat/row-tracking; sessions and upkeep on feat/p12-sessions; run, toggle and plan on feat/p12-run-plan.
+
Added in
84741df; override, system group and reload on fix/console-permissions; value rendering on fix/console-uuid-cells; copying results on feat/copy-results; exporting on feat/export-screen; exporting a whole result on feat/export-full; query history on feat/query-history; the history panel and saved queries on feat/history-panel; query suggestions on feat/query-suggestions; the object browser on feat/object-browser; the data grid on feat/data-grid; row tracking on feat/row-tracking; sessions and upkeep on feat/p12-sessions; run, toggle and plan on feat/p12-run-plan; function and trigger dialogs on feat/p12-routines.
L5
Blackbox page
Working
diff --git a/docs/FOX_Feature_Implemented.pdf b/docs/FOX_Feature_Implemented.pdf
index 009e83c..adc71aa 100644
Binary files a/docs/FOX_Feature_Implemented.pdf and b/docs/FOX_Feature_Implemented.pdf differ
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 ?
+ {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 (
+
+
+
+ )
+}
+
+const EVENTS = ['INSERT', 'UPDATE', 'DELETE', 'TRUNCATE']
+
+/** TriggerDialog makes a trigger on a table, or edits one. */
+export function TriggerDialog({ branch, schema, table, functions, edit, onClose, onDone, onOpenSql }: {
+ branch: string; schema: string; table: string; functions: string[]
+ edit?: CatalogPart
+ onClose: () => void; onDone: () => void; onOpenSql: (sql: string) => void
+}) {
+ const t = edit?.trigger
+ const [name, setName] = useState(edit?.name ?? '')
+ const [timing, setTiming] = useState(t?.timing ?? 'BEFORE')
+ const [events, setEvents] = useState(t?.events ?? ['INSERT'])
+ const [columns, setColumns] = useState((t?.columns ?? []).join(', '))
+ const [forEach, setForEach] = useState(t?.forEach ?? 'ROW')
+ const [when, setWhen] = useState(t?.when ?? '')
+ const [func, setFunc] = useState(t?.function ?? functions[0] ?? '')
+ const [args, setArgs] = useState(t?.args ?? '')
+ const [err, setErr] = useState('')
+ const [busy, setBusy] = useState(false)
+ const built = buildTrigger({ name, schema, table, timing, events: EVENTS.filter(e => events.includes(e)), columns, forEach, when, func, args,
+ referencing: t?.referencing, replace: !!edit, disabled: edit?.disabled })
+ 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 options = func && !functions.includes(func) ? [func, ...functions] : functions
+ return (
+
+
+
+ )
+}
+
+/** RenameDialog asks for a new name. */
+export function RenameDialog({ title, current, build, branch, onClose, onDone, onOpenSql }: {
+ title: string; current: string; build: (to: string) => string; branch: string
+ onClose: () => void; onDone: () => void; onOpenSql: (sql: string) => void
+}) {
+ const [to, setTo] = useState(current)
+ const [err, setErr] = useState('')
+ const [busy, setBusy] = useState(false)
+ const sql = to.trim() && to.trim() !== current ? build(to) : undefined
+ const save = async () => {
+ if (!sql) return
+ setBusy(true); setErr('')
+ const e = await runDDL(branch, sql)
+ setBusy(false)
+ if (e) setErr(e); else onDone()
+ }
+ return (
+
+
+
+ )
+}
diff --git a/web/src/ddl.test.ts b/web/src/ddl.test.ts
new file mode 100644
index 0000000..49596e1
--- /dev/null
+++ b/web/src/ddl.test.ts
@@ -0,0 +1,67 @@
+// SPDX-License-Identifier: AGPL-3.0-or-later
+import { describe, expect, it } from 'vitest'
+import { buildRoutine, buildTrigger, dollarTag, dropRoutine, dropTrigger, renameRoutine, renameTrigger, type RoutineSpec, type TriggerSpec } from './ddl'
+
+const f: RoutineSpec = { kind: 'function', schema: 'public', name: 'add', arguments: 'a integer, b integer DEFAULT 1', returns: 'integer', language: 'sql', body: 'SELECT a + b', replace: false }
+
+describe('buildRoutine', () => {
+ it('writes a plain function, volatile and cost left to their defaults', () => {
+ expect(buildRoutine({ ...f, volatility: 'volatile', cost: 100, parallel: 'unsafe' }).sql).toBe(
+ `CREATE FUNCTION "public"."add"(a integer, b integer DEFAULT 1)\n RETURNS integer\n LANGUAGE sql\nAS $body$\nSELECT a + b\n$body$`)
+ })
+
+ it('keeps on an edit everything it was not asked to change', () => {
+ expect(buildRoutine({ ...f, replace: true, volatility: 'stable', strict: true, definer: true, leakproof: true, parallel: 'safe', cost: 50, rows: 20,
+ set: [`SET search_path TO 'public', 'pg_temp'`] }).sql).toBe(
+ `CREATE OR REPLACE FUNCTION "public"."add"(a integer, b integer DEFAULT 1)\n RETURNS integer\n LANGUAGE sql\n STABLE\n LEAKPROOF\n STRICT\n SECURITY DEFINER\n PARALLEL SAFE\n COST 50\n ROWS 20\n SET search_path TO 'public', 'pg_temp'\nAS $body$\nSELECT a + b\n$body$`)
+ })
+
+ it('writes a procedure without what only a function has', () => {
+ expect(buildRoutine({ kind: 'procedure', schema: 's', name: 'p', arguments: '', language: 'plpgsql', body: 'BEGIN NULL; END;', replace: false, volatility: 'stable', strict: true, cost: 7 }).sql)
+ .toBe(`CREATE PROCEDURE "s"."p"()\n LANGUAGE plpgsql\nAS $body$\nBEGIN NULL; END;\n$body$`)
+ })
+
+ it('never lets the body close its own quoting', () => {
+ expect(dollarTag('x')).toBe('$body$')
+ expect(dollarTag('a $body$ b $body1$')).toBe('$body2$')
+ expect(buildRoutine({ ...f, body: 'SELECT $body$x$body$' }).sql).toContain('AS $body1$\n')
+ })
+
+ it('says what is missing, and refuses a language that is not a word', () => {
+ expect(buildRoutine({ ...f, name: ' ' }).error).toContain('name')
+ expect(buildRoutine({ ...f, returns: '' }).error).toContain('returns')
+ expect(buildRoutine({ ...f, body: ' ' }).error).toContain('body')
+ expect(buildRoutine({ ...f, language: 'sql; DROP' }).error).toContain('language')
+ })
+
+ it('renames and drops by identity, quoting names', () => {
+ expect(renameRoutine('function', 'public', 'a"b', 'x integer', 'c')).toBe(`ALTER FUNCTION "public"."a""b"(x integer) RENAME TO "c"`)
+ expect(dropRoutine('procedure', 'public', 'p', '')).toBe(`DROP PROCEDURE "public"."p"()`)
+ })
+})
+
+const t: TriggerSpec = { name: 'trg', schema: 'public', table: 'orders', timing: 'BEFORE', events: ['INSERT'], columns: '', forEach: 'ROW', when: '', func: 'public.fn', args: '', replace: false }
+
+describe('buildTrigger', () => {
+ it('writes the trigger', () => {
+ expect(buildTrigger(t).sql).toBe(`CREATE TRIGGER "trg" BEFORE INSERT ON "public"."orders"\n FOR EACH ROW\n EXECUTE FUNCTION public.fn()`)
+ })
+ it('narrows an UPDATE to columns, keeps a condition, arguments and REFERENCING', () => {
+ expect(buildTrigger({ ...t, replace: true, timing: 'AFTER', events: ['INSERT', 'UPDATE'], columns: 'a, b', when: 'new.a > 0', args: `'x'`, referencing: 'NEW TABLE AS n', forEach: 'STATEMENT' }).sql)
+ .toBe(`CREATE OR REPLACE TRIGGER "trg" AFTER INSERT OR UPDATE OF "a", "b" ON "public"."orders"\n REFERENCING NEW TABLE AS n\n FOR EACH STATEMENT\n WHEN (new.a > 0)\n EXECUTE FUNCTION public.fn('x')`)
+ })
+ it('leaves an edited trigger disabled if it was', () => {
+ expect(buildTrigger({ ...t, replace: true, disabled: true }).sql).toContain(`;\nALTER TABLE "public"."orders" DISABLE TRIGGER "trg"`)
+ })
+ it('says what cannot be', () => {
+ expect(buildTrigger({ ...t, name: '' }).error).toContain('name')
+ expect(buildTrigger({ ...t, events: [] }).error).toContain('event')
+ expect(buildTrigger({ ...t, func: '' }).error).toContain('function')
+ expect(buildTrigger({ ...t, events: ['TRUNCATE'] }).error).toContain('TRUNCATE')
+ expect(buildTrigger({ ...t, columns: 'a' }).error).toContain('UPDATE')
+ })
+ it('renames and drops on its table', () => {
+ expect(renameTrigger('public', 'orders', 'a', 'b')).toBe(`ALTER TRIGGER "a" ON "public"."orders" RENAME TO "b"`)
+ expect(dropTrigger('public', 'orders', 'a')).toBe(`DROP TRIGGER "a" ON "public"."orders"`)
+ })
+})
diff --git a/web/src/ddl.ts b/web/src/ddl.ts
new file mode 100644
index 0000000..804c631
--- /dev/null
+++ b/web/src/ddl.ts
@@ -0,0 +1,100 @@
+// SPDX-License-Identifier: AGPL-3.0-or-later
+import { quoteIdent } from './runcall'
+
+// The SQL behind the create and edit dialogs (point 12.3). The dialogs show it
+// as it is built and run it as the person, through the console, so it is
+// governed as anything they write is: their rights, the guardrails, the
+// Blackbox. Arguments, return types, conditions and bodies are the person's
+// own SQL; names are always quoted.
+
+export type RoutineSpec = {
+ kind: 'function' | 'procedure'; schema: string; name: string
+ arguments: string; returns?: string; language: string; body: string
+ volatility?: string; strict?: boolean; definer?: boolean; leakproof?: boolean; window?: boolean
+ parallel?: string; cost?: number; rows?: number; set?: string[]
+ replace: boolean
+}
+
+const defaultCost = 100
+const defaultRows = 1000
+
+/** A dollar-quote tag the body does not contain. */
+export function dollarTag(body: string): string {
+ for (let i = 0; ; i++) {
+ const tag = i === 0 ? '$body$' : `$body${i}$`
+ if (!body.includes(tag)) return tag
+ }
+}
+
+export function buildRoutine(r: RoutineSpec): { sql?: string; error?: string } {
+ if (!r.name.trim()) return { error: 'It needs a name.' }
+ if (r.kind === 'function' && !r.returns?.trim()) return { error: 'A function says what it returns.' }
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(r.language)) return { error: 'The language is a single word: plpgsql, sql, …' }
+ if (!r.body.trim()) return { error: 'It needs a body.' }
+ const lines = [`CREATE ${r.replace ? 'OR REPLACE ' : ''}${r.kind.toUpperCase()} ${quoteIdent(r.schema)}.${quoteIdent(r.name.trim())}(${r.arguments.trim()})`]
+ if (r.kind === 'function') lines.push(` RETURNS ${r.returns!.trim()}`)
+ lines.push(` LANGUAGE ${r.language}`)
+ if (r.kind === 'function') {
+ if (r.window) lines.push(' WINDOW')
+ if (r.volatility === 'immutable') lines.push(' IMMUTABLE')
+ if (r.volatility === 'stable') lines.push(' STABLE')
+ if (r.leakproof) lines.push(' LEAKPROOF')
+ if (r.strict) lines.push(' STRICT')
+ }
+ if (r.definer) lines.push(' SECURITY DEFINER')
+ if (r.kind === 'function') {
+ if (r.parallel === 'safe') lines.push(' PARALLEL SAFE')
+ if (r.parallel === 'restricted') lines.push(' PARALLEL RESTRICTED')
+ if (r.cost !== undefined && r.cost !== defaultCost) lines.push(` COST ${r.cost}`)
+ if (r.rows !== undefined && r.rows > 0 && r.rows !== defaultRows) lines.push(` ROWS ${r.rows}`)
+ }
+ for (const s of r.set ?? []) lines.push(` ${s}`)
+ const tag = dollarTag(r.body)
+ const body = r.body.endsWith('\n') ? r.body : r.body + '\n'
+ lines.push(`AS ${tag}\n${body}${tag}`)
+ return { sql: lines.join('\n') }
+}
+
+export const TRIGGER_BODY = `BEGIN
+ -- NEW is the row being written: return it, or NULL to skip the row.
+ RETURN NEW;
+END;`
+
+export const renameRoutine = (kind: string, schema: string, name: string, identity: string, to: string) =>
+ `ALTER ${kind.toUpperCase()} ${quoteIdent(schema)}.${quoteIdent(name)}(${identity}) RENAME TO ${quoteIdent(to.trim())}`
+export const dropRoutine = (kind: string, schema: string, name: string, identity: string) =>
+ `DROP ${kind.toUpperCase()} ${quoteIdent(schema)}.${quoteIdent(name)}(${identity})`
+
+export type TriggerSpec = {
+ name: string; schema: string; table: string
+ timing: string; events: string[]; columns: string; forEach: string
+ when: string; func: string; args: string; referencing?: string
+ replace: boolean
+ disabled?: boolean // an edited trigger that was disabled stays so
+}
+
+export function buildTrigger(t: TriggerSpec): { sql?: string; error?: string } {
+ if (!t.name.trim()) return { error: 'It needs a name.' }
+ if (t.events.length === 0) return { error: 'Choose at least one event.' }
+ if (!t.func) return { error: 'Choose the trigger function it runs.' }
+ if (t.events.includes('TRUNCATE') && t.forEach === 'ROW') return { error: 'TRUNCATE fires once for the statement, not for each row.' }
+ if (t.when.trim() && t.events.includes('TRUNCATE')) return { error: 'A TRUNCATE trigger cannot have a WHEN condition.' }
+ const cols = t.columns.split(',').map(c => c.trim()).filter(Boolean)
+ if (cols.length && !t.events.includes('UPDATE')) return { error: 'Columns narrow an UPDATE: choose UPDATE, or clear them.' }
+ const events = t.events.map(e => (e === 'UPDATE' && cols.length ? `UPDATE OF ${cols.map(quoteIdent).join(', ')}` : e)).join(' OR ')
+ const rel = `${quoteIdent(t.schema)}.${quoteIdent(t.table)}`
+ const name = quoteIdent(t.name.trim())
+ let sql = `CREATE ${t.replace ? 'OR REPLACE ' : ''}TRIGGER ${name} ${t.timing} ${events} ON ${rel}`
+ if (t.referencing) sql += `\n REFERENCING ${t.referencing}`
+ sql += `\n FOR EACH ${t.forEach}`
+ if (t.when.trim()) sql += `\n WHEN (${t.when.trim()})`
+ sql += `\n EXECUTE FUNCTION ${t.func}(${t.args.trim()})`
+ // CREATE OR REPLACE enables what it replaces.
+ if (t.replace && t.disabled) sql += `;\nALTER TABLE ${rel} DISABLE TRIGGER ${name}`
+ return { sql }
+}
+
+export const renameTrigger = (schema: string, table: string, name: string, to: string) =>
+ `ALTER TRIGGER ${quoteIdent(name)} ON ${quoteIdent(schema)}.${quoteIdent(table)} RENAME TO ${quoteIdent(to.trim())}`
+export const dropTrigger = (schema: string, table: string, name: string) =>
+ `DROP TRIGGER ${quoteIdent(name)} ON ${quoteIdent(schema)}.${quoteIdent(table)}`
diff --git a/web/src/pages/Console.tsx b/web/src/pages/Console.tsx
index 40a3995..98be273 100644
--- a/web/src/pages/Console.tsx
+++ b/web/src/pages/Console.tsx
@@ -13,6 +13,7 @@ import { TrackingControl, trackedOf } from '../components/Tracking'
import { AuditControl } from '../components/AuditMark'
import TableUpkeep from '../components/TableUpkeep'
import PlanView from '../components/PlanView'
+import { RoutineDialog } from '../components/RoutineDialogs'
import { explainSQL, parsePlan, type Plan } from '../plan'
import { countQuery, nextSort, rowsQuery, summary, type Filter, type Sort } from '../gridquery'
import { applySuggestion, schemaFrom, snippets, SCHEMA_SQL, type Rel } from '../sqlcomplete'
@@ -98,6 +99,7 @@ export default function Console() {
// An EXPLAIN's plan (point 12.4), shown in place of a result.
const [plan, setPlan] = useState<{ plan: Plan; raw: string } | null>(null)
const [analyse, setAnalyse] = useState(false)
+ const [newRoutine, setNewRoutine] = useState(false)
const [ranSql, setRanSql] = useState('')
// The history panel (point 8.2): open or shut, remembered in this browser,
// and a count of runs so it can show each new one.
@@ -128,6 +130,7 @@ export default function Console() {
// Dragging the divider. Tracked on the window rather than the handle so the
// pointer can leave it mid-drag without the pane sticking — the usual way a
// splitter feels broken.
+ const loadSeq = useRef(0)
const gutterRef = useRef(null)
const editorRef = useRef(null)
@@ -188,9 +191,14 @@ export default function Console() {
// The console's own reads (the object list, a table's rows and structure)
// stay out of the user's query history: nobody typed them.
const loadObjects = useCallback(async (b: string) => {
+ // A reply to an earlier load, of the branch left or of this one, is not
+ // what is on screen now and must not replace it.
+ const seq = ++loadSeq.current
+ const live = () => seq === loadSeq.current
setListing(true); setListErr('')
try {
const r = await runQuery(b, LIST_SQL, { history: false })
+ if (!live()) return
if (r.error) { setListErr(r.error); return }
setObjects((r.rows || []).map(row => ({
schema: String(row[0]), name: String(row[1]),
@@ -199,17 +207,17 @@ export default function Console() {
// The columns, for suggestions. Not worth an error of its own: without
// them the editor still offers tables and keywords.
runQuery(b, SCHEMA_SQL, { history: false })
- .then(c => setRels(c.error ? [] : schemaFrom(c.rows || [])))
- .catch(() => setRels([]))
+ .then(c => { if (live()) setRels(c.error ? [] : schemaFrom(c.rows || [])) })
+ .catch(() => { if (live()) setRels([]) })
// Everything else it holds, for the object browser. Without it the rail
// still lists tables and views, as it always has.
- getCatalog(b).then(c => setCatalog(c.objects ?? [])).catch(() => setCatalog([]))
- getTracking(b).then(setTracking).catch(() => setTracking(null))
- getAuditMarks(b).then(setMarks).catch(() => setMarks(null))
+ getCatalog(b).then(c => { if (live()) setCatalog(c.objects ?? []) }).catch(() => { if (live()) setCatalog([]) })
+ getTracking(b).then(t => { if (live()) setTracking(t) }).catch(() => { if (live()) setTracking(null) })
+ getAuditMarks(b).then(m => { if (live()) setMarks(m) }).catch(() => { if (live()) setMarks(null) })
} catch (e) {
- setListErr((e as Error).message)
+ if (live()) setListErr((e as Error).message)
} finally {
- setListing(false)
+ if (live()) setListing(false)
}
}, [])
@@ -499,7 +507,11 @@ export default function Console() {
{views.map(item)}
{ setObjSel(o); setMode('object') }} />
+ onOpen={o => { setObjSel(o); setMode('object') }} onNew={() => setNewRoutine(true)} />
+ {newRoutine && (
+ o.kind === 'schema' && !o.system).map(o => o.name)} onOpenSql={openSql}
+ onClose={() => setNewRoutine(false)} onDone={() => { setNewRoutine(false); loadObjects(branch) }} />
+ )}
{showSystem && system.length > 0 && (
<>