Documentation
¶
Overview ¶
Package dbbrowse is the data layer of a read-only table browser: it describes what a database role can read, and compiles a structured browse request (a table, columns, filters, a sort, a page) into one parameterised SELECT that Run executes through dbquery.Run.
It is what a Supabase-style "Data" tab needs and nothing more: no free SQL reaches the database on this path, and nothing here writes.
The security argument ¶
Three independent layers, each sufficient for what it covers:
- The role. The caller connects as the environment's read-only role (forge_ro, see pkg/dbgrants). Postgres refuses every column that role was not granted, a forge:secret column included, whatever SQL reaches it. Describe asks the server what that role may read (has_schema_privilege, has_column_privilege) rather than trusting a declaration, so a browser never offers a column the database would refuse: an ungranted column is simply absent from the description.
- The identifier allow-list. Every table and column a Request names is looked up in that description, by exact name, and refused with a typed error when it is not there. Only names that came out of the catalog are ever written into SQL, and each is quoted (pgx.Identifier). A request's own spelling of a name never reaches the statement, so an identifier cannot carry SQL.
- Binding. Every filter value is a $n parameter sent over the extended protocol; none is spliced into the statement. Operators come from a closed set (Op), and the limit and offset are integers the compiler checked. The compiled statement then runs through dbquery: a READ ONLY transaction with statement, lock and idle timeouts, row and byte caps, and dbquery's lexical guard and dbquery.Auditor.
How a value is typed ¶
A value is bound as text with no declared type, and Postgres types it from the column it is compared with, exactly as it types a quoted literal: `"id" = $1` reads $1 as a uuid when id is one, a timestamptz as a timestamptz, an enum label as that enum, '{a,b}' as the column's array type, a jsonb document as jsonb. The compiler never spells a type name, so no catalog text other than a quoted identifier is ever written into SQL, and domains, enums and types outside the search_path need no special case. A value the type cannot read is the server's invalid-input error, not a different statement.
Two operations do not use the column's type. LIKE and ILIKE compare the column's text form ("col"::text), which every type has. And a type with no default btree operator class (json, xml, the geometric types: see Column.Sortable) has no ordering and often no equality, so eq, neq and in compare its text form too, and an ordering operator or a sort on it is refused.
Why not pkg/schemadef ¶
schemadef introspects the full applied schema as its owner, for codegen: over database/sql, keyed by unqualified table name, with one catalog round trip per table. A browser needs the opposite cut: only what one role may read, every schema at once keyed by qualified name, and the incoming half of every foreign key. Two focused pg_catalog queries do that in two round trips, with the privilege checks inside the query, so a column the role cannot read never leaves the server.
Index ¶
Constants ¶
const ( DefaultLimit = 50 MaxLimit = 500 // ExactCountCap is where an exact count stops counting: past it the // total is reported as "at least ExactCountCap" ([Page.TotalAtLeast]), // so a count never scans an unbounded table. ExactCountCap = 10_000 // MaxFilters caps a request's filters, MaxInValues one in filter's // values, and MaxValueBytes one value's length. MaxFilters = 32 MaxInValues = 100 MaxValueBytes = 4 << 10 )
Request bounds. A value past its cap is refused, not clamped, so a caller learns the cap it asked past.
const DescribeTimeout = 10 * time.Second
DescribeTimeout is the statement timeout Describe's catalog queries run under.
Variables ¶
var ( // ErrUnknownTable: the request named a table the role cannot read, or // that does not exist. The two are deliberately indistinguishable. ErrUnknownTable = fmt.Errorf("%w: unknown table", ErrInvalidRequest) // ErrUnknownColumn: a column, filter or sort named a column the table // does not have or the role cannot read. ErrUnknownColumn = fmt.Errorf("%w: unknown column", ErrInvalidRequest) // ErrUnknownOp: a filter's operator is not one of the [Op] constants. ErrUnknownOp = fmt.Errorf("%w: unknown filter operator", ErrInvalidRequest) )
var ErrInvalidRequest = errors.New("invalid browse request")
ErrInvalidRequest is wrapped by every error Compile returns: the request is wrong, and retrying it unchanged cannot help. A transport maps it to its invalid-argument code with one errors.Is. The finer sentinels below wrap it, so errors.Is(err, ErrInvalidRequest) holds for each of them too.
Functions ¶
This section is empty.
Types ¶
type Column ¶
type Column struct {
Name string `json:"name"`
// Type is the column's type as Postgres prints it (format_type):
// "uuid", "timestamp with time zone", "character varying(40)", "text[]",
// an enum's name. It is for display; the compiler never writes it into
// SQL.
Type string `json:"type"`
Nullable bool `json:"nullable"`
Comment string `json:"comment,omitempty"`
// Ordinal is the column's attribute number (pg_attribute.attnum): 1-based,
// with gaps where columns were dropped.
Ordinal int `json:"ordinal"`
// Sortable reports whether the type has a default btree operator class,
// so it can be ordered and compared with <, <=, >, >=. Enums, ranges,
// records and arrays of sortable types are; json, xml, point and the other
// geometric types are not.
Sortable bool `json:"sortable"`
}
Column is one readable column.
type Compiled ¶
type Compiled struct {
// Table is the described table the request reads.
Table *Table
// Columns are the selected columns, in result order.
Columns []Column
Limit int
Offset int
Count CountMode
// contains filtered or unexported fields
}
Compiled is a Request checked against a Database and compiled. Its statements are reachable only through Run, which executes them, and the read-only accessors, so what runs is always what Compile wrote.
func Compile ¶
Compile checks req against d, which must be the Describe result for the role the query will run as, and compiles it. Every error wraps ErrInvalidRequest.
func (*Compiled) CountQuery ¶
CountQuery returns the statement that counts, and false when the count needs none (CountNone, or an estimate the description already holds).
type CountMode ¶
type CountMode string
CountMode says how Run reports a filtered table's size.
const ( // CountNone reports no total. CountNone CountMode = "none" // CountEstimated reports the planner's estimate: the table's // [Table.EstimatedRows] when the request has no filters and there is // one, else the row estimate EXPLAIN gives for the filtered query. It // executes nothing. CountEstimated CountMode = "estimated" // CountExact counts, up to [ExactCountCap]. CountExact CountMode = "exact" )
type Database ¶
type Database struct {
// Tables are sorted by schema, then name.
Tables []Table `json:"tables"`
}
Database is what one role can read in one database.
func Describe ¶
Describe reports what the connected role can read: every table and view of every schema it has USAGE on, with the columns it may SELECT, primary keys, and foreign keys in both directions. Connect as the role the browse queries will run as (forge_ro): the description is that role's, and a table described for another role is an allow-list for the wrong reader.
It reads only the catalogs, in a READ ONLY transaction under DescribeTimeout, and changes nothing. A failure is a *dbquery.Error, classified as dbquery.Run's are (a timeout, an unreachable database, a privilege error), so one switch on its Kind serves Describe and Run alike.
func (*Database) Table ¶
Table returns the table whose Table.QualifiedName is name, or an error wrapping ErrUnknownTable.
type Filter ¶
type Filter struct {
Column string `json:"column"`
Op Op `json:"op"`
Values []string `json:"values,omitempty"`
}
Filter is one WHERE condition. Values holds one value for the comparison and like operators, none for is_null and not_null, and 1..MaxInValues for in. A value is text in the form Postgres reads for the column's type.
type ForeignKey ¶
type ForeignKey struct {
Name string `json:"name"`
// Schema, Table and Columns are the referencing side.
Schema string `json:"schema"`
Table string `json:"table"`
Columns []string `json:"columns"`
// RefSchema, RefTable and RefColumns are the referenced side.
RefSchema string `json:"ref_schema"`
RefTable string `json:"ref_table"`
RefColumns []string `json:"ref_columns"`
// OnDelete is the referential action: "NO ACTION", "RESTRICT",
// "CASCADE", "SET NULL" or "SET DEFAULT".
OnDelete string `json:"on_delete"`
}
ForeignKey is one foreign-key constraint, the same value in the referencing table's ForeignKeys and the referenced table's ReferencedBy. A composite key is one ForeignKey: Columns[i] references RefColumns[i].
type Op ¶
type Op string
Op is a filter operator.
const ( OpEq Op = "eq" OpNeq Op = "neq" // SQL <>: a NULL is neither equal nor unequal, so it never matches OpLt Op = "lt" OpLte Op = "lte" OpGt Op = "gt" OpGte Op = "gte" // OpLike and OpIlike match the column's text form against a LIKE // pattern (% and _ wildcards, backslash escapes). OpLike Op = "like" OpIlike Op = "ilike" OpIsNull Op = "is_null" OpNotNull Op = "not_null" // OpIn matches any of 1..MaxInValues values. OpIn Op = "in" )
type Page ¶
type Page struct {
// Columns and Rows are dbquery's: each row has one cell per column, a
// nil, bool, json.Number, string or json.RawMessage (see
// [dbquery.Result]).
Columns []dbquery.Column `json:"columns"`
Rows [][]any `json:"rows"`
// HasMore reports whether rows follow this page: the extra row the page
// statement fetched came back, or the byte cap cut the page short.
HasMore bool `json:"has_more"`
// Total is the table's size under the request's filters, -1 with
// CountNone or when the estimate is unknown. TotalIsEstimate marks a
// planner estimate (CountEstimated); TotalAtLeast marks an exact count
// that stopped at ExactCountCap, so the size is at least Total.
Total int64 `json:"total"`
TotalIsEstimate bool `json:"total_is_estimate,omitempty"`
TotalAtLeast bool `json:"total_at_least,omitempty"`
// Truncated is true when dbquery's byte cap, not the page size, ended the
// page; CellsTruncated counts the page's cells cut to
// dbquery.MaxCellBytes, and TruncatedCells says which: nil when none was,
// otherwise one entry per row, aligned with Rows, listing the 0-based
// column indexes of that row's cut cells (see
// [dbquery.Result.TruncatedCells]).
Truncated bool `json:"truncated,omitempty"`
CellsTruncated int `json:"cells_truncated,omitempty"`
TruncatedCells [][]int `json:"truncated_cells,omitempty"`
DurationMS int64 `json:"duration_ms"`
}
Page is one page of a table.
func Run ¶
func Run(ctx context.Context, db dbquery.Beginner, c *Compiled, opts dbquery.Options) (*Page, error)
Run executes c through dbquery.Run: each statement in a READ ONLY transaction under opts' timeouts and byte cap, past dbquery's guard, and reported to opts.Auditor. A page is one statement and a count is a second one, so an Auditor sees one Event per statement; Event.SQL tells them apart.
opts.MaxRows is ignored: the page statement's row cap is c.Limit+1, and a count returns one row. A statement's failure is a *dbquery.Error.
type Request ¶
type Request struct {
// Table is schema-qualified, as [Table.QualifiedName] prints it.
Table string `json:"table"`
// Columns to return, in order; empty means every readable column.
Columns []string `json:"columns,omitempty"`
Filters []Filter `json:"filters,omitempty"`
// Sort keys, most significant first. The primary key follows them as a
// tiebreaker (for a table without one, every sortable column does), so a
// page boundary is stable; with no Sort, the primary key ascending is
// the order.
Sort []Sort `json:"sort,omitempty"`
// Limit is the page size: 0 means DefaultLimit, above MaxLimit is
// refused. Offset is how many rows to skip.
Limit int `json:"limit,omitempty"`
Offset int `json:"offset,omitempty"`
// Count is CountNone when empty.
Count CountMode `json:"count,omitempty"`
}
Request is one page of one table. Filters are ANDed.
type Table ¶
type Table struct {
Schema string `json:"schema"`
Name string `json:"name"`
Kind TableKind `json:"kind"`
Comment string `json:"comment,omitempty"`
// Columns are the readable columns, in ordinal order. A column the role
// may not SELECT (a forge:secret column) is absent.
Columns []Column `json:"columns"`
// PrimaryKey names the key's columns in key order, nil when the table has
// none (always, for a view) or when the role may not read all of them.
PrimaryKey []string `json:"primary_key,omitempty"`
// ForeignKeys are this table's references to other tables; ReferencedBy
// are the references other tables make to this one (a row page's
// "Related" section). Each is listed only when the role can follow it:
// both tables are in the description and every column on both sides is
// readable.
ForeignKeys []ForeignKey `json:"foreign_keys,omitempty"`
ReferencedBy []ForeignKey `json:"referenced_by,omitempty"`
// EstimatedRows is the planner's row estimate (pg_class.reltuples), -1
// when the server has none: a table never vacuumed or analyzed, a view,
// a partitioned parent.
EstimatedRows int64 `json:"estimated_rows"`
// contains filtered or unexported fields
}
Table is one relation the role can read at least one column of. Partitions are not listed; their partitioned parent is.
func (*Table) QualifiedName ¶
QualifiedName is schema.name, unquoted: the form a Request names a table by.