diff --git a/README.md b/README.md index ff0d4a3..c03266e 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,51 @@ # sqlutil + [![Build Status](https://github.com/allisson/sqlutil/workflows/Release/badge.svg)](https://github.com/allisson/sqlutil/actions) [![Go Report Card](https://goreportcard.com/badge/github.com/allisson/sqlutil)](https://goreportcard.com/report/github.com/allisson/sqlutil) [![go.dev reference](https://img.shields.io/badge/go.dev-reference-007d9c?logo=go&logoColor=white&style=flat-square)](https://pkg.go.dev/github.com/allisson/sqlutil) -A collection of helpers to deal with database. +A powerful and flexible collection of Go helpers for database operations. sqlutil simplifies common database tasks with a clean, chainable API that works with PostgreSQL, MySQL, and SQLite. + +## Features + +- Clean, intuitive API for CRUD operations +- Support for multiple SQL flavors (PostgreSQL, MySQL, SQLite) +- Flexible filtering with comparison operators (=, <>, >, <, >=, <=, LIKE, IN, NOT IN) +- Pagination and ordering support +- Field selection to retrieve only needed columns +- Row locking support (FOR UPDATE) +- Works with `*sql.DB`, `*sql.Conn`, and `*sql.Tx` +- Built on top of [sqlquery](https://github.com/allisson/sqlquery) and [scany](https://github.com/georgysavva/scany) -Example: +## Installation + +```bash +go get github.com/allisson/sqlutil +``` -```golang +## Table of Contents + +- [Quick Start](#quick-start) +- [Database Setup](#database-setup) +- [Basic CRUD Operations](#basic-crud-operations) + - [Insert](#insert) + - [Get (Single Record)](#get-single-record) + - [Select (Multiple Records)](#select-multiple-records) + - [Update](#update) + - [Delete](#delete) +- [Advanced Queries](#advanced-queries) + - [Filtering](#filtering) + - [Field Selection](#field-selection) + - [Pagination](#pagination) + - [Ordering](#ordering) + - [Row Locking](#row-locking) +- [Bulk Operations](#bulk-operations) +- [Using with Transactions](#using-with-transactions) +- [Multiple SQL Flavors](#multiple-sql-flavors) + +## Quick Start + +```go package main import ( @@ -20,133 +58,827 @@ import ( _ "github.com/lib/pq" ) -type Player struct { - ID int `db:"id"` - Name string `db:"name" fieldtag:"insert,update"` - Age int `db:"age" fieldtag:"insert,update"` +type User struct { + ID int `db:"id"` + Name string `db:"name" fieldtag:"insert,update"` + Email string `db:"email" fieldtag:"insert,update"` + Age int `db:"age" fieldtag:"insert,update"` } func main() { - // Run a database with docker: docker run --name test --restart unless-stopped -e POSTGRES_USER=user -e POSTGRES_PASSWORD=password -e POSTGRES_DB=sqlutil -p 5432:5432 -d postgres:14-alpine // Connect to database - db, err := sql.Open("postgres", "postgres://user:password@localhost/sqlutil?sslmode=disable") + db, err := sql.Open("postgres", "postgres://user:password@localhost/mydb?sslmode=disable") if err != nil { log.Fatal(err) } - err = db.Ping() + defer db.Close() + + ctx := context.Background() + flavor := sqlutil.PostgreSQLFlavor + + // Insert a user + user := User{Name: "Alice", Email: "alice@example.com", Age: 30} + if err := sqlutil.Insert(ctx, db, flavor, "insert", "users", &user); err != nil { + log.Fatal(err) + } + + // Get a user + var alice User + opts := sqlutil.NewFindOptions(flavor).WithFilter("email", "alice@example.com") + if err := sqlutil.Get(ctx, db, "users", opts, &alice); err != nil { + log.Fatal(err) + } + fmt.Printf("Found user: %+v\n", alice) + + // Update the user + alice.Age = 31 + if err := sqlutil.Update(ctx, db, flavor, "update", "users", alice.ID, &alice); err != nil { + log.Fatal(err) + } + + // Delete the user + if err := sqlutil.Delete(ctx, db, flavor, "users", alice.ID); err != nil { + log.Fatal(err) + } +} +``` + +## Database Setup + +For the examples below, we'll use this table structure: + +```sql +CREATE TABLE users ( + id SERIAL PRIMARY KEY, + name VARCHAR(255) NOT NULL, + email VARCHAR(255) UNIQUE NOT NULL, + age INTEGER NOT NULL, + country VARCHAR(100), + active BOOLEAN DEFAULT true, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); +``` + +Run a PostgreSQL database with Docker: + +```bash +docker run --name sqlutil-postgres \ + -e POSTGRES_USER=user \ + -e POSTGRES_PASSWORD=password \ + -e POSTGRES_DB=sqlutil \ + -p 5432:5432 \ + -d postgres:14-alpine +``` + +## Basic CRUD Operations + +### Insert + +Insert a single record into the database: + +```go +package main + +import ( + "context" + "database/sql" + "log" + + "github.com/allisson/sqlutil" + _ "github.com/lib/pq" +) + +type User struct { + ID int `db:"id"` + Name string `db:"name" fieldtag:"insert,update"` + Email string `db:"email" fieldtag:"insert,update"` + Age int `db:"age" fieldtag:"insert,update"` + Country string `db:"country" fieldtag:"insert,update"` + Active bool `db:"active" fieldtag:"insert,update"` +} + +func main() { + db, _ := sql.Open("postgres", "postgres://user:password@localhost/sqlutil?sslmode=disable") + defer db.Close() + + ctx := context.Background() + flavor := sqlutil.PostgreSQLFlavor + + // Insert a new user + newUser := User{ + Name: "Bob Smith", + Email: "bob@example.com", + Age: 28, + Country: "USA", + Active: true, + } + + // The "insert" tag tells sqlutil to use fields marked with fieldtag:"insert" + if err := sqlutil.Insert(ctx, db, flavor, "insert", "users", &newUser); err != nil { + log.Fatal(err) + } + + log.Println("User inserted successfully") +} +``` + +### Get (Single Record) + +Retrieve a single record from the database: + +```go +// Get user by ID +var user User +opts := sqlutil.NewFindOptions(flavor).WithFilter("id", 1) +if err := sqlutil.Get(ctx, db, "users", opts, &user); err != nil { + log.Fatal(err) +} +fmt.Printf("User: %+v\n", user) + +// Get user by email +var userByEmail User +opts = sqlutil.NewFindOptions(flavor).WithFilter("email", "bob@example.com") +if err := sqlutil.Get(ctx, db, "users", opts, &userByEmail); err != nil { + log.Fatal(err) +} + +// Get user with specific fields only +var partialUser struct { + ID int `db:"id"` + Name string `db:"name"` + Email string `db:"email"` +} +opts = sqlutil.NewFindOptions(flavor). + WithFields([]string{"id", "name", "email"}). + WithFilter("id", 1) +if err := sqlutil.Get(ctx, db, "users", opts, &partialUser); err != nil { + log.Fatal(err) +} +``` + +### Select (Multiple Records) + +Retrieve multiple records from the database: + +```go +// Get all users +var users []User +opts := sqlutil.NewFindAllOptions(flavor) +if err := sqlutil.Select(ctx, db, "users", opts, &users); err != nil { + log.Fatal(err) +} +fmt.Printf("Found %d users\n", len(users)) + +// Get active users only +var activeUsers []User +opts = sqlutil.NewFindAllOptions(flavor).WithFilter("active", true) +if err := sqlutil.Select(ctx, db, "users", opts, &activeUsers); err != nil { + log.Fatal(err) +} + +// Get users with pagination +var pagedUsers []User +opts = sqlutil.NewFindAllOptions(flavor). + WithLimit(10). + WithOffset(0). + WithOrderBy("created_at DESC") +if err := sqlutil.Select(ctx, db, "users", opts, &pagedUsers); err != nil { + log.Fatal(err) +} +``` + +### Update + +Update existing records: + +```go +// Update user by ID +user := User{ + ID: 1, + Name: "Bob Smith Jr.", + Email: "bob.jr@example.com", + Age: 29, + Country: "USA", + Active: true, +} + +// The "update" tag tells sqlutil to use fields marked with fieldtag:"update" +if err := sqlutil.Update(ctx, db, flavor, "update", "users", user.ID, &user); err != nil { + log.Fatal(err) +} + +// Update with custom options (more flexible) +updateOpts := sqlutil.NewUpdateOptions(flavor). + WithSet("age", 30). + WithSet("country", "Canada"). + WithFilter("id", 1) + +if err := sqlutil.UpdateWithOptions(ctx, db, flavor, "users", updateOpts); err != nil { + log.Fatal(err) +} + +// Bulk update - update all inactive users +updateOpts = sqlutil.NewUpdateOptions(flavor). + WithSet("active", false). + WithFilter("age.lt", 18) + +if err := sqlutil.UpdateWithOptions(ctx, db, flavor, "users", updateOpts); err != nil { + log.Fatal(err) +} +``` + +### Delete + +Delete records from the database: + +```go +// Delete user by ID +if err := sqlutil.Delete(ctx, db, flavor, "users", 1); err != nil { + log.Fatal(err) +} + +// Delete with custom options (more flexible) +deleteOpts := sqlutil.NewDeleteOptions(flavor).WithFilter("active", false) +if err := sqlutil.DeleteWithOptions(ctx, db, flavor, "users", deleteOpts); err != nil { + log.Fatal(err) +} + +// Delete users older than 65 +deleteOpts = sqlutil.NewDeleteOptions(flavor).WithFilter("age.gt", 65) +if err := sqlutil.DeleteWithOptions(ctx, db, flavor, "users", deleteOpts); err != nil { + log.Fatal(err) +} + +// Delete users from specific country +deleteOpts = sqlutil.NewDeleteOptions(flavor).WithFilter("country", "USA") +if err := sqlutil.DeleteWithOptions(ctx, db, flavor, "users", deleteOpts); err != nil { + log.Fatal(err) +} +``` + +## Advanced Queries + +### Filtering + +sqlutil supports a wide range of filter operators: + +```go +ctx := context.Background() +flavor := sqlutil.PostgreSQLFlavor + +// Equality +opts := sqlutil.NewFindAllOptions(flavor).WithFilter("age", 30) +// WHERE age = 30 + +// Not equal +opts = sqlutil.NewFindAllOptions(flavor).WithFilter("age.not", 30) +// WHERE age <> 30 + +// Greater than +opts = sqlutil.NewFindAllOptions(flavor).WithFilter("age.gt", 18) +// WHERE age > 18 + +// Greater than or equal +opts = sqlutil.NewFindAllOptions(flavor).WithFilter("age.gte", 18) +// WHERE age >= 18 + +// Less than +opts = sqlutil.NewFindAllOptions(flavor).WithFilter("age.lt", 65) +// WHERE age < 65 + +// Less than or equal +opts = sqlutil.NewFindAllOptions(flavor).WithFilter("age.lte", 65) +// WHERE age <= 65 + +// LIKE operator +opts = sqlutil.NewFindAllOptions(flavor).WithFilter("name.like", "%Smith%") +// WHERE name LIKE '%Smith%' + +// IN operator +opts = sqlutil.NewFindAllOptions(flavor).WithFilter("country.in", "USA,Canada,Mexico") +// WHERE country IN ('USA', 'Canada', 'Mexico') + +// NOT IN operator +opts = sqlutil.NewFindAllOptions(flavor).WithFilter("country.notin", "USA,Canada") +// WHERE country NOT IN ('USA', 'Canada') + +// IS NULL +opts = sqlutil.NewFindAllOptions(flavor).WithFilter("country.null", true) +// WHERE country IS NULL + +// IS NOT NULL +opts = sqlutil.NewFindAllOptions(flavor).WithFilter("country.null", false) +// WHERE country IS NOT NULL + +// Multiple filters (AND condition) +opts = sqlutil.NewFindAllOptions(flavor). + WithFilter("age.gte", 18). + WithFilter("age.lte", 65). + WithFilter("active", true). + WithFilter("country", "USA") +// WHERE age >= 18 AND age <= 65 AND active = true AND country = 'USA' + +// Complex example: Find active users aged 25-40 from specific countries +var users []User +opts = sqlutil.NewFindAllOptions(flavor). + WithFilter("active", true). + WithFilter("age.gte", 25). + WithFilter("age.lte", 40). + WithFilter("country.in", "USA,Canada,UK") + +if err := sqlutil.Select(ctx, db, "users", opts, &users); err != nil { + log.Fatal(err) +} +``` + +### Field Selection + +Retrieve only the fields you need: + +```go +// Define a struct with only the fields you need +type UserBasic struct { + ID int `db:"id"` + Name string `db:"name"` + Email string `db:"email"` +} + +// Select only specific fields +var users []UserBasic +opts := sqlutil.NewFindAllOptions(flavor). + WithFields([]string{"id", "name", "email"}). + WithFilter("active", true) + +if err := sqlutil.Select(ctx, db, "users", opts, &users); err != nil { + log.Fatal(err) +} +// SELECT id, name, email FROM users WHERE active = true + +// Get single user with limited fields +var userBasic UserBasic +opts := sqlutil.NewFindOptions(flavor). + WithFields([]string{"id", "name", "email"}). + WithFilter("id", 1) + +if err := sqlutil.Get(ctx, db, "users", opts, &userBasic); err != nil { + log.Fatal(err) +} +``` + +### Pagination + +Implement pagination for large result sets: + +```go +// Page 1: First 10 users +var page1 []User +opts := sqlutil.NewFindAllOptions(flavor). + WithLimit(10). + WithOffset(0). + WithOrderBy("id ASC") + +if err := sqlutil.Select(ctx, db, "users", opts, &page1); err != nil { + log.Fatal(err) +} + +// Page 2: Next 10 users +var page2 []User +opts = sqlutil.NewFindAllOptions(flavor). + WithLimit(10). + WithOffset(10). + WithOrderBy("id ASC") + +if err := sqlutil.Select(ctx, db, "users", opts, &page2); err != nil { + log.Fatal(err) +} + +// Pagination helper function +func GetUserPage(ctx context.Context, db *sql.DB, page, pageSize int) ([]User, error) { + var users []User + offset := (page - 1) * pageSize + + opts := sqlutil.NewFindAllOptions(flavor). + WithLimit(pageSize). + WithOffset(offset). + WithOrderBy("id ASC") + + err := sqlutil.Select(ctx, db, "users", opts, &users) + return users, err +} + +// Usage +page1Users, err := GetUserPage(ctx, db, 1, 20) // First page, 20 items +page2Users, err := GetUserPage(ctx, db, 2, 20) // Second page, 20 items +``` + +### Ordering + +Sort results by one or multiple columns: + +```go +// Order by single column ascending +opts := sqlutil.NewFindAllOptions(flavor).WithOrderBy("name ASC") + +// Order by single column descending +opts = sqlutil.NewFindAllOptions(flavor).WithOrderBy("created_at DESC") + +// Order by multiple columns +opts = sqlutil.NewFindAllOptions(flavor).WithOrderBy("country ASC, age DESC") + +// Complex example: Get top 10 youngest active users from USA +var youngUsers []User +opts = sqlutil.NewFindAllOptions(flavor). + WithFilter("active", true). + WithFilter("country", "USA"). + WithOrderBy("age ASC"). + WithLimit(10) + +if err := sqlutil.Select(ctx, db, "users", opts, &youngUsers); err != nil { + log.Fatal(err) +} +``` + +### Row Locking + +Use row locking for concurrent operations: + +```go +// FOR UPDATE - locks rows for update +opts := sqlutil.NewFindAllOptions(flavor). + WithFilter("active", true). + WithForUpdate("") + +// FOR UPDATE SKIP LOCKED - skip rows that are already locked +opts = sqlutil.NewFindAllOptions(flavor). + WithFilter("active", true). + WithForUpdate("SKIP LOCKED") + +// FOR UPDATE NOWAIT - return immediately if rows are locked +opts = sqlutil.NewFindAllOptions(flavor). + WithFilter("active", true). + WithForUpdate("NOWAIT") + +// Example: Process queue items with row locking +tx, err := db.BeginTx(ctx, nil) +if err != nil { + log.Fatal(err) +} +defer tx.Rollback() + +var queueItems []QueueItem +opts := sqlutil.NewFindAllOptions(flavor). + WithFilter("status", "pending"). + WithOrderBy("created_at ASC"). + WithLimit(10). + WithForUpdate("SKIP LOCKED") + +if err := sqlutil.Select(ctx, tx, "queue", opts, &queueItems); err != nil { + log.Fatal(err) +} + +// Process items... +for _, item := range queueItems { + // Process item + updateOpts := sqlutil.NewUpdateOptions(flavor). + WithSet("status", "processed"). + WithFilter("id", item.ID) + + if err := sqlutil.UpdateWithOptions(ctx, tx, flavor, "queue", updateOpts); err != nil { + log.Fatal(err) + } +} + +tx.Commit() +``` + +## Bulk Operations + +Efficiently handle multiple operations: + +```go +// Insert multiple users +users := []User{ + {Name: "Alice", Email: "alice@example.com", Age: 30, Country: "USA"}, + {Name: "Bob", Email: "bob@example.com", Age: 25, Country: "Canada"}, + {Name: "Charlie", Email: "charlie@example.com", Age: 35, Country: "UK"}, +} + +for _, user := range users { + if err := sqlutil.Insert(ctx, db, flavor, "insert", "users", &user); err != nil { + log.Fatal(err) + } +} + +// Bulk update with filter +updateOpts := sqlutil.NewUpdateOptions(flavor). + WithSet("active", false). + WithFilter("country.in", "USA,Canada,Mexico") + +if err := sqlutil.UpdateWithOptions(ctx, db, flavor, "users", updateOpts); err != nil { + log.Fatal(err) +} + +// Bulk delete with filter +deleteOpts := sqlutil.NewDeleteOptions(flavor). + WithFilter("active", false). + WithFilter("age.lt", 18) + +if err := sqlutil.DeleteWithOptions(ctx, db, flavor, "users", deleteOpts); err != nil { + log.Fatal(err) +} +``` + +## Using with Transactions + +sqlutil works seamlessly with database transactions: + +```go +// Start a transaction +tx, err := db.BeginTx(ctx, nil) +if err != nil { + log.Fatal(err) +} +defer tx.Rollback() // Rollback if not committed + +// Insert user +user := User{Name: "David", Email: "david@example.com", Age: 40, Country: "France"} +if err := sqlutil.Insert(ctx, tx, flavor, "insert", "users", &user); err != nil { + log.Fatal(err) +} + +// Get the inserted user +var insertedUser User +opts := sqlutil.NewFindOptions(flavor).WithFilter("email", "david@example.com") +if err := sqlutil.Get(ctx, tx, "users", opts, &insertedUser); err != nil { + log.Fatal(err) +} + +// Update the user +insertedUser.Age = 41 +if err := sqlutil.Update(ctx, tx, flavor, "update", "users", insertedUser.ID, &insertedUser); err != nil { + log.Fatal(err) +} + +// Commit the transaction +if err := tx.Commit(); err != nil { + log.Fatal(err) +} + +// Example: Transfer operation with rollback +func TransferUser(ctx context.Context, db *sql.DB, userID int, newCountry string) error { + tx, err := db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer tx.Rollback() + + // Get user + var user User + opts := sqlutil.NewFindOptions(flavor).WithFilter("id", userID) + if err := sqlutil.Get(ctx, tx, "users", opts, &user); err != nil { + return err + } + + // Update country + updateOpts := sqlutil.NewUpdateOptions(flavor). + WithSet("country", newCountry). + WithFilter("id", userID) + + if err := sqlutil.UpdateWithOptions(ctx, tx, flavor, "users", updateOpts); err != nil { + return err + } + + // Commit transaction + return tx.Commit() +} +``` + +## Multiple SQL Flavors + +sqlutil supports PostgreSQL, MySQL, and SQLite: + +### PostgreSQL + +```go +import ( + "github.com/allisson/sqlutil" + _ "github.com/lib/pq" +) + +db, _ := sql.Open("postgres", "postgres://user:password@localhost/mydb?sslmode=disable") +flavor := sqlutil.PostgreSQLFlavor + +opts := sqlutil.NewFindOptions(flavor).WithFilter("id", 1) +``` + +### MySQL + +```go +import ( + "github.com/allisson/sqlutil" + _ "github.com/go-sql-driver/mysql" +) + +db, _ := sql.Open("mysql", "user:password@tcp(localhost:3306)/mydb") +flavor := sqlutil.MySQLFlavor + +opts := sqlutil.NewFindOptions(flavor).WithFilter("id", 1) +``` + +### SQLite + +```go +import ( + "github.com/allisson/sqlutil" + _ "github.com/mattn/go-sqlite3" +) + +db, _ := sql.Open("sqlite3", "./mydb.db") +flavor := sqlutil.SQLiteFlavor + +opts := sqlutil.NewFindOptions(flavor).WithFilter("id", 1) +``` + +### Cross-Database Compatibility + +Write database-agnostic code by passing the flavor as a parameter: + +```go +type UserRepository struct { + db *sql.DB + flavor sqlutil.Flavor +} + +func NewUserRepository(db *sql.DB, flavor sqlutil.Flavor) *UserRepository { + return &UserRepository{db: db, flavor: flavor} +} + +func (r *UserRepository) GetByID(ctx context.Context, id int) (*User, error) { + var user User + opts := sqlutil.NewFindOptions(r.flavor).WithFilter("id", id) + err := sqlutil.Get(ctx, r.db, "users", opts, &user) + return &user, err +} + +func (r *UserRepository) GetActiveUsers(ctx context.Context) ([]User, error) { + var users []User + opts := sqlutil.NewFindAllOptions(r.flavor). + WithFilter("active", true). + WithOrderBy("name ASC") + err := sqlutil.Select(ctx, r.db, "users", opts, &users) + return users, err +} + +// Works with any database flavor +pgRepo := NewUserRepository(pgDB, sqlutil.PostgreSQLFlavor) +mysqlRepo := NewUserRepository(mysqlDB, sqlutil.MySQLFlavor) +sqliteRepo := NewUserRepository(sqliteDB, sqlutil.SQLiteFlavor) +``` + +## Complete Example + +Here's a complete example demonstrating various features: + +```go +package main + +import ( + "context" + "database/sql" + "fmt" + "log" + + "github.com/allisson/sqlutil" + _ "github.com/lib/pq" +) + +type User struct { + ID int `db:"id"` + Name string `db:"name" fieldtag:"insert,update"` + Email string `db:"email" fieldtag:"insert,update"` + Age int `db:"age" fieldtag:"insert,update"` + Country string `db:"country" fieldtag:"insert,update"` + Active bool `db:"active" fieldtag:"insert,update"` +} + +func main() { + // Connect to database + db, err := sql.Open("postgres", "postgres://user:password@localhost/sqlutil?sslmode=disable") if err != nil { log.Fatal(err) } defer db.Close() + ctx := context.Background() + flavor := sqlutil.PostgreSQLFlavor + // Create table - _, err = db.Exec(` - CREATE TABLE IF NOT EXISTS players( + _, err = db.ExecContext(ctx, ` + CREATE TABLE IF NOT EXISTS users ( id SERIAL PRIMARY KEY, - name VARCHAR NOT NULL, - age INTEGER NOT NULL + name VARCHAR(255) NOT NULL, + email VARCHAR(255) UNIQUE NOT NULL, + age INTEGER NOT NULL, + country VARCHAR(100), + active BOOLEAN DEFAULT true ) `) if err != nil { log.Fatal(err) } - // Insert players - r9 := Player{ - Name: "Ronaldo Fenômeno", - Age: 44, + // Insert users + users := []User{ + {Name: "Alice Johnson", Email: "alice@example.com", Age: 30, Country: "USA", Active: true}, + {Name: "Bob Smith", Email: "bob@example.com", Age: 25, Country: "Canada", Active: true}, + {Name: "Charlie Brown", Email: "charlie@example.com", Age: 35, Country: "UK", Active: true}, + {Name: "David Lee", Email: "david@example.com", Age: 28, Country: "USA", Active: false}, } - r10 := Player{ - Name: "Ronaldinho Gaúcho", - Age: 41, + + for _, user := range users { + if err := sqlutil.Insert(ctx, db, flavor, "insert", "users", &user); err != nil { + log.Printf("Error inserting user: %v", err) + } } - flavour := sqlutil.PostgreSQLFlavor - tag := "insert" // will use fields with fieldtag:"insert" - ctx := context.Background() - if err := sqlutil.Insert(ctx, db, sqlutil.PostgreSQLFlavor, tag, "players", &r9); err != nil { + + // Get single user by email + var alice User + opts := sqlutil.NewFindOptions(flavor).WithFilter("email", "alice@example.com") + if err := sqlutil.Get(ctx, db, "users", opts, &alice); err != nil { log.Fatal(err) } - if err := sqlutil.Insert(ctx, db, sqlutil.PostgreSQLFlavor, tag, "players", &r10); err != nil { + fmt.Printf("Found user: %+v\n", alice) + + // Get all active users from USA + var activeUSAUsers []User + opts2 := sqlutil.NewFindAllOptions(flavor). + WithFilter("active", true). + WithFilter("country", "USA"). + WithOrderBy("name ASC") + + if err := sqlutil.Select(ctx, db, "users", opts2, &activeUSAUsers); err != nil { log.Fatal(err) } + fmt.Printf("Active USA users: %d\n", len(activeUSAUsers)) - // Get player - findOptions := sqlutil.NewFindOptions(flavour).WithFilter("name", r10.Name) - if err := sqlutil.Get(ctx, db, "players", findOptions, &r10); err != nil { + // Get users with age between 25 and 30 + var youngUsers []User + opts3 := sqlutil.NewFindAllOptions(flavor). + WithFilter("age.gte", 25). + WithFilter("age.lte", 30). + WithOrderBy("age ASC") + + if err := sqlutil.Select(ctx, db, "users", opts3, &youngUsers); err != nil { log.Fatal(err) } - findOptions = sqlutil.NewFindOptions(flavour).WithFilter("name", r9.Name) - if err := sqlutil.Get(ctx, db, "players", findOptions, &r9); err != nil { + fmt.Printf("Users aged 25-30: %d\n", len(youngUsers)) + + // Update user + alice.Age = 31 + alice.Country = "Canada" + if err := sqlutil.Update(ctx, db, flavor, "update", "users", alice.ID, &alice); err != nil { log.Fatal(err) } - // Select players - players := []*Player{} - findAllOptions := sqlutil.NewFindAllOptions(flavour).WithLimit(10).WithOffset(0).WithOrderBy("name asc") - if err := sqlutil.Select(ctx, db, "players", findAllOptions, &players); err != nil { + // Bulk update: deactivate all users older than 30 + updateOpts := sqlutil.NewUpdateOptions(flavor). + WithSet("active", false). + WithFilter("age.gt", 30) + + if err := sqlutil.UpdateWithOptions(ctx, db, flavor, "users", updateOpts); err != nil { log.Fatal(err) } - for _, p := range players { - fmt.Printf("%#v\n", p) - } - // Update player - tag = "update" // will use fields with fieldtag:"update" - r10.Name = "Ronaldinho Bruxo" - if err := sqlutil.Update(ctx, db, sqlutil.PostgreSQLFlavor, tag, "players", r10.ID, &r10); err != nil { + // Get users with pagination + page1 := []User{} + paginationOpts := sqlutil.NewFindAllOptions(flavor). + WithLimit(2). + WithOffset(0). + WithOrderBy("name ASC") + + if err := sqlutil.Select(ctx, db, "users", paginationOpts, &page1); err != nil { log.Fatal(err) } + fmt.Printf("Page 1: %d users\n", len(page1)) - // Delete player - if err := sqlutil.Delete(ctx, db, sqlutil.PostgreSQLFlavor, "players", r9.ID); err != nil { + // Delete inactive users + deleteOpts := sqlutil.NewDeleteOptions(flavor).WithFilter("active", false) + if err := sqlutil.DeleteWithOptions(ctx, db, flavor, "users", deleteOpts); err != nil { log.Fatal(err) } + fmt.Println("Inactive users deleted") } ``` -Options for FindOptions and FindAllOptions: +## License -```golang -package main +This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. -import ( - "github.com/allisson/sqlutil" - _ "github.com/lib/pq" -) +## Contributing -func main() { - findOptions := sqlutil.NewFindOptions(sqlutil.PostgreSQLFlavor). - WithFields([]string{"id", "name"}). // Return only id and name fields - WithFilter("id", 1). // WHERE id = 1 - WithFilter("id", nil). // WHERE id IS NULL - WithFilter("id.in", "1,2,3"). // WHERE id IN (1, 2, 3) - WithFilter("id.notin", "1,2,3"). // WHERE id NOT IN ($1, $2, $3) - WithFilter("id.not", 1). // WHERE id <> 1 - WithFilter("id.gt", 1). // WHERE id > 1 - WithFilter("id.gte", 1). // WHERE id >= 1 - WithFilter("id.lt", 1). // WHERE id < 1 - WithFilter("id.lte", 1). // WHERE id <= 1 - WithFilter("id.like", 1). // WHERE id LIKE 1 - WithFilter("id.null", true). // WHERE id.null IS NULL - WithFilter("id.null", false) // WHERE id.null IS NOT NULL - - findAllOptions := sqlutil.NewFindAllOptions(sqlutil.PostgreSQLFlavor). - WithFields([]string{"id", "name"}). // Return only id and name fields - WithFilter("id", 1). // WHERE id = 1 - WithFilter("id", nil). // WHERE id IS NULL - WithFilter("id.in", "1,2,3"). // WHERE id IN (1, 2, 3) - WithFilter("id.notin", "1,2,3"). // WHERE id NOT IN ($1, $2, $3) - WithFilter("id.not", 1). // WHERE id <> 1 - WithFilter("id.gt", 1). // WHERE id > 1 - WithFilter("id.gte", 1). // WHERE id >= 1 - WithFilter("id.lt", 1). // WHERE id < 1 - WithFilter("id.lte", 1). // WHERE id <= 1 - WithFilter("id.like", 1). // WHERE id LIKE 1 - WithFilter("id.null", true). // WHERE id.null IS NULL - WithFilter("id.null", false). // WHERE id.null IS NOT NULL - WithLimit(10). // LIMIT 10 - WithOffset(0). // OFFSET 0 - WithOrderBy("name asc"). // ORDER BY name asc - WithForUpdate("SKIP LOCKED") // FOR UPDATE SKIP LOCKED -} -``` +Contributions are welcome! Please feel free to submit a Pull Request. + +## Related Projects + +- [sqlquery](https://github.com/allisson/sqlquery) - SQL query builder for Go +- [scany](https://github.com/georgysavva/scany) - Library for scanning data from a database into Go structs diff --git a/go.mod b/go.mod index a7edc3d..a8f91a2 100644 --- a/go.mod +++ b/go.mod @@ -4,15 +4,16 @@ go 1.22 require ( github.com/DATA-DOG/go-sqlmock v1.5.2 - github.com/allisson/sqlquery v1.4.0 - github.com/georgysavva/scany/v2 v2.1.0 - github.com/stretchr/testify v1.8.4 + github.com/allisson/sqlquery v1.5.0 + github.com/georgysavva/scany/v2 v2.1.4 + github.com/stretchr/testify v1.11.1 ) require ( github.com/davecgh/go-spew v1.1.1 // indirect - github.com/huandu/go-sqlbuilder v1.25.0 // indirect - github.com/huandu/xstrings v1.3.2 // indirect + github.com/huandu/go-clone v1.7.3 // indirect + github.com/huandu/go-sqlbuilder v1.39.0 // indirect + github.com/huandu/xstrings v1.4.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 65c55b2..2892a21 100644 --- a/go.sum +++ b/go.sum @@ -1,26 +1,29 @@ github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU= github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU= -github.com/allisson/sqlquery v1.4.0 h1:mA4+Pjmku8bUDy7J/2Xc0pAbutvx4wGE+9lzHiUnwXc= -github.com/allisson/sqlquery v1.4.0/go.mod h1:GvoJ1/In4XEZu9jCHXDabcQNr7EovpeoxQijcB2A0CQ= +github.com/allisson/sqlquery v1.5.0 h1:fPSpwWIelpSXcrVbQpX1qNjzmVcMZw7A3FUgntYnHmM= +github.com/allisson/sqlquery v1.5.0/go.mod h1:PbwTeUaIvV3r+8Q50eBxx3ExgjhALczLoY+NZGCS4j4= github.com/cockroachdb/cockroach-go/v2 v2.2.0 h1:/5znzg5n373N/3ESjHF5SMLxiW4RKB05Ql//KWfeTFs= github.com/cockroachdb/cockroach-go/v2 v2.2.0/go.mod h1:u3MiKYGupPPjkn3ozknpMUpxPaNLTFWAya419/zv6eI= 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/georgysavva/scany/v2 v2.1.0 h1:jEAX+yPQ2AAtnv0WJzAYlgsM/KzvwbD6BjSjLIyDxfc= -github.com/georgysavva/scany/v2 v2.1.0/go.mod h1:fqp9yHZzM/PFVa3/rYEC57VmDx+KDch0LoqrJzkvtos= +github.com/georgysavva/scany/v2 v2.1.4 h1:nrzHEJ4oQVRoiKmocRqA1IyGOmM/GQOEsg9UjMR5Ip4= +github.com/georgysavva/scany/v2 v2.1.4/go.mod h1:fqp9yHZzM/PFVa3/rYEC57VmDx+KDch0LoqrJzkvtos= github.com/gofrs/flock v0.8.1 h1:+gYjHKf32LDeiEEFhQaotPbLuUXjY5ZqxKgXy7n59aw= github.com/gofrs/flock v0.8.1/go.mod h1:F1TvTiK9OcQqauNUHlbJvyl9Qa1QvF/gOUDKA14jxHU= github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 h1:au07oEsX2xN0ktxqI+Sida1w446QrXBRJ0nee3SNZlA= github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0= github.com/golang-sql/sqlexp v0.1.0 h1:ZCD6MBpcuOVfGVqsEmY5/4FtYiKz6tSyUv9LPEDei6A= github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EOqtpKwwwHI= -github.com/huandu/go-assert v1.1.5 h1:fjemmA7sSfYHJD7CUqs9qTwwfdNAx7/j2/ZlHXzNB3c= github.com/huandu/go-assert v1.1.5/go.mod h1:yOLvuqZwmcHIC5rIzrBhT7D3Q9c3GFnd0JrPVhn/06U= -github.com/huandu/go-sqlbuilder v1.25.0 h1:h1l+6CqeCviPJCnkEZoRGNdfZ5RO9BKMvG3A+1VuKNM= -github.com/huandu/go-sqlbuilder v1.25.0/go.mod h1:nUVmMitjOmn/zacMLXT0d3Yd3RHoO2K+vy906JzqxMI= -github.com/huandu/xstrings v1.3.2 h1:L18LIDzqlW6xN2rEkpdV8+oL/IXWJ1APd+vsdYy4Wdw= -github.com/huandu/xstrings v1.3.2/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= +github.com/huandu/go-assert v1.1.6 h1:oaAfYxq9KNDi9qswn/6aE0EydfxSa+tWZC1KabNitYs= +github.com/huandu/go-assert v1.1.6/go.mod h1:JuIfbmYG9ykwvuxoJ3V8TB5QP+3+ajIA54Y44TmkMxs= +github.com/huandu/go-clone v1.7.3 h1:rtQODA+ABThEn6J5LBTppJfKmZy/FwfpMUWa8d01TTQ= +github.com/huandu/go-clone v1.7.3/go.mod h1:ReGivhG6op3GYr+UY3lS6mxjKp7MIGTknuU5TbTVaXE= +github.com/huandu/go-sqlbuilder v1.39.0 h1:O3eSJZXrOfysA1SoDTf/sCiZqhA/FvdKRnehYwhrdOA= +github.com/huandu/go-sqlbuilder v1.39.0/go.mod h1:zdONH67liL+/TvoUMwnZP/sUYGSSvHh9psLe/HpXn8E= +github.com/huandu/xstrings v1.4.0 h1:D17IlohoQq4UcpqD7fDk80P7l+lwAmlFaBHgOipl2FU= +github.com/huandu/xstrings v1.4.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= 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-20200714003250-2b9c44734f2b h1:C8S2+VttkHFdOOCXJe+YGfa4vHYwlt4Zx+IVXQ97jYg= @@ -39,11 +42,11 @@ github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINE github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c= -github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= -github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= -github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= golang.org/x/crypto v0.17.0 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k= golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc= diff --git a/helper.go b/helper.go index b39e9f8..422191c 100644 --- a/helper.go +++ b/helper.go @@ -22,73 +22,101 @@ var ( SQLiteFlavor = sqlquery.SQLiteFlavor ) -// NewFindOptions returns a FindOptions. +// NewFindOptions creates and returns a new FindOptions instance for the specified SQL flavor. +// FindOptions is used to build queries for finding a single record with support for field selection, +// filtering, and various comparison operators. func NewFindOptions(flavor Flavor) *FindOptions { return sqlquery.NewFindOptions(flavor) } -// NewFindAllOptions returns a FindAllOptions. +// NewFindAllOptions creates and returns a new FindAllOptions instance for the specified SQL flavor. +// FindAllOptions is used to build queries for finding multiple records with support for field selection, +// filtering, pagination (limit/offset), ordering, and row locking (FOR UPDATE). func NewFindAllOptions(flavor Flavor) *FindAllOptions { return sqlquery.NewFindAllOptions(flavor) } -// NewUpdateOptions returns a UpdateOptions. +// NewUpdateOptions creates and returns a new UpdateOptions instance for the specified SQL flavor. +// UpdateOptions is used to build UPDATE queries with support for filtering and setting multiple fields. func NewUpdateOptions(flavor Flavor) *UpdateOptions { return sqlquery.NewUpdateOptions(flavor) } -// NewDeleteOptions returns a DeleteOptions. +// NewDeleteOptions creates and returns a new DeleteOptions instance for the specified SQL flavor. +// DeleteOptions is used to build DELETE queries with support for filtering conditions. func NewDeleteOptions(flavor Flavor) *DeleteOptions { return sqlquery.NewDeleteOptions(flavor) } -// Querier is a abstraction over *sql.DB/*sql.Conn/*sql.Tx. +// Querier is an abstraction over *sql.DB, *sql.Conn, and *sql.Tx. +// It provides a common interface for executing queries and commands against a database, +// allowing functions to work with any of these types. type Querier interface { QueryContext(ctx context.Context, query string, args ...interface{}) (*sql.Rows, error) ExecContext(ctx context.Context, query string, args ...interface{}) (sql.Result, error) } -// Get is a high-level function that calls sqlquery.FindQuery and scany sqlscan.Get function. +// Get retrieves a single record from the database and scans it into dst. +// It builds a SELECT query using sqlquery.FindQuery with the provided options, +// then uses scany's sqlscan.Get to scan the result into the destination struct. +// Returns an error if no rows are found or if scanning fails. func Get(ctx context.Context, db Querier, tableName string, options *FindOptions, dst interface{}) error { sqlQuery, args := sqlquery.FindQuery(tableName, options) return sqlscan.Get(ctx, db, dst, sqlQuery, args...) } -// Select is a high-level function that calls sqlquery.FindAllQuery and scany sqlscan.Select function. +// Select retrieves multiple records from the database and scans them into dst. +// It builds a SELECT query using sqlquery.FindAllQuery with the provided options, +// then uses scany's sqlscan.Select to scan the results into the destination slice. +// The dst parameter should be a pointer to a slice of structs. func Select(ctx context.Context, db Querier, tableName string, options *FindAllOptions, dst interface{}) error { sqlQuery, args := sqlquery.FindAllQuery(tableName, options) return sqlscan.Select(ctx, db, dst, sqlQuery, args...) } -// Insert is a high-level function that calls sqlquery.InsertQuery and db.ExecContext. +// Insert inserts a new record into the database table. +// It builds an INSERT query using sqlquery.InsertQuery based on the struct fields +// that match the specified tag (e.g., "insert"), then executes the query. +// The structValue parameter should be a pointer to a struct with appropriate field tags. func Insert(ctx context.Context, db Querier, flavor Flavor, tag, tableName string, structValue interface{}) error { sqlQuery, args := sqlquery.InsertQuery(flavor, tag, tableName, structValue) _, err := db.ExecContext(ctx, sqlQuery, args...) return err } -// Update is a high-level function that calls sqlquery.UpdateQuery and db.ExecContext. +// Update updates an existing record in the database table by its ID. +// It builds an UPDATE query using sqlquery.UpdateQuery based on the struct fields +// that match the specified tag (e.g., "update"), adding a WHERE clause for the given ID. +// The structValue parameter should be a pointer to a struct with appropriate field tags. func Update(ctx context.Context, db Querier, flavor Flavor, tag, tableName string, id interface{}, structValue interface{}) error { sqlQuery, args := sqlquery.UpdateQuery(flavor, tag, tableName, id, structValue) _, err := db.ExecContext(ctx, sqlQuery, args...) return err } -// Delete is a high-level function that calls sqlquery.DeleteQuery and db.ExecContext. +// Delete deletes a record from the database table by its ID. +// It builds a DELETE query using sqlquery.DeleteQuery with a WHERE clause +// matching the given ID, then executes the query. func Delete(ctx context.Context, db Querier, flavor Flavor, tableName string, id interface{}) error { sqlQuery, args := sqlquery.DeleteQuery(flavor, tableName, id) _, err := db.ExecContext(ctx, sqlQuery, args...) return err } -// UpdateWithOptions is a high-level function that calls sqlquery.UpdateWithOptionsQuery and db.ExecContext. +// UpdateWithOptions updates records in the database table based on custom criteria. +// It builds an UPDATE query using sqlquery.UpdateWithOptionsQuery with the provided options, +// which allow for complex WHERE clauses and multiple field updates. +// This provides more flexibility than the simple Update function. func UpdateWithOptions(ctx context.Context, db Querier, flavor Flavor, tableName string, options *UpdateOptions) error { sqlQuery, args := sqlquery.UpdateWithOptionsQuery(tableName, options) _, err := db.ExecContext(ctx, sqlQuery, args...) return err } -// DeleteWithOptions is a high-level function that calls sqlquery.DeleteWithOptionsQuery and db.ExecContext. +// DeleteWithOptions deletes records from the database table based on custom criteria. +// It builds a DELETE query using sqlquery.DeleteWithOptionsQuery with the provided options, +// which allow for complex WHERE clauses to match multiple records. +// This provides more flexibility than the simple Delete function. func DeleteWithOptions(ctx context.Context, db Querier, flavor Flavor, tableName string, options *DeleteOptions) error { sqlQuery, args := sqlquery.DeleteWithOptionsQuery(tableName, options) _, err := db.ExecContext(ctx, sqlQuery, args...) diff --git a/helper_test.go b/helper_test.go index 4fe554a..d8dbe18 100644 --- a/helper_test.go +++ b/helper_test.go @@ -38,7 +38,7 @@ func TestSelect(t *testing.T) { defer db.Close() rows := sqlmock.NewRows([]string{"id", "name"}).AddRow(1, "Ronaldinho Gaúcho").AddRow(2, "Ronaldo Fenômeno") - mock.ExpectQuery(regexp.QuoteMeta(`SELECT * FROM players LIMIT 10 OFFSET 0 FOR UPDATE SKIP LOCKED`)).WillReturnRows(rows) + mock.ExpectQuery(regexp.QuoteMeta(`SELECT * FROM players LIMIT $1 OFFSET $2 FOR UPDATE SKIP LOCKED`)).WillReturnRows(rows) options := NewFindAllOptions(PostgreSQLFlavor).WithLimit(10).WithOffset(0).WithForUpdate("SKIP LOCKED") p := []*player{}