From 542d3137605f8948127bc15e035b981e1b3e0d7a Mon Sep 17 00:00:00 2001 From: Simon Wikstrand Date: Sat, 27 Sep 2025 15:48:50 +0200 Subject: [PATCH] reworked interface and drivers to get better aligned support between drivers, and mock capabilities --- .dockerignore | 3 +- .github/workflows/testing.yaml | 2 +- .gitignore | 3 +- README.md | 556 +++++++++++---- docker-compose.yaml | 12 +- driver/postgres/mock/mock.go | 283 ++++++++ driver/postgres/mock/pgx_mock.go | 404 +++++++++++ driver/postgres/mock/pgx_mock_test.go | 395 +++++++++++ driver/postgres/mock/pgxpool_mock.go | 433 ++++++++++++ driver/postgres/mock/pgxpool_mock_test.go | 374 ++++++++++ driver/postgres/pgx.go | 268 ++++++++ driver/postgres/pgx_test.go | 794 ++++++++++++++++++++++ driver/postgres/pgxpool.go | 247 +++++++ driver/postgres/pgxpool_test.go | 654 ++++++++++++++++++ driver/postgres/postgres.go | 308 ++------- driver/postgres/postgres_test.go | 107 --- example/go.mod | 8 - example/go.sum | 6 - example/main.go | 110 --- example/query/postgres.go | 50 -- examples/blog/main.go | 535 +++++++++++++++ examples/simple/main.go | 222 ++++++ go.mod | 18 +- go.sum | 30 +- octobe.go | 247 ++++++- 25 files changed, 5360 insertions(+), 709 deletions(-) create mode 100644 driver/postgres/mock/mock.go create mode 100644 driver/postgres/mock/pgx_mock.go create mode 100644 driver/postgres/mock/pgx_mock_test.go create mode 100644 driver/postgres/mock/pgxpool_mock.go create mode 100644 driver/postgres/mock/pgxpool_mock_test.go create mode 100644 driver/postgres/pgx.go create mode 100644 driver/postgres/pgx_test.go create mode 100644 driver/postgres/pgxpool.go create mode 100644 driver/postgres/pgxpool_test.go delete mode 100644 driver/postgres/postgres_test.go delete mode 100644 example/go.mod delete mode 100644 example/go.sum delete mode 100644 example/main.go delete mode 100644 example/query/postgres.go create mode 100644 examples/blog/main.go create mode 100644 examples/simple/main.go diff --git a/.dockerignore b/.dockerignore index c068319..a18226a 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,3 +1,4 @@ /terraform /.github -/.vscode \ No newline at end of file +/.vscode +/.env diff --git a/.github/workflows/testing.yaml b/.github/workflows/testing.yaml index 1b50783..113fa74 100644 --- a/.github/workflows/testing.yaml +++ b/.github/workflows/testing.yaml @@ -15,7 +15,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v2 with: - go-version: 1.21 + go-version: 1.25 - name: Vendor deps run: go mod vendor diff --git a/.gitignore b/.gitignore index f95d04b..7709775 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ /.idea/* /.vscode/* -coverage.txt \ No newline at end of file +coverage.txt +/.env diff --git a/README.md b/README.md index e927f03..5943135 100644 --- a/README.md +++ b/README.md @@ -1,118 +1,438 @@ -# ![Alt text](https://raw.github.com/Kansuler/octobe/master/doc/octobe_logo.svg) - -![License](https://img.shields.io/github/license/Kansuler/octobe) ![Tag](https://img.shields.io/github/v/tag/Kansuler/octobe) ![Version](https://img.shields.io/github/go-mod/go-version/Kansuler/octobe) [![Codacy Badge](https://app.codacy.com/project/badge/Grade/492e6729782b471788994a72f2359f39)](https://www.codacy.com/gh/Kansuler/octobe/dashboard?utm_source=github.com&utm_medium=referral&utm_content=Kansuler/octobe&utm_campaign=Badge_Grade) [![Go Reference](https://pkg.go.dev/badge/github.com/Kansuler/octobe.svg)](https://pkg.go.dev/github.com/Kansuler/octobe) - -A slim golang package for programmers that love to write raw SQL, but has a problem with boilerplate code. This package -will help you structure and the way you work with your database. - -The main advantage with this library is to enable developers to build a predictable and consistent database layer -without losing the feeling of freedom. The octobe library draws inspiration from http handlers, but where handlers -interface with the database instead. - -Read package documentation at -[https://pkg.go.dev/github.com/Kansuler/octobe](https://pkg.go.dev/github.com/Kansuler/octobe) - -## Usage - -### Postgres Example - -```go -package main - -import ( - "context" - "github.com/Kansuler/octobe/v2" - "github.com/Kansuler/octobe/v2/driver/postgres" - "github.com/google/uuid" - "os" -) - -func main() { - ctx := context.Background() - dsn := os.Getenv("DSN") - if dsn == "" { - panic("DSN is not set") - } - - // Create a new octobe instance with a postgres driver, insert optional options for configuration that applies to - // every session. - o, err := octobe.New(postgres.Open(ctx, dsn, postgres.WithTransaction(postgres.TxOptions{}))) - if err != nil { - panic(err) - } - - // Begin a new session, since `postgres.WithTransaction` is set, this will start a postgres transaction. - session, err := o.Begin(context.Background()) - if err != nil { - panic(err) - } - - // WatchRollback will rollback the transaction if var err is not nil when the function returns. - defer session.WatchRollback(func() error { - return err - }) - - name := uuid.New().String() - - // Insert a new product into the database, and return a Product struct. - product1, err := postgres.Execute(session, AddProduct(name)) - if err != nil { - panic(err) - } - - // Select the product from the database by name, and return a Product struct. - product2, err := postgres.Execute(session, ProductByName(name)) - if err != nil { - panic(err) - } - - // Commit the transaction, if err is not nil, the transaction will be rolled back via WatchRollback. - err = session.Commit() - if err != nil { - panic(err) - } -} - -// Product is a model that represents a product in the database -type Product struct { - ID int - Name string -} - -// AddProduct is an octobe handler that will insert a product into the database, and return a product model. -// In the octobe.Handler signature the first generic is the type of driver builder, and the second is the returned type. -func AddProduct(name string) postgres.Handler[Product] { - return func(builder postgres.Builder) (Product, error) { - var product Product - query := builder(` - INSERT INTO products (name) VALUES ($1) RETURNING id, name; - `) - - query.Arguments(name) - err := query.QueryRow(&product.ID, &product.Name) - return product, err - } -} - - -// ProductByName is an octobe handler that will select a product from the database by name, and return a product model. -// In the octobe.Handler signature the first generic is the type of driver builder, and the second is the returned type. -func ProductByName(name string) postgres.Handler[Product] { - return func(builder postgres.Builder) (Product, error) { - var product Product - query := builder(` - SELECT id, name FROM products WHERE name = $1; - `) - - query.Arguments(name) - err := query.QueryRow(&product.ID, &product.Name) - return product, err - } -} -``` - -## Contributing - -Pull requests are welcome. For major changes, please open an issue first to discuss what you would like to change. - -Please make sure to update tests as appropriate. +# ![Octobe Logotype](https://raw.github.com/Kansuler/octobe/master/doc/octobe_logo.svg) + +[![Codacy Badge](https://app.codacy.com/project/badge/Coverage/0d33b2e3bd9d410c949845214cb81e3e)](https://app.codacy.com/gh/Kansuler/octobe/dashboard?utm_source=gh&utm_medium=referral&utm_content=&utm_campaign=Badge_coverage) +[![Codacy Badge](https://app.codacy.com/project/badge/Grade/0d33b2e3bd9d410c949845214cb81e3e)](https://app.codacy.com/gh/Kansuler/octobe/dashboard?utm_source=gh&utm_medium=referral&utm_content=&utm_campaign=Badge_grade) +[![GoDoc](https://pkg.go.dev/badge/github.com/Kansuler/octobe.svg)](https://pkg.go.dev/github.com/Kansuler/octobe/v3) +![MIT License](https://img.shields.io/github/license/Kansuler/octobe) +![Tag](https://img.shields.io/github/v/tag/Kansuler/octobe) +![Version](https://img.shields.io/github/go-mod/go-version/Kansuler/octobe) + +**Raw SQL power. Zero boilerplate. One API for any database.** + +Stop writing the same transaction management code over and over. Octobe gives you clean, testable database handlers with automatic transaction lifecycle management. + +## The Problem vs. The Solution + +**Without Octobe** - Messy, repetitive, error-prone: + +```go +func CreateUser(db *sql.DB, name string) (*User, error) { + tx, err := db.Begin() + if err != nil { + return nil, err + } + defer func() { + if err != nil { + tx.Rollback() + return + } + tx.Commit() + }() + + var user User + err = tx.QueryRow("INSERT INTO users (name) VALUES ($1) RETURNING id, name", name). + Scan(&user.ID, &user.Name) + return &user, err +} +``` + +**With Octobe** - Clean, structured, automatic: + +```go +func CreateUser(name string) postgres.Handler[User] { + return func(builder postgres.Builder) (User, error) { + var user User + query := builder(`INSERT INTO users (name) VALUES ($1) RETURNING id, name`) + err := query.Arguments(name).QueryRow(&user.ID, &user.Name) + return user, err + } +} + +// Usage - transaction management is automatic +user, err := postgres.Execute(session, CreateUser("Alice")) +``` + +## Why Octobe? + +✅ **Zero boilerplate** - No more manual transaction begin/commit/rollback + +✅ **Raw SQL freedom** - Write the queries you want, not what an ORM allows + +✅ **Built for testing** - Mock any database interaction with ease + +✅ **Production ready** - Handle panics, errors, and edge cases automatically + +## Quick Start + +Install: + +```bash +go get github.com/Kansuler/octobe/v3 +``` + +Use: + +```go +// 1. Create handlers (your SQL logic) +func GetProduct(id int) postgres.Handler[Product] { + return func(builder postgres.Builder) (Product, error) { + var p Product + query := builder(`SELECT id, name FROM products WHERE id = $1`) + err := query.Arguments(id).QueryRow(&p.ID, &p.Name) + return p, err + } +} + +// 2. Execute with automatic transaction management +db, _ := octobe.New(postgres.OpenPGXPool(ctx, dsn)) +err := db.StartTransaction(ctx, func(session octobe.BuilderSession[postgres.Builder]) error { + product, err := postgres.Execute(session, GetProduct(123)) + if err != nil { + return err // Automatic rollback + } + fmt.Printf("Product: %+v\n", product) + return nil // Automatic commit +}) +``` + +That's it. No manual transaction management, no connection handling, no boilerplate. + +## Full Example + +Here's a complete example showing the handler pattern in action: + +```go +package main + +import ( + "context" + "fmt" + "os" + + "github.com/Kansuler/octobe" + "github.com/Kansuler/octobe/driver/postgres" +) + +type Product struct { + ID int + Name string +} + +// Handlers are pure functions that encapsulate SQL logic +func CreateProduct(name string) postgres.Handler[Product] { + return func(builder postgres.Builder) (Product, error) { + var product Product + query := builder(`INSERT INTO products (name) VALUES ($1) RETURNING id, name`) + err := query.Arguments(name).QueryRow(&product.ID, &product.Name) + return product, err + } +} + +func GetProduct(id int) postgres.Handler[Product] { + return func(builder postgres.Builder) (Product, error) { + var product Product + query := builder(`SELECT id, name FROM products WHERE id = $1`) + err := query.Arguments(id).QueryRow(&product.ID, &product.Name) + return product, err + } +} + +func main() { + ctx := context.Background() + db, err := octobe.New(postgres.OpenPGXPool(ctx, os.Getenv("DSN"))) + if err != nil { + panic(err) + } + defer db.Close(ctx) + + // Everything happens in one transaction - automatic begin/commit/rollback + err = db.StartTransaction(ctx, func(session octobe.BuilderSession[postgres.Builder]) error { + // Create product + product, err := postgres.Execute(session, CreateProduct("Super Widget")) + if err != nil { + return err // Automatic rollback on any error + } + + // Fetch it back + fetched, err := postgres.Execute(session, GetProduct(product.ID)) + if err != nil { + return err + } + + fmt.Printf("Created and fetched: %+v\n", fetched) + return nil // Automatic commit + }) + + if err != nil { + panic(err) + } +} +``` + +## Testing Made Simple + +Mock any handler without touching your database: + +```go +func TestCreateProduct(t *testing.T) { + ctx := context.Background() + + // 1. Create mock + mockPool := mock.NewPGXPoolMock() + db, _ := octobe.New(postgres.OpenPGXPoolWithPool(mockPool)) + + // 2. Set expectations + rows := mock.NewMockRow(1, "Super Widget") + mockPool.ExpectQueryRow("INSERT INTO products").WithArgs("Super Widget").WillReturnRow(rows) + + // 3. Test your handler + session, _ := db.Begin(ctx) + product, err := postgres.Execute(session, CreateProduct("Super Widget")) + + // 4. Assert results + require.NoError(t, err) + require.Equal(t, 1, product.ID) + require.NoError(t, mockPool.AllExpectationsMet()) +} +``` + +## Migration Guide + +### From database/sql + +**Before (database/sql):** + +```go +func GetUser(db *sql.DB, id int) (*User, error) { + tx, err := db.Begin() + if err != nil { + return nil, err + } + defer func() { + if err != nil { + tx.Rollback() + return + } + tx.Commit() + }() + + var user User + err = tx.QueryRow("SELECT id, name FROM users WHERE id = ?", id). + Scan(&user.ID, &user.Name) + return &user, err +} +``` + +**After (Octobe):** + +```go +func GetUser(id int) postgres.Handler[User] { + return func(builder postgres.Builder) (User, error) { + var user User + err := builder(`SELECT id, name FROM users WHERE id = $1`). + Arguments(id). + QueryRow(&user.ID, &user.Name) + return user, err + } +} + +// Usage +user, err := postgres.Execute(session, GetUser(123)) +// Or with automatic transaction management: +var user User +err := db.StartTransaction(ctx, func(session octobe.BuilderSession[postgres.Builder]) error { + user, err := postgres.Execute(session, GetUser(123)) + return err +}) +``` + +### From GORM + +**Before (GORM):** + +```go +type User struct { + ID uint `gorm:"primaryKey"` + Name string +} + +func GetUserWithPosts(db *gorm.DB, userID uint) (User, []Post, error) { + var user User + var posts []Post + + err := db.First(&user, userID).Error + if err != nil { + return user, posts, err + } + + err = db.Where("user_id = ?", userID).Find(&posts).Error + return user, posts, err +} +``` + +**After (Octobe):** + +```go +func GetUserWithPosts(userID int) postgres.Handler[UserWithPosts] { + return func(builder postgres.Builder) (UserWithPosts, error) { + var result UserWithPosts + + // Get user + userQuery := builder(`SELECT id, name FROM users WHERE id = $1`) + err := userQuery.Arguments(userID).QueryRow(&result.User.ID, &result.User.Name) + if err != nil { + return result, err + } + + // Get posts + postsQuery := builder(`SELECT id, title, content FROM posts WHERE user_id = $1`) + err = postsQuery.Arguments(userID).Query(func(rows postgres.Rows) error { + for rows.Next() { + var post Post + if err := rows.Scan(&post.ID, &post.Title, &post.Content); err != nil { + return err + } + result.Posts = append(result.Posts, post) + } + return rows.Err() + }) + + return result, err + } +} +``` + +### From Squirrel + +**Before (Squirrel):** + +```go +func UpdateUser(db *sql.DB, id int, name string) error { + sql, args, err := squirrel. + Update("users"). + Set("name", name). + Where(squirrel.Eq{"id": id}). + PlaceholderFormat(squirrel.Dollar). + ToSql() + if err != nil { + return err + } + + _, err = db.Exec(sql, args...) + return err +} +``` + +**After (Octobe):** + +```go +func UpdateUser(id int, name string) postgres.Handler[octobe.Void] { + return func(builder postgres.Builder) (octobe.Void, error) { + query := builder(`UPDATE users SET name = $1 WHERE id = $2`) + _, err := query.Arguments(name, id).Exec() + return nil, err + } +} +``` + +### How does Octobe handle connection pooling? + +Octobe uses the underlying driver's connection pooling (like pgxpool). Configure your pool settings when creating the driver: + +```go +config, _ := pgxpool.ParseConfig(dsn) +config.MaxConns = 50 +pool, _ := pgxpool.NewWithConfig(ctx, config) +db, _ := octobe.New(postgres.OpenPGXPoolWithPool(pool)) +``` + +### Can I use Octobe with existing database code? + +Yes! Octobe doesn't require you to rewrite everything. You can: + +1. Use Octobe for new features +2. Gradually migrate existing code +3. Use both approaches in the same application +4. Extract the underlying connection for direct use when needed + +### What about logging and observability? + +Add logging middleware to your handlers: + +```go +func WithLogging[T any](name string, handler postgres.Handler[T]) postgres.Handler[T] { + return func(builder postgres.Builder) (T, error) { + start := time.Now() + result, err := handler(builder) + + log.Printf("Handler %s took %v, error: %v", name, time.Since(start), err) + return result, err + } +} + +// Usage +user, err := postgres.Execute(session, WithLogging("GetUser", GetUser(123))) +``` + +## Installation & Drivers + +```bash +# Core package +go get github.com/Kansuler/octobe/v3 + +# Database drivers +go get github.com/Kansuler/octobe/v3/driver/postgres +``` + +### Available Drivers + +- **PostgreSQL**: Full-featured driver using pgx/v5 +- **SQLite**: _Coming soon_ +- **MySQL**: _Coming soon_ +- **SQL Server**: _Coming soon_ + +Want to add a driver? Check our [Driver Development Guide](CONTRIBUTING.md#driver-development). + +## Examples + +Check out the [examples directory](examples/) for complete, runnable examples: + +- **[Simple CRUD](examples/simple/)**: Basic operations to get started +- **[Blog Application](examples/blog/)**: Complex real-world example with relationships + +## Contributing + +We welcome contributions! Here's how to get started: + +### Quick Start for Contributors + +1. Fork the repository +2. Create your feature branch (`git checkout -b feature/amazing-feature`) +3. Run tests: `docker compose up --abort-on-container-exit` +4. Commit your changes (`git commit -m 'Add amazing feature'`) +5. Push to the branch (`git push origin feature/amazing-feature`) +6. Open a Pull Request + +### Development Areas + +- **New Database Drivers**: SQLite, MySQL, SQL Server +- **Testing Utilities**: More helper functions and assertions +- **Documentation**: Examples, guides, and API documentation +- **Performance**: Benchmarks and optimizations +- **Tooling**: Code generation, CLI tools, IDE plugins + +### Driver Development + +Creating a new database driver? Follow these steps: + +1. Implement the core interfaces in `driver/yourdb/` +2. Add comprehensive tests +3. Create mock implementations for testing +4. Add examples and documentation +5. Submit a PR with benchmarks + +See the [PostgreSQL driver](driver/postgres/) as a reference implementation. + +## License + +MIT License - see the [LICENSE](LICENSE) file for details. diff --git a/docker-compose.yaml b/docker-compose.yaml index ba9b1c2..f350dd4 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -1,4 +1,4 @@ -version: '3.8' +version: "3.8" services: postgres: @@ -8,7 +8,7 @@ services: POSTGRES_PASSWORD: password POSTGRES_DB: testdb test: - image: golang:1.21 + image: golang:1.25 environment: DSN: postgresql://user:password@postgres:5432/testdb?sslmode=disable&connect_timeout=10 depends_on: @@ -19,7 +19,7 @@ services: target: /workspace working_dir: /workspace command: > - bash -c " - go install gotest.tools/gotestsum@latest && - gotestsum -- -coverprofile=coverage.txt -timeout=10s -v -count=1 -coverpkg=./... -covermode=atomic ./driver/... - " \ No newline at end of file + bash -c " + go install gotest.tools/gotestsum@latest && + gotestsum -- -coverprofile=coverage.txt -timeout=10s -v -count=1 -coverpkg=./... -covermode=atomic ./driver/... + " diff --git a/driver/postgres/mock/mock.go b/driver/postgres/mock/mock.go new file mode 100644 index 0000000..5e2382e --- /dev/null +++ b/driver/postgres/mock/mock.go @@ -0,0 +1,283 @@ +package mock + +import ( + "errors" + "fmt" + "io" + "reflect" + "regexp" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" +) + +// expectation defines the interface for mock database operation expectations. +type expectation interface { + fulfilled() bool + match(method string, args ...any) error + getReturns() []any + String() string +} + +type basicExpectation struct { + method string + isFulfilled bool + returns []any + query *regexp.Regexp + args []any +} + +func (e *basicExpectation) fulfilled() bool { + return e.isFulfilled +} + +func (e *basicExpectation) getReturns() []any { + e.isFulfilled = true + return e.returns +} + +func (e *basicExpectation) WithArgs(args ...any) { + e.args = args +} + +// match validates that the method call matches the expected signature and arguments. +func (e *basicExpectation) match(method string, args ...any) error { + if e.method != method { + return fmt.Errorf("method mismatch: expected %s, got %s", e.method, method) + } + + if e.query != nil { + query, ok := args[0].(string) + if !ok { + return fmt.Errorf("first argument was not a string query") + } + if !e.query.MatchString(query) { + return fmt.Errorf("query does not match regexp %s", e.query) + } + args = args[1:] + } + + if e.args != nil { + if !reflect.DeepEqual(e.args, args) { + return fmt.Errorf("args mismatch: expected %v, got %v", e.args, args) + } + } + + return nil +} + +func (e *basicExpectation) String() string { + var queryStr string + if e.query != nil { + queryStr = e.query.String() + } else { + queryStr = "" + } + return fmt.Sprintf("method %s with query %s and args %v", e.method, queryStr, e.args) +} + +type PingExpectation struct { + basicExpectation +} + +func (e *PingExpectation) WillReturnError(err error) { + e.returns = []any{err} +} + +type CloseExpectation struct { + basicExpectation +} + +func (e *CloseExpectation) WillReturnError(err error) { + e.returns = []any{err} +} + +// NewResult creates a pgconn.CommandTag for mocking Exec operation results. +func NewResult(command string, rowsAffected int64) pgconn.CommandTag { + return pgconn.NewCommandTag(fmt.Sprintf("%s 0 %d", command, rowsAffected)) +} + +type ExecExpectation struct { + basicExpectation +} + +func (e *ExecExpectation) WithArgs(args ...any) *ExecExpectation { + e.basicExpectation.WithArgs(args...) + return e +} + +func (e *ExecExpectation) WillReturnResult(res pgconn.CommandTag) { + e.returns = []any{res, nil} +} + +func (e *ExecExpectation) WillReturnError(err error) { + e.returns = []any{pgconn.CommandTag{}, err} +} + +type QueryExpectation struct { + basicExpectation +} + +func (e *QueryExpectation) WithArgs(args ...any) *QueryExpectation { + e.basicExpectation.WithArgs(args...) + return e +} + +func (e *QueryExpectation) WillReturnRows(rows pgx.Rows) { + e.returns = []any{rows, nil} +} + +func (e *QueryExpectation) WillReturnError(err error) { + e.returns = []any{nil, err} +} + +type QueryRowExpectation struct { + basicExpectation +} + +func (e *QueryRowExpectation) WithArgs(args ...any) *QueryRowExpectation { + e.basicExpectation.WithArgs(args...) + return e +} + +func (e *QueryRowExpectation) WillReturnRow(row pgx.Row) { + e.returns = []any{row} +} + +type BeginExpectation struct{ basicExpectation } + +func (e *BeginExpectation) WillReturnError(err error) { e.returns = []any{nil, err} } + +type BeginTxExpectation struct{ basicExpectation } + +func (e *BeginTxExpectation) WithOptions(opts pgx.TxOptions) *BeginTxExpectation { + e.args = []any{opts} + return e +} + +func (e *BeginTxExpectation) WillReturnError(err error) { e.returns = []any{nil, err} } + +type CommitExpectation struct{ basicExpectation } + +func (e *CommitExpectation) WillReturnError(err error) { e.returns = []any{err} } + +type RollbackExpectation struct{ basicExpectation } + +func (e *RollbackExpectation) WillReturnError(err error) { e.returns = []any{err} } + +// Row provides a mock implementation of pgx.Row for testing QueryRow operations. +type Row struct { + row []any + err error +} + +func NewRow(row ...any) *Row { + return &Row{row: row} +} + +func (r *Row) WillReturnError(err error) *Row { + r.err = err + return r +} + +// Scan copies row values into destination pointers using reflection. +func (r *Row) Scan(dest ...any) error { + if r.err != nil { + return r.err + } + for i, val := range r.row { + reflect.ValueOf(dest[i]).Elem().Set(reflect.ValueOf(val)) + } + return nil +} + +// Rows provides a mock implementation of pgx.Rows for testing Query operations. +// Supports adding rows and controlling iteration behavior. +type Rows struct { + fields []pgconn.FieldDescription + rows [][]any + pos int + err error + closed bool +} + +func NewRows(columns []string) *Rows { + fields := make([]pgconn.FieldDescription, len(columns)) + for i, col := range columns { + fields[i] = pgconn.FieldDescription{Name: col} + } + return &Rows{fields: fields, pos: -1} +} + +// AddRow appends a data row with values matching the column count. +func (r *Rows) AddRow(values ...any) *Rows { + if len(values) != len(r.fields) { + panic("number of values does not match number of columns") + } + r.rows = append(r.rows, values) + return r +} + +func (r *Rows) Close() { r.closed = true } + +func (r *Rows) Err() error { return r.err } + +func (r *Rows) CommandTag() pgconn.CommandTag { return pgconn.CommandTag{} } + +func (r *Rows) FieldDescriptions() []pgconn.FieldDescription { return r.fields } + +func (r *Rows) Next() bool { + if r.closed { + return false + } + r.pos++ + return r.pos < len(r.rows) +} + +// Scan copies current row values into destination pointers using reflection. +func (r *Rows) Scan(dest ...any) error { + if r.err != nil { + return r.err + } + if r.closed { + return errors.New("rows is closed") + } + if r.pos < 0 || r.pos >= len(r.rows) { + return io.EOF + } + for i, val := range r.rows[r.pos] { + reflect.ValueOf(dest[i]).Elem().Set(reflect.ValueOf(val)) + } + return nil +} + +func (r *Rows) Values() ([]any, error) { + if r.pos < 0 || r.pos >= len(r.rows) { + return nil, io.EOF + } + return r.rows[r.pos], nil +} + +// RawValues returns current row values as byte slices for compatibility. +func (r *Rows) RawValues() [][]byte { + if r.pos < 0 || r.pos >= len(r.rows) { + return nil + } + + rawValues := make([][]byte, len(r.rows[r.pos])) + for i, val := range r.rows[r.pos] { + if val == nil { + rawValues[i] = nil + } else { + rawValues[i] = []byte(fmt.Sprintf("%v", val)) + } + } + return rawValues +} + +func (r *Rows) Conn() *pgx.Conn { return nil } + +// GetRowsForTesting exposes internal row data for test verification. +func (r *Rows) GetRowsForTesting() [][]any { + return r.rows +} diff --git a/driver/postgres/mock/pgx_mock.go b/driver/postgres/mock/pgx_mock.go new file mode 100644 index 0000000..ac58fc7 --- /dev/null +++ b/driver/postgres/mock/pgx_mock.go @@ -0,0 +1,404 @@ +package mock + +import ( + "context" + "errors" + "fmt" + "regexp" + "sync" + + "github.com/Kansuler/octobe/v3/driver/postgres" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" +) + +var ErrNoExpectation = errors.New("no expectation found") + +// PGXMock provides a mock implementation of postgres.PGXConn and pgx.Tx interfaces +// for testing database interactions without requiring an actual database connection. +type PGXMock struct { + mu sync.Mutex + expectations []expectation + ordered bool +} + +var ( + _ postgres.PGXConn = (*PGXMock)(nil) + _ pgx.Tx = (*PGXMock)(nil) +) + +// NewPGXMock creates a new mock database connection for testing. +func NewPGXMock() *PGXMock { + return &PGXMock{} +} + +// findExpectation locates the first unfulfilled expectation matching the method and arguments. +func (m *PGXMock) findExpectation(method string, args ...any) (expectation, error) { + m.mu.Lock() + defer m.mu.Unlock() + + for _, e := range m.expectations { + if e.fulfilled() { + continue + } + if err := e.match(method, args...); err == nil { + return e, nil + } + } + + return nil, fmt.Errorf("%w for %s with args %v", ErrNoExpectation, method, args) +} + +// AllExpectationsMet verifies that all configured expectations have been fulfilled. +func (m *PGXMock) AllExpectationsMet() error { + m.mu.Lock() + defer m.mu.Unlock() + for _, e := range m.expectations { + if !e.fulfilled() { + return fmt.Errorf("unfulfilled expectation: %s", e) + } + } + return nil +} + +func (m *PGXMock) ExpectPing() *PingExpectation { + e := &PingExpectation{basicExpectation: basicExpectation{method: "Ping"}} + m.expectations = append(m.expectations, e) + return e +} + +func (m *PGXMock) Ping(ctx context.Context) error { + e, err := m.findExpectation("Ping") + if err != nil { + return err + } + ret := e.getReturns() + if len(ret) > 0 && ret[0] != nil { + return ret[0].(error) + } + return nil +} + +func (m *PGXMock) ExpectClose() *CloseExpectation { + e := &CloseExpectation{basicExpectation: basicExpectation{method: "Close"}} + m.expectations = append(m.expectations, e) + return e +} + +func (m *PGXMock) Close(ctx context.Context) error { + e, err := m.findExpectation("Close") + if err != nil { + return err + } + ret := e.getReturns() + if len(ret) > 0 && ret[0] != nil { + return ret[0].(error) + } + return nil +} + +// ExpectExec configures an expectation for an Exec operation with the specified query. +func (m *PGXMock) ExpectExec(query string) *ExecExpectation { + e := &ExecExpectation{ + basicExpectation: basicExpectation{ + method: "Exec", + query: regexp.MustCompile(regexp.QuoteMeta(query)), + }, + } + m.expectations = append(m.expectations, e) + return e +} + +func (m *PGXMock) Exec(ctx context.Context, query string, args ...any) (pgconn.CommandTag, error) { + e, err := m.findExpectation("Exec", append([]any{query}, args...)...) + if err != nil { + return pgconn.CommandTag{}, err + } + ret := e.getReturns() + if ret[1] != nil { + return pgconn.CommandTag{}, ret[1].(error) + } + return ret[0].(pgconn.CommandTag), nil +} + +// ExpectQuery configures an expectation for a Query operation with the specified query. +func (m *PGXMock) ExpectQuery(query string) *QueryExpectation { + e := &QueryExpectation{ + basicExpectation: basicExpectation{ + method: "Query", + query: regexp.MustCompile(regexp.QuoteMeta(query)), + }, + } + m.expectations = append(m.expectations, e) + return e +} + +func (m *PGXMock) Query(ctx context.Context, query string, args ...any) (pgx.Rows, error) { + e, err := m.findExpectation("Query", append([]any{query}, args...)...) + if err != nil { + return nil, err + } + ret := e.getReturns() + if ret[1] != nil { + return nil, ret[1].(error) + } + if ret[0] == nil { + return nil, nil + } + return ret[0].(pgx.Rows), nil +} + +// ExpectQueryRow configures an expectation for a QueryRow operation with the specified query. +func (m *PGXMock) ExpectQueryRow(query string) *QueryRowExpectation { + e := &QueryRowExpectation{ + basicExpectation: basicExpectation{ + method: "QueryRow", + query: regexp.MustCompile(regexp.QuoteMeta(query)), + }, + } + m.expectations = append(m.expectations, e) + return e +} + +func (m *PGXMock) QueryRow(ctx context.Context, query string, args ...any) pgx.Row { + e, err := m.findExpectation("QueryRow", append([]any{query}, args...)...) + if err != nil { + return &Row{err: err} + } + ret := e.getReturns() + return ret[0].(pgx.Row) +} + +func (m *PGXMock) ExpectBegin() *BeginExpectation { + e := &BeginExpectation{basicExpectation: basicExpectation{method: "Begin"}} + m.expectations = append(m.expectations, e) + return e +} + +func (m *PGXMock) Begin(ctx context.Context) (pgx.Tx, error) { + e, err := m.findExpectation("Begin") + if err != nil { + return nil, err + } + ret := e.getReturns() + if len(ret) > 1 && ret[1] != nil { + return nil, ret[1].(error) + } + return m, nil +} + +func (m *PGXMock) ExpectBeginTx() *BeginTxExpectation { + e := &BeginTxExpectation{basicExpectation: basicExpectation{method: "BeginTx"}} + m.expectations = append(m.expectations, e) + return e +} + +func (m *PGXMock) BeginTx(ctx context.Context, txOptions pgx.TxOptions) (pgx.Tx, error) { + e, err := m.findExpectation("BeginTx", txOptions) + if err != nil { + return nil, err + } + ret := e.getReturns() + if len(ret) > 1 && ret[1] != nil { + return nil, ret[1].(error) + } + return m, nil +} + +func (m *PGXMock) ExpectCommit() *CommitExpectation { + e := &CommitExpectation{basicExpectation: basicExpectation{method: "Commit"}} + m.expectations = append(m.expectations, e) + return e +} + +func (m *PGXMock) Commit(ctx context.Context) error { + e, err := m.findExpectation("Commit") + if err != nil { + return err + } + ret := e.getReturns() + if len(ret) > 0 && ret[0] != nil { + return ret[0].(error) + } + return nil +} + +func (m *PGXMock) ExpectRollback() *RollbackExpectation { + e := &RollbackExpectation{basicExpectation: basicExpectation{method: "Rollback"}} + m.expectations = append(m.expectations, e) + return e +} + +func (m *PGXMock) Rollback(ctx context.Context) error { + e, err := m.findExpectation("Rollback") + if err != nil { + return err + } + ret := e.getReturns() + if len(ret) > 0 && ret[0] != nil { + return ret[0].(error) + } + return nil +} + +type PrepareExpectation struct { + basicExpectation +} + +func (e *PrepareExpectation) WithName(name string) *PrepareExpectation { + e.args = []any{name} + return e +} + +func (e *PrepareExpectation) WillReturnResult(desc *pgconn.StatementDescription) { + e.returns = []any{desc, nil} +} + +func (e *PrepareExpectation) WillReturnError(err error) { + e.returns = []any{nil, err} +} + +// ExpectPrepare configures an expectation for preparing a statement. +func (m *PGXMock) ExpectPrepare(name, sql string) *PrepareExpectation { + e := &PrepareExpectation{ + basicExpectation: basicExpectation{ + method: "Prepare", + args: []any{name, sql}, + }, + } + m.expectations = append(m.expectations, e) + return e +} + +func (m *PGXMock) Prepare(ctx context.Context, name, sql string) (*pgconn.StatementDescription, error) { + e, err := m.findExpectation("Prepare", name, sql) + if err != nil { + return nil, err + } + ret := e.getReturns() + if len(ret) > 1 && ret[1] != nil { + return nil, ret[1].(error) + } + if len(ret) > 0 && ret[0] == nil { + return &pgconn.StatementDescription{Name: name, SQL: sql}, nil + } + if len(ret) > 0 { + return ret[0].(*pgconn.StatementDescription), nil + } + return &pgconn.StatementDescription{Name: name, SQL: sql}, nil +} + +type DeallocateExpectation struct { + basicExpectation +} + +func (e *DeallocateExpectation) WillReturnError(err error) { + e.returns = []any{err} +} + +func (m *PGXMock) ExpectDeallocate(name string) *DeallocateExpectation { + e := &DeallocateExpectation{ + basicExpectation: basicExpectation{ + method: "Deallocate", + args: []any{name}, + }, + } + m.expectations = append(m.expectations, e) + return e +} + +func (m *PGXMock) Deallocate(ctx context.Context, name string) error { + e, err := m.findExpectation("Deallocate", name) + if err != nil { + return err + } + ret := e.getReturns() + if len(ret) > 0 && ret[0] != nil { + return ret[0].(error) + } + return nil +} + +type DeallocateAllExpectation struct { + basicExpectation +} + +func (e *DeallocateAllExpectation) WillReturnError(err error) { + e.returns = []any{err} +} + +func (m *PGXMock) ExpectDeallocateAll() *DeallocateAllExpectation { + e := &DeallocateAllExpectation{ + basicExpectation: basicExpectation{method: "DeallocateAll"}, + } + m.expectations = append(m.expectations, e) + return e +} + +func (m *PGXMock) DeallocateAll(ctx context.Context) error { + e, err := m.findExpectation("DeallocateAll") + if err != nil { + return err + } + ret := e.getReturns() + if len(ret) > 0 && ret[0] != nil { + return ret[0].(error) + } + return nil +} + +type CopyFromExpectation struct { + basicExpectation +} + +func (e *CopyFromExpectation) WithColumns(columns []string) *CopyFromExpectation { + e.args = append(e.args, columns) + return e +} + +func (e *CopyFromExpectation) WillReturnResult(rowsAffected int64) { + e.returns = []any{rowsAffected, nil} +} + +func (e *CopyFromExpectation) WillReturnError(err error) { + e.returns = []any{int64(0), err} +} + +// ExpectCopyFrom configures an expectation for bulk copy operations. +func (m *PGXMock) ExpectCopyFrom(tableName pgx.Identifier) *CopyFromExpectation { + e := &CopyFromExpectation{ + basicExpectation: basicExpectation{ + method: "CopyFrom", + args: []any{tableName}, + }, + } + m.expectations = append(m.expectations, e) + return e +} + +func (m *PGXMock) CopyFrom(ctx context.Context, tableName pgx.Identifier, columnNames []string, rowSrc pgx.CopyFromSource) (int64, error) { + e, err := m.findExpectation("CopyFrom", tableName, columnNames) + if err != nil { + return 0, err + } + ret := e.getReturns() + if len(ret) > 1 && ret[1] != nil { + return 0, ret[1].(error) + } + if len(ret) > 0 { + return ret[0].(int64), nil + } + return 0, nil +} + +// Methods that return nil/defaults for interface compliance +func (m *PGXMock) PgConn() *pgconn.PgConn { return nil } +func (m *PGXMock) Config() *pgx.ConnConfig { return nil } +func (m *PGXMock) LargeObjects() pgx.LargeObjects { + panic("not implemented") +} +func (m *PGXMock) Conn() *pgx.Conn { return nil } + +func (m *PGXMock) SendBatch(ctx context.Context, batch *pgx.Batch) pgx.BatchResults { + return nil +} diff --git a/driver/postgres/mock/pgx_mock_test.go b/driver/postgres/mock/pgx_mock_test.go new file mode 100644 index 0000000..1324272 --- /dev/null +++ b/driver/postgres/mock/pgx_mock_test.go @@ -0,0 +1,395 @@ +package mock + +import ( + "context" + "errors" + "testing" + + "github.com/Kansuler/octobe/v3" + "github.com/Kansuler/octobe/v3/driver/postgres" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" + "github.com/stretchr/testify/require" +) + +func TestMock(t *testing.T) { + ctx := context.Background() + + t.Run("Ping success", func(t *testing.T) { + mock := NewPGXMock() + o, err := octobe.New(postgres.OpenPGXWithConn(mock)) + require.NoError(t, err) + + mock.ExpectPing() + err = o.Ping(ctx) + require.NoError(t, err) + require.NoError(t, mock.AllExpectationsMet()) + }) + + t.Run("Ping error", func(t *testing.T) { + mock := NewPGXMock() + o, err := octobe.New(postgres.OpenPGXWithConn(mock)) + require.NoError(t, err) + + expectedErr := errors.New("ping failed") + mock.ExpectPing().WillReturnError(expectedErr) + + err = o.Ping(ctx) + require.Error(t, err) + require.Equal(t, expectedErr, err) + require.NoError(t, mock.AllExpectationsMet()) + }) + + t.Run("Close success", func(t *testing.T) { + mock := NewPGXMock() + o, err := octobe.New(postgres.OpenPGXWithConn(mock)) + require.NoError(t, err) + + mock.ExpectClose() + err = o.Close(ctx) + require.NoError(t, err) + require.NoError(t, mock.AllExpectationsMet()) + }) + + t.Run("Exec success", func(t *testing.T) { + mock := NewPGXMock() + o, err := octobe.New(postgres.OpenPGXWithConn(mock)) + require.NoError(t, err) + session, err := o.Begin(ctx) + require.NoError(t, err) + + query := "INSERT INTO events" + args := []any{1, "test"} + mock.ExpectExec(query).WithArgs(args...).WillReturnResult(pgconn.CommandTag{}) + + _, err = session.Builder()(query).Arguments(args...).Exec() + require.NoError(t, err) + require.NoError(t, mock.AllExpectationsMet()) + }) + + t.Run("Exec error", func(t *testing.T) { + mock := NewPGXMock() + o, err := octobe.New(postgres.OpenPGXWithConn(mock)) + require.NoError(t, err) + session, err := o.Begin(ctx) + require.NoError(t, err) + + query := "INSERT INTO events" + expectedErr := errors.New("exec error") + mock.ExpectExec(query).WillReturnError(expectedErr) + + _, err = session.Builder()(query).Exec() + require.Error(t, err) + require.Equal(t, expectedErr, err) + require.NoError(t, mock.AllExpectationsMet()) + }) + + t.Run("Query success", func(t *testing.T) { + mock := NewPGXMock() + o, err := octobe.New(postgres.OpenPGXWithConn(mock)) + require.NoError(t, err) + session, err := o.Begin(ctx) + require.NoError(t, err) + + query := "SELECT id, name FROM users" + rows := NewRows([]string{"id", "name"}). + AddRow(1, "John Doe"). + AddRow(2, "Jane Doe") + + mock.ExpectQuery(query).WillReturnRows(rows) + + err = session.Builder()(query).Query(func(r postgres.Rows) error { + i := 0 + for r.Next() { + var id int + var name string + require.NoError(t, r.Scan(&id, &name)) + require.Equal(t, rows.GetRowsForTesting()[i][0], id) + require.Equal(t, rows.GetRowsForTesting()[i][1], name) + i++ + } + return r.Err() + }) + require.NoError(t, err) + require.NoError(t, mock.AllExpectationsMet()) + }) + + t.Run("Query error", func(t *testing.T) { + mock := NewPGXMock() + o, err := octobe.New(postgres.OpenPGXWithConn(mock)) + require.NoError(t, err) + session, err := o.Begin(ctx) + require.NoError(t, err) + + query := "SELECT id, name FROM users" + expectedErr := errors.New("query error") + mock.ExpectQuery(query).WillReturnError(expectedErr) + + err = session.Builder()(query).Query(func(r postgres.Rows) error { + return nil + }) + require.Error(t, err) + require.Equal(t, expectedErr, err) + require.NoError(t, mock.AllExpectationsMet()) + }) + + t.Run("QueryRow success", func(t *testing.T) { + mock := NewPGXMock() + o, err := octobe.New(postgres.OpenPGXWithConn(mock)) + require.NoError(t, err) + session, err := o.Begin(ctx) + require.NoError(t, err) + + query := "SELECT name FROM users WHERE id = ?" + row := NewRow("John Doe") + mock.ExpectQueryRow(query).WithArgs(1).WillReturnRow(row) + + var name string + err = session.Builder()(query).Arguments(1).QueryRow(&name) + require.NoError(t, err) + require.Equal(t, "John Doe", name) + require.NoError(t, mock.AllExpectationsMet()) + }) + + t.Run("QueryRow error", func(t *testing.T) { + mock := NewPGXMock() + o, err := octobe.New(postgres.OpenPGXWithConn(mock)) + require.NoError(t, err) + session, err := o.Begin(ctx) + require.NoError(t, err) + + query := "SELECT name FROM users WHERE id = ?" + expectedErr := errors.New("row scan error") + row := NewRow().WillReturnError(expectedErr) + mock.ExpectQueryRow(query).WithArgs(1).WillReturnRow(row) + + var name string + err = session.Builder()(query).Arguments(1).QueryRow(&name) + require.Error(t, err) + require.Equal(t, expectedErr, err) + require.NoError(t, mock.AllExpectationsMet()) + }) + + t.Run("Transaction success", func(t *testing.T) { + mock := NewPGXMock() + o, err := octobe.New(postgres.OpenPGXWithConn(mock)) + require.NoError(t, err) + + txOpts := postgres.PGXTxOptions{} + mock.ExpectBeginTx() + mock.ExpectCommit() + + session, err := o.Begin(ctx, postgres.WithPGXTxOptions(txOpts)) + require.NoError(t, err) + + err = session.Commit() + require.NoError(t, err) + + require.NoError(t, mock.AllExpectationsMet()) + }) + + t.Run("Transaction with exec", func(t *testing.T) { + mock := NewPGXMock() + o, err := octobe.New(postgres.OpenPGXWithConn(mock)) + require.NoError(t, err) + + txOpts := postgres.PGXTxOptions{} + mock.ExpectBeginTx() + query := "INSERT INTO users (name) VALUES ($1)" + mock.ExpectExec(query).WithArgs("test-user").WillReturnResult(pgconn.CommandTag{}) + mock.ExpectCommit() + + session, err := o.Begin(ctx, postgres.WithPGXTxOptions(txOpts)) + require.NoError(t, err) + + _, err = session.Builder()(query).Arguments("test-user").Exec() + require.NoError(t, err) + + err = session.Commit() + require.NoError(t, err) + + require.NoError(t, mock.AllExpectationsMet()) + }) + + t.Run("Transaction rollback", func(t *testing.T) { + mock := NewPGXMock() + o, err := octobe.New(postgres.OpenPGXWithConn(mock)) + require.NoError(t, err) + + txOpts := postgres.PGXTxOptions{} + mock.ExpectBeginTx() + mock.ExpectRollback() + + session, err := o.Begin(ctx, postgres.WithPGXTxOptions(txOpts)) + require.NoError(t, err) + + err = session.Rollback() + require.NoError(t, err) + + require.NoError(t, mock.AllExpectationsMet()) + }) + + t.Run("Unfulfilled expectations", func(t *testing.T) { + mock := NewPGXMock() + mock.ExpectPing() + mock.ExpectClose() + + err := mock.AllExpectationsMet() + require.Error(t, err) + require.Contains(t, err.Error(), "unfulfilled expectation: method Ping") + }) + + t.Run("No more expectations", func(t *testing.T) { + mock := NewPGXMock() + o, err := octobe.New(postgres.OpenPGXWithConn(mock)) + require.NoError(t, err) + + err = o.Ping(ctx) + require.Error(t, err) + require.ErrorIs(t, err, ErrNoExpectation) + }) + + t.Run("Prepare success", func(t *testing.T) { + mock := NewPGXMock() + + name := "test_stmt" + sql := "SELECT * FROM users WHERE id = $1" + mock.ExpectPrepare(name, sql) + + desc, err := mock.Prepare(ctx, name, sql) + require.NoError(t, err) + require.NotNil(t, desc) + require.Equal(t, name, desc.Name) + require.Equal(t, sql, desc.SQL) + require.NoError(t, mock.AllExpectationsMet()) + }) + + t.Run("Prepare error", func(t *testing.T) { + mock := NewPGXMock() + + name := "test_stmt" + sql := "SELECT * FROM users WHERE id = $1" + expectedErr := errors.New("prepare failed") + mock.ExpectPrepare(name, sql).WillReturnError(expectedErr) + + desc, err := mock.Prepare(ctx, name, sql) + require.Error(t, err) + require.Equal(t, expectedErr, err) + require.Nil(t, desc) + require.NoError(t, mock.AllExpectationsMet()) + }) + + t.Run("Deallocate success", func(t *testing.T) { + mock := NewPGXMock() + + name := "test_stmt" + mock.ExpectDeallocate(name) + + err := mock.Deallocate(ctx, name) + require.NoError(t, err) + require.NoError(t, mock.AllExpectationsMet()) + }) + + t.Run("Deallocate error", func(t *testing.T) { + mock := NewPGXMock() + + name := "test_stmt" + expectedErr := errors.New("deallocate failed") + mock.ExpectDeallocate(name).WillReturnError(expectedErr) + + err := mock.Deallocate(ctx, name) + require.Error(t, err) + require.Equal(t, expectedErr, err) + require.NoError(t, mock.AllExpectationsMet()) + }) + + t.Run("DeallocateAll success", func(t *testing.T) { + mock := NewPGXMock() + + mock.ExpectDeallocateAll() + + err := mock.DeallocateAll(ctx) + require.NoError(t, err) + require.NoError(t, mock.AllExpectationsMet()) + }) + + t.Run("DeallocateAll error", func(t *testing.T) { + mock := NewPGXMock() + + expectedErr := errors.New("deallocate all failed") + mock.ExpectDeallocateAll().WillReturnError(expectedErr) + + err := mock.DeallocateAll(ctx) + require.Error(t, err) + require.Equal(t, expectedErr, err) + require.NoError(t, mock.AllExpectationsMet()) + }) + + t.Run("CopyFrom success", func(t *testing.T) { + mock := NewPGXMock() + + tableName := pgx.Identifier{"users"} + columns := []string{"name", "email"} + mock.ExpectCopyFrom(tableName).WithColumns(columns).WillReturnResult(2) + + rowsAffected, err := mock.CopyFrom(ctx, tableName, columns, nil) + require.NoError(t, err) + require.Equal(t, int64(2), rowsAffected) + require.NoError(t, mock.AllExpectationsMet()) + }) + + t.Run("CopyFrom error", func(t *testing.T) { + mock := NewPGXMock() + + tableName := pgx.Identifier{"users"} + columns := []string{"name", "email"} + expectedErr := errors.New("copy from failed") + mock.ExpectCopyFrom(tableName).WithColumns(columns).WillReturnError(expectedErr) + + rowsAffected, err := mock.CopyFrom(ctx, tableName, columns, nil) + require.Error(t, err) + require.Equal(t, expectedErr, err) + require.Equal(t, int64(0), rowsAffected) + require.NoError(t, mock.AllExpectationsMet()) + }) + + t.Run("Rows RawValues", func(t *testing.T) { + rows := NewRows([]string{"id", "name"}). + AddRow(1, "John Doe"). + AddRow(2, "Jane Doe") + + // Test before Next() call + rawValues := rows.RawValues() + require.Nil(t, rawValues) + + // Test after Next() call + require.True(t, rows.Next()) + rawValues = rows.RawValues() + require.Len(t, rawValues, 2) + require.Equal(t, []byte("1"), rawValues[0]) + require.Equal(t, []byte("John Doe"), rawValues[1]) + + // Test second row + require.True(t, rows.Next()) + rawValues = rows.RawValues() + require.Len(t, rawValues, 2) + require.Equal(t, []byte("2"), rawValues[0]) + require.Equal(t, []byte("Jane Doe"), rawValues[1]) + + // Test after last row + require.False(t, rows.Next()) + rawValues = rows.RawValues() + require.Nil(t, rawValues) + }) + + t.Run("Rows RawValues with nil", func(t *testing.T) { + rows := NewRows([]string{"id", "name"}). + AddRow(1, nil) + + require.True(t, rows.Next()) + rawValues := rows.RawValues() + require.Len(t, rawValues, 2) + require.Equal(t, []byte("1"), rawValues[0]) + require.Nil(t, rawValues[1]) + }) +} diff --git a/driver/postgres/mock/pgxpool_mock.go b/driver/postgres/mock/pgxpool_mock.go new file mode 100644 index 0000000..646dd7f --- /dev/null +++ b/driver/postgres/mock/pgxpool_mock.go @@ -0,0 +1,433 @@ +package mock + +import ( + "context" + "fmt" + "regexp" + "sync" + + "github.com/Kansuler/octobe/v3/driver/postgres" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" + "github.com/jackc/pgx/v5/pgxpool" +) + +// PGXPoolMock provides a mock implementation of postgres.PGXPool and pgx.Tx interfaces +// for testing database pool interactions without requiring an actual database connection. +type PGXPoolMock struct { + mu sync.Mutex + expectations []expectation + ordered bool +} + +var ( + _ postgres.PGXPool = (*PGXPoolMock)(nil) + _ pgx.Tx = (*PGXPoolMock)(nil) +) + +// NewPGXPoolMock creates a new mock database connection pool for testing. +func NewPGXPoolMock() *PGXPoolMock { + return &PGXPoolMock{} +} + +// findExpectation locates the first unfulfilled expectation matching the method and arguments. +func (m *PGXPoolMock) findExpectation(method string, args ...any) (expectation, error) { + m.mu.Lock() + defer m.mu.Unlock() + + for _, e := range m.expectations { + if e.fulfilled() { + continue + } + if err := e.match(method, args...); err == nil { + return e, nil + } + } + + return nil, fmt.Errorf("%w for %s with args %v", ErrNoExpectation, method, args) +} + +// AllExpectationsMet verifies that all configured expectations have been fulfilled. +func (m *PGXPoolMock) AllExpectationsMet() error { + m.mu.Lock() + defer m.mu.Unlock() + for _, e := range m.expectations { + if !e.fulfilled() { + return fmt.Errorf("unfulfilled expectation: %s", e) + } + } + return nil +} + +func (m *PGXPoolMock) ExpectPing() *PingExpectation { + e := &PingExpectation{basicExpectation: basicExpectation{method: "Ping"}} + m.expectations = append(m.expectations, e) + return e +} + +func (m *PGXPoolMock) Ping(ctx context.Context) error { + e, err := m.findExpectation("Ping") + if err != nil { + return err + } + ret := e.getReturns() + if len(ret) > 0 && ret[0] != nil { + return ret[0].(error) + } + return nil +} + +func (m *PGXPoolMock) ExpectClose() *CloseExpectation { + e := &CloseExpectation{basicExpectation: basicExpectation{method: "Close"}} + m.expectations = append(m.expectations, e) + return e +} + +func (m *PGXPoolMock) Close() { + e, err := m.findExpectation("Close") + if err != nil { + return + } + ret := e.getReturns() + if len(ret) > 0 && ret[0] != nil { + return + } + return +} + +// ExpectExec configures an expectation for an Exec operation with the specified query. +func (m *PGXPoolMock) ExpectExec(query string) *ExecExpectation { + e := &ExecExpectation{ + basicExpectation: basicExpectation{ + method: "Exec", + query: regexp.MustCompile(regexp.QuoteMeta(query)), + }, + } + m.expectations = append(m.expectations, e) + return e +} + +func (m *PGXPoolMock) Exec(ctx context.Context, query string, args ...any) (pgconn.CommandTag, error) { + e, err := m.findExpectation("Exec", append([]any{query}, args...)...) + if err != nil { + return pgconn.CommandTag{}, err + } + ret := e.getReturns() + if ret[1] != nil { + return pgconn.CommandTag{}, ret[1].(error) + } + return ret[0].(pgconn.CommandTag), nil +} + +// ExpectQuery configures an expectation for a Query operation with the specified query. +func (m *PGXPoolMock) ExpectQuery(query string) *QueryExpectation { + e := &QueryExpectation{ + basicExpectation: basicExpectation{ + method: "Query", + query: regexp.MustCompile(regexp.QuoteMeta(query)), + }, + } + m.expectations = append(m.expectations, e) + return e +} + +func (m *PGXPoolMock) Query(ctx context.Context, query string, args ...any) (pgx.Rows, error) { + e, err := m.findExpectation("Query", append([]any{query}, args...)...) + if err != nil { + return nil, err + } + ret := e.getReturns() + if ret[1] != nil { + return nil, ret[1].(error) + } + if ret[0] == nil { + return nil, nil + } + return ret[0].(pgx.Rows), nil +} + +// ExpectQueryRow configures an expectation for a QueryRow operation with the specified query. +func (m *PGXPoolMock) ExpectQueryRow(query string) *QueryRowExpectation { + e := &QueryRowExpectation{ + basicExpectation: basicExpectation{ + method: "QueryRow", + query: regexp.MustCompile(regexp.QuoteMeta(query)), + }, + } + m.expectations = append(m.expectations, e) + return e +} + +func (m *PGXPoolMock) QueryRow(ctx context.Context, query string, args ...any) pgx.Row { + e, err := m.findExpectation("QueryRow", append([]any{query}, args...)...) + if err != nil { + return &Row{err: err} + } + ret := e.getReturns() + return ret[0].(pgx.Row) +} + +func (m *PGXPoolMock) ExpectBegin() *BeginExpectation { + e := &BeginExpectation{basicExpectation: basicExpectation{method: "Begin"}} + m.expectations = append(m.expectations, e) + return e +} + +func (m *PGXPoolMock) Begin(ctx context.Context) (pgx.Tx, error) { + e, err := m.findExpectation("Begin") + if err != nil { + return nil, err + } + ret := e.getReturns() + if len(ret) > 1 && ret[1] != nil { + return nil, ret[1].(error) + } + return m, nil +} + +func (m *PGXPoolMock) ExpectBeginTx() *BeginTxExpectation { + e := &BeginTxExpectation{basicExpectation: basicExpectation{method: "BeginTx"}} + m.expectations = append(m.expectations, e) + return e +} + +func (m *PGXPoolMock) BeginTx(ctx context.Context, txOptions pgx.TxOptions) (pgx.Tx, error) { + e, err := m.findExpectation("BeginTx", txOptions) + if err != nil { + return nil, err + } + ret := e.getReturns() + if len(ret) > 1 && ret[1] != nil { + return nil, ret[1].(error) + } + return m, nil +} + +func (m *PGXPoolMock) ExpectCommit() *CommitExpectation { + e := &CommitExpectation{basicExpectation: basicExpectation{method: "Commit"}} + m.expectations = append(m.expectations, e) + return e +} + +func (m *PGXPoolMock) Commit(ctx context.Context) error { + e, err := m.findExpectation("Commit") + if err != nil { + return err + } + ret := e.getReturns() + if len(ret) > 0 && ret[0] != nil { + return ret[0].(error) + } + return nil +} + +func (m *PGXPoolMock) ExpectRollback() *RollbackExpectation { + e := &RollbackExpectation{basicExpectation: basicExpectation{method: "Rollback"}} + m.expectations = append(m.expectations, e) + return e +} + +func (m *PGXPoolMock) Rollback(ctx context.Context) error { + e, err := m.findExpectation("Rollback") + if err != nil { + return err + } + ret := e.getReturns() + if len(ret) > 0 && ret[0] != nil { + return ret[0].(error) + } + return nil +} + +type AcquireExpectation struct { + basicExpectation +} + +func (e *AcquireExpectation) WillReturnConn(conn *pgxpool.Conn) { + e.returns = []any{conn, nil} +} + +func (e *AcquireExpectation) WillReturnError(err error) { + e.returns = []any{nil, err} +} + +// ExpectAcquire configures an expectation for acquiring a connection from the pool. +func (m *PGXPoolMock) ExpectAcquire() *AcquireExpectation { + e := &AcquireExpectation{basicExpectation: basicExpectation{method: "Acquire"}} + m.expectations = append(m.expectations, e) + return e +} + +func (m *PGXPoolMock) Acquire(ctx context.Context) (*pgxpool.Conn, error) { + e, err := m.findExpectation("Acquire") + if err != nil { + return nil, err + } + ret := e.getReturns() + if ret[1] != nil { + return nil, ret[1].(error) + } + if ret[0] == nil { + return nil, nil + } + return ret[0].(*pgxpool.Conn), nil +} + +type AcquireFuncExpectation struct { + basicExpectation +} + +func (e *AcquireFuncExpectation) WillReturnError(err error) { + e.returns = []any{err} +} + +// ExpectAcquireFunc configures an expectation for AcquireFunc operations. +func (m *PGXPoolMock) ExpectAcquireFunc() *AcquireFuncExpectation { + e := &AcquireFuncExpectation{basicExpectation: basicExpectation{method: "AcquireFunc"}} + m.expectations = append(m.expectations, e) + return e +} + +// AcquireFunc executes fn with a nil connection for mock purposes. +func (m *PGXPoolMock) AcquireFunc(ctx context.Context, fn func(*pgxpool.Conn) error) error { + e, err := m.findExpectation("AcquireFunc") + if err != nil { + return err + } + ret := e.getReturns() + if len(ret) > 0 && ret[0] != nil { + return ret[0].(error) + } + return fn(nil) +} + +type AcquireAllIdleExpectation struct { + basicExpectation +} + +func (e *AcquireAllIdleExpectation) WillReturnConns(conns []*pgxpool.Conn) { + e.returns = []any{conns} +} + +// ExpectAcquireAllIdle configures an expectation for acquiring all idle connections. +func (m *PGXPoolMock) ExpectAcquireAllIdle() *AcquireAllIdleExpectation { + e := &AcquireAllIdleExpectation{basicExpectation: basicExpectation{method: "AcquireAllIdle"}} + m.expectations = append(m.expectations, e) + return e +} + +func (m *PGXPoolMock) AcquireAllIdle(ctx context.Context) []*pgxpool.Conn { + e, err := m.findExpectation("AcquireAllIdle") + if err != nil { + return nil + } + ret := e.getReturns() + if len(ret) > 0 && ret[0] != nil { + return ret[0].([]*pgxpool.Conn) + } + return nil +} + +type PoolPrepareExpectation struct { + basicExpectation +} + +func (e *PoolPrepareExpectation) WithName(name string) *PoolPrepareExpectation { + e.args = []any{name} + return e +} + +func (e *PoolPrepareExpectation) WillReturnResult(desc *pgconn.StatementDescription) { + e.returns = []any{desc, nil} +} + +func (e *PoolPrepareExpectation) WillReturnError(err error) { + e.returns = []any{nil, err} +} + +// ExpectPrepare configures an expectation for preparing a statement. +func (m *PGXPoolMock) ExpectPrepare(name, sql string) *PoolPrepareExpectation { + e := &PoolPrepareExpectation{ + basicExpectation: basicExpectation{ + method: "Prepare", + args: []any{name, sql}, + }, + } + m.expectations = append(m.expectations, e) + return e +} + +func (m *PGXPoolMock) Prepare(ctx context.Context, name, sql string) (*pgconn.StatementDescription, error) { + e, err := m.findExpectation("Prepare", name, sql) + if err != nil { + return nil, err + } + ret := e.getReturns() + if len(ret) > 1 && ret[1] != nil { + return nil, ret[1].(error) + } + if len(ret) > 0 && ret[0] == nil { + return &pgconn.StatementDescription{Name: name, SQL: sql}, nil + } + if len(ret) > 0 { + return ret[0].(*pgconn.StatementDescription), nil + } + return &pgconn.StatementDescription{Name: name, SQL: sql}, nil +} + +type PoolCopyFromExpectation struct { + basicExpectation +} + +func (e *PoolCopyFromExpectation) WithColumns(columns []string) *PoolCopyFromExpectation { + e.args = append(e.args, columns) + return e +} + +func (e *PoolCopyFromExpectation) WillReturnResult(rowsAffected int64) { + e.returns = []any{rowsAffected, nil} +} + +func (e *PoolCopyFromExpectation) WillReturnError(err error) { + e.returns = []any{int64(0), err} +} + +// ExpectCopyFrom configures an expectation for bulk copy operations. +func (m *PGXPoolMock) ExpectCopyFrom(tableName pgx.Identifier) *PoolCopyFromExpectation { + e := &PoolCopyFromExpectation{ + basicExpectation: basicExpectation{ + method: "CopyFrom", + args: []any{tableName}, + }, + } + m.expectations = append(m.expectations, e) + return e +} + +func (m *PGXPoolMock) CopyFrom(ctx context.Context, tableName pgx.Identifier, columnNames []string, rowSrc pgx.CopyFromSource) (int64, error) { + e, err := m.findExpectation("CopyFrom", tableName, columnNames) + if err != nil { + return 0, err + } + ret := e.getReturns() + if len(ret) > 1 && ret[1] != nil { + return 0, ret[1].(error) + } + if len(ret) > 0 { + return ret[0].(int64), nil + } + return 0, nil +} + +// Methods that return nil/defaults for interface compliance +func (m *PGXPoolMock) Reset() {} +func (m *PGXPoolMock) Config() *pgxpool.Config { return nil } +func (m *PGXPoolMock) Stat() *pgxpool.Stat { return nil } +func (m *PGXPoolMock) LargeObjects() pgx.LargeObjects { + panic("not implemented") +} +func (m *PGXPoolMock) Conn() *pgx.Conn { return nil } + +func (m *PGXPoolMock) SendBatch(ctx context.Context, batch *pgx.Batch) pgx.BatchResults { + return nil +} diff --git a/driver/postgres/mock/pgxpool_mock_test.go b/driver/postgres/mock/pgxpool_mock_test.go new file mode 100644 index 0000000..7396575 --- /dev/null +++ b/driver/postgres/mock/pgxpool_mock_test.go @@ -0,0 +1,374 @@ +package mock + +import ( + "context" + "errors" + "testing" + + "github.com/Kansuler/octobe/v3" + "github.com/Kansuler/octobe/v3/driver/postgres" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" + "github.com/jackc/pgx/v5/pgxpool" + "github.com/stretchr/testify/require" +) + +func TestPoolMock(t *testing.T) { + ctx := context.Background() + + t.Run("Ping success", func(t *testing.T) { + mock := NewPGXPoolMock() + o, err := octobe.New(postgres.OpenPGXWithPool(mock)) + require.NoError(t, err) + + mock.ExpectPing() + err = o.Ping(ctx) + require.NoError(t, err) + require.NoError(t, mock.AllExpectationsMet()) + }) + + t.Run("Ping error", func(t *testing.T) { + mock := NewPGXPoolMock() + o, err := octobe.New(postgres.OpenPGXWithPool(mock)) + require.NoError(t, err) + + expectedErr := errors.New("ping failed") + mock.ExpectPing().WillReturnError(expectedErr) + + err = o.Ping(ctx) + require.Error(t, err) + require.Equal(t, expectedErr, err) + require.NoError(t, mock.AllExpectationsMet()) + }) + + t.Run("Close success", func(t *testing.T) { + mock := NewPGXPoolMock() + o, err := octobe.New(postgres.OpenPGXWithPool(mock)) + require.NoError(t, err) + + mock.ExpectClose() + err = o.Close(ctx) + require.NoError(t, err) + require.NoError(t, mock.AllExpectationsMet()) + }) + + t.Run("Exec success without tx", func(t *testing.T) { + mock := NewPGXPoolMock() + o, err := octobe.New(postgres.OpenPGXWithPool(mock)) + require.NoError(t, err) + session, err := o.Begin(ctx) + require.NoError(t, err) + + query := "INSERT INTO events" + args := []any{1, "test"} + mock.ExpectExec(query).WithArgs(args...).WillReturnResult(pgconn.CommandTag{}) + + _, err = session.Builder()(query).Arguments(args...).Exec() + require.NoError(t, err) + require.NoError(t, mock.AllExpectationsMet()) + }) + + t.Run("Exec error without tx", func(t *testing.T) { + mock := NewPGXPoolMock() + o, err := octobe.New(postgres.OpenPGXWithPool(mock)) + require.NoError(t, err) + session, err := o.Begin(ctx) + require.NoError(t, err) + + query := "INSERT INTO events" + expectedErr := errors.New("exec error") + mock.ExpectExec(query).WillReturnError(expectedErr) + + _, err = session.Builder()(query).Exec() + require.Error(t, err) + require.Equal(t, expectedErr, err) + require.NoError(t, mock.AllExpectationsMet()) + }) + + t.Run("Query success without tx", func(t *testing.T) { + mock := NewPGXPoolMock() + o, err := octobe.New(postgres.OpenPGXWithPool(mock)) + require.NoError(t, err) + session, err := o.Begin(ctx) + require.NoError(t, err) + + query := "SELECT id, name FROM users" + rows := NewRows([]string{"id", "name"}). + AddRow(1, "John Doe"). + AddRow(2, "Jane Doe") + + mock.ExpectQuery(query).WillReturnRows(rows) + + err = session.Builder()(query).Query(func(r postgres.Rows) error { + i := 0 + for r.Next() { + var id int + var name string + require.NoError(t, r.Scan(&id, &name)) + require.Equal(t, rows.GetRowsForTesting()[i][0], id) + require.Equal(t, rows.GetRowsForTesting()[i][1], name) + i++ + } + return r.Err() + }) + require.NoError(t, err) + require.NoError(t, mock.AllExpectationsMet()) + }) + + t.Run("Query error without tx", func(t *testing.T) { + mock := NewPGXPoolMock() + o, err := octobe.New(postgres.OpenPGXWithPool(mock)) + require.NoError(t, err) + session, err := o.Begin(ctx) + require.NoError(t, err) + + query := "SELECT id, name FROM users" + expectedErr := errors.New("query error") + mock.ExpectQuery(query).WillReturnError(expectedErr) + + err = session.Builder()(query).Query(func(r postgres.Rows) error { + return nil + }) + require.Error(t, err) + require.Equal(t, expectedErr, err) + require.NoError(t, mock.AllExpectationsMet()) + }) + + t.Run("QueryRow success without tx", func(t *testing.T) { + mock := NewPGXPoolMock() + o, err := octobe.New(postgres.OpenPGXWithPool(mock)) + require.NoError(t, err) + session, err := o.Begin(ctx) + require.NoError(t, err) + + query := "SELECT name FROM users WHERE id = ?" + row := NewRow("John Doe") + mock.ExpectQueryRow(query).WithArgs(1).WillReturnRow(row) + + var name string + err = session.Builder()(query).Arguments(1).QueryRow(&name) + require.NoError(t, err) + require.Equal(t, "John Doe", name) + require.NoError(t, mock.AllExpectationsMet()) + }) + + t.Run("QueryRow error without tx", func(t *testing.T) { + mock := NewPGXPoolMock() + o, err := octobe.New(postgres.OpenPGXWithPool(mock)) + require.NoError(t, err) + session, err := o.Begin(ctx) + require.NoError(t, err) + + query := "SELECT name FROM users WHERE id = ?" + expectedErr := errors.New("row scan error") + row := NewRow().WillReturnError(expectedErr) + mock.ExpectQueryRow(query).WithArgs(1).WillReturnRow(row) + + var name string + err = session.Builder()(query).Arguments(1).QueryRow(&name) + require.Error(t, err) + require.Equal(t, expectedErr, err) + require.NoError(t, mock.AllExpectationsMet()) + }) + + t.Run("Transaction success", func(t *testing.T) { + mock := NewPGXPoolMock() + o, err := octobe.New(postgres.OpenPGXWithPool(mock)) + require.NoError(t, err) + + txOpts := postgres.PGXTxOptions{} + mock.ExpectBeginTx() + mock.ExpectCommit() + + session, err := o.Begin(ctx, postgres.WithPGXTxOptions(txOpts)) + require.NoError(t, err) + + err = session.Commit() + require.NoError(t, err) + + require.NoError(t, mock.AllExpectationsMet()) + }) + + t.Run("Transaction with exec", func(t *testing.T) { + mock := NewPGXPoolMock() + o, err := octobe.New(postgres.OpenPGXWithPool(mock)) + require.NoError(t, err) + + txOpts := postgres.PGXTxOptions{} + mock.ExpectBeginTx() + query := "INSERT INTO users (name) VALUES ($1)" + mock.ExpectExec(query).WithArgs("test-user").WillReturnResult(pgconn.CommandTag{}) + mock.ExpectCommit() + + session, err := o.Begin(ctx, postgres.WithPGXTxOptions(txOpts)) + require.NoError(t, err) + + _, err = session.Builder()(query).Arguments("test-user").Exec() + require.NoError(t, err) + + err = session.Commit() + require.NoError(t, err) + + require.NoError(t, mock.AllExpectationsMet()) + }) + + t.Run("Transaction rollback", func(t *testing.T) { + mock := NewPGXPoolMock() + o, err := octobe.New(postgres.OpenPGXWithPool(mock)) + require.NoError(t, err) + + txOpts := postgres.PGXTxOptions{} + mock.ExpectBeginTx() + mock.ExpectRollback() + + session, err := o.Begin(ctx, postgres.WithPGXTxOptions(txOpts)) + require.NoError(t, err) + + err = session.Rollback() + require.NoError(t, err) + + require.NoError(t, mock.AllExpectationsMet()) + }) + + t.Run("Unfulfilled expectations", func(t *testing.T) { + mock := NewPGXPoolMock() + mock.ExpectPing() + mock.ExpectClose() + + err := mock.AllExpectationsMet() + require.Error(t, err) + require.Contains(t, err.Error(), "unfulfilled expectation: method Ping") + }) + + t.Run("No more expectations", func(t *testing.T) { + mock := NewPGXPoolMock() + o, err := octobe.New(postgres.OpenPGXWithPool(mock)) + require.NoError(t, err) + + err = o.Ping(ctx) + require.Error(t, err) + require.ErrorIs(t, err, ErrNoExpectation) + }) + + t.Run("Acquire success", func(t *testing.T) { + mock := NewPGXPoolMock() + + mock.ExpectAcquire().WillReturnConn(nil) // Using nil for simplicity in tests + + conn, err := mock.Acquire(ctx) + require.NoError(t, err) + require.Nil(t, conn) + require.NoError(t, mock.AllExpectationsMet()) + }) + + t.Run("Acquire error", func(t *testing.T) { + mock := NewPGXPoolMock() + + expectedErr := errors.New("acquire failed") + mock.ExpectAcquire().WillReturnError(expectedErr) + + conn, err := mock.Acquire(ctx) + require.Error(t, err) + require.Equal(t, expectedErr, err) + require.Nil(t, conn) + require.NoError(t, mock.AllExpectationsMet()) + }) + + t.Run("AcquireFunc success", func(t *testing.T) { + mock := NewPGXPoolMock() + + mock.ExpectAcquireFunc() + + called := false + err := mock.AcquireFunc(ctx, func(conn *pgxpool.Conn) error { + called = true + return nil + }) + require.NoError(t, err) + require.True(t, called) + require.NoError(t, mock.AllExpectationsMet()) + }) + + t.Run("AcquireFunc error", func(t *testing.T) { + mock := NewPGXPoolMock() + + expectedErr := errors.New("acquire func failed") + mock.ExpectAcquireFunc().WillReturnError(expectedErr) + + err := mock.AcquireFunc(ctx, func(conn *pgxpool.Conn) error { + return nil + }) + require.Error(t, err) + require.Equal(t, expectedErr, err) + require.NoError(t, mock.AllExpectationsMet()) + }) + + t.Run("AcquireAllIdle success", func(t *testing.T) { + mock := NewPGXPoolMock() + + expectedConns := []*pgxpool.Conn{} + mock.ExpectAcquireAllIdle().WillReturnConns(expectedConns) + + conns := mock.AcquireAllIdle(ctx) + require.Equal(t, expectedConns, conns) + require.NoError(t, mock.AllExpectationsMet()) + }) + + t.Run("Prepare success", func(t *testing.T) { + mock := NewPGXPoolMock() + + name := "test_stmt" + sql := "SELECT * FROM users WHERE id = $1" + mock.ExpectPrepare(name, sql) + + desc, err := mock.Prepare(ctx, name, sql) + require.NoError(t, err) + require.NotNil(t, desc) + require.Equal(t, name, desc.Name) + require.Equal(t, sql, desc.SQL) + require.NoError(t, mock.AllExpectationsMet()) + }) + + t.Run("Prepare error", func(t *testing.T) { + mock := NewPGXPoolMock() + + name := "test_stmt" + sql := "SELECT * FROM users WHERE id = $1" + expectedErr := errors.New("prepare failed") + mock.ExpectPrepare(name, sql).WillReturnError(expectedErr) + + desc, err := mock.Prepare(ctx, name, sql) + require.Error(t, err) + require.Equal(t, expectedErr, err) + require.Nil(t, desc) + require.NoError(t, mock.AllExpectationsMet()) + }) + + t.Run("CopyFrom success", func(t *testing.T) { + mock := NewPGXPoolMock() + + tableName := pgx.Identifier{"users"} + columns := []string{"name", "email"} + mock.ExpectCopyFrom(tableName).WithColumns(columns).WillReturnResult(3) + + rowsAffected, err := mock.CopyFrom(ctx, tableName, columns, nil) + require.NoError(t, err) + require.Equal(t, int64(3), rowsAffected) + require.NoError(t, mock.AllExpectationsMet()) + }) + + t.Run("CopyFrom error", func(t *testing.T) { + mock := NewPGXPoolMock() + + tableName := pgx.Identifier{"users"} + columns := []string{"name", "email"} + expectedErr := errors.New("copy from failed") + mock.ExpectCopyFrom(tableName).WithColumns(columns).WillReturnError(expectedErr) + + rowsAffected, err := mock.CopyFrom(ctx, tableName, columns, nil) + require.Error(t, err) + require.Equal(t, expectedErr, err) + require.Equal(t, int64(0), rowsAffected) + require.NoError(t, mock.AllExpectationsMet()) + }) +} diff --git a/driver/postgres/pgx.go b/driver/postgres/pgx.go new file mode 100644 index 0000000..c1de411 --- /dev/null +++ b/driver/postgres/pgx.go @@ -0,0 +1,268 @@ +package postgres + +import ( + "context" + "errors" + + "github.com/Kansuler/octobe/v3" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" +) + +// PGXConn defines the essential pgx connection interface for database operations. +type PGXConn interface { + Close(context.Context) error + Prepare(context.Context, string, string) (*pgconn.StatementDescription, error) + Deallocate(context.Context, string) error + DeallocateAll(context.Context) error + Ping(context.Context) error + PgConn() *pgconn.PgConn + Config() *pgx.ConnConfig + Exec(context.Context, string, ...any) (pgconn.CommandTag, error) + Query(context.Context, string, ...any) (pgx.Rows, error) + QueryRow(context.Context, string, ...any) pgx.Row + SendBatch(context.Context, *pgx.Batch) pgx.BatchResults + Begin(context.Context) (pgx.Tx, error) + BeginTx(context.Context, pgx.TxOptions) (pgx.Tx, error) + CopyFrom(context.Context, pgx.Identifier, []string, pgx.CopyFromSource) (int64, error) +} + +var _ PGXConn = &pgx.Conn{} + +type pgxConn struct { + conn PGXConn +} + +var _ PGXDriver = &pgxConn{} + +// OpenPGX creates a pgx connection driver from a DSN string. +func OpenPGX(ctx context.Context, dsn string) octobe.Open[pgxConn, pgxConfig, Builder] { + return func() (octobe.Driver[pgxConn, pgxConfig, Builder], error) { + conn, err := pgx.Connect(ctx, dsn) + if err != nil { + return nil, err + } + + return &pgxConn{ + conn: conn, + }, nil + } +} + +// ParseConfigOptions wraps pgconn parse configuration options. +type ParseConfigOptions struct { + pgconn.ParseConfigOptions +} + +// OpenPGXWithOptions creates a pgx connection driver with custom parse options. +func OpenPGXWithOptions(ctx context.Context, dsn string, options ParseConfigOptions) octobe.Open[pgxConn, pgxConfig, Builder] { + return func() (octobe.Driver[pgxConn, pgxConfig, Builder], error) { + conn, err := pgx.ConnectWithOptions(ctx, dsn, pgx.ParseConfigOptions{ParseConfigOptions: options.ParseConfigOptions}) + if err != nil { + return nil, err + } + + return &pgxConn{ + conn: conn, + }, nil + } +} + +// OpenPGXWithConn creates a driver from an existing pgx connection. +func OpenPGXWithConn(c PGXConn) octobe.Open[pgxConn, pgxConfig, Builder] { + return func() (octobe.Driver[pgxConn, pgxConfig, Builder], error) { + if c == nil { + return nil, errors.New("conn is nil") + } + + return &pgxConn{ + conn: c, + }, nil + } +} + +// Begin starts a new session, optionally within a transaction if txOptions are provided. +func (d *pgxConn) Begin(ctx context.Context, opts ...octobe.Option[pgxConfig]) (octobe.Session[Builder], error) { + var cfg pgxConfig + for _, opt := range opts { + opt(&cfg) + } + + var tx pgx.Tx + var err error + if cfg.txOptions != nil { + tx, err = d.conn.BeginTx(ctx, pgx.TxOptions{ + IsoLevel: cfg.txOptions.IsoLevel, + AccessMode: cfg.txOptions.AccessMode, + DeferrableMode: cfg.txOptions.DeferrableMode, + BeginQuery: cfg.txOptions.BeginQuery, + }) + } + + if err != nil { + return nil, err + } + + return &pgxSession{ + ctx: ctx, + cfg: cfg, + tx: tx, + d: d, + }, nil +} + +func (d *pgxConn) Close(ctx context.Context) error { + if d.conn == nil { + return errors.New("connection is nil") + } + return d.conn.Close(ctx) +} + +func (d *pgxConn) Ping(ctx context.Context) error { + if d.conn == nil { + return errors.New("connection is nil") + } + return d.conn.Ping(ctx) +} + +func (d *pgxConn) StartTransaction(ctx context.Context, fn func(session octobe.BuilderSession[Builder]) error, opts ...octobe.Option[pgxConfig]) (err error) { + return octobe.StartTransaction[pgxConn, pgxConfig, Builder](ctx, d, fn, opts...) +} + +// pgxSession manages a database session that may be transactional or non-transactional. +// Not thread-safe - use one session per goroutine. +type pgxSession struct { + ctx context.Context + cfg pgxConfig + tx pgx.Tx + d *pgxConn + committed bool +} + +var _ octobe.Session[Builder] = &pgxSession{} + +// Commit commits the transaction. Only works for transactional sessions. +func (s *pgxSession) Commit() error { + if s.committed { + return errors.New("cannot commit a session that has already been committed") + } + + if s.cfg.txOptions == nil { + return errors.New("cannot commit without transaction") + } + defer func() { + s.committed = true + }() + return s.tx.Commit(s.ctx) +} + +// Rollback rolls back the transaction. Only works for transactional sessions. +func (s *pgxSession) Rollback() error { + if s.cfg.txOptions == nil { + return errors.New("cannot rollback without transaction") + } + return s.tx.Rollback(s.ctx) +} + +// Builder returns a query builder function for this session. +func (s *pgxSession) Builder() Builder { + return func(query string) Segment { + return &pgxSegment{ + query: query, + args: nil, + used: false, + tx: s.tx, + d: s.d, + ctx: s.ctx, + } + } +} + +// pgxSegment represents a single-use query with arguments and execution tracking. +type pgxSegment struct { + query string + args []any + used bool + tx pgx.Tx + d *pgxConn + ctx context.Context +} + +var _ Segment = &pgxSegment{} + +func (s *pgxSegment) use() { + s.used = true +} + +// Arguments sets query parameters and returns the segment for method chaining. +func (s *pgxSegment) Arguments(args ...any) Segment { + s.args = args + return s +} + +// Exec executes the query and returns the number of affected rows. +func (s *pgxSegment) Exec() (ExecResult, error) { + if s.used { + return ExecResult{}, octobe.ErrAlreadyUsed + } + defer s.use() + if s.tx == nil { + res, err := s.d.conn.Exec(s.ctx, s.query, s.args...) + if err != nil { + return ExecResult{}, err + } + + return ExecResult{ + RowsAffected: res.RowsAffected(), + }, nil + } + + res, err := s.tx.Exec(s.ctx, s.query, s.args...) + if err != nil { + return ExecResult{}, err + } + return ExecResult{ + RowsAffected: res.RowsAffected(), + }, nil +} + +// QueryRow executes the query expecting exactly one row and scans into dest. +func (s *pgxSegment) QueryRow(dest ...any) error { + if s.used { + return octobe.ErrAlreadyUsed + } + defer s.use() + if s.tx == nil { + return s.d.conn.QueryRow(s.ctx, s.query, s.args...).Scan(dest...) + } + return s.tx.QueryRow(s.ctx, s.query, s.args...).Scan(dest...) +} + +// Query executes the query and calls cb for each row in the result set. +func (s *pgxSegment) Query(cb func(Rows) error) error { + if s.used { + return octobe.ErrAlreadyUsed + } + defer s.use() + + var err error + var rows pgx.Rows + if s.tx == nil { + rows, err = s.d.conn.Query(s.ctx, s.query, s.args...) + if err != nil { + return err + } + } else { + rows, err = s.tx.Query(s.ctx, s.query, s.args...) + if err != nil { + return err + } + } + + defer rows.Close() + if err = cb(rows); err != nil { + return err + } + + return nil +} diff --git a/driver/postgres/pgx_test.go b/driver/postgres/pgx_test.go new file mode 100644 index 0000000..069cb01 --- /dev/null +++ b/driver/postgres/pgx_test.go @@ -0,0 +1,794 @@ +package postgres_test + +import ( + "context" + "errors" + "testing" + + "github.com/Kansuler/octobe/v3" + "github.com/Kansuler/octobe/v3/driver/postgres" + "github.com/Kansuler/octobe/v3/driver/postgres/mock" + "github.com/stretchr/testify/assert" +) + +func TestPGXWithTxInsideStartTransaction(t *testing.T) { + m := mock.NewPGXMock() + m.ExpectBeginTx() + name := "Some name" + m.ExpectExec("CREATE TABLE IF NOT EXISTS products").WillReturnResult(mock.NewResult("", 0)) + m.ExpectQueryRow("INSERT INTO products").WithArgs(name).WillReturnRow(mock.NewRow(1, name)) + m.ExpectQuery("SELECT id, name FROM products").WithArgs(name).WillReturnRows(mock.NewRows([]string{"id", "name"}).AddRow(1, name)) + m.ExpectCommit() + m.ExpectClose() + + ob, err := octobe.New(postgres.OpenPGXWithConn(m)) + if !assert.NoError(t, err) { + t.FailNow() + } + + ctx := context.Background() + err = ob.StartTransaction(ctx, func(session octobe.BuilderSession[postgres.Builder]) error { + _, err = octobe.Execute(session, Migration()) + if !assert.NoError(t, err) { + t.FailNow() + } + product, err := octobe.Execute(session, AddProduct(name)) + if !assert.NoError(t, err) { + t.FailNow() + } + + assert.Equal(t, name, product.Name) + assert.NotZero(t, product.ID) + + products, err := octobe.Execute(session, ProductsByName(name)) + if !assert.NoError(t, err) { + t.FailNow() + } + + if assert.Equal(t, 1, len(products)) { + assert.Equal(t, name, products[0].Name) + assert.NotZero(t, products[0].ID) + } + return nil + }, postgres.WithPGXTxOptions(postgres.PGXTxOptions{})) + + err = ob.Close(ctx) + assert.NoError(t, err) + + assert.NoError(t, m.AllExpectationsMet()) +} + +func TestPGXWithTx(t *testing.T) { + m := mock.NewPGXMock() + name := "Some name" + + m.ExpectBeginTx() + m.ExpectExec("CREATE TABLE IF NOT EXISTS products").WillReturnResult(mock.NewResult("", 0)) + m.ExpectQueryRow("INSERT INTO products").WithArgs(name).WillReturnRow(mock.NewRow(1, name)) + m.ExpectQuery("SELECT id, name FROM products").WithArgs(name).WillReturnRows(mock.NewRows([]string{"id", "name"}).AddRow(1, name)) + m.ExpectCommit() + m.ExpectClose() + + ob, err := octobe.New(postgres.OpenPGXWithConn(m)) + if !assert.NoError(t, err) { + t.FailNow() + } + + ctx := context.Background() + session, err := ob.Begin(ctx, postgres.WithPGXTxOptions(postgres.PGXTxOptions{})) + if !assert.NoError(t, err) { + t.FailNow() + } + + _, err = octobe.Execute(session, Migration()) + if !assert.NoError(t, err) { + t.FailNow() + } + + product, err := octobe.Execute(session, AddProduct(name)) + if !assert.NoError(t, err) { + t.FailNow() + } + + assert.Equal(t, name, product.Name) + assert.NotZero(t, product.ID) + + products, err := octobe.Execute(session, ProductsByName(name)) + if !assert.NoError(t, err) { + t.FailNow() + } + + if assert.Equal(t, 1, len(products)) { + assert.Equal(t, name, products[0].Name) + assert.NotZero(t, products[0].ID) + } + + err = session.Commit() + if !assert.NoError(t, err) { + t.FailNow() + } + + err = ob.Close(ctx) + assert.NoError(t, err) + + assert.NoError(t, m.AllExpectationsMet()) +} + +func TestPGXWithoutTx(t *testing.T) { + m := mock.NewPGXMock() + name := "Some name" + + m.ExpectExec("CREATE TABLE IF NOT EXISTS products").WillReturnResult(mock.NewResult("", 0)) + m.ExpectQueryRow("INSERT INTO products").WithArgs(name).WillReturnRow(mock.NewRow(1, name)) + m.ExpectQuery("SELECT id, name FROM products").WithArgs(name).WillReturnRows(mock.NewRows([]string{"id", "name"}).AddRow(1, name)) + m.ExpectClose() + + ob, err := octobe.New(postgres.OpenPGXWithConn(m)) + if !assert.NoError(t, err) { + t.FailNow() + } + + ctx := context.Background() + session, err := ob.Begin(ctx) + if !assert.NoError(t, err) { + t.FailNow() + } + + _, err = octobe.Execute(session, Migration()) + if !assert.NoError(t, err) { + t.FailNow() + } + + product, err := octobe.Execute(session, AddProduct(name)) + if !assert.NoError(t, err) { + t.FailNow() + } + + assert.Equal(t, name, product.Name) + assert.NotZero(t, product.ID) + + products, err := octobe.Execute(session, ProductsByName(name)) + if !assert.NoError(t, err) { + t.FailNow() + } + + if assert.Equal(t, 1, len(products)) { + assert.Equal(t, name, products[0].Name) + assert.NotZero(t, products[0].ID) + } + + err = ob.Close(ctx) + assert.NoError(t, err) + + assert.NoError(t, m.AllExpectationsMet()) +} + +func Migration() octobe.Handler[octobe.Void, postgres.Builder] { + return func(builder postgres.Builder) (octobe.Void, error) { + query := builder(` + CREATE TABLE IF NOT EXISTS products ( + id SERIAL PRIMARY KEY, + name TEXT NOT NULL + ); + `) + _, err := query.Exec() + return nil, err + } +} + +type Product struct { + ID int + Name string +} + +func AddProduct(name string) octobe.Handler[Product, postgres.Builder] { + return func(builder postgres.Builder) (Product, error) { + var product Product + query := builder(` + INSERT INTO products (name) VALUES ($1) RETURNING id, name; + `) + + query.Arguments(name) + err := query.QueryRow(&product.ID, &product.Name) + return product, err + } +} + +func ProductsByName(name string) octobe.Handler[[]Product, postgres.Builder] { + return func(builder postgres.Builder) ([]Product, error) { + var products []Product + query := builder(` + SELECT id, name FROM products WHERE name = $1; + `) + + query.Arguments(name) + err := query.Query(func(rows postgres.Rows) error { + if rows.Next() { + var product Product + err := rows.Scan(&product.ID, &product.Name) + if err != nil { + return err + } + products = append(products, product) + } + + return nil + }) + return products, err + } +} + +func TestPGXWithTxInsideStartTransactionRollbackOnError(t *testing.T) { + m := mock.NewPGXMock() + m.ExpectBeginTx() + m.ExpectExec("CREATE TABLE IF NOT EXISTS products").WillReturnResult(mock.NewResult("", 0)) + m.ExpectRollback() + m.ExpectClose() + + ob, err := octobe.New(postgres.OpenPGXWithConn(m)) + if !assert.NoError(t, err) { + t.FailNow() + } + + ctx := context.Background() + expectedErr := errors.New("something went wrong") + err = ob.StartTransaction(ctx, func(session octobe.BuilderSession[postgres.Builder]) error { + _, err = octobe.Execute(session, Migration()) + if !assert.NoError(t, err) { + t.FailNow() + } + return expectedErr + }, postgres.WithPGXTxOptions(postgres.PGXTxOptions{})) + + assert.Equal(t, expectedErr, err) + + err = ob.Close(ctx) + assert.NoError(t, err) + + assert.NoError(t, m.AllExpectationsMet()) +} + +func TestPGXWithTxInsideStartTransactionRollbackOnPanic(t *testing.T) { + m := mock.NewPGXMock() + m.ExpectBeginTx() + m.ExpectExec("CREATE TABLE IF NOT EXISTS products").WillReturnResult(mock.NewResult("", 0)) + m.ExpectRollback() + m.ExpectClose() + + ob, err := octobe.New(postgres.OpenPGXWithConn(m)) + if !assert.NoError(t, err) { + t.FailNow() + } + + ctx := context.Background() + panicMsg := "oh no!" + defer func() { + p := recover() + assert.Equal(t, panicMsg, p) + + err = ob.Close(ctx) + assert.NoError(t, err) + assert.NoError(t, m.AllExpectationsMet()) + }() + + _ = ob.StartTransaction(ctx, func(session octobe.BuilderSession[postgres.Builder]) error { + _, err = octobe.Execute(session, Migration()) + if !assert.NoError(t, err) { + t.FailNow() + } + panic(panicMsg) + }, postgres.WithPGXTxOptions(postgres.PGXTxOptions{})) +} + +func TestPGXWithTxManualRollback(t *testing.T) { + m := mock.NewPGXMock() + name := "Some name" + + m.ExpectBeginTx() + m.ExpectExec("CREATE TABLE IF NOT EXISTS products").WillReturnResult(mock.NewResult("", 0)) + m.ExpectQueryRow("INSERT INTO products").WithArgs(name).WillReturnRow(mock.NewRow(1, name)) + m.ExpectRollback() + m.ExpectClose() + + ob, err := octobe.New(postgres.OpenPGXWithConn(m)) + if !assert.NoError(t, err) { + t.FailNow() + } + + ctx := context.Background() + session, err := ob.Begin(ctx, postgres.WithPGXTxOptions(postgres.PGXTxOptions{})) + if !assert.NoError(t, err) { + t.FailNow() + } + + _, err = octobe.Execute(session, Migration()) + if !assert.NoError(t, err) { + t.FailNow() + } + + _, err = octobe.Execute(session, AddProduct(name)) + if !assert.NoError(t, err) { + t.FailNow() + } + + err = session.Rollback() + if !assert.NoError(t, err) { + t.FailNow() + } + + err = ob.Close(ctx) + assert.NoError(t, err) + + assert.NoError(t, m.AllExpectationsMet()) +} + +func TestPGXWithoutTxCommit(t *testing.T) { + m := mock.NewPGXMock() + m.ExpectClose() + + ob, err := octobe.New(postgres.OpenPGXWithConn(m)) + if !assert.NoError(t, err) { + t.FailNow() + } + + ctx := context.Background() + session, err := ob.Begin(ctx) + if !assert.NoError(t, err) { + t.FailNow() + } + + err = session.Commit() + assert.Error(t, err) + assert.Equal(t, "cannot commit without transaction", err.Error()) + + err = ob.Close(ctx) + assert.NoError(t, err) + + assert.NoError(t, m.AllExpectationsMet()) +} + +func TestPGXWithoutTxRollback(t *testing.T) { + m := mock.NewPGXMock() + m.ExpectClose() + + ob, err := octobe.New(postgres.OpenPGXWithConn(m)) + if !assert.NoError(t, err) { + t.FailNow() + } + + ctx := context.Background() + session, err := ob.Begin(ctx) + if !assert.NoError(t, err) { + t.FailNow() + } + + err = session.Rollback() + assert.Error(t, err) + assert.Equal(t, "cannot rollback without transaction", err.Error()) + + err = ob.Close(ctx) + assert.NoError(t, err) + + assert.NoError(t, m.AllExpectationsMet()) +} + +func TestSegmentUsedTwice(t *testing.T) { + t.Run("Exec", func(t *testing.T) { + m := mock.NewPGXMock() + m.ExpectExec("CREATE TABLE").WillReturnResult(mock.NewResult("", 0)) + m.ExpectClose() + + ob, err := octobe.New(postgres.OpenPGXWithConn(m)) + if !assert.NoError(t, err) { + t.FailNow() + } + + ctx := context.Background() + session, err := ob.Begin(ctx) + if !assert.NoError(t, err) { + t.FailNow() + } + + handler := func(builder postgres.Builder) (octobe.Void, error) { + query := builder(`CREATE TABLE`) + _, err := query.Exec() + if err != nil { + return nil, err + } + // Use it again + _, err = query.Exec() + return nil, err + } + + _, err = octobe.Execute(session, handler) + assert.ErrorIs(t, err, octobe.ErrAlreadyUsed) + + err = ob.Close(ctx) + assert.NoError(t, err) + + assert.NoError(t, m.AllExpectationsMet()) + }) + + t.Run("QueryRow", func(t *testing.T) { + m := mock.NewPGXMock() + name := "Some name" + + m.ExpectQueryRow("SELECT").WillReturnRow(mock.NewRow(1, name)) + m.ExpectClose() + + ob, err := octobe.New(postgres.OpenPGXWithConn(m)) + if !assert.NoError(t, err) { + t.FailNow() + } + + ctx := context.Background() + session, err := ob.Begin(ctx) + if !assert.NoError(t, err) { + t.FailNow() + } + + handler := func(builder postgres.Builder) (octobe.Void, error) { + query := builder(`SELECT`) + var p Product + err := query.QueryRow(&p.ID, &p.Name) + if err != nil { + return nil, err + } + // Use it again + err = query.QueryRow(&p.ID, &p.Name) + return nil, err + } + + _, err = octobe.Execute(session, handler) + assert.ErrorIs(t, err, octobe.ErrAlreadyUsed) + + err = ob.Close(ctx) + assert.NoError(t, err) + + assert.NoError(t, m.AllExpectationsMet()) + }) + + t.Run("Query", func(t *testing.T) { + m := mock.NewPGXMock() + m.ExpectQuery("SELECT").WillReturnRows(mock.NewRows([]string{"id", "name"})) + m.ExpectClose() + + ob, err := octobe.New(postgres.OpenPGXWithConn(m)) + if !assert.NoError(t, err) { + t.FailNow() + } + + ctx := context.Background() + session, err := ob.Begin(ctx) + if !assert.NoError(t, err) { + t.FailNow() + } + + handler := func(builder postgres.Builder) (octobe.Void, error) { + query := builder(`SELECT`) + err := query.Query(func(rows postgres.Rows) error { + return nil + }) + if err != nil { + return nil, err + } + // Use it again + err = query.Query(func(rows postgres.Rows) error { + return nil + }) + return nil, err + } + + _, err = octobe.Execute(session, handler) + assert.ErrorIs(t, err, octobe.ErrAlreadyUsed) + + err = ob.Close(ctx) + assert.NoError(t, err) + + assert.NoError(t, m.AllExpectationsMet()) + }) +} + +func TestOpenWithConnNil(t *testing.T) { + _, err := octobe.New(postgres.OpenPGXWithConn(nil)) + assert.Error(t, err) + assert.Equal(t, "conn is nil", err.Error()) +} + +func TestBeginError(t *testing.T) { + m := mock.NewPGXMock() + expectedErr := errors.New("begin error") + m.ExpectBeginTx().WillReturnError(expectedErr) + m.ExpectClose() + + ob, err := octobe.New(postgres.OpenPGXWithConn(m)) + if !assert.NoError(t, err) { + t.FailNow() + } + + ctx := context.Background() + _, err = ob.Begin(ctx, postgres.WithPGXTxOptions(postgres.PGXTxOptions{})) + assert.ErrorIs(t, err, expectedErr) + + err = ob.Close(ctx) + assert.NoError(t, err) + assert.NoError(t, m.AllExpectationsMet()) +} + +func TestCommitError(t *testing.T) { + m := mock.NewPGXMock() + expectedErr := errors.New("commit error") + m.ExpectBeginTx() + m.ExpectCommit().WillReturnError(expectedErr) + m.ExpectClose() + + ob, err := octobe.New(postgres.OpenPGXWithConn(m)) + if !assert.NoError(t, err) { + t.FailNow() + } + + ctx := context.Background() + session, err := ob.Begin(ctx, postgres.WithPGXTxOptions(postgres.PGXTxOptions{})) + if !assert.NoError(t, err) { + t.FailNow() + } + + err = session.Commit() + assert.ErrorIs(t, err, expectedErr) + + err = ob.Close(ctx) + assert.NoError(t, err) + assert.NoError(t, m.AllExpectationsMet()) +} + +func TestSegmentExecError(t *testing.T) { + t.Run("without tx", func(t *testing.T) { + m := mock.NewPGXMock() + expectedErr := errors.New("exec error") + m.ExpectExec("INSERT").WillReturnError(expectedErr) + m.ExpectClose() + + ob, err := octobe.New(postgres.OpenPGXWithConn(m)) + if !assert.NoError(t, err) { + t.FailNow() + } + + ctx := context.Background() + session, err := ob.Begin(ctx) + if !assert.NoError(t, err) { + t.FailNow() + } + + _, err = octobe.Execute(session, func(builder postgres.Builder) (octobe.Void, error) { + query := builder("INSERT") + _, err := query.Exec() + return nil, err + }) + assert.ErrorIs(t, err, expectedErr) + + err = ob.Close(ctx) + assert.NoError(t, err) + + assert.NoError(t, m.AllExpectationsMet()) + }) + + t.Run("with tx", func(t *testing.T) { + m := mock.NewPGXMock() + expectedErr := errors.New("exec error") + m.ExpectBeginTx() + m.ExpectExec("INSERT").WillReturnError(expectedErr) + m.ExpectRollback() + m.ExpectClose() + + ob, err := octobe.New(postgres.OpenPGXWithConn(m)) + if !assert.NoError(t, err) { + t.FailNow() + } + + ctx := context.Background() + err = ob.StartTransaction(ctx, func(session octobe.BuilderSession[postgres.Builder]) error { + _, err := octobe.Execute(session, func(builder postgres.Builder) (octobe.Void, error) { + query := builder("INSERT") + _, err := query.Exec() + return nil, err + }) + return err + }, postgres.WithPGXTxOptions(postgres.PGXTxOptions{})) + + assert.ErrorIs(t, err, expectedErr) + + err = ob.Close(ctx) + assert.NoError(t, err) + + assert.NoError(t, m.AllExpectationsMet()) + }) +} + +func TestSegmentQueryRowError(t *testing.T) { + t.Run("without tx", func(t *testing.T) { + m := mock.NewPGXMock() + expectedErr := errors.New("query row error") + m.ExpectQueryRow("SELECT").WillReturnRow(mock.NewRow().WillReturnError(expectedErr)) + m.ExpectClose() + + ob, err := octobe.New(postgres.OpenPGXWithConn(m)) + if !assert.NoError(t, err) { + t.FailNow() + } + + ctx := context.Background() + session, err := ob.Begin(ctx) + if !assert.NoError(t, err) { + t.FailNow() + } + + _, err = octobe.Execute(session, func(builder postgres.Builder) (Product, error) { + var p Product + query := builder("SELECT") + err := query.QueryRow(&p.ID, &p.Name) + return p, err + }) + assert.ErrorIs(t, err, expectedErr) + + err = ob.Close(ctx) + assert.NoError(t, err) + + assert.NoError(t, m.AllExpectationsMet()) + }) + + t.Run("with tx", func(t *testing.T) { + m := mock.NewPGXMock() + expectedErr := errors.New("query row error") + m.ExpectBeginTx() + m.ExpectQueryRow("SELECT").WillReturnRow(mock.NewRow().WillReturnError(expectedErr)) + m.ExpectRollback() + m.ExpectClose() + + ob, err := octobe.New(postgres.OpenPGXWithConn(m)) + if !assert.NoError(t, err) { + t.FailNow() + } + + ctx := context.Background() + err = ob.StartTransaction(ctx, func(session octobe.BuilderSession[postgres.Builder]) error { + _, err := octobe.Execute(session, func(builder postgres.Builder) (Product, error) { + var p Product + query := builder("SELECT") + err := query.QueryRow(&p.ID, &p.Name) + return p, err + }) + return err + }, postgres.WithPGXTxOptions(postgres.PGXTxOptions{})) + + assert.ErrorIs(t, err, expectedErr) + + err = ob.Close(ctx) + assert.NoError(t, err) + + assert.NoError(t, m.AllExpectationsMet()) + }) +} + +func TestSegmentQueryError(t *testing.T) { + t.Run("query error without tx", func(t *testing.T) { + m := mock.NewPGXMock() + expectedErr := errors.New("query error") + m.ExpectQuery("SELECT").WillReturnError(expectedErr) + m.ExpectClose() + + ob, err := octobe.New(postgres.OpenPGXWithConn(m)) + if !assert.NoError(t, err) { + t.FailNow() + } + + ctx := context.Background() + session, err := ob.Begin(ctx) + if !assert.NoError(t, err) { + t.FailNow() + } + + _, err = octobe.Execute(session, func(builder postgres.Builder) (octobe.Void, error) { + query := builder("SELECT") + err := query.Query(func(rows postgres.Rows) error { return nil }) + return nil, err + }) + assert.ErrorIs(t, err, expectedErr) + + err = ob.Close(ctx) + assert.NoError(t, err) + + assert.NoError(t, m.AllExpectationsMet()) + }) + + t.Run("query error with tx", func(t *testing.T) { + m := mock.NewPGXMock() + expectedErr := errors.New("query error") + m.ExpectBeginTx() + m.ExpectQuery("SELECT").WillReturnError(expectedErr) + m.ExpectRollback() + m.ExpectClose() + + ob, err := octobe.New(postgres.OpenPGXWithConn(m)) + if !assert.NoError(t, err) { + t.FailNow() + } + + ctx := context.Background() + err = ob.StartTransaction(ctx, func(session octobe.BuilderSession[postgres.Builder]) error { + _, err := octobe.Execute(session, func(builder postgres.Builder) (octobe.Void, error) { + query := builder("SELECT") + err := query.Query(func(rows postgres.Rows) error { return nil }) + return nil, err + }) + return err + }, postgres.WithPGXTxOptions(postgres.PGXTxOptions{})) + + assert.ErrorIs(t, err, expectedErr) + + err = ob.Close(ctx) + assert.NoError(t, err) + + assert.NoError(t, m.AllExpectationsMet()) + }) + + t.Run("callback error without tx", func(t *testing.T) { + m := mock.NewPGXMock() + expectedErr := errors.New("callback error") + m.ExpectQuery("SELECT").WillReturnRows(mock.NewRows([]string{"id"}).AddRow(1)) + m.ExpectClose() + + ob, err := octobe.New(postgres.OpenPGXWithConn(m)) + if !assert.NoError(t, err) { + t.FailNow() + } + + ctx := context.Background() + session, err := ob.Begin(ctx) + if !assert.NoError(t, err) { + t.FailNow() + } + + _, err = octobe.Execute(session, func(builder postgres.Builder) (octobe.Void, error) { + query := builder("SELECT") + err := query.Query(func(rows postgres.Rows) error { return expectedErr }) + return nil, err + }) + assert.ErrorIs(t, err, expectedErr) + + err = ob.Close(ctx) + assert.NoError(t, err) + + assert.NoError(t, m.AllExpectationsMet()) + }) + + t.Run("callback error with tx", func(t *testing.T) { + m := mock.NewPGXMock() + expectedErr := errors.New("callback error") + m.ExpectBeginTx() + m.ExpectQuery("SELECT").WillReturnRows(mock.NewRows([]string{"id"}).AddRow(1)) + m.ExpectRollback() + m.ExpectClose() + + ob, err := octobe.New(postgres.OpenPGXWithConn(m)) + if !assert.NoError(t, err) { + t.FailNow() + } + + ctx := context.Background() + err = ob.StartTransaction(ctx, func(session octobe.BuilderSession[postgres.Builder]) error { + _, err := octobe.Execute(session, func(builder postgres.Builder) (octobe.Void, error) { + query := builder("SELECT") + err := query.Query(func(rows postgres.Rows) error { return expectedErr }) + return nil, err + }) + return err + }, postgres.WithPGXTxOptions(postgres.PGXTxOptions{})) + + assert.ErrorIs(t, err, expectedErr) + + err = ob.Close(ctx) + assert.NoError(t, err) + + assert.NoError(t, m.AllExpectationsMet()) + }) +} diff --git a/driver/postgres/pgxpool.go b/driver/postgres/pgxpool.go new file mode 100644 index 0000000..c8597ae --- /dev/null +++ b/driver/postgres/pgxpool.go @@ -0,0 +1,247 @@ +package postgres + +import ( + "context" + "errors" + + "github.com/Kansuler/octobe/v3" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" + "github.com/jackc/pgx/v5/pgxpool" +) + +// PGXPool defines the essential pgxpool interface. +type PGXPool interface { + Close() + Acquire(ctx context.Context) (c *pgxpool.Conn, err error) + AcquireFunc(ctx context.Context, f func(*pgxpool.Conn) error) error + AcquireAllIdle(ctx context.Context) []*pgxpool.Conn + Reset() + Config() *pgxpool.Config + Stat() *pgxpool.Stat + Begin(context.Context) (pgx.Tx, error) + BeginTx(context.Context, pgx.TxOptions) (pgx.Tx, error) + Exec(context.Context, string, ...any) (pgconn.CommandTag, error) + Query(context.Context, string, ...any) (pgx.Rows, error) + QueryRow(context.Context, string, ...any) pgx.Row + SendBatch(ctx context.Context, b *pgx.Batch) pgx.BatchResults + CopyFrom(ctx context.Context, tableName pgx.Identifier, columnNames []string, rowSrc pgx.CopyFromSource) (int64, error) + Ping(ctx context.Context) error +} + +var _ PGXPool = &pgxpool.Pool{} + +type pgxpoolConn struct { + pool PGXPool +} + +var _ PGXPoolDriver = &pgxpoolConn{} + +// OpenPGXPool creates a connection pool driver from a DSN. +func OpenPGXPool(ctx context.Context, dsn string) octobe.Open[pgxpoolConn, pgxConfig, Builder] { + return func() (octobe.Driver[pgxpoolConn, pgxConfig, Builder], error) { + pool, err := pgxpool.New(ctx, dsn) + if err != nil { + return nil, err + } + + return &pgxpoolConn{ + pool: pool, + }, nil + } +} + +// OpenPGXWithPool creates a driver from an existing pool. +func OpenPGXWithPool(pool PGXPool) octobe.Open[pgxpoolConn, pgxConfig, Builder] { + return func() (octobe.Driver[pgxpoolConn, pgxConfig, Builder], error) { + if pool == nil { + return nil, errors.New("pool is nil") + } + + return &pgxpoolConn{ + pool: pool, + }, nil + } +} + +// Begin starts a new session, optionally within a transaction. +func (d *pgxpoolConn) Begin(ctx context.Context, opts ...octobe.Option[pgxConfig]) (octobe.Session[Builder], error) { + var cfg pgxConfig + for _, opt := range opts { + opt(&cfg) + } + + var tx pgx.Tx + var err error + if cfg.txOptions != nil { + tx, err = d.pool.BeginTx(ctx, pgx.TxOptions{ + IsoLevel: cfg.txOptions.IsoLevel, + AccessMode: cfg.txOptions.AccessMode, + DeferrableMode: cfg.txOptions.DeferrableMode, + BeginQuery: cfg.txOptions.BeginQuery, + }) + } + + if err != nil { + return nil, err + } + + return &pgxpoolSession{ + ctx: ctx, + cfg: cfg, + tx: tx, + d: d, + }, nil +} + +func (d *pgxpoolConn) Close(_ context.Context) error { + d.pool.Close() + return nil +} + +func (d *pgxpoolConn) Ping(ctx context.Context) error { + if d.pool == nil { + return errors.New("pool is nil") + } + return d.pool.Ping(ctx) +} + +func (d *pgxpoolConn) StartTransaction(ctx context.Context, fn func(session octobe.BuilderSession[Builder]) error, opts ...octobe.Option[pgxConfig]) (err error) { + return octobe.StartTransaction[pgxpoolConn, pgxConfig, Builder](ctx, d, fn, opts...) +} + +// pgxpoolSession manages a pooled database session. +type pgxpoolSession struct { + ctx context.Context + cfg pgxConfig + tx pgx.Tx + d *pgxpoolConn + committed bool +} + +var _ octobe.Session[Builder] = &pgxpoolSession{} + +// Commit commits the transaction. +func (s *pgxpoolSession) Commit() error { + if s.committed { + return errors.New("cannot commit a session that has already been committed") + } + if s.cfg.txOptions == nil { + return errors.New("cannot commit without transaction") + } + defer func() { + s.committed = true + }() + return s.tx.Commit(s.ctx) +} + +// Rollback rolls back the transaction. +func (s *pgxpoolSession) Rollback() error { + if s.cfg.txOptions == nil { + return errors.New("cannot rollback without transaction") + } + return s.tx.Rollback(s.ctx) +} + +// Builder returns a query builder for this session. +func (s *pgxpoolSession) Builder() Builder { + return func(query string) Segment { + return &pgxpoolSegment{ + query: query, + args: nil, + used: false, + tx: s.tx, + d: s.d, + ctx: s.ctx, + } + } +} + +// pgxpoolSegment represents a single-use query with arguments. +type pgxpoolSegment struct { + query string + args []any + used bool + tx pgx.Tx + d *pgxpoolConn + ctx context.Context +} + +var _ Segment = &pgxpoolSegment{} + +func (s *pgxpoolSegment) use() { + s.used = true +} + +// Arguments sets query parameters. +func (s *pgxpoolSegment) Arguments(args ...any) Segment { + s.args = args + return s +} + +// Exec executes the query and returns affected rows. +func (s *pgxpoolSegment) Exec() (ExecResult, error) { + if s.used { + return ExecResult{}, octobe.ErrAlreadyUsed + } + defer s.use() + if s.tx == nil { + res, err := s.d.pool.Exec(s.ctx, s.query, s.args...) + if err != nil { + return ExecResult{}, err + } + + return ExecResult{ + RowsAffected: res.RowsAffected(), + }, nil + } + + res, err := s.tx.Exec(s.ctx, s.query, s.args...) + if err != nil { + return ExecResult{}, err + } + return ExecResult{ + RowsAffected: res.RowsAffected(), + }, nil +} + +// QueryRow executes the query expecting one row and scans into dest. +func (s *pgxpoolSegment) QueryRow(dest ...any) error { + if s.used { + return octobe.ErrAlreadyUsed + } + defer s.use() + if s.tx == nil { + return s.d.pool.QueryRow(s.ctx, s.query, s.args...).Scan(dest...) + } + return s.tx.QueryRow(s.ctx, s.query, s.args...).Scan(dest...) +} + +// Query executes the query and calls cb for each row. +func (s *pgxpoolSegment) Query(cb func(Rows) error) error { + if s.used { + return octobe.ErrAlreadyUsed + } + defer s.use() + + var err error + var rows pgx.Rows + if s.tx == nil { + rows, err = s.d.pool.Query(s.ctx, s.query, s.args...) + if err != nil { + return err + } + } else { + rows, err = s.tx.Query(s.ctx, s.query, s.args...) + if err != nil { + return err + } + } + + defer rows.Close() + if err = cb(rows); err != nil { + return err + } + + return nil +} diff --git a/driver/postgres/pgxpool_test.go b/driver/postgres/pgxpool_test.go new file mode 100644 index 0000000..47c0882 --- /dev/null +++ b/driver/postgres/pgxpool_test.go @@ -0,0 +1,654 @@ +package postgres_test + +import ( + "context" + "errors" + "testing" + + "github.com/Kansuler/octobe/v3" + "github.com/Kansuler/octobe/v3/driver/postgres" + "github.com/Kansuler/octobe/v3/driver/postgres/mock" + "github.com/stretchr/testify/assert" +) + +func TestPGXPoolWithTxInsideStartTransaction(t *testing.T) { + m := mock.NewPGXPoolMock() + ctx := context.Background() + + name := "Some name" + + m.ExpectBeginTx() + m.ExpectExec("CREATE TABLE IF NOT EXISTS products").WillReturnResult(mock.NewResult("", 0)) + m.ExpectQueryRow("INSERT INTO products").WithArgs(name).WillReturnRow(mock.NewRow(1, name)) + m.ExpectQuery("SELECT id, name FROM products").WithArgs(name).WillReturnRows(mock.NewRows([]string{"id", "name"}).AddRow(1, name)) + m.ExpectCommit() + m.ExpectClose() + + ob, err := octobe.New(postgres.OpenPGXWithPool(m)) + if !assert.NoError(t, err) { + t.FailNow() + } + + err = ob.StartTransaction(ctx, func(session octobe.BuilderSession[postgres.Builder]) error { + _, err := octobe.Execute(session, Migration()) + if !assert.NoError(t, err) { + return err + } + + p, err := octobe.Execute(session, AddProduct(name)) + if !assert.NoError(t, err) { + return err + } + + if !assert.Equal(t, 1, p.ID) { + return errors.New("expected ID to be 1") + } + + if !assert.Equal(t, name, p.Name) { + return errors.New("expected name to be " + name) + } + + products, err := octobe.Execute(session, ProductsByName(name)) + if !assert.NoError(t, err) { + return err + } + + if !assert.Len(t, products, 1) { + return errors.New("expected 1 product") + } + + if !assert.Equal(t, 1, products[0].ID) { + return errors.New("expected ID to be 1") + } + + if !assert.Equal(t, name, products[0].Name) { + return errors.New("expected name to be " + name) + } + + return nil + }, postgres.WithPGXTxOptions(postgres.PGXTxOptions{})) + + assert.NoError(t, err) + assert.NoError(t, ob.Close(ctx)) + assert.NoError(t, m.AllExpectationsMet()) +} + +func TestPGXPoolWithTx(t *testing.T) { + m := mock.NewPGXPoolMock() + ctx := context.Background() + + name := "Some name" + + m.ExpectBeginTx() + m.ExpectExec("CREATE TABLE IF NOT EXISTS products").WillReturnResult(mock.NewResult("", 0)) + m.ExpectQueryRow("INSERT INTO products").WithArgs(name).WillReturnRow(mock.NewRow(1, name)) + m.ExpectQuery("SELECT id, name FROM products").WithArgs(name).WillReturnRows(mock.NewRows([]string{"id", "name"}).AddRow(1, name)) + m.ExpectCommit() + + ob, err := octobe.New(postgres.OpenPGXWithPool(m)) + if !assert.NoError(t, err) { + t.FailNow() + } + + session, err := ob.Begin(ctx, postgres.WithPGXTxOptions(postgres.PGXTxOptions{})) + if !assert.NoError(t, err) { + t.FailNow() + } + + _, err = octobe.Execute(session, Migration()) + if !assert.NoError(t, err) { + t.FailNow() + } + + p, err := octobe.Execute(session, AddProduct(name)) + if !assert.NoError(t, err) { + t.FailNow() + } + + if !assert.Equal(t, 1, p.ID) { + t.FailNow() + } + + if !assert.Equal(t, name, p.Name) { + t.FailNow() + } + + products, err := octobe.Execute(session, ProductsByName(name)) + if !assert.NoError(t, err) { + t.FailNow() + } + + if !assert.Len(t, products, 1) { + t.FailNow() + } + + if !assert.Equal(t, 1, products[0].ID) { + t.FailNow() + } + + if !assert.Equal(t, name, products[0].Name) { + t.FailNow() + } + + err = session.Commit() + assert.NoError(t, err) + assert.NoError(t, m.AllExpectationsMet()) +} + +func TestPGXPoolWithoutTx(t *testing.T) { + m := mock.NewPGXPoolMock() + ctx := context.Background() + + name := "Some name" + + m.ExpectExec("CREATE TABLE IF NOT EXISTS products").WillReturnResult(mock.NewResult("", 0)) + m.ExpectQueryRow("INSERT INTO products").WithArgs(name).WillReturnRow(mock.NewRow(1, name)) + m.ExpectQuery("SELECT id, name FROM products").WithArgs(name).WillReturnRows(mock.NewRows([]string{"id", "name"}).AddRow(1, name)) + + ob, err := octobe.New(postgres.OpenPGXWithPool(m)) + if !assert.NoError(t, err) { + t.FailNow() + } + + session, err := ob.Begin(ctx) + if !assert.NoError(t, err) { + t.FailNow() + } + + _, err = octobe.Execute(session, Migration()) + if !assert.NoError(t, err) { + t.FailNow() + } + + p, err := octobe.Execute(session, AddProduct(name)) + if !assert.NoError(t, err) { + t.FailNow() + } + + if !assert.Equal(t, 1, p.ID) { + t.FailNow() + } + + if !assert.Equal(t, name, p.Name) { + t.FailNow() + } + + products, err := octobe.Execute(session, ProductsByName(name)) + if !assert.NoError(t, err) { + t.FailNow() + } + + if !assert.Len(t, products, 1) { + t.FailNow() + } + + if !assert.Equal(t, 1, products[0].ID) { + t.FailNow() + } + + if !assert.Equal(t, name, products[0].Name) { + t.FailNow() + } + + assert.NoError(t, m.AllExpectationsMet()) +} + +func TestPGXPoolWithTxInsideStartTransactionRollbackOnError(t *testing.T) { + m := mock.NewPGXPoolMock() + ctx := context.Background() + + expectedErr := errors.New("some error") + + m.ExpectBeginTx() + m.ExpectExec("CREATE TABLE IF NOT EXISTS products").WillReturnError(expectedErr) + m.ExpectRollback() + m.ExpectClose() + + ob, err := octobe.New(postgres.OpenPGXWithPool(m)) + if !assert.NoError(t, err) { + t.FailNow() + } + + err = ob.StartTransaction(ctx, func(session octobe.BuilderSession[postgres.Builder]) error { + _, err := octobe.Execute(session, Migration()) + return err + }, postgres.WithPGXTxOptions(postgres.PGXTxOptions{})) + + assert.Error(t, err) + assert.Equal(t, expectedErr, err) + assert.NoError(t, ob.Close(ctx)) + assert.NoError(t, m.AllExpectationsMet()) +} + +func TestPGXPoolWithTxInsideStartTransactionRollbackOnPanic(t *testing.T) { + m := mock.NewPGXPoolMock() + ctx := context.Background() + + m.ExpectBeginTx() + m.ExpectExec("CREATE TABLE IF NOT EXISTS products").WillReturnResult(mock.NewResult("", 0)) + m.ExpectRollback() + m.ExpectClose() + + ob, err := octobe.New(postgres.OpenPGXWithPool(m)) + if !assert.NoError(t, err) { + t.FailNow() + } + + assert.Panics(t, func() { + _ = ob.StartTransaction(ctx, func(session octobe.BuilderSession[postgres.Builder]) error { + _, err := octobe.Execute(session, Migration()) + if err != nil { + return err + } + panic("some panic") + }, postgres.WithPGXTxOptions(postgres.PGXTxOptions{})) + }) + + assert.NoError(t, ob.Close(ctx)) + assert.NoError(t, m.AllExpectationsMet()) +} + +func TestPGXPoolWithTxManualRollback(t *testing.T) { + m := mock.NewPGXPoolMock() + ctx := context.Background() + + name := "Some name" + + m.ExpectBeginTx() + m.ExpectExec("CREATE TABLE IF NOT EXISTS products").WillReturnResult(mock.NewResult("", 0)) + m.ExpectQueryRow("INSERT INTO products").WithArgs(name).WillReturnRow(mock.NewRow(1, name)) + m.ExpectRollback() + + ob, err := octobe.New(postgres.OpenPGXWithPool(m)) + if !assert.NoError(t, err) { + t.FailNow() + } + + session, err := ob.Begin(ctx, postgres.WithPGXTxOptions(postgres.PGXTxOptions{})) + if !assert.NoError(t, err) { + t.FailNow() + } + + _, err = octobe.Execute(session, Migration()) + if !assert.NoError(t, err) { + t.FailNow() + } + + p, err := octobe.Execute(session, AddProduct(name)) + if !assert.NoError(t, err) { + t.FailNow() + } + + if !assert.Equal(t, 1, p.ID) { + t.FailNow() + } + + if !assert.Equal(t, name, p.Name) { + t.FailNow() + } + + err = session.Rollback() + assert.NoError(t, err) + assert.NoError(t, m.AllExpectationsMet()) +} + +func TestPGXPoolWithoutTxCommit(t *testing.T) { + m := mock.NewPGXPoolMock() + ctx := context.Background() + + m.ExpectExec("CREATE TABLE IF NOT EXISTS products").WillReturnResult(mock.NewResult("", 0)) + + ob, err := octobe.New(postgres.OpenPGXWithPool(m)) + if !assert.NoError(t, err) { + t.FailNow() + } + + session, err := ob.Begin(ctx) + if !assert.NoError(t, err) { + t.FailNow() + } + + _, err = octobe.Execute(session, Migration()) + if !assert.NoError(t, err) { + t.FailNow() + } + + err = session.Commit() + assert.Error(t, err) + assert.NoError(t, m.AllExpectationsMet()) +} + +func TestPGXPoolWithoutTxRollback(t *testing.T) { + m := mock.NewPGXPoolMock() + ctx := context.Background() + + m.ExpectExec("CREATE TABLE IF NOT EXISTS products").WillReturnResult(mock.NewResult("", 0)) + + ob, err := octobe.New(postgres.OpenPGXWithPool(m)) + if !assert.NoError(t, err) { + t.FailNow() + } + + session, err := ob.Begin(ctx) + if !assert.NoError(t, err) { + t.FailNow() + } + + _, err = octobe.Execute(session, Migration()) + if !assert.NoError(t, err) { + t.FailNow() + } + + err = session.Rollback() + assert.Error(t, err) + assert.NoError(t, m.AllExpectationsMet()) +} + +func TestPGXPoolSegmentUsedTwice(t *testing.T) { + m := mock.NewPGXPoolMock() + ctx := context.Background() + + name := "Some name" + + m.ExpectExec("CREATE TABLE IF NOT EXISTS products").WillReturnResult(mock.NewResult("", 0)) + + ob, err := octobe.New(postgres.OpenPGXWithPool(m)) + if !assert.NoError(t, err) { + t.FailNow() + } + + session, err := ob.Begin(ctx) + if !assert.NoError(t, err) { + t.FailNow() + } + + t.Run("Exec", func(t *testing.T) { + segment := session.Builder()("CREATE TABLE IF NOT EXISTS products (id SERIAL PRIMARY KEY, name TEXT NOT NULL)") + + _, err := segment.Exec() + assert.NoError(t, err) + + _, err = segment.Exec() + assert.Error(t, err) + assert.Equal(t, octobe.ErrAlreadyUsed, err) + }) + + m.ExpectQueryRow("INSERT INTO products").WithArgs(name).WillReturnRow(mock.NewRow(1, name)) + + t.Run("QueryRow", func(t *testing.T) { + segment := session.Builder()("INSERT INTO products (name) VALUES ($1) RETURNING id, name").Arguments(name) + + var p Product + err := segment.QueryRow(&p.ID, &p.Name) + assert.NoError(t, err) + assert.Equal(t, 1, p.ID) + assert.Equal(t, name, p.Name) + + var p2 Product + err = segment.QueryRow(&p2.ID, &p2.Name) + assert.Error(t, err) + assert.Equal(t, octobe.ErrAlreadyUsed, err) + }) + + m.ExpectQuery("SELECT id, name FROM products").WithArgs(name).WillReturnRows(mock.NewRows([]string{"id", "name"}).AddRow(1, name)) + + t.Run("Query", func(t *testing.T) { + segment := session.Builder()("SELECT id, name FROM products WHERE name = $1").Arguments(name) + + var products []Product + err := segment.Query(func(r postgres.Rows) error { + for r.Next() { + var p Product + if err := r.Scan(&p.ID, &p.Name); err != nil { + return err + } + products = append(products, p) + } + return r.Err() + }) + assert.NoError(t, err) + assert.Len(t, products, 1) + + var products2 []Product + err = segment.Query(func(r postgres.Rows) error { + for r.Next() { + var p Product + if err := r.Scan(&p.ID, &p.Name); err != nil { + return err + } + products2 = append(products2, p) + } + return r.Err() + }) + assert.Error(t, err) + assert.Equal(t, octobe.ErrAlreadyUsed, err) + }) + + assert.NoError(t, m.AllExpectationsMet()) +} + +func TestOpenPGXWithPoolNil(t *testing.T) { + _, err := postgres.OpenPGXWithPool(nil)() + assert.Error(t, err) +} + +func TestPGXPoolBeginError(t *testing.T) { + m := mock.NewPGXPoolMock() + ctx := context.Background() + + expectedErr := errors.New("begin error") + m.ExpectBeginTx().WillReturnError(expectedErr) + + ob, err := octobe.New(postgres.OpenPGXWithPool(m)) + if !assert.NoError(t, err) { + t.FailNow() + } + + _, err = ob.Begin(ctx, postgres.WithPGXTxOptions(postgres.PGXTxOptions{})) + assert.Error(t, err) + assert.Equal(t, expectedErr, err) + assert.NoError(t, m.AllExpectationsMet()) +} + +func TestPGXPoolCommitError(t *testing.T) { + m := mock.NewPGXPoolMock() + ctx := context.Background() + + expectedErr := errors.New("commit error") + m.ExpectBeginTx() + m.ExpectCommit().WillReturnError(expectedErr) + + ob, err := octobe.New(postgres.OpenPGXWithPool(m)) + if !assert.NoError(t, err) { + t.FailNow() + } + + session, err := ob.Begin(ctx, postgres.WithPGXTxOptions(postgres.PGXTxOptions{})) + if !assert.NoError(t, err) { + t.FailNow() + } + + err = session.Commit() + assert.Error(t, err) + assert.Equal(t, expectedErr, err) + assert.NoError(t, m.AllExpectationsMet()) +} + +func TestPGXPoolSegmentExecError(t *testing.T) { + m := mock.NewPGXPoolMock() + ctx := context.Background() + + expectedErr := errors.New("exec error") + + t.Run("WithoutTx", func(t *testing.T) { + m.ExpectExec("INSERT INTO products").WillReturnError(expectedErr) + + ob, err := octobe.New(postgres.OpenPGXWithPool(m)) + if !assert.NoError(t, err) { + t.FailNow() + } + + session, err := ob.Begin(ctx) + if !assert.NoError(t, err) { + t.FailNow() + } + + _, err = session.Builder()("INSERT INTO products (name) VALUES ($1)").Arguments("test").Exec() + assert.Error(t, err) + assert.Equal(t, expectedErr, err) + }) + + t.Run("WithTx", func(t *testing.T) { + m.ExpectBeginTx() + m.ExpectExec("INSERT INTO products").WillReturnError(expectedErr) + + ob, err := octobe.New(postgres.OpenPGXWithPool(m)) + if !assert.NoError(t, err) { + t.FailNow() + } + + session, err := ob.Begin(ctx, postgres.WithPGXTxOptions(postgres.PGXTxOptions{})) + if !assert.NoError(t, err) { + t.FailNow() + } + + _, err = session.Builder()("INSERT INTO products (name) VALUES ($1)").Arguments("test").Exec() + assert.Error(t, err) + assert.Equal(t, expectedErr, err) + }) + + assert.NoError(t, m.AllExpectationsMet()) +} + +func TestPGXPoolSegmentQueryRowError(t *testing.T) { + m := mock.NewPGXPoolMock() + ctx := context.Background() + + expectedErr := errors.New("query row error") + + t.Run("WithoutTx", func(t *testing.T) { + row := mock.NewRow().WillReturnError(expectedErr) + m.ExpectQueryRow("SELECT id FROM products").WillReturnRow(row) + + ob, err := octobe.New(postgres.OpenPGXWithPool(m)) + if !assert.NoError(t, err) { + t.FailNow() + } + + session, err := ob.Begin(ctx) + if !assert.NoError(t, err) { + t.FailNow() + } + + var id int + err = session.Builder()("SELECT id FROM products WHERE name = $1").Arguments("test").QueryRow(&id) + assert.Error(t, err) + assert.Equal(t, expectedErr, err) + }) + + t.Run("WithTx", func(t *testing.T) { + m.ExpectBeginTx() + row := mock.NewRow().WillReturnError(expectedErr) + m.ExpectQueryRow("SELECT id FROM products").WillReturnRow(row) + + ob, err := octobe.New(postgres.OpenPGXWithPool(m)) + if !assert.NoError(t, err) { + t.FailNow() + } + + session, err := ob.Begin(ctx, postgres.WithPGXTxOptions(postgres.PGXTxOptions{})) + if !assert.NoError(t, err) { + t.FailNow() + } + + var id int + err = session.Builder()("SELECT id FROM products WHERE name = $1").Arguments("test").QueryRow(&id) + assert.Error(t, err) + assert.Equal(t, expectedErr, err) + }) + + assert.NoError(t, m.AllExpectationsMet()) +} + +func TestPGXPoolSegmentQueryError(t *testing.T) { + m := mock.NewPGXPoolMock() + ctx := context.Background() + + expectedErr := errors.New("query error") + + t.Run("WithoutTx", func(t *testing.T) { + m.ExpectQuery("SELECT id, name FROM products").WillReturnError(expectedErr) + + ob, err := octobe.New(postgres.OpenPGXWithPool(m)) + if !assert.NoError(t, err) { + t.FailNow() + } + + session, err := ob.Begin(ctx) + if !assert.NoError(t, err) { + t.FailNow() + } + + err = session.Builder()("SELECT id, name FROM products WHERE name = $1").Arguments("test").Query(func(r postgres.Rows) error { + for r.Next() { + var p Product + if err := r.Scan(&p.ID, &p.Name); err != nil { + return err + } + } + return r.Err() + }) + assert.Error(t, err) + assert.Equal(t, expectedErr, err) + }) + + t.Run("WithTx", func(t *testing.T) { + m.ExpectBeginTx() + m.ExpectQuery("SELECT id, name FROM products").WillReturnError(expectedErr) + + ob, err := octobe.New(postgres.OpenPGXWithPool(m)) + if !assert.NoError(t, err) { + t.FailNow() + } + + session, err := ob.Begin(ctx, postgres.WithPGXTxOptions(postgres.PGXTxOptions{})) + if !assert.NoError(t, err) { + t.FailNow() + } + + err = session.Builder()("SELECT id, name FROM products WHERE name = $1").Arguments("test").Query(func(r postgres.Rows) error { + for r.Next() { + var p Product + if err := r.Scan(&p.ID, &p.Name); err != nil { + return err + } + } + return r.Err() + }) + assert.Error(t, err) + assert.Equal(t, expectedErr, err) + }) + + t.Run("CallbackError", func(t *testing.T) { + rows := mock.NewRows([]string{"id", "name"}).AddRow(1, "test") + m.ExpectQuery("SELECT id, name FROM products").WillReturnRows(rows) + + ob, err := octobe.New(postgres.OpenPGXWithPool(m)) + if !assert.NoError(t, err) { + t.FailNow() + } + + session, err := ob.Begin(ctx) + if !assert.NoError(t, err) { + t.FailNow() + } + + err = session.Builder()("SELECT id, name FROM products WHERE name = $1").Arguments("test").Query(func(r postgres.Rows) error { + return expectedErr + }) + assert.Error(t, err) + assert.Equal(t, expectedErr, err) + }) + + assert.NoError(t, m.AllExpectationsMet()) +} diff --git a/driver/postgres/postgres.go b/driver/postgres/postgres.go index f8dc8df..048ff2a 100644 --- a/driver/postgres/postgres.go +++ b/driver/postgres/postgres.go @@ -1,257 +1,81 @@ package postgres import ( - "context" - "errors" - "github.com/Kansuler/octobe/v2" + "github.com/Kansuler/octobe/v3" "github.com/jackc/pgx/v5" - "github.com/jackc/pgx/v5/pgconn" ) -type Driver octobe.Driver[postgres, config, Builder] - -// postgres holds the connection pool and default configuration for the postgres driver -type postgres struct { - pool *pgx.Conn - cfg config -} - -// config defined various configurations possible for the postgres driver -type config struct { - txOptions *TxOptions -} - -// TxOptions is a struct that holds the options for a transaction -type TxOptions pgx.TxOptions - -// WithTransaction enables the use of a transaction for the session, enforce the usage of commit and rollback. -func WithTransaction(options TxOptions) octobe.Option[config] { - return func(c *config) { - c.txOptions = &options - } -} - -// WithoutTransaction disables the use of a transaction for the session, this will not enforce the usage of commit and -// rollback. -func WithoutTransaction() octobe.Option[config] { - return func(c *config) { - c.txOptions = nil - } -} - -// Type check to make sure that the postgres driver implements the Octobe Driver interface -var _ octobe.Driver[postgres, config, Builder] = &postgres{} - -// Open is a function that can be used for opening a new database connection, it should always return a driver with set -// signature of types for the local driver. -func Open(ctx context.Context, dsn string, opts ...octobe.Option[config]) octobe.Open[postgres, config, Builder] { - return func() (octobe.Driver[postgres, config, Builder], error) { - pool, err := pgx.Connect(ctx, dsn) - if err != nil { - return nil, err - } - - var cfg config - for _, opt := range opts { - opt(&cfg) - } - - return &postgres{ - pool: pool, - cfg: cfg, - }, nil - } -} - -// OpenWithPool is a function that can be used for opening a new database connection, it should always return a driver -// with set signature of types for the local driver. This function is used when a connection pool is already available. -func OpenWithPool(pool *pgx.Conn, opts ...octobe.Option[config]) octobe.Open[postgres, config, Builder] { - return func() (octobe.Driver[postgres, config, Builder], error) { - if pool == nil { - return nil, errors.New("pool is nil") - } - - var cfg config - for _, opt := range opts { - opt(&cfg) - } - - return &postgres{ - pool: pool, - cfg: cfg, - }, nil - } -} - -// Begin will start a new session with the database, this will return a Session instance that can be used for handling -// queries. Options can be passed to the driver for specific configuration that overwrites the default configuration -// given at instantiation of the Octobe instance. If no options are passed, the default configuration will be used. -// If the default configuration is not set, the session will not be transactional. -func (d *postgres) Begin(ctx context.Context, opts ...octobe.Option[config]) (octobe.Session[Builder], error) { - cfg := d.cfg - for _, opt := range opts { - opt(&cfg) - } - - var tx pgx.Tx - var err error - if cfg.txOptions == nil { - tx, err = d.pool.Begin(ctx) - } else { - tx, err = d.pool.BeginTx(ctx, pgx.TxOptions{ - IsoLevel: cfg.txOptions.IsoLevel, - AccessMode: cfg.txOptions.AccessMode, - DeferrableMode: cfg.txOptions.DeferrableMode, - BeginQuery: cfg.txOptions.BeginQuery, - }) - } - - if err != nil { - return nil, err - } - - return &session{ - ctx: ctx, - cfg: cfg, - tx: tx, - }, nil -} - -// Close will close the database connection. -func (d *postgres) Close(ctx context.Context) error { - return d.pool.Close(ctx) -} - -// session is a struct that holds session context, a session should be considered a series of queries that are related -// to each other. A session can be transactional or non-transactional, if it is transactional, it will enforce the usage -// of commit and rollback. If it is non-transactional, it will not enforce the usage of commit and rollback. -// A session is not thread safe, it should only be used in one thread at a time. -type session struct { - ctx context.Context - cfg config - tx pgx.Tx - committed bool -} - -// Type check to make sure that the session implements the Octobe Session interface -var _ octobe.Session[Builder] = &session{} - -// Commit will commit a transaction, this will only work if the session is transactional. -func (s *session) Commit() error { - if s.cfg.txOptions == nil { - return errors.New("cannot commit without transaction") - } - defer func() { - s.committed = true - }() - return s.tx.Commit(s.ctx) -} - -// Rollback will rollback a transaction, this will only work if the session is transactional. -func (s *session) Rollback() error { - if s.cfg.txOptions == nil { - return errors.New("cannot rollback without transaction") - } - return s.tx.Rollback(s.ctx) -} - -// WatchRollback will watch for a rollback, if the session is not committed, it will rollback the transaction. -func (s *session) WatchRollback(cb func() error) { - if !s.committed { - _ = s.Rollback() - return - } - - if err := cb(); err != nil { - _ = s.Rollback() - } -} +type ( + PGXDriver octobe.Driver[pgxConn, pgxConfig, Builder] + PGXPoolDriver octobe.Driver[pgxpoolConn, pgxConfig, Builder] +) -// Builder is a function signature that is used for building queries with postgres +// Builder constructs executable query segments from SQL strings. type Builder func(query string) Segment -// Builder will return a new builder for building queries -func (s *session) Builder() Builder { - return func(query string) Segment { - return Segment{ - query: query, - args: nil, - used: false, - tx: s.tx, - ctx: s.ctx, - } - } -} - -// Handler is a signature type for a handler. The handler receives a builder of the specific driver and returns a result -// and an error. -type Handler[RESULT any] func(Builder) (RESULT, error) +// PGXTxOptions configures transaction behavior and isolation levels. +type PGXTxOptions pgx.TxOptions -// Execute is a function that can be used for executing a handler with a session builder. This function injects the -// builder of the driver into the handler. -func Execute[RESULT any](session octobe.Session[Builder], f Handler[RESULT]) (RESULT, error) { - return f(session.Builder()) +type pgxConfig struct { + txOptions *PGXTxOptions } -// Segment is a specific query that can be run only once it keeps a few fields for keeping track on the Segment -type Segment struct { - // query in SQL that is going to be executed - query string - // args include argument values - args []any - // used specify if this Segment already has been executed - used bool - // tx is the database transaction, initiated by BeginTx - tx pgx.Tx - // ctx is a context that can be used to interrupt a query - ctx context.Context -} - -// use will set used to true after a Segment has been performed -func (s *Segment) use() { - s.used = true -} - -// Arguments receives unknown amount of arguments to use in the query -func (s *Segment) Arguments(args ...interface{}) *Segment { - s.args = args - return s -} - -// Exec will execute a query. Used for inserts or updates -func (s *Segment) Exec() (pgconn.CommandTag, error) { - if s.used { - return pgconn.CommandTag{}, octobe.ErrAlreadyUsed - } - defer s.use() - return s.tx.Exec(s.ctx, s.query, s.args...) -} - -// QueryRow will return one result and put them into destination pointers -func (s *Segment) QueryRow(dest ...interface{}) error { - if s.used { - return octobe.ErrAlreadyUsed +// WithPGXTxOptions configures transaction options for the session. +func WithPGXTxOptions(options PGXTxOptions) octobe.Option[pgxConfig] { + return func(c *pgxConfig) { + c.txOptions = &options } - defer s.use() - return s.tx.QueryRow(s.ctx, s.query, s.args...).Scan(dest...) } -// Query will perform a normal query against database that returns rows -func (s *Segment) Query(cb func(pgx.Rows) error) error { - if s.used { - return octobe.ErrAlreadyUsed - } - defer s.use() - - rows, err := s.tx.Query(s.ctx, s.query, s.args...) - if err != nil { - return err - } - - defer rows.Close() - if err = cb(rows); err != nil { - return err - } - - return nil -} +// Segment represents a prepared query with arguments that can be executed once. +// Once executed, the segment becomes invalid and cannot be reused. +// +// The single-use nature prevents accidental query reuse and ensures predictable behavior. +// To execute the same query multiple times, create new segments each time. +// +// Method chaining example: +// +// result, err := builder(`INSERT INTO users (name) VALUES ($1) RETURNING id`) +// .Arguments("Alice") +// .QueryRow(&userID) +// +// Multiple operations example: +// +// // First query +// err := builder(`UPDATE users SET name = $1 WHERE id = $2`) +// .Arguments("Alice", 123) +// .QueryRow() +// +// // Second query (new segment required) +// err = builder(`DELETE FROM sessions WHERE user_id = $1`) +// .Arguments(123) +// .Exec() +type Segment interface { + Arguments(args ...any) Segment + Exec() (ExecResult, error) + QueryRow(dest ...any) error + Query(cb func(Rows) error) error +} + +// ExecResult contains the outcome of an INSERT, UPDATE, or DELETE operation. +type ExecResult struct { + RowsAffected int64 +} + +// Rows provides iteration over query result sets with pgx/database compatibility. +// Callers must check Err() after Next() returns false to detect premature termination. +type Rows interface { + // Err returns any error encountered during iteration. + // Only call after rows are closed or Next() returns false. + Err() error + + // Next advances to the next row, returning false when no more rows exist. + // Automatically closes rows when iteration completes. + Next() bool + + // Scan copies column values from the current row into dest variables. + // Must call Next() and verify it returned true before calling Scan. + Scan(dest ...any) error +} + +var _ Rows = (pgx.Rows)(nil) diff --git a/driver/postgres/postgres_test.go b/driver/postgres/postgres_test.go deleted file mode 100644 index df40cf1..0000000 --- a/driver/postgres/postgres_test.go +++ /dev/null @@ -1,107 +0,0 @@ -package postgres_test - -import ( - "context" - "github.com/Kansuler/octobe/v2" - "github.com/Kansuler/octobe/v2/driver/postgres" - "github.com/google/uuid" - "github.com/stretchr/testify/assert" - "os" - "testing" -) - -func TestPostgres(t *testing.T) { - ctx := context.Background() - dsn := os.Getenv("DSN") - if dsn == "" { - panic("DSN is not set") - } - - ob, err := octobe.New(postgres.Open(ctx, dsn, postgres.WithTransaction(postgres.TxOptions{}))) - if !assert.NoError(t, err) { - t.FailNow() - } - - session, err := ob.Begin(context.Background()) - if !assert.NoError(t, err) { - t.FailNow() - } - - defer session.WatchRollback(func() error { - return err - }) - - _, err = postgres.Execute(session, Migration()) - if !assert.NoError(t, err) { - t.FailNow() - } - - name := uuid.New().String() - product1, err := postgres.Execute(session, AddProduct(name)) - if !assert.NoError(t, err) { - t.FailNow() - } - - assert.Equal(t, name, product1.Name) - assert.NotZero(t, product1.ID) - - product2, err := postgres.Execute(session, ProductByName(name)) - if !assert.NoError(t, err) { - t.FailNow() - } - - assert.Equal(t, name, product2.Name) - assert.NotZero(t, product2.ID) - - err = session.Commit() - if !assert.NoError(t, err) { - t.FailNow() - } - - err = ob.Close(ctx) - assert.NoError(t, err) -} - -func Migration() postgres.Handler[octobe.Void] { - return func(builder postgres.Builder) (octobe.Void, error) { - query := builder(` - CREATE TABLE IF NOT EXISTS products ( - id SERIAL PRIMARY KEY, - name TEXT NOT NULL - ); - `) - _, err := query.Exec() - return nil, err - } -} - -type Product struct { - ID int - Name string -} - -func AddProduct(name string) postgres.Handler[Product] { - return func(builder postgres.Builder) (Product, error) { - var product Product - query := builder(` - INSERT INTO products (name) VALUES ($1) RETURNING id, name; - `) - - query.Arguments(name) - err := query.QueryRow(&product.ID, &product.Name) - return product, err - } -} - -func ProductByName(name string) postgres.Handler[Product] { - return func(builder postgres.Builder) (Product, error) { - var product Product - query := builder(` - SELECT id, name FROM products WHERE name = $1; - `) - - query.Arguments(name) - err := query.QueryRow(&product.ID, &product.Name) - return product, err - } -} diff --git a/example/go.mod b/example/go.mod deleted file mode 100644 index 5e11e95..0000000 --- a/example/go.mod +++ /dev/null @@ -1,8 +0,0 @@ -module github.com/Kansuler/octobe/example - -go 1.21.5 - -require ( - github.com/Kansuler/octobe/v2 v2.0.0-20231222135250-80258178b429 // indirect - github.com/go-chi/chi/v5 v5.0.11 // indirect -) diff --git a/example/go.sum b/example/go.sum deleted file mode 100644 index 48f8579..0000000 --- a/example/go.sum +++ /dev/null @@ -1,6 +0,0 @@ -github.com/Kansuler/octobe/v2 v2.0.0-20231222123441-5a4840494eaf h1:CLY87hdUnWdSttmalq8rPmLx0V6p5ZVGsy/nczx70Hg= -github.com/Kansuler/octobe/v2 v2.0.0-20231222123441-5a4840494eaf/go.mod h1:eJtv4nmpMFBDun61Tmk/cYsCgCIqpRhbuCCzGL0P3Ig= -github.com/Kansuler/octobe/v2 v2.0.0-20231222135250-80258178b429 h1:Ib6vxuOKU98tHCBWN8GNp8+nFAkT9v61ogGsew3cu2Y= -github.com/Kansuler/octobe/v2 v2.0.0-20231222135250-80258178b429/go.mod h1:Jjj4AriFqMIDTlLZc/tSNu7mhXV5I0jEC/YT11WkWNc= -github.com/go-chi/chi/v5 v5.0.11 h1:BnpYbFZ3T3S1WMpD79r7R5ThWX40TaFB7L31Y8xqSwA= -github.com/go-chi/chi/v5 v5.0.11/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= diff --git a/example/main.go b/example/main.go deleted file mode 100644 index 18b78f4..0000000 --- a/example/main.go +++ /dev/null @@ -1,110 +0,0 @@ -package main - -import ( - "context" - "github.com/Kansuler/octobe/example/query" - "github.com/Kansuler/octobe/v2" - "github.com/Kansuler/octobe/v2/driver/postgres" - "github.com/go-chi/chi/v5" - "net/http" - "os" -) - -type Container struct { - Postgres postgres.Driver -} - -func main() { - ctx := context.Background() - cnt := Container{} - var err error - dsn := os.Getenv("DSN") - if dsn == "" { - panic("DSN is not set") - } - - cnt.Postgres, err = octobe.New(postgres.Open(ctx, dsn)) - if err != nil { - panic(err) - } - - session, err := cnt.Postgres.Begin(ctx) - if err != nil { - panic(err) - } - - _, err = postgres.Execute(session, query.Migration()) - if err != nil { - panic(err) - } - - err = router(cnt) - if err != nil { - panic(err) - } -} - -func router(cnt Container) error { - r := chi.NewRouter() - - // Postgres - r.Get("/postgres/product/{name}", func(w http.ResponseWriter, r *http.Request) { - name := chi.URLParam(r, "name") - session, err := cnt.Postgres.Begin(r.Context()) - if err != nil { - w.WriteHeader(http.StatusInternalServerError) - w.Write([]byte(err.Error())) - return - } - - product, err := postgres.Execute(session, query.ProductByName(name)) - if err != nil { - w.WriteHeader(http.StatusInternalServerError) - w.Write([]byte(err.Error())) - return - } - - _, err = w.Write([]byte(product.Name)) - if err != nil { - w.WriteHeader(http.StatusInternalServerError) - w.Write([]byte(err.Error())) - return - } - }) - r.Post("/postgres/product/{name}", func(w http.ResponseWriter, r *http.Request) { - name := chi.URLParam(r, "name") - session, err := cnt.Postgres.Begin(r.Context(), postgres.WithTransaction(postgres.TxOptions{})) - if err != nil { - w.WriteHeader(http.StatusInternalServerError) - w.Write([]byte(err.Error())) - return - } - - defer session.WatchRollback(func() error { - return err - }) - - product, err := postgres.Execute(session, query.AddProduct(name)) - if err != nil { - w.WriteHeader(http.StatusInternalServerError) - w.Write([]byte(err.Error())) - return - } - - err = session.Commit() - if err != nil { - w.WriteHeader(http.StatusInternalServerError) - w.Write([]byte(err.Error())) - return - } - - _, err = w.Write([]byte(product.Name)) - if err != nil { - w.WriteHeader(http.StatusInternalServerError) - w.Write([]byte(err.Error())) - return - } - }) - - return http.ListenAndServe(":8080", r) -} diff --git a/example/query/postgres.go b/example/query/postgres.go deleted file mode 100644 index 5afe61b..0000000 --- a/example/query/postgres.go +++ /dev/null @@ -1,50 +0,0 @@ -package query - -import ( - "github.com/Kansuler/octobe/v2" - "github.com/Kansuler/octobe/v2/driver/postgres" -) - -func Migration() postgres.Handler[octobe.Void] { - return func(builder postgres.Builder) (octobe.Void, error) { - query := builder(` - CREATE TABLE IF NOT EXISTS products ( - id SERIAL PRIMARY KEY, - name TEXT NOT NULL - ); - `) - _, err := query.Exec() - return nil, err - } -} - -type Product struct { - ID int - Name string -} - -func AddProduct(name string) postgres.Handler[Product] { - return func(builder postgres.Builder) (Product, error) { - var product Product - query := builder(` - INSERT INTO products (name) VALUES ($1) RETURNING id, name; - `) - - query.Arguments(name) - err := query.QueryRow(&product.ID, &product.Name) - return product, err - } -} - -func ProductByName(name string) postgres.Handler[Product] { - return func(builder postgres.Builder) (Product, error) { - var product Product - query := builder(` - SELECT id, name FROM products WHERE name = $1; - `) - - query.Arguments(name) - err := query.QueryRow(&product.ID, &product.Name) - return product, err - } -} diff --git a/examples/blog/main.go b/examples/blog/main.go new file mode 100644 index 0000000..4028fb7 --- /dev/null +++ b/examples/blog/main.go @@ -0,0 +1,535 @@ +// Package main demonstrates a comprehensive blog application using Octobe +// for database operations. This example shows real-world usage patterns +// including CRUD operations, transactions, and complex queries. +package main + +import ( + "context" + "fmt" + "log" + "os" + "time" + + "github.com/Kansuler/octobe/v3" + "github.com/Kansuler/octobe/v3/driver/postgres" +) + +// Domain models +type User struct { + ID int `json:"id"` + Username string `json:"username"` + Email string `json:"email"` + CreatedAt time.Time `json:"created_at"` +} + +type Post struct { + ID int `json:"id"` + Title string `json:"title"` + Content string `json:"content"` + AuthorID int `json:"author_id"` + Author *User `json:"author,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type Comment struct { + ID int `json:"id"` + PostID int `json:"post_id"` + AuthorID int `json:"author_id"` + Content string `json:"content"` + CreatedAt time.Time `json:"created_at"` +} + +type Tag struct { + ID int `json:"id"` + Name string `json:"name"` +} + +// Database schema creation +func CreateSchema() octobe.Handler[octobe.Void, postgres.Builder] { + return func(builder postgres.Builder) (octobe.Void, error) { + schema := ` + CREATE TABLE IF NOT EXISTS users ( + id SERIAL PRIMARY KEY, + username VARCHAR(50) UNIQUE NOT NULL, + email VARCHAR(100) UNIQUE NOT NULL, + created_at TIMESTAMP DEFAULT NOW() + ); + + CREATE TABLE IF NOT EXISTS posts ( + id SERIAL PRIMARY KEY, + title VARCHAR(200) NOT NULL, + content TEXT NOT NULL, + author_id INTEGER REFERENCES users(id) ON DELETE CASCADE, + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW() + ); + + CREATE TABLE IF NOT EXISTS comments ( + id SERIAL PRIMARY KEY, + post_id INTEGER REFERENCES posts(id) ON DELETE CASCADE, + author_id INTEGER REFERENCES users(id) ON DELETE CASCADE, + content TEXT NOT NULL, + created_at TIMESTAMP DEFAULT NOW() + ); + + CREATE TABLE IF NOT EXISTS tags ( + id SERIAL PRIMARY KEY, + name VARCHAR(50) UNIQUE NOT NULL + ); + + CREATE TABLE IF NOT EXISTS post_tags ( + post_id INTEGER REFERENCES posts(id) ON DELETE CASCADE, + tag_id INTEGER REFERENCES tags(id) ON DELETE CASCADE, + PRIMARY KEY (post_id, tag_id) + );` + + query := builder(schema) + _, err := query.Exec() + return nil, err + } +} + +// User operations +func CreateUser(username, email string) octobe.Handler[User, postgres.Builder] { + return func(builder postgres.Builder) (User, error) { + var user User + query := builder(` + INSERT INTO users (username, email) + VALUES ($1, $2) + RETURNING id, username, email, created_at`) + + err := query.Arguments(username, email).QueryRow( + &user.ID, &user.Username, &user.Email, &user.CreatedAt) + return user, err + } +} + +func GetUserByID(id int) octobe.Handler[User, postgres.Builder] { + return func(builder postgres.Builder) (User, error) { + var user User + query := builder(` + SELECT id, username, email, created_at + FROM users + WHERE id = $1`) + + err := query.Arguments(id).QueryRow( + &user.ID, &user.Username, &user.Email, &user.CreatedAt) + return user, err + } +} + +func GetUserByUsername(username string) octobe.Handler[User, postgres.Builder] { + return func(builder postgres.Builder) (User, error) { + var user User + query := builder(` + SELECT id, username, email, created_at + FROM users + WHERE username = $1`) + + err := query.Arguments(username).QueryRow( + &user.ID, &user.Username, &user.Email, &user.CreatedAt) + return user, err + } +} + +// Post operations +func CreatePost(title, content string, authorID int) octobe.Handler[Post, postgres.Builder] { + return func(builder postgres.Builder) (Post, error) { + var post Post + query := builder(` + INSERT INTO posts (title, content, author_id) + VALUES ($1, $2, $3) + RETURNING id, title, content, author_id, created_at, updated_at`) + + err := query.Arguments(title, content, authorID).QueryRow( + &post.ID, &post.Title, &post.Content, &post.AuthorID, + &post.CreatedAt, &post.UpdatedAt) + return post, err + } +} + +func GetPostWithAuthor(postID int) octobe.Handler[Post, postgres.Builder] { + return func(builder postgres.Builder) (Post, error) { + var post Post + var author User + + query := builder(` + SELECT + p.id, p.title, p.content, p.author_id, p.created_at, p.updated_at, + u.id, u.username, u.email, u.created_at + FROM posts p + JOIN users u ON p.author_id = u.id + WHERE p.id = $1`) + + err := query.Arguments(postID).QueryRow( + &post.ID, &post.Title, &post.Content, &post.AuthorID, + &post.CreatedAt, &post.UpdatedAt, + &author.ID, &author.Username, &author.Email, &author.CreatedAt) + + if err == nil { + post.Author = &author + } + return post, err + } +} + +func GetPostsByAuthor(authorID int) octobe.Handler[[]Post, postgres.Builder] { + return func(builder postgres.Builder) ([]Post, error) { + query := builder(` + SELECT id, title, content, author_id, created_at, updated_at + FROM posts + WHERE author_id = $1 + ORDER BY created_at DESC`) + + var posts []Post + err := query.Arguments(authorID).Query(func(rows postgres.Rows) error { + for rows.Next() { + var post Post + if err := rows.Scan(&post.ID, &post.Title, &post.Content, + &post.AuthorID, &post.CreatedAt, &post.UpdatedAt); err != nil { + return err + } + posts = append(posts, post) + } + return rows.Err() + }) + + return posts, err + } +} + +func UpdatePost(postID int, title, content string) octobe.Handler[Post, postgres.Builder] { + return func(builder postgres.Builder) (Post, error) { + var post Post + query := builder(` + UPDATE posts + SET title = $1, content = $2, updated_at = NOW() + WHERE id = $3 + RETURNING id, title, content, author_id, created_at, updated_at`) + + err := query.Arguments(title, content, postID).QueryRow( + &post.ID, &post.Title, &post.Content, &post.AuthorID, + &post.CreatedAt, &post.UpdatedAt) + return post, err + } +} + +func DeletePost(postID int) octobe.Handler[octobe.Void, postgres.Builder] { + return func(builder postgres.Builder) (octobe.Void, error) { + query := builder(`DELETE FROM posts WHERE id = $1`) + _, err := query.Arguments(postID).Exec() + return nil, err + } +} + +// Comment operations +func CreateComment(postID, authorID int, content string) octobe.Handler[Comment, postgres.Builder] { + return func(builder postgres.Builder) (Comment, error) { + var comment Comment + query := builder(` + INSERT INTO comments (post_id, author_id, content) + VALUES ($1, $2, $3) + RETURNING id, post_id, author_id, content, created_at`) + + err := query.Arguments(postID, authorID, content).QueryRow( + &comment.ID, &comment.PostID, &comment.AuthorID, + &comment.Content, &comment.CreatedAt) + return comment, err + } +} + +func GetCommentsByPost(postID int) octobe.Handler[[]Comment, postgres.Builder] { + return func(builder postgres.Builder) ([]Comment, error) { + query := builder(` + SELECT id, post_id, author_id, content, created_at + FROM comments + WHERE post_id = $1 + ORDER BY created_at ASC`) + + var comments []Comment + err := query.Arguments(postID).Query(func(rows postgres.Rows) error { + for rows.Next() { + var comment Comment + if err := rows.Scan(&comment.ID, &comment.PostID, &comment.AuthorID, + &comment.Content, &comment.CreatedAt); err != nil { + return err + } + comments = append(comments, comment) + } + return rows.Err() + }) + + return comments, err + } +} + +// Tag operations +func CreateTag(name string) octobe.Handler[Tag, postgres.Builder] { + return func(builder postgres.Builder) (Tag, error) { + var tag Tag + query := builder(` + INSERT INTO tags (name) VALUES ($1) + ON CONFLICT (name) DO UPDATE SET name = EXCLUDED.name + RETURNING id, name`) + + err := query.Arguments(name).QueryRow(&tag.ID, &tag.Name) + return tag, err + } +} + +func AddTagToPost(postID, tagID int) octobe.Handler[octobe.Void, postgres.Builder] { + return func(builder postgres.Builder) (octobe.Void, error) { + query := builder(` + INSERT INTO post_tags (post_id, tag_id) + VALUES ($1, $2) + ON CONFLICT (post_id, tag_id) DO NOTHING`) + + _, err := query.Arguments(postID, tagID).Exec() + return nil, err + } +} + +// Complex operations that demonstrate transaction usage +func CreatePostWithTags(title, content string, authorID int, tagNames []string) octobe.Handler[Post, postgres.Builder] { + return func(builder postgres.Builder) (Post, error) { + // This handler demonstrates multiple related operations + // that should succeed or fail together + + // 1. Create the post + var post Post + query := builder(` + INSERT INTO posts (title, content, author_id) + VALUES ($1, $2, $3) + RETURNING id, title, content, author_id, created_at, updated_at`) + + err := query.Arguments(title, content, authorID).QueryRow( + &post.ID, &post.Title, &post.Content, &post.AuthorID, + &post.CreatedAt, &post.UpdatedAt) + if err != nil { + return post, fmt.Errorf("failed to create post: %w", err) + } + + // 2. Create tags and associate them with the post + for _, tagName := range tagNames { + // Create or get existing tag + var tagID int + tagQuery := builder(` + INSERT INTO tags (name) VALUES ($1) + ON CONFLICT (name) DO UPDATE SET name = EXCLUDED.name + RETURNING id`) + + err = tagQuery.Arguments(tagName).QueryRow(&tagID) + if err != nil { + return post, fmt.Errorf("failed to create tag %s: %w", tagName, err) + } + + // Link tag to post + linkQuery := builder(` + INSERT INTO post_tags (post_id, tag_id) + VALUES ($1, $2) + ON CONFLICT (post_id, tag_id) DO NOTHING`) + + _, err = linkQuery.Arguments(post.ID, tagID).Exec() + if err != nil { + return post, fmt.Errorf("failed to link tag %s to post: %w", tagName, err) + } + } + + return post, nil + } +} + +// Application service layer - demonstrates transaction usage +type BlogService struct { + db postgres.PGXDriver +} + +func NewBlogService(db postgres.PGXDriver) *BlogService { + return &BlogService{db: db} +} + +func (s *BlogService) CreateUserAndWelcomePost(ctx context.Context, username, email string) (*User, *Post, error) { + var user User + var post Post + + err := s.db.StartTransaction(ctx, func(session octobe.BuilderSession[postgres.Builder]) error { + var err error + + // Create user + user, err = octobe.Execute(session, CreateUser(username, email)) + if err != nil { + return fmt.Errorf("failed to create user: %w", err) + } + + // Create welcome post + welcomeTitle := fmt.Sprintf("Welcome %s!", username) + welcomeContent := fmt.Sprintf("Hello %s! Welcome to our blog platform. This is your first post!", username) + + post, err = octobe.Execute(session, CreatePost(welcomeTitle, welcomeContent, user.ID)) + if err != nil { + return fmt.Errorf("failed to create welcome post: %w", err) + } + + return nil + }) + if err != nil { + return nil, nil, err + } + + return &user, &post, nil +} + +func (s *BlogService) GetPostWithComments(ctx context.Context, postID int) (*Post, []Comment, error) { + var post Post + var comments []Comment + + err := s.db.StartTransaction(ctx, func(session octobe.BuilderSession[postgres.Builder]) error { + var err error + + post, err = octobe.Execute(session, GetPostWithAuthor(postID)) + if err != nil { + return fmt.Errorf("failed to get post: %w", err) + } + + comments, err = octobe.Execute(session, GetCommentsByPost(postID)) + if err != nil { + return fmt.Errorf("failed to get comments: %w", err) + } + + return nil + }) + if err != nil { + return nil, nil, err + } + + return &post, comments, nil +} + +func main() { + // Get database URL from environment + dsn := os.Getenv("DSN") + if dsn == "" { + dsn = "postgresql://user:password@localhost:5432/blogdb?sslmode=disable" + log.Printf("Using default DSN: %s", dsn) + log.Println("Set DATABASE_URL environment variable to use a different database") + } + + ctx := context.Background() + + // Initialize database + db, err := octobe.New(postgres.OpenPGXPool(ctx, dsn)) + if err != nil { + log.Fatalf("Failed to connect to database: %v", err) + } + defer db.Close(ctx) + + // Test connection + if err := db.Ping(ctx); err != nil { + log.Fatalf("Failed to ping database: %v", err) + } + log.Println("Connected to database successfully") + + // Create schema + err = db.StartTransaction(ctx, func(session octobe.BuilderSession[postgres.Builder]) error { + return octobe.ExecuteVoid(session, CreateSchema()) + }) + if err != nil { + log.Fatalf("Failed to create schema: %v", err) + } + log.Println("Database schema created") + + // Create service + service := NewBlogService(db) + + // Demo: Create user and welcome post + user, welcomePost, err := service.CreateUserAndWelcomePost(ctx, "alice", "alice@example.com") + if err != nil { + log.Fatalf("Failed to create user and welcome post: %v", err) + } + + fmt.Printf("Created user: %s (ID: %d)\n", user.Username, user.ID) + fmt.Printf("Created welcome post: %s (ID: %d)\n", welcomePost.Title, welcomePost.ID) + + // Demo: Create another user + var bob User + err = db.StartTransaction(ctx, func(session octobe.BuilderSession[postgres.Builder]) error { + bob, err = octobe.Execute(session, CreateUser("bob", "bob@example.com")) + return err + }) + if err != nil { + log.Fatalf("Failed to create user bob: %v", err) + } + fmt.Printf("Created user: %s (ID: %d)\n", bob.Username, bob.ID) + + // Demo: Create a blog post with tags + err = db.StartTransaction(ctx, func(session octobe.BuilderSession[postgres.Builder]) error { + post, err := octobe.Execute(session, CreatePostWithTags( + "Getting Started with Go", + "Go is a fantastic programming language for backend development...", + bob.ID, + []string{"go", "programming", "tutorial"})) + if err != nil { + return err + } + + fmt.Printf("Created post with tags: %s (ID: %d)\n", post.Title, post.ID) + return nil + }) + if err != nil { + log.Fatalf("Failed to create post with tags: %v", err) + } + + // Demo: Add comments + err = db.StartTransaction(ctx, func(session octobe.BuilderSession[postgres.Builder]) error { + comment, err := octobe.Execute(session, CreateComment(welcomePost.ID, bob.ID, "Welcome to the platform, Alice!")) + if err != nil { + return err + } + + fmt.Printf("Created comment: %s (ID: %d)\n", comment.Content, comment.ID) + return nil + }) + if err != nil { + log.Fatalf("Failed to create comment: %v", err) + } + + // Demo: Get post with comments + post, comments, err := service.GetPostWithComments(ctx, welcomePost.ID) + if err != nil { + log.Fatalf("Failed to get post with comments: %v", err) + } + + fmt.Printf("\n=== Post Details ===\n") + fmt.Printf("Title: %s\n", post.Title) + fmt.Printf("Author: %s\n", post.Author.Username) + fmt.Printf("Created: %s\n", post.CreatedAt.Format(time.RFC3339)) + fmt.Printf("Content: %s\n", post.Content) + + fmt.Printf("\n=== Comments ===\n") + for _, comment := range comments { + fmt.Printf("Comment ID %d: %s\n", comment.ID, comment.Content) + } + + // Demo: Get all posts by alice + err = db.StartTransaction(ctx, func(session octobe.BuilderSession[postgres.Builder]) error { + posts, err := octobe.Execute(session, GetPostsByAuthor(user.ID)) + if err != nil { + return err + } + + fmt.Printf("\n=== Posts by %s ===\n", user.Username) + for _, p := range posts { + fmt.Printf("- %s (created: %s)\n", p.Title, p.CreatedAt.Format("2006-01-02 15:04:05")) + } + return nil + }) + if err != nil { + log.Fatalf("Failed to get posts by author: %v", err) + } + + fmt.Println("\nBlog demo completed successfully!") +} + +// Note: This example uses interface{} for simplicity in type parameters. +// In real applications, you would import and use the specific driver types. diff --git a/examples/simple/main.go b/examples/simple/main.go new file mode 100644 index 0000000..490d58b --- /dev/null +++ b/examples/simple/main.go @@ -0,0 +1,222 @@ +// Package main demonstrates basic Octobe usage with simple CRUD operations. +// This example shows the fundamental patterns for database operations using Octobe. +package main + +import ( + "context" + "fmt" + "log" + "os" + + "github.com/Kansuler/octobe/v3" + "github.com/Kansuler/octobe/v3/driver/postgres" +) + +// Simple data model +type User struct { + ID int `json:"id"` + Name string `json:"name"` + Email string `json:"email"` +} + +// Create table handler +func CreateUsersTable() octobe.Handler[octobe.Void, postgres.Builder] { + return func(builder postgres.Builder) (octobe.Void, error) { + query := builder(` + CREATE TABLE IF NOT EXISTS users ( + id SERIAL PRIMARY KEY, + name VARCHAR(100) NOT NULL, + email VARCHAR(100) UNIQUE NOT NULL + )`) + _, err := query.Exec() + return nil, err + } +} + +// Create user handler +func CreateUser(name, email string) octobe.Handler[User, postgres.Builder] { + return func(builder postgres.Builder) (User, error) { + var user User + query := builder(` + INSERT INTO users (name, email) + VALUES ($1, $2) + RETURNING id, name, email`) + err := query.Arguments(name, email).QueryRow(&user.ID, &user.Name, &user.Email) + return user, err + } +} + +// Get user by ID handler +func GetUser(id int) octobe.Handler[User, postgres.Builder] { + return func(builder postgres.Builder) (User, error) { + var user User + query := builder(` + SELECT id, name, email + FROM users + WHERE id = $1`) + err := query.Arguments(id).QueryRow(&user.ID, &user.Name, &user.Email) + return user, err + } +} + +// Update user handler +func UpdateUser(id int, name, email string) octobe.Handler[User, postgres.Builder] { + return func(builder postgres.Builder) (User, error) { + var user User + query := builder(` + UPDATE users + SET name = $1, email = $2 + WHERE id = $3 + RETURNING id, name, email`) + err := query.Arguments(name, email, id).QueryRow(&user.ID, &user.Name, &user.Email) + return user, err + } +} + +// Delete user handler +func DeleteUser(id int) octobe.Handler[octobe.Void, postgres.Builder] { + return func(builder postgres.Builder) (octobe.Void, error) { + query := builder(`DELETE FROM users WHERE id = $1`) + _, err := query.Arguments(id).Exec() + return nil, err + } +} + +// List all users handler +func ListUsers() octobe.Handler[[]User, postgres.Builder] { + return func(builder postgres.Builder) ([]User, error) { + query := builder(`SELECT id, name, email FROM users ORDER BY id`) + + var users []User + err := query.Query(func(rows postgres.Rows) error { + for rows.Next() { + var user User + if err := rows.Scan(&user.ID, &user.Name, &user.Email); err != nil { + return err + } + users = append(users, user) + } + return rows.Err() + }) + + return users, err + } +} + +func main() { + // Get database URL from environment or use default + dsn := os.Getenv("DSN") + if dsn == "" { + dsn = "postgresql://user:password@localhost:5432/testdb?sslmode=disable" + log.Printf("Using default DSN. Set DATABASE_URL environment variable to use different database.") + } + + ctx := context.Background() + + // Step 1: Initialize database connection + db, err := octobe.New(postgres.OpenPGXPool(ctx, dsn)) + if err != nil { + log.Fatalf("Failed to connect to database: %v", err) + } + defer db.Close(ctx) + + // Step 2: Test connection + if err := db.Ping(ctx); err != nil { + log.Fatalf("Failed to ping database: %v", err) + } + fmt.Println("✓ Connected to database") + + // Step 3: Create table (in a transaction) + err = db.StartTransaction(ctx, func(session octobe.BuilderSession[postgres.Builder]) error { + return octobe.ExecuteVoid(session, CreateUsersTable()) + }) + if err != nil { + log.Fatalf("Failed to create table: %v", err) + } + fmt.Println("✓ Created users table") + + // Step 4: Create a user + var alice User + err = db.StartTransaction(ctx, func(session octobe.BuilderSession[postgres.Builder]) error { + alice, err = octobe.Execute(session, CreateUser("Alice Smith", "alice@example.com")) + return err + }) + if err != nil { + log.Fatalf("Failed to create user: %v", err) + } + fmt.Printf("✓ Created user: %s (ID: %d)\n", alice.Name, alice.ID) + + // Step 5: Create another user + var bob User + err = db.StartTransaction(ctx, func(session octobe.BuilderSession[postgres.Builder]) error { + bob, err = octobe.Execute(session, CreateUser("Bob Jones", "bob@example.com")) + return err + }) + if err != nil { + log.Fatalf("Failed to create user: %v", err) + } + fmt.Printf("✓ Created user: %s (ID: %d)\n", bob.Name, bob.ID) + + // Step 6: Read user back + var retrievedUser User + err = db.StartTransaction(ctx, func(session octobe.BuilderSession[postgres.Builder]) error { + retrievedUser, err = octobe.Execute(session, GetUser(alice.ID)) + return err + }) + if err != nil { + log.Fatalf("Failed to get user: %v", err) + } + fmt.Printf("✓ Retrieved user: %s <%s>\n", retrievedUser.Name, retrievedUser.Email) + + // Step 7: Update user + var updatedUser User + err = db.StartTransaction(ctx, func(session octobe.BuilderSession[postgres.Builder]) error { + updatedUser, err = octobe.Execute(session, UpdateUser(alice.ID, "Alice Johnson", "alice.johnson@example.com")) + return err + }) + if err != nil { + log.Fatalf("Failed to update user: %v", err) + } + fmt.Printf("✓ Updated user: %s <%s>\n", updatedUser.Name, updatedUser.Email) + + // Step 8: List all users + var users []User + err = db.StartTransaction(ctx, func(session octobe.BuilderSession[postgres.Builder]) error { + users, err = octobe.Execute(session, ListUsers()) + return err + }) + if err != nil { + log.Fatalf("Failed to list users: %v", err) + } + fmt.Printf("✓ Found %d users:\n", len(users)) + for _, user := range users { + fmt.Printf(" - %s <%s> (ID: %d)\n", user.Name, user.Email, user.ID) + } + + // Step 9: Delete a user + err = db.StartTransaction(ctx, func(session octobe.BuilderSession[postgres.Builder]) error { + return octobe.ExecuteVoid(session, DeleteUser(bob.ID)) + }) + if err != nil { + log.Fatalf("Failed to delete user: %v", err) + } + fmt.Printf("✓ Deleted user with ID: %d\n", bob.ID) + + // Step 10: Verify deletion + err = db.StartTransaction(ctx, func(session octobe.BuilderSession[postgres.Builder]) error { + users, err = octobe.Execute(session, ListUsers()) + return err + }) + if err != nil { + log.Fatalf("Failed to list users after deletion: %v", err) + } + fmt.Printf("✓ Users remaining: %d\n", len(users)) + + fmt.Println("\n🎉 Simple example completed successfully!") + fmt.Println("\nKey concepts demonstrated:") + fmt.Println("• Handler pattern for encapsulating SQL operations") + fmt.Println("• Automatic transaction management with StartTransaction") + fmt.Println("• Type-safe query results") + fmt.Println("• CRUD operations (Create, Read, Update, Delete)") + fmt.Println("• Error handling with automatic rollback") +} diff --git a/go.mod b/go.mod index 6fcdfd8..78fbd37 100644 --- a/go.mod +++ b/go.mod @@ -1,22 +1,22 @@ -module github.com/Kansuler/octobe/v2 +module github.com/Kansuler/octobe/v3 -go 1.21.5 +go 1.23.0 require ( - github.com/google/uuid v1.5.0 - github.com/jackc/pgx/v5 v5.5.1 - github.com/stretchr/testify v1.8.4 + github.com/jackc/pgx/v5 v5.7.4 + github.com/stretchr/testify v1.9.0 ) require ( github.com/davecgh/go-spew v1.1.1 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect - github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/kr/text v0.2.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/rogpeppe/go-internal v1.12.0 // indirect - golang.org/x/crypto v0.16.0 // indirect - golang.org/x/sync v0.5.0 // indirect - golang.org/x/text v0.14.0 // indirect + golang.org/x/crypto v0.37.0 // indirect + golang.org/x/sync v0.13.0 // indirect + golang.org/x/text v0.24.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index ac5e992..992550c 100644 --- a/go.sum +++ b/go.sum @@ -2,16 +2,14 @@ github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ3 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/google/uuid v1.5.0 h1:1p67kYwdtXjb0gL0BPiP1Av9wiZPo5A8z2cWkTZ+eyU= -github.com/google/uuid v1.5.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= -github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk= -github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= -github.com/jackc/pgx/v5 v5.5.1 h1:5I9etrGkLrN+2XPCsi6XLlV5DITbSL/xBZdmAxFcXPI= -github.com/jackc/pgx/v5 v5.5.1/go.mod h1:Ig06C2Vu0t5qXC60W8sqIthScaEnFvojjj9dSljmHRA= -github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk= -github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.7.4 h1:9wKznZrhWa2QiHL+NjTSPP6yjl3451BX3imWDnokYlg= +github.com/jackc/pgx/v5 v5.7.4/go.mod h1:ncY89UGWxg82EykZUwSpUKEfccBGGYq1xjrOpsbsfGQ= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= @@ -23,14 +21,14 @@ github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99 github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= -github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= -golang.org/x/crypto v0.16.0 h1:mMMrFzRSCF0GvB7Ne27XVtVAaXLrPmgPC7/v0tkwHaY= -golang.org/x/crypto v0.16.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= -golang.org/x/sync v0.5.0 h1:60k92dhOjHxJkrqnwsfl8KuaHbn/5dl0lUPUklKo3qE= -golang.org/x/sync v0.5.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= -golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= -golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE= +golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc= +golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610= +golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= +golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= diff --git a/octobe.go b/octobe.go index c4dae9a..45f1589 100644 --- a/octobe.go +++ b/octobe.go @@ -1,68 +1,247 @@ +// Package octobe provides a database abstraction layer focused on automatic transaction management +// and raw SQL execution without ORM complexity. It supports multiple database drivers while +// maintaining type safety through Go generics. +// +// The core philosophy is to eliminate boilerplate transaction management code while preserving +// the power and flexibility of raw SQL queries. Octobe uses the Handler pattern to encapsulate +// database operations in testable, composable functions. +// +// Basic usage: +// +// db, err := octobe.New(postgres.OpenPGXPool(ctx, dsn)) +// if err != nil { +// log.Fatal(err) +// } +// +// err = db.StartTransaction(ctx, func(session octobe.BuilderSession[postgres.Builder]) error { +// user, err := postgres.Execute(session, CreateUser("Alice")) +// return err // Automatic rollback on error, commit on success +// }) package octobe import ( "context" "errors" + "fmt" ) -var ErrAlreadyUsed = errors.New("query already used") +var ErrAlreadyUsed = errors.New("segment has already been executed - segments can only be used once, create a new segment for additional queries") -// Option is a signature that can be used for passing options to a driver +// Option applies configuration to a driver config. Use this to customize +// transaction options, connection settings, or other driver-specific behavior. +// +// Example: +// +// db.Begin(ctx, postgres.WithPGXTxOptions(postgres.PGXTxOptions{ +// IsoLevel: pgx.ReadCommitted, +// })) type Option[CONFIG any] func(cfg *CONFIG) -// Driver is a signature that holds the specific driver in the Octobe context. +// Driver manages database connections and sessions with type-safe configuration. +// +// Generic type parameters: +// - DRIVER: The underlying database driver type (e.g., *sql.DB, *pgxpool.Pool) +// - CONFIG: Configuration struct for driver options (e.g., transaction settings) +// - BUILDER: Query builder type that constructs executable queries +// +// Implementations handle connection pooling, transaction lifecycle, and driver-specific +// optimizations while providing a consistent interface across database types. type Driver[DRIVER any, CONFIG any, BUILDER any] interface { + // Begin starts a new database session. If transaction options are provided, + // the session will be transactional and require Commit/Rollback. Begin(ctx context.Context, opts ...Option[CONFIG]) (Session[BUILDER], error) + + // Close releases all database connections and resources. Close(ctx context.Context) error -} -// Open is a signature that can be used for opening a driver, it should always return a driver with set signature of -// types for the local driver. -type Open[DRIVER any, CONFIG any, BUILDER any] func() (Driver[DRIVER, CONFIG, BUILDER], error) + // Ping verifies database connectivity. + Ping(ctx context.Context) error -// Octobe struct that holds the database session -type Octobe[DRIVER any, CONFIG any, BUILDER any] struct { - driver Driver[DRIVER, CONFIG, BUILDER] + // StartTransaction executes fn within a transaction, automatically handling commit/rollback. + StartTransaction(ctx context.Context, fn func(session BuilderSession[BUILDER]) error, opts ...Option[CONFIG]) (err error) } -// New creates a new Octobe instance. -func New[DRIVER any, CONFIG any, BUILDER any](init Open[DRIVER, CONFIG, BUILDER]) (*Octobe[DRIVER, CONFIG, BUILDER], error) { +// Open initializes and returns a configured driver instance. This function type +// encapsulates driver creation logic including connection string parsing, +// pool configuration, and initial connectivity validation. +// +// Example: +// +// opener := postgres.OpenPGXPool(ctx, "postgresql://user:pass@localhost/db") +// db, err := octobe.New(opener) +type Open[DRIVER any, CONFIG any, BUILDER any] func() (Driver[DRIVER, CONFIG, BUILDER], error) + +// New creates a new Octobe instance using the provided driver opener function. +// The opener is called immediately to initialize the underlying driver and +// establish database connectivity. +// +// This is typically the first function called when setting up database access: +// +// db, err := octobe.New(postgres.OpenPGXPool(ctx, dsn)) +// if err != nil { +// return fmt.Errorf("failed to initialize database: %w", err) +// } +// defer db.Close(ctx) +func New[DRIVER any, CONFIG any, BUILDER any](init Open[DRIVER, CONFIG, BUILDER]) (Driver[DRIVER, CONFIG, BUILDER], error) { driver, err := init() if err != nil { return nil, err } - return &Octobe[DRIVER, CONFIG, BUILDER]{ - driver: driver, - }, nil -} - -// Begin a new session of queries, this will return a Session instance that can be used for handling queries. Options can be -// passed to the driver for specific configuration that overwrites the default configuration given at instantiation of -// the Octobe instance. -func (ob *Octobe[DRIVER, CONFIG, BUILDER]) Begin(ctx context.Context, opts ...Option[CONFIG]) (Session[BUILDER], error) { - return ob.driver.Begin(ctx, opts...) + return driver, nil } -// Close the database connection. -func (ob *Octobe[DRIVER, CONFIG, BUILDER]) Close(ctx context.Context) error { - return ob.driver.Close(ctx) -} - -// Session is a signature that has a +// Session represents an active database session that may or may not be transactional. +// +// Transactional sessions (created with transaction options) maintain ACID properties +// and must call Commit() to persist changes or Rollback() to discard them. +// Non-transactional sessions execute queries immediately without transaction boundaries. +// +// Sessions embed BuilderSession to provide direct access to query construction methods. type Session[BUILDER any] interface { - // Commit will commit the transaction. + // Commit persists all changes made within the transaction. + // Only valid for transactional sessions. Commit() error - // Rollback will rollback the transaction. + // Rollback discards all changes made within the transaction. + // Only valid for transactional sessions. Rollback() error - // WatchRollback will watch for error within the function, if it's set WatchRollback will rollback the transaction. - WatchRollback(func() error) + BuilderSession[BUILDER] +} - // Builder returns a new builder from the driver that is used to build queries for that specific driver. +// BuilderSession provides access to the query builder for constructing database operations. +// This interface is embedded in Session and used directly by StartTransaction for +// automatic transaction management. +// +// The Builder creates Segment instances that represent prepared queries with arguments. +type BuilderSession[BUILDER any] interface { + // Builder returns a query builder function for this session. + // Each call to Builder() creates segments that are scoped to this session's + // transaction (if transactional) or connection (if non-transactional). Builder() BUILDER } -// Void is a type that can be used for returning nothing from a handler. +// Void represents an empty return type for handlers that perform actions without returning data. +// Use this for operations like INSERT, UPDATE, DELETE that only need to report success/failure. +// +// Example: +// +// func DeleteUser(id int) postgres.Handler[octobe.Void] { +// return func(builder postgres.Builder) (octobe.Void, error) { +// query := builder(`DELETE FROM users WHERE id = $1`) +// _, err := query.Arguments(id).Exec() +// return nil, err +// } +// } type Void *struct{} + +// StartTransaction executes fn within a database transaction, automatically handling commit/rollback. +// +// This is the recommended way to perform database operations as it: +// - Automatically begins a transaction +// - Calls fn with a transactional session +// - Commits on successful completion +// - Rolls back on any error or panic +// - Ensures proper cleanup in all cases +// +// The function parameter receives a BuilderSession that can be used to execute +// multiple related database operations within the same transaction. +// +// Example: +// +// err := db.StartTransaction(ctx, func(session octobe.BuilderSession[postgres.Builder]) error { +// user, err := postgres.Execute(session, CreateUser("Alice")) +// if err != nil { +// return err // Automatic rollback +// } +// +// _, err = postgres.Execute(session, CreateProfile(user.ID)) +// return err // Automatic commit if nil, rollback if error +// }) +func StartTransaction[DRIVER, CONFIG, BUILDER any](ctx context.Context, driver Driver[DRIVER, CONFIG, BUILDER], fn func(session BuilderSession[BUILDER]) error, opts ...Option[CONFIG]) (err error) { + session, err := driver.Begin(ctx, opts...) + if err != nil { + return err + } + + defer func() { + if p := recover(); p != nil { + _ = session.Rollback() + panic(p) + } else if err != nil { + _ = session.Rollback() + } + }() + + err = fn(session) + if err != nil { + return err + } + + err = session.Commit() + return err +} + +// Handler processes database operations and returns typed results. +// Handlers encapsulate SQL logic and can be easily tested by mocking the Builder. +// +// The Handler pattern provides several benefits: +// - Composable: handlers can be combined and reused +// - Testable: mock the builder to test SQL logic without a database +// - Type-safe: compile-time verification of return types +// - Transactional: automatic transaction management when used with StartTransaction +// +// Example: +// +// func GetUser(id int) Handler[User] { +// return func(builder Builder) (User, error) { +// var user User +// query := builder(`SELECT id, name, email FROM users WHERE id = $1`) +// err := query.Arguments(id).QueryRow(&user.ID, &user.Name, &user.Email) +// return user, err +// } +// } +type Handler[RESULT, BUILDER any] func(BUILDER) (RESULT, error) + +// Execute runs a handler function with the session's query builder. +func Execute[RESULT, BUILDER any](session BuilderSession[BUILDER], f Handler[RESULT, BUILDER]) (RESULT, error) { + return f(session.Builder()) +} + +// ExecuteVoid runs a void handler (one that returns octobe.Void) and returns only the error. +// This provides cleaner syntax for operations that don't return data. +// +// Example: +// +// err := postgres.ExecuteVoid(session, DeleteUser(123)) +// if err != nil { +// return fmt.Errorf("failed to delete user: %w", err) +// } +func ExecuteVoid[BUILDER any](session BuilderSession[BUILDER], f Handler[Void, BUILDER]) error { + _, err := f(session.Builder()) + return err +} + +// ExecuteMany runs multiple handlers in sequence within the same session. +// If any handler fails, execution stops and the error is returned. +// This is useful for running related operations that should succeed or fail together. +// +// Example: +// +// results, err := postgres.ExecuteMany(session, +// CreateUser("Alice"), +// CreateUser("Bob"), +// CreateUser("Charlie"), +// ) +func ExecuteMany[RESULT, BUILDER any](session BuilderSession[BUILDER], handlers ...Handler[RESULT, BUILDER]) ([]RESULT, error) { + results := make([]RESULT, 0, len(handlers)) + for i, handler := range handlers { + result, err := handler(session.Builder()) + if err != nil { + return nil, fmt.Errorf("handler %d failed: %w", i, err) + } + results = append(results, result) + } + return results, nil +}