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
+168
View File
@@ -0,0 +1,168 @@
// SPDX-License-Identifier: MPL-2.0
package cmssqlite
import (
"context"
"database/sql"
"encoding/json"
"gamertan.com/web/cms"
)
// PutRevision appends once. Unknown terms are rejected; retired terms remain
// usable when copying historical revisions. Editors decide whether to admit new
// retired-term assignments. Link targets are validated by the application: they
// can live in a different content/catalog store. A link grants no authority.
func PutRevision(ctx context.Context, tx *sql.Tx, scope string, ref cms.Reference, revision int64, a cms.Associations) error {
if !cms.ValidID(scope) || revision < 1 || a.Validate(ref) != nil {
return cms.ErrInvalid
}
for _, id := range a.Terms {
var count int
if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM gwf_cms_terms WHERE scope=? AND id=?`, scope, id).Scan(&count); err != nil {
return err
}
if count != 1 {
return cms.ErrNotFound
}
}
b, err := json.Marshal(a)
if err != nil {
return err
}
if _, err = tx.ExecContext(ctx, `INSERT INTO gwf_cms_resources(scope,kind,id) VALUES(?,?,?) ON CONFLICT DO NOTHING`, scope, ref.Kind, ref.ID); err != nil {
return err
}
result, err := tx.ExecContext(ctx, `INSERT INTO gwf_cms_associations(scope,kind,id,revision,document) VALUES(?,?,?,?,?) ON CONFLICT DO NOTHING`, scope, ref.Kind, ref.ID, revision, string(b))
if err = oneRow(result, err); err != nil {
return err
}
for _, id := range a.Terms {
if _, err = tx.ExecContext(ctx, `INSERT INTO gwf_cms_memberships(scope,kind,id,revision,term_id) VALUES(?,?,?,?,?)`, scope, ref.Kind, ref.ID, revision, id); err != nil {
return err
}
}
for _, target := range a.Links {
if _, err = tx.ExecContext(ctx, `INSERT INTO gwf_cms_links(scope,kind,id,revision,target_kind,target_id) VALUES(?,?,?,?,?,?)`, scope, ref.Kind, ref.ID, revision, target.Kind, target.ID); err != nil {
return err
}
}
return nil
}
// SetPublished selects an exact revision or zero to unpublish. Call inside the
// same transaction as the application's publication transition and audit. Draft
// saves do not call this function. Product availability is also checked by the
// consuming application; publication is not entitlement or payment authority.
func SetPublished(ctx context.Context, tx *sql.Tx, scope string, ref cms.Reference, revision int64) error {
if !cms.ValidID(scope) || ref.Validate() != nil || revision < 0 {
return cms.ErrInvalid
}
if revision > 0 {
var count int
if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM gwf_cms_associations WHERE scope=? AND kind=? AND id=? AND revision=?`, scope, ref.Kind, ref.ID, revision).Scan(&count); err != nil {
return err
}
if count != 1 {
return cms.ErrNotFound
}
}
result, err := tx.ExecContext(ctx, `UPDATE gwf_cms_resources SET published_revision=? WHERE scope=? AND kind=? AND id=?`, revision, scope, ref.Kind, ref.ID)
return oneRow(result, err)
}
func (r *Reader) Revision(ctx context.Context, ref cms.Reference, revision int64) (cms.Associations, error) {
if ref.Validate() != nil || revision < 1 {
return cms.Associations{}, cms.ErrInvalid
}
var raw string
err := r.db.QueryRowContext(ctx, `SELECT document FROM gwf_cms_associations WHERE scope=? AND kind=? AND id=? AND revision=?`, r.scope, ref.Kind, ref.ID, revision).Scan(&raw)
if err != nil {
return cms.Associations{}, notFound(err)
}
var a cms.Associations
if len(raw) > 16384 || json.Unmarshal([]byte(raw), &a) != nil || a.Validate(ref) != nil {
return cms.Associations{}, cms.ErrInvalid
}
return a, nil
}
func (r *Reader) LatestRevision(ctx context.Context, ref cms.Reference) (int64, error) {
if ref.Validate() != nil {
return 0, cms.ErrInvalid
}
var revision sql.NullInt64
err := r.db.QueryRowContext(ctx, `SELECT MAX(revision) FROM gwf_cms_associations WHERE scope=? AND kind=? AND id=?`, r.scope, ref.Kind, ref.ID).Scan(&revision)
if err != nil {
return 0, err
}
if !revision.Valid {
return 0, cms.ErrNotFound
}
return revision.Int64, nil
}
func (r *Reader) PublishedRevision(ctx context.Context, ref cms.Reference) (int64, error) {
if ref.Validate() != nil {
return 0, cms.ErrInvalid
}
var revision int64
err := r.db.QueryRowContext(ctx, `SELECT published_revision FROM gwf_cms_resources WHERE scope=? AND kind=? AND id=? AND published_revision>0`, r.scope, ref.Kind, ref.ID).Scan(&revision)
return revision, notFound(err)
}
// Members returns published members of an active term and taxonomy. Pagination
// happens after publication filtering, with stable kind/ID cursors.
func (r *Reader) Members(ctx context.Context, termID string, after *cms.Reference, limit int) (cms.Page, error) {
if !cms.ValidID(termID) {
return cms.Page{}, cms.ErrInvalid
}
query := `SELECT DISTINCT r.kind,r.id,r.published_revision FROM gwf_cms_memberships m JOIN gwf_cms_resources r ON r.scope=m.scope AND r.kind=m.kind AND r.id=m.id AND r.published_revision=m.revision JOIN gwf_cms_terms t ON t.scope=m.scope AND t.id=m.term_id JOIN gwf_cms_taxonomies x ON x.scope=t.scope AND x.id=t.taxonomy_id WHERE m.scope=? AND m.term_id=? AND r.published_revision>0 AND t.active=1 AND x.active=1`
return r.page(ctx, query, []any{r.scope, termID}, after, limit)
}
// Related returns both directions of explicit relationships between published
// revisions. Shared taxonomy membership alone does not assert a relationship.
func (r *Reader) Related(ctx context.Context, ref cms.Reference, after *cms.Reference, limit int) (cms.Page, error) {
if ref.Validate() != nil {
return cms.Page{}, cms.ErrInvalid
}
query := `WITH edges AS (
SELECT l.target_kind AS kind,l.target_id AS id FROM gwf_cms_links l JOIN gwf_cms_resources s ON s.scope=l.scope AND s.kind=l.kind AND s.id=l.id AND s.published_revision=l.revision WHERE l.scope=? AND l.kind=? AND l.id=? AND s.published_revision>0
UNION
SELECT l.kind,l.id FROM gwf_cms_links l JOIN gwf_cms_resources s ON s.scope=l.scope AND s.kind=l.kind AND s.id=l.id AND s.published_revision=l.revision WHERE l.scope=? AND l.target_kind=? AND l.target_id=? AND s.published_revision>0
) SELECT r.kind,r.id,r.published_revision FROM edges e JOIN gwf_cms_resources r ON r.kind=e.kind AND r.id=e.id WHERE r.scope=? AND r.published_revision>0 AND NOT(r.kind=? AND r.id=?) AND EXISTS(SELECT 1 FROM gwf_cms_resources origin WHERE origin.scope=? AND origin.kind=? AND origin.id=? AND origin.published_revision>0)`
return r.page(ctx, query, []any{r.scope, ref.Kind, ref.ID, r.scope, ref.Kind, ref.ID, r.scope, ref.Kind, ref.ID, r.scope, ref.Kind, ref.ID}, after, limit)
}
func (r *Reader) page(ctx context.Context, query string, args []any, after *cms.Reference, limit int) (cms.Page, error) {
if limit < 1 || limit > 100 || (after != nil && after.Validate() != nil) {
return cms.Page{}, cms.ErrInvalid
}
if after != nil {
query += ` AND (r.kind>? OR (r.kind=? AND r.id>?))`
args = append(args, after.Kind, after.Kind, after.ID)
}
query += ` ORDER BY r.kind,r.id LIMIT ?`
args = append(args, limit+1)
rows, err := r.db.QueryContext(ctx, query, args...)
if err != nil {
return cms.Page{}, err
}
defer rows.Close()
p := cms.Page{Items: []cms.ResourceVersion{}}
for rows.Next() {
var v cms.ResourceVersion
if err := rows.Scan(&v.Kind, &v.ID, &v.Revision); err != nil {
return cms.Page{}, err
}
p.Items = append(p.Items, v)
}
if err := rows.Err(); err != nil {
return cms.Page{}, err
}
if len(p.Items) > limit {
p.Items = p.Items[:limit]
ref := p.Items[limit-1].Reference
p.Next = &ref
}
return p, nil
}
+61
View File
@@ -0,0 +1,61 @@
// SPDX-License-Identifier: MPL-2.0
package cmssqlite_test
import (
"context"
"database/sql"
"fmt"
"gamertan.com/web/cms"
"gamertan.com/web/cmssqlite"
_ "modernc.org/sqlite"
)
func Example() {
ctx := context.Background()
db, err := sql.Open("sqlite", ":memory:?_pragma=foreign_keys(1)")
if err != nil {
panic(err)
}
defer db.Close()
db.SetMaxOpenConns(1)
tx, err := db.BeginTx(ctx, nil)
if err != nil {
panic(err)
}
defer tx.Rollback()
if err = cmssqlite.CreateSchema(ctx, tx); err != nil {
panic(err)
}
const scope = "my-site"
if err = cmssqlite.PutTaxonomy(ctx, tx, scope, cms.Taxonomy{ID: "topics", Slug: "topics", Name: "Topics", Revision: 1, Active: true}, 0); err != nil {
panic(err)
}
if err = cmssqlite.PutTerm(ctx, tx, scope, cms.Term{ID: "go", TaxonomyID: "topics", Slug: "go", Name: "Go", Revision: 1, Active: true}, 0); err != nil {
panic(err)
}
ref := cms.Reference{Kind: "article", ID: "first-post"}
// The application authorizes the writer and stores its content/audit in this
// same transaction. Only publication advances the public association pointer.
if err = cmssqlite.PutRevision(ctx, tx, scope, ref, 1, cms.Associations{Terms: []string{"go"}}); err != nil {
panic(err)
}
if err = cmssqlite.SetPublished(ctx, tx, scope, ref, 1); err != nil {
panic(err)
}
if err = tx.Commit(); err != nil {
panic(err)
}
reader, err := cmssqlite.New(db, scope)
if err != nil {
panic(err)
}
page, err := reader.Members(ctx, "go", nil, 20)
if err != nil {
panic(err)
}
for _, item := range page.Items {
fmt.Println(item.Kind, item.ID, item.Revision)
}
// Output: article first-post 1
}
+53
View File
@@ -0,0 +1,53 @@
// SPDX-License-Identifier: MPL-2.0
// Package cmssqlite stores cms values in an application's SQLite transaction.
// Schema installation is explicit. Callers own database opening, migration
// versions, authorization and audits. Every mutation must use a caller-owned
// transaction, committing its domain change and audit together; never use a
// pooled *sql.DB for multi-statement writes. Namespaces isolate application data.
package cmssqlite
import (
"context"
"database/sql"
"gamertan.com/web/cms"
)
type Queryer interface {
QueryContext(context.Context, string, ...any) (*sql.Rows, error)
QueryRowContext(context.Context, string, ...any) *sql.Row
}
// Reader may use a database or read transaction. Writers below require *sql.Tx.
type Reader struct {
db Queryer
scope string
}
func New(db Queryer, scope string) (*Reader, error) {
if db == nil || !cms.ValidID(scope) {
return nil, cms.ErrInvalid
}
return &Reader{db: db, scope: scope}, nil
}
// CreateSchema must be called from the application's explicit migration.
// It never changes an existing publishing schema or starts a transaction.
func CreateSchema(ctx context.Context, tx *sql.Tx) error {
for _, statement := range []string{
`CREATE TABLE IF NOT EXISTS gwf_cms_taxonomies (scope TEXT NOT NULL,id TEXT NOT NULL,slug TEXT NOT NULL,name TEXT NOT NULL,description TEXT NOT NULL,revision INTEGER NOT NULL CHECK(revision>0),active INTEGER NOT NULL CHECK(active IN (0,1)),PRIMARY KEY(scope,id),UNIQUE(scope,slug))`,
`CREATE TABLE IF NOT EXISTS gwf_cms_terms (scope TEXT NOT NULL,id TEXT NOT NULL,taxonomy_id TEXT NOT NULL,slug TEXT NOT NULL,name TEXT NOT NULL,description TEXT NOT NULL,revision INTEGER NOT NULL CHECK(revision>0),active INTEGER NOT NULL CHECK(active IN (0,1)),PRIMARY KEY(scope,id),UNIQUE(scope,taxonomy_id,slug),FOREIGN KEY(scope,taxonomy_id) REFERENCES gwf_cms_taxonomies(scope,id))`,
`CREATE TABLE IF NOT EXISTS gwf_cms_term_slugs (scope TEXT NOT NULL,taxonomy_id TEXT NOT NULL,slug TEXT NOT NULL,term_id TEXT NOT NULL,PRIMARY KEY(scope,taxonomy_id,slug),FOREIGN KEY(scope,term_id) REFERENCES gwf_cms_terms(scope,id))`,
`CREATE TABLE IF NOT EXISTS gwf_cms_resources (scope TEXT NOT NULL,kind TEXT NOT NULL,id TEXT NOT NULL,published_revision INTEGER NOT NULL DEFAULT 0 CHECK(published_revision>=0),PRIMARY KEY(scope,kind,id))`,
`CREATE TABLE IF NOT EXISTS gwf_cms_associations (scope TEXT NOT NULL,kind TEXT NOT NULL,id TEXT NOT NULL,revision INTEGER NOT NULL CHECK(revision>0),document TEXT NOT NULL,PRIMARY KEY(scope,kind,id,revision),FOREIGN KEY(scope,kind,id) REFERENCES gwf_cms_resources(scope,kind,id))`,
`CREATE TABLE IF NOT EXISTS gwf_cms_memberships (scope TEXT NOT NULL,kind TEXT NOT NULL,id TEXT NOT NULL,revision INTEGER NOT NULL,term_id TEXT NOT NULL,PRIMARY KEY(scope,kind,id,revision,term_id),FOREIGN KEY(scope,kind,id,revision) REFERENCES gwf_cms_associations(scope,kind,id,revision),FOREIGN KEY(scope,term_id) REFERENCES gwf_cms_terms(scope,id))`,
`CREATE INDEX IF NOT EXISTS gwf_cms_memberships_term ON gwf_cms_memberships(scope,term_id,kind,id,revision)`,
`CREATE TABLE IF NOT EXISTS gwf_cms_links (scope TEXT NOT NULL,kind TEXT NOT NULL,id TEXT NOT NULL,revision INTEGER NOT NULL,target_kind TEXT NOT NULL,target_id TEXT NOT NULL,PRIMARY KEY(scope,kind,id,revision,target_kind,target_id),FOREIGN KEY(scope,kind,id,revision) REFERENCES gwf_cms_associations(scope,kind,id,revision))`,
`CREATE INDEX IF NOT EXISTS gwf_cms_links_target ON gwf_cms_links(scope,target_kind,target_id,kind,id,revision)`,
} {
if _, err := tx.ExecContext(ctx, statement); err != nil {
return err
}
}
return nil
}
+214
View File
@@ -0,0 +1,214 @@
// SPDX-License-Identifier: MPL-2.0
package cmssqlite
import (
"context"
"database/sql"
"errors"
"fmt"
"path/filepath"
"testing"
"gamertan.com/web/cms"
_ "modernc.org/sqlite"
)
func fixture(t *testing.T) (*sql.DB, *Reader) {
t.Helper()
db, err := sql.Open("sqlite", "file:"+filepath.Join(t.TempDir(), "cms.sqlite")+"?_pragma=foreign_keys(1)&_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)&_txlock=immediate")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { db.Close() })
mutate(t, db, nil, func(tx *sql.Tx) error { return CreateSchema(context.Background(), tx) })
r, e := New(db, "merchant")
if e != nil {
t.Fatal(e)
}
for _, scope := range []string{"merchant", "other"} {
mutate(t, db, nil, func(tx *sql.Tx) error {
return PutTaxonomy(context.Background(), tx, scope, cms.Taxonomy{ID: "topics", Slug: "topics", Name: "Topics", Active: true, Revision: 1}, 0)
})
}
mutate(t, db, nil, func(tx *sql.Tx) error {
return PutTerm(context.Background(), tx, "merchant", cms.Term{ID: "go", TaxonomyID: "topics", Slug: "go", Name: "Go", Active: true, Revision: 1}, 0)
})
return db, r
}
func mutate(t *testing.T, db *sql.DB, want error, fn func(*sql.Tx) error) {
t.Helper()
tx, e := db.BeginTx(context.Background(), nil)
if e != nil {
t.Fatal(e)
}
defer tx.Rollback()
err := fn(tx)
if !errors.Is(err, want) {
t.Fatalf("mutation error %v, want %v", err, want)
}
if err == nil {
if e = tx.Commit(); e != nil {
t.Fatal(e)
}
}
}
func save(t *testing.T, db *sql.DB, ref cms.Reference, rev int64, a cms.Associations, publish bool) {
t.Helper()
mutate(t, db, nil, func(tx *sql.Tx) error {
if err := PutRevision(context.Background(), tx, "merchant", ref, rev, a); err != nil {
return err
}
if publish {
return SetPublished(context.Background(), tx, "merchant", ref, rev)
}
return nil
})
}
func TestPublicationRelationshipsAndHistory(t *testing.T) {
db, r := fixture(t)
ctx := context.Background()
project := cms.Reference{Kind: "project", ID: "hime"}
news := cms.Reference{Kind: "news", ID: "launch"}
product := cms.Reference{Kind: "product", ID: "support"}
save(t, db, project, 1, cms.Associations{Terms: []string{"go"}}, true)
save(t, db, news, 1, cms.Associations{Terms: []string{"go"}, Links: []cms.Reference{project}}, false)
p, e := r.Related(ctx, project, nil, 10)
if e != nil || len(p.Items) != 0 {
t.Fatalf("draft leak: %+v %v", p, e)
}
mutate(t, db, nil, func(tx *sql.Tx) error { return SetPublished(ctx, tx, "merchant", news, 1) })
for _, ref := range []cms.Reference{project, news} {
p, e = r.Related(ctx, ref, nil, 10)
if e != nil || len(p.Items) != 1 {
t.Fatalf("reverse missing: %+v %v", p, e)
}
}
save(t, db, product, 1, cms.Associations{}, true)
save(t, db, news, 2, cms.Associations{Links: []cms.Reference{product}}, false)
p, e = r.Related(ctx, project, nil, 10)
if e != nil || len(p.Items) != 1 {
t.Fatal("draft replaced published graph", e)
}
mutate(t, db, nil, func(tx *sql.Tx) error { return SetPublished(ctx, tx, "merchant", news, 2) })
p, e = r.Related(ctx, project, nil, 10)
if e != nil || len(p.Items) != 0 {
t.Fatal("stale published edge", e)
}
p, e = r.Related(ctx, product, nil, 10)
if e != nil || len(p.Items) != 1 {
t.Fatal("missing new edge", e)
}
old, e := r.Revision(ctx, news, 1)
if e != nil || old.Links[0] != project {
t.Fatal("history changed", e)
}
mutate(t, db, nil, func(tx *sql.Tx) error { return SetPublished(ctx, tx, "merchant", product, 0) })
p, e = r.Related(ctx, news, nil, 10)
if e != nil || len(p.Items) != 0 {
t.Fatal("unpublished target leak", e)
}
p, e = r.Related(ctx, product, nil, 10)
if e != nil || len(p.Items) != 0 {
t.Fatal("unpublished source discovery", e)
}
// Restoring an old association is a new revision, not an overwritten row.
save(t, db, news, 3, old, true)
p, e = r.Related(ctx, project, nil, 10)
if e != nil || len(p.Items) != 1 || p.Items[0].Revision != 3 {
t.Fatal("restore", e)
}
mutate(t, db, cms.ErrConflict, func(tx *sql.Tx) error { return PutRevision(ctx, tx, "merchant", news, 1, cms.Associations{}) })
mutate(t, db, cms.ErrNotFound, func(tx *sql.Tx) error { return SetPublished(ctx, tx, "merchant", news, 999) })
}
func TestTermRenameRetirementAndScope(t *testing.T) {
db, r := fixture(t)
ctx := context.Background()
ref := cms.Reference{Kind: "writing", ID: "essay"}
save(t, db, ref, 1, cms.Associations{Terms: []string{"go"}}, true)
term, e := r.Term(ctx, "go")
if e != nil {
t.Fatal(e)
}
term.Slug = "golang"
term.Name = "Go language"
term.Revision = 2
mutate(t, db, nil, func(tx *sql.Tx) error { return PutTerm(ctx, tx, "merchant", term, 1) })
alias, e := r.TermBySlug(ctx, "topics", "go")
if e != nil || alias.ID != term.ID || alias.Slug != "golang" {
t.Fatal("alias", e)
}
stolen := cms.Term{ID: "stolen", TaxonomyID: "topics", Slug: "go", Name: "Other", Revision: 1, Active: true}
mutate(t, db, cms.ErrConflict, func(tx *sql.Tx) error { return PutTerm(ctx, tx, "merchant", stolen, 0) })
duplicate := term
duplicate.Revision = 1
mutate(t, db, cms.ErrConflict, func(tx *sql.Tx) error { return PutTerm(ctx, tx, "merchant", duplicate, 0) })
other, _ := New(db, "other")
if _, e = other.Term(ctx, "go"); !errors.Is(e, cms.ErrNotFound) {
t.Fatal("cross scope term", e)
}
mutate(t, db, cms.ErrNotFound, func(tx *sql.Tx) error {
return PutRevision(ctx, tx, "other", ref, 1, cms.Associations{Terms: []string{"go"}})
})
p, e := other.Members(ctx, "go", nil, 10)
if e != nil || len(p.Items) != 0 {
t.Fatal("scope leak", e)
}
p, e = r.Members(ctx, "go", nil, 10)
if e != nil || len(p.Items) != 1 {
t.Fatal("membership lost on rename", e)
}
term.Active = false
term.Revision = 3
mutate(t, db, nil, func(tx *sql.Tx) error { return PutTerm(ctx, tx, "merchant", term, 2) })
p, e = r.Members(ctx, "go", nil, 10)
if e != nil || len(p.Items) != 0 {
t.Fatal("retired term discovery", e)
}
old, e := r.Revision(ctx, ref, 1)
if e != nil || len(old.Terms) != 1 {
t.Fatal("retirement rewrote history", e)
}
save(t, db, ref, 2, old, true)
}
func TestPaginationFiltersDraftsBeforeLimitAndRollback(t *testing.T) {
db, r := fixture(t)
ctx := context.Background()
for i := 0; i < 35; i++ {
save(t, db, cms.Reference{Kind: "news", ID: fmt.Sprintf("news-%02d", i)}, 1, cms.Associations{Terms: []string{"go"}}, i >= 30)
}
var after *cms.Reference
var ids []string
for {
p, e := r.Members(ctx, "go", after, 2)
if e != nil {
t.Fatal(e)
}
for _, v := range p.Items {
ids = append(ids, v.ID)
}
if p.Next == nil {
break
}
after = p.Next
}
if len(ids) != 5 || ids[0] != "news-30" || ids[4] != "news-34" {
t.Fatal(ids)
}
tx, e := db.BeginTx(ctx, nil)
if e != nil {
t.Fatal(e)
}
ref := cms.Reference{Kind: "project", ID: "rollback"}
if e = PutRevision(ctx, tx, "merchant", ref, 1, cms.Associations{}); e != nil {
t.Fatal(e)
}
if e = SetPublished(ctx, tx, "merchant", ref, 1); e != nil {
t.Fatal(e)
}
if e = tx.Rollback(); e != nil {
t.Fatal(e)
}
if _, e = r.PublishedRevision(ctx, ref); !errors.Is(e, cms.ErrNotFound) {
t.Fatal("partial transaction", e)
}
}
+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
}