Skip to content

Commit

Permalink
chore(allsrv): add sqlite db implementation and test suite
Browse files Browse the repository at this point in the history
The test suite underwent a few changes here. There are race conditions
in the original test suite, when it comes to executing the concurrency
focused tests. Made a small update here to address that. TL/DR the race is with
the actual `*testing.T` type, so we make use of the closure to capture the
error and log. This enforces the `testing.T` access is done **after** any test
behavior.

We are also able to update the error handling a bit here. I don't care much
for what the error message says, but I care deeply about the behavior of
the errors I receive. I want to validate the correct behavior is obtained.
This is very useful when integrating amongst a larger, more complex system.
For this trivial `Foo` server, we don't have much complexity.

The sqlite db implementation here is fairly trivial once again. We're able
to reuse the test suite in full. All that was required is a new funciton to initize the unit under test (UUT),
and the rest is including 4 more lines of code to call the test around it.
Not to shabby.

Since we've effectively decoupled our domain `Foo` from the db entity `foo`,
we've provided maximum flexibility to our database implementation without
having to pollute the domain space. This is intensely useful as a system
grows.

Think through, what would it look like to add a `PostgreSQL` db implementation?
Not as much now that you have a test suite to verify the desired behavior.

The last thing that is missing here is what we do to decouple our server
from HTTP. There is a glaring hole in our design, and that's the lack of
service layer. The layer where all our business logic resides. Take a moment
to think through what this might look like.

Question: How would you break up the server so that it's no longer coupled to HTTP/REST?
Question: What can we do to allow ourselves to support any myriad of `RPC` technologies without duplicating allthe business logic?
  • Loading branch information
jsteenb2 committed Jul 10, 2024
1 parent 3cde05e commit 8aeed09
Show file tree
Hide file tree
Showing 12 changed files with 362 additions and 54 deletions.
52 changes: 51 additions & 1 deletion allsrv/cmd/allsrv/main.go
Original file line number Diff line number Diff line change
@@ -1,15 +1,31 @@
package main

import (
"database/sql"
"log"
"net/http"
"os"

"github.com/golang-migrate/migrate/v4"
migsqlite "github.com/golang-migrate/migrate/v4/database/sqlite3"
"github.com/golang-migrate/migrate/v4/source/iofs"
"github.com/jmoiron/sqlx"
_ "github.com/mattn/go-sqlite3"

"github.com/jsteenb2/mess/allsrv"
"github.com/jsteenb2/mess/allsrv/migrations"
)

