Documentation
¶
Overview ¶
Package crud implements helpers around CRUD operations.
Index ¶
- Constants
- Variables
- func JSONProblemFromErr(w http.ResponseWriter, err error) bool
- type BaseHandler
- func (h *BaseHandler[T]) Create(ctx context.Context, req CreateRequest[T]) (T, error)
- func (h *BaseHandler[T]) CreateFromHTTP(w http.ResponseWriter, r *http.Request)
- func (h *BaseHandler[T]) Delete(ctx context.Context, req DeleteRequest) error
- func (h *BaseHandler[T]) DeleteFromHTTP(w http.ResponseWriter, r *http.Request)
- func (h *BaseHandler[T]) DeleteSoft(ctx context.Context, req DeleteRequest) error
- func (h *BaseHandler[T]) Get(ctx context.Context, req GetRequest) (T, error)
- func (h *BaseHandler[T]) GetFromHTTP(w http.ResponseWriter, r *http.Request)
- func (h *BaseHandler[T]) List(ctx context.Context, req ListRequest) (iter.Seq2[T, error], error)
- func (h *BaseHandler[T]) ListFromHTTP(w http.ResponseWriter, r *http.Request)
- func (h *BaseHandler[T]) Update(ctx context.Context, req UpdateRequest[T]) (T, error)
- func (h *BaseHandler[T]) UpdateFromHTTP(w http.ResponseWriter, r *http.Request)
- type CompositeHook
- func (ch *CompositeHook[T]) AfterCreate(ctx context.Context, req CreateRequest[T], created T) error
- func (ch *CompositeHook[T]) AfterDelete(ctx context.Context, req DeleteRequest) error
- func (ch *CompositeHook[T]) AfterUpdate(ctx context.Context, req UpdateRequest[T], updated T) error
- func (ch *CompositeHook[T]) BeforeCreate(ctx context.Context, req CreateRequest[T]) error
- func (ch *CompositeHook[T]) BeforeDelete(ctx context.Context, req DeleteRequest) error
- func (ch *CompositeHook[T]) BeforeUpdate(ctx context.Context, req UpdateRequest[T]) error
- type CreateRequest
- type DeleteRequest
- type Entity
- type EntityBase
- type GetRequest
- type Hook
- type HookAdapter
- func (*HookAdapter[T]) AfterCreate(_ context.Context, _ CreateRequest[T], _ T) error
- func (*HookAdapter[T]) AfterDelete(_ context.Context, _ DeleteRequest) error
- func (*HookAdapter[T]) AfterUpdate(_ context.Context, _ UpdateRequest[T], _ T) error
- func (*HookAdapter[T]) BeforeCreate(_ context.Context, _ CreateRequest[T]) error
- func (*HookAdapter[T]) BeforeDelete(_ context.Context, _ DeleteRequest) error
- func (*HookAdapter[T]) BeforeUpdate(_ context.Context, _ UpdateRequest[T]) error
- type ListRequest
- type Option
- type QueryOptions
- type Repository
- type UpdateRequest
- type Validator
- type ValidatorFunc
Examples ¶
Constants ¶
const (
// JSONProblemMessageIDInvalid is [jsonproblem.Detail.Title] when id is invalid.
JSONProblemMessageIDInvalid = "provided id is not valid"
)
Variables ¶
var ( // ErrInputRequired reports that required input is not provided. ErrInputRequired = errors.New("required input is not provided") // ErrValidationFailed reports that a validation failed. ErrValidationFailed = errors.New("validation failed") // ErrHookFailed reports that a hook failed. ErrHookFailed = errors.New("hook failed") // ErrNotFound reports that the requested entity is not found. ErrNotFound = errors.New("entity not found") // ErrConstraintViolation reports that the requested entity results in a constraint violation. ErrConstraintViolation = errors.New("entity constraint violation") // ErrInvalidQuery reports that the error is due to invvalid query. ErrInvalidQuery = errors.New("invvalid query") // ErrIsCallerSide reports that the error is due to the caller, not the server. ErrIsCallerSide = errors.New("client side error") )
Functions ¶
func JSONProblemFromErr ¶
func JSONProblemFromErr(w http.ResponseWriter, err error) bool
JSONProblemFromErr writes the appropriate problem response for errors defined in this package, and returns whether the error is server-side.
Types ¶
type BaseHandler ¶
type BaseHandler[T any] struct { // contains filtered or unexported fields }
BaseHandler represents an entity handler.
func NewBaseHandler ¶
func NewBaseHandler[T any]( repo Repository[T], opts ...Option[T], ) *BaseHandler[T]
NewBaseHandler returns a new BaseHandler instance.
Example ¶
package main
import (
"context"
"fmt"
"iter"
"log/slog"
"net/http"
"strings"
"uuid"
"codeberg.org/kema/kmicro/pkg/client/database/dbpostgresql"
"codeberg.org/kema/kmicro/pkg/crud"
"codeberg.org/kema/kmicro/pkg/crudpg"
"codeberg.org/kema/kmicro/pkg/http/router"
"github.com/jackc/pgx/v5/pgxpool"
)
// User represents the user entity.
type User struct {
crud.EntityBase
Email string `db:"email" json:"email"` // Email represents the user's email
}
// UserHandler handles operations for users.
type UserHandler struct {
*crud.BaseHandler[User]
}
// UserRepository implements [crud.Repository] for [User].
type UserRepository struct {
db *pgxpool.Pool
}
// NewUserRepository returns a new [UserRepository] instance from db. For demo purposes, it also initializes (and resets) database extension, table and indexes,
// but you must run things like this in database's CRD & migration scripts in real projects.
func NewUserRepository(ctx context.Context, db *pgxpool.Pool) crud.Repository[User] {
_, err := db.Exec(ctx, `
DROP TABLE IF EXISTS "users";
DROP INDEX IF EXISTS "idx_users_active";
CREATE TABLE users (
id uuid PRIMARY KEY DEFAULT uuidv7(),
email text NOT NULL UNIQUE,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz
);
CREATE INDEX idx_users_active ON users (id)
WHERE deleted_at IS NULL;
CREATE INDEX idx_users_soft_deleted ON users (id)
WHERE deleted_at IS NOT NULL;
`)
if err != nil {
panic("initializing user demo hack: " + err.Error())
}
return UserRepository{db: db}
}
// NewUserHandler returns a new [UserHandler] instance from repo.
func NewUserHandler(ctx context.Context, db *pgxpool.Pool) UserHandler {
repo := NewUserRepository(ctx, db)
return UserHandler{
BaseHandler: crud.NewBaseHandler(repo),
}
}
// Create implements [crud.Repository].
func (r UserRepository) Create(ctx context.Context, entity User) (User, error) {
q := `INSERT INTO users (email) VALUES ($1) RETURNING *`
return crudpg.QueryOne[User](ctx, r.db, q, entity.Email)
}
// Get implements [crud.Repository].
func (r UserRepository) Get(ctx context.Context, id uuid.UUID) (User, error) {
q := `SELECT * FROM users WHERE id = $1 AND deleted_at IS NULL`
return crudpg.QueryOne[User](ctx, r.db, q, id)
}
// Update implements [crud.Repository].
func (r UserRepository) Update(ctx context.Context, id uuid.UUID, entity User) (User, error) {
q := `UPDATE users
SET email = $1, updated_at = now()
WHERE id = $2 AND deleted_at IS NULL
RETURNING *`
return crudpg.QueryOne[User](ctx, r.db, q, entity.Email, id)
}
// Delete implements [crud.Repository].
func (r UserRepository) Delete(ctx context.Context, id uuid.UUID) error {
q := `DELETE FROM users WHERE id = $1`
return crudpg.Exec(ctx, r.db, q, id)
}
// SoftDelete implements [crud.Repository].
func (r UserRepository) SoftDelete(ctx context.Context, id uuid.UUID) error {
q := `UPDATE users SET deleted_at = now() WHERE id = $1 AND deleted_at IS NULL`
return crudpg.Exec(ctx, r.db, q, id)
}
// List implements [crud.Repository].
func (r UserRepository) List(ctx context.Context, opts crud.QueryOptions) (iter.Seq2[User, error], error) {
allowedColumns := map[string]struct{}{
"id": {},
"email": {},
"created_at": {},
"updated_at": {},
"deleted_at": {},
}
var b strings.Builder
args := make([]any, 0, 2)
idx := 1
b.WriteString(`SELECT * FROM users`)
hasWhere := false
where := func(clause string) {
if !hasWhere {
b.WriteString(` WHERE `)
hasWhere = true
} else {
b.WriteString(` AND `)
}
b.WriteString(clause)
}
if !opts.IncludeSoftDeleted {
where(`deleted_at IS NULL`)
}
if opts.Cursor != nil {
where(fmt.Sprintf(`id > $%d`, idx))
args = append(args, opts.Cursor)
idx++
}
for col, val := range opts.Filter {
if _, ok := allowedColumns[col]; !ok {
return nil, fmt.Errorf("list users: unknown filter column %q: %w: %w", col, crud.ErrInvalidQuery, crud.ErrIsCallerSide)
}
where(fmt.Sprintf(`%s = $%d`, col, idx))
args = append(args, val)
idx++
}
if len(opts.Sort) > 0 {
b.WriteString(` ORDER BY `)
for i, s := range opts.Sort {
if i > 0 {
b.WriteString(`, `)
}
col, dir, err := parseSortField(s)
if err != nil {
return nil, fmt.Errorf("preparing query: %w", err)
}
if _, ok := allowedColumns[col]; !ok {
return nil, fmt.Errorf("preparing query: unknown sort column %q: %w: %w", col, crud.ErrInvalidQuery, crud.ErrIsCallerSide)
}
fmt.Fprintf(&b, `%s %s`, col, dir)
}
}
if opts.Limit > 0 {
fmt.Fprintf(&b, ` LIMIT $%d`, idx)
args = append(args, opts.Limit)
}
return crudpg.QueryIter[User](ctx, r.db, b.String(), args...)
}
func parseSortField(s string) (string, string, error) {
if len(s) < 2 {
return "", "", fmt.Errorf("invalid sort field %q: must be prefixed with + or -: %w: %w", s, crud.ErrInvalidQuery, crud.ErrIsCallerSide)
}
switch s[0] {
case '+':
return s[1:], "ASC", nil
case '-':
return s[1:], "DESC", nil
default:
return "", "", fmt.Errorf("invalid sort field %q: must be prefixed with + or -: %w: %w", s, crud.ErrInvalidQuery, crud.ErrIsCallerSide)
}
}
func main() {
ctx := context.Background()
db, shutdown, err := dbpostgresql.New(ctx,
dbpostgresql.WithHost("localhost"),
dbpostgresql.WithUser("hello"),
dbpostgresql.WithPasswordFilePath("/mnt/world"),
dbpostgresql.WithDatabase("example"),
)
if err != nil {
slog.Error("creating postgresql database client", slog.String("error.message", err.Error()))
return
}
defer shutdown(context.Background())
r := router.New()
u := NewUserHandler(ctx, db)
// curl -X POST localhost:8080/users --data '{"email":"hello@world.net"}'
r.HandleFunc("POST /users", u.CreateFromHTTP)
// curl localhost:8080/users/00000000-0000-0000-0000-000000000000
r.HandleFunc("GET /users/{id}", u.GetFromHTTP)
// curl localhost:8080/users/
r.HandleFunc("GET /users", u.ListFromHTTP)
// curl -X PATCH localhost:8080/users/00000000-0000-0000-0000-000000000000 --data '{"email":"hello@world.com"}'
r.HandleFunc("PATCH /users/{id}", u.UpdateFromHTTP)
// curl -X DELETE localhost:8080/users/00000000-0000-0000-0000-000000000000
r.HandleFunc("DELETE /users/{id}", u.DeleteFromHTTP)
_ = http.ListenAndServe("[::]:8080", r.Compile())
}
Output:
func (*BaseHandler[T]) Create ¶
func (h *BaseHandler[T]) Create(ctx context.Context, req CreateRequest[T]) (T, error)
Create creates an entity and returns it.
func (*BaseHandler[T]) CreateFromHTTP ¶
func (h *BaseHandler[T]) CreateFromHTTP(w http.ResponseWriter, r *http.Request)
CreateFromHTTP creates an entity from its JSON definition in request body.
func (*BaseHandler[T]) Delete ¶
func (h *BaseHandler[T]) Delete(ctx context.Context, req DeleteRequest) error
Delete deletes an entity.
func (*BaseHandler[T]) DeleteFromHTTP ¶
func (h *BaseHandler[T]) DeleteFromHTTP(w http.ResponseWriter, r *http.Request)
DeleteFromHTTP deletes an entity from its id path value.
func (*BaseHandler[T]) DeleteSoft ¶
func (h *BaseHandler[T]) DeleteSoft(ctx context.Context, req DeleteRequest) error
DeleteSoft soft deletes an entity.
func (*BaseHandler[T]) Get ¶
func (h *BaseHandler[T]) Get(ctx context.Context, req GetRequest) (T, error)
Get gets an entity and returns it.
func (*BaseHandler[T]) GetFromHTTP ¶
func (h *BaseHandler[T]) GetFromHTTP(w http.ResponseWriter, r *http.Request)
GetFromHTTP gets an entity from its id path value.
func (*BaseHandler[T]) List ¶
func (h *BaseHandler[T]) List(ctx context.Context, req ListRequest) (iter.Seq2[T, error], error)
List lists entities and returns a lazy iterator over the page.
func (*BaseHandler[T]) ListFromHTTP ¶
func (h *BaseHandler[T]) ListFromHTTP(w http.ResponseWriter, r *http.Request)
ListFromHTTP lists entities from query-param filters. Filters and sorts are currently not implemented and are no-ops.
Query params:
cursor=<uuid> optional; omit to start from the beginning limit=<int> optional; maximum entities returned soft=<bool> optional; include soft deleted entities
func (*BaseHandler[T]) Update ¶
func (h *BaseHandler[T]) Update(ctx context.Context, req UpdateRequest[T]) (T, error)
Update updates an entity and returns the old and updated entities.
func (*BaseHandler[T]) UpdateFromHTTP ¶
func (h *BaseHandler[T]) UpdateFromHTTP(w http.ResponseWriter, r *http.Request)
UpdateFromHTTP updates an entity from its id path value.
type CompositeHook ¶
type CompositeHook[T any] struct { // contains filtered or unexported fields }
CompositeHook represents an implementation that runs chained Hook.
func NewCompositeHook ¶
func NewCompositeHook[T any](hooks ...Hook[T]) *CompositeHook[T]
NewCompositeHook returns a new CompositeHook instance.
func (*CompositeHook[T]) AfterCreate ¶
func (ch *CompositeHook[T]) AfterCreate(ctx context.Context, req CreateRequest[T], created T) error
AfterCreate calls all AfterCreate hooks in sequence.
func (*CompositeHook[T]) AfterDelete ¶
func (ch *CompositeHook[T]) AfterDelete(ctx context.Context, req DeleteRequest) error
AfterDelete calls all AfterDelete hooks in sequence.
func (*CompositeHook[T]) AfterUpdate ¶
func (ch *CompositeHook[T]) AfterUpdate(ctx context.Context, req UpdateRequest[T], updated T) error
AfterUpdate calls all AfterUpdate hooks in sequence.
func (*CompositeHook[T]) BeforeCreate ¶
func (ch *CompositeHook[T]) BeforeCreate(ctx context.Context, req CreateRequest[T]) error
BeforeCreate calls all BeforeCreate hooks in sequence.
func (*CompositeHook[T]) BeforeDelete ¶
func (ch *CompositeHook[T]) BeforeDelete(ctx context.Context, req DeleteRequest) error
BeforeDelete calls all BeforeDelete hooks in sequence.
func (*CompositeHook[T]) BeforeUpdate ¶
func (ch *CompositeHook[T]) BeforeUpdate(ctx context.Context, req UpdateRequest[T]) error
BeforeUpdate calls all BeforeUpdate hooks in sequence.
type CreateRequest ¶
type CreateRequest[T any] struct { Entity T `json:"entity"` // Entity represent the entity to be created. }
CreateRequest represents necessary data for BaseHandler.Create.
type DeleteRequest ¶
type DeleteRequest struct {
ID uuid.UUID `json:"id"` // ID represents the ID of the entity to delete.
}
DeleteRequest represents necessary data for BaseHandler.Delete.
type Entity ¶
type Entity interface {
// GetID returns entity ID.
GetID() uuid.UUID
// GetCreatedAt returns creation timestamp.
GetCreatedAt() time.Time
// GetUpdatedAt returns last update timestamp.
GetUpdatedAt() time.Time
// GetDeletedAt returns soft delete timestamp.
GetDeletedAt() time.Time
// SetUpdatedAt sets updated timestamp.
SetUpdatedAt(t time.Time)
// SetDeletedAt sets deleted timestamp for soft deletes.
SetDeletedAt(t time.Time)
}
Entity represents the data access interface for entities bases.
type EntityBase ¶
type EntityBase struct {
ID uuid.UUID `db:"id" json:"id"` // ID represents the unique identifier of the entity.
CreatedAt time.Time `db:"created_at" json:"created_at"` // CreatedAt represents the creation time of the entity.
UpdatedAt time.Time `db:"updated_at" json:"updated_at"` // UpdatedAt represents the last update time of the entity.
DeletedAt *time.Time `db:"deleted_at" json:"deleted_at,omitzero"` // DeletedAt represents the soft deletion time of the entity
}
EntityBase represents common fields for all CRUD entities.
func (*EntityBase) GetCreatedAt ¶
func (e *EntityBase) GetCreatedAt() time.Time
GetCreatedAt returns creation timestamp.
func (*EntityBase) GetDeletedAt ¶
func (e *EntityBase) GetDeletedAt() time.Time
GetDeletedAt returns soft delete timestamp.
func (*EntityBase) GetUpdatedAt ¶
func (e *EntityBase) GetUpdatedAt() time.Time
GetUpdatedAt returns last update timestamp.
func (*EntityBase) SetDeletedAt ¶
func (e *EntityBase) SetDeletedAt(t time.Time)
SetDeletedAt sets deleted timestamp for soft deletes.
func (*EntityBase) SetUpdatedAt ¶
func (e *EntityBase) SetUpdatedAt(t time.Time)
SetUpdatedAt sets updated timestamp.
type GetRequest ¶
GetRequest represents necessary data for BaseHandler.Get.