Help us shape the future of Go! Take the 2026 Go Developer Survey

taxonomy

package
v1.0.2 Latest Latest
Warning

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

Go to latest
Published: Oct 28, 2026 License: Apache-2.0 Imports: 17 Imported by: 0

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

View Source
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.

View Source
const MaxDepth = 8

MaxDepth caps how deep terms nest (a root is depth 1), so a term's public path stays a sane URL.

View Source
const MaxDescriptionLen = 2000

MaxDescriptionLen caps a term description.

View Source
const MaxKindLen = 16

MaxKindLen mirrors the kind column width.

View Source
const MaxNameLen = 128

MaxNameLen caps the human-readable name a term may carry. Mirrors the VARCHAR(128) column width.

View Source
const MaxSlugLen = 128

MaxSlugLen mirrors the slug column width.

View Source
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

View Source
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.

View Source
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.

View Source
var ErrTermNotFound = errors.New("taxonomy: term not found")

ErrTermNotFound is returned when a lookup misses.

View Source
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.

View Source
var ErrTermParentNotFound = errors.New("taxonomy: parent term not found")

ErrTermParentNotFound is returned when the parent term does not exist.

View Source
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

func ArchivePrefix(kind string) string

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

func Slugify(name string) string

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

func ValidKind

func ValidKind(kind string) bool

ValidKind reports whether kind is a usable term kind: the built-in tag and category, or a custom lower-case word that is not a reserved URL segment.

func ValidSlug

func ValidSlug(slug string) bool

ValidSlug reports whether the supplied slug is well-formed.

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

func NewHandlers(svc *Service, logger *slog.Logger) *Handlers

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

type MoveFunc func(ctx context.Context, moves []PathMove)

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

func (r *PostgresRepository) DeleteTerm(ctx context.Context, id uuid.UUID) error

DeleteTerm removes a term row. FK CASCADE on hatamoto_entity_terms removes any attachments. Idempotent — missing ID is not an error.

func (*PostgresRepository) Descendants

func (r *PostgresRepository) Descendants(ctx context.Context, id uuid.UUID) ([]*Term, error)

Descendants returns every term below id (children, grandchildren, ...), without id itself. Depth is capped at MaxDepth+1, which also ends a cycle.

func (*PostgresRepository) GetTerm

func (r *PostgresRepository) GetTerm(ctx context.Context, id uuid.UUID) (*Term, error)

GetTerm fetches by ID.

func (*PostgresRepository) GetTermBySlug

func (r *PostgresRepository) GetTermBySlug(ctx context.Context, kind, slug string) (*Term, error)

GetTermBySlug fetches by (kind, slug).

func (*PostgresRepository) KindExists

func (r *PostgresRepository) KindExists(ctx context.Context, kind string) (bool, error)

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

func (r *PostgresRepository) ListTermsByIDs(ctx context.Context, ids []uuid.UUID) ([]*Term, error)

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

func (r *PostgresRepository) MergeTerms(ctx context.Context, from, into uuid.UUID) error

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

func (s *Service) CreateTerm(ctx context.Context, in CreateTermInput) (*Term, error)

CreateTerm validates input and persists a new term. Returns ErrTermSlugConflict on (kind, slug) collision.

func (*Service) DeleteTerm

func (s *Service) DeleteTerm(ctx context.Context, id uuid.UUID) error

DeleteTerm hard-deletes. Idempotent.

func (*Service) GetTerm

func (s *Service) GetTerm(ctx context.Context, id uuid.UUID) (*Term, error)

GetTerm pass-through.

func (*Service) GetTermBySlug

func (s *Service) GetTermBySlug(ctx context.Context, kind, slug string) (*Term, error)

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

func (s *Service) ListTermsForEntity(ctx context.Context, entityID uuid.UUID) ([]*Term, error)

ListTermsForEntity pass-through.

func (*Service) MergeTerms

func (s *Service) MergeTerms(ctx context.Context, from, into uuid.UUID) error

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

func (s *Service) SetChangeObserver(f func(ctx context.Context))

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

func (s *Service) SetMoveObserver(f MoveFunc)

SetMoveObserver installs the callback run after a term moved; nil disables it.

func (*Service) UpdateTerm

func (s *Service) UpdateTerm(ctx context.Context, in UpdateTermInput) (*Term, error)

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

type TermWithCount struct {
	Term
	EntityCount int `json:"entity_count"`
}

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.

Jump to

Keyboard shortcuts

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