136 lines
3.6 KiB
Go
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
|
|
}
|