Files

139 lines
3.2 KiB
Go
Raw Permalink Normal View History

// 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()
}