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
4 changes: 3 additions & 1 deletion cmd/fox/schemacmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -183,7 +183,9 @@ func displayName(o controlplane.CatalogObject) string {
if o.Kind == "function" || o.Kind == "procedure" {
n += "(" + o.Args + ")"
}
if o.Sub != "" && o.Kind != "extension" {
// A function's sub says it is a trigger function, for the console's dialogs;
// the listing names functions by their arguments alone.
if o.Sub != "" && o.Kind != "extension" && o.Kind != "function" {
n += " [" + o.Sub + "]"
}
return n
Expand Down
2 changes: 1 addition & 1 deletion cmd/fox/schemacmd_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ func TestPrintCatalog(t *testing.T) {
{Kind: "schema", Name: "empty"},
{Kind: "table", Schema: "shop", Name: "orders"},
{Kind: "table", Schema: "shop", Name: "events", Sub: "partitioned"},
{Kind: "function", Schema: "shop", Name: "total", Args: "o_id integer"},
{Kind: "function", Schema: "shop", Name: "total", Args: "o_id integer", Sub: "trigger"},
{Kind: "extension", Schema: "public", Name: "dblink", Sub: "1.2"},
}
var b bytes.Buffer
Expand Down
1 change: 1 addition & 0 deletions docs/FOX_Checklist.html
Original file line number Diff line number Diff line change
Expand Up @@ -590,6 +590,7 @@ <h2><span class="al">L</span>Web console<span class="cnt"></span></h2>
<li class="ok">Data grid: sort, filter, page; add, change and delete rows on a table with a primary key, saved together in one transaction as the user (point 12.2)</li>
<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">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.
4 changes: 3 additions & 1 deletion docs/FOX_Feature_Implemented.html

Large diffs are not rendered by default.

Binary file modified docs/FOX_Feature_Implemented.pdf
Binary file not shown.
140 changes: 136 additions & 4 deletions internal/controlplane/catalog.go
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,30 @@ type ObjectDetail struct {
// Params are a function's or a procedure's arguments, in order, for a
// form that calls it (point 12.4).
Params []FuncParam `json:"params,omitempty"`
// Routine is what an edit dialog needs to write a function or procedure
// again without losing anything it was not asked to change (point 12.3).
Routine *RoutineInfo `json:"routine,omitempty"`
}

// RoutineInfo is a function or a procedure, attribute by attribute.
type RoutineInfo struct {
Arguments string `json:"arguments"` // as declared, defaults included
Identity string `json:"identity"` // as ALTER and DROP name it
Returns string `json:"returns,omitempty"`
Language string `json:"language"`
Body string `json:"body"`
// Atomic: written with BEGIN ATOMIC, which has no body as text to edit.
Atomic bool `json:"atomic,omitempty"`
Volatility string `json:"volatility"` // immutable, stable, volatile
Strict bool `json:"strict,omitempty"`
Definer bool `json:"definer,omitempty"`
Leakproof bool `json:"leakproof,omitempty"`
Window bool `json:"window,omitempty"`
Parallel string `json:"parallel"` // safe, restricted, unsafe
Cost float64 `json:"cost"`
Rows float64 `json:"rows,omitempty"`
Set []string `json:"set,omitempty"` // each SET clause, as Postgres writes it
Trigger bool `json:"trigger,omitempty"`
}

// FuncParam is one argument of a function or a procedure.
Expand Down Expand Up @@ -106,6 +130,91 @@ type CatalogPart struct {
// System marks a trigger FoxByte installed (the Blackbox's), as opposed to one of the user's.
System bool `json:"system,omitempty"`
Disabled bool `json:"disabled,omitempty"`
// Trigger is a trigger's parts, for the dialog that edits it (point 12.3).
Trigger *TriggerInfo `json:"trigger,omitempty"`
}

// TriggerInfo is a trigger, part by part.
type TriggerInfo struct {
Timing string `json:"timing"` // BEFORE, AFTER, INSTEAD OF
Events []string `json:"events"` // INSERT, UPDATE, DELETE, TRUNCATE
Columns []string `json:"columns,omitempty"`
ForEach string `json:"forEach"` // ROW or STATEMENT
When string `json:"when,omitempty"`
Function string `json:"function"` // schema-qualified and quoted
Args string `json:"args,omitempty"` // as written between the parentheses
Referencing string `json:"referencing,omitempty"`
// Constraint: a CONSTRAINT TRIGGER, which CREATE OR REPLACE cannot replace.
Constraint bool `json:"constraint,omitempty"`
}

// The bits of pg_trigger.tgtype.
const (
tgRow = 1 << 0
tgBefore = 1 << 1
tgInsert = 1 << 2
tgDelete = 1 << 3
tgUpdate = 1 << 4
tgTruncate = 1 << 5
tgInstead = 1 << 6
)

// triggerInfo reads a trigger's parts from its type bits, and what only its
// definition holds (the WHEN condition, the arguments, REFERENCING) from the
// text pg_get_triggerdef writes.
func triggerInfo(tgtype int, columns []string, function string, constraint bool, def string) *TriggerInfo {
t := &TriggerInfo{Timing: "AFTER", ForEach: "STATEMENT", Columns: columns, Function: function, Constraint: constraint, Events: []string{}}
switch {
case tgtype&tgBefore != 0:
t.Timing = "BEFORE"
case tgtype&tgInstead != 0:
t.Timing = "INSTEAD OF"
}
if tgtype&tgRow != 0 {
t.ForEach = "ROW"
}
for _, e := range []struct {
bit int
name string
}{{tgInsert, "INSERT"}, {tgUpdate, "UPDATE"}, {tgDelete, "DELETE"}, {tgTruncate, "TRUNCATE"}} {
if tgtype&e.bit != 0 {
t.Events = append(t.Events, e.name)
}
}
exec := strings.LastIndex(def, " EXECUTE FUNCTION ")
if exec < 0 {
return t
}
head := def[:exec]
if w := strings.Index(head, " WHEN ("); w >= 0 && strings.HasSuffix(head, ")") {
t.When = head[w+len(" WHEN (") : len(head)-1]
head = head[:w]
}
if r := strings.Index(head, " REFERENCING "); r >= 0 {
if f := strings.Index(head[r:], " FOR EACH "); f >= 0 {
t.Referencing = head[r+len(" REFERENCING ") : r+f]
}
}
call := def[exec+len(" EXECUTE FUNCTION "):]
if o := strings.Index(call, "("); o >= 0 && strings.HasSuffix(call, ")") {
t.Args = call[o+1 : len(call)-1]
}
return t
}

// setClauses are the SET lines of a pg_get_functiondef text, as Postgres
// writes them: search_path's list quoting is its own to get right.
func setClauses(def string) []string {
var out []string
for _, l := range strings.Split(def, "\n") {
if strings.HasPrefix(l, "AS ") || strings.HasPrefix(l, "BEGIN ATOMIC") {
break
}
if t := strings.TrimSpace(l); strings.HasPrefix(t, "SET ") {
out = append(out, t)
}
}
return out
}

// CatalogMember is something an extension installed.
Expand Down Expand Up @@ -189,16 +298,20 @@ func readCatalog(ctx context.Context, conn *pgx.Conn) (Catalog, error) {
}); err != nil {
return err
}
if err := add(`SELECT p.oid, n.nspname, p.proname, pg_get_function_identity_arguments(p.oid), p.prokind::text FROM pg_proc p
if err := add(`SELECT p.oid, n.nspname, p.proname, pg_get_function_identity_arguments(p.oid), p.prokind::text, p.prorettype = 'trigger'::regtype FROM pg_proc p
JOIN pg_namespace n ON n.oid = p.pronamespace
WHERE `+userSchemas+` AND p.prokind IN ('f','p')
AND `+fmt.Sprintf(notExtensionMember, `'pg_proc'::regclass`, `p.oid`)+`
ORDER BY n.nspname, p.proname, 4`,
func(r pgx.Rows) (CatalogObject, error) {
var o CatalogObject
var k string
err := r.Scan(&o.OID, &o.Schema, &o.Name, &o.Args, &k)
var trig bool
err := r.Scan(&o.OID, &o.Schema, &o.Name, &o.Args, &k, &trig)
o.Kind = map[string]string{"f": "function", "p": "procedure"}[k]
if trig {
o.Sub = "trigger" // what a trigger's dialog offers (point 12.3)
}
return o, err
}); err != nil {
return err
Expand Down Expand Up @@ -398,18 +511,26 @@ func readRelation(ctx context.Context, tx pgx.Tx, d *ObjectDetail) error {
return err
}
rows, err = tx.Query(ctx, `SELECT t.tgname, pg_get_triggerdef(t.oid, true), t.tgenabled = 'D',
pn.nspname = 'bb' OR pn.nspname LIKE 'bb\_%'
pn.nspname = 'bb' OR pn.nspname LIKE 'bb\_%', t.tgtype::int, t.tgconstraint <> 0,
format('%I.%I', pn.nspname, p.proname),
coalesce((SELECT array_agg(a.attname::text ORDER BY k.o) FROM unnest(t.tgattr::int2[]) WITH ORDINALITY AS k(n, o)
JOIN pg_attribute a ON a.attrelid = t.tgrelid AND a.attnum = k.n), '{}')
FROM pg_trigger t JOIN pg_proc p ON p.oid = t.tgfoid JOIN pg_namespace pn ON pn.oid = p.pronamespace
WHERE t.tgrelid = $1::oid AND NOT t.tgisinternal ORDER BY t.tgname`, d.OID)
if err != nil {
return err
}
for rows.Next() {
var p CatalogPart
if err := rows.Scan(&p.Name, &p.Def, &p.Disabled, &p.System); err != nil {
var tgtype int
var constraint bool
var fn string
var cols []string
if err := rows.Scan(&p.Name, &p.Def, &p.Disabled, &p.System, &tgtype, &constraint, &fn, &cols); err != nil {
rows.Close()
return err
}
p.Trigger = triggerInfo(tgtype, cols, fn, constraint, p.Def)
d.Triggers = append(d.Triggers, p)
}
rows.Close()
Expand Down Expand Up @@ -580,6 +701,17 @@ func readFunction(ctx context.Context, tx pgx.Tx, d *ObjectDetail) error {
}
d.Facts = append(d.Facts, [2]string{"language", lang})
d.DDL = strings.TrimRight(def, "\n") + ";\n"
r := &RoutineInfo{Arguments: args, Returns: result, Language: lang, Set: setClauses(def),
Volatility: map[string]string{"i": "immutable", "s": "stable", "v": "volatile"}[vol]}
var parallel string
if err := tx.QueryRow(ctx, `SELECT pg_get_function_identity_arguments(p.oid), p.prosrc, p.prosqlbody IS NOT NULL, p.proisstrict, p.prosecdef,
p.proleakproof, p.prokind = 'w', p.proparallel::text, p.procost::float8, p.prorows::float8, p.prorettype = 'trigger'::regtype
FROM pg_proc p WHERE p.oid = $1::oid`, d.OID).Scan(&r.Identity, &r.Body, &r.Atomic, &r.Strict, &r.Definer,
&r.Leakproof, &r.Window, &parallel, &r.Cost, &r.Rows, &r.Trigger); err != nil {
return err
}
r.Parallel = map[string]string{"s": "safe", "r": "restricted", "u": "unsafe"}[parallel]
d.Routine = r
return readParams(ctx, tx, d)
}

Expand Down
25 changes: 25 additions & 0 deletions internal/controlplane/catalog_params_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,3 +36,28 @@ func TestParamsSQL(t *testing.T) {
}
}
}

// A trigger's parts, from its type bits and its definition (point 12.3).
func TestTriggerInfo(t *testing.T) {
def := `CREATE TRIGGER trg BEFORE INSERT OR UPDATE OF v, w ON public.t FOR EACH ROW WHEN (old.v IS DISTINCT FROM new.v) EXECUTE FUNCTION public.f('a', 'b)')`
got := triggerInfo(tgRow|tgBefore|tgInsert|tgUpdate, []string{"v", "w"}, "public.f", false, def)
if got.Timing != "BEFORE" || got.ForEach != "ROW" || strings.Join(got.Events, ",") != "INSERT,UPDATE" || got.When != "old.v IS DISTINCT FROM new.v" || got.Args != "'a', 'b)'" || strings.Join(got.Columns, ",") != "v,w" {
t.Errorf("%+v", got)
}
ref := `CREATE TRIGGER s AFTER DELETE ON t REFERENCING OLD TABLE AS gone FOR EACH STATEMENT EXECUTE FUNCTION f()`
got = triggerInfo(tgDelete, nil, "public.f", false, ref)
if got.Timing != "AFTER" || got.ForEach != "STATEMENT" || got.Referencing != "OLD TABLE AS gone" || got.When != "" || got.Args != "" {
t.Errorf("%+v", got)
}
if got := triggerInfo(tgInstead|tgRow|tgInsert, nil, "f", false, "x"); got.Timing != "INSTEAD OF" {
t.Errorf("%+v", got)
}
}

// The SET clauses of a function's definition, and none from its body.
func TestSetClauses(t *testing.T) {
def := "CREATE OR REPLACE FUNCTION public.f()\n RETURNS integer\n LANGUAGE sql\n SET search_path TO 'public', 'pg_temp'\n SET work_mem TO '1MB'\nAS $function$\nSET x TO 1;\n$function$\n"
if got := setClauses(def); strings.Join(got, "|") != "SET search_path TO 'public', 'pg_temp'|SET work_mem TO '1MB'" {
t.Errorf("%q", got)
}
}
4 changes: 3 additions & 1 deletion internal/controlplane/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -670,7 +670,9 @@ paths:
a function's arguments, result and language, and for a function or a
procedure params, each argument's name, type, mode (in, out, inout,
variadic, table) and whether it has a default, for the console's Run
form (point 12.4); a sequence's range; a
form (point 12.4), and routine, its attributes for the Edit dialog, and
for each of a table's triggers trigger, its parts (point 12.3); a
sequence's range; a
type's values or fields; what an extension installed. ddl is what it
would take to make the object again, without grants or ownership. An
object the caller's list does not hold answers 404.
Expand Down
Loading
Loading