150 lines
4.0 KiB
Go
150 lines
4.0 KiB
Go
package userstore
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"time"
|
|
|
|
"galaxy/user/internal/domain/common"
|
|
"galaxy/user/internal/ports"
|
|
|
|
"github.com/jackc/pgx/v5/pgconn"
|
|
)
|
|
|
|
// pgUniqueViolationCode identifies the SQLSTATE returned by PostgreSQL when
|
|
// a UNIQUE constraint is violated by INSERT or UPDATE.
|
|
const pgUniqueViolationCode = "23505"
|
|
|
|
// classifyUniqueViolation maps a PostgreSQL unique-violation error to the
|
|
// matching ports sentinel. constraint identifies which UNIQUE constraint name
|
|
// the caller cares about so we can surface ports.ErrUserNameConflict for the
|
|
// dedicated user-name index. Returns nil when err is not a unique violation
|
|
// or does not match constraint.
|
|
func classifyUniqueViolation(err error, constraint string, mapped error) error {
|
|
var pgErr *pgconn.PgError
|
|
if !errors.As(err, &pgErr) || pgErr.Code != pgUniqueViolationCode {
|
|
return nil
|
|
}
|
|
if constraint != "" && pgErr.ConstraintName != constraint {
|
|
return nil
|
|
}
|
|
return mapped
|
|
}
|
|
|
|
// isUniqueViolation reports whether err is a PostgreSQL unique-violation,
|
|
// regardless of constraint name. Useful for "any conflict ⇒ ErrConflict"
|
|
// translations on simple INSERT calls.
|
|
func isUniqueViolation(err error) bool {
|
|
var pgErr *pgconn.PgError
|
|
if !errors.As(err, &pgErr) {
|
|
return false
|
|
}
|
|
return pgErr.Code == pgUniqueViolationCode
|
|
}
|
|
|
|
// nullableString returns the trimmed string when s is non-empty, otherwise
|
|
// reports a NULL stand-in usable in $-parameter lists. Empty strings are
|
|
// stored as NULL so optional columns round-trip through nil.
|
|
func nullableString(s string) any {
|
|
if s == "" {
|
|
return nil
|
|
}
|
|
return s
|
|
}
|
|
|
|
// nullableActorID converts an optional ActorID (the zero value indicates
|
|
// "no caller supplied this field") to a NULL stand-in for SQL parameters.
|
|
func nullableActorID(id common.ActorID) any {
|
|
if id.IsZero() {
|
|
return nil
|
|
}
|
|
return id.String()
|
|
}
|
|
|
|
// nullableActorType mirrors nullableActorID for ActorType.
|
|
func nullableActorType(t common.ActorType) any {
|
|
if t.IsZero() {
|
|
return nil
|
|
}
|
|
return t.String()
|
|
}
|
|
|
|
// nullableReasonCode mirrors nullableActorID for ReasonCode.
|
|
func nullableReasonCode(code common.ReasonCode) any {
|
|
if code.IsZero() {
|
|
return nil
|
|
}
|
|
return code.String()
|
|
}
|
|
|
|
// nullableUserID mirrors nullableActorID for UserID.
|
|
func nullableUserID(id common.UserID) any {
|
|
if id.IsZero() {
|
|
return nil
|
|
}
|
|
return id.String()
|
|
}
|
|
|
|
// nullableTime returns t.UTC() when non-nil, otherwise nil for NULL columns.
|
|
func nullableTime(t *time.Time) any {
|
|
if t == nil {
|
|
return nil
|
|
}
|
|
return t.UTC()
|
|
}
|
|
|
|
// nullableCountry returns the upper-cased ISO 3166-1 alpha-2 string when set,
|
|
// otherwise nil.
|
|
func nullableCountry(code common.CountryCode) any {
|
|
if code.IsZero() {
|
|
return nil
|
|
}
|
|
return code.String()
|
|
}
|
|
|
|
// stringFromNullable trims an optional sql.NullString-like *string (read from
|
|
// Postgres COLUMNAR_NULL) into an ActorID/ReasonCode/UserID-friendly string.
|
|
func stringFromNullable(value *string) string {
|
|
if value == nil {
|
|
return ""
|
|
}
|
|
return *value
|
|
}
|
|
|
|
// timeFromNullable copies an optional *time.Time read from Postgres into a
|
|
// new pointer normalised to UTC.
|
|
func timeFromNullable(value *time.Time) *time.Time {
|
|
if value == nil {
|
|
return nil
|
|
}
|
|
utc := value.UTC()
|
|
return &utc
|
|
}
|
|
|
|
// mapNotFound translates sql.ErrNoRows into ports.ErrNotFound, leaving every
|
|
// other error untouched.
|
|
func mapNotFound(err error) error {
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return ports.ErrNotFound
|
|
}
|
|
return err
|
|
}
|
|
|
|
// withTimeout derives a child context bounded by timeout and prefixes context
|
|
// errors with operation. Callers must always invoke the returned cancel.
|
|
func withTimeout(ctx context.Context, operation string, timeout time.Duration) (context.Context, context.CancelFunc, error) {
|
|
if ctx == nil {
|
|
return nil, nil, fmt.Errorf("%s: nil context", operation)
|
|
}
|
|
if err := ctx.Err(); err != nil {
|
|
return nil, nil, fmt.Errorf("%s: %w", operation, err)
|
|
}
|
|
if timeout <= 0 {
|
|
return nil, nil, fmt.Errorf("%s: operation timeout must be positive", operation)
|
|
}
|
|
bounded, cancel := context.WithTimeout(ctx, timeout)
|
|
return bounded, cancel, nil
|
|
}
|