2 Commits

Author SHA1 Message Date
3654383ad2 v1 2025-08-30 10:30:53 +03:00
Dmitry Fedotov
2e7ab5e382 upd README.md 2025-05-04 21:39:59 +03:00
10 changed files with 150 additions and 43 deletions

View File

@@ -1,2 +1,69 @@
# repo # repo
This module is mainly an exercise with generics. Although it is a fully functional repository for any Go structs.
Might be useful for pet-projects and like.
Example usage.
```go
import (
"code.uint32.ru/tiny/repo"
_ "github.com/mattn/go-sqlite3"
)
type My struct {
A string
B int
}
func main() {
db, err := sql.Open("sqlite3", "test.db")
if err != nil {
panic(err)
}
defer db.Close()
ctx := context.Background()
// init repository for our type "My"
r, err := repo.OpenOrCreate[My](ctx, db, "my_test_table")
if err != nil {
panic(err)
}
first := &My{
A: "hello",
B: 42,
}
if err := r.Create(ctx, "first_record", first); err != nil {
panic(err)
}
duplicate, err = r.Read(ctx, "first_record")
if err != nil {
panic(err)
}
fmt.Printf("Type: %T, Value: %+v", duplicate)
}
```
The interface is a plain CRUD.
```go
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 deletes object with id.
Delete(ctx context.Context, id string) error
}
```

View File

