ejected

package
v0.6.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 1, 2026 License: MIT Imports: 13 Imported by: 0

README

ejected — ejected from a sqlb schema

This package was written by sqlb eject. It imports pgx and the standard library, and nothing else — no sqlb, no huma, no router. Deleting sqlb from go.mod after taking this is a supported end state, which is the entire point of the command.

Nothing here is precious. Edit it, delete the parts you do not serve, or keep running sqlb eject -check in CI for as long as you want the exit kept current — and drop that gate on the day you stop.

What is here

File What it is
schema.sql The whole schema as DDL. The same statements sqlb migrate would write for a first migration.
models.go The row structs, with the sqlb tags removed.
store.go One function per statement. The SQL is written out.
support.go Query-string parsing, WHERE assembly, JSON writing. The only file that is the same in every project.
handlers.go net/http handlers, one per exposed operation.

The endpoints

Method Path Handler
GET /authors ListAuthor
GET /authors/{id} GetAuthor
POST /authors InsertAuthor
PATCH /authors/{id} UpdateAuthor
DELETE /authors/{id} DeleteAuthor
GET /orgs ListOrg
GET /orgs/{id} GetOrg
GET /posts ListPost
GET /posts/{id} GetPost
POST /posts InsertPost
PATCH /posts/{id} UpdatePost

What came out whole

  • CRUD and list, at the same paths, with the same status codes and the same JSON envelope — items, page, per_page, has_more, and total when ?count=exact was asked for.
  • The filter operators that are one SQL fragment each: eq, ne, lt, lte, gt, gte, in, nin, isnull, notnull, between, like, ilike, contains, startswith, endswith — and the bare ?column=value shorthand for equality.
  • ?sort, ?search and ?page/?per_page, with the ceilings the schema declared.
  • Capabilities as refusals. A column that never declared Filterable cannot be filtered here either, and the rejection lists the ones that can be. That is a security property, not a convenience: a column left out of the grammar cannot be probed through it. Hidden columns are absent from the column table entirely.
  • The error shape. RFC 9457 problem documents, with the allowed list on each detail, so a client's error handling does not change.
  • The constraint mapping. A duplicate unique value is still a 409, and a foreign-key, check or not-null violation still a 422, classified off SQLSTATE class 23 exactly as before — so a retry loop keyed on 409 keeps working. The detail text is generic where the API named the resource; the status, which is what clients branch on, is identical.
  • The request budgets. MaxFilters and MaxSortTerms come from the schema; the list cap (100 values in one in/nin) and the value-length cap (256 bytes) are constants at the top of support.go, edit them there. ?search escapes % and _ in the term, so a search for a literal percent sign still matches literally.
  • The obligation. A table that declared Scoped or SoftDelete refuses to register without a Confine hook, and a scoped table with a create endpoint refuses without an Assign hook. Startup errors, exactly as before.

What did not come out, by name

  • Keyset pagination (?cursor). Offset paging is here; the cursor is not. ?cursor is refused with a message saying so rather than ignored.
  • Sparse projections (?select). Every read returns the full row.
  • Relation expansion (?expand). One statement that joined a target and built a JSON object for it was the engine, not the surface. Fetch the related row from its own endpoint.
  • The JSON filter tree (?filter=). Arbitrary and/or/not nesting is gone; the query-parameter operators are not. Negation survives only where an operator spells it — ne, nin, notnull — so a filter that leaned on a not group has to be restated as one of those or moved into the handler.
  • Array and document operators (has, hasany, hasall, hasdoc, and their negations nhas, nhasany, nhasall, nhasdoc). The columns are still there and still returned; the containment operators are not.
  • The OpenAPI document, and with it the generated TypeScript, Dart and CLI clients. They were emitted from the schema, and the schema is what you are leaving. The wire format they speak is unchanged, so a committed client keeps working — it just has no generator behind it any more.
  • Hooks other than the two seams above. BeforeCreate, AfterUpdate and the rest were registrations on a runtime that is no longer here; the handler is a function, so the code that ran in a hook goes in it.
  • Transactions across handlers. Each handler runs one statement. DB is an interface a pgx.Tx satisfies, so wrapping is yours to arrange.
  • Type overrides. The models use the default type mapping; a column that had a Types override in the generator has its default Go type here. Enums are plain strings, and the CHECK constraint in schema.sql is what still enforces the value set.

Notes for this schema

  • authors expanded org through ?expand. The foreign key is still returned; the joined row is not.
  • posts expanded author through ?expand. The foreign key is still returned; the joined row is not.

Wiring it up

