feat: complete API-driven projects with infrastructure and business acceptance
This commit is contained in:
143
examples/userService/internal/repository/user/mysql.go
Normal file
143
examples/userService/internal/repository/user/mysql.go
Normal file
@@ -0,0 +1,143 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
|
||||
"example.com/userService/internal/model"
|
||||
"github.com/go-sql-driver/mysql"
|
||||
)
|
||||
|
||||
// SQLExecutor is implemented by both *sql.DB and *sql.Tx. A repository created
|
||||
// with a transaction keeps all of its queries on that same transaction.
|
||||
type SQLExecutor interface {
|
||||
ExecContext(context.Context, string, ...any) (sql.Result, error)
|
||||
QueryContext(context.Context, string, ...any) (*sql.Rows, error)
|
||||
QueryRowContext(context.Context, string, ...any) *sql.Row
|
||||
}
|
||||
|
||||
// MySQLUsers is the persistent implementation of UserRepository.
|
||||
// The schema is documented in schema.sql; migrations stay outside the framework.
|
||||
type MySQLUsers struct {
|
||||
db SQLExecutor
|
||||
}
|
||||
|
||||
func NewMySQLUsers(db SQLExecutor) *MySQLUsers {
|
||||
return &MySQLUsers{db: db}
|
||||
}
|
||||
|
||||
// CheckSchema validates that the application's migration has been applied.
|
||||
// Runtime startup never creates or alters tables.
|
||||
func (r *MySQLUsers) CheckSchema(ctx context.Context) error {
|
||||
rows, err := r.db.QueryContext(ctx, `SELECT id, name, email, created_at, updated_at FROM users LIMIT 0`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return rows.Close()
|
||||
}
|
||||
|
||||
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) {
|
||||
if page < 1 || pageSize < 1 || pageSize > 100 {
|
||||
return model.UserPage{}, model.ErrInvalidPage
|
||||
}
|
||||
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
|
||||
}
|
||||
16
examples/userService/internal/repository/user/repository.go
Normal file
16
examples/userService/internal/repository/user/repository.go
Normal file
@@ -0,0 +1,16 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"context"
|
||||
"example.com/userService/internal/model"
|
||||
)
|
||||
|
||||
// UserRepository describes the storage operations the business needs.
|
||||
// Implementations must atomically enforce unique, normalized email addresses.
|
||||
type UserRepository interface {
|
||||
Create(context.Context, model.UserInput) (model.User, error)
|
||||
Get(context.Context, int64) (model.User, error)
|
||||
List(context.Context, int, int) (model.UserPage, error)
|
||||
Update(context.Context, int64, model.UserInput) (model.User, error)
|
||||
Delete(context.Context, int64) error
|
||||
}
|
||||
41
examples/userService/internal/repository/user/store.go
Normal file
41
examples/userService/internal/repository/user/store.go
Normal file
@@ -0,0 +1,41 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"git.yuntuo.hk/k2safe/go-omni/database"
|
||||
"time"
|
||||
)
|
||||
|
||||
type UnitOfWork interface {
|
||||
UserRepository
|
||||
WithTx(context.Context, func(UserRepository, func(string, int64, string) error) error) error
|
||||
}
|
||||
type Store struct {
|
||||
*MySQLUsers
|
||||
db *database.DB
|
||||
}
|
||||
|
||||
func NewStore(db *database.DB) *Store { return &Store{MySQLUsers: NewMySQLUsers(db.DB), db: db} }
|
||||
func (s *Store) WithTx(ctx context.Context, work func(UserRepository, func(string, int64, string) error) error) error {
|
||||
return s.db.InTx(ctx, nil, func(tx *sql.Tx) error {
|
||||
audit := func(operation string, id int64, requestID string) error {
|
||||
_, err := tx.ExecContext(ctx, "INSERT INTO user_audit (user_id,operation,request_id,created_at) VALUES (?,?,?,?)", id, operation, requestID, time.Now().UTC())
|
||||
return err
|
||||
}
|
||||
return work(NewMySQLUsers(tx), audit)
|
||||
})
|
||||
}
|
||||
|
||||
// CheckSchema validates columns; it never mutates database schema.
|
||||
func (s *Store) CheckSchema(ctx context.Context) error {
|
||||
if err := s.MySQLUsers.CheckSchema(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
rows, err := s.db.QueryContext(ctx, "SELECT id,user_id,operation,request_id,created_at FROM user_audit LIMIT 0")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return errors.Join(rows.Err(), rows.Close())
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"example.com/userService/internal/model"
|
||||
"git.yuntuo.hk/k2safe/go-omni/database"
|
||||
)
|
||||
|
||||
// This suite runs only against the disposable database created by verifyAPIWorkflow.
|
||||
func TestTransactions(t *testing.T) {
|
||||
if os.Getenv("USER_SERVICE_TEST_DATABASE") != "1" {
|
||||
t.Skip("isolated MySQL required")
|
||||
}
|
||||
cfg, err := database.ConfigFromEnv("")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
db, err := database.Open(context.Background(), cfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
store := NewStore(db)
|
||||
for _, kind := range []string{"error", "panic", "cancel", "commit"} {
|
||||
t.Run(kind, func(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
email := kind + "@transaction.example"
|
||||
var recovered any
|
||||
func() {
|
||||
defer func() { recovered = recover() }()
|
||||
err = store.WithTx(ctx, func(r UserRepository, audit func(string, int64, string) error) error {
|
||||
u, e := r.Create(ctx, model.UserInput{Name: "Transaction", Email: email})
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
if e = audit("created", u.ID, "transactionCheck"); e != nil {
|
||||
return e
|
||||
}
|
||||
switch kind {
|
||||
case "error":
|
||||
return errors.New("rollback sentinel")
|
||||
case "panic":
|
||||
panic("rollback sentinel")
|
||||
case "cancel":
|
||||
cancel()
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}()
|
||||
if kind == "panic" && recovered == nil {
|
||||
t.Fatal("panic lost")
|
||||
}
|
||||
if kind == "commit" && err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if kind != "commit" && kind != "panic" && err == nil {
|
||||
t.Fatal("failed transaction committed")
|
||||
}
|
||||
var count int
|
||||
if e := db.QueryRow("SELECT COUNT(*) FROM users WHERE email=?", email).Scan(&count); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
want := 0
|
||||
if kind == "commit" {
|
||||
want = 1
|
||||
}
|
||||
if count != want {
|
||||
t.Fatalf("rows=%d want=%d", count, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
var audits int
|
||||
if err = db.QueryRow("SELECT COUNT(*) FROM user_audit WHERE request_id='transactionCheck'").Scan(&audits); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if audits != 1 {
|
||||
t.Fatalf("audit transaction mismatch: %d", audits)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user