veylant/internal/flags/pg_store.go
2026-02-23 13:35:04 +01:00

136 lines
3.6 KiB
Go

package flags
import (
"context"
"database/sql"
"errors"
"fmt"
"time"
"go.uber.org/zap"
)
// PgFlagStore implements FlagStore using PostgreSQL.
type PgFlagStore struct {
db *sql.DB
logger *zap.Logger
}
func NewPgFlagStore(db *sql.DB, logger *zap.Logger) *PgFlagStore {
return &PgFlagStore{db: db, logger: logger}
}
func (p *PgFlagStore) IsEnabled(ctx context.Context, tenantID, name string) (bool, error) {
// Tenant-specific takes precedence over global (NULL tenant_id)
const q = `
SELECT is_enabled FROM feature_flags
WHERE name = $1 AND (tenant_id = $2 OR tenant_id IS NULL)
ORDER BY tenant_id NULLS LAST
LIMIT 1`
var enabled bool
err := p.db.QueryRowContext(ctx, q, name, tenantID).Scan(&enabled)
if errors.Is(err, sql.ErrNoRows) {
return false, nil
}
if err != nil {
return false, fmt.Errorf("feature_flags is_enabled: %w", err)
}
return enabled, nil
}
func (p *PgFlagStore) Get(ctx context.Context, tenantID, name string) (FeatureFlag, error) {
const q = `
SELECT id, COALESCE(tenant_id::text,''), name, is_enabled, created_at, updated_at
FROM feature_flags
WHERE name = $1 AND (tenant_id = $2 OR ($2 = '' AND tenant_id IS NULL))`
row := p.db.QueryRowContext(ctx, q, name, tenantID)
f, err := scanFlag(row)
if errors.Is(err, sql.ErrNoRows) {
return FeatureFlag{}, ErrNotFound
}
return f, err
}
func (p *PgFlagStore) Set(ctx context.Context, tenantID, name string, enabled bool) (FeatureFlag, error) {
// tenantID="" → NULL in DB (global flag)
var tidArg interface{}
if tenantID != "" {
tidArg = tenantID
}
const q = `
INSERT INTO feature_flags (tenant_id, name, is_enabled)
VALUES ($1, $2, $3)
ON CONFLICT (tenant_id, name) DO UPDATE
SET is_enabled = EXCLUDED.is_enabled, updated_at = NOW()
RETURNING id, COALESCE(tenant_id::text,''), name, is_enabled, created_at, updated_at`
row := p.db.QueryRowContext(ctx, q, tidArg, name, enabled)
return scanFlag(row)
}
func (p *PgFlagStore) List(ctx context.Context, tenantID string) ([]FeatureFlag, error) {
const q = `
SELECT id, COALESCE(tenant_id::text,''), name, is_enabled, created_at, updated_at
FROM feature_flags
WHERE tenant_id = $1 OR tenant_id IS NULL
ORDER BY name`
rows, err := p.db.QueryContext(ctx, q, tenantID)
if err != nil {
return nil, fmt.Errorf("feature_flags list: %w", err)
}
defer rows.Close() //nolint:errcheck
var out []FeatureFlag
for rows.Next() {
f, err := scanFlag(rows)
if err != nil {
return nil, err
}
out = append(out, f)
}
return out, rows.Err()
}
func (p *PgFlagStore) Delete(ctx context.Context, tenantID, name string) error {
var tidArg interface{}
if tenantID != "" {
tidArg = tenantID
}
const q = `DELETE FROM feature_flags WHERE name = $1 AND (tenant_id = $2 OR ($2 IS NULL AND tenant_id IS NULL))`
res, err := p.db.ExecContext(ctx, q, name, tidArg)
if err != nil {
return fmt.Errorf("feature_flags delete: %w", err)
}
n, _ := res.RowsAffected()
if n == 0 {
return ErrNotFound
}
return nil
}
// ─── scanner ─────────────────────────────────────────────────────────────────
type flagScanner interface {
Scan(dest ...interface{}) error
}
func scanFlag(s flagScanner) (FeatureFlag, error) {
var (
f FeatureFlag
createdAt time.Time
updatedAt time.Time
)
err := s.Scan(&f.ID, &f.TenantID, &f.Name, &f.IsEnabled, &createdAt, &updatedAt)
if err != nil {
return FeatureFlag{}, fmt.Errorf("scanning feature_flag row: %w", err)
}
f.CreatedAt = createdAt
f.UpdatedAt = updatedAt
return f, nil
}