diff --git a/docs/FOX_Checklist.html b/docs/FOX_Checklist.html index 5301913..6f3bad5 100644 --- a/docs/FOX_Checklist.html +++ b/docs/FOX_Checklist.html @@ -591,6 +591,7 @@

LWeb console

  • 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)
  • +
  • Tables made, their columns added, edited and dropped, renamed and dropped, and their primary, unique, foreign and check keys added, edited, renamed and dropped, from dialogs in the Console: each shows the SQL it will run and runs it as the person, an edit of a column or a key applied whole or not at all; a key the dialog cannot write again (INCLUDE, NULLS NOT DISTINCT, an exclusion, an inherited one) is renamed and dropped but not edited; DROP TABLE meets the guardrail as anywhere, and is put in the editor to override (point 12.3, second 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 3d512f3..1821eb4 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 2485026..a46cbcf 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.

    + @@ -1380,6 +1381,7 @@

    Feature index

    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.
    +
    Table dialogs
    (10 Nov 2026, point 12.3, second part.) + New table… in the Console’s schema rail makes a table: its schema, name, and columns, each with a type (common ones offered), a default, NOT NULL and whether it is in the primary key; it opens on its columns. A table’s Structure tab lists its columns from the catalog, identity and generated ones marked, with + Add column…, Edit… (name, type with how existing values convert, NOT NULL, default; only what changed is written, in one transaction, the rename last) and Drop…, and Rename table… and Drop table…. Its Constraints tab adds a primary key, a unique key, a foreign key or a check: columns picked in the order the key has them, a foreign key’s table chosen with its primary key filled in as what it references, ON DELETE and ON UPDATE, deferrable; Edit… replaces a key in one statement under its name (Postgres cannot change one in place) and keeps what the dialog has no field for (MATCH FULL, NOT VALID, NO INHERIT); a key with parts it cannot write again is renamed and dropped but not edited. Every dialog shows its SQL and runs it as the person; DROP TABLE meets the guardrail as anywhere, and Put it in the editor is the way to override it. FoxByte’s own tables and foreign tables are not offered. The engine gives each constraint’s parts (key) on GET …/schema/table/{oid}.
    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; function and trigger dialogs on feat/p12-routines.
    diff --git a/docs/FOX_Feature_Implemented.pdf b/docs/FOX_Feature_Implemented.pdf index adc71aa..a5c3180 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 b1cfeca..1b10154 100644 --- a/internal/controlplane/catalog.go +++ b/internal/controlplane/catalog.go @@ -132,6 +132,54 @@ type CatalogPart struct { 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"` + // Key is a constraint's parts, for the dialog that edits it (point 12.3). + Key *ConstraintInfo `json:"key,omitempty"` +} + +// ConstraintInfo is a table constraint, part by part. +type ConstraintInfo struct { + Type string `json:"type"` // primary, unique, foreign, check, exclusion + Columns []string `json:"columns,omitempty"` + RefSchema string `json:"refSchema,omitempty"` + RefTable string `json:"refTable,omitempty"` + RefColumns []string `json:"refColumns,omitempty"` + OnDelete string `json:"onDelete,omitempty"` // CASCADE, SET NULL, SET DEFAULT, RESTRICT; none is NO ACTION + OnUpdate string `json:"onUpdate,omitempty"` + Match string `json:"match,omitempty"` // FULL; none is SIMPLE + Deferrable bool `json:"deferrable,omitempty"` + Deferred bool `json:"deferred,omitempty"` + NotValid bool `json:"notValid,omitempty"` + NoInherit bool `json:"noInherit,omitempty"` + Check string `json:"check,omitempty"` + // Editable: the parts above say all of it, so writing it again loses nothing. + Editable bool `json:"editable,omitempty"` +} + +// keyExtras are what a key's definition can hold that ConstraintInfo has no +// part for. A key with one is renamed and dropped, but not rewritten. +var keyExtras = []string{" INCLUDE (", " NULLS NOT DISTINCT", " WITH (", " USING INDEX TABLESPACE ", " WITHOUT OVERLAPS", "PERIOD ", " SET NULL (", " SET DEFAULT ("} + +var fkActions = map[string]string{"r": "RESTRICT", "c": "CASCADE", "n": "SET NULL", "d": "SET DEFAULT"} + +// constraintInfo reads a pg_constraint row. local is false for a constraint +// inherited from a parent, which only the parent can change. +func constraintInfo(contype, def, onDelete, onUpdate, match string, local bool) *ConstraintInfo { + k := &ConstraintInfo{ + Type: map[string]string{"p": "primary", "u": "unique", "f": "foreign", "c": "check", "x": "exclusion"}[contype], + OnDelete: fkActions[onDelete], OnUpdate: fkActions[onUpdate], + } + if match == "f" { + k.Match = "FULL" + } + k.Editable = local && k.Type != "" && k.Type != "exclusion" && !strings.HasSuffix(def, " NOT ENFORCED") + if k.Type != "check" { + for _, x := range keyExtras { + if strings.Contains(def, x) { + k.Editable = false + } + } + } + return k } // TriggerInfo is a trigger, part by part. @@ -487,20 +535,37 @@ func readRelation(ctx context.Context, tx pgx.Tx, d *ObjectDetail) error { } return nil } - rows, err := tx.Query(ctx, `SELECT conname, contype::text, pg_get_constraintdef(oid, true) FROM pg_constraint - WHERE conrelid = $1::oid AND contype <> 'n' - ORDER BY array_position(ARRAY['p','u','f','c','x'], contype::text), conname`, d.OID) + rows, err := tx.Query(ctx, `SELECT c.conname, c.contype::text, pg_get_constraintdef(c.oid, true), + c.confdeltype::text, c.confupdtype::text, c.confmatchtype::text, c.conislocal AND c.conparentid = 0, + ARRAY(SELECT a.attname::text FROM unnest(c.conkey) WITH ORDINALITY k(n, o) + JOIN pg_attribute a ON a.attrelid = c.conrelid AND a.attnum = k.n ORDER BY k.o), + ARRAY(SELECT a.attname::text FROM unnest(c.confkey) WITH ORDINALITY k(n, o) + JOIN pg_attribute a ON a.attrelid = c.confrelid AND a.attnum = k.n ORDER BY k.o), + coalesce(fn.nspname::text, ''), coalesce(f.relname::text, ''), + c.condeferrable, c.condeferred, NOT c.convalidated, c.connoinherit, + coalesce(CASE WHEN c.contype = 'c' THEN pg_get_expr(c.conbin, c.conrelid, true) END, '') + FROM pg_constraint c + LEFT JOIN pg_class f ON f.oid = c.confrelid + LEFT JOIN pg_namespace fn ON fn.oid = f.relnamespace + WHERE c.conrelid = $1::oid AND c.contype <> 'n' + ORDER BY array_position(ARRAY['p','u','f','c','x'], c.contype::text), c.conname`, d.OID) if err != nil { return err } for rows.Next() { var p CatalogPart - var k string - if err := rows.Scan(&p.Name, &k, &p.Def); err != nil { + var k, del, upd, match string + var local bool + var key ConstraintInfo + if err := rows.Scan(&p.Name, &k, &p.Def, &del, &upd, &match, &local, &key.Columns, &key.RefColumns, &key.RefSchema, &key.RefTable, + &key.Deferrable, &key.Deferred, &key.NotValid, &key.NoInherit, &key.Check); err != nil { rows.Close() return err } p.Kind = map[string]string{"p": "primary key", "u": "unique", "f": "foreign key", "c": "check", "x": "exclusion"}[k] + info := constraintInfo(k, p.Def, del, upd, match, local) + key.Type, key.OnDelete, key.OnUpdate, key.Match, key.Editable = info.Type, info.OnDelete, info.OnUpdate, info.Match, info.Editable + p.Key = &key d.Constraints = append(d.Constraints, p) } rows.Close() diff --git a/internal/controlplane/catalog_params_test.go b/internal/controlplane/catalog_params_test.go index 2e097b2..361856a 100644 --- a/internal/controlplane/catalog_params_test.go +++ b/internal/controlplane/catalog_params_test.go @@ -61,3 +61,31 @@ func TestSetClauses(t *testing.T) { t.Errorf("%q", got) } } + +func TestConstraintInfo(t *testing.T) { + k := constraintInfo("f", "FOREIGN KEY (a) REFERENCES t(id) ON DELETE CASCADE", "c", "a", "f", true) + if k.Type != "foreign" || k.OnDelete != "CASCADE" || k.OnUpdate != "" || k.Match != "FULL" || !k.Editable { + t.Errorf("%+v", k) + } + for _, c := range []struct { + contype, def string + local, want bool + }{ + {"p", "PRIMARY KEY (id)", true, true}, + {"u", "UNIQUE (a)", true, true}, + {"c", "CHECK (a > 0)", true, true}, + // A check's expression is the person's: its words are not clauses. + {"c", "CHECK (a <> ' INCLUDE (')", true, true}, + {"c", "CHECK (a > 0) NOT ENFORCED", true, false}, + {"p", "PRIMARY KEY (id) INCLUDE (b)", true, false}, + {"u", "UNIQUE NULLS NOT DISTINCT (a)", true, false}, + {"u", "UNIQUE (a) WITH (fillfactor='70')", true, false}, + {"f", "FOREIGN KEY (a, b) REFERENCES t(a, b) ON DELETE SET NULL (b)", true, false}, + {"x", "EXCLUDE USING gist (r WITH &&)", true, false}, + {"p", "PRIMARY KEY (id)", false, false}, + } { + if got := constraintInfo(c.contype, c.def, " ", " ", " ", c.local).Editable; got != c.want { + t.Errorf("%s: editable = %v, want %v", c.def, got, c.want) + } + } +} diff --git a/internal/controlplane/openapi.yaml b/internal/controlplane/openapi.yaml index 51191fb..faaf6ca 100644 --- a/internal/controlplane/openapi.yaml +++ b/internal/controlplane/openapi.yaml @@ -671,7 +671,8 @@ paths: 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), and routine, its attributes for the Edit dialog, and - for each of a table's triggers trigger, its parts (point 12.3); a + for each of a table's triggers trigger, its parts, and for each of its + constraints key, 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 diff --git a/scripts/integration_test.sh b/scripts/integration_test.sh index 9628995..f719382 100644 --- a/scripts/integration_test.sh +++ b/scripts/integration_test.sh @@ -3979,6 +3979,84 @@ assert_eq "a trigger renamed and dropped, as written; the drop in the Blackbox b 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 "### 11aq. create and edit dialogs for tables, columns and keys (point 12.3)" +# As in 11ap: what the dialogs write is run here and what the engine gives an +# edit dialog is checked; the dialogs themselves are tables.spec.ts. +pg pg-main "SET bb.allow_destructive = on; DROP TABLE IF EXISTS kt_child, kt_parent, kt_ex, kt_renamed, kt_inc CASCADE" >/dev/null +assert_eq "what the New table dialog writes makes it: columns, not null, a default and a primary key of two" \ + "$(rd 'CREATE TABLE "public"."kt_parent" ( + "id" integer, + "sub" integer, + "label" text NOT NULL DEFAULT '"'"'x'"'"', + PRIMARY KEY ("id", "sub") +)' | rderr)|$(pg pg-main "SELECT string_agg(attname, ',' ORDER BY attnum) FROM pg_attribute WHERE attrelid = 'kt_parent'::regclass AND attnum > 0")|$(pg pg-main "SELECT pg_get_constraintdef(oid) FROM pg_constraint WHERE conrelid = 'kt_parent'::regclass AND contype = 'p'")" \ + "|id,sub,label|PRIMARY KEY (id, sub)" +rd 'CREATE TABLE "public"."kt_child" (id bigserial PRIMARY KEY, pid integer, psub integer, qty integer DEFAULT 1, note text)' >/dev/null +pg pg-main "INSERT INTO kt_parent VALUES (1, 1, 'a')" >/dev/null +pg pg-main "INSERT INTO kt_child (pid, psub, qty) VALUES (1, 1, 5)" >/dev/null +kcat() { RCAT="$(curl -sk -H "$AUTH" https://localhost:8080/api/branches/main/schema)"; } +kcat +assert_eq "what the Add column dialog writes adds it" \ + "$(rd 'ALTER TABLE "public"."kt_child" ADD COLUMN "extra" text NOT NULL DEFAULT '"'"'y'"'"'' | rderr)|$(pg pg-main "SELECT extra FROM kt_child")" "|y" +assert_eq "what the Edit column dialog writes changes the type with its conversion, not null, the default and the name, together" \ + "$(rd 'ALTER TABLE "public"."kt_child" + ALTER COLUMN "qty" TYPE text USING qty::text || '"'"'!'"'"', + ALTER COLUMN "qty" SET NOT NULL, + ALTER COLUMN "qty" SET DEFAULT '"'"'0'"'"'; +ALTER TABLE "public"."kt_child" RENAME COLUMN "qty" TO "amount"' | rderr)|$(pg pg-main "SELECT amount FROM kt_child")|$(rout kt_child table | hjson "c=[x for x in d['columns'] if x['name']=='amount'][0]; print(c['type'], c.get('not_null'), c['default'])")" \ + "|5!|text True '0'::text" +assert_contains "…and a rename that cannot be done leaves the rest undone" \ + "$(rd 'ALTER TABLE "public"."kt_child" ALTER COLUMN "note" SET DEFAULT '"'"'n'"'"'; +ALTER TABLE "public"."kt_child" RENAME COLUMN "note" TO "amount"' | rderr)" "already exists" +assert_eq "…the column's default not applied" "$(pg pg-main "SELECT atthasdef FROM pg_attribute WHERE attrelid = 'kt_child'::regclass AND attname = 'note'")" "f" +assert_eq "a column dropped, as written, in the Blackbox as an ALTER TABLE" \ + "$(rd 'ALTER TABLE "public"."kt_child" DROP COLUMN "extra"' | rderr)|$(pg pg-main "SELECT count(*) FROM pg_attribute WHERE attrelid = 'kt_child'::regclass AND attname = 'extra' AND NOT attisdropped")|$(pg pg-main "SELECT count(*) > 0 FROM bb.schema_ledger WHERE command_tag = 'ALTER TABLE' AND statement LIKE '%kt_child%DROP COLUMN%'")" "|0|t" +assert_eq "what the Add key dialog writes: unique, named or left to Postgres, foreign and check" \ + "$(rd 'ALTER TABLE "public"."kt_child" + ADD CONSTRAINT "kt_note_u" UNIQUE ("note") DEFERRABLE INITIALLY DEFERRED' | rderr)|$(rd 'ALTER TABLE "public"."kt_child" + ADD UNIQUE ("psub", "pid")' | rderr)|$(rd 'ALTER TABLE "public"."kt_child" + ADD CONSTRAINT "kt_fk" FOREIGN KEY ("pid", "psub") REFERENCES "public"."kt_parent" ("id", "sub") ON UPDATE RESTRICT ON DELETE CASCADE' | rderr)|$(rd 'ALTER TABLE "public"."kt_child" + ADD CONSTRAINT "kt_ck" CHECK (id > 0)' | rderr)|$(pg pg-main "SELECT string_agg(conname, ',' ORDER BY conname) FROM pg_constraint WHERE conrelid = 'kt_child'::regclass AND contype <> 'n'")" \ + "||||kt_child_pkey,kt_child_psub_pid_key,kt_ck,kt_fk,kt_note_u" +kparts() { rout kt_child table | hjson "print({x['name']: (x['key']['type'], x['key'].get('columns'), x['key'].get('refSchema'), x['key'].get('refTable'), x['key'].get('refColumns'), x['key'].get('onDelete'), x['key'].get('onUpdate'), x['key'].get('deferrable'), x['key'].get('deferred'), x['key'].get('check'), x['key'].get('editable')) for x in d['constraints']}['$1'])"; } +assert_eq "an edit dialog is given a foreign key, part by part, its columns in the key's order" \ + "$(kparts kt_fk)" "('foreign', ['pid', 'psub'], 'public', 'kt_parent', ['id', 'sub'], 'CASCADE', 'RESTRICT', None, None, None, True)" +assert_eq "…a unique key, deferred, and in the order it was written" \ + "$(kparts kt_note_u)|$(kparts kt_child_psub_pid_key)" "('unique', ['note'], None, None, None, None, None, True, True, None, True)|('unique', ['psub', 'pid'], None, None, None, None, None, None, None, None, True)" +assert_eq "…a primary key and a check" \ + "$(kparts kt_child_pkey)|$(kparts kt_ck)" "('primary', ['id'], None, None, None, None, None, None, None, None, True)|('check', ['id'], None, None, None, None, None, None, None, 'id > 0', True)" +assert_eq "what the Edit key dialog writes replaces it in one statement, under its name" \ + "$(rd 'ALTER TABLE "public"."kt_child" + DROP CONSTRAINT "kt_fk", + ADD CONSTRAINT "kt_fk" FOREIGN KEY ("pid", "psub") REFERENCES "public"."kt_parent" ("id", "sub") ON DELETE SET NULL' | rderr)|$(kparts kt_fk)" \ + "|('foreign', ['pid', 'psub'], 'public', 'kt_parent', ['id', 'sub'], 'SET NULL', None, None, None, None, True)" +assert_contains "…one that fails keeps the key as it was" \ + "$(rd 'ALTER TABLE "public"."kt_child" + DROP CONSTRAINT "kt_ck", + ADD CONSTRAINT "kt_ck" CHECK (id < 0)' | rderr)" "violated by some row" +assert_eq "…the check still there" "$(kparts kt_ck)" "('check', ['id'], None, None, None, None, None, None, None, 'id > 0', True)" +assert_eq "a primary key replaced under its name, a new order of its columns" \ + "$(rd 'ALTER TABLE "public"."kt_child" + DROP CONSTRAINT "kt_child_pkey", + ADD CONSTRAINT "kt_child_pkey" PRIMARY KEY ("pid", "id")' | rderr)|$(pg pg-main "SELECT pg_get_constraintdef(oid) FROM pg_constraint WHERE conname = 'kt_child_pkey'")" "|PRIMARY KEY (pid, id)" +assert_contains "…one another table depends on is refused, and Postgres says why" \ + "$(rd 'ALTER TABLE "public"."kt_parent" + DROP CONSTRAINT "kt_parent_pkey", + ADD CONSTRAINT "kt_parent_pkey" PRIMARY KEY ("sub", "id")' | rderr)" "depend on it" +rd 'CREATE TABLE "public"."kt_ex" (id int, r int4range, b int, CONSTRAINT kt_ex_x EXCLUDE USING gist (r WITH &&), CONSTRAINT kt_ex_u UNIQUE (id) INCLUDE (b))' >/dev/null +kcat +assert_eq "what the key dialog cannot write again is not offered as an edit: an exclusion, and a key with INCLUDE" \ + "$(rout kt_ex table | hjson "print(sorted((x['name'], x['key']['type'], bool(x['key'].get('editable'))) for x in d['constraints']))")" "[('kt_ex_u', 'unique', False), ('kt_ex_x', 'exclusion', False)]" +assert_eq "a key renamed and dropped, as written" \ + "$(rd 'ALTER TABLE "public"."kt_child" RENAME CONSTRAINT "kt_ck" TO "kt_ck2"' | rderr)|$(rd 'ALTER TABLE "public"."kt_child" DROP CONSTRAINT "kt_ck2"' | rderr)|$(pg pg-main "SELECT count(*) FROM pg_constraint WHERE conrelid = 'kt_child'::regclass AND conname LIKE 'kt_ck%'")" "||0" +assert_eq "a table renamed, as written" \ + "$(rd 'ALTER TABLE "public"."kt_ex" RENAME TO "kt_renamed"' | rderr)|$(pg pg-main "SELECT to_regclass('kt_ex') IS NULL, to_regclass('kt_renamed') IS NOT NULL")" "|t|t" +assert_contains "DROP TABLE from its dialog meets the guardrail, as it does anywhere" \ + "$(rd 'DROP TABLE "public"."kt_renamed"' | rderr)" "guardrail: DROP TABLE is blocked by policy" +assert_eq "…and the table is still there; with the override, as the editor asks for it, it goes" \ + "$(pg pg-main "SELECT to_regclass('kt_renamed') IS NOT NULL")|$(curl -sk -H "$AUTH" -H 'Content-Type: application/json' -X POST -d '{"sql":"DROP TABLE \"public\".\"kt_renamed\"","allow_destructive":true}' https://localhost:8080/api/branches/main/query | rderr)|$(pg pg-main "SELECT to_regclass('kt_renamed') IS NULL")" "t||t" +pg pg-main "SET bb.allow_destructive = on; DROP TABLE IF EXISTS kt_child, kt_parent, kt_ex, kt_renamed CASCADE" >/dev/null + 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 176aefc..b5aedf1 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -443,7 +443,13 @@ export type CatalogObject = { 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 } +export type CatalogPart = { name: string; kind?: string; def: string; constraint?: boolean; system?: boolean; disabled?: boolean; trigger?: TriggerInfo; key?: KeyInfo } +// A table constraint, part by part (point 12.3). +export type KeyInfo = { + type: string; columns?: string[]; refSchema?: string; refTable?: string; refColumns?: string[] + onDelete?: string; onUpdate?: string; match?: string + deferrable?: boolean; deferred?: boolean; notValid?: boolean; noInherit?: boolean; check?: string; editable?: boolean +} // 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 diff --git a/web/src/components/ObjectBrowser.tsx b/web/src/components/ObjectBrowser.tsx index 54bb5b5..c8a14c0 100644 --- a/web/src/components/ObjectBrowser.tsx +++ b/web/src/components/ObjectBrowser.tsx @@ -1,10 +1,11 @@ // SPDX-License-Identifier: AGPL-3.0-or-later import { useEffect, useState, type ReactNode } from 'react' -import { getObject, runQuery, type CatalogKind, type CatalogObject, type CatalogPart, type FuncParam, type ObjectDetail } from '../api' +import { getObject, runQuery, type CatalogCol, 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 { dropColumn, dropKey, dropRoutine, dropTable, dropTrigger, renameKey, renameRoutine, renameTable, renameTrigger } from '../ddl' import { RenameDialog, RoutineDialog, runDDL, TriggerDialog } from './RoutineDialogs' +import { ColumnDialog, DropDialog, KeyDialog } from './TableDialogs' // 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, @@ -33,18 +34,20 @@ 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, onNew }: { +export function CatalogGroups({ objects, filter, showSystem, active, onOpen, onNew, onNewTable }: { objects: CatalogObject[] filter: string showSystem: boolean active: CatalogObject | null onOpen: (o: CatalogObject) => void onNew?: () => void + onNewTable?: () => void }) { const [open, setOpen] = useState>({}) const f = filter.trim().toLowerCase() return ( <> + {onNewTable && } {onNew && } {GROUPS.map(g => { const list = objects.filter(o => o.kind === g.kind && (showSystem || !o.system) @@ -111,7 +114,7 @@ function Parts({ title, parts, empty, onToggle, actions }: {
    {!parts?.length ?

    {empty}

    : (
    DateFeature(s)What changedCommit / PR
    11 Nov 2026L4Table dialogs (point 12.3, second part, pgAdmin’s basics). + New table… in the Console’s schema rail makes a table with its columns and primary key, and opens on them. A table’s Structure tab lists its columns from the catalog with + Add column…, Edit… (name, type with a USING conversion, NOT NULL, default; only what changed, in one transaction, the rename last) and Drop…, and renames and drops the table; its Constraints tab adds, edits, renames and drops primary, unique, foreign and check keys: columns in the order picked, a foreign key’s referenced table with its primary key filled in, ON DELETE and ON UPDATE, deferrable; an edit replaces the key in one statement under its name and keeps MATCH FULL, NOT VALID and NO INHERIT; a key with parts the dialog cannot write again (INCLUDE, NULLS NOT DISTINCT, an exclusion, an inherited one) is renamed and dropped but not edited. Each dialog shows its SQL and runs it as the person; DROP TABLE meets the guardrail, and Put it in the editor is the way to override it. The engine gives each constraint’s parts (key) on GET …/schema/table/{oid}. Tests: integration 936/936 (§11aq new: what each dialog writes run; a failed rename leaving a column edit undone; each key’s parts, in the key’s order; a key replaced under its name, a failed replacement keeping it, one depended on refused; keys not offered as edits; DROP TABLE refused by the guardrail, then dropped with the override), UI 61/61 plus the upgrade test (a table made, its columns added, edited and dropped, renamed, its drop refused and put in the editor; its four kinds of key added, a foreign key edited, a check renamed and dropped; a refusal shown in the dialog), integration-f5 57/57, f1 70/70, f6 71/71, web unit 294/294, Go unit tests in both editions, vet for macOS, Linux and Windows. v2 401/403: its two failures (a point-in-time branch’s Blackbox holding 82 entries from after the entry it was made before, and a branch’s restart adding them again) are the same on main without this change.feat/p12-tables
    10 Nov 2026L4Function 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 2026L4Run, 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 2026L4Sessions 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
    - {onToggle && + {(onToggle || actions) && {parts.map(p => ( @@ -121,11 +124,11 @@ function Parts({ title, parts, empty, onToggle, actions }: { {p.disabled && disabled} - {onToggle && ( + {(onToggle || actions) && ( )} @@ -207,19 +210,137 @@ function TriggerParts({ branch, obj, d, reload, catalog, onOpenSql }: { ) } -/** The table tabs the catalog fills: constraints, triggers and the CREATE script. */ -export function TableParts({ branch, obj, tab, onOpenSql, catalog }: { +/** Whether the table dialogs apply: FoxByte's own are not offered, and a foreign table is altered otherwise. */ +export const tableEditable = (o?: CatalogObject | null) => !!o && o.kind === 'table' && !o.system && o.sub !== 'foreign' + +export type TableChange = { gone?: boolean; renamed?: string } + +/** A table's columns, each edited and dropped; the table renamed and dropped (point 12.3). */ +function ColumnParts({ branch, obj, d, reload, onOpenSql, onChanged }: { + branch: string; obj: CatalogObject; d: ObjectDetail; reload: () => void; onOpenSql: (sql: string) => void; onChanged: (c: TableChange) => void +}) { + const [note, setNote] = useState('') + const [dialog, setDialog] = useState<{ type: 'add' | 'renameTable' | 'dropTable' } | { type: 'edit' | 'drop'; col: CatalogCol } | null>(null) + const schema = obj.schema ?? 'public' + const done = (msg: string) => { setDialog(null); setNote(msg); reload(); onChanged({}) } + return ( + <> +
    + + + +
    +
    +
    NameDefinition}
    NameDefinition}
    {p.def} {p.system ? FoxByte’s own - : <>{actions?.(p)}} + : <>{onToggle && }{actions?.(p)}}
    + + + {(d.columns ?? []).map(c => ( + + + + + + + + ))} + +
    NameTypeNot nullDefault
    {c.name} + {c.identity && identity} + {c.generated && generated} + {c.type}{c.not_null ? 'not null' : ''}{c.generated || c.default} + + {' '} +
    + + {note &&
    {note}
    } + {dialog?.type === 'add' && ( + setDialog(null)} onDone={() => done('Column added.')} /> + )} + {dialog?.type === 'edit' && ( + setDialog(null)} onDone={() => done(`${dialog.col.name} saved.`)} /> + )} + {dialog?.type === 'drop' && ( + setDialog(null)} onDone={() => done(`${dialog.col.name} dropped.`)}> + Every row loses this column’s value. The change is recorded in the Blackbox. + + )} + {dialog?.type === 'renameTable' && ( + renameTable(schema, obj.name, to)} onClose={() => setDialog(null)} + onDone={to => { setDialog(null); onChanged({ renamed: to }) }} /> + )} + {dialog?.type === 'dropTable' && ( + setDialog(null)} onDone={() => { setDialog(null); onChanged({ gone: true }) }}> + The table and all its rows go. A guardrail refuses this until it is allowed: if it does, put it in the editor, where the Console can ask to override. + + )} + + ) +} + +/** A table's primary, unique, foreign and check keys, added, edited, renamed and dropped (point 12.3). */ +function KeyParts({ branch, obj, d, reload, catalog, onOpenSql, onChanged }: { + branch: string; obj: CatalogObject; d: ObjectDetail; reload: () => void; catalog: CatalogObject[]; onOpenSql: (sql: string) => void; onChanged: (c: TableChange) => void +}) { + const [note, setNote] = useState('') + const [dialog, setDialog] = useState<{ type: 'add' } | { type: 'edit' | 'rename' | 'drop'; part: CatalogPart } | null>(null) + const schema = obj.schema ?? 'public' + const done = (msg: string) => { setDialog(null); setNote(msg); reload(); onChanged({}) } + const columns = (d.columns ?? []).map(c => c.name) + const tables = catalog.filter(o => o.kind === 'table' && !o.system && o.sub !== 'foreign') + const hasPrimary = !!d.constraints?.some(k => k.key?.type === 'primary') + const dialogProps = { branch, schema, table: obj.name, columns, tables, hasPrimary, onOpenSql, onClose: () => setDialog(null) } + return ( + <> +
    + +
    + ( + <> + {p.key?.editable && <>{' '}} + + {' '} + + )} /> + {note &&
    {note}
    } + {dialog?.type === 'add' && done('Key added.')} />} + {dialog?.type === 'edit' && done(`${dialog.part.name} saved.`)} />} + {dialog?.type === 'rename' && ( + renameKey(schema, obj.name, dialog.part.name, to)} onClose={() => setDialog(null)} onDone={() => done('Key renamed.')} /> + )} + {dialog?.type === 'drop' && ( + setDialog(null)} onDone={() => done(`${dialog.part.name} dropped.`)}> + The table stops enforcing this. The change is recorded in the Blackbox. + + )} + + ) +} + +/** The table tabs the catalog fills: columns, constraints, triggers and the CREATE script. */ +export function TableParts({ branch, obj, tab, onOpenSql, catalog, onChanged }: { branch: string obj: CatalogObject | null - tab: 'constraints' | 'triggers' | 'ddl' + tab: 'structure' | 'constraints' | 'triggers' | 'ddl' onOpenSql: (sql: string) => void catalog: CatalogObject[] + onChanged?: (c: TableChange) => void }) { 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 + const changed = onChanged ?? (() => {}) + if (tab === 'structure') return + if (tab === 'constraints') { + return tableEditable(obj) + ? + : + } if (tab === 'triggers') return return } diff --git a/web/src/components/RoutineDialogs.tsx b/web/src/components/RoutineDialogs.tsx index daf408e..19f25e9 100644 --- a/web/src/components/RoutineDialogs.tsx +++ b/web/src/components/RoutineDialogs.tsx @@ -16,7 +16,7 @@ export async function runDDL(branch: string, sql: string): Promise { } catch (e) { return (e as Error).message } } -function Shell({ title, sql, error, busy, save, onClose, onOpenSql, children }: { +export 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 }) { @@ -40,7 +40,7 @@ function Shell({ title, sql, error, busy, save, onClose, onOpenSql, children }: ) } -const Field = ({ label, children }: { label: string; children: ReactNode }) => +export const Field = ({ label, children }: { label: string; children: ReactNode }) => type Kind = 'function' | 'procedure' | 'trigger' @@ -202,7 +202,7 @@ export function TriggerDialog({ branch, schema, table, functions, edit, onClose, /** 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 + onClose: () => void; onDone: (to: string) => void; onOpenSql: (sql: string) => void }) { const [to, setTo] = useState(current) const [err, setErr] = useState('') @@ -213,7 +213,7 @@ export function RenameDialog({ title, current, build, branch, onClose, onDone, o setBusy(true); setErr('') const e = await runDDL(branch, sql) setBusy(false) - if (e) setErr(e); else onDone() + if (e) setErr(e); else onDone(to.trim()) } return ( diff --git a/web/src/components/TableDialogs.tsx b/web/src/components/TableDialogs.tsx new file mode 100644 index 0000000..fa6bb1f --- /dev/null +++ b/web/src/components/TableDialogs.tsx @@ -0,0 +1,244 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +import { useEffect, useRef, useState, type ReactNode } from 'react' +import { getObject, type CatalogCol, type CatalogObject, type CatalogPart } from '../api' +import { buildAddColumn, buildAddKey, buildAlterColumn, buildCreateTable, buildEditKey, type ColumnSpec, type KeySpec, type KeyType } from '../ddl' +import { Field, runDDL, Shell } from './RoutineDialogs' + +// The table dialogs (point 12.3): a table, its columns and its keys. As with +// the function and trigger dialogs, each shows the SQL and runs it as the person. + +function useSave(branch: string, sql: string | undefined, onDone: () => void) { + const [err, setErr] = useState('') + const [busy, setBusy] = useState(false) + const save = async () => { + if (!sql) return + setBusy(true); setErr('') + const e = await runDDL(branch, sql) + setBusy(false) + if (e) setErr(e); else onDone() + } + return { err, busy, save } +} + +const TYPES = ['bigint', 'bigserial', 'boolean', 'bytea', 'date', 'double precision', 'integer', 'interval', 'json', 'jsonb', 'numeric', 'real', 'serial', 'smallint', 'text', 'time', 'timestamp', 'timestamptz', 'uuid', 'varchar(255)'] +const TypeList = () => {TYPES.map(t => + +type Row = ColumnSpec & { id: number } + +/** NewTableDialog makes a table: its columns and primary key. */ +export function NewTableDialog({ branch, schemas, onClose, onDone, onOpenSql }: { + branch: string; schemas: string[] + onClose: () => void; onDone: (schema: string, name: string) => void; onOpenSql: (sql: string) => void +}) { + const nextId = useRef(2) + const [schema, setSchema] = useState(schemas.includes('public') ? 'public' : schemas[0] ?? 'public') + const [name, setName] = useState('') + const [rows, setRows] = useState([{ id: 1, name: 'id', type: 'bigserial', notNull: false, default: '', pk: true }]) + const set = (id: number, patch: Partial) => setRows(rs => rs.map(r => (r.id === id ? { ...r, ...patch } : r))) + const built = buildCreateTable(schema, name, rows) + const { err, busy, save } = useSave(branch, built.sql, () => onDone(schema, name.trim())) + return ( + +
    { e.preventDefault(); void save() }}> + + + + setName(e.target.value)} /> +
    + + {rows.map((r, i) => ( +
    + set(r.id, { name: e.target.value })} /> + set(r.id, { type: e.target.value })} /> + set(r.id, { default: e.target.value })} /> + + + +
    + ))} +
    +
    +
    +
    + ) +} + +/** ColumnDialog adds a column to a table, or edits one. */ +export function ColumnDialog({ branch, schema, table, edit, onClose, onDone, onOpenSql }: { + branch: string; schema: string; table: string; edit?: CatalogCol + onClose: () => void; onDone: () => void; onOpenSql: (sql: string) => void +}) { + const before: ColumnSpec = { name: edit?.name ?? '', type: edit?.type ?? '', notNull: !!edit?.not_null, default: edit?.default ?? '' } + const [name, setName] = useState(before.name) + const [type, setType] = useState(before.type) + const [notNull, setNotNull] = useState(before.notNull) + const [def, setDef] = useState(before.default) + const [using, setUsing] = useState('') + const after: ColumnSpec = { name, type, notNull, default: def } + const built = edit ? buildAlterColumn(schema, table, before, after, using) : buildAddColumn(schema, table, after) + const { err, busy, save } = useSave(branch, built.sql, onDone) + // Postgres fills these two itself: an identity column is always NOT NULL, + // and neither takes a default. + const filled = !!edit?.identity || !!edit?.generated + return ( + +
    { e.preventDefault(); void save() }}> + + setName(e.target.value)} /> + setType(e.target.value)} /> + {edit && type.trim() !== before.type && ( + + setUsing(e.target.value)} /> + + )} + setDef(e.target.value)} /> + + {edit?.identity &&

    An identity column: Postgres numbers it.

    } + {edit?.generated &&

    A generated column: Postgres computes it.

    } + +
    + ) +} + +const quoteHint = (n: string) => (/^[a-z_][a-z0-9_]*$/.test(n) ? n : `"${n}"`) + +/** DropDialog says what a drop does and shows the SQL; a guardrail's refusal is answered in the editor. */ +export function DropDialog({ title, sql, children, branch, onClose, onDone, onOpenSql }: { + title: string; sql: string; children: ReactNode; branch: string + onClose: () => void; onDone: () => void; onOpenSql: (sql: string) => void +}) { + const { err, busy, save } = useSave(branch, sql, onDone) + return ( + +
    { e.preventDefault(); void save() }}> +

    {children}

    +
    +
    + ) +} + +/** Pick columns: the order they are picked in is the order the key has. */ +function ColumnPicker({ label, options, value, onChange }: { label: string; options: string[]; value: string[]; onChange: (v: string[]) => void }) { + const all = [...options, ...value.filter(v => !options.includes(v))] + return ( +
    + {label} +
    + {all.map(c => ( + + ))} + {all.length === 0 && Choose a table first.} +
    +
    + ) +} + +const ACTIONS = ['', 'CASCADE', 'SET NULL', 'SET DEFAULT', 'RESTRICT'] +const actionLabel = (a: string) => a || 'NO ACTION' +const KEY_LABEL: Record = { primary: 'Primary key', unique: 'Unique', foreign: 'Foreign key', check: 'Check' } + +const specOf = (p?: CatalogPart): KeySpec | undefined => p?.key && ({ + type: p.key.type as KeyType, columns: p.key.columns ?? [], refSchema: p.key.refSchema, refTable: p.key.refTable, refColumns: p.key.refColumns ?? [], + onDelete: p.key.onDelete ?? '', onUpdate: p.key.onUpdate ?? '', match: p.key.match, deferrable: p.key.deferrable, deferred: p.key.deferred, + notValid: p.key.notValid, noInherit: p.key.noInherit, check: p.key.check ?? '', +}) + +/** KeyDialog adds a primary, unique, foreign or check key to a table, or edits one. */ +export function KeyDialog({ branch, schema, table, columns, tables, edit, hasPrimary, onClose, onDone, onOpenSql }: { + branch: string; schema: string; table: string; columns: string[]; tables: CatalogObject[] + edit?: CatalogPart; hasPrimary: boolean + onClose: () => void; onDone: () => void; onOpenSql: (sql: string) => void +}) { + const before = specOf(edit) + const [type, setType] = useState(before?.type ?? (hasPrimary ? 'unique' : 'primary')) + const [name, setName] = useState(edit?.name ?? '') + const [cols, setCols] = useState(before?.columns ?? []) + const [target, setTarget] = useState(before?.refTable ? `${before.refSchema}.${before.refTable}` : '') + const [refCols, setRefCols] = useState(before?.refColumns ?? []) + const [refOptions, setRefOptions] = useState([]) + const [onDelete, setOnDelete] = useState(before?.onDelete ?? '') + const [onUpdate, setOnUpdate] = useState(before?.onUpdate ?? '') + const [deferrable, setDeferrable] = useState(!!before?.deferrable) + const [deferred, setDeferred] = useState(!!before?.deferred) + const [check, setCheck] = useState(before?.check ?? '') + + const label = (o: CatalogObject) => `${o.schema ?? 'public'}.${o.name}` + const targetObj = tables.find(o => label(o) === target) + const first = useRef(true) + // A table chosen brings its columns, and its primary key as what is referenced. + useEffect(() => { + const keep = first.current && !!edit + first.current = false + if (!targetObj) { setRefOptions([]); return } + let live = true + getObject(branch, 'table', targetObj.oid).then(d => { + if (!live) return + setRefOptions((d.columns ?? []).map(c => c.name)) + if (!keep) setRefCols(d.constraints?.find(k => k.key?.type === 'primary')?.key?.columns ?? []) + }).catch(() => { if (live) setRefOptions([]) }) + return () => { live = false } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [branch, targetObj?.oid]) + + const dot = target.indexOf('.') + // A schema or table name holding a dot is told apart through the catalog. + const refSchema = targetObj?.schema ?? (dot > 0 ? target.slice(0, dot) : undefined) + const refTable = targetObj?.name ?? (dot > 0 ? target.slice(dot + 1) : undefined) + const canDefer = type !== 'check' + const after: KeySpec = { + type, columns: type === 'check' ? [] : cols, + ...(type === 'foreign' ? { refSchema, refTable, refColumns: refCols, onDelete, onUpdate, match: before?.match } : {}), + deferrable: canDefer && deferrable, deferred: canDefer && deferrable && deferred, + notValid: before?.notValid, noInherit: before?.noInherit, check, + } + const built = edit && before ? buildEditKey(schema, table, edit.name, before, after) : buildAddKey(schema, table, name, after) + const { err, busy, save } = useSave(branch, built.sql, onDone) + const tableOptions = [...tables.map(label), ...(target && !targetObj ? [target] : [])] + return ( + +
    { e.preventDefault(); void save() }}> + + + + setName(e.target.value)} /> + {type === 'check' + ? setCheck(e.target.value)} /> + : } + {type === 'foreign' && ( + <> + + + + + + + + + + + + )} + {canDefer && ( +
    + + {deferrable && } +
    + )} + {edit &&

    Postgres cannot change a key in place: saving drops it and adds it again in one statement, checking every row.

    } + +
    + ) +} diff --git a/web/src/ddl.test.ts b/web/src/ddl.test.ts index 49596e1..b921938 100644 --- a/web/src/ddl.test.ts +++ b/web/src/ddl.test.ts @@ -1,6 +1,7 @@ // 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' +import { buildAddColumn, buildAddKey, buildAlterColumn, buildCreateTable, buildEditKey, dropColumn, dropKey, dropTable, keyClause, renameKey, renameTable, type ColumnSpec, type KeySpec } 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 } @@ -65,3 +66,82 @@ describe('buildTrigger', () => { expect(dropTrigger('public', 'orders', 'a')).toBe(`DROP TRIGGER "a" ON "public"."orders"`) }) }) + +const col = (name: string, type: string, extra: Partial = {}): ColumnSpec => ({ name, type, notNull: false, default: '', ...extra }) + +describe('buildCreateTable', () => { + it('writes the columns and a primary key of one or several', () => { + expect(buildCreateTable('public', 'orders', [col('id', 'bigserial', { pk: true }), col('Label', 'text', { notNull: true, default: `'x'` })]).sql).toBe( + `CREATE TABLE "public"."orders" (\n "id" bigserial,\n "Label" text NOT NULL DEFAULT 'x',\n PRIMARY KEY ("id")\n)`) + expect(buildCreateTable('s', 't', [col('a', 'int', { pk: true }), col('b', 'int', { pk: true })]).sql).toContain('PRIMARY KEY ("a", "b")') + }) + it('leaves out an empty row, and says what is missing', () => { + expect(buildCreateTable('s', 't', [col('a', 'int'), col('', '')]).sql).toBe('CREATE TABLE "s"."t" (\n "a" int\n)') + expect(buildCreateTable('s', '', [col('a', 'int')]).error).toBe('It needs a name.') + expect(buildCreateTable('s', 't', [col('', '')]).error).toBe('It needs at least one column.') + expect(buildCreateTable('s', 't', [col('a', '')]).error).toBe('a needs a type.') + expect(buildCreateTable('s', 't', [col('', 'int')]).error).toBe('Each column needs a name.') + expect(buildCreateTable('s', 't', [col('a', 'int'), col('a', 'text')]).error).toBe('a is named twice.') + }) + it('never lets a name close its own quoting', () => { + expect(buildCreateTable('s', 'a"b', [col('c"d', 'int')]).sql).toBe('CREATE TABLE "s"."a""b" (\n "c""d" int\n)') + }) +}) + +describe('columns', () => { + const before = col('qty', 'integer', { default: '1' }) + it('adds one', () => { + expect(buildAddColumn('s', 't', col('a', 'int', { notNull: true, default: '0' })).sql).toBe('ALTER TABLE "s"."t" ADD COLUMN "a" int NOT NULL DEFAULT 0') + expect(buildAddColumn('s', 't', col('a', '')).error).toBe('a needs a type.') + }) + it('writes nothing when nothing changed', () => { + expect(buildAlterColumn('s', 't', before, before, '')).toEqual({}) + }) + it('writes only what changed', () => { + expect(buildAlterColumn('s', 't', before, { ...before, notNull: true }, '').sql).toBe('ALTER TABLE "s"."t"\n ALTER COLUMN "qty" SET NOT NULL') + expect(buildAlterColumn('s', 't', before, { ...before, default: '' }, '').sql).toBe('ALTER TABLE "s"."t"\n ALTER COLUMN "qty" DROP DEFAULT') + expect(buildAlterColumn('s', 't', { ...before, notNull: true }, before, '').sql).toBe('ALTER TABLE "s"."t"\n ALTER COLUMN "qty" DROP NOT NULL') + }) + it('changes the type with its conversion, then renames, under the old name until the last', () => { + expect(buildAlterColumn('s', 't', before, { name: 'amount', type: 'text', notNull: true, default: `'2'` }, 'qty::text').sql).toBe( + `ALTER TABLE "s"."t"\n ALTER COLUMN "qty" TYPE text USING qty::text,\n ALTER COLUMN "qty" SET NOT NULL,\n ALTER COLUMN "qty" SET DEFAULT '2';\nALTER TABLE "s"."t" RENAME COLUMN "qty" TO "amount"`) + }) + it('drops and renames', () => { + expect(dropColumn('s', 't', 'a')).toBe('ALTER TABLE "s"."t" DROP COLUMN "a"') + expect(renameTable('s', 't', ' u ')).toBe('ALTER TABLE "s"."t" RENAME TO "u"') + expect(dropTable('s', 't')).toBe('DROP TABLE "s"."t"') + }) +}) + +describe('keys', () => { + const fk: KeySpec = { type: 'foreign', columns: ['a', 'b'], refSchema: 'r', refTable: 'p', refColumns: ['x', 'y'], onDelete: 'CASCADE', onUpdate: '' } + it('writes each kind, its columns in the order chosen', () => { + expect(keyClause({ type: 'primary', columns: ['b', 'a'] }).clause).toBe('PRIMARY KEY ("b", "a")') + expect(keyClause({ type: 'unique', columns: ['a'], deferrable: true, deferred: true }).clause).toBe('UNIQUE ("a") DEFERRABLE INITIALLY DEFERRED') + expect(keyClause(fk).clause).toBe('FOREIGN KEY ("a", "b") REFERENCES "r"."p" ("x", "y") ON DELETE CASCADE') + expect(keyClause({ type: 'check', columns: [], check: 'a > 0' }).clause).toBe('CHECK (a > 0)') + }) + it('keeps what the dialog has no field for', () => { + expect(keyClause({ ...fk, match: 'FULL', onUpdate: 'RESTRICT', notValid: true }).clause).toBe('FOREIGN KEY ("a", "b") REFERENCES "r"."p" ("x", "y") MATCH FULL ON UPDATE RESTRICT ON DELETE CASCADE NOT VALID') + expect(keyClause({ type: 'check', columns: [], check: 'a > 0', noInherit: true, notValid: true }).clause).toBe('CHECK (a > 0) NO INHERIT NOT VALID') + }) + it('says what is missing', () => { + expect(keyClause({ type: 'primary', columns: [] }).error).toBe('Choose at least one column.') + expect(keyClause({ type: 'check', columns: [], check: ' ' }).error).toBe('It needs a condition.') + expect(keyClause({ ...fk, refTable: '' }).error).toBe('Choose the table it references.') + expect(keyClause({ ...fk, refColumns: ['x'] }).error).toBe('A foreign key names as many columns on each side.') + }) + it('adds one, named or left to Postgres', () => { + expect(buildAddKey('s', 't', 'k', { type: 'unique', columns: ['a'] }).sql).toBe('ALTER TABLE "s"."t"\n ADD CONSTRAINT "k" UNIQUE ("a")') + expect(buildAddKey('s', 't', '', { type: 'unique', columns: ['a'] }).sql).toBe('ALTER TABLE "s"."t"\n ADD UNIQUE ("a")') + }) + it('replaces one in a single statement, and writes nothing when nothing changed', () => { + expect(buildEditKey('s', 't', 'k', fk, fk)).toEqual({}) + expect(buildEditKey('s', 't', 'k', fk, { ...fk, onDelete: 'SET NULL' }).sql).toBe( + 'ALTER TABLE "s"."t"\n DROP CONSTRAINT "k",\n ADD CONSTRAINT "k" FOREIGN KEY ("a", "b") REFERENCES "r"."p" ("x", "y") ON DELETE SET NULL') + }) + it('renames and drops', () => { + expect(renameKey('s', 't', 'k', 'l')).toBe('ALTER TABLE "s"."t" RENAME CONSTRAINT "k" TO "l"') + expect(dropKey('s', 't', 'k')).toBe('ALTER TABLE "s"."t" DROP CONSTRAINT "k"') + }) +}) diff --git a/web/src/ddl.ts b/web/src/ddl.ts index 804c631..4541cd5 100644 --- a/web/src/ddl.ts +++ b/web/src/ddl.ts @@ -98,3 +98,118 @@ export const renameTrigger = (schema: string, table: string, name: string, to: s `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)}` + +// ── Tables ──────────────────────────────────────────────────────────────── +// A column's type and default, and a check's condition, are the person's own +// SQL, as a function's arguments are. A multi-statement run is one transaction, +// so an edit that also renames is applied whole or not at all. + +const rel = (schema: string, table: string) => `${quoteIdent(schema)}.${quoteIdent(table)}` + +export type ColumnSpec = { name: string; type: string; notNull: boolean; default: string; pk?: boolean } + +function columnDef(c: ColumnSpec): string { + let d = `${quoteIdent(c.name.trim())} ${c.type.trim()}` + if (c.notNull) d += ' NOT NULL' + if (c.default.trim()) d += ` DEFAULT ${c.default.trim()}` + return d +} + +function columnProblem(c: ColumnSpec): string { + if (!c.name.trim()) return 'Each column needs a name.' + if (!c.type.trim()) return `${c.name.trim()} needs a type.` + return '' +} + +export function buildCreateTable(schema: string, name: string, columns: ColumnSpec[]): { sql?: string; error?: string } { + if (!name.trim()) return { error: 'It needs a name.' } + const cols = columns.filter(c => c.name.trim() || c.type.trim()) + if (cols.length === 0) return { error: 'It needs at least one column.' } + for (const c of cols) { const p = columnProblem(c); if (p) return { error: p } } + const names = cols.map(c => c.name.trim()) + const twice = names.find((n, i) => names.indexOf(n) !== i) + if (twice) return { error: `${twice} is named twice.` } + const lines = cols.map(columnDef) + const pk = cols.filter(c => c.pk).map(c => quoteIdent(c.name.trim())) + if (pk.length) lines.push(`PRIMARY KEY (${pk.join(', ')})`) + return { sql: `CREATE TABLE ${rel(schema, name.trim())} (\n ${lines.join(',\n ')}\n)` } +} + +export function buildAddColumn(schema: string, table: string, c: ColumnSpec): { sql?: string; error?: string } { + const p = columnProblem(c) + return p ? { error: p } : { sql: `ALTER TABLE ${rel(schema, table)} ADD COLUMN ${columnDef(c)}` } +} + +/** buildAlterColumn writes what changed, and nothing when nothing did. */ +export function buildAlterColumn(schema: string, table: string, before: ColumnSpec, after: ColumnSpec, using: string): { sql?: string; error?: string } { + const p = columnProblem(after) + if (p) return { error: p } + const col = `ALTER COLUMN ${quoteIdent(before.name)}` + const parts: string[] = [] + if (after.type.trim() !== before.type) parts.push(`${col} TYPE ${after.type.trim()}${using.trim() ? ` USING ${using.trim()}` : ''}`) + if (after.notNull !== before.notNull) parts.push(`${col} ${after.notNull ? 'SET' : 'DROP'} NOT NULL`) + if (after.default.trim() !== before.default) parts.push(after.default.trim() ? `${col} SET DEFAULT ${after.default.trim()}` : `${col} DROP DEFAULT`) + const stmts: string[] = [] + if (parts.length) stmts.push(`ALTER TABLE ${rel(schema, table)}\n ${parts.join(',\n ')}`) + if (after.name.trim() !== before.name) stmts.push(`ALTER TABLE ${rel(schema, table)} RENAME COLUMN ${quoteIdent(before.name)} TO ${quoteIdent(after.name.trim())}`) + return stmts.length ? { sql: stmts.join(';\n') } : {} +} + +export const dropColumn = (schema: string, table: string, name: string) => + `ALTER TABLE ${rel(schema, table)} DROP COLUMN ${quoteIdent(name)}` +export const renameTable = (schema: string, table: string, to: string) => + `ALTER TABLE ${rel(schema, table)} RENAME TO ${quoteIdent(to.trim())}` +export const dropTable = (schema: string, table: string) => `DROP TABLE ${rel(schema, table)}` + +export type KeyType = 'primary' | 'unique' | 'foreign' | 'check' +export type KeySpec = { + type: KeyType; columns: string[] + refSchema?: string; refTable?: string; refColumns?: string[] + onDelete?: string; onUpdate?: string; match?: string + deferrable?: boolean; deferred?: boolean; notValid?: boolean; noInherit?: boolean + check?: string +} + +export function keyClause(k: KeySpec): { clause?: string; error?: string } { + const cols = () => k.columns.map(quoteIdent).join(', ') + let c: string + if (k.type === 'check') { + if (!k.check?.trim()) return { error: 'It needs a condition.' } + c = `CHECK (${k.check.trim()})` + if (k.noInherit) c += ' NO INHERIT' + } else { + if (k.columns.length === 0) return { error: 'Choose at least one column.' } + if (k.type === 'foreign') { + if (!k.refTable) return { error: 'Choose the table it references.' } + if ((k.refColumns ?? []).length !== k.columns.length) return { error: 'A foreign key names as many columns on each side.' } + c = `FOREIGN KEY (${cols()}) REFERENCES ${rel(k.refSchema ?? 'public', k.refTable)} (${k.refColumns!.map(quoteIdent).join(', ')})` + if (k.match) c += ` MATCH ${k.match}` + if (k.onUpdate) c += ` ON UPDATE ${k.onUpdate}` + if (k.onDelete) c += ` ON DELETE ${k.onDelete}` + } else { + c = `${k.type === 'primary' ? 'PRIMARY KEY' : 'UNIQUE'} (${cols()})` + } + if (k.deferrable) c += ` DEFERRABLE${k.deferred ? ' INITIALLY DEFERRED' : ''}` + } + if (k.notValid && (k.type === 'foreign' || k.type === 'check')) c += ' NOT VALID' + return { clause: c } +} + +export function buildAddKey(schema: string, table: string, name: string, k: KeySpec): { sql?: string; error?: string } { + const { clause, error } = keyClause(k) + if (!clause) return { error } + return { sql: `ALTER TABLE ${rel(schema, table)}\n ADD ${name.trim() ? `CONSTRAINT ${quoteIdent(name.trim())} ` : ''}${clause}` } +} + +/** buildEditKey replaces a key in one statement, as Postgres cannot change one in place. */ +export function buildEditKey(schema: string, table: string, name: string, before: KeySpec, after: KeySpec): { sql?: string; error?: string } { + const { clause, error } = keyClause(after) + if (!clause) return { error } + if (clause === keyClause(before).clause) return {} + return { sql: `ALTER TABLE ${rel(schema, table)}\n DROP CONSTRAINT ${quoteIdent(name)},\n ADD CONSTRAINT ${quoteIdent(name)} ${clause}` } +} + +export const renameKey = (schema: string, table: string, name: string, to: string) => + `ALTER TABLE ${rel(schema, table)} RENAME CONSTRAINT ${quoteIdent(name)} TO ${quoteIdent(to.trim())}` +export const dropKey = (schema: string, table: string, name: string) => + `ALTER TABLE ${rel(schema, table)} DROP CONSTRAINT ${quoteIdent(name)}` diff --git a/web/src/pages/Console.tsx b/web/src/pages/Console.tsx index 98be273..7b10edd 100644 --- a/web/src/pages/Console.tsx +++ b/web/src/pages/Console.tsx @@ -7,7 +7,8 @@ import CopyMenu, { cellText } from '../components/CopyMenu' import ExportMenu from '../components/ExportMenu' import HistoryPanel from '../components/HistoryPanel' import { useCompletion } from '../components/Completion' -import { CatalogGroups, ObjectView, TableParts } from '../components/ObjectBrowser' +import { CatalogGroups, ObjectView, TableParts, tableEditable, type TableChange } from '../components/ObjectBrowser' +import { NewTableDialog } from '../components/TableDialogs' import { EditGrid, FilterBar, type ColInfo } from '../components/DataGrid' import { TrackingControl, trackedOf } from '../components/Tracking' import { AuditControl } from '../components/AuditMark' @@ -100,6 +101,7 @@ export default function Console() { const [plan, setPlan] = useState<{ plan: Plan; raw: string } | null>(null) const [analyse, setAnalyse] = useState(false) const [newRoutine, setNewRoutine] = useState(false) + const [newTable, setNewTable] = 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. @@ -277,6 +279,13 @@ export default function Console() { setSel(o); setTab('rows'); setPage(0); setTotal(null); setMode('browse') setSort(null); setFilters([]); setEditing(null); setGridNote('') } + // A table dialog changed the open table: the rail, its key and its columns follow. + const tableChanged = (c: TableChange) => { + loadObjects(branch) + if (c.gone) { setSel(null); setMode('query') } + else if (c.renamed) { const to = c.renamed; setSel(s => (s ? { ...s, name: to } : s)) } + } + // A table's primary key, which orders its pages and names a row in an edit. const keyOf = (o: DbObject) => rels.find(r => r.schema === o.schema && r.name === o.name)?.cols.filter(c => c.pk).map(c => c.name) ?? [] @@ -507,7 +516,12 @@ export default function Console() { {views.map(item)} { setObjSel(o); setMode('object') }} onNew={() => setNewRoutine(true)} /> + onOpen={o => { setObjSel(o); setMode('object') }} onNew={() => setNewRoutine(true)} onNewTable={() => setNewTable(true)} /> + {newTable && ( + o.kind === 'schema' && !o.system).map(o => o.name)} onOpenSql={openSql} + onClose={() => setNewTable(false)} + onDone={(schema, name) => { setNewTable(false); loadObjects(branch); setSel({ schema, name, type: 'table' }); setTab('structure'); setPage(0); setTotal(null); setMode('browse') }} /> + )} {newRoutine && ( o.kind === 'schema' && !o.system).map(o => o.name)} onOpenSql={openSql} onClose={() => setNewRoutine(false)} onDone={() => { setNewRoutine(false); loadObjects(branch) }} /> @@ -676,8 +690,8 @@ export default function Console() { )} {tab === 'upkeep' ? - : CATALOG_TABS.includes(tab) - ? o.kind === 'table' && o.schema === sel.schema && o.name === sel.name))) + ? (o.kind === 'table' || o.kind === 'view') && o.schema === sel.schema && o.name === sel.name) ?? null} /> : tab === 'rows' && editing && browseRes && !browseRes.error ? { + await signIn(page) + const t = `e2e_tbl_${Date.now().toString().slice(-6)}` + await console_(page) + + await page.getByRole('button', { name: '+ New table…' }).click() + let dlg = page.getByRole('dialog') + await dlg.getByLabel('Name', { exact: true }).fill(t) + await dlg.getByRole('button', { name: '+ Add a column' }).click() + await dlg.getByLabel('Column 2 name', { exact: true }).fill('label') + await dlg.getByLabel('Column 2 type', { exact: true }).fill('text') + await dlg.getByLabel('Column 2 not null', { exact: true }).check() + await expect(dlg.getByTestId('ddl-sql')).toContainText(`CREATE TABLE "public"."${t}"`) + await expect(dlg.getByTestId('ddl-sql')).toContainText('PRIMARY KEY ("id")') + await dlg.getByRole('button', { name: 'Create', exact: true }).click() + await expect(dlg).toHaveCount(0, { timeout: 30_000 }) + + // It opens on its columns. + await expect(page.getByTestId('col-label')).toContainText('not null', { timeout: 30_000 }) + + await page.getByRole('button', { name: '+ Add column…' }).click() + dlg = page.getByRole('dialog') + await dlg.getByLabel('Name', { exact: true }).fill('qty') + await dlg.getByLabel('Type', { exact: true }).fill('integer') + await dlg.getByLabel('Default', { exact: true }).fill('1') + await dlg.getByRole('button', { name: 'Add', exact: true }).click() + await expect(page.getByTestId('table-note')).toHaveText('Column added.', { timeout: 30_000 }) + await expect(page.getByTestId('col-qty')).toContainText('integer') + + await page.getByTestId('col-qty').getByRole('button', { name: 'Edit…' }).click() + dlg = page.getByRole('dialog') + await expect(dlg.getByRole('button', { name: 'Save', exact: true })).toBeDisabled() + await dlg.getByLabel('Name', { exact: true }).fill('amount') + await dlg.getByLabel('Type', { exact: true }).fill('bigint') + await dlg.getByLabel('Not null', { exact: true }).check() + await expect(dlg.getByLabel('Using', { exact: true })).toBeVisible() + await expect(dlg.getByTestId('ddl-sql')).toContainText('ALTER COLUMN "qty" TYPE bigint') + await expect(dlg.getByTestId('ddl-sql')).toContainText('RENAME COLUMN "qty" TO "amount"') + await dlg.getByRole('button', { name: 'Save', exact: true }).click() + await expect(page.getByTestId('table-note')).toHaveText('qty saved.', { timeout: 30_000 }) + await expect(page.getByTestId('col-amount')).toContainText('bigint') + await expect(page.getByTestId('col-amount')).toContainText('not null') + + await page.getByTestId('col-amount').getByRole('button', { name: 'Drop…' }).click() + dlg = page.getByRole('dialog') + await dlg.getByRole('button', { name: 'Drop it', exact: true }).click() + await expect(page.getByTestId('table-note')).toHaveText('amount dropped.', { timeout: 30_000 }) + await expect(page.getByTestId('col-amount')).toHaveCount(0) + + const to = `${t}_r` + await page.getByRole('button', { name: 'Rename table…' }).click() + dlg = page.getByRole('dialog') + await dlg.getByLabel('New name', { exact: true }).fill(to) + await dlg.getByRole('button', { name: 'Rename', exact: true }).click() + await expect(dlg).toHaveCount(0, { timeout: 30_000 }) + await expect(page.getByRole('heading', { name: to, level: 2 })).toBeVisible({ timeout: 30_000 }) + await expect(page.getByTestId('col-label')).toBeVisible({ timeout: 30_000 }) + + // The guardrail answers a drop as it does anywhere; the editor is the way on. + await page.getByRole('button', { name: 'Drop table…' }).click() + dlg = page.getByRole('dialog') + await dlg.getByRole('button', { name: 'Drop it', exact: true }).click() + await expect(dlg.getByTestId('ddl-err')).toContainText('DROP TABLE is blocked by policy', { timeout: 30_000 }) + await dlg.getByRole('button', { name: 'Put it in the editor' }).click() + await expect(page.locator('textarea.editor')).toHaveValue(`DROP TABLE "public"."${to}"`) + await sql(page, `SET bb.allow_destructive = on; DROP TABLE "public"."${to}"`) +}) + +test('a table\'s keys are added, edited, renamed and dropped', async ({ page }) => { + await signIn(page) + const n = Date.now().toString().slice(-6) + const p = `e2e_kp_${n}`, c = `e2e_kc_${n}` + await sql(page, `CREATE TABLE ${p} (id int PRIMARY KEY, code text); CREATE TABLE ${c} (cid int, pid int, code text, qty int)`) + await console_(page) + await openTable(page, c, 'Constraints') + + const add = async () => { + await page.getByRole('button', { name: '+ Add key…' }).click({ timeout: 30_000 }) + return page.getByRole('dialog') + } + // A table with none is offered a primary key first. + let dlg = await add() + await expect(dlg.getByLabel('Kind', { exact: true })).toHaveValue('primary') + await dlg.getByLabel('Columns: cid', { exact: true }).check() + await expect(dlg.getByTestId('ddl-sql')).toContainText('ADD PRIMARY KEY ("cid")') + await dlg.getByRole('button', { name: 'Add', exact: true }).click() + await expect(page.getByTestId('key-note')).toHaveText('Key added.', { timeout: 30_000 }) + await expect(page.getByTestId(`part-${c}_pkey`)).toContainText('PRIMARY KEY') + + dlg = await add() + await expect(dlg.getByLabel('Kind', { exact: true })).toHaveValue('unique') + await dlg.getByLabel('Name', { exact: true }).fill(`${c}_u`) + await dlg.getByLabel('Columns: qty', { exact: true }).check() + await dlg.getByLabel('Columns: code', { exact: true }).check() + await expect(dlg.getByTestId('ddl-sql')).toContainText('UNIQUE ("qty", "code")') + await dlg.getByRole('button', { name: 'Add', exact: true }).click() + await expect(page.getByTestId(`part-${c}_u`)).toBeVisible({ timeout: 30_000 }) + + dlg = await add() + await dlg.getByLabel('Kind', { exact: true }).selectOption('check') + await dlg.getByLabel('Name', { exact: true }).fill(`${c}_ck`) + await dlg.getByLabel('Condition', { exact: true }).fill('qty > 0') + await dlg.getByRole('button', { name: 'Add', exact: true }).click() + await expect(page.getByTestId(`part-${c}_ck`)).toContainText('qty > 0', { timeout: 30_000 }) + + // A foreign key takes the other table's primary key as what it references. + dlg = await add() + await dlg.getByLabel('Kind', { exact: true }).selectOption('foreign') + await dlg.getByLabel('Name', { exact: true }).fill(`${c}_fk`) + await dlg.getByLabel('Columns: pid', { exact: true }).check() + await dlg.getByLabel('References', { exact: true }).selectOption(`public.${p}`) + await expect(dlg.getByLabel('Its columns: id', { exact: true })).toBeChecked({ timeout: 30_000 }) + await dlg.getByLabel('On delete', { exact: true }).selectOption('CASCADE') + await expect(dlg.getByTestId('ddl-sql')).toContainText(`REFERENCES "public"."${p}" ("id") ON DELETE CASCADE`) + await dlg.getByRole('button', { name: 'Add', exact: true }).click() + const fk = page.getByTestId(`part-${c}_fk`) + await expect(fk).toContainText('ON DELETE CASCADE', { timeout: 30_000 }) + + await fk.getByRole('button', { name: 'Edit…' }).click() + dlg = page.getByRole('dialog') + await expect(dlg.getByRole('button', { name: 'Save', exact: true })).toBeDisabled() + await expect(dlg.getByLabel('On delete', { exact: true })).toHaveValue('CASCADE') + await dlg.getByLabel('On delete', { exact: true }).selectOption('SET NULL') + await expect(dlg.getByTestId('ddl-sql')).toContainText(`DROP CONSTRAINT "${c}_fk"`) + await dlg.getByRole('button', { name: 'Save', exact: true }).click() + await expect(fk).toContainText('ON DELETE SET NULL', { timeout: 30_000 }) + + await page.getByTestId(`part-${c}_ck`).getByRole('button', { name: 'Rename…' }).click() + dlg = page.getByRole('dialog') + await dlg.getByLabel('New name', { exact: true }).fill(`${c}_ck2`) + await dlg.getByRole('button', { name: 'Rename', exact: true }).click() + const ck2 = page.getByTestId(`part-${c}_ck2`) + await expect(ck2).toBeVisible({ timeout: 30_000 }) + + await ck2.getByRole('button', { name: 'Drop…' }).click() + dlg = page.getByRole('dialog') + await dlg.getByRole('button', { name: 'Drop it', exact: true }).click() + await expect(page.getByTestId('key-note')).toHaveText(`${c}_ck2 dropped.`, { timeout: 30_000 }) + await expect(ck2).toHaveCount(0) + + // Postgres's refusal is shown in the dialog. + await page.getByRole('button', { name: 'Structure', exact: true }).click() + await page.getByTestId('col-cid').getByRole('button', { name: 'Edit…' }).click({ timeout: 30_000 }) + dlg = page.getByRole('dialog') + await dlg.getByLabel('Name', { exact: true }).fill('pid') + await dlg.getByRole('button', { name: 'Save', exact: true }).click() + await expect(dlg.getByTestId('ddl-err')).toContainText('already exists', { timeout: 30_000 }) + await dlg.getByRole('button', { name: 'Cancel', exact: true }).click() +})