add: story and some tests
This commit is contained in:
@@ -0,0 +1,508 @@
|
||||
// Package story provides database access for the Story resource.
|
||||
// It defines the DBStory struct and StoryTranslator to convert between
|
||||
// the API format (api.pb.go) and the database format (schema.sql).
|
||||
package story
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
v1 "git.tipsy.codes/charles/webstory/pkg/api/webstory/v1"
|
||||
"github.com/google/uuid"
|
||||
"google.golang.org/protobuf/types/known/timestamppb"
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrStoryIDInvalid is returned when the story_id format is invalid.
|
||||
ErrStoryIDInvalid = errors.New("invalid story_id format: must be 4-63 characters, matching pattern /[a-z][0-9-]/")
|
||||
|
||||
// storyIDPattern validates the story_id format.
|
||||
// Must start with a lowercase letter, end with a letter or number,
|
||||
// and contain only lowercase letters, numbers, and hyphens.
|
||||
storyIDPattern = regexp.MustCompile(`^[a-z][0-9a-z-]{2,61}[0-9a-z]$`)
|
||||
)
|
||||
|
||||
// DBStory represents a story in the PostgreSQL database.
|
||||
// This is the database model that corresponds to the schema.sql structure.
|
||||
type DBStory struct {
|
||||
// Database-generated unique identifier
|
||||
ID int64
|
||||
|
||||
// StoryID is the resource identifier used in the story's resource name.
|
||||
// Format: stories/{story_id}
|
||||
StoryID string
|
||||
|
||||
// Title of the story.
|
||||
Title string
|
||||
|
||||
// Content of the story.
|
||||
Content string
|
||||
|
||||
// Description or summary of the story.
|
||||
Description string
|
||||
|
||||
// Labels for organizing and categorizing the story.
|
||||
Labels []string
|
||||
|
||||
// CreatedAt is when the story was created.
|
||||
CreatedAt time.Time
|
||||
|
||||
// UpdatedAt is when the story was last updated.
|
||||
UpdatedAt time.Time
|
||||
|
||||
// Etag is used for concurrency control.
|
||||
Etag string
|
||||
}
|
||||
|
||||
// StoryTranslator provides conversion methods between API and database formats.
|
||||
type StoryTranslator struct{}
|
||||
|
||||
// NewStoryTranslator creates a new StoryTranslator instance.
|
||||
func NewStoryTranslator() *StoryTranslator {
|
||||
return &StoryTranslator{}
|
||||
}
|
||||
|
||||
// FromAPI converts an API Story message to a DBStory struct.
|
||||
// This handles the conversion from the protobuf-generated type to the database model.
|
||||
func (t *StoryTranslator) FromAPI(apiStory *v1.Story) (*DBStory, error) {
|
||||
if apiStory == nil {
|
||||
return nil, errors.New("api story cannot be nil")
|
||||
}
|
||||
|
||||
// Validate story_id if provided (required for create operations)
|
||||
if apiStory.StoryId != "" {
|
||||
if err := validateStoryID(apiStory.StoryId); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// Validate title (required field)
|
||||
if apiStory.Title == "" {
|
||||
return nil, errors.New("title is required")
|
||||
}
|
||||
|
||||
// Convert labels (may be nil)
|
||||
labels := apiStory.GetLabels()
|
||||
if labels == nil {
|
||||
labels = []string{}
|
||||
}
|
||||
|
||||
// Build resource name if not set
|
||||
name := apiStory.GetName()
|
||||
if name == "" && apiStory.StoryId != "" {
|
||||
name = fmt.Sprintf("stories/%s", apiStory.StoryId)
|
||||
}
|
||||
|
||||
// Convert timestamps if provided
|
||||
var createdAt, updatedAt time.Time
|
||||
if apiStory.GetCreateTime() != nil {
|
||||
createdAt = apiStory.CreateTime.AsTime()
|
||||
}
|
||||
if apiStory.GetUpdateTime() != nil {
|
||||
updatedAt = apiStory.UpdateTime.AsTime()
|
||||
}
|
||||
|
||||
return &DBStory{
|
||||
ID: 0, // Will be generated by database
|
||||
StoryID: apiStory.StoryId,
|
||||
Title: apiStory.Title,
|
||||
Content: apiStory.Content,
|
||||
Description: apiStory.Description,
|
||||
Labels: labels,
|
||||
CreatedAt: createdAt,
|
||||
UpdatedAt: updatedAt,
|
||||
Etag: apiStory.Etag,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ToAPI converts a DBStory struct to an API Story message.
|
||||
// This prepares the database model for response serialization.
|
||||
func (t *StoryTranslator) ToAPI(dbStory *DBStory) *v1.Story {
|
||||
if dbStory == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Build scenes list from the story
|
||||
scenes := make([]*v1.Scene, 0)
|
||||
|
||||
// Build actors list from the story
|
||||
actors := make([]*v1.Actor, 0)
|
||||
|
||||
// Build resource name
|
||||
name := fmt.Sprintf("stories/%s", dbStory.StoryID)
|
||||
|
||||
return &v1.Story{
|
||||
Name: name,
|
||||
StoryId: dbStory.StoryID,
|
||||
Title: dbStory.Title,
|
||||
Content: dbStory.Content,
|
||||
Description: dbStory.Description,
|
||||
Labels: dbStory.Labels,
|
||||
CreateTime: timestamppb.New(dbStory.CreatedAt),
|
||||
UpdateTime: timestamppb.New(dbStory.UpdatedAt),
|
||||
Etag: dbStory.Etag,
|
||||
Scenes: scenes,
|
||||
Actors: actors,
|
||||
}
|
||||
}
|
||||
|
||||
// ToAPIDetailed converts a DBStory to an API Story with nested scenes and actors.
|
||||
// This is used when loading stories with their related resources.
|
||||
func (t *StoryTranslator) ToAPIDetailed(dbStory *DBStory, scenes []*v1.Scene, actors []*v1.Actor) *v1.Story {
|
||||
apiStory := t.ToAPI(dbStory)
|
||||
apiStory.Scenes = scenes
|
||||
apiStory.Actors = actors
|
||||
return apiStory
|
||||
}
|
||||
|
||||
// Validate validates the DBStory struct.
|
||||
func (s *DBStory) Validate() error {
|
||||
if s.StoryID == "" {
|
||||
return errors.New("story_id is required")
|
||||
}
|
||||
|
||||
if err := validateStoryID(s.StoryID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if s.Title == "" {
|
||||
return errors.New("title is required")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateStoryID validates the story_id format.
|
||||
func validateStoryID(storyID string) error {
|
||||
if storyID == "" {
|
||||
return ErrStoryIDInvalid
|
||||
}
|
||||
|
||||
if len(storyID) < 4 || len(storyID) > 63 {
|
||||
return ErrStoryIDInvalid
|
||||
}
|
||||
|
||||
if !storyIDPattern.MatchString(storyID) {
|
||||
return ErrStoryIDInvalid
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Save saves the DBStory to the database.
|
||||
// If the story has an ID, it performs an UPDATE; otherwise, it performs an INSERT.
|
||||
func (s *DBStory) Save(db *sql.DB) error {
|
||||
if err := s.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Check if this is an update (has ID from database) or insert
|
||||
if s.ID > 0 {
|
||||
return s.update(db)
|
||||
}
|
||||
|
||||
return s.insert(db)
|
||||
}
|
||||
|
||||
// insert inserts a new story into the database.
|
||||
func (s *DBStory) insert(db *sql.DB) error {
|
||||
query := `
|
||||
INSERT INTO stories (story_id, title, content, description, labels, created_at, updated_at, etag)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
RETURNING id, created_at, updated_at, etag
|
||||
`
|
||||
|
||||
labels := make([]string, len(s.Labels))
|
||||
copy(labels, s.Labels)
|
||||
|
||||
now := time.Now()
|
||||
if s.CreatedAt.IsZero() {
|
||||
s.CreatedAt = now
|
||||
}
|
||||
if s.UpdatedAt.IsZero() {
|
||||
s.UpdatedAt = now
|
||||
}
|
||||
|
||||
// Generate etag if not provided
|
||||
etag := s.Etag
|
||||
if etag == "" {
|
||||
etag = generateEtag()
|
||||
}
|
||||
|
||||
var createdID int64
|
||||
var createdAt, updatedAt time.Time
|
||||
var returnedEtag string
|
||||
|
||||
err := db.QueryRow(
|
||||
query,
|
||||
s.StoryID,
|
||||
s.Title,
|
||||
s.Content,
|
||||
s.Description,
|
||||
labels,
|
||||
s.CreatedAt,
|
||||
s.UpdatedAt,
|
||||
etag,
|
||||
).Scan(&createdID, &createdAt, &updatedAt, &returnedEtag)
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to insert story: %w", err)
|
||||
}
|
||||
|
||||
s.ID = createdID
|
||||
s.CreatedAt = createdAt
|
||||
s.UpdatedAt = updatedAt
|
||||
s.Etag = returnedEtag
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// update updates an existing story in the database.
|
||||
func (s *DBStory) update(db *sql.DB) error {
|
||||
query := `
|
||||
UPDATE stories
|
||||
SET title = $1,
|
||||
content = $2,
|
||||
description = $3,
|
||||
labels = $4,
|
||||
updated_at = $5,
|
||||
etag = $6
|
||||
WHERE id = $7
|
||||
RETURNING created_at, updated_at, etag
|
||||
`
|
||||
|
||||
labels := make([]string, len(s.Labels))
|
||||
copy(labels, s.Labels)
|
||||
|
||||
now := time.Now()
|
||||
if s.UpdatedAt.IsZero() {
|
||||
s.UpdatedAt = now
|
||||
}
|
||||
|
||||
etag := s.Etag
|
||||
if etag == "" {
|
||||
etag = generateEtag()
|
||||
}
|
||||
|
||||
var createdAt, updatedAt time.Time
|
||||
var returnedEtag string
|
||||
|
||||
err := db.QueryRow(
|
||||
query,
|
||||
s.Title,
|
||||
s.Content,
|
||||
s.Description,
|
||||
labels,
|
||||
s.UpdatedAt,
|
||||
etag,
|
||||
s.ID,
|
||||
).Scan(&createdAt, &updatedAt, &returnedEtag)
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to update story: %w", err)
|
||||
}
|
||||
|
||||
s.CreatedAt = createdAt
|
||||
s.UpdatedAt = updatedAt
|
||||
s.Etag = returnedEtag
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// generateEtag generates a unique etag for the story.
|
||||
func generateEtag() string {
|
||||
return uuid.New().String()
|
||||
}
|
||||
|
||||
// GetByID retrieves a story by its primary key ID.
|
||||
func GetByID(db *sql.DB, id int64) (*DBStory, error) {
|
||||
query := `
|
||||
SELECT id, story_id, title, content, description, labels, created_at, updated_at, etag
|
||||
FROM stories
|
||||
WHERE id = $1
|
||||
`
|
||||
|
||||
story := &DBStory{}
|
||||
err := db.QueryRow(query, id).Scan(
|
||||
&story.ID,
|
||||
&story.StoryID,
|
||||
&story.Title,
|
||||
&story.Content,
|
||||
&story.Description,
|
||||
&story.Labels,
|
||||
&story.CreatedAt,
|
||||
&story.UpdatedAt,
|
||||
&story.Etag,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, fmt.Errorf("story with id %d not found", id)
|
||||
}
|
||||
return nil, fmt.Errorf("failed to get story: %w", err)
|
||||
}
|
||||
|
||||
return story, nil
|
||||
}
|
||||
|
||||
// GetByStoryID retrieves a story by its story_id.
|
||||
func GetByStoryID(db *sql.DB, storyID string) (*DBStory, error) {
|
||||
if err := validateStoryID(storyID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
query := `
|
||||
SELECT id, story_id, title, content, description, labels, created_at, updated_at, etag
|
||||
FROM stories
|
||||
WHERE story_id = $1
|
||||
`
|
||||
|
||||
story := &DBStory{}
|
||||
err := db.QueryRow(query, storyID).Scan(
|
||||
&story.ID,
|
||||
&story.StoryID,
|
||||
&story.Title,
|
||||
&story.Content,
|
||||
&story.Description,
|
||||
&story.Labels,
|
||||
&story.CreatedAt,
|
||||
&story.UpdatedAt,
|
||||
&story.Etag,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, fmt.Errorf("story with story_id '%s' not found", storyID)
|
||||
}
|
||||
return nil, fmt.Errorf("failed to get story: %w", err)
|
||||
}
|
||||
|
||||
return story, nil
|
||||
}
|
||||
|
||||
// List returns a paginated list of stories.
|
||||
func List(db *sql.DB, pageSize int, pageToken string, filter string, orderBy string) ([]*DBStory, string, error) {
|
||||
if pageSize <= 0 || pageSize > 1000 {
|
||||
pageSize = 50
|
||||
}
|
||||
|
||||
var query string
|
||||
var args []interface{}
|
||||
|
||||
switch orderBy {
|
||||
case "title":
|
||||
query = `
|
||||
SELECT id, story_id, title, content, description, labels, created_at, updated_at, etag
|
||||
FROM stories
|
||||
WHERE 1=1
|
||||
`
|
||||
case "create_time":
|
||||
fallthrough
|
||||
default:
|
||||
query = `
|
||||
SELECT id, story_id, title, content, description, labels, created_at, updated_at, etag
|
||||
FROM stories
|
||||
WHERE 1=1
|
||||
`
|
||||
}
|
||||
|
||||
// Parse page token for pagination
|
||||
if pageToken != "" {
|
||||
query += " AND id > " + pageToken
|
||||
}
|
||||
|
||||
query += " ORDER BY id DESC"
|
||||
query += fmt.Sprintf(" LIMIT %d", pageSize)
|
||||
|
||||
rows, err := db.Query(query, args...)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("failed to list stories: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
stories := make([]*DBStory, 0)
|
||||
for rows.Next() {
|
||||
var id int64
|
||||
var storyID string
|
||||
var title, content, description, etag string
|
||||
var labels []string
|
||||
var createdAt, updatedAt time.Time
|
||||
|
||||
err := rows.Scan(&id, &storyID, &title, &content, &description, &labels, &createdAt, &updatedAt, &etag)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("failed to scan story: %w", err)
|
||||
}
|
||||
|
||||
stories = append(stories, &DBStory{
|
||||
ID: id,
|
||||
StoryID: storyID,
|
||||
Title: title,
|
||||
Content: content,
|
||||
Description: description,
|
||||
Labels: labels,
|
||||
CreatedAt: createdAt,
|
||||
UpdatedAt: updatedAt,
|
||||
Etag: etag,
|
||||
})
|
||||
}
|
||||
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, "", fmt.Errorf("error iterating stories: %w", err)
|
||||
}
|
||||
|
||||
var nextPageToken string
|
||||
if len(stories) == pageSize {
|
||||
// Use the last story's ID as the next page token
|
||||
nextPageToken = fmt.Sprintf("%d", stories[len(stories)-1].ID)
|
||||
}
|
||||
|
||||
return stories, nextPageToken, nil
|
||||
}
|
||||
|
||||
// Delete removes a story from the database.
|
||||
func Delete(db *sql.DB, id int64) error {
|
||||
query := `
|
||||
DELETE FROM stories
|
||||
WHERE id = $1
|
||||
`
|
||||
|
||||
result, err := db.Exec(query, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete story: %w", err)
|
||||
}
|
||||
|
||||
rowsAffected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get rows affected: %w", err)
|
||||
}
|
||||
|
||||
if rowsAffected == 0 {
|
||||
return fmt.Errorf("story with id %d not found", id)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetStoryIDForResourceName extracts the story_id from a resource name.
|
||||
// Resource name format: stories/{story_id}
|
||||
func GetStoryIDForResourceName(resourceName string) (string, error) {
|
||||
prefix := "stories/"
|
||||
if !strings.HasPrefix(resourceName, prefix) {
|
||||
return "", fmt.Errorf("invalid resource name: must start with '%s', got '%s'", prefix, resourceName)
|
||||
}
|
||||
|
||||
storyID := strings.TrimPrefix(resourceName, prefix)
|
||||
if storyID == "" {
|
||||
return "", errors.New("story_id is empty in resource name")
|
||||
}
|
||||
|
||||
if err := validateStoryID(storyID); err != nil {
|
||||
return "", fmt.Errorf("invalid story_id in resource name: %w", err)
|
||||
}
|
||||
|
||||
return storyID, nil
|
||||
}
|
||||
Reference in New Issue
Block a user