mux := http.NewServeMux()
pool, err := pgxpool.New(ctx, dsn)
if err != nil {
	log.Fatal(err)
}
if err := ejected.Register(mux, pool, ejected.Options{
	Posts: ejected.PostsHooks{
		// Required: deleted_at declares a soft delete.
		Confine: func(r *http.Request) ([]ejected.Condition, error) {
			return []ejected.Condition{
				{Column: "deleted_at", Op: ejected.OpIsNull},
			}, nil
		},
	},
}); err != nil {
	// A resource whose schema declared Scoped or SoftDelete fails here rather
	// than serving unconfined rows.
	log.Fatal(err)
}
log.Fatal(http.ListenAndServe(":8080", mux))

Documentation

Index

Constants

View Source
const (
	OpEq      = "="
	OpNe      = "<>"
	OpLt      = "<"
	OpLte     = "<="
	OpGt      = ">"
	OpGte     = ">="
	OpIn      = "IN"
	OpNotIn   = "NOT IN"
	OpIsNull  = "IS NULL"
	OpNotNull = "IS NOT NULL"
	OpLike    = "LIKE"
	OpILike   = "ILIKE"
	OpBetween = "BETWEEN"
)

The operators, as the SQL each one becomes.

Variables

View Source
var (
	// ErrNotFound is the id that matched nothing the request was allowed to
	// see. Handlers turn it into a 404 without saying which of the two it was.
	ErrNotFound = errors.New("not found")
	// ErrNoChanges is a PATCH that named no column.
	ErrNoChanges = errors.New("no columns to change")
)
View Source
var Schema string

Schema is schema.sql, embedded so that a test or a bootstrap can apply the whole schema without knowing where the file ended up.

Functions

func CountAuthor

func CountAuthor(ctx context.Context, db DB, where []Condition) (int64, error)

CountAuthor is ?count=exact: the size of the matching set, which costs a second query over the same predicate.

func CountOrg

func CountOrg(ctx context.Context, db DB, where []Condition) (int64, error)

CountOrg is ?count=exact: the size of the matching set, which costs a second query over the same predicate.

func CountPost

func CountPost(ctx context.Context, db DB, where []Condition) (int64, error)

CountPost is ?count=exact: the size of the matching set, which costs a second query over the same predicate.

func DeleteAuthor

func DeleteAuthor(ctx context.Context, db DB, id any, where []Condition) error

DeleteAuthor removes one row, and reports ErrNotFound rather than success when the id matched nothing the conditions admit.

func DeleteOrg

func DeleteOrg(ctx context.Context, db DB, id any, where []Condition) error

DeleteOrg removes one row, and reports ErrNotFound rather than success when the id matched nothing the conditions admit.

func DeletePost

func DeletePost(ctx context.Context, db DB, id any, where []Condition) error

DeletePost removes one row, and reports ErrNotFound rather than success when the id matched nothing the conditions admit.

func ParseBool

func ParseBool(s string) (any, error)

func ParseFloat

func ParseFloat(s string) (any, error)

func ParseInt

func ParseInt(s string) (any, error)

func ParseText

func ParseText(s string) (any, error)

func ParseTime

func ParseTime(s string) (any, error)

ParseTime accepts RFC 3339 and a bare date, which are the two spellings a client sends for a timestamp and a date column.

func Register

func Register(mux *http.ServeMux, db DB, opts Options) error

Register mounts every resource the schema exposed.

It returns an error rather than panicking, and it returns one for a missing obligation before it registers anything: a resource that declared a tenant column and has nothing to confine it with would serve every tenant's rows with a 200 next to them, and that is the failure this check exists for.

func WriteJSON

func WriteJSON(w http.ResponseWriter, status int, body any)

WriteJSON writes a success response.

func WriteProblem

func WriteProblem(w http.ResponseWriter, err error)

WriteProblem writes an error response. Anything that is not already a Problem, and is not an integrity violation the database named, is a 500 whose detail is not the caller's business.

Types

type Author

type Author struct {
	ID           string    `db:"id" json:"id"`
	OrgID        string    `db:"org_id" json:"org_id"`
	Email        string    `db:"email" json:"email"`
	Name         string    `db:"name" json:"name"`
	PasswordHash string    `db:"password_hash" json:"-"`
	CreatedAt    time.Time `db:"created_at" json:"created_at"`
	UpdatedAt    time.Time `db:"updated_at" json:"updated_at"`
}

Author is a row of authors.

func GetAuthor

func GetAuthor(ctx context.Context, db DB, id any, where []Condition) (Author, error)

