crud

package
v1.8.0 Latest Latest
Warning

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

Go to latest
Published: Sep 18, 2026 License: MPL-2.0 Imports: 14 Imported by: 0

Documentation

Overview

Package crud implements helpers around CRUD operations.

Index

Examples

Constants

View Source
const (
	// JSONProblemMessageIDInvalid is [jsonproblem.Detail.Title] when id is invalid.
	JSONProblemMessageIDInvalid = "provided id is not valid"
)

Variables

View Source
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())
}

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) GetID

func (e *EntityBase) GetID() uuid.UUID

GetID returns entity ID.

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

type GetRequest struct {
	ID uuid.UUID `json:"id"` // ID represents the ID of the entity to get.
}

GetRequest represents necessary data for BaseHandler.Get.

type Hook

type Hook[T any] interface {
	// BeforeCreate is executed before create operation.
	BeforeCreate(ctx context.Context, req CreateRequest[T]) error
	// AfterCreate is executed after create operation.
	AfterCreate(ctx context.Context, req