Files
cmroubao/backend-api/internal/repository/sqlite/helpers.go
T

271 lines
5.8 KiB
Go

package sqlite
import (
"context"
"database/sql"
"errors"
"fmt"
"strings"
"time"
"cmroubao/backend-api/internal/domain"
"cmroubao/backend-api/internal/usecase"
sqlite3 "github.com/mattn/go-sqlite3"
)
const timestampLayout = time.RFC3339Nano
type queryRower interface {
QueryRowContext(context.Context, string, ...any) *sql.Row
}
type rowScanner interface {
Scan(...any) error
}
func scanAsset(scanner rowScanner) (domain.Asset, error) {
var asset domain.Asset
var createdAt string
err := scanner.Scan(
&asset.ID,
&asset.CreatorSubject,
&asset.Purpose,
&asset.MediaType,
&asset.SizeBytes,
&asset.SHA256,
&asset.StorageKey,
&createdAt,
)
if err != nil {
return domain.Asset{}, err
}
asset.CreatedAt, err = parseTimestamp(createdAt)
if err != nil {
return domain.Asset{}, err
}
return asset, nil
}
func scanTask(scanner rowScanner) (domain.PurchaseTask, error) {
var task domain.PurchaseTask
var sourceRef sql.NullString
var maxBudget sql.NullInt64
var cancelReason sql.NullString
var canceledAt sql.NullString
var createdAt string
var updatedAt string
err := scanner.Scan(
&task.ID,
&task.CreatorSubject,
&sourceRef,
&task.Title,
&task.Description,
&task.SKU,
&task.ImageAssetID,
&task.Quantity,
&maxBudget,
&task.Currency,
&task.Status,
&task.Version,
&cancelReason,
&canceledAt,
&createdAt,
&updatedAt,
)
if err != nil {
return domain.PurchaseTask{}, err
}
if sourceRef.Valid {
task.SourceRef = &sourceRef.String
}
if maxBudget.Valid {
task.MaxBudgetCents = &maxBudget.Int64
}
if cancelReason.Valid {
task.CancelReason = &cancelReason.String
}
if canceledAt.Valid {
value, err := parseTimestamp(canceledAt.String)
if err != nil {
return domain.PurchaseTask{}, err
}
task.CanceledAt = &value
}
task.CreatedAt, err = parseTimestamp(createdAt)
if err != nil {
return domain.PurchaseTask{}, err
}
task.UpdatedAt, err = parseTimestamp(updatedAt)
if err != nil {
return domain.PurchaseTask{}, err
}
return task, nil
}
func getAssetByID(
ctx context.Context,
queryer queryRower,
creatorSubject string,
assetID string,
) (domain.Asset, error) {
asset, err := scanAsset(queryer.QueryRowContext(
ctx,
`SELECT
id, creator_subject, purpose, media_type, size_bytes,
sha256, storage_key, created_at
FROM assets
WHERE creator_subject = ? AND id = ?`,
creatorSubject,
assetID,
))
if errors.Is(err, sql.ErrNoRows) {
return domain.Asset{}, usecase.ErrRepositoryNotFound
}
if err != nil {
return domain.Asset{}, repositoryFailure(err)
}
return asset, nil
}
func getTaskByID(
ctx context.Context,
queryer queryRower,
creatorSubject string,
taskID string,
) (domain.PurchaseTask, error) {
task, err := scanTask(queryer.QueryRowContext(
ctx,
`SELECT
id, creator_subject, source_ref, title, description, sku,
image_asset_id, quantity, max_budget_cents, currency, status,
version, cancel_reason, canceled_at, created_at, updated_at
FROM purchase_tasks
WHERE creator_subject = ? AND id = ?`,
creatorSubject,
taskID,
))
if errors.Is(err, sql.ErrNoRows) {
return domain.PurchaseTask{}, usecase.ErrRepositoryNotFound
}
if err != nil {
return domain.PurchaseTask{}, repositoryFailure(err)
}
return task, nil
}
func lookupIdempotency(
ctx context.Context,
tx *sql.Tx,
creatorSubject string,
operation string,
idempotencyKey string,
) (requestHash string, resourceID string, found bool, err error) {
err = tx.QueryRowContext(
ctx,
`SELECT request_sha256, resource_id
FROM idempotency_records
WHERE creator_subject = ?
AND operation = ?
AND idempotency_key = ?`,
creatorSubject,
operation,
idempotencyKey,
).Scan(&requestHash, &resourceID)
if errors.Is(err, sql.ErrNoRows) {
return "", "", false, nil
}
if err != nil {
return "", "", false, repositoryFailure(err)
}
return requestHash, resourceID, true, nil
}
func insertIdempotency(
ctx context.Context,
tx *sql.Tx,
creatorSubject string,
operation string,
idempotencyKey string,
requestHash string,
resourceType string,
resourceID string,
createdAt time.Time,
) error {
_, err := tx.ExecContext(
ctx,
`INSERT INTO idempotency_records (
creator_subject, operation, idempotency_key, request_sha256,
resource_type, resource_id, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?)`,
creatorSubject,
operation,
idempotencyKey,
requestHash,
resourceType,
resourceID,
formatTimestamp(createdAt),
)
if err != nil {
return repositoryFailure(err)
}
return nil
}
func formatTimestamp(value time.Time) string {
return value.UTC().Format(timestampLayout)
}
func parseTimestamp(value string) (time.Time, error) {
parsed, err := time.Parse(timestampLayout, value)
if err != nil {
return time.Time{}, fmt.Errorf(
"%w: invalid stored timestamp",
usecase.ErrRepositoryInvariant,
)
}
return parsed.UTC(), nil
}
func nullableString(value *string) any {
if value == nil {
return nil
}
return *value
}
func nullableInt64(value *int64) any {
if value == nil {
return nil
}
return *value
}
func repositoryFailure(err error) error {
if err == nil {
return nil
}
var sqliteError sqlite3.Error
if errors.As(err, &sqliteError) {
switch sqliteError.Code {
case sqlite3.ErrBusy, sqlite3.ErrLocked, sqlite3.ErrIoErr,
sqlite3.ErrCantOpen, sqlite3.ErrFull:
return fmt.Errorf("%w", usecase.ErrRepositoryUnavailable)
}
}
if errors.Is(err, context.Canceled) ||
errors.Is(err, context.DeadlineExceeded) {
return fmt.Errorf("%w", usecase.ErrRepositoryUnavailable)
}
return fmt.Errorf("%w", usecase.ErrRepositoryInvariant)
}
func isUniqueConstraint(err error, fragment string) bool {
var sqliteError sqlite3.Error
if !errors.As(err, &sqliteError) ||
sqliteError.ExtendedCode != sqlite3.ErrConstraintUnique {
return false
}
return strings.Contains(err.Error(), fragment)
}