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