feat: use postgres
This commit is contained in:
@@ -0,0 +1,375 @@
|
||||
package userstore
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
pgtable "galaxy/user/internal/adapters/postgres/jet/user/table"
|
||||
"galaxy/user/internal/domain/account"
|
||||
"galaxy/user/internal/domain/common"
|
||||
"galaxy/user/internal/ports"
|
||||
|
||||
pg "github.com/go-jet/jet/v2/postgres"
|
||||
)
|
||||
|
||||
// SQL constraint names declared in 00001_init.sql; referenced from error
|
||||
// translation so we can disambiguate UNIQUE violations on (email) versus
|
||||
// (user_name).
|
||||
const (
|
||||
accountsEmailUniqueConstraint = "accounts_email_unique"
|
||||
accountsUserNameUniqueConstraint = "accounts_user_name_unique"
|
||||
)
|
||||
|
||||
// accountSelectColumns is the canonical SELECT list for accounts, matching
|
||||
// scanAccountRow's column order.
|
||||
var accountSelectColumns = pg.ColumnList{
|
||||
pgtable.Accounts.UserID,
|
||||
pgtable.Accounts.Email,
|
||||
pgtable.Accounts.UserName,
|
||||
pgtable.Accounts.DisplayName,
|
||||
pgtable.Accounts.PreferredLanguage,
|
||||
pgtable.Accounts.TimeZone,
|
||||
pgtable.Accounts.DeclaredCountry,
|
||||
pgtable.Accounts.CreatedAt,
|
||||
pgtable.Accounts.UpdatedAt,
|
||||
pgtable.Accounts.DeletedAt,
|
||||
}
|
||||
|
||||
// Create stores one new account record. Email and user-name uniqueness are
|
||||
// enforced by the schema; conflicts on those columns surface as
|
||||
// ports.ErrConflict (with ports.ErrUserNameConflict for the dedicated
|
||||
// user-name index).
|
||||
func (store *Store) Create(ctx context.Context, input ports.CreateAccountInput) error {
|
||||
if err := input.Validate(); err != nil {
|
||||
return fmt.Errorf("create account in postgres: %w", err)
|
||||
}
|
||||
|
||||
operationCtx, cancel, err := store.operationContext(ctx, "create account in postgres")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer cancel()
|
||||
|
||||
if err := insertAccount(operationCtx, store.db, input.Account); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// insertAccount runs one INSERT against accounts using the supplied Queryer
|
||||
// (a *sql.DB or a *sql.Tx). It centralises the column list and error
|
||||
// translation used by Create and EnsureByEmail.
|
||||
func insertAccount(ctx context.Context, q queryer, record account.UserAccount) error {
|
||||
stmt := pgtable.Accounts.INSERT(
|
||||
pgtable.Accounts.UserID,
|
||||
pgtable.Accounts.Email,
|
||||
pgtable.Accounts.UserName,
|
||||
pgtable.Accounts.DisplayName,
|
||||
pgtable.Accounts.PreferredLanguage,
|
||||
pgtable.Accounts.TimeZone,
|
||||
pgtable.Accounts.DeclaredCountry,
|
||||
pgtable.Accounts.CreatedAt,
|
||||
pgtable.Accounts.UpdatedAt,
|
||||
pgtable.Accounts.DeletedAt,
|
||||
).VALUES(
|
||||
record.UserID.String(),
|
||||
record.Email.String(),
|
||||
record.UserName.String(),
|
||||
record.DisplayName.String(),
|
||||
record.PreferredLanguage.String(),
|
||||
record.TimeZone.String(),
|
||||
nullableCountry(record.DeclaredCountry),
|
||||
record.CreatedAt.UTC(),
|
||||
record.UpdatedAt.UTC(),
|
||||
nullableTime(record.DeletedAt),
|
||||
)
|
||||
|
||||
query, args := stmt.Sql()
|
||||
_, err := q.ExecContext(ctx, query, args...)
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if mapped := classifyUniqueViolation(err, accountsUserNameUniqueConstraint, ports.ErrUserNameConflict); mapped != nil {
|
||||
return fmt.Errorf("create account %q in postgres: %w", record.UserID, mapped)
|
||||
}
|
||||
if isUniqueViolation(err) {
|
||||
return fmt.Errorf("create account %q in postgres: %w", record.UserID, ports.ErrConflict)
|
||||
}
|
||||
return fmt.Errorf("create account %q in postgres: %w", record.UserID, err)
|
||||
}
|
||||
|
||||
// queryer is the subset of *sql.DB / *sql.Tx used by helpers that need to
|
||||
// run inside an existing transaction or against the bare pool.
|
||||
type queryer interface {
|
||||
QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row
|
||||
QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error)
|
||||
ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error)
|
||||
}
|
||||
|
||||
// GetByUserID returns the stored account identified by userID.
|
||||
func (store *Store) GetByUserID(ctx context.Context, userID common.UserID) (account.UserAccount, error) {
|
||||
if err := userID.Validate(); err != nil {
|
||||
return account.UserAccount{}, fmt.Errorf("get account by user id from postgres: %w", err)
|
||||
}
|
||||
|
||||
operationCtx, cancel, err := store.operationContext(ctx, "get account by user id from postgres")
|
||||
if err != nil {
|
||||
return account.UserAccount{}, err
|
||||
}
|
||||
defer cancel()
|
||||
|
||||
record, err := scanAccountByUserID(operationCtx, store.db, userID)
|
||||
switch {
|
||||
case errors.Is(err, ports.ErrNotFound):
|
||||
return account.UserAccount{}, fmt.Errorf("get account by user id %q from postgres: %w", userID, ports.ErrNotFound)
|
||||
case err != nil:
|
||||
return account.UserAccount{}, fmt.Errorf("get account by user id %q from postgres: %w", userID, err)
|
||||
}
|
||||
return record, nil
|
||||
}
|
||||
|
||||
// GetByEmail returns the stored account identified by the normalized e-mail
|
||||
// address.
|
||||
func (store *Store) GetByEmail(ctx context.Context, email common.Email) (account.UserAccount, error) {
|
||||
if err := email.Validate(); err != nil {
|
||||
return account.UserAccount{}, fmt.Errorf("get account by email from postgres: %w", err)
|
||||
}
|
||||
|
||||
operationCtx, cancel, err := store.operationContext(ctx, "get account by email from postgres")
|
||||
if err != nil {
|
||||
return account.UserAccount{}, err
|
||||
}
|
||||
defer cancel()
|
||||
|
||||
record, err := scanAccountByEmail(operationCtx, store.db, email)
|
||||
switch {
|
||||
case errors.Is(err, ports.ErrNotFound):
|
||||
return account.UserAccount{}, fmt.Errorf("get account by email %q from postgres: %w", email, ports.ErrNotFound)
|
||||
case err != nil:
|
||||
return account.UserAccount{}, fmt.Errorf("get account by email %q from postgres: %w", email, err)
|
||||
}
|
||||
return record, nil
|
||||
}
|
||||
|
||||
// GetByUserName returns the stored account identified by the exact stored
|
||||
// user name.
|
||||
func (store *Store) GetByUserName(ctx context.Context, userName common.UserName) (account.UserAccount, error) {
|
||||
if err := userName.Validate(); err != nil {
|
||||
return account.UserAccount{}, fmt.Errorf("get account by user name from postgres: %w", err)
|
||||
}
|
||||
|
||||
operationCtx, cancel, err := store.operationContext(ctx, "get account by user name from postgres")
|
||||
if err != nil {
|
||||
return account.UserAccount{}, err
|
||||
}
|
||||
defer cancel()
|
||||
|
||||
record, err := scanAccountByUserName(operationCtx, store.db, userName)
|
||||
switch {
|
||||
case errors.Is(err, ports.ErrNotFound):
|
||||
return account.UserAccount{}, fmt.Errorf("get account by user name %q from postgres: %w", userName, ports.ErrNotFound)
|
||||
case err != nil:
|
||||
return account.UserAccount{}, fmt.Errorf("get account by user name %q from postgres: %w", userName, err)
|
||||
}
|
||||
return record, nil
|
||||
}
|
||||
|
||||
// ExistsByUserID reports whether userID currently identifies a stored account
|
||||
// that is not soft-deleted. Soft-deleted accounts are treated as non-existing
|
||||
// for external callers per Stage 22.
|
||||
func (store *Store) ExistsByUserID(ctx context.Context, userID common.UserID) (bool, error) {
|
||||
if err := userID.Validate(); err != nil {
|
||||
return false, fmt.Errorf("exists by user id from postgres: %w", err)
|
||||
}
|
||||
|
||||
operationCtx, cancel, err := store.operationContext(ctx, "exists by user id from postgres")
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
defer cancel()
|
||||
|
||||
stmt := pg.SELECT(pgtable.Accounts.DeletedAt).
|
||||
FROM(pgtable.Accounts).
|
||||
WHERE(pgtable.Accounts.UserID.EQ(pg.String(userID.String())))
|
||||
|
||||
query, args := stmt.Sql()
|
||||
var deletedAt *time.Time
|
||||
err = store.db.QueryRowContext(operationCtx, query, args...).Scan(&deletedAt)
|
||||
switch {
|
||||
case errors.Is(err, sql.ErrNoRows):
|
||||
return false, nil
|
||||
case err != nil:
|
||||
return false, fmt.Errorf("exists by user id %q from postgres: %w", userID, err)
|
||||
}
|
||||
return deletedAt == nil, nil
|
||||
}
|
||||
|
||||
// Update replaces the stored account state for record.UserID. Email and
|
||||
// user_name are immutable; mutation attempts return ports.ErrConflict.
|
||||
// declared_country, display_name, preferred_language, time_zone, updated_at,
|
||||
// and deleted_at are the columns affected.
|
||||
func (store *Store) Update(ctx context.Context, record account.UserAccount) error {
|
||||
if err := record.Validate(); err != nil {
|
||||
return fmt.Errorf("update account in postgres: %w", err)
|
||||
}
|
||||
|
||||
return store.withTx(ctx, "update account in postgres", func(ctx context.Context, tx *sql.Tx) error {
|
||||
current, err := scanAccountForUpdate(ctx, tx, record.UserID)
|
||||
if err != nil {
|
||||
if errors.Is(err, ports.ErrNotFound) {
|
||||
return fmt.Errorf("update account %q in postgres: %w", record.UserID, ports.ErrNotFound)
|
||||
}
|
||||
return fmt.Errorf("update account %q in postgres: %w", record.UserID, err)
|
||||
}
|
||||
if current.Email != record.Email || current.UserName != record.UserName {
|
||||
return fmt.Errorf("update account %q in postgres: %w", record.UserID, ports.ErrConflict)
|
||||
}
|
||||
|
||||
stmt := pgtable.Accounts.UPDATE(
|
||||
pgtable.Accounts.DisplayName,
|
||||
pgtable.Accounts.PreferredLanguage,
|
||||
pgtable.Accounts.TimeZone,
|
||||
pgtable.Accounts.DeclaredCountry,
|
||||
pgtable.Accounts.UpdatedAt,
|
||||
pgtable.Accounts.DeletedAt,
|
||||
).SET(
|
||||
record.DisplayName.String(),
|
||||
record.PreferredLanguage.String(),
|
||||
record.TimeZone.String(),
|
||||
nullableCountry(record.DeclaredCountry),
|
||||
record.UpdatedAt.UTC(),
|
||||
nullableTime(record.DeletedAt),
|
||||
).WHERE(pgtable.Accounts.UserID.EQ(pg.String(record.UserID.String())))
|
||||
|
||||
query, args := stmt.Sql()
|
||||
if _, err := tx.ExecContext(ctx, query, args...); err != nil {
|
||||
return fmt.Errorf("update account %q in postgres: %w", record.UserID, err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// scanAccountByUserID is a thin wrapper around scanAccountWhere for the
|
||||
// (user_id) column so atomic flows can reuse the same scanner with FOR
|
||||
// UPDATE locking semantics.
|
||||
func scanAccountByUserID(ctx context.Context, q queryer, userID common.UserID) (account.UserAccount, error) {
|
||||
return scanAccountWhere(ctx, q, pgtable.Accounts.UserID.EQ(pg.String(userID.String())), false)
|
||||
}
|
||||
|
||||
func scanAccountByEmail(ctx context.Context, q queryer, email common.Email) (account.UserAccount, error) {
|
||||
return scanAccountWhere(ctx, q, pgtable.Accounts.Email.EQ(pg.String(email.String())), false)
|
||||
}
|
||||
|
||||
func scanAccountByUserName(ctx context.Context, q queryer, userName common.UserName) (account.UserAccount, error) {
|
||||
return scanAccountWhere(ctx, q, pgtable.Accounts.UserName.EQ(pg.String(userName.String())), false)
|
||||
}
|
||||
|
||||
func scanAccountForUpdate(ctx context.Context, q queryer, userID common.UserID) (account.UserAccount, error) {
|
||||
return scanAccountWhere(ctx, q, pgtable.Accounts.UserID.EQ(pg.String(userID.String())), true)
|
||||
}
|
||||
|
||||
func scanAccountForUpdateByEmail(ctx context.Context, q queryer, email common.Email) (account.UserAccount, error) {
|
||||
return scanAccountWhere(ctx, q, pgtable.Accounts.Email.EQ(pg.String(email.String())), true)
|
||||
}
|
||||
|
||||
func scanAccountWhere(ctx context.Context, q queryer, condition pg.BoolExpression, forUpdate bool) (account.UserAccount, error) {
|
||||
stmt := pg.SELECT(accountSelectColumns).
|
||||
FROM(pgtable.Accounts).
|
||||
WHERE(condition)
|
||||
if forUpdate {
|
||||
stmt = stmt.FOR(pg.UPDATE())
|
||||
}
|
||||
query, args := stmt.Sql()
|
||||
row := q.QueryRowContext(ctx, query, args...)
|
||||
return scanAccountRow(row)
|
||||
}
|
||||
|
||||
func scanAccountRow(row *sql.Row) (account.UserAccount, error) {
|
||||
var (
|
||||
record account.UserAccount
|
||||
userID string
|
||||
email string
|
||||
userName string
|
||||
displayName string
|
||||
preferredLang string
|
||||
timeZone string
|
||||
declaredCountry *string
|
||||
createdAt time.Time
|
||||
updatedAt time.Time
|
||||
deletedAt *time.Time
|
||||
)
|
||||
|
||||
if err := row.Scan(
|
||||
&userID, &email, &userName, &displayName,
|
||||
&preferredLang, &timeZone, &declaredCountry,
|
||||
&createdAt, &updatedAt, &deletedAt,
|
||||
); err != nil {
|
||||
return account.UserAccount{}, mapNotFound(err)
|
||||
}
|
||||
|
||||
record.UserID = common.UserID(userID)
|
||||
record.Email = common.Email(email)
|
||||
record.UserName = common.UserName(userName)
|
||||
record.DisplayName = common.DisplayName(displayName)
|
||||
record.PreferredLanguage = common.LanguageTag(preferredLang)
|
||||
record.TimeZone = common.TimeZoneName(timeZone)
|
||||
if declaredCountry != nil {
|
||||
record.DeclaredCountry = common.CountryCode(*declaredCountry)
|
||||
}
|
||||
record.CreatedAt = createdAt.UTC()
|
||||
record.UpdatedAt = updatedAt.UTC()
|
||||
record.DeletedAt = timeFromNullable(deletedAt)
|
||||
return record, nil
|
||||
}
|
||||
|
||||
// AccountStore adapts Store to the UserAccountStore port. The wrapper is
|
||||
// returned by Store.Accounts() so callers that need only the narrow port
|
||||
// interface remain unaware of the broader Store surface.
|
||||
type AccountStore struct {
|
||||
store *Store
|
||||
}
|
||||
|
||||
// Accounts returns one adapter that exposes the user-account store port over
|
||||
// Store.
|
||||
func (store *Store) Accounts() *AccountStore {
|
||||
if store == nil {
|
||||
return nil
|
||||
}
|
||||
return &AccountStore{store: store}
|
||||
}
|
||||
|
||||
// Create stores one new account record.
|
||||
func (adapter *AccountStore) Create(ctx context.Context, input ports.CreateAccountInput) error {
|
||||
return adapter.store.Create(ctx, input)
|
||||
}
|
||||
|
||||
// GetByUserID returns the stored account identified by userID.
|
||||
func (adapter *AccountStore) GetByUserID(ctx context.Context, userID common.UserID) (account.UserAccount, error) {
|
||||
return adapter.store.GetByUserID(ctx, userID)
|
||||
}
|
||||
|
||||
// GetByEmail returns the stored account identified by email.
|
||||
func (adapter *AccountStore) GetByEmail(ctx context.Context, email common.Email) (account.UserAccount, error) {
|
||||
return adapter.store.GetByEmail(ctx, email)
|
||||
}
|
||||
|
||||
// GetByUserName returns the stored account identified by userName.
|
||||
func (adapter *AccountStore) GetByUserName(ctx context.Context, userName common.UserName) (account.UserAccount, error) {
|
||||
return adapter.store.GetByUserName(ctx, userName)
|
||||
}
|
||||
|
||||
// ExistsByUserID reports whether userID currently identifies a stored
|
||||
// account.
|
||||
func (adapter *AccountStore) ExistsByUserID(ctx context.Context, userID common.UserID) (bool, error) {
|
||||
return adapter.store.ExistsByUserID(ctx, userID)
|
||||
}
|
||||
|
||||
// Update replaces the stored account state for record.UserID.
|
||||
func (adapter *AccountStore) Update(ctx context.Context, record account.UserAccount) error {
|
||||
return adapter.store.Update(ctx, record)
|
||||
}
|
||||
|
||||
var _ ports.UserAccountStore = (*AccountStore)(nil)
|
||||
Reference in New Issue
Block a user