Skip to content

Commit ff34ab3

Browse files
committed
feat(volume): add sqlite repository
1 parent 9577450 commit ff34ab3

2 files changed

Lines changed: 377 additions & 0 deletions

File tree

Lines changed: 277 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,277 @@
1+
package volume
2+
3+
import (
4+
"database/sql"
5+
"encoding/json"
6+
"errors"
7+
"fmt"
8+
9+
_ "modernc.org/sqlite"
10+
)
11+
12+
type SQLiteRepository struct {
13+
db *sql.DB
14+
}
15+
16+
func OpenSQLiteRepository(dataSourceName string) (*SQLiteRepository, error) {
17+
db, err := sql.Open("sqlite", dataSourceName)
18+
if err != nil {
19+
return nil, err
20+
}
21+
22+
repository := &SQLiteRepository{db: db}
23+
if err := repository.Init(); err != nil {
24+
_ = db.Close()
25+
return nil, err
26+
}
27+
28+
return repository, nil
29+
}
30+
31+
func (r *SQLiteRepository) Init() error {
32+
_, err := r.db.Exec(`
33+
CREATE TABLE IF NOT EXISTS volumes (
34+
sequence INTEGER PRIMARY KEY AUTOINCREMENT,
35+
id TEXT NOT NULL UNIQUE,
36+
status TEXT NOT NULL,
37+
size INTEGER NOT NULL,
38+
name TEXT NOT NULL,
39+
description TEXT NOT NULL,
40+
volume_type TEXT NOT NULL,
41+
metadata_json TEXT NOT NULL,
42+
created_at TEXT NOT NULL,
43+
updated_at TEXT NOT NULL,
44+
bootable TEXT NOT NULL,
45+
encrypted INTEGER NOT NULL,
46+
multiattach INTEGER NOT NULL
47+
)`)
48+
49+
return err
50+
}
51+
52+
func (r *SQLiteRepository) Close() error {
53+
return r.db.Close()
54+
}
55+
56+
func (r *SQLiteRepository) Create(volume Volume) Volume {
57+
metadataJSON := marshalStringMap(volume.Metadata)
58+
_, err := r.db.Exec(
59+
`INSERT INTO volumes (
60+
id,
61+
status,
62+
size,
63+
name,
64+
description,
65+
volume_type,
66+
metadata_json,
67+
created_at,
68+
updated_at,
69+
bootable,
70+
encrypted,
71+
multiattach
72+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
73+
volume.ID,
74+
volume.Status,
75+
volume.Size,
76+
volume.Name,
77+
volume.Description,
78+
volume.VolumeType,
79+
metadataJSON,
80+
volume.CreatedAt,
81+
volume.UpdatedAt,
82+
volume.Bootable,
83+
boolToInt(volume.Encrypted),
84+
boolToInt(volume.Multiattach),
85+
)
86+
if err != nil {
87+
panic(fmt.Errorf("insert volume: %w", err))
88+
}
89+
90+
return volume
91+
}
92+
93+
func (r *SQLiteRepository) List() []Volume {
94+
rows, err := r.db.Query(`
95+
SELECT
96+
id,
97+
status,
98+
size,
99+
name,
100+
description,
101+
volume_type,
102+
metadata_json,
103+
created_at,
104+
updated_at,
105+
bootable,
106+
encrypted,
107+
multiattach
108+
FROM volumes
109+
ORDER BY sequence`)
110+
if err != nil {
111+
panic(fmt.Errorf("list volumes: %w", err))
112+
}
113+
defer rows.Close()
114+
115+
volumes := []Volume{}
116+
for rows.Next() {
117+
volume, err := scanVolume(rows)
118+
if err != nil {
119+
panic(fmt.Errorf("scan volume: %w", err))
120+
}
121+
volumes = append(volumes, volume)
122+
}
123+
if err := rows.Err(); err != nil {
124+
panic(fmt.Errorf("iterate volumes: %w", err))
125+
}
126+
127+
return volumes
128+
}
129+
130+
func (r *SQLiteRepository) Get(id string) (Volume, error) {
131+
row := r.db.QueryRow(`
132+
SELECT
133+
id,
134+
status,
135+
size,
136+
name,
137+
description,
138+
volume_type,
139+
metadata_json,
140+
created_at,
141+
updated_at,
142+
bootable,
143+
encrypted,
144+
multiattach
145+
FROM volumes
146+
WHERE id = ?`, id)
147+
148+
volume, err := scanVolume(row)
149+
if errors.Is(err, sql.ErrNoRows) {
150+
return Volume{}, ErrVolumeNotFound
151+
}
152+
if err != nil {
153+
return Volume{}, err
154+
}
155+
156+
return volume, nil
157+
}
158+
159+
func (r *SQLiteRepository) Update(volume Volume) (Volume, error) {
160+
metadataJSON := marshalStringMap(volume.Metadata)
161+
result, err := r.db.Exec(
162+
`UPDATE volumes
163+
SET status = ?,
164+
size = ?,
165+
name = ?,
166+
description = ?,
167+
volume_type = ?,
168+
metadata_json = ?,
169+
created_at = ?,
170+
updated_at = ?,
171+
bootable = ?,
172+
encrypted = ?,
173+
multiattach = ?
174+
WHERE id = ?`,
175+
volume.Status,
176+
volume.Size,
177+
volume.Name,
178+
volume.Description,
179+
volume.VolumeType,
180+
metadataJSON,
181+
volume.CreatedAt,
182+
volume.UpdatedAt,
183+
volume.Bootable,
184+
boolToInt(volume.Encrypted),
185+
boolToInt(volume.Multiattach),
186+
volume.ID,
187+
)
188+
if err != nil {
189+
return Volume{}, err
190+
}
191+
192+
rowsAffected, err := result.RowsAffected()
193+
if err != nil {
194+
return Volume{}, err
195+
}
196+
if rowsAffected == 0 {
197+
return Volume{}, ErrVolumeNotFound
198+
}
199+
200+
return volume, nil
201+
}
202+
203+
func (r *SQLiteRepository) Delete(id string) error {
204+
result, err := r.db.Exec(`DELETE FROM volumes WHERE id = ?`, id)
205+
if err != nil {
206+
return err
207+
}
208+
209+
rowsAffected, err := result.RowsAffected()
210+
if err != nil {
211+
return err
212+
}
213+
if rowsAffected == 0 {
214+
return ErrVolumeNotFound
215+
}
216+
217+
return nil
218+
}
219+
220+
func (r *SQLiteRepository) Reset() {
221+
if _, err := r.db.Exec(`DELETE FROM volumes`); err != nil {
222+
panic(fmt.Errorf("reset volumes: %w", err))
223+
}
224+
}
225+
226+
type volumeScanner interface {
227+
Scan(dest ...any) error
228+
}
229+
230+
func scanVolume(scanner volumeScanner) (Volume, error) {
231+
var volume Volume
232+
var metadataJSON string
233+
var encrypted int
234+
var multiattach int
235+
236+
if err := scanner.Scan(
237+
&volume.ID,
238+
&volume.Status,
239+
&volume.Size,
240+
&volume.Name,
241+
&volume.Description,
242+
&volume.VolumeType,
243+
&metadataJSON,
244+
&volume.CreatedAt,
245+
&volume.UpdatedAt,
246+
&volume.Bootable,
247+
&encrypted,
248+
&multiattach,
249+
); err != nil {
250+
return Volume{}, err
251+
}
252+
253+
if err := json.Unmarshal([]byte(metadataJSON), &volume.Metadata); err != nil {
254+
return Volume{}, err
255+
}
256+
volume.Encrypted = encrypted != 0
257+
volume.Multiattach = multiattach != 0
258+
259+
return volume, nil
260+
}
261+
262+
func marshalStringMap(value map[string]string) string {
263+
data, err := json.Marshal(value)
264+
if err != nil {
265+
panic(fmt.Errorf("marshal string map: %w", err))
266+
}
267+
268+
return string(data)
269+
}
270+
271+
func boolToInt(value bool) int {
272+
if value {
273+
return 1
274+
}
275+
276+
return 0
277+
}
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
package volume_test
2+
3+
import (
4+
"path/filepath"
5+
"testing"
6+
7+
"github.com/JSYoo5B/SandStack/internal/app/volume"
8+
"github.com/stretchr/testify/suite"
9+
)
10+
11+
type SQLiteRepositorySuite struct {
12+
suite.Suite
13+
repository *volume.SQLiteRepository
14+
}
15+
16+
func TestSQLiteRepositorySuite(t *testing.T) {
17+
suite.Run(t, new(SQLiteRepositorySuite))
18+
}
19+
20+
func (s *SQLiteRepositorySuite) SetupTest() {
21+
repository, err := volume.OpenSQLiteRepository(":memory:")
22+
s.Require().NoError(err)
23+
24+
s.repository = repository
25+
}
26+
27+
func (s *SQLiteRepositorySuite) TearDownTest() {
28+
s.Require().NoError(s.repository.Close())
29+
}
30+
31+
func (s *SQLiteRepositorySuite) TestCreateListGetAndUpdateVolume() {
32+
created := s.repository.Create(volumeFixture("vol-1"))
33+
created.Status = "available"
34+
35+
updated, err := s.repository.Update(created)
36+
s.Require().NoError(err)
37+
listed := s.repository.List()
38+
found, err := s.repository.Get(created.ID)
39+
s.Require().NoError(err)
40+
41+
s.Assert().Len(listed, 1)
42+
s.Assert().Equal(updated, listed[0])
43+
s.Assert().Equal(updated, found)
44+
}
45+
46+
func (s *SQLiteRepositorySuite) TestDeleteVolume() {
47+
created := s.repository.Create(volumeFixture("vol-1"))
48+
49+
err := s.repository.Delete(created.ID)
50+
s.Require().NoError(err)
51+
52+
_, err = s.repository.Get(created.ID)
53+
s.Require().ErrorIs(err, volume.ErrVolumeNotFound)
54+
s.Assert().Empty(s.repository.List())
55+
}
56+
57+
func (s *SQLiteRepositorySuite) TestResetClearsVolumes() {
58+
s.repository.Create(volumeFixture("vol-1"))
59+
60+
s.repository.Reset()
61+
62+
s.Assert().Empty(s.repository.List())
63+
}
64+
65+
func (s *SQLiteRepositorySuite) TestFileBackedDatabasePersistsVolumes() {
66+
path := filepath.Join(s.T().TempDir(), "sandstack.db")
67+
repository, err := volume.OpenSQLiteRepository(path)
68+
s.Require().NoError(err)
69+
70+
created := repository.Create(volumeFixture("vol-1"))
71+
s.Require().NoError(repository.Close())
72+
73+
reopened, err := volume.OpenSQLiteRepository(path)
74+
s.Require().NoError(err)
75+
defer reopened.Close()
76+
77+
found, err := reopened.Get(created.ID)
78+
s.Require().NoError(err)
79+
80+
s.Assert().Equal(created, found)
81+
}
82+
83+
func volumeFixture(id string) volume.Volume {
84+
return volume.Volume{
85+
ID: id,
86+
Status: "creating",
87+
Size: 1,
88+
Name: "database",
89+
Description: "test volume",
90+
VolumeType: "__DEFAULT__",
91+
Metadata: map[string]string{
92+
"role": "database",
93+
},
94+
CreatedAt: "2026-07-03T00:00:00.000000",
95+
UpdatedAt: "2026-07-03T00:00:00.000000",
96+
Bootable: "false",
97+
Encrypted: true,
98+
Multiattach: true,
99+
}
100+
}

0 commit comments

Comments
 (0)