@@ -13,9 +13,9 @@ CREATE TABLE IF NOT EXISTS -- (
id text PRIMARY KEY, id text PRIMARY KEY,
created_at timestamp with time zone, created_at timestamp with time zone,
updated_at timestamp with time zone, updated_at timestamp with time zone,
deleted_at timestamp with time zone,
payload jsonb payload jsonb
)` )
`
func initDB(ctx context.Context, db *sql.DB, tablename string) error { func initDB(ctx context.Context, db *sql.DB, tablename string) error {
if tablename == "" { if tablename == "" {

26
repo.go
View File

@@ -16,17 +16,15 @@ var (
) )
type Repo[T any] interface { type Repo[T any] interface {
// Create inserts object into repository table. If id already exists // Create saves object to the repository. If id already exists
// in the database it is an error. // in the database it is an error.
Create(ctx context.Context, id string, v *T) error Create(ctx context.Context, id string, v *T) error
// Read returns object with specified id or ErrNotFound // Read returns object with specified id or ErrNotFound
Read(ctx context.Context, id string) (*T, error) Read(ctx context.Context, id string) (*T, error)
// Update updates object with id. // Update updates object with id.
Update(ctx context.Context, id string, v *T) error Update(ctx context.Context, id string, v *T) error
// Delete performs soft-delete (actually marks object as unavailable). // Delete deletes object from the database.
Delete(ctx context.Context, id string) error 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 // OpenOrCreate accepts *sql.DB and tablename and tries to create the named table
@@ -65,7 +63,7 @@ func (r *repo[T]) Create(ctx context.Context, id string, v *T) error {
} }
func (r *repo[T]) Read(ctx context.Context, id string) (*T, error) { 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" query := "SELECT payload FROM " + r.table + " WHERE id = $1"
row := r.db.QueryRowContext(ctx, query, id) row := r.db.QueryRowContext(ctx, query, id)
@@ -94,7 +92,7 @@ func (r *repo[T]) Update(ctx context.Context, id string, v *T) error {
return err return err
} }
query := "UPDATE " + r.table + " SET updated_at = $1, payload = $2 WHERE id = $3 AND deleted_at IS NULL" query := "UPDATE " + r.table + " SET updated_at = $1, payload = $2 WHERE id = $3"
if err := r.execContext(ctx, query, now, string(b), id); err != nil { if err := r.execContext(ctx, query, now, string(b), id); err != nil {
return err return err
@@ -103,21 +101,8 @@ func (r *repo[T]) Update(ctx context.Context, id string, v *T) error {
return nil return nil
} }
// Delete performs soft-delete. It just marks the record as deleted and it will // Delete deletes record with cpecified id.
// no longer be available to Read and Update methods.
func (r *repo[T]) Delete(ctx context.Context, id string) error { 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" query := "DELETE FROM " + r.table + " WHERE id = $1"
if err := r.execContext(ctx, query, id); err != nil { if err := r.execContext(ctx, query, id); err != nil {
@@ -132,6 +117,7 @@ func (r *repo[T]) execContext(ctx context.Context, query string, args ...any) er
if err != nil { if err != nil {
return errors.Join(ErrExecQuery, err) return errors.Join(ErrExecQuery, err)
} }
affected, err := res.RowsAffected() affected, err := res.RowsAffected()
if err != nil { if err != nil {
return errors.Join(ErrExecQuery, err) return errors.Join(ErrExecQuery, err)

View File

@@ -2,6 +2,7 @@
test: test:
docker run --rm -d -e POSTGRES_PASSWORD=postgres --name test-postgres -p 5432:5432 postgres:15.6-bookworm docker run --rm -d -e POSTGRES_PASSWORD=postgres --name test-postgres -p 5432:5432 postgres:15.6-bookworm
# wait for postgres to launch
sleep 10 sleep 10
PGX_DSN=postgres://postgres:postgres@127.0.0.1:5432 go test -v -cover PGX_DSN=postgres://postgres:postgres@127.0.0.1:5432 go test -v -cover
docker stop test-postgres docker stop test-postgres

49
test/example.go Normal file
View File

@@ -0,0 +1,49 @@
package main
import (
"context"
"database/sql"
"fmt"
"code.uint32.ru/tiny/repo"
_ "github.com/mattn/go-sqlite3"
)
type My struct {
A string
B int
}
func main() {
db, err := sql.Open("sqlite3", "test.db")
if err != nil {
panic(err)
}
defer db.Close()
ctx := context.Background()
// init repository for our type "My"
r, err := repo.OpenOrCreate[My](ctx, db, "my_test_table")
if err != nil {
panic(err)
}
first := &My{
A: "hello",
B: 42,
}
if err := r.Create(ctx, "first_record", first); err != nil {
panic(err)
}
duplicate, err := r.Read(ctx, "first_record")
if err != nil {
panic(err)
}
// prints Type: *main.My, Value: &{A:hello B:42}
fmt.Printf("Type: %T, Value: %+v\n", duplicate, duplicate)
}

View File

@@ -3,7 +3,7 @@ module pkgtest
go 1.24.2 go 1.24.2
require ( require (
code.uint32.ru/tiny/repo v0.0.0-20250504181319-b6dc6d3fce1a code.uint32.ru/tiny/repo v1.0.0
github.com/jackc/pgx/v5 v5.7.4 github.com/jackc/pgx/v5 v5.7.4
github.com/mattn/go-sqlite3 v1.14.28 github.com/mattn/go-sqlite3 v1.14.28
) )

View File

@@ -1,5 +1,5 @@
code.uint32.ru/tiny/repo v0.0.0-20250504181319-b6dc6d3fce1a h1:xlO71KbJ4fl3F05JN/I8rY3MjVF0CV3B1awa4TB6GN0= code.uint32.ru/tiny/repo v1.0.0 h1:hWLL+atKth6XwKawQcnaBkQyuiGlS7+r4pqNhvUl4XE=
code.uint32.ru/tiny/repo v0.0.0-20250504181319-b6dc6d3fce1a/go.mod h1:yk97QS1fB9mdT/5iRN4ufo6IwZRDYHFl1kDtBSTpuk0= code.uint32.ru/tiny/repo v1.0.0/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.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 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=

6
test/go.work Normal file
View File

@@ -0,0 +1,6 @@
go 1.24.5
use (
.
..
)

15
test/go.work.sum Normal file
View File

@@ -0,0 +1,15 @@
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
github.com/stretchr/objx v0.1.0 h1:4G4v2dO3VZwixGIRoQ5Lfboy6nUhCyYzaqnIAPPhYs4=
golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/net v0.21.0 h1:AQyQV4dYCvJ7vGmJyKki9+PBdyvhkSd8EIx/qb0AYv4=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.27.0 h1:WP60Sv1nlK1T6SupCHbXzSaN0b9wUmsPoRS9b61A23Q=
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=

View File

@@ -1,4 +1,4 @@
package repo_test package main
import ( import (
"context" "context"
@@ -15,11 +15,6 @@ import (
"code.uint32.ru/tiny/repo" "code.uint32.ru/tiny/repo"
) )
type My struct {
A string
B int
}
func TestSqlite3(t *testing.T) { func TestSqlite3(t *testing.T) {
db, err := sql.Open("sqlite3", "test.db") db, err := sql.Open("sqlite3", "test.db")
if err != nil { if err != nil {
@@ -132,15 +127,6 @@ func dotest(ctx context.Context, db *sql.DB, t *testing.T) {
t.Errorf("incorrect error on delete for non-existing id, want: %v, have: %v", repo.ErrNotFound, err) 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) { 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) t.Errorf("incorrect error on read for non-existing id, want: %v, have: %v", repo.ErrNotFound, err)
} }
@@ -153,7 +139,4 @@ func dotest(ctx context.Context, db *sql.DB, t *testing.T) {
t.Errorf("incorrect error on delete for non-existing id, want: %v, have: %v", repo.ErrNotFound, err) 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)
}
} }