Documentation
¶
Overview ¶
Package taxonomy implements the Phase-2 Hatamoto first-class taxonomy system: tags and categories as a proper many-to-many model that works across every entity schema (not just blog posts), backed by the hatamoto_terms and hatamoto_entity_terms tables introduced in migration 113.
The package is intentionally additive — existing JSONB `attributes.tags` remain valid and unchanged. New code paths (admin term picker, public /tag and /category archives) read and write through this package; the old per-entity JSONB tags still render in JSON-LD and on legacy pages. Migrating the old data into the new store is a deliberate non-goal of Phase 2 (see plans/archive/completed/hatamoto-core-incremental-plan.md).
Index ¶
- Constants
- Variables
- func ArchivePrefix(kind string) string
- func FillPaths(ctx context.Context, r Repository, terms ...*Term) error
- func Slugify(name string) string
- func ValidKind(kind string) bool
- func ValidSlug(slug string) bool
- type CreateTermInput
- type EntityRef
- type Handlers
- func (h *Handlers) CreateTerm(w http.ResponseWriter, r *http.Request)
- func (h *Handlers) DeleteTerm(w http.ResponseWriter, r *http.Request)
- func (h *Handlers) GetEntityTerms(w http.ResponseWriter, r *http.Request)
- func (h *Handlers) ListTerms(w http.ResponseWriter, r *http.Request)
- func (h *Handlers) PublicTermArchive(w http.ResponseWriter, r *http.Request)
- func (h *Handlers) ReplaceEntityTerms(w http.ResponseWriter, r *http.Request)
- func (h *Handlers) UpdateTerm(w http.ResponseWriter, r *http.Request)
- type MoveFunc
- type PathMove
- type PostgresRepository
- func (r *PostgresRepository) Chains(ctx context.Context, ids []uuid.UUID) (map[uuid.UUID][]*Term, error)
- func (r *PostgresRepository) CreateTerm(ctx context.Context, t *Term) error
- func (r *PostgresRepository) DeleteTerm(ctx context.Context, id uuid.UUID) error
- func (r *PostgresRepository) Descendants(ctx context.Context, id uuid.UUID) ([]*Term, error)
- func (r *PostgresRepository) GetTerm(ctx context.Context, id uuid.UUID) (*Term, error)
- func (r *PostgresRepository) GetTermBySlug(ctx context.Context, kind, slug string) (*Term, error)
- func (r *PostgresRepository) KindExists(ctx context.Context, kind string) (bool, error)
- func (r *PostgresRepository) ListEntitiesForTerm(ctx context.Context, termID uuid.UUID, page, perPage int) ([]*EntityRef, int, error)
- func (r *PostgresRepository) ListPublicTerms(ctx context.Context, kind string, limit int) ([]*TermWithCount, error)
- func (r *PostgresRepository) ListTerms(ctx context.Context, kind, search string, page, perPage int) (*TermListResult, error)
- func (r *PostgresRepository) ListTermsByIDs(ctx context.Context, ids []uuid.UUID) ([]*Term, error)
- func (r *PostgresRepository) ListTermsForEntity(ctx context.Context, entityID uuid.UUID) ([]*Term, error)
- func (r *PostgresRepository) MergeTerms(ctx context.Context, from, into uuid.UUID) error
- func (r *PostgresRepository) ReplaceEntityTerms(ctx context.Context, entityID uuid.UUID, termIDs []uuid.UUID) error
- func (r *PostgresRepository) UpdateTerm(ctx context.Context, t *Term) error
- type Repository
- type Service
- func (s *Service) CreateTerm(ctx context.Context, in CreateTermInput) (*Term, error)
- func (s *Service) DeleteTerm(ctx context.Context, id uuid.UUID) error
- func (s *Service) GetTerm(ctx context.Context, id uuid.UUID) (*Term, error)
- func (s *Service) GetTermBySlug(ctx context.Context, kind, slug string) (*Term, error)
- func (s *Service) ListEntitiesForTerm(ctx context.Context, kind, slug string, page, perPage int) (*TermArchiveResult, error)
- func (s *Service) ListTerms(ctx context.Context, kind, search string, page, perPage int) (*TermListResult, error)
- func (s *Service) ListTermsForEntity(ctx context.Context, entityID uuid.UUID) ([]*Term, error)
- func (s *Service) MergeTerms(ctx context.Context, from, into uuid.UUID) error
- func (s *Service) ReplaceEntityTerms(ctx context.Context, entityID uuid.UUID, termIDs []uuid.UUID) error
- func (s *Service) SetChangeObserver(f func(ctx context.Context))
- func (s *Service) SetMoveObserver(f MoveFunc)
- func (s *Service) UpdateTerm(ctx context.Context, in UpdateTermInput) (*Term, error)
- type Term
- type TermArchiveResult
- type TermListResult
- type TermWithCount
- type UpdateTermInput
Constants ¶
const ( KindTag = "tag" KindCategory = "category" )
Term kinds. "tag" and "category" are built in; any other kind is a custom taxonomy (for example "genre") that exists as soon as its first term does. There is no registry: a kind is a short lower-case word, and the CHECK constraint on hatamoto_terms.kind enforces the same format as ValidKind.
const MaxDepth = 8
MaxDepth caps how deep terms nest (a root is depth 1), so a term's public path stays a sane URL.
const MaxDescriptionLen = 2000
MaxDescriptionLen caps a term description.
const MaxKindLen = 16
MaxKindLen mirrors the kind column width.
const MaxNameLen = 128
MaxNameLen caps the human-readable name a term may carry. Mirrors the VARCHAR(128) column width.
const MaxSlugLen = 128
MaxSlugLen mirrors the slug column width.
const MaxTermsPerEntity = 64
MaxTermsPerEntity caps how many terms can be attached to a single entity in one PUT replace call. Sane upper bound — even taxonomy-heavy content rarely uses more than ~30 terms.
Variables ¶
var ( ErrMergeSameTerm = errors.New("taxonomy: cannot merge a term into itself") ErrMergeKind = errors.New("taxonomy: terms of different kinds cannot be merged") ErrMergeHasChilds = errors.New("taxonomy: the term has child terms: move or delete them first") )
Merge errors.
var ( ErrInvalidKind = errors.New("invalid_kind") ErrInvalidName = errors.New("invalid_name") ErrInvalidSlug = errors.New("invalid_slug") ErrInvalidDescription = errors.New("invalid_description") )
Validation errors of term writes. Their messages are the codes the REST API answers with.
var ErrTermNotFound = errors.New("taxonomy: term not found")
ErrTermNotFound is returned when a lookup misses.
var ErrTermParentInvalid = errors.New("taxonomy: invalid parent term")
ErrTermParentInvalid is returned for a parent of another kind, a parent cycle, or nesting deeper than MaxDepth.
var ErrTermParentNotFound = errors.New("taxonomy: parent term not found")
ErrTermParentNotFound is returned when the parent term does not exist.
var ErrTermSlugConflict = errors.New("taxonomy: term slug already exists for this kind")
ErrTermSlugConflict is returned when an insert or slug-update would violate the (kind, slug) uniqueness constraint.
Functions ¶
func ArchivePrefix ¶
Archive URL paths: the built-in kinds keep /tag/ and /category/; a custom kind lives at /{kind}/. Below the kind prefix a term sits at the slugs of its ancestors and its own, joined by '/'.
func FillPaths ¶
func FillPaths(ctx context.Context, r Repository, terms ...*Term) error
FillPaths sets Path on each term to the slugs from its root term down to itself. Terms whose chain cannot be loaded keep their own slug.
func Slugify ¶
Slugify converts a free-form name to a slug. Lowercases, replaces runs of non-alphanumerics with single hyphens, strips leading/trailing hyphens. Returns "" for inputs that contain no slug-able characters (caller decides whether to error or fall back).
Types ¶
type CreateTermInput ¶
type CreateTermInput struct {
Kind string
Name string
Slug string
Description string
ParentID *uuid.UUID
}
CreateTermInput is the create payload. Slug is optional — when empty, the service derives it from name via Slugify.
type EntityRef ¶
type EntityRef struct {
ID uuid.UUID `json:"id"`
Name string `json:"name"`
Slug string `json:"slug"`
SchemaSlug string `json:"schema_slug"`
Excerpt string `json:"excerpt,omitempty"`
ImageURL string `json:"image_url,omitempty"`
PublishedAt *time.Time `json:"published_at,omitempty"`
}
EntityRef is the lightweight entity projection returned by the public archive endpoint. We avoid leaking the full entity row — public consumers only need enough to build a card link.
type Handlers ¶
type Handlers struct {
// contains filtered or unexported fields
}
Handlers exposes the HTTP surface for the taxonomy package.
func NewHandlers ¶
NewHandlers wires Handlers around a Service.
func (*Handlers) CreateTerm ¶
func (h *Handlers) CreateTerm(w http.ResponseWriter, r *http.Request)
CreateTerm handles POST /api/v1/admin/terms.
func (*Handlers) DeleteTerm ¶
func (h *Handlers) DeleteTerm(w http.ResponseWriter, r *http.Request)
DeleteTerm handles DELETE /api/v1/admin/terms/{id}. Idempotent — a missing ID still returns 204 because cascade deletion semantics make "didn't exist" indistinguishable from "we just removed it."
func (*Handlers) GetEntityTerms ¶
func (h *Handlers) GetEntityTerms(w http.ResponseWriter, r *http.Request)
GetEntityTerms handles GET /api/v1/admin/entities/{entity_id}/terms.
func (*Handlers) ListTerms ¶
func (h *Handlers) ListTerms(w http.ResponseWriter, r *http.Request)
ListTerms handles GET /api/v1/admin/terms. Query params: kind (tag|category), search, page, per_page.
func (*Handlers) PublicTermArchive ¶
func (h *Handlers) PublicTermArchive(w http.ResponseWriter, r *http.Request)
PublicTermArchive handles GET /api/v1/public/terms/{kind}/{slug}/entities. Query params: page, per_page.
func (*Handlers) ReplaceEntityTerms ¶
func (h *Handlers) ReplaceEntityTerms(w http.ResponseWriter, r *http.Request)
ReplaceEntityTerms handles PUT /api/v1/admin/entities/{entity_id}/terms. Set-replace semantics — the supplied term_ids list is the new full set of attachments for the entity. Empty list detaches everything.
func (*Handlers) UpdateTerm ¶
func (h *Handlers) UpdateTerm(w http.ResponseWriter, r *http.Request)
UpdateTerm handles PUT /api/v1/admin/terms/{id}.
type MoveFunc ¶
MoveFunc is told which archive paths changed when an update renamed a term or gave it another parent (its descendants move with it). The server wires it to write redirects, like it does for entity moves.
type PathMove ¶
type PathMove struct{ From, To string }
PathMove is an archive path a term left and the one it has now.
type PostgresRepository ¶
type PostgresRepository struct {
// contains filtered or unexported fields
}
PostgresRepository is the pgx-backed implementation.
func NewPostgresRepository ¶
func NewPostgresRepository(db *database.DB) *PostgresRepository
NewPostgresRepository constructs a repository.
func (*PostgresRepository) Chains ¶
func (r *PostgresRepository) Chains(ctx context.Context, ids []uuid.UUID) (map[uuid.UUID][]*Term, error)
Chains implements Repository.
func (*PostgresRepository) CreateTerm ¶
func (r *PostgresRepository) CreateTerm(ctx context.Context, t *Term) error
CreateTerm inserts a hatamoto_terms row. ID is generated server-side when zero. created_at / updated_at always default to NOW().
func (*PostgresRepository) DeleteTerm ¶
DeleteTerm removes a term row. FK CASCADE on hatamoto_entity_terms removes any attachments. Idempotent — missing ID is not an error.
func (*PostgresRepository) Descendants ¶
Descendants returns every term below id (children, grandchildren, ...), without id itself. Depth is capped at MaxDepth+1, which also ends a cycle.
func (*PostgresRepository) GetTermBySlug ¶
GetTermBySlug fetches by (kind, slug).
func (*PostgresRepository) KindExists ¶
KindExists implements Repository.
func (*PostgresRepository) ListEntitiesForTerm ¶
func (r *PostgresRepository) ListEntitiesForTerm(ctx context.Context, termID uuid.UUID, page, perPage int) ([]*EntityRef, int, error)
ListEntitiesForTerm joins through the M2M and returns published entities, newest first.
func (*PostgresRepository) ListPublicTerms ¶
func (r *PostgresRepository) ListPublicTerms(ctx context.Context, kind string, limit int) ([]*TermWithCount, error)
ListPublicTerms returns the terms of a kind that have at least one published entity, most used first (name breaks ties), with the published count.
func (*PostgresRepository) ListTerms ¶
func (r *PostgresRepository) ListTerms(ctx context.Context, kind, search string, page, perPage int) (*TermListResult, error)
ListTerms returns the paginated admin list with attachment counts. The COUNT is computed in the same query via LEFT JOIN + GROUP BY so the round-trip stays at one.
func (*PostgresRepository) ListTermsByIDs ¶
ListTermsByIDs fetches the supplied IDs in one round-trip.
func (*PostgresRepository) ListTermsForEntity ¶
func (r *PostgresRepository) ListTermsForEntity(ctx context.Context, entityID uuid.UUID) ([]*Term, error)
ListTermsForEntity returns terms attached to an entity, sorted by kind then name. The editor sidebar uses this to pre-populate the picker on load.
func (*PostgresRepository) MergeTerms ¶
MergeTerms runs the merge in one transaction.
func (*PostgresRepository) ReplaceEntityTerms ¶
func (r *PostgresRepository) ReplaceEntityTerms(ctx context.Context, entityID uuid.UUID, termIDs []uuid.UUID) error
ReplaceEntityTerms sets the entity's terms to exactly the supplied IDs. Implemented as DELETE-then-INSERT inside one tx so the join table never transiently shows a mixed state. Duplicate IDs in the input slice are idempotent (PK collision is silently ignored via ON CONFLICT).
Term-existence validation happens INSIDE the transaction (Phase 2 review item #4 — TOCTOU). The service-layer pre-fetch in Service.ReplaceEntityTerms is a UX optimisation only; if a term is deleted between that pre-fetch and reaching this tx the user would otherwise see a generic FK-violation 500. We hold a SHARE lock on each supplied term row for the duration of the join INSERTs, which blocks a concurrent DELETE FROM hatamoto_terms WHERE id = ... long enough for the join to commit. If the lookup misses a row, we return a typed error the handler maps to 400 unknown_term:<uuid>.
func (*PostgresRepository) UpdateTerm ¶
func (r *PostgresRepository) UpdateTerm(ctx context.Context, t *Term) error
UpdateTerm updates name + slug. kind is immutable post-create — letting admins flip kind would silently break public URLs.
type Repository ¶
type Repository interface {
// CreateTerm inserts a row. On (kind, slug) conflict returns
// ErrTermSlugConflict.
CreateTerm(ctx context.Context, t *Term) error
// UpdateTerm overwrites name + slug. On conflict returns
// ErrTermSlugConflict. On miss returns ErrTermNotFound.
UpdateTerm(ctx context.Context, t *Term) error
// DeleteTerm hard-deletes a term. FK CASCADE wipes the join rows.
DeleteTerm(ctx context.Context, id uuid.UUID) error
// GetTerm returns one term by ID.
GetTerm(ctx context.Context, id uuid.UUID) (*Term, error)
// GetTermBySlug returns one term by (kind, slug).
GetTermBySlug(ctx context.Context, kind, slug string) (*Term, error)
// ListTerms returns a paginated list, optionally filtered by kind
// and a case-insensitive name substring match.
ListTerms(ctx context.Context, kind, search string, page, perPage int) (*TermListResult, error)
// ListTermsByIDs returns a slice in arbitrary order for the supplied
// IDs. Missing IDs are silently skipped.
ListTermsByIDs(ctx context.Context, ids []uuid.UUID) ([]*Term, error)
// ListTermsForEntity returns every term attached to an entity,
// ordered by kind then name. Used by the admin editor sidebar to
// hydrate the picker on load.
ListTermsForEntity(ctx context.Context, entityID uuid.UUID) ([]*Term, error)
// ReplaceEntityTerms is the idempotent set-replace operation behind
// the PUT /entities/{id}/terms endpoint. termIDs may be empty to
// detach all terms. Runs in a single transaction so partial state
// never leaks.
ReplaceEntityTerms(ctx context.Context, entityID uuid.UUID, termIDs []uuid.UUID) error
// ListEntitiesForTerm returns published entities attached to the
// given term, paginated, newest-first.
ListEntitiesForTerm(ctx context.Context, termID uuid.UUID, page, perPage int) ([]*EntityRef, int, error)
// Chains returns, for each id, the terms from the root down to it
// (itself last), at most MaxDepth long.
Chains(ctx context.Context, ids []uuid.UUID) (map[uuid.UUID][]*Term, error)
// KindExists reports whether any term of kind exists.
KindExists(ctx context.Context, kind string) (bool, error)
}
Repository is the storage contract.
type Service ¶
type Service struct {
// contains filtered or unexported fields
}
Service is the application-layer facade for the taxonomy package. The HTTP handler layer talks to Service, not Repository, so we have one place to enforce validation and (in the future) emit listener events or invalidate caches.
func NewService ¶
func NewService(repo Repository, logger *slog.Logger) *Service
NewService wires a service around the supplied repository.
func (*Service) CreateTerm ¶
CreateTerm validates input and persists a new term. Returns ErrTermSlugConflict on (kind, slug) collision.
func (*Service) DeleteTerm ¶
DeleteTerm hard-deletes. Idempotent.
func (*Service) GetTermBySlug ¶
GetTermBySlug pass-through.
func (*Service) ListEntitiesForTerm ¶
func (s *Service) ListEntitiesForTerm(ctx context.Context, kind, slug string, page, perPage int) (*TermArchiveResult, error)
ListEntitiesForTerm proxies to the repository, paginating and assembling the wrapped TermArchiveResult for the public endpoint.
func (*Service) ListTerms ¶
func (s *Service) ListTerms(ctx context.Context, kind, search string, page, perPage int) (*TermListResult, error)
ListTerms pass-through with input normalisation.
func (*Service) ListTermsForEntity ¶
ListTermsForEntity pass-through.
func (*Service) MergeTerms ¶
MergeTerms moves every entity tagged with from onto into, then deletes from. Both must be of one kind and from must have no children. The archive path of from is reported to the move observer so it redirects to into.
func (*Service) ReplaceEntityTerms ¶
func (s *Service) ReplaceEntityTerms(ctx context.Context, entityID uuid.UUID, termIDs []uuid.UUID) error
ReplaceEntityTerms validates the term IDs (all must exist) then calls the repository. We pre-fetch to give the admin a clear error rather than a generic FK-violation surface.
func (*Service) SetChangeObserver ¶
SetChangeObserver installs the callback run after a term was created, updated or deleted, or an entity's terms were replaced; nil disables it.
func (*Service) SetMoveObserver ¶
SetMoveObserver installs the callback run after a term moved; nil disables it.
func (*Service) UpdateTerm ¶
UpdateTerm patches name + slug. Empty slug auto-slugifies from name.
type Term ¶
type Term struct {
ID uuid.UUID `json:"id"`
Kind string `json:"kind"`
Slug string `json:"slug"`
Name string `json:"name"`
Description string `json:"description"`
ParentID *uuid.UUID `json:"parent_id,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
// Path is the slash-joined slugs from the root term down to this one
// ("tutorials/go"); set by Repository.FillPaths, empty otherwise.
Path string `json:"path,omitempty"`
}
Term is the Go projection of a hatamoto_terms row.
type TermArchiveResult ¶
type TermArchiveResult struct {
Term *Term `json:"term"`
Items []*EntityRef `json:"items"`
Total int `json:"total"`
Page int `json:"page"`
PerPage int `json:"per_page"`
HasNext bool `json:"has_next"`
HasPrev bool `json:"has_prev"`
}
TermArchiveResult is the public archive payload.
type TermListResult ¶
type TermListResult struct {
Items []*TermWithCount `json:"items"`
Total int `json:"total"`
Page int `json:"page"`
PerPage int `json:"per_page"`
}
TermListResult is the paginated admin list response.
type TermWithCount ¶
TermWithCount augments a term with the count of entities currently attached to it. Used by the admin list endpoint so the UI can show usage at a glance.
type UpdateTermInput ¶
type UpdateTermInput struct {
ID uuid.UUID
Name string
Slug string
// Description replaces the stored one when non-nil.
Description *string
// ParentID moves the term under a parent; ClearParent makes it a root.
// Both unset leaves the parent alone.
ParentID *uuid.UUID
ClearParent bool
}
UpdateTermInput is the update payload. Kind is immutable — see PostgresRepository.UpdateTerm rationale.