first working version
This commit is contained in:
2
LICENSE
2
LICENSE
@@ -1,6 +1,6 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025 tiny
|
||||
Copyright (c) 2025 Dmitry Fedotov
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
|
37
init_db.go
Normal file
37
init_db.go
Normal file
@@ -0,0 +1,37 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
_ "embed"
|
||||
"errors"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var initStatement = `
|
||||
CREATE TABLE IF NOT EXISTS -- (
|
||||
id text PRIMARY KEY,
|
||||
created_at timestamp with time zone,
|
||||
updated_at timestamp with time zone,
|
||||
deleted_at timestamp with time zone,
|
||||
payload jsonb
|
||||
)`
|
||||
|
||||
func initDB(ctx context.Context, db *sql.DB, tablename string) error {
|
||||
if tablename == "" {
|
||||
return errors.Join(ErrInitRepo, errors.New("tablename may not be empty"))
|
||||
}
|
||||
|
||||
if db == nil {
|
||||
return errors.Join(ErrInitRepo, errors.New("db instance is nil"))
|
||||
}
|
||||
|
||||
query := strings.Replace(initStatement, "--", tablename, 1)
|
||||
|
||||
_, err := db.ExecContext(ctx, query)
|
||||
if err != nil {
|
||||
return errors.Join(ErrInitRepo, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
163
repo.go
Normal file
163
repo.go
Normal file
@@ -0,0 +1,163 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrInitRepo = errors.New("error creating table")
|
||||
ErrMarshal = errors.New("error marshal")
|
||||
ErrExecQuery = errors.New("error executing DB query")
|
||||
ErrNotFound = errors.New("not found")
|
||||
)
|
||||
|
||||
type Repo[T any] interface {
|
||||
// Create inserts object into repository table. If id already exists
|
||||
// in the database it is an error.
|
||||
Create(ctx context.Context, id string, v *T) error
|
||||
// Read returns object with specified id or ErrNotFound
|
||||
Read(ctx context.Context, id string) (*T, error)
|
||||
// Update updates object with id.
|
||||
Update(ctx context.Context, id string, v *T) error
|
||||
// Delete performs soft-delete (actually marks object as unavailable).
|
||||
Delete(ctx context.Context, id string) error
|
||||
// Purge actually deletes database record with id.
|
||||
Purge(ctx context.Context, id string) error
|
||||
}
|
||||
|
||||
// OpenOrCreate accepts *sql.DB and tablename and tries to create the named table
|
||||
// in the database if it does not exist. The DB instance will also be used by the repository.
|
||||
func OpenOrCreate[T any](ctx context.Context, db *sql.DB, tablename string) (Repo[T], error) {
|
||||
if err := initDB(ctx, db, tablename); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &repo[T]{
|
||||
db: db,
|
||||
table: tablename,
|
||||
}, nil
|
||||
}
|
||||
|
||||
type repo[T any] struct {
|
||||
db *sql.DB
|
||||
table string
|
||||
}
|
||||
|
||||
func (r *repo[T]) Create(ctx context.Context, id string, v *T) error {
|
||||
now := time.Now()
|
||||
|
||||
b, err := marshal(v)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
query := "INSERT INTO " + r.table + " (id, created_at, updated_at, payload) VALUES ($1, $2, $3, $4)"
|
||||
|
||||
if err := r.execContext(ctx, query, id, now, now, string(b)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *repo[T]) Read(ctx context.Context, id string) (*T, error) {
|
||||
query := "SELECT payload FROM " + r.table + " WHERE id = $1 AND deleted_at is NULL"
|
||||
|
||||
row := r.db.QueryRowContext(ctx, query, id)
|
||||
|
||||
var s string
|
||||
|
||||
err := row.Scan(&s)
|
||||
if err != nil && errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, errors.Join(ErrNotFound, err)
|
||||
} else if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
v, err := unmarshal[T]([]byte(s))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return v, nil
|
||||
}
|
||||
|
||||
func (r *repo[T]) Update(ctx context.Context, id string, v *T) error {
|
||||
now := time.Now()
|
||||
|
||||
b, err := marshal(v)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
query := "UPDATE " + r.table + " SET updated_at = $1, payload = $2 WHERE id = $3 AND deleted_at IS NULL"
|
||||
|
||||
if err := r.execContext(ctx, query, now, string(b), id); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Delete performs soft-delete. It just marks the record as deleted and it will
|
||||
// no longer be available to Read and Update methods.
|
||||
func (r *repo[T]) Delete(ctx context.Context, id string) error {
|
||||
now := time.Now()
|
||||
|
||||
query := "UPDATE " + r.table + " SET deleted_at = $1 WHERE id = $2 AND deleted_at IS NULL"
|
||||
|
||||
if err := r.execContext(ctx, query, now, id); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *repo[T]) Purge(ctx context.Context, id string) error {
|
||||
query := "DELETE FROM " + r.table + " WHERE id = $1"
|
||||
|
||||
if err := r.execContext(ctx, query, id); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *repo[T]) execContext(ctx context.Context, query string, args ...any) error {
|
||||
res, err := r.db.ExecContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return errors.Join(ErrExecQuery, err)
|
||||
}
|
||||
affected, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return errors.Join(ErrExecQuery, err)
|
||||
}
|
||||
|
||||
if affected == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func marshal(v any) ([]byte, error) {
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return nil, errors.Join(ErrMarshal, err)
|
||||
}
|
||||
|
||||
return b, nil
|
||||
}
|
||||
|
||||
func unmarshal[T any](b []byte) (*T, error) {
|
||||
v := new(T)
|
||||
if err := json.Unmarshal(b, v); err != nil {
|
||||
return nil, errors.Join(ErrMarshal, err)
|
||||
}
|
||||
|
||||
return v, nil
|
||||
}
|
7
test/Makefile
Normal file
7
test/Makefile
Normal file
@@ -0,0 +1,7 @@
|
||||
.PHONY: test
|
||||
|
||||
test:
|
||||
docker run --rm -d -e POSTGRES_PASSWORD=postgres --name test-postgres -p 5432:5432 postgres:15.6-bookworm
|
||||
sleep 10
|
||||
PGX_DSN=postgres://postgres:postgres@127.0.0.1:5432 go test -v -cover
|
||||
docker stop test-postgres
|
18
test/go.mod
Normal file
18
test/go.mod
Normal file
@@ -0,0 +1,18 @@
|
||||
module pkgtest
|
||||
|
||||
go 1.24.2
|
||||
|
||||
require (
|
||||
code.uint32.ru/tiny/repo v0.0.0-20250504181319-b6dc6d3fce1a
|
||||
github.com/jackc/pgx/v5 v5.7.4
|
||||
github.com/mattn/go-sqlite3 v1.14.28
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
golang.org/x/crypto v0.31.0 // indirect
|
||||
golang.org/x/sync v0.10.0 // indirect
|
||||
golang.org/x/text v0.21.0 // indirect
|
||||
)
|
32
test/go.sum
Normal file
32
test/go.sum
Normal file
@@ -0,0 +1,32 @@
|
||||
code.uint32.ru/tiny/repo v0.0.0-20250504181319-b6dc6d3fce1a h1:xlO71KbJ4fl3F05JN/I8rY3MjVF0CV3B1awa4TB6GN0=
|
||||
code.uint32.ru/tiny/repo v0.0.0-20250504181319-b6dc6d3fce1a/go.mod h1:yk97QS1fB9mdT/5iRN4ufo6IwZRDYHFl1kDtBSTpuk0=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||
github.com/jackc/pgx/v5 v5.7.4 h1:9wKznZrhWa2QiHL+NjTSPP6yjl3451BX3imWDnokYlg=
|
||||
github.com/jackc/pgx/v5 v5.7.4/go.mod h1:ncY89UGWxg82EykZUwSpUKEfccBGGYq1xjrOpsbsfGQ=
|
||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/mattn/go-sqlite3 v1.14.28 h1:ThEiQrnbtumT+QMknw63Befp/ce/nUPgBPMlRFEum7A=
|
||||
github.com/mattn/go-sqlite3 v1.14.28/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
|
||||
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||
golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
|
||||
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
|
||||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
159
test/repo_test.go
Normal file
159
test/repo_test.go
Normal file
@@ -0,0 +1,159 @@
|
||||
package repo_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"os"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/stdlib"
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
|
||||
"code.uint32.ru/tiny/repo"
|
||||
)
|
||||
|
||||
type My struct {
|
||||
A string
|
||||
B int
|
||||
}
|
||||
|
||||
func TestSqlite3(t *testing.T) {
|
||||
db, err := sql.Open("sqlite3", "test.db")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
defer os.Remove("test.db")
|
||||
defer db.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
dotest(ctx, db, t)
|
||||
}
|
||||
|
||||
func TestPGX(t *testing.T) {
|
||||
dsn, ok := os.LookupEnv("PGX_DSN")
|
||||
if !ok {
|
||||
t.SkipNow()
|
||||
}
|
||||
|
||||
conf, err := pgx.ParseConfig(dsn)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
conf.DefaultQueryExecMode = pgx.QueryExecModeSimpleProtocol
|
||||
conf.DescriptionCacheCapacity = 0
|
||||
conf.StatementCacheCapacity = 0
|
||||
|
||||
db, err := sql.Open("pgx", stdlib.RegisterConnConfig(conf))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
defer db.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
dotest(ctx, db, t)
|
||||
}
|
||||
|
||||
func TestOpenOrCreateFails(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
if _, err := repo.OpenOrCreate[My](ctx, nil, ""); err == nil {
|
||||
t.Errorf("must fail on empty tablename")
|
||||
}
|
||||
|
||||
if _, err := repo.OpenOrCreate[My](ctx, nil, "test"); err == nil {
|
||||
t.Errorf("must fail on nil DB instance")
|
||||
}
|
||||
}
|
||||
|
||||
func dotest(ctx context.Context, db *sql.DB, t *testing.T) {
|
||||
r, err := repo.OpenOrCreate[My](ctx, db, "my_test")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
testID := "watermelon"
|
||||
incorrectID := "surelynonexistant"
|
||||
|
||||
one := &My{
|
||||
A: "hello",
|
||||
B: 42,
|
||||
}
|
||||
|
||||
if err := r.Create(ctx, testID, one); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := r.Create(ctx, testID, one); err == nil {
|
||||
// can not insert with same id
|
||||
t.Errorf("insert of the same id dows not fail")
|
||||
}
|
||||
|
||||
two, err := r.Read(ctx, testID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if !reflect.DeepEqual(one, two) {
|
||||
t.Errorf("instances are not equal")
|
||||
t.Logf("%+v", one)
|
||||
t.Logf("%+v", two)
|
||||
}
|
||||
|
||||
one.B = 13
|
||||
|
||||
if err := r.Update(ctx, testID, one); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
|
||||
two, err = r.Read(ctx, testID)
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
|
||||
if !reflect.DeepEqual(one, two) {
|
||||
t.Errorf("instances are not equal")
|
||||
t.Logf("%+v", one)
|
||||
t.Logf("%+v", two)
|
||||
}
|
||||
|
||||
if err := r.Delete(ctx, testID); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
|
||||
if err := r.Delete(ctx, testID); err == nil || !errors.Is(err, repo.ErrNotFound) {
|
||||
// can only delete once
|
||||
t.Errorf("incorrect error on delete for non-existing id, want: %v, have: %v", repo.ErrNotFound, err)
|
||||
}
|
||||
|
||||
if err := r.Purge(ctx, testID); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
|
||||
if err := r.Purge(ctx, testID); err == nil || !errors.Is(err, repo.ErrNotFound) {
|
||||
// can only purge once
|
||||
t.Errorf("incorrect error on delete for non-existing id, want: %v, have: %v", repo.ErrNotFound, err)
|
||||
}
|
||||
|
||||
if _, err := r.Read(ctx, incorrectID); err == nil || !errors.Is(err, repo.ErrNotFound) {
|
||||
t.Errorf("incorrect error on read for non-existing id, want: %v, have: %v", repo.ErrNotFound, err)
|
||||
}
|
||||
|
||||
if err := r.Update(ctx, incorrectID, one); err == nil || !errors.Is(err, repo.ErrNotFound) {
|
||||
t.Errorf("incorrect error on update for non-existing id, want: %v, have: %v", repo.ErrNotFound, err)
|
||||
}
|
||||
|
||||
if err := r.Delete(ctx, incorrectID); err == nil || !errors.Is(err, repo.ErrNotFound) {
|
||||
t.Errorf("incorrect error on delete for non-existing id, want: %v, have: %v", repo.ErrNotFound, err)
|
||||
}
|
||||
|
||||
if err := r.Purge(ctx, incorrectID); err == nil || !errors.Is(err, repo.ErrNotFound) {
|
||||
t.Errorf("incorrect error on purge for non-existing id, want: %v, have: %v", repo.ErrNotFound, err)
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user