Skip to content

Commit 3929f5c

Browse files
committed
new store implementation
* store/ - common package * filestore/ - store implement with file system * channel store, completed with tests
1 parent edbcd08 commit 3929f5c

File tree

7 files changed

+497
-0
lines changed

7 files changed

+497
-0
lines changed

internal/filestore/channel.go

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
package filestore
2+
3+
import (
4+
"errors"
5+
"fmt"
6+
"log"
7+
"os"
8+
"path/filepath"
9+
"sort"
10+
"sync"
11+
12+
"github.com/vim-jp/slacklog-generator/internal/store"
13+
)
14+
15+
type channelStore struct {
16+
dir string
17+
18+
rw sync.RWMutex
19+
channels []store.Channel
20+
idxID map[string]int
21+
}
22+
23+
// Get gets a channel by ID.
24+
func (cs *channelStore) Get(id string) (*store.Channel, error) {
25+
err := cs.assureLoad()
26+
if err != nil {
27+
return nil, err
28+
}
29+
cs.rw.RLock()
30+
defer cs.rw.RUnlock()
31+
32+
x, ok := cs.idxID[id]
33+
if !ok {
34+
return nil, fmt.Errorf("channel not found, uknown id: id=%s", id)
35+
}
36+
if x < 0 || x >= len(cs.channels) {
37+
return nil, fmt.Errorf("channel index collapsed, ask developers: id=%s", id)
38+
}
39+
c := cs.channels[x]
40+
return &c, nil
41+
}
42+
43+
// Iterate enumerates all channels by callback.
44+
// 呼び出し時点でチャンネル一覧のコピーが本イテレート専用に作成される。
45+
// コールバックが false を返すと store.ErrIterateAbort が返る
46+
func (cs *channelStore) Iterate(iter store.ChannelIterator) error {
47+
err := cs.assureLoad()
48+
if err != nil {
49+
return err
50+
}
51+
cs.rw.RLock()
52+
channels := make([]store.Channel, len(cs.channels))
53+
copy(channels, cs.channels)
54+
// FIXME: cs.idxID に入ってないのは省くべきでは?
55+
cs.rw.RUnlock()
56+
for i := range channels {
57+
cont := iter.Iterate(&channels[i])
58+
if !cont {
59+
return store.ErrIterateAbort
60+
}
61+
}
62+
return nil
63+
}
64+
65+
// Upsert updates or inserts a channel in store.
66+
// This returns true as 1st parameter, when a channel inserted.
67+
func (cs *channelStore) Upsert(c store.Channel) (bool, error) {
68+
if c.ID == "" {
69+
return false, errors.New("empty ID is forbidden")
70+
}
71+
72+
err := cs.assureLoad()
73+
if err != nil {
74+
return false, err
75+
}
76+
cs.rw.Lock()
77+
defer cs.rw.Unlock()
78+
79+
c.Tidy()
80+
81+
x, ok := cs.idxID[c.ID]
82+
if ok {
83+
cs.channels[x] = c
84+
return true, nil
85+
}
86+
cs.idxID[c.ID] = len(cs.channels)
87+
cs.channels = append(cs.channels, c)
88+
return false, nil
89+
}
90+
91+
// Commit saves channels to file:channels.json.
92+
func (cs *channelStore) Commit() error {
93+
cs.rw.Lock()
94+
defer cs.rw.Unlock()
95+
if cs.channels == nil {
96+
log.Printf("[DEBUG] no channels to commit. not load yet?")
97+
return nil
98+
}
99+
100+
ids := make([]string, 0, len(cs.idxID))
101+
for id := range cs.idxID {
102+
ids = append(ids, id)
103+
}
104+
sort.Strings(ids)
105+
ca := make([]store.Channel, len(ids))
106+
for i, id := range ids {
107+
ca[i] = cs.channels[cs.idxID[id]]
108+
}
109+
err := jsonWriteFile(cs.path(), ca)
110+
if err != nil {
111+
return err
112+
}
113+
114+
cs.replaceChannels(ca)
115+
return nil
116+
}
117+
118+
// path returns path for channels.json
119+
func (cs *channelStore) path() string {
120+
return filepath.Join(cs.dir, "channels.json")
121+
}
122+
123+
// assureLoad assure channels.json is loaded.
124+
func (cs *channelStore) assureLoad() error {
125+
cs.rw.Lock()
126+
defer cs.rw.Unlock()
127+
if cs.channels != nil {
128+
return nil
129+
}
130+
var channels []store.Channel
131+
err := jsonReadFile(cs.path(), true, &channels)
132+
if err != nil && !os.IsNotExist(err) {
133+
return err
134+
}
135+
cs.replaceChannels(channels)
136+
return nil
137+
}
138+
139+
func (cs *channelStore) replaceChannels(channels []store.Channel) {
140+
if len(channels) == 0 {
141+
cs.channels = []store.Channel{}
142+
cs.idxID = map[string]int{}
143+
return
144+
}
145+
idxID := make(map[string]int, len(channels))
146+
for i, c := range channels {
147+
idxID[c.ID] = i
148+
}
149+
cs.channels = channels
150+
cs.idxID = idxID
151+
}

