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 }