GetAuthor reads one row by primary key. The extra conditions are whatever confines this table — a tenant, a soft delete — and they are part of the lookup rather than a check afterwards, so a row outside them is a 404 and not a 403 that confirms it exists.

func InsertAuthor

func InsertAuthor(ctx context.Context, db DB, values map[string]any) (Author, error)

InsertAuthor writes one row and reads it back, so database defaults and computed columns arrive without a second query.

The column order is the schema's, not the map's: a generated statement whose text depends on map iteration is a statement that cannot be diffed.

func ListAuthor

func ListAuthor(ctx context.Context, db DB, q Query) ([]Author, error)

ListAuthor reads a page. It returns one row more than asked for when there is one, which is how the handler answers has_more without a second count.

func UpdateAuthor

func UpdateAuthor(ctx context.Context, db DB, id any, changes map[string]any, where []Condition) (Author, error)

UpdateAuthor writes the named columns of one row and reads the row back. An empty change set is the caller's mistake rather than a statement with no SET clause, which Postgres will not parse.

type AuthorsHooks

type AuthorsHooks struct {
	// Confine narrows every statement this resource issues. Nil means
	// unconfined, which is only allowed when the schema declared nothing.
	Confine func(*http.Request) ([]Condition, error)
	// Assign supplies column values a create must set that no request body
	// carries. It runs before the insert and its values win.
	Assign func(*http.Request) (map[string]any, error)
}

AuthorsHooks are the seams for /authors.

type Column

type Column struct {
	Name       string
	Filterable bool
	Sortable   bool
	Searchable bool
	// Parse turns a query-string value into something pgx can bind.
	Parse func(string) (any, error)
}

Column is what a request may name, and for what. The capabilities are the ones the schema declared: a column that never opted into filtering is not filterable here either, and the rejection says which columns are.

type Condition

type Condition struct {
	Column string
	Op     string
	Value  any
	Value2 any
	Values []any
	Or     []Condition
}

Condition is one predicate. Or holds a disjunction — ?search fans out over the searchable columns and is the only thing that produces one.

type DB

type DB interface {
	Query(ctx context.Context, sql string, args ...any) (pgx.Rows, error)
	QueryRow(ctx context.Context, sql string, args ...any) pgx.Row
	Exec(ctx context.Context, sql string, args ...any) (pgconn.CommandTag, error)
}

DB is what the statements need: a pgx pool, a connection or a transaction, all three of which satisfy it. Narrow on purpose — a handler that takes this cannot begin a transaction it was not given, and a test can hand it whatever it likes.

type Limits

type Limits struct {
	DefaultPageSize int
	MaxPageSize     int
	MaxFilters      int
	MaxSortTerms    int
}

Limits are the resource's declared ceilings, emitted from the schema so the exit refuses the same oversized requests the API did.

type ListRequest

type ListRequest struct {
	Query   Query
	Page    int
	PerPage int
	Count   bool
}

ListRequest is a parsed list query.

func ParseList

func ParseList(values url.Values, cols []Column, lim Limits) (ListRequest, error)

ParseList turns a query string into a Query, refusing what the resource never offered and what the exit does not carry.

type Options

type Options struct {
	Authors AuthorsHooks
	Orgs    OrgsHooks
	Posts   PostsHooks
}

Options carries the seams the handlers cannot supply for themselves.

In sqlb these were hooks on a registry; here they are function fields, which is the same seam with the machinery removed. A resource whose table declared Scoped or SoftDelete will not register until they are set — see Register.

type Order

type Order struct {
	Column string
	Desc   bool
}

Order is one ORDER BY term.

type Org

type Org struct {
	ID        string    `db:"id" json:"id"`
	Name      string    `db:"name" json:"name"`
	Slug      string    `db:"slug" json:"slug"`
	CreatedAt time.Time `db:"created_at" json:"created_at"`
	UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
}

Org a tenant. Every other table is scoped to one.

func GetOrg

func GetOrg(ctx context.Context, db DB, id any, where []Condition) (Org, error)

GetOrg reads one row by primary key. The extra conditions are whatever confines this table — a tenant, a soft delete — and they are part of the lookup rather than a check afterwards, so a row outside them is a 404 and not a 403 that confirms it exists.

func InsertOrg

func InsertOrg(ctx context.Context, db DB, values map[string]any) (Org, error)

InsertOrg writes one row and reads it back, so database defaults and computed columns arrive without a second query.

The column order is the schema's, not the map's: a generated statement whose text depends on map iteration is a statement that cannot be diffed.

func ListOrg

func ListOrg(ctx context.Context, db DB, q Query) ([]Org, error)