internal/filestore/channel_test.go

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
package filestore
2+
3+
import (
4+
"encoding/json"
5+
"errors"
6+
"io/ioutil"
7+
"os"
8+
"strings"
9+
"testing"
10+
11+
"github.com/vim-jp/slacklog-generator/internal/store"
12+
"github.com/vim-jp/slacklog-generator/internal/testassert"
13+
)
14+
15+
func jsonToChannel(t *testing.T, s string) *store.Channel {
16+
t.Helper()
17+
var c store.Channel
18+
err := json.Unmarshal([]byte(s), &c)
19+
if err != nil {
20+
t.Fatalf("failed to parse as Channel: %s", err)
21+
}
22+
return &c
23+
}
24+
25+
func TestChannelStore_Get(t *testing.T) {
26+
cs := &channelStore{dir: "testdata/channel_read"}
27+
28+
for _, tc := range []struct {
29+
id string
30+
exp string
31+
}{
32+
{"CXXXX0001", `{"id":"CXXXX0001","name":"channel01"}`},
33+
{"CXXXX0002", `{"id":"CXXXX0002","name":"channel02"}`},
34+
{"CXXXX0003", `{"id":"CXXXX0003","name":"channel03"}`},
35+
{"CXXXX0004", `{"id":"CXXXX0004","name":"channel04"}`},
36+
{"CXXXX0005", `{"id":"CXXXX0005","name":"channel05"}`},
37+
} {
38+
act, err := cs.Get(tc.id)
39+
if err != nil {
40+
t.Fatalf("failed to get(%s): %s", tc.id, err)
41+
}
42+
exp := jsonToChannel(t, tc.exp)
43+
testassert.Equal(t, exp, act, "id:"+tc.id)
44+
}
45+
46+
c, err := cs.Get("CXXXX9999")
47+
if err == nil {
48+
t.Fatalf("should fail to get unknown ID: %+v", c)
49+
}
50+
if !strings.HasPrefix(err.Error(), "channel not found, ") {
51+
t.Fatalf("unexpected error for getting unknown ID: %s", err)
52+
}
53+
}
54+
55+
func TestChannelStore_Iterate(t *testing.T) {
56+
cs := &channelStore{dir: "testdata/channel_read"}
57+
58+
var act []*store.Channel
59+
err := cs.Iterate(store.ChannelIterateFunc(func(c *store.Channel) bool {
60+
act = append(act, c)
61+
return true
62+
}))
63+
if err != nil {
64+
t.Fatalf("iteration failed: %s", err)
65+
}
66+
exp := []*store.Channel{
67+
jsonToChannel(t, `{"id":"CXXXX0001","name":"channel01"}`),
68+
jsonToChannel(t, `{"id":"CXXXX0002","name":"channel02"}`),
69+
jsonToChannel(t, `{"id":"CXXXX0003","name":"channel03"}`),
70+
jsonToChannel(t, `{"id":"CXXXX0004","name":"channel04"}`),
71+
jsonToChannel(t, `{"id":"CXXXX0005","name":"channel05"}`),
72+
}
73+
testassert.Equal(t, exp, act, "simple iteration")
74+
}
75+
76+
func TestChannelStore_Iterate_Break(t *testing.T) {
77+
cs := &channelStore{dir: "testdata/channel_read"}
78+
79+
i := 0
80+
err := cs.Iterate(store.ChannelIterateFunc(func(_ *store.Channel) bool {
81+
i++
82+
return i > 2
83+
}))
84+
if !errors.Is(err, store.ErrIterateAbort) {
85+
t.Fatalf("iterate should be failed with:%s got:%s", store.ErrIterateAbort, err)
86+
}
87+
}
88+
89+
func TestChannelStore_Write(t *testing.T) {
90+
dir, err := ioutil.TempDir("testdata", "channel_write*")
91+
if err != nil {
92+
t.Fatalf("failed to TempDir: %s", err)
93+
}
94+
t.Cleanup(func() {
95+
os.RemoveAll(dir)
96+
})
97+
cs := &channelStore{dir: dir}
98+
99+
for i, s := range []string{
100+
`{"id":"W0001","name":"channel01"}`,
101+
`{"id":"W0009","name":"channel09"}`,
102+
`{"id":"W0005","name":"channel05"}`,
103+
`{"id":"W0001","name":"channel01a"}`,
104+
} {
105+
_, err := cs.Upsert(*jsonToChannel(t, s))
106+
if err != nil {
107+
t.Fatalf("upsert failed #%d: %s", i, err)
108+
}
109+
}
110+
err = cs.Commit()
111+
if err != nil {
112+
t.Fatalf("commit failed: %s", err)
113+
}
114+
115+
cs2 := &channelStore{dir: dir}
116+
err = cs2.assureLoad()
117+
if err != nil {
118+
t.Fatalf("assureLoad failed: %s", err)
119+
}
120+
testassert.Equal(t, []store.Channel{
121+
*jsonToChannel(t, `{"id":"W0001","name":"channel01a"}`),
122+
*jsonToChannel(t, `{"id":"W0005","name":"channel05"}`),
123+
*jsonToChannel(t, `{"id":"W0009","name":"channel09"}`),
124+
}, cs2.channels, "wrote channels.json")
125+
}
126+
127+
func TestChannelStore_Upsert_NoID(t *testing.T) {
128+
dir, err := ioutil.TempDir("testdata", "channel_write*")
129+
if err != nil {
130+
t.Fatalf("failed to TempDir: %s", err)
131+
}
132+
t.Cleanup(func() {
133+
os.RemoveAll(dir)
134+
})
135+
cs := &channelStore{dir: dir}
136+
_, err = cs.Upsert(*jsonToChannel(t, `{"name":"foobar"}`))
137+
if err == nil {
138+
t.Fatal("upsert without ID should be failed")
139+
}
140+
if err.Error() != "empty ID is forbidden" {
141+
t.Fatalf("unexpected failure: %s", err)
142+
}
143+
}
144+
145+
func TestChannelStore_Commit_Empty(t *testing.T) {
146+
// 空のCommitは channels.json を作らない
147+
dir, err := ioutil.TempDir("testdata", "channel_write*")
148+
if err != nil {
149+
t.Fatalf("failed to TempDir: %s", err)
150+
}
151+
t.Cleanup(func() {
152+
os.RemoveAll(dir)
153+
})
154+
cs := &channelStore{dir: dir}
155+
156+
err = cs.Commit()
157+
if err != nil {
158+
t.Fatalf("unexpted failure: %s", err)
159+
}
160+
fi, err := os.Stat(cs.path())
161+
if err == nil {
162+
t.Fatalf("channels.json created unexpectedly: %s", fi.Name())
163+
}
164+
if !os.IsNotExist(err) {
165+
t.Fatalf("unexpected failure: %s", err)
166+
}
167+
}

internal/filestore/filestore.go

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
package filestore
2+
3+
import (
4+
"fmt"
5+
"os"
6+
"path/filepath"
7+
8+
"github.com/vim-jp/slacklog-generator/internal/store"
9+
)
10+
11+
// FileStore is an implementation of Store on file system.
12+
type FileStore struct {
13+
dir string
14+
15+
cs *channelStore
16+
}
17+
18+
// New creates a FileStore.
19+
func New(dir string) (*FileStore, error) {
20+
fi, err := os.Stat(dir)
21+
if err != nil {
22+
if !os.IsNotExist(err) {
23+
return nil, err
24+
}
25+
} else {
26+
if !fi.IsDir() {
27+
return nil, fmt.Errorf("path is used with not directory: %s", dir)
28+
}
29+
}
30+
return &FileStore{
31+
dir: dir,
32+
cs: &channelStore{
33+
dir: filepath.Join(dir, "slacklog_data"),
34+
},
35+
}, nil
36+
}
37+
38+
// Channel returns a Channel by ID.
39+
func (fs *FileStore) Channel(id string) (*store.Channel, error) {
40+
return fs.cs.Get(id)
41+
}

0 commit comments

Comments
 (0)