Add revision-aware CMS taxonomies and relationships
This commit is contained in:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user