func main() {
db := new(allsrv.InmemDB)
var db allsrv.DB = new(allsrv.InmemDB)
if dsn := os.Getenv("ALLSRV_SQLITE_DSN"); dsn != "" {
var err error
db, err = newSQLiteDB(dsn)
if err != nil {
log.Println("failed to open sqlite db: " + err.Error())
os.Exit(1)
}
}

var svr http.Handler
switch os.Getenv("ALLSRV_SERVER") {
Expand All @@ -34,3 +50,37 @@ func main() {
os.Exit(1)
}
}

func newSQLiteDB(dsn string) (allsrv.DB, error) {
const driver = "sqlite3"

db, err := sql.Open(driver, dsn)
if err != nil {
return nil, err
}

const dbName = "testdb"
drvr, err := migsqlite.WithInstance(db, &migsqlite.Config{DatabaseName: dbName})
if err != nil {
return nil, err
}

iodrvr, err := iofs.New(migrations.SQLite, "sqlite")
if err != nil {
return nil, err
}

m, err := migrate.NewWithInstance("iofs", iodrvr, dbName, drvr)
if err != nil {
return nil, err
}
err = m.Up()
if err != nil {
return nil, err
}

dbx := sqlx.NewDb(db, driver)
dbx.SetMaxIdleConns(1)

return allsrv.NewSQLiteDB(dbx), nil
}
2 changes: 1 addition & 1 deletion allsrv/db_inmem.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ func (db *InmemDB) CreateFoo(_ context.Context, f Foo) error {
defer db.mu.Unlock()

for _, existing := range db.m {
if f.Name == existing.Name {
if f.Name == existing.Name || f.ID == existing.ID {
return ExistsErr("foo "+f.Name+" exists", "name", f.Name, "existing_foo_id", existing.ID) // 8)
}
}
Expand Down
121 changes: 121 additions & 0 deletions allsrv/db_sqlite.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
package allsrv

import (
"context"
"database/sql"
"errors"
"sync"
"time"

sq "github.com/Masterminds/squirrel"
"github.com/jmoiron/sqlx"
"github.com/mattn/go-sqlite3"
)

// NewSQLiteDB creates a new sqlite db.
func NewSQLiteDB(db *sqlx.DB) DB {
return &sqlDB{
db: db,
sq: sq.StatementBuilder.PlaceholderFormat(sq.Question),
}
}

type sqlDB struct {
db *sqlx.DB
sq sq.StatementBuilderType

mu sync.RWMutex
}

func (s *sqlDB) CreateFoo(ctx context.Context, f Foo) error {
sb := s.sq.
Insert("foos").
Columns("id", "name", "note", "created_at", "updated_at").
Values(f.ID, f.Name, f.Note, f.CreatedAt, f.UpdatedAt)

_, err := s.exec(ctx, sb)
return err
}

func (s *sqlDB) ReadFoo(ctx context.Context, id string) (Foo, error) {
s.mu.RLock()
defer s.mu.RUnlock()

const query = `SELECT * FROM foos WHERE id=?`

var ent entFoo
err := s.db.GetContext(ctx, &ent, s.db.Rebind(query), id)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return Foo{}, NotFoundErr("foo not found for id: " + id)
}
return Foo{}, err
}

out := Foo{
ID: ent.ID,
Name: ent.Name,
Note: ent.Note,
CreatedAt: ent.CreatedAt,
UpdatedAt: ent.UpdatedAt,
}

return out, nil
}

func (s *sqlDB) UpdateFoo(ctx context.Context, f Foo) error {
sb := s.sq.
Update("foos").
Set("name", f.Name).
Set("note", f.Note).
Set("updated_at", f.UpdatedAt).
Where(sq.Eq{"id": f.ID})

return s.update(ctx, sb)
}

func (s *sqlDB) DelFoo(ctx context.Context, id string) error {
err := s.update(ctx, s.sq.Delete("foos").Where(sq.Eq{"id": id}))
return err
}

func (s *sqlDB) exec(ctx context.Context, sqlizer sq.Sqlizer) (sql.Result, error) {
query, args, err := sqlizer.ToSql()
if err != nil {
return nil, err
}

s.mu.Lock()
defer s.mu.Unlock()

res, err := s.db.ExecContext(ctx, query, args...)
if sqErr := new(sqlite3.Error); errors.As(err, sqErr) {
switch sqErr.Code {
case sqlite3.ErrConstraint:
return nil, ExistsErr("foo exists")
}
}
return res, err
}

func (s *sqlDB) update(ctx context.Context, sqlizer sq.Sqlizer) error {
res, err := s.exec(ctx, sqlizer)
if err != nil {
return err
}

n, err := res.RowsAffected()
if err == nil && n == 0 {
return NotFoundErr("foo not found")
}

return nil
}

type entFoo struct {
ID string `db:"id"`
Name string `db:"name"`
Note string `db:"note"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
}
50 changes: 50 additions & 0 deletions allsrv/db_sqlite_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
package allsrv_test

import (
"database/sql"
"testing"

"github.com/golang-migrate/migrate/v4"
migsqlite "github.com/golang-migrate/migrate/v4/database/sqlite3"
"github.com/golang-migrate/migrate/v4/source/iofs"
"github.com/jmoiron/sqlx"
_ "github.com/mattn/go-sqlite3"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/jsteenb2/mess/allsrv"
"github.com/jsteenb2/mess/allsrv/migrations"
)

func TestSQLite(t *testing.T) {
testDB(t, func(t *testing.T) allsrv.DB {
db := newSQLiteInmem(t)
t.Cleanup(func() {
assert.NoError(t, db.Close())
})

return allsrv.NewSQLiteDB(db)
})
}

func newSQLiteInmem(t *testing.T) *sqlx.DB {
const driver = "sqlite3"

db, err := sql.Open(driver, ":memory:")
require.NoError(t, err)

const dbName = "testdb"
drvr, err := migsqlite.WithInstance(db, &migsqlite.Config{DatabaseName: dbName})
require.NoError(t, err)

iodrvr, err := iofs.New(migrations.SQLite, "sqlite")
require.NoError(t, err)

m, err := migrate.NewWithInstance("iofs", iodrvr, dbName, drvr)
require.NoError(t, err)
require.NoError(t, m.Up())

dbx := sqlx.NewDb(db, driver)
dbx.SetMaxIdleConns(1)
return dbx
}
Loading

0 comments on commit 8aeed09

Please sign in to comment.