LINUS ROUND 1: Delete dead code (rust/c), generic Store[T], tests, slim main.go
- Removed rust-service/, c-runtime/, kafka stubs - Generic Store[T] pattern with real tests - Slimmed main.go from 324 to ~50 lines - Added config, middleware, store, ledger, pdf tests - Frontend SPA shell with router - Binary: 15.5MB -> 12MB
This commit is contained in:
@@ -0,0 +1,138 @@
|
||||
// Package store provides a generic CRUD repository for BOC entities.
|
||||
// Linus principle: write it once, use it everywhere.
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/lib/pq"
|
||||
)
|
||||
|
||||
// DB wraps sql.DB with helper methods
|
||||
type DB struct {
|
||||
*sql.DB
|
||||
}
|
||||
|
||||
// New wraps an existing sql.DB
|
||||
func New(db *sql.DB) *DB {
|
||||
return &DB{db}
|
||||
}
|
||||
|
||||
// WithTx executes fn inside a transaction. Commits on nil error, rolls back on error.
|
||||
func (db *DB) WithTx(ctx context.Context, fn func(*sql.Tx) error) error {
|
||||
tx, err := db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin tx: %w", err)
|
||||
}
|
||||
if err := fn(tx); err != nil {
|
||||
_ = tx.Rollback()
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// Scanner knows how to scan a database row into itself
|
||||
type Scanner interface {
|
||||
ScanRow(*sql.Rows) error
|
||||
}
|
||||
|
||||
// Scanners knows how to scan a single row
|
||||
type Scanners interface {
|
||||
ScanRow(*sql.Row) error
|
||||
}
|
||||
|
||||
// Store provides generic CRUD for a table.
|
||||
// T must implement Scanner for List and Scanners for Get.
|
||||
type Store[T Scanner] struct {
|
||||
db *DB
|
||||
table string
|
||||
columns []string
|
||||
scanFn func(*sql.Rows) (T, error)
|
||||
scanOneFn func(*sql.Row) (T, error)
|
||||
}
|
||||
|
||||
// NewStore creates a Store for the given table and columns.
|
||||
func NewStore[T Scanner](db *DB, table string, columns []string,
|
||||
scanFn func(*sql.Rows) (T, error),
|
||||
scanOneFn func(*sql.Row) (T, error)) *Store[T] {
|
||||
return &Store[T]{
|
||||
db: db,
|
||||
table: table,
|
||||
columns: columns,
|
||||
scanFn: scanFn,
|
||||
scanOneFn: scanOneFn,
|
||||
}
|
||||
}
|
||||
|
||||
// List returns all rows matching the where clause
|
||||
func (s *Store[T]) List(ctx context.Context, where string, args ...interface{}) ([]T, error) {
|
||||
query := fmt.Sprintf("SELECT %s FROM %s", strings.Join(s.columns, ", "), s.table)
|
||||
if where != "" {
|
||||
query += " WHERE " + where
|
||||
}
|
||||
query += " ORDER BY created_at DESC LIMIT 100"
|
||||
|
||||
rows, err := s.db.QueryContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list %s: %w", s.table, err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var results []T
|
||||
for rows.Next() {
|
||||
item, err := s.scanFn(rows)
|
||||
if err != nil {
|
||||
continue // skip bad rows, log in production
|
||||
}
|
||||
results = append(results, item)
|
||||
}
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// Get returns a single row by ID
|
||||
func (s *Store[T]) Get(ctx context.Context, id string) (T, error) {
|
||||
var zero T
|
||||
query := fmt.Sprintf("SELECT %s FROM %s WHERE id = $1", strings.Join(s.columns, ", "), s.table)
|
||||
row := s.db.QueryRowContext(ctx, query, id)
|
||||
item, err := s.scanOneFn(row)
|
||||
if err == sql.ErrNoRows {
|
||||
return zero, fmt.Errorf("%s not found", s.table)
|
||||
}
|
||||
if err != nil {
|
||||
return zero, fmt.Errorf("get %s: %w", s.table, err)
|
||||
}
|
||||
return item, nil
|
||||
}
|
||||
|
||||
// Delete removes a row by ID
|
||||
func (s *Store[T]) Delete(ctx context.Context, id string) error {
|
||||
query := fmt.Sprintf("DELETE FROM %s WHERE id = $1", s.table)
|
||||
_, err := s.db.ExecContext(ctx, query, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete %s: %w", s.table, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Helper: pqArray handles nil slices
|
||||
func pqArray(a []string) interface{} {
|
||||
if a == nil {
|
||||
return nil
|
||||
}
|
||||
return pq.Array(a)
|
||||
}
|
||||
|
||||
// Helper: now returns current time
|
||||
func now() time.Time {
|
||||
return time.Now().UTC()
|
||||
}
|
||||
|
||||
// Helper: isZero checks if a value is zero
|
||||
func isZero(v interface{}) bool {
|
||||
return reflect.ValueOf(v).IsZero()
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"testing"
|
||||
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestDB_WithTx_Commit(t *testing.T) {
|
||||
db, mock, err := sqlmock.New()
|
||||
require.NoError(t, err)
|
||||
defer db.Close()
|
||||
|
||||
sdb := New(db)
|
||||
|
||||
mock.ExpectBegin()
|
||||
mock.ExpectExec("INSERT INTO test").WillReturnResult(sqlmock.NewResult(1, 1))
|
||||
mock.ExpectCommit()
|
||||
|
||||
err = sdb.WithTx(context.Background(), func(tx *sql.Tx) error {
|
||||
_, err := tx.Exec("INSERT INTO test VALUES (1)")
|
||||
return err
|
||||
})
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.NoError(t, mock.ExpectationsWereMet())
|
||||
}
|
||||
|
||||
func TestDB_WithTx_Rollback(t *testing.T) {
|
||||
db, mock, err := sqlmock.New()
|
||||
require.NoError(t, err)
|
||||
defer db.Close()
|
||||
|
||||
sdb := New(db)
|
||||
|
||||
mock.ExpectBegin()
|
||||
mock.ExpectRollback()
|
||||
|
||||
testErr := assert.AnError
|
||||
err = sdb.WithTx(context.Background(), func(tx *sql.Tx) error {
|
||||
return testErr
|
||||
})
|
||||
|
||||
assert.Error(t, err)
|
||||
assert.NoError(t, mock.ExpectationsWereMet())
|
||||
}
|
||||
|
||||
// mockEntity for generic store tests
|
||||
type mockEntity struct {
|
||||
ID string
|
||||
Name string
|
||||
}
|
||||
|
||||
func (m *mockEntity) ScanRow(rows *sql.Rows) error {
|
||||
return rows.Scan(&m.ID, &m.Name)
|
||||
}
|
||||
|
||||
func scanRows(rows *sql.Rows) (*mockEntity, error) {
|
||||
m := &mockEntity{}
|
||||
err := m.ScanRow(rows)
|
||||
return m, err
|
||||
}
|
||||
|
||||
func scanRow(row *sql.Row) (*mockEntity, error) {
|
||||
m := &mockEntity{}
|
||||
err := row.Scan(&m.ID, &m.Name)
|
||||
return m, err
|
||||
}
|
||||
|
||||
func TestStore_List(t *testing.T) {
|
||||
db, mock, err := sqlmock.New()
|
||||
require.NoError(t, err)
|
||||
defer db.Close()
|
||||
|
||||
sdb := New(db)
|
||||
store := NewStore(sdb, "test_table", []string{"id", "name"}, scanRows, scanRow)
|
||||
|
||||
mock.ExpectQuery("SELECT id, name FROM test_table").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "name"}).
|
||||
AddRow("1", "Alice").
|
||||
AddRow("2", "Bob"))
|
||||
|
||||
results, err := store.List(context.Background(), "")
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, results, 2)
|
||||
assert.Equal(t, "Alice", results[0].Name)
|
||||
assert.Equal(t, "Bob", results[1].Name)
|
||||
|
||||
assert.NoError(t, mock.ExpectationsWereMet())
|
||||
}
|
||||
|
||||
func TestStore_List_WithWhere(t *testing.T) {
|
||||
db, mock, err := sqlmock.New()
|
||||
require.NoError(t, err)
|
||||
defer db.Close()
|
||||
|
||||
sdb := New(db)
|
||||
store := NewStore(sdb, "test_table", []string{"id", "name"}, scanRows, scanRow)
|
||||
|
||||
mock.ExpectQuery("SELECT id, name FROM test_table WHERE status = \\$1 ORDER BY created_at DESC LIMIT 100").
|
||||
WithArgs("active").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "name"}).AddRow("1", "Alice"))
|
||||
|
||||
results, err := store.List(context.Background(), "status = $1", "active")
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, results, 1)
|
||||
|
||||
assert.NoError(t, mock.ExpectationsWereMet())
|
||||
}
|
||||
|
||||
func TestStore_Get(t *testing.T) {
|
||||
db, mock, err := sqlmock.New()
|
||||
require.NoError(t, err)
|
||||
defer db.Close()
|
||||
|
||||
sdb := New(db)
|
||||
store := NewStore(sdb, "test_table", []string{"id", "name"}, scanRows, scanRow)
|
||||
|
||||
mock.ExpectQuery("SELECT id, name FROM test_table WHERE id = \\$1").
|
||||
WithArgs("1").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "name"}).AddRow("1", "Alice"))
|
||||
|
||||
result, err := store.Get(context.Background(), "1")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "Alice", result.Name)
|
||||
|
||||
assert.NoError(t, mock.ExpectationsWereMet())
|
||||
}
|
||||
|
||||
func TestStore_Get_NotFound(t *testing.T) {
|
||||
db, mock, err := sqlmock.New()
|
||||
require.NoError(t, err)
|
||||
defer db.Close()
|
||||
|
||||
sdb := New(db)
|
||||
store := NewStore(sdb, "test_table", []string{"id", "name"},
|
||||
func(rows *sql.Rows) (*mockEntity, error) { return nil, nil },
|
||||
scanRow,
|
||||
)
|
||||
|
||||
mock.ExpectQuery("SELECT id, name FROM test_table WHERE id = \\$1").
|
||||
WithArgs("999").
|
||||
WillReturnError(sql.ErrNoRows)
|
||||
|
||||
_, err = store.Get(context.Background(), "999")
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "not found")
|
||||
|
||||
assert.NoError(t, mock.ExpectationsWereMet())
|
||||
}
|
||||
|
||||
func TestStore_Delete(t *testing.T) {
|
||||
db, mock, err := sqlmock.New()
|
||||
require.NoError(t, err)
|
||||
defer db.Close()
|
||||
|
||||
sdb := New(db)
|
||||
store := NewStore(sdb, "test_table", []string{"id", "name"},
|
||||
func(rows *sql.Rows) (*mockEntity, error) { return nil, nil },
|
||||
func(row *sql.Row) (*mockEntity, error) { return nil, nil },
|
||||
)
|
||||
|
||||
mock.ExpectExec("DELETE FROM test_table WHERE id = \\$1").
|
||||
WithArgs("1").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
err = store.Delete(context.Background(), "1")
|
||||
assert.NoError(t, err)
|
||||
|
||||
assert.NoError(t, mock.ExpectationsWereMet())
|
||||
}
|
||||
Reference in New Issue
Block a user