ListOrg reads a page. It returns one row more than asked for when there is one, which is how the handler answers has_more without a second count.

func UpdateOrg

func UpdateOrg(ctx context.Context, db DB, id any, changes map[string]any, where []Condition) (Org, error)

UpdateOrg writes the named columns of one row and reads the row back. An empty change set is the caller's mistake rather than a statement with no SET clause, which Postgres will not parse.

type OrgsHooks

type OrgsHooks struct {
	// Confine narrows every statement this resource issues. Nil means
	// unconfined, which is only allowed when the schema declared nothing.
	Confine func(*http.Request) ([]Condition, error)
	// Assign supplies column values a create must set that no request body
	// carries. It runs before the insert and its values win.
	Assign func(*http.Request) (map[string]any, error)
}

OrgsHooks are the seams for /orgs.

type Page

type Page[T any] struct {
	Items   []T    `json:"items"`
	Page    int    `json:"page"`
	PerPage int    `json:"per_page"`
	HasMore bool   `json:"has_more"`
	Total   *int64 `json:"total,omitempty"`
}

Page is the body of a list response, and is the envelope sqlb served, minus next_cursor: keyset paging did not come out with the rest, so offering the field would be a promise this code cannot keep.

type Post

type Post struct {
	ID          string     `db:"id" json:"id"`
	OrgID       string     `db:"org_id" json:"org_id"`
	AuthorID    string     `db:"author_id" json:"author_id"`
	Title       string     `db:"title" json:"title"`
	Body        string     `db:"body" json:"body"`
	Status      string     `db:"status" json:"status"`
	ViewCount   int64      `db:"view_count" json:"view_count"`
	PublishedAt *time.Time `db:"published_at" json:"published_at"`
	CreatedAt   time.Time  `db:"created_at" json:"created_at"`
	UpdatedAt   time.Time  `db:"updated_at" json:"updated_at"`
	DeletedAt   *time.Time `db:"deleted_at" json:"deleted_at"`
}

Post a blog post.

func GetPost

func GetPost(ctx context.Context, db DB, id any, where []Condition) (Post, error)

GetPost reads one row by primary key. The extra conditions are whatever confines this table — a tenant, a soft delete — and they are part of the lookup rather than a check afterwards, so a row outside them is a 404 and not a 403 that confirms it exists.

func InsertPost

func InsertPost(ctx context.Context, db DB, values map[string]any) (Post, error)

InsertPost writes one row and reads it back, so database defaults and computed columns arrive without a second query.

The column order is the schema's, not the map's: a generated statement whose text depends on map iteration is a statement that cannot be diffed.

func ListPost

func ListPost(ctx context.Context, db DB, q Query) ([]Post, error)

ListPost reads a page. It returns one row more than asked for when there is one, which is how the handler answers has_more without a second count.

func UpdatePost

func UpdatePost(ctx context.Context, db DB, id any, changes map[string]any, where []Condition) (Post, error)

UpdatePost writes the named columns of one row and reads the row back. An empty change set is the caller's mistake rather than a statement with no SET clause, which Postgres will not parse.

type PostsHooks

type PostsHooks struct {
	// Confine narrows every statement this resource issues. Nil means
	// unconfined, which is only allowed when the schema declared nothing.
	Confine func(*http.Request) ([]Condition, error)
	// Assign supplies column values a create must set that no request body
	// carries. It runs before the insert and its values win.
	Assign func(*http.Request) (map[string]any, error)
}

PostsHooks are the seams for /posts.

Confine is required here (deleted_at declares a soft delete), and returns the conditions every read and write is narrowed by — the predicate a BeforeQuery hook used to add.

type Problem

type Problem struct {
	Type   string           `json:"type,omitempty"`
	Title  string           `json:"title,omitempty"`
	Status int              `json:"status,omitempty"`
	Detail string           `json:"detail,omitempty"`
	Errors []*ProblemDetail `json:"errors,omitempty"`
}

Problem is the error body, RFC 9457 shaped — the same one sqlb served, so a client's error handling does not change on the way out.

func (*Problem) Error

func (p *Problem) Error() string

type ProblemDetail

type ProblemDetail struct {
	Message  string   `json:"message"`
	Location string   `json:"location,omitempty"`
	Allowed  []string `json:"allowed,omitempty"`
}

ProblemDetail is one rejected parameter or field. Allowed carries what would have worked instead, which is the half of an error message that saves a round trip.

type Query

type Query struct {
	Where  []Condition
	Order  []Order
	Limit  int
	Offset int
}

Query is everything a read varies by.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL