Add revision-aware CMS taxonomies and relationships

This commit is contained in:
2026-09-10 12:37:21 -04:00
parent a16283efd7
commit 57d74bf601
15 changed files with 986 additions and 4 deletions
+159
View File
@@ -0,0 +1,159 @@
// SPDX-License-Identifier: MPL-2.0
package cmssqlite
import (
"context"
"database/sql"
"errors"
"gamertan.com/web/cms"
)
func notFound(err error) error {
if errors.Is(err, sql.ErrNoRows) {
return cms.ErrNotFound
}
return err
}
func (r *Reader) Taxonomy(ctx context.Context, id string) (cms.Taxonomy, error) {
var v cms.Taxonomy
err := r.db.QueryRowContext(ctx, `SELECT id,slug,name,description,revision,active FROM gwf_cms_taxonomies WHERE scope=? AND id=?`, r.scope, id).Scan(&v.ID, &v.Slug, &v.Name, &v.Description, &v.Revision, &v.Active)
return v, notFound(err)
}
func (r *Reader) Taxonomies(ctx context.Context) ([]cms.Taxonomy, error) {
rows, err := r.db.QueryContext(ctx, `SELECT id,slug,name,description,revision,active FROM gwf_cms_taxonomies WHERE scope=? ORDER BY slug LIMIT 101`, r.scope)
if err != nil {
return nil, err
}
defer rows.Close()
values := []cms.Taxonomy{}
for rows.Next() {
var v cms.Taxonomy
if err := rows.Scan(&v.ID, &v.Slug, &v.Name, &v.Description, &v.Revision, &v.Active); err != nil {
return nil, err
}
values = append(values, v)
}
if len(values) > 100 {
return nil, cms.ErrInvalid
}
return values, rows.Err()
}
func PutTaxonomy(ctx context.Context, tx *sql.Tx, scope string, v cms.Taxonomy, expected int64) error {
if !cms.ValidID(scope) || v.Validate() != nil || expected < 0 || v.Revision != expected+1 {
return cms.ErrInvalid
}
var result sql.Result
var err error
if expected == 0 {
var count int
if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM gwf_cms_taxonomies WHERE scope=?`, scope).Scan(&count); err != nil {
return err
}
if count >= 100 {
return cms.ErrInvalid
}
result, err = tx.ExecContext(ctx, `INSERT INTO gwf_cms_taxonomies(scope,id,slug,name,description,revision,active) VALUES(?,?,?,?,?,?,?) ON CONFLICT DO NOTHING`, scope, v.ID, v.Slug, v.Name, v.Description, v.Revision, v.Active)
} else {
result, err = tx.ExecContext(ctx, `UPDATE gwf_cms_taxonomies SET name=?,description=?,revision=?,active=? WHERE scope=? AND id=? AND revision=? AND slug=?`, v.Name, v.Description, v.Revision, v.Active, scope, v.ID, expected, v.Slug)
}
return oneRow(result, err)
}
func oneRow(result sql.Result, err error) error {
if err != nil {
return err
}
n, err := result.RowsAffected()
if err != nil {
return err
}
if n != 1 {
return cms.ErrConflict
}
return nil
}
func (r *Reader) Term(ctx context.Context, id string) (cms.Term, error) {
var v cms.Term
err := r.db.QueryRowContext(ctx, `SELECT id,taxonomy_id,slug,name,description,revision,active FROM gwf_cms_terms WHERE scope=? AND id=?`, r.scope, id).Scan(&v.ID, &v.TaxonomyID, &v.Slug, &v.Name, &v.Description, &v.Revision, &v.Active)
return v, notFound(err)
}
// Terms pages by immutable ID, including retired values for editors. The caller
// filters public availability using both taxonomy and term Active fields.
func (r *Reader) Terms(ctx context.Context, taxonomyID, after string, limit int) ([]cms.Term, error) {
if !cms.ValidID(taxonomyID) || (after != "" && !cms.ValidID(after)) || limit < 1 || limit > 200 {
return nil, cms.ErrInvalid
}
rows, err := r.db.QueryContext(ctx, `SELECT id,taxonomy_id,slug,name,description,revision,active FROM gwf_cms_terms WHERE scope=? AND taxonomy_id=? AND id>? ORDER BY id LIMIT ?`, r.scope, taxonomyID, after, limit)
if err != nil {
return nil, err
}
defer rows.Close()
values := []cms.Term{}
for rows.Next() {
var v cms.Term
if err := rows.Scan(&v.ID, &v.TaxonomyID, &v.Slug, &v.Name, &v.Description, &v.Revision, &v.Active); err != nil {
return nil, err
}
values = append(values, v)
}
return values, rows.Err()
}
// TermBySlug resolves old term slugs to the current record. The caller redirects
// to v.Slug after checking visibility; no private/retired term is published here.
func (r *Reader) TermBySlug(ctx context.Context, taxonomyID, slug string) (cms.Term, error) {
var id string
err := r.db.QueryRowContext(ctx, `SELECT id FROM gwf_cms_terms WHERE scope=? AND taxonomy_id=? AND slug=? UNION SELECT term_id FROM gwf_cms_term_slugs WHERE scope=? AND taxonomy_id=? AND slug=? LIMIT 1`, r.scope, taxonomyID, slug, r.scope, taxonomyID, slug).Scan(&id)
if err != nil {
return cms.Term{}, notFound(err)
}
return r.Term(ctx, id)
}
func PutTerm(ctx context.Context, tx *sql.Tx, scope string, v cms.Term, expected int64) error {
if !cms.ValidID(scope) || v.Validate() != nil || expected < 0 || v.Revision != expected+1 {
return cms.ErrInvalid
}
r, _ := New(tx, scope)
tax, err := r.Taxonomy(ctx, v.TaxonomyID)
if err != nil {
return err
}
if !tax.Active && v.Active {
return cms.ErrInvalid
}
var old cms.Term
if expected > 0 {
old, err = r.Term(ctx, v.ID)
if err != nil {
return err
}
if old.Revision != expected || old.TaxonomyID != v.TaxonomyID {
return cms.ErrConflict
}
}
occupied, err := r.TermBySlug(ctx, v.TaxonomyID, v.Slug)
if err == nil && occupied.ID != v.ID {
return cms.ErrConflict
}
if err != nil && !errors.Is(err, cms.ErrNotFound) {
return err
}
var result sql.Result
if expected == 0 {
result, err = tx.ExecContext(ctx, `INSERT INTO gwf_cms_terms(scope,id,taxonomy_id,slug,name,description,revision,active) VALUES(?,?,?,?,?,?,?,?) ON CONFLICT DO NOTHING`, scope, v.ID, v.TaxonomyID, v.Slug, v.Name, v.Description, v.Revision, v.Active)
} else {
result, err = tx.ExecContext(ctx, `UPDATE gwf_cms_terms SET slug=?,name=?,description=?,revision=?,active=? WHERE scope=? AND id=? AND revision=?`, v.Slug, v.Name, v.Description, v.Revision, v.Active, scope, v.ID, expected)
}
if err = oneRow(result, err); err != nil {
return err
}
if expected > 0 && old.Slug != v.Slug {
if _, err = tx.ExecContext(ctx, `DELETE FROM gwf_cms_term_slugs WHERE scope=? AND taxonomy_id=? AND slug=? AND term_id=?`, scope, v.TaxonomyID, v.Slug, v.ID); err != nil {
return err
}
_, err = tx.ExecContext(ctx, `INSERT INTO gwf_cms_term_slugs(scope,taxonomy_id,slug,term_id) VALUES(?,?,?,?)`, scope, v.TaxonomyID, old.Slug, v.ID)
}
return err
}