169 lines
7.3 KiB
Go
169 lines
7.3 KiB
Go
// 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
|
|
}
|