Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/FOX_Checklist.html
Original file line number Diff line number Diff line change
Expand Up @@ -591,6 +591,7 @@ <h2><span class="al">L</span>Web console<span class="cnt"></span></h2>
<li class="ok">Sessions and upkeep: the Sessions page and <code>fox sessions</code> list a branch&rsquo;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&rsquo;s, anyone else their own and their agent keys&rsquo;; FoxByte&rsquo;s own never ended from here. A table&rsquo;s Upkeep tab and <code>fox maintain</code>: rows, dead row versions, size, last vacuum and analyse, and VACUUM, ANALYZE or both, the owner&rsquo;s or an admin&rsquo;s; VACUUM FULL not offered (point 12.5)</li>
<li class="ok">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&rsquo;s own triggers disabled (after asking) and enabled from its Triggers tab, FoxByte&rsquo;s own not offered; Explain in the Console, the plan as a table with each step&rsquo;s share and the most expensive marked, and with Analyse measured inside a transaction that is rolled back (point 12.4)</li>
<li class="ok">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&rsquo;s own are not offered; what a guardrail refuses can be put in the editor (point 12.3, first part)</li>
<li class="ok">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)</li>
<li class="ok">Row tracking: created_at, updated_at, last_modified_by (the login) kept by a protected trigger; opt-in per table or for new tables; <code>fox track</code> (points 5.1, 6.1)</li>
<li class="ok">Row tracking&rsquo;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)</li>
<li class="ok">Each result says which statement produced it; labelling is dropped rather than guessed when a split is unsafe</li>
Expand Down
Binary file modified docs/FOX_Checklist.pdf
Binary file not shown.
2 changes: 2 additions & 0 deletions docs/FOX_Feature_Implemented.html

Large diffs are not rendered by default.

Binary file modified docs/FOX_Feature_Implemented.pdf
Binary file not shown.
75 changes: 70 additions & 5 deletions internal/controlplane/catalog.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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()
Expand Down
28 changes: 28 additions & 0 deletions internal/controlplane/catalog_params_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
}
3 changes: 2 additions & 1 deletion internal/controlplane/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
78 changes: 78 additions & 0 deletions scripts/integration_test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
8 changes: 7 additions & 1 deletion web/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading