feat: bootstrap reusable GoOmni framework
This commit is contained in:
126
examples/reference/repository/memory.go
Normal file
126
examples/reference/repository/memory.go
Normal file
@@ -0,0 +1,126 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sort"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.yuntuo.hk/k2safe/go-omni/examples/reference/model"
|
||||
)
|
||||
|
||||
// MemoryUsers stores data only for this process. Replace it with a database
|
||||
// implementation of service.UserRepository when persistence is required.
|
||||
type MemoryUsers struct {
|
||||
mu sync.RWMutex
|
||||
users map[int64]model.User
|
||||
emails map[string]int64
|
||||
nextID int64
|
||||
}
|
||||
|
||||
func NewMemoryUsers() *MemoryUsers {
|
||||
return &MemoryUsers{
|
||||
users: make(map[int64]model.User),
|
||||
emails: make(map[string]int64),
|
||||
nextID: 1,
|
||||
}
|
||||
}
|
||||
|
||||
func (r *MemoryUsers) Create(ctx context.Context, input model.UserInput) (model.User, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if err := ctx.Err(); err != nil {
|
||||
return model.User{}, err
|
||||
}
|
||||
if _, exists := r.emails[input.Email]; exists {
|
||||
return model.User{}, model.ErrEmailTaken
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
user := model.User{
|
||||
ID: r.nextID, Name: input.Name, Email: input.Email,
|
||||
CreatedAt: now, UpdatedAt: now,
|
||||
}
|
||||
r.nextID++
|
||||
r.users[user.ID] = user
|
||||
r.emails[user.Email] = user.ID
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func (r *MemoryUsers) Get(ctx context.Context, id int64) (model.User, error) {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
if err := ctx.Err(); err != nil {
|
||||
return model.User{}, err
|
||||
}
|
||||
user, exists := r.users[id]
|
||||
if !exists {
|
||||
return model.User{}, model.ErrUserNotFound
|
||||
}
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func (r *MemoryUsers) List(ctx context.Context, page, pageSize int) (model.UserPage, error) {
|
||||
if page < 1 || pageSize < 1 || pageSize > 100 {
|
||||
return model.UserPage{}, model.ErrInvalidPage
|
||||
}
|
||||
r.mu.RLock()
|
||||
if err := ctx.Err(); err != nil {
|
||||
r.mu.RUnlock()
|
||||
return model.UserPage{}, err
|
||||
}
|
||||
users := make([]model.User, 0, len(r.users))
|
||||
for _, user := range r.users {
|
||||
users = append(users, user)
|
||||
}
|
||||
r.mu.RUnlock()
|
||||
|
||||
// A stable order is essential: Go map iteration order changes between calls.
|
||||
sort.Slice(users, func(i, j int) bool { return users[i].ID < users[j].ID })
|
||||
result := model.UserPage{
|
||||
Items: []model.User{}, Total: len(users), Page: page, PageSize: pageSize,
|
||||
}
|
||||
// Check the page before multiplying so even a very large page cannot overflow.
|
||||
if len(users) == 0 || page-1 > (len(users)-1)/pageSize {
|
||||
return result, nil
|
||||
}
|
||||
start := (page - 1) * pageSize
|
||||
count := min(pageSize, len(users)-start)
|
||||
result.Items = users[start : start+count]
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (r *MemoryUsers) Update(ctx context.Context, id int64, input model.UserInput) (model.User, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if err := ctx.Err(); err != nil {
|
||||
return model.User{}, err
|
||||
}
|
||||
user, exists := r.users[id]
|
||||
if !exists {
|
||||
return model.User{}, model.ErrUserNotFound
|
||||
}
|
||||
if owner, exists := r.emails[input.Email]; exists && owner != id {
|
||||
return model.User{}, model.ErrEmailTaken
|
||||
}
|
||||
delete(r.emails, user.Email)
|
||||
user.Name, user.Email = input.Name, input.Email
|
||||
user.UpdatedAt = time.Now().UTC()
|
||||
r.users[id] = user
|
||||
r.emails[user.Email] = id
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func (r *MemoryUsers) Delete(ctx context.Context, id int64) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
user, exists := r.users[id]
|
||||
if !exists {
|
||||
return model.ErrUserNotFound
|
||||
}
|
||||
delete(r.users, id)
|
||||
delete(r.emails, user.Email)
|
||||
return nil
|
||||
}
|
||||
40
examples/reference/repository/memory_test.go
Normal file
40
examples/reference/repository/memory_test.go
Normal file
@@ -0,0 +1,40 @@
|
||||
package repository_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"git.yuntuo.hk/k2safe/go-omni/examples/reference/model"
|
||||
"git.yuntuo.hk/k2safe/go-omni/examples/reference/repository"
|
||||
)
|
||||
|
||||
func TestCanceledRequestsDoNotMutateStorage(t *testing.T) {
|
||||
store := repository.NewMemoryUsers()
|
||||
live := context.Background()
|
||||
user, err := store.Create(live, model.UserInput{Name: "Alice", Email: "alice@example.com"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
canceled, cancel := context.WithCancel(live)
|
||||
cancel()
|
||||
if _, err := store.Create(canceled, model.UserInput{Name: "Bob", Email: "bob@example.com"}); !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
if _, err := store.Update(canceled, user.ID, model.UserInput{Name: "Changed", Email: "changed@example.com"}); !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("update: %v", err)
|
||||
}
|
||||
if err := store.Delete(canceled, user.ID); !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("delete: %v", err)
|
||||
}
|
||||
if _, err := store.Get(canceled, user.ID); !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("get: %v", err)
|
||||
}
|
||||
if _, err := store.List(canceled, 1, 20); !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("list: %v", err)
|
||||
}
|
||||
page, err := store.List(live, 1, 20)
|
||||
if err != nil || page.Total != 1 || len(page.Items) != 1 || page.Items[0] != user {
|
||||
t.Fatalf("canceled requests changed stored data: %+v, %v", page, err)
|
||||
}
|
||||
}
|
||||
122
examples/reference/repository/mysql.go
Normal file
122
examples/reference/repository/mysql.go
Normal file
@@ -0,0 +1,122 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
|
||||
"git.yuntuo.hk/k2safe/go-omni/examples/reference/model"
|
||||
"github.com/go-sql-driver/mysql"
|
||||
)
|
||||
|
||||
// MySQLUsers is the persistent implementation of service.UserRepository.
|
||||
// The schema is documented in schema.sql; migrations stay outside the framework.
|
||||
type MySQLUsers struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
func NewMySQLUsers(db *sql.DB) *MySQLUsers {
|
||||
return &MySQLUsers{db: db}
|
||||
}
|
||||
|
||||
func (r *MySQLUsers) Create(ctx context.Context, input model.UserInput) (model.User, error) {
|
||||
result, err := r.db.ExecContext(ctx, `
|
||||
INSERT INTO users (name, email, created_at, updated_at)
|
||||
VALUES (?, ?, UTC_TIMESTAMP(6), UTC_TIMESTAMP(6))`, input.Name, input.Email)
|
||||
if err != nil {
|
||||
return model.User{}, mapMySQLError(err)
|
||||
}
|
||||
id, err := result.LastInsertId()
|
||||
if err != nil {
|
||||
return model.User{}, err
|
||||
}
|
||||
return r.Get(ctx, id)
|
||||
}
|
||||
|
||||
func (r *MySQLUsers) Get(ctx context.Context, id int64) (model.User, error) {
|
||||
var user model.User
|
||||
err := r.db.QueryRowContext(ctx, `
|
||||
SELECT id, name, email, created_at, updated_at
|
||||
FROM users WHERE id = ?`, id).Scan(
|
||||
&user.ID, &user.Name, &user.Email, &user.CreatedAt, &user.UpdatedAt,
|
||||
)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return model.User{}, model.ErrUserNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return model.User{}, err
|
||||
}
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func (r *MySQLUsers) List(ctx context.Context, page, pageSize int) (model.UserPage, error) {
|
||||
var total int
|
||||
if err := r.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM users`).Scan(&total); err != nil {
|
||||
return model.UserPage{}, err
|
||||
}
|
||||
result := model.UserPage{Items: []model.User{}, Total: total, Page: page, PageSize: pageSize}
|
||||
if total == 0 || page-1 > (total-1)/pageSize {
|
||||
return result, nil
|
||||
}
|
||||
offset := (page - 1) * pageSize
|
||||
rows, err := r.db.QueryContext(ctx, `
|
||||
SELECT id, name, email, created_at, updated_at
|
||||
FROM users ORDER BY id LIMIT ? OFFSET ?`, pageSize, offset)
|
||||
if err != nil {
|
||||
return model.UserPage{}, err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var user model.User
|
||||
if err := rows.Scan(&user.ID, &user.Name, &user.Email, &user.CreatedAt, &user.UpdatedAt); err != nil {
|
||||
return model.UserPage{}, err
|
||||
}
|
||||
result.Items = append(result.Items, user)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return model.UserPage{}, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (r *MySQLUsers) Update(ctx context.Context, id int64, input model.UserInput) (model.User, error) {
|
||||
result, err := r.db.ExecContext(ctx, `
|
||||
UPDATE users SET name = ?, email = ?, updated_at = UTC_TIMESTAMP(6)
|
||||
WHERE id = ?`, input.Name, input.Email, id)
|
||||
if err != nil {
|
||||
return model.User{}, mapMySQLError(err)
|
||||
}
|
||||
changed, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return model.User{}, err
|
||||
}
|
||||
if changed == 0 {
|
||||
// MySQL may report zero affected rows when the replacement is identical;
|
||||
// distinguish that from an ID that does not exist.
|
||||
return r.Get(ctx, id)
|
||||
}
|
||||
return r.Get(ctx, id)
|
||||
}
|
||||
|
||||
func (r *MySQLUsers) Delete(ctx context.Context, id int64) error {
|
||||
result, err := r.db.ExecContext(ctx, `DELETE FROM users WHERE id = ?`, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
deleted, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if deleted == 0 {
|
||||
return model.ErrUserNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func mapMySQLError(err error) error {
|
||||
var mysqlErr *mysql.MySQLError
|
||||
if errors.As(err, &mysqlErr) && mysqlErr.Number == 1062 {
|
||||
return model.ErrEmailTaken
|
||||
}
|
||||
return err
|
||||
}
|
||||
Reference in New Issue
Block a user