139 lines
3.2 KiB
Go
139 lines
3.2 KiB
Go
|
|
// 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()
|
||
|
|
}
|