parent
76ff79f470
commit
7ac2f5ebb3
@ -0,0 +1,186 @@ |
|||||||
|
package post |
||||||
|
|
||||||
|
import ( |
||||||
|
"database/sql" |
||||||
|
"encoding/json" |
||||||
|
"errors" |
||||||
|
"fmt" |
||||||
|
) |
||||||
|
|
||||||
|
type DraftStore interface { |
||||||
|
|
||||||
|
// Set sets the draft Post's data into the storage, keyed by the draft
|
||||||
|
// Post's ID.
|
||||||
|
Set(post Post) error |
||||||
|
|
||||||
|
// Get returns count draft Posts, sorted id descending, offset by the
|
||||||
|
// given page number. The returned boolean indicates if there are more pages
|
||||||
|
// or not.
|
||||||
|
Get(page, count int) ([]Post, bool, error) |
||||||
|
|
||||||
|
// GetByID will return the draft Post with the given ID, or ErrPostNotFound.
|
||||||
|
GetByID(id string) (Post, error) |
||||||
|
|
||||||
|
// Delete will delete the draft Post with the given ID.
|
||||||
|
Delete(id string) error |
||||||
|
} |
||||||
|
|
||||||
|
type draftStore struct { |
||||||
|
db *SQLDB |
||||||
|
} |
||||||
|
|
||||||
|
// NewDraftStore initializes a new DraftStore using an existing SQLDB.
|
||||||
|
func NewDraftStore(db *SQLDB) DraftStore { |
||||||
|
return &draftStore{ |
||||||
|
db: db, |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
func (s *draftStore) Set(post Post) error { |
||||||
|
|
||||||
|
if post.ID == "" { |
||||||
|
return errors.New("post ID can't be empty") |
||||||
|
} |
||||||
|
|
||||||
|
tagsJSON, err := json.Marshal(post.Tags) |
||||||
|
if err != nil { |
||||||
|
return fmt.Errorf("json marshaling tags %#v: %w", post.Tags, err) |
||||||
|
} |
||||||
|
|
||||||
|
_, err = s.db.db.Exec( |
||||||
|
`INSERT INTO post_drafts ( |
||||||
|
id, title, description, tags, series, body |
||||||
|
) |
||||||
|
VALUES |
||||||
|
(?, ?, ?, ?, ?, ?) |
||||||
|
ON CONFLICT (id) DO UPDATE SET |
||||||
|
title=excluded.title, |
||||||
|
description=excluded.description, |
||||||
|
tags=excluded.tags, |
||||||
|
series=excluded.series, |
||||||
|
body=excluded.body`, |
||||||
|
post.ID, |
||||||
|
post.Title, |
||||||
|
post.Description, |
||||||
|
&sql.NullString{String: string(tagsJSON), Valid: len(post.Tags) > 0}, |
||||||
|
&sql.NullString{String: post.Series, Valid: post.Series != ""}, |
||||||
|
post.Body, |
||||||
|
) |
||||||
|
|
||||||
|
if err != nil { |
||||||
|
return fmt.Errorf("inserting into post_drafts: %w", err) |
||||||
|
} |
||||||
|
|
||||||
|
return nil |
||||||
|
} |
||||||
|
|
||||||
|
func (s *draftStore) get( |
||||||
|
querier interface { |
||||||
|
Query(string, ...interface{}) (*sql.Rows, error) |
||||||
|
}, |
||||||
|
limit, offset int, |
||||||
|
where string, whereArgs ...interface{}, |
||||||
|
) ( |
||||||
|
[]Post, error, |
||||||
|
) { |
||||||
|
|
||||||
|
query := ` |
||||||
|
SELECT |
||||||
|
p.id, p.title, p.description, p.tags, p.series, p.body |
||||||
|
FROM post_drafts p |
||||||
|
ORDER BY p.id ASC` |
||||||
|
|
||||||
|
if limit > 0 { |
||||||
|
query += fmt.Sprintf(" LIMIT %d", limit) |
||||||
|
} |
||||||
|
|
||||||
|
if offset > 0 { |
||||||
|
query += fmt.Sprintf(" OFFSET %d", offset) |
||||||
|
} |
||||||
|
|
||||||
|
rows, err := querier.Query(query, whereArgs...) |
||||||
|
|
||||||
|
if err != nil { |
||||||
|
return nil, fmt.Errorf("selecting: %w", err) |
||||||
|
} |
||||||
|
|
||||||
|
defer rows.Close() |
||||||
|
|
||||||
|
var posts []Post |
||||||
|
|
||||||
|
for rows.Next() { |
||||||
|
|
||||||
|
var ( |
||||||
|
post Post |
||||||
|
tags, series sql.NullString |
||||||
|
) |
||||||
|
|
||||||
|
err := rows.Scan( |
||||||
|
&post.ID, &post.Title, &post.Description, &tags, &series, |
||||||
|
&post.Body, |
||||||
|
) |
||||||
|
|
||||||
|
if err != nil { |
||||||
|
return nil, fmt.Errorf("scanning row: %w", err) |
||||||
|
} |
||||||
|
|
||||||
|
post.Series = series.String |
||||||
|
|
||||||
|
if tags.String != "" { |
||||||
|
|
||||||
|
if err := json.Unmarshal([]byte(tags.String), &post.Tags); err != nil { |
||||||
|
return nil, fmt.Errorf("json parsing %q: %w", tags.String, err) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
posts = append(posts, post) |
||||||
|
} |
||||||
|
|
||||||
|
return posts, nil |
||||||
|
} |
||||||
|
|
||||||
|
func (s *draftStore) Get(page, count int) ([]Post, bool, error) { |
||||||
|
|
||||||
|
posts, err := s.get(s.db.db, count+1, page*count, ``) |
||||||
|
|
||||||
|
if err != nil { |
||||||
|
return nil, false, fmt.Errorf("querying post_drafts: %w", err) |
||||||
|
} |
||||||
|
|
||||||
|
var hasMore bool |
||||||
|
|
||||||
|
if len(posts) > count { |
||||||
|
hasMore = true |
||||||
|
posts = posts[:count] |
||||||
|
} |
||||||
|
|
||||||
|
return posts, hasMore, nil |
||||||
|
} |
||||||
|
|
||||||
|
func (s *draftStore) GetByID(id string) (Post, error) { |
||||||
|
|
||||||
|
posts, err := s.get(s.db.db, 0, 0, `WHERE p.id=?`, id) |
||||||
|
|
||||||
|
if err != nil { |
||||||
|
return Post{}, fmt.Errorf("querying post_drafts: %w", err) |
||||||
|
} |
||||||
|
|
||||||
|
if len(posts) == 0 { |
||||||
|
return Post{}, ErrPostNotFound |
||||||
|
} |
||||||
|
|
||||||
|
if len(posts) > 1 { |
||||||
|
panic(fmt.Sprintf("got back multiple draft posts querying id %q: %+v", id, posts)) |
||||||
|
} |
||||||
|
|
||||||
|
return posts[0], nil |
||||||
|
} |
||||||
|
|
||||||
|
func (s *draftStore) Delete(id string) error { |
||||||
|
|
||||||
|
if _, err := s.db.db.Exec(`DELETE FROM post_drafts WHERE id = ?`, id); err != nil { |
||||||
|
return fmt.Errorf("deleting from post_drafts: %w", err) |
||||||
|
} |
||||||
|
|
||||||
|
return nil |
||||||
|
} |
@ -0,0 +1,130 @@ |
|||||||
|
package post |
||||||
|
|
||||||
|
import ( |
||||||
|
"sort" |
||||||
|
"testing" |
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert" |
||||||
|
) |
||||||
|
|
||||||
|
type draftStoreTestHarness struct { |
||||||
|
store DraftStore |
||||||
|
} |
||||||
|
|
||||||
|
func newDraftStoreTestHarness(t *testing.T) draftStoreTestHarness { |
||||||
|
|
||||||
|
db := NewInMemSQLDB() |
||||||
|
t.Cleanup(func() { db.Close() }) |
||||||
|
|
||||||
|
store := NewDraftStore(db) |
||||||
|
|
||||||
|
return draftStoreTestHarness{ |
||||||
|
store: store, |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
func TestDraftStore(t *testing.T) { |
||||||
|
|
||||||
|
assertPostEqual := func(t *testing.T, exp, got Post) { |
||||||
|
t.Helper() |
||||||
|
sort.Strings(exp.Tags) |
||||||
|
sort.Strings(got.Tags) |
||||||
|
assert.Equal(t, exp, got) |
||||||
|
} |
||||||
|
|
||||||
|
assertPostsEqual := func(t *testing.T, exp, got []Post) { |
||||||
|
t.Helper() |
||||||
|
|
||||||
|
if !assert.Len(t, got, len(exp), "exp:%+v\ngot: %+v", exp, got) { |
||||||
|
return |
||||||
|
} |
||||||
|
|
||||||
|
for i := range exp { |
||||||
|
assertPostEqual(t, exp[i], got[i]) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
t.Run("not_found", func(t *testing.T) { |
||||||
|
h := newDraftStoreTestHarness(t) |
||||||
|
|
||||||
|
_, err := h.store.GetByID("foo") |
||||||
|
assert.ErrorIs(t, err, ErrPostNotFound) |
||||||
|
}) |
||||||
|
|
||||||
|
t.Run("set_get_delete", func(t *testing.T) { |
||||||
|
h := newDraftStoreTestHarness(t) |
||||||
|
|
||||||
|
post := testPost(0) |
||||||
|
post.Tags = []string{"foo", "bar"} |
||||||
|
|
||||||
|
err := h.store.Set(post) |
||||||
|
assert.NoError(t, err) |
||||||
|
|
||||||
|
gotPost, err := h.store.GetByID(post.ID) |
||||||
|
assert.NoError(t, err) |
||||||
|
|
||||||
|
assertPostEqual(t, post, gotPost) |
||||||
|
|
||||||
|
// we will now try updating the post, and ensure it updates properly
|
||||||
|
|
||||||
|
post.Title = "something else" |
||||||
|
post.Series = "whatever" |
||||||
|
post.Body = "anything" |
||||||
|
post.Tags = []string{"bar", "baz"} |
||||||
|
|
||||||
|
err = h.store.Set(post) |
||||||
|
assert.NoError(t, err) |
||||||
|
|
||||||
|
gotPost, err = h.store.GetByID(post.ID) |
||||||
|
assert.NoError(t, err) |
||||||
|
|
||||||
|
assertPostEqual(t, post, gotPost) |
||||||
|
|
||||||
|
// delete the post, it should go away
|
||||||
|
assert.NoError(t, h.store.Delete(post.ID)) |
||||||
|
|
||||||
|
_, err = h.store.GetByID(post.ID) |
||||||
|
assert.ErrorIs(t, err, ErrPostNotFound) |
||||||
|
}) |
||||||
|
|
||||||
|
t.Run("get", func(t *testing.T) { |
||||||
|
h := newDraftStoreTestHarness(t) |
||||||
|
|
||||||
|
posts := []Post{ |
||||||
|
testPost(0), |
||||||
|
testPost(1), |
||||||
|
testPost(2), |
||||||
|
testPost(3), |
||||||
|
} |
||||||
|
|
||||||
|
for _, post := range posts { |
||||||
|
err := h.store.Set(post) |
||||||
|
assert.NoError(t, err) |
||||||
|
} |
||||||
|
|
||||||
|
gotPosts, hasMore, err := h.store.Get(0, 2) |
||||||
|
assert.NoError(t, err) |
||||||
|
assert.True(t, hasMore) |
||||||
|
assertPostsEqual(t, posts[:2], gotPosts) |
||||||
|
|
||||||
|
gotPosts, hasMore, err = h.store.Get(1, 2) |
||||||
|
assert.NoError(t, err) |
||||||
|
assert.False(t, hasMore) |
||||||
|
assertPostsEqual(t, posts[2:4], gotPosts) |
||||||
|
|
||||||
|
posts = append(posts, testPost(4)) |
||||||
|
err = h.store.Set(posts[4]) |
||||||
|
assert.NoError(t, err) |
||||||
|
|
||||||
|
gotPosts, hasMore, err = h.store.Get(1, 2) |
||||||
|
assert.NoError(t, err) |
||||||
|
assert.True(t, hasMore) |
||||||
|
assertPostsEqual(t, posts[2:4], gotPosts) |
||||||
|
|
||||||
|
gotPosts, hasMore, err = h.store.Get(2, 2) |
||||||
|
assert.NoError(t, err) |
||||||
|
assert.False(t, hasMore) |
||||||
|
assertPostsEqual(t, posts[4:], gotPosts) |
||||||
|
}) |
||||||
|
|
||||||
|
} |
Loading…
Reference in new issue