Skip to content
This repository was archived by the owner on Jun 10, 2025. It is now read-only.

Commit e685380

Browse files
Add sql.Helper from maragu.dev/goo (#1)
1 parent 87c85a9 commit e685380

File tree

11 files changed

+415
-4
lines changed

11 files changed

+415
-4
lines changed

README.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
1-
# template
1+
# sqlh
22

3-
[![CI](https://github.com/maragudk/template/actions/workflows/ci.yml/badge.svg)](https://github.com/maragudk/template/actions/workflows/ci.yml)
3+
[![CI](https://github.com/maragudk/sqlh/actions/workflows/ci.yml/badge.svg)](https://github.com/maragudk/sqlh/actions/workflows/ci.yml)
4+
5+
SQL helpers.
46

57
Made with ✨sparkles✨ by [maragu](https://www.maragu.dev/).
68

go.mod

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,12 @@
1-
module template
1+
module maragu.dev/sqlh
22

33
go 1.24
4+
5+
require (
6+
github.com/jmoiron/sqlx v1.4.0
7+
github.com/maragudk/goqite v0.2.3
8+
github.com/mattn/go-sqlite3 v1.14.24
9+
maragu.dev/errors v0.3.0
10+
maragu.dev/is v0.2.0
11+
maragu.dev/migrate v0.6.0
12+
)

go.sum

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
2+
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
3+
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
4+
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
5+
github.com/jackc/chunkreader/v2 v2.0.1 h1:i+RDz65UE+mmpjTfyz0MoVTnzeYxroil2G82ki7MGG8=
6+
github.com/jackc/chunkreader/v2 v2.0.1/go.mod h1:odVSm741yZoC3dpHEUXIqA9tQRhFrgOHwnPIn9lDKlk=
7+
github.com/jackc/pgconn v1.14.3 h1:bVoTr12EGANZz66nZPkMInAV/KHD2TxH9npjXXgiB3w=
8+
github.com/jackc/pgconn v1.14.3/go.mod h1:RZbme4uasqzybK2RK5c65VsHxoyaml09lx3tXOcO/VM=
9+
github.com/jackc/pgio v1.0.0 h1:g12B9UwVnzGhueNavwioyEEpAmqMe1E/BN9ES+8ovkE=
10+
github.com/jackc/pgio v1.0.0/go.mod h1:oP+2QK2wFfUWgr+gxjoBH9KGBb31Eio69xUb0w5bYf8=
11+
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
12+
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
13+
github.com/jackc/pgproto3/v2 v2.3.3 h1:1HLSx5H+tXR9pW3in3zaztoEwQYRC9SQaYUHjTSUOag=
14+
github.com/jackc/pgproto3/v2 v2.3.3/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA=
15+
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
16+
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
17+
github.com/jackc/pgtype v1.14.0 h1:y+xUdabmyMkJLyApYuPj38mW+aAIqCe5uuBB51rH3Vw=
18+
github.com/jackc/pgtype v1.14.0/go.mod h1:LUMuVrfsFfdKGLw+AFFVv6KtHOFMwRgDDzBt76IqCA4=
19+
github.com/jackc/pgx/v4 v4.18.2 h1:xVpYkNR5pk5bMCZGfClbO962UIqVABcAGt7ha1s/FeU=
20+
github.com/jackc/pgx/v4 v4.18.2/go.mod h1:Ey4Oru5tH5sB6tV7hDmfWFahwF15Eb7DNXlRKx2CkVw=
21+
github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o=
22+
github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY=
23+
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
24+
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
25+
github.com/maragudk/goqite v0.2.3 h1:R8oVD6IMCQfjhCKyGIYwWxR1w8yxjvT/3uwYtA656jE=
26+
github.com/maragudk/goqite v0.2.3/go.mod h1:5430TCLkycUeLE314c9fifTrTbwcJqJXdU3iyEiF6hM=
27+
github.com/maragudk/is v0.1.0 h1:obq9anZNmOYcaNbeT0LMyjIexdNeYTw/TLAPD/BnZHA=
28+
github.com/maragudk/is v0.1.0/go.mod h1:W/r6+TpnISu+a88OLXQy5JQGCOhXQXXLD2e5b4xMn5c=
29+
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
30+
github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM=
31+
github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
32+
golang.org/x/crypto v0.20.0 h1:jmAMJJZXr5KiCw05dfYK9QnqaqKLYXijU23lsEdcQqg=
33+
golang.org/x/crypto v0.20.0/go.mod h1:Xwo95rrVNIoSMx9wa1JroENMToLWn3RNVrTBpLHgZPQ=
34+
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
35+
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
36+
maragu.dev/errors v0.3.0 h1:huI+n+ddMfVgQFD+cEqIPaozUlfz3TkfgpkssNip5G0=
37+
maragu.dev/errors v0.3.0/go.mod h1:cygLiyNnq4ofF3whYscilo2ecUADCaUQXwvwFrMOhmM=
38+
maragu.dev/is v0.2.0 h1:poeuVEA5GG3vrDpGmzo2KjWtIMZmqUyvGnOB0/pemig=
39+
maragu.dev/is v0.2.0/go.mod h1:bviaM5S0fBshCw7wuumFGTju/izopZ/Yvq4g7Klc7y8=
40+
maragu.dev/migrate v0.6.0 h1:gJLAIVaRh9z9sN55Q2sWwScpEH+JsT6N0L1DnzedXFE=
41+
maragu.dev/migrate v0.6.0/go.mod h1:TdZBD5wRvBbzLocsSV08kyvLiLCn0Q6DvgYHmyygWVQ=

sql/helper.go

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
package sql
2+
3+
import (
4+
"context"
5+
"database/sql"
6+
"log/slog"
7+
"time"
8+
9+
"github.com/jmoiron/sqlx"
10+
"github.com/maragudk/goqite"
11+
_ "github.com/mattn/go-sqlite3"
12+
"maragu.dev/errors"
13+
)
14+
15+
type Helper struct {
16+
DB *sqlx.DB
17+
JobsQ *goqite.Queue
18+
log *slog.Logger
19+
path string
20+
}
21+
22+
type NewHelperOptions struct {
23+
Log *slog.Logger
24+
Path string
25+
}
26+
27+
// NewHelper with the given options.
28+
// If no logger is provided, logs are discarded.
29+
func NewHelper(opts NewHelperOptions) *Helper {
30+
if opts.Log == nil {
31+
opts.Log = slog.New(slog.DiscardHandler)
32+
}
33+
34+
// - Set WAL mode (not strictly necessary each time because it's persisted in the database, but good for first run)
35+
// - Set busy timeout, so concurrent writers wait on each other instead of erroring immediately
36+
// - Enable foreign key checks
37+
opts.Path += "?_journal=WAL&_timeout=5000&_fk=true"
38+
39+
return &Helper{
40+
log: opts.Log,
41+
path: opts.Path,
42+
}
43+
}
44+
45+
func (h *Helper) Connect() error {
46+
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
47+
defer cancel()
48+
49+
h.log.Info("Starting database", "path", h.path)
50+
51+
var err error
52+
h.DB, err = sqlx.ConnectContext(ctx, "sqlite3", h.path)
53+
if err != nil {
54+
return err
55+
}
56+
57+
return nil
58+
}
59+
60+
// InTransaction runs callback in a transaction, and makes sure to handle rollbacks, commits etc.
61+
func (h *Helper) InTransaction(ctx context.Context, callback func(tx *Tx) error) (err error) {
62+
tx, err := h.DB.BeginTxx(ctx, &sql.TxOptions{Isolation: sql.LevelSerializable})
63+
if err != nil {
64+
return errors.Wrap(err, "error beginning transaction")
65+
}
66+
defer func() {
67+
if rec := recover(); rec != nil {
68+
err = rollback(tx, errors.Newf("panic: %v", rec))
69+
}
70+
}()
71+
if err := callback(&Tx{Tx: tx}); err != nil {
72+
return rollback(tx, err)
73+
}
74+
if err := tx.Commit(); err != nil {
75+
return errors.Wrap(err, "error committing transaction")
76+
}
77+
78+
return nil
79+
}
80+
81+
// rollback a transaction, handling both the original error and any transaction rollback errors.
82+
func rollback(tx *sqlx.Tx, err error) error {
83+
if txErr := tx.Rollback(); txErr != nil {
84+
return errors.Wrap(err, "error rolling back transaction after error (transaction error: %v), original error", txErr)
85+
}
86+
return err
87+
}
88+
89+
func (h *Helper) Ping(ctx context.Context) error {
90+
return h.InTransaction(ctx, func(tx *Tx) error {
91+
return tx.Exec(ctx, `select 1`)
92+
})
93+
}
94+
95+
func (h *Helper) Select(ctx context.Context, dest any, query string, args ...any) error {
96+
return h.DB.SelectContext(ctx, dest, query, args...)
97+
}
98+
99+
func (h *Helper) Get(ctx context.Context, dest any, query string, args ...any) error {
100+
return h.DB.GetContext(ctx, dest, query, args...)
101+
}
102+
103+
func (h *Helper) Exec(ctx context.Context, query string, args ...any) error {
104+
_, err := h.DB.ExecContext(ctx, query, args...)
105+
return err
106+
}
107+
108+
type Tx struct {
109+
Tx *sqlx.Tx
110+
}
111+
112+
func (t *Tx) Select(ctx context.Context, dest any, query string, args ...any) error {
113+
return t.Tx.SelectContext(ctx, dest, query, args...)
114+
}
115+
116+
func (t *Tx) Get(ctx context.Context, dest any, query string, args ...any) error {
117+
return t.Tx.GetContext(ctx, dest, query, args...)
118+
}
119+
120+
func (t *Tx) Exec(ctx context.Context, query string, args ...any) error {
121+
_, err := t.Tx.ExecContext(ctx, query, args...)
122+
return err
123+
}
124+
125+
var ErrNoRows = sql.ErrNoRows

sql/helper_test.go

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
package sql_test
2+
3+
import (
4+
"context"
5+
"testing"
6+
7+
"maragu.dev/is"
8+
9+
"maragu.dev/sqlh/sqltest"
10+
)
11+
12+
func TestHelper_Ping(t *testing.T) {
13+
t.Run("can ping", func(t *testing.T) {
14+
db := sqltest.NewHelper(t)
15+
16+
err := db.Ping(context.Background())
17+
is.NotError(t, err)
18+
})
19+
}

sql/migrate.go

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
package sql
2+
3+
import (
4+
"context"
5+
"embed"
6+
"io"
7+
"io/fs"
8+
"os"
9+
"path/filepath"
10+
"sync"
11+
"testing/fstest"
12+
13+
"maragu.dev/migrate"
14+
)
15+
16+
//go:embed migrations
17+
var migrations embed.FS
18+
var migrationsOnce sync.Once
19+
var allMigrations fs.FS
20+
21+
func (h *Helper) MigrateUp(ctx context.Context) error {
22+
return migrate.Up(ctx, h.DB.DB, h.getMigrations())
23+
}
24+
25+
func (h *Helper) MigrateDown(ctx context.Context) error {
26+
return migrate.Down(ctx, h.DB.DB, h.getMigrations())
27+
}
28+
29+
// getMigrations both embedded here in this module, as well as in the client module.
30+
func (h *Helper) getMigrations() fs.FS {
31+
migrationsOnce.Do(func() {
32+
migrationsDirs := []fs.FS{migrations}
33+
34+
for _, path := range []string{"sql/migrations", "../sql/migrations"} {
35+
ms := os.DirFS(path)
36+
matches, err := fs.Glob(ms, "*.sql")
37+
if err == nil && len(matches) > 0 {
38+
migrationsDirs = append(migrationsDirs, ms)
39+
}
40+
}
41+
42+
var err error
43+
allMigrations, err = toMapFS(migrationsDirs...)
44+
if err != nil {
45+
panic(err)
46+
}
47+
migrationNames, err := fs.Glob(allMigrations, "*.sql")
48+
if err != nil {
49+
panic(err)
50+
}
51+
h.log.Info("Found migrations", "files", migrationNames)
52+
})
53+
return allMigrations
54+
}
55+
56+
// toMapFS reads all files from the given [fs.FS] arguments into an [fstest.MapFS].
57+
func toMapFS(filesystems ...fs.FS) (fstest.MapFS, error) {
58+
result := make(fstest.MapFS)
59+
60+
for _, fsys := range filesystems {
61+
err := fs.WalkDir(fsys, ".", func(path string, d fs.DirEntry, err error) (outErr error) {
62+
if err != nil {
63+
return err
64+
}
65+
66+
if d.IsDir() {
67+
return nil
68+
}
69+
70+
info, err := d.Info()
71+
if err != nil {
72+
return err
73+
}
74+
75+
file, err := fsys.Open(path)
76+
if err != nil {
77+
return err
78+
}
79+
defer func() {
80+
if err := file.Close(); err != nil && outErr == nil {
81+
outErr = err
82+
}
83+
}()
84+
85+
data, err := io.ReadAll(file)
86+
if err != nil {
87+
return err
88+
}
89+
90+
base := filepath.Base(path)
91+
result[base] = &fstest.MapFile{
92+
Data: data,
93+
Mode: info.Mode(),
94+
ModTime: info.ModTime(),
95+
Sys: info.Sys(),
96+
}
97+
98+
return nil
99+
})
100+
101+
if err != nil {
102+
return nil, err
103+
}
104+
}
105+
106+
return result, nil
107+
}

sql/migrate_test.go

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
package sql_test
2+
3+
import (
4+
"context"
5+
"testing"
6+
7+
"maragu.dev/is"
8+
9+
"maragu.dev/sqlh/sqltest"
10+
)
11+
12+
func TestHelper_Migrate(t *testing.T) {
13+
t.Run("can migrate down and back up", func(t *testing.T) {
14+
h := sqltest.NewHelper(t)
15+
16+
err := h.MigrateDown(context.Background())
17+
is.NotError(t, err)
18+
19+
err = h.MigrateUp(context.Background())
20+
is.NotError(t, err)
21+
22+
var version string
23+
err = h.Get(context.Background(), &version, `select version from migrations`)
24+
is.NotError(t, err)
25+
is.True(t, len(version) > 0)
26+
})
27+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
drop table goqite;
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
create table goqite (
2+
id text primary key default ('m_' || lower(hex(randomblob(16)))),
3+
created text not null default (strftime('%Y-%m-%dT%H:%M:%fZ')),
4+
updated text not null default (strftime('%Y-%m-%dT%H:%M:%fZ')),
5+
queue text not null,
6+
body blob not null,
7+
timeout text not null default (strftime('%Y-%m-%dT%H:%M:%fZ')),
8+
received integer not null default 0
9+
) strict;
10+
11+
create trigger goqite_updated_timestamp after update on goqite begin
12+
update goqite set updated = strftime('%Y-%m-%dT%H:%M:%fZ') where id = old.id;
13+
end;
14+
15+
create index goqite_queue_created_idx on goqite (queue, created);

0 commit comments

Comments
 (0)