Initial commit
This commit is contained in:
792
references/api-reference.md
Normal file
792
references/api-reference.md
Normal file
@@ -0,0 +1,792 @@
|
||||
# cool-mysql API Reference
|
||||
|
||||
Complete API documentation for all cool-mysql methods, organized by category.
|
||||
|
||||
## Database Creation
|
||||
|
||||
### New
|
||||
```go
|
||||
func New(wUser, wPass, wSchema, wHost string, wPort int,
|
||||
rUser, rPass, rSchema, rHost string, rPort int,
|
||||
collation, timeZone string) (*Database, error)
|
||||
```
|
||||
|
||||
Create a new database connection from connection parameters.
|
||||
|
||||
**Parameters:**
|
||||
- `wUser`, `wPass`, `wSchema`, `wHost`, `wPort` - Write connection credentials
|
||||
- `rUser`, `rPass`, `rSchema`, `rHost`, `rPort` - Read connection credentials
|
||||
- `collation` - Database collation (e.g., `"utf8mb4_unicode_ci"`)
|
||||
- `timeZone` - Time zone for connections (e.g., `"America/New_York"`, `"UTC"`)
|
||||
|
||||
**Returns:**
|
||||
- `*Database` - Database instance with dual connection pools
|
||||
- `error` - Connection error if unable to establish connections
|
||||
|
||||
**Example:**
|
||||
```go
|
||||
db, err := mysql.New(
|
||||
"root", "password", "mydb", "localhost", 3306,
|
||||
"root", "password", "mydb", "localhost", 3306,
|
||||
"utf8mb4_unicode_ci",
|
||||
"UTC",
|
||||
)
|
||||
```
|
||||
|
||||
### NewFromDSN
|
||||
```go
|
||||
func NewFromDSN(writesDSN, readsDSN string) (*Database, error)
|
||||
```
|
||||
|
||||
Create database connection from DSN strings.
|
||||
|
||||
**Parameters:**
|
||||
- `writesDSN` - Write connection DSN
|
||||
- `readsDSN` - Read connection DSN
|
||||
|
||||
**DSN Format:**
|
||||
```
|
||||
username:password@protocol(address)/dbname?param=value
|
||||
```
|
||||
|
||||
**Example:**
|
||||
```go
|
||||
writesDSN := "user:pass@tcp(write-host:3306)/dbname?parseTime=true&loc=UTC"
|
||||
readsDSN := "user:pass@tcp(read-host:3306)/dbname?parseTime=true&loc=UTC"
|
||||
db, err := mysql.NewFromDSN(writesDSN, readsDSN)
|
||||
```
|
||||
|
||||
### NewFromConn
|
||||
```go
|
||||
func NewFromConn(writesConn, readsConn *sql.DB) (*Database, error)
|
||||
```
|
||||
|
||||
Create database from existing `*sql.DB` connections.
|
||||
|
||||
**Parameters:**
|
||||
- `writesConn` - Existing write connection
|
||||
- `readsConn` - Existing read connection
|
||||
|
||||
**Example:**
|
||||
```go
|
||||
writesConn, _ := sql.Open("mysql", writesDSN)
|
||||
readsConn, _ := sql.Open("mysql", readsDSN)
|
||||
db, err := mysql.NewFromConn(writesConn, readsConn)
|
||||
```
|
||||
|
||||
## Query Methods (SELECT)
|
||||
|
||||
### Select
|
||||
```go
|
||||
func (db *Database) Select(dest any, query string, cacheTTL time.Duration, params ...mysql.Params) error
|
||||
```
|
||||
|
||||
Execute SELECT query and scan results into destination. Uses read connection pool.
|
||||
|
||||
**Parameters:**
|
||||
- `dest` - Destination for results (struct, slice, map, channel, function, or primitive)
|
||||
- `query` - SQL query with `@@paramName` placeholders
|
||||
- `cacheTTL` - Cache duration (`0` = no cache, `> 0` = cache for duration)
|
||||
- `params` - Query parameters (`mysql.Params{}` or structs)
|
||||
|
||||
**Destination Types:**
|
||||
- `*[]StructType` - Slice of structs
|
||||
- `*StructType` - Single struct (returns `sql.ErrNoRows` if not found)
|
||||
- `*string`, `*int`, `*time.Time`, etc. - Single value
|
||||
- `chan StructType` - Channel for streaming results
|
||||
- `func(StructType)` - Function called for each row
|
||||
- `*[]map[string]any` - Slice of maps
|
||||
- `*json.RawMessage` - JSON result
|
||||
|
||||
**Returns:**
|
||||
- `error` - Query error or `sql.ErrNoRows` for single-value queries with no results
|
||||
|
||||
**Examples:**
|
||||
```go
|
||||
// Select into struct slice
|
||||
var users []User
|
||||
err := db.Select(&users, "SELECT `id`, `name`, `email`, `age`, `active`, `created_at`, `updated_at` FROM `users` WHERE age > @@minAge", 5*time.Minute,
|
||||
18)
|
||||
|
||||
// Select single value
|
||||
var name string
|
||||
err := db.Select(&name, "SELECT `name` FROM `users` WHERE `id` = @@id", 0,
|
||||
1)
|
||||
|
||||
// Select into channel
|
||||
userCh := make(chan User)
|
||||
go func() {
|
||||
defer close(userCh)
|
||||
db.Select(userCh, "SELECT `id`, `name`, `email`, `age`, `active`, `created_at`, `updated_at` FROM `users`", 0)
|
||||
}()
|
||||
|
||||
// Select with function
|
||||
db.Select(func(u User) {
|
||||
log.Printf("User: %s", u.Name)
|
||||
}, "SELECT `id`, `name`, `email`, `age`, `active`, `created_at`, `updated_at` FROM `users`", 0)
|
||||
```
|
||||
|
||||
### SelectContext
|
||||
```go
|
||||
func (db *Database) SelectContext(ctx context.Context, dest any, query string,
|
||||
cacheTTL time.Duration, params ...mysql.Params) error
|
||||
```
|
||||
|
||||
Context-aware version of `Select()`. Supports cancellation and deadlines.
|
||||
|
||||
**Parameters:**
|
||||
- `ctx` - Context for cancellation/timeout
|
||||
- Additional parameters same as `Select()`
|
||||
|
||||
**Example:**
|
||||
```go
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var users []User
|
||||
err := db.SelectContext(ctx, &users, "SELECT `id`, `name`, `email`, `age`, `active`, `created_at`, `updated_at` FROM `users`", 0)
|
||||
```
|
||||
|
||||
### SelectWrites
|
||||
```go
|
||||
func (db *Database) SelectWrites(dest any, query string, cacheTTL time.Duration,
|
||||
params ...mysql.Params) error
|
||||
```
|
||||
|
||||
Select using write connection pool. Use for read-after-write consistency.
|
||||
|
||||
**When to Use:**
|
||||
- Immediately after INSERT/UPDATE/DELETE when you need to read the modified data
|
||||
- When you need strong consistency and can't risk reading stale replica data
|
||||
|
||||
**Example:**
|
||||
```go
|
||||
// Insert then immediately read
|
||||
db.Insert("users", user)
|
||||
db.SelectWrites(&user, "SELECT `id`, `name`, `email`, `age`, `active`, `created_at`, `updated_at` FROM `users` WHERE `id` = @@id", 0,
|
||||
user.ID)
|
||||
```
|
||||
|
||||
### SelectWritesContext
|
||||
```go
|
||||
func (db *Database) SelectWritesContext(ctx context.Context, dest any, query string,
|
||||
cacheTTL time.Duration, params ...mysql.Params) error
|
||||
```
|
||||
|
||||
Context-aware version of `SelectWrites()`.
|
||||
|
||||
### SelectJSON
|
||||
```go
|
||||
func (db *Database) SelectJSON(dest *json.RawMessage, query string,
|
||||
cacheTTL time.Duration, params ...mysql.Params) error
|
||||
```
|
||||
|
||||
Select query results as JSON.
|
||||
|
||||
**Example:**
|
||||
```go
|
||||
var result json.RawMessage
|
||||
err := db.SelectJSON(&result,
|
||||
"SELECT JSON_OBJECT('id', id, 'name', name) FROM `users` WHERE `id` = @@id",
|
||||
0, 1)
|
||||
```
|
||||
|
||||
### SelectJSONContext
|
||||
```go
|
||||
func (db *Database) SelectJSONContext(ctx context.Context, dest *json.RawMessage,
|
||||
query string, cacheTTL time.Duration,
|
||||
params ...mysql.Params) error
|
||||
```
|
||||
|
||||
Context-aware version of `SelectJSON()`.
|
||||
|
||||
## Utility Query Methods
|
||||
|
||||
### Count
|
||||
```go
|
||||
func (db *Database) Count(query string, cacheTTL time.Duration, params ...mysql.Params) (int64, error)
|
||||
```
|
||||
|
||||
Execute COUNT query and return result as `int64`. Uses read pool.
|
||||
|
||||
**Parameters:**
|
||||
- `query` - Query that returns a single integer (typically `SELECT COUNT(*)`)
|
||||
- `cacheTTL` - Cache duration
|
||||
- `params` - Query parameters
|
||||
|
||||
**Returns:**
|
||||
- `int64` - Count result
|
||||
- `error` - Query error
|
||||
|
||||
**Example:**
|
||||
```go
|
||||
count, err := db.Count("SELECT COUNT(*) FROM `users` WHERE `active` = @@active",
|
||||
5*time.Minute, 1)
|
||||
```
|
||||
|
||||
### CountContext
|
||||
```go
|
||||
func (db *Database) CountContext(ctx context.Context, query string, cacheTTL time.Duration,
|
||||
params ...mysql.Params) (int64, error)
|
||||
```
|
||||
|
||||
Context-aware version of `Count()`.
|
||||
|
||||
### Exists
|
||||
```go
|
||||
func (db *Database) Exists(query string, cacheTTL time.Duration, params ...mysql.Params) (bool, error)
|
||||
```
|
||||
|
||||
Check if query returns any rows. Uses read pool.
|
||||
|
||||
**Parameters:**
|
||||
- `query` - Query to check (typically `SELECT 1 FROM ... WHERE ...`)
|
||||
- `cacheTTL` - Cache duration
|
||||
- `params` - Query parameters
|
||||
|
||||
**Returns:**
|
||||
- `bool` - `true` if rows exist, `false` otherwise
|
||||
- `error` - Query error
|
||||
|
||||
**Example:**
|
||||
```go
|
||||
exists, err := db.Exists("SELECT 1 FROM `users` WHERE `email` = @@email", 0,
|
||||
"user@example.com")
|
||||
```
|
||||
|
||||
### ExistsContext
|
||||
```go
|
||||
func (db *Database) ExistsContext(ctx context.Context, query string, cacheTTL time.Duration,
|
||||
params ...mysql.Params) (bool, error)
|
||||
```
|
||||
|
||||
Context-aware version of `Exists()`.
|
||||
|
||||
### ExistsWrites
|
||||
```go
|
||||
func (db *Database) ExistsWrites(query string, params ...mysql.Params) (bool, error)
|
||||
```
|
||||
|
||||
Check existence using write pool for read-after-write consistency.
|
||||
|
||||
### ExistsWritesContext
|
||||
```go
|
||||
func (db *Database) ExistsWritesContext(ctx context.Context, query string,
|
||||
params ...mysql.Params) (bool, error)
|
||||
```
|
||||
|
||||
Context-aware version of `ExistsWrites()`.
|
||||
|
||||
## Insert Operations
|
||||
|
||||
### Insert
|
||||
```go
|
||||
func (db *Database) Insert(table string, data any) error
|
||||
```
|
||||
|
||||
Insert data into table. Automatically chunks large batches based on `max_allowed_packet`.
|
||||
|
||||
**Parameters:**
|
||||
- `table` - Table name
|
||||
- `data` - Single struct, slice of structs, or channel of structs
|
||||
|
||||
**Returns:**
|
||||
- `error` - Insert error
|
||||
|
||||
**Examples:**
|
||||
```go
|
||||
// Single insert
|
||||
user := User{Name: "Alice", Email: "alice@example.com"}
|
||||
err := db.Insert("users", user)
|
||||
|
||||
// Batch insert
|
||||
users := []User{
|
||||
{Name: "Bob", Email: "bob@example.com"},
|
||||
{Name: "Charlie", Email: "charlie@example.com"},
|
||||
}
|
||||
err := db.Insert("users", users)
|
||||
|
||||
// Streaming insert
|
||||
userCh := make(chan User)
|
||||
go func() {
|
||||
for _, u := range users {
|
||||
userCh <- u
|
||||
}
|
||||
close(userCh)
|
||||
}()
|
||||
err := db.Insert("users", userCh)
|
||||
```
|
||||
|
||||
### InsertContext
|
||||
```go
|
||||
func (db *Database) InsertContext(ctx context.Context, table string, data any) error
|
||||
```
|
||||
|
||||
Context-aware version of `Insert()`.
|
||||
|
||||
**Example:**
|
||||
```go
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
err := db.InsertContext(ctx, "users", users)
|
||||
```
|
||||
|
||||
## Upsert Operations
|
||||
|
||||
### Upsert
|
||||
```go
|
||||
func (db *Database) Upsert(table string, uniqueCols, updateCols []string,
|
||||
where string, data any) error
|
||||
```
|
||||
|
||||
Perform INSERT ... ON DUPLICATE KEY UPDATE operation.
|
||||
|
||||
**Parameters:**
|
||||
- `table` - Table name
|
||||
- `uniqueCols` - Columns that define uniqueness (used in conflict detection)
|
||||
- `updateCols` - Columns to update on duplicate key
|
||||
- `where` - Optional WHERE clause for conditional update (can be empty)
|
||||
- `data` - Single struct, slice of structs, or channel of structs
|
||||
|
||||
**Returns:**
|
||||
- `error` - Upsert error
|
||||
|
||||
**Examples:**
|
||||
```go
|
||||
// Basic upsert on unique email
|
||||
err := db.Upsert(
|
||||
"users",
|
||||
[]string{"email"}, // unique column
|
||||
[]string{"name", "updated_at"}, // columns to update
|
||||
"", // no WHERE clause
|
||||
user,
|
||||
)
|
||||
|
||||
// Upsert with conditional update
|
||||
err := db.Upsert(
|
||||
"users",
|
||||
[]string{"id"},
|
||||
[]string{"name", "email"},
|
||||
"updated_at < VALUES(updated_at)", // only update if newer
|
||||
users,
|
||||
)
|
||||
|
||||
// Batch upsert
|
||||
err := db.Upsert(
|
||||
"users",
|
||||
[]string{"email"},
|
||||
[]string{"name", "last_login"},
|
||||
"",
|
||||
[]User{{Email: "a@example.com", Name: "Alice"}, ...},
|
||||
)
|
||||
```
|
||||
|
||||
### UpsertContext
|
||||
```go
|
||||
func (db *Database) UpsertContext(ctx context.Context, table string, uniqueCols,
|
||||
updateCols []string, where string, data any) error
|
||||
```
|
||||
|
||||
Context-aware version of `Upsert()`.
|
||||
|
||||
## Execute Operations
|
||||
|
||||
### Exec
|
||||
```go
|
||||
func (db *Database) Exec(query string, params ...mysql.Params) error
|
||||
```
|
||||
|
||||
Execute query without returning results (UPDATE, DELETE, etc.). Uses write pool.
|
||||
|
||||
**Parameters:**
|
||||
- `query` - SQL query with `@@paramName` placeholders
|
||||
- `params` - Query parameters
|
||||
|
||||
**Returns:**
|
||||
- `error` - Execution error
|
||||
|
||||
**Example:**
|
||||
```go
|
||||
err := db.Exec("UPDATE `users` SET `active` = @@active WHERE `id` = @@id",
|
||||
mysql.Params{"active": 1, "id": 123})
|
||||
|
||||
err := db.Exec("DELETE FROM `users` WHERE last_login < @@cutoff",
|
||||
time.Now().Add(-365*24*time.Hour))
|
||||
```
|
||||
|
||||
### ExecContext
|
||||
```go
|
||||
func (db *Database) ExecContext(ctx context.Context, query string, params ...mysql.Params) error
|
||||
```
|
||||
|
||||
Context-aware version of `Exec()`.
|
||||
|
||||
### ExecResult
|
||||
```go
|
||||
func (db *Database) ExecResult(query string, params ...mysql.Params) (sql.Result, error)
|
||||
```
|
||||
|
||||
Execute query and return `sql.Result` for accessing `LastInsertId()` and `RowsAffected()`.
|
||||
|
||||
**Returns:**
|
||||
- `sql.Result` - Execution result
|
||||
- `error` - Execution error
|
||||
|
||||
**Example:**
|
||||
```go
|
||||
result, err := db.ExecResult("UPDATE `users` SET `name` = @@name WHERE `id` = @@id",
|
||||
mysql.Params{"name": "Alice", "id": 1})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
rowsAffected, _ := result.RowsAffected()
|
||||
log.Printf("Updated %d rows", rowsAffected)
|
||||
```
|
||||
|
||||
### ExecResultContext
|
||||
```go
|
||||
func (db *Database) ExecResultContext(ctx context.Context, query string,
|
||||
params ...mysql.Params) (sql.Result, error)
|
||||
```
|
||||
|
||||
Context-aware version of `ExecResult()`.
|
||||
|
||||
## Transaction Management
|
||||
|
||||
### GetOrCreateTxFromContext
|
||||
```go
|
||||
func GetOrCreateTxFromContext(ctx context.Context) (*sql.Tx, func() error, func(), error)
|
||||
```
|
||||
|
||||
Get existing transaction from context or create new one.
|
||||
|
||||
**Returns:**
|
||||
- `*sql.Tx` - Transaction instance
|
||||
- `func() error` - Commit function
|
||||
- `func()` - Cancel function (rolls back if not committed)
|
||||
- `error` - Transaction creation error
|
||||
|
||||
**Usage Pattern:**
|
||||
```go
|
||||
tx, commit, cancel, err := mysql.GetOrCreateTxFromContext(ctx)
|
||||
defer cancel() // Always safe to call - rolls back if commit() not called
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Store transaction in context
|
||||
ctx = mysql.NewContextWithTx(ctx, tx)
|
||||
|
||||
// Do database operations...
|
||||
|
||||
if err := commit(); err != nil {
|
||||
return err
|
||||
}
|
||||
```
|
||||
|
||||
### NewContextWithTx
|
||||
```go
|
||||
func NewContextWithTx(ctx context.Context, tx *sql.Tx) context.Context
|
||||
```
|
||||
|
||||
Store transaction in context for use by database operations.
|
||||
|
||||
### TxFromContext
|
||||
```go
|
||||
func TxFromContext(ctx context.Context) (*sql.Tx, bool)
|
||||
```
|
||||
|
||||
Retrieve transaction from context.
|
||||
|
||||
**Returns:**
|
||||
- `*sql.Tx` - Transaction if present
|
||||
- `bool` - `true` if transaction exists in context
|
||||
|
||||
## Context Management
|
||||
|
||||
### NewContext
|
||||
```go
|
||||
func NewContext(ctx context.Context, db *Database) context.Context
|
||||
```
|
||||
|
||||
Store database instance in context.
|
||||
|
||||
**Example:**
|
||||
```go
|
||||
ctx := mysql.NewContext(context.Background(), db)
|
||||
```
|
||||
|
||||
### NewContextWithFunc
|
||||
```go
|
||||
func NewContextWithFunc(ctx context.Context, f func() *Database) context.Context
|
||||
```
|
||||
|
||||
Store database factory function in context for lazy initialization.
|
||||
|
||||
**Example:**
|
||||
```go
|
||||
ctx := mysql.NewContextWithFunc(ctx, sync.OnceValue(func() *Database {
|
||||
db, err := mysql.New(...)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return db
|
||||
}))
|
||||
```
|
||||
|
||||
### FromContext
|
||||
```go
|
||||
func FromContext(ctx context.Context) *Database
|
||||
```
|
||||
|
||||
Retrieve database from context.
|
||||
|
||||
**Returns:**
|
||||
- `*Database` - Database instance or `nil` if not found
|
||||
|
||||
**Example:**
|
||||
```go
|
||||
db := mysql.FromContext(ctx)
|
||||
if db == nil {
|
||||
return errors.New("database not in context")
|
||||
}
|
||||
```
|
||||
|
||||
## Caching Configuration
|
||||
|
||||
### EnableRedis
|
||||
```go
|
||||
func (db *Database) EnableRedis(client *redis.Client)
|
||||
```
|
||||
|
||||
Enable Redis caching with distributed locking.
|
||||
|
||||
**Example:**
|
||||
```go
|
||||
redisClient := redis.NewClient(&redis.Options{
|
||||
Addr: "localhost:6379",
|
||||
})
|
||||
db.EnableRedis(redisClient)
|
||||
```
|
||||
|
||||
### EnableMemcache
|
||||
```go
|
||||
func (db *Database) EnableMemcache(client *memcache.Client)
|
||||
```
|
||||
|
||||
Enable Memcached caching.
|
||||
|
||||
**Example:**
|
||||
```go
|
||||
memcacheClient := memcache.New("localhost:11211")
|
||||
db.EnableMemcache(memcacheClient)
|
||||
```
|
||||
|
||||
### UseCache
|
||||
```go
|
||||
func (db *Database) UseCache(cache Cache)
|
||||
```
|
||||
|
||||
Use custom cache implementation.
|
||||
|
||||
**Examples:**
|
||||
```go
|
||||
// In-memory cache
|
||||
db.UseCache(mysql.NewWeakCache())
|
||||
|
||||
// Multi-level cache
|
||||
db.UseCache(mysql.NewMultiCache(
|
||||
mysql.NewWeakCache(), // L1: Local fast cache
|
||||
mysql.NewRedisCache(redisClient), // L2: Distributed cache
|
||||
))
|
||||
```
|
||||
|
||||
### NewWeakCache
|
||||
```go
|
||||
func NewWeakCache() *WeakCache
|
||||
```
|
||||
|
||||
Create in-memory cache with weak pointers (GC-managed).
|
||||
|
||||
### NewRedisCache
|
||||
```go
|
||||
func NewRedisCache(client *redis.Client) *RedisCache
|
||||
```
|
||||
|
||||
Create Redis cache with distributed locking support.
|
||||
|
||||
### NewMultiCache
|
||||
```go
|
||||
func NewMultiCache(caches ...Cache) *MultiCache
|
||||
```
|
||||
|
||||
Create layered cache that checks caches in order.
|
||||
|
||||
## Parameter Interpolation
|
||||
|
||||
### InterpolateParams
|
||||
```go
|
||||
func (db *Database) InterpolateParams(query string, params ...mysql.Params) (string, []any, error)
|
||||
```
|
||||
|
||||
Manually interpolate parameters in query. Useful for debugging or logging.
|
||||
|
||||
**Parameters:**
|
||||
- `query` - Query with `@@paramName` placeholders
|
||||
- `params` - Parameters to interpolate
|
||||
|
||||
**Returns:**
|
||||
- `string` - Query with `?` placeholders
|
||||
- `[]any` - Normalized parameter values
|
||||
- `error` - Interpolation error
|
||||
|
||||
**Example:**
|
||||
```go
|
||||
replacedQuery, normalizedParams, err := db.InterpolateParams(
|
||||
"SELECT `id`, `name`, `email`, `age`, `active`, `created_at`, `updated_at` FROM `users` WHERE `id` = @@id",
|
||||
mysql.Params{"id": 1},
|
||||
)
|
||||
// replacedQuery: "SELECT `id`, `name`, `email`, `age`, `active`, `created_at`, `updated_at` FROM `users` WHERE `id` = ?"
|
||||
// normalizedParams: []any{1}
|
||||
```
|
||||
|
||||
## Template Functions
|
||||
|
||||
### AddTemplateFuncs
|
||||
```go
|
||||
func (db *Database) AddTemplateFuncs(funcs template.FuncMap)
|
||||
```
|
||||
|
||||
Add custom functions available in query templates.
|
||||
|
||||
**Example:**
|
||||
```go
|
||||
db.AddTemplateFuncs(template.FuncMap{
|
||||
"upper": strings.ToUpper,
|
||||
"lower": strings.ToLower,
|
||||
})
|
||||
|
||||
db.Select(&users,
|
||||
"SELECT `id`, `name`, `email`, `age`, `active`, `created_at`, `updated_at` FROM `users` WHERE `name` = @@name{{ if .UpperCase }} COLLATE utf8mb4_bin{{ end }}",
|
||||
0,
|
||||
mysql.Params{"name": "alice", "upperCase": true})
|
||||
```
|
||||
|
||||
## Special Types
|
||||
|
||||
### Params
|
||||
```go
|
||||
type Params map[string]any
|
||||
```
|
||||
|
||||
Parameter map for query placeholders.
|
||||
|
||||
### Raw
|
||||
```go
|
||||
type Raw string
|
||||
```
|
||||
|
||||
Literal SQL that won't be escaped. **Use with caution - SQL injection risk.**
|
||||
|
||||
**Example:**
|
||||
```go
|
||||
db.Select(&users, "SELECT `id`, `name`, `email`, `age`, `active`, `created_at`, `updated_at` FROM `users` WHERE @@condition", 0,
|
||||
mysql.Params{
|
||||
"condition": mysql.Raw("created_at > NOW() - INTERVAL 1 DAY"),
|
||||
})
|
||||
```
|
||||
|
||||
### MapRow / SliceRow / MapRows / SliceRows
|
||||
```go
|
||||
type MapRow map[string]any
|
||||
type SliceRow []any
|
||||
type MapRows []map[string]any
|
||||
type SliceRows [][]any
|
||||
```
|
||||
|
||||
Flexible result types when struct mapping isn't needed.
|
||||
|
||||
**Example:**
|
||||
```go
|
||||
var rows mysql.MapRows
|
||||
db.Select(&rows, "SELECT `id`, name FROM `users`", 0)
|
||||
for _, row := range rows {
|
||||
fmt.Printf("ID: %v, Name: %v\n", row["id"], row["name"])
|
||||
}
|
||||
```
|
||||
|
||||
## Custom Interfaces
|
||||
|
||||
### Zeroer
|
||||
```go
|
||||
type Zeroer interface {
|
||||
IsZero() bool
|
||||
}
|
||||
```
|
||||
|
||||
Implement for custom zero-value detection with `defaultzero` tag.
|
||||
|
||||
**Example:**
|
||||
```go
|
||||
type CustomTime struct {
|
||||
time.Time
|
||||
}
|
||||
|
||||
func (ct CustomTime) IsZero() bool {
|
||||
return ct.Time.IsZero() || ct.Time.Unix() == 0
|
||||
}
|
||||
```
|
||||
|
||||
### Valueser
|
||||
```go
|
||||
type Valueser interface {
|
||||
Values() []any
|
||||
}
|
||||
```
|
||||
|
||||
Implement for custom value conversion during inserts.
|
||||
|
||||
**Example:**
|
||||
```go
|
||||
type Point struct {
|
||||
X, Y float64
|
||||
}
|
||||
|
||||
func (p Point) Values() []any {
|
||||
return []any{p.X, p.Y}
|
||||
}
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
### Automatic Retries
|
||||
|
||||
cool-mysql automatically retries these MySQL error codes:
|
||||
- `1213` - Deadlock detected
|
||||
- `1205` - Lock wait timeout exceeded
|
||||
- `2006` - MySQL server has gone away
|
||||
- `2013` - Lost connection to MySQL server during query
|
||||
|
||||
Retry behavior uses exponential backoff and can be configured with `COOL_MAX_ATTEMPTS` environment variable.
|
||||
|
||||
### sql.ErrNoRows
|
||||
|
||||
- **Single value/struct queries**: Returns `sql.ErrNoRows` when no results
|
||||
- **Slice queries**: Returns empty slice (not `sql.ErrNoRows`)
|
||||
|
||||
**Example:**
|
||||
```go
|
||||
var name string
|
||||
err := db.Select(&name, "SELECT `name` FROM `users` WHERE `id` = @@id", 0,
|
||||
999)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
// Handle not found
|
||||
}
|
||||
|
||||
var users []User
|
||||
err := db.Select(&users, "SELECT `id`, `name`, `email`, `age`, `active`, `created_at`, `updated_at` FROM `users` WHERE `id` = @@id", 0,
|
||||
999)
|
||||
// err is nil, users is empty slice []
|
||||
```
|
||||
700
references/caching-guide.md
Normal file
700
references/caching-guide.md
Normal file
@@ -0,0 +1,700 @@
|
||||
# Caching Guide
|
||||
|
||||
Complete guide to caching strategies and configuration in cool-mysql.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Caching Overview](#caching-overview)
|
||||
2. [Cache Types](#cache-types)
|
||||
3. [Cache Configuration](#cache-configuration)
|
||||
4. [TTL Selection](#ttl-selection)
|
||||
5. [Multi-Level Caching](#multi-level-caching)
|
||||
6. [Cache Invalidation](#cache-invalidation)
|
||||
7. [Distributed Locking](#distributed-locking)
|
||||
8. [Performance Optimization](#performance-optimization)
|
||||
9. [Best Practices](#best-practices)
|
||||
|
||||
## Caching Overview
|
||||
|
||||
cool-mysql supports pluggable caching for SELECT queries to reduce database load and improve response times.
|
||||
|
||||
### How Caching Works
|
||||
|
||||
1. **Cache Key Generation**: Automatically generated from query + parameters
|
||||
2. **Cache Check**: Before executing query, check cache for existing result
|
||||
3. **Cache Miss**: Execute query and store result with TTL
|
||||
4. **Cache Hit**: Return cached result without database query
|
||||
|
||||
### What Gets Cached
|
||||
|
||||
- **Cached**: All SELECT queries with `cacheTTL > 0`
|
||||
- **Not Cached**: INSERT, UPDATE, DELETE, EXEC operations
|
||||
- **Not Cached**: SELECT queries with `cacheTTL = 0`
|
||||
|
||||
### Cache Behavior
|
||||
|
||||
```go
|
||||
// No caching (TTL = 0)
|
||||
err := db.Select(&users, "SELECT `id`, `name`, `email`, `age`, `active`, `created_at`, `updated_at` FROM `users`", 0)
|
||||
|
||||
// Cache for 5 minutes
|
||||
err := db.Select(&users, "SELECT `id`, `name`, `email`, `age`, `active`, `created_at`, `updated_at` FROM `users`", 5*time.Minute)
|
||||
|
||||
// Cache for 1 hour
|
||||
err := db.Select(&users, "SELECT `id`, `name`, `email`, `age`, `active`, `created_at`, `updated_at` FROM `users` WHERE `active` = 1", time.Hour)
|
||||
```
|
||||
|
||||
## Cache Types
|
||||
|
||||
### 1. In-Memory Weak Cache
|
||||
|
||||
**Type**: Local, process-specific, GC-managed
|
||||
**Use Case**: Single-server applications, development, testing
|
||||
|
||||
**Characteristics:**
|
||||
- Fastest access (no network)
|
||||
- Memory managed by Go GC
|
||||
- Weak pointers - automatically freed when under memory pressure
|
||||
- Not shared across processes
|
||||
- Lost on restart
|
||||
|
||||
**Setup:**
|
||||
```go
|
||||
db.UseCache(mysql.NewWeakCache())
|
||||
```
|
||||
|
||||
**Pros:**
|
||||
- Zero configuration
|
||||
- No external dependencies
|
||||
- Automatic memory management
|
||||
- Extremely fast
|
||||
|
||||
**Cons:**
|
||||
- Not shared across servers
|
||||
- No distributed locking
|
||||
- Cache lost on restart
|
||||
- Memory limited
|
||||
|
||||
**Best For:**
|
||||
- Development
|
||||
- Testing
|
||||
- Single-server deployments
|
||||
- Applications with low cache requirements
|
||||
|
||||
### 2. Redis Cache
|
||||
|
||||
**Type**: Distributed, persistent
|
||||
**Use Case**: Multi-server deployments, high-traffic applications
|
||||
|
||||
**Characteristics:**
|
||||
- Shared across all application instances
|
||||
- Distributed locking to prevent cache stampedes
|
||||
- Configurable persistence
|
||||
- Network latency overhead
|
||||
- Requires Redis server
|
||||
|
||||
**Setup:**
|
||||
```go
|
||||
import "github.com/redis/go-redis/v9"
|
||||
|
||||
redisClient := redis.NewClient(&redis.Options{
|
||||
Addr: "localhost:6379",
|
||||
Password: "", // no password set
|
||||
DB: 0, // use default DB
|
||||
})
|
||||
|
||||
db.EnableRedis(redisClient)
|
||||
```
|
||||
|
||||
**Pros:**
|
||||
- Shared cache across servers
|
||||
- Distributed locking
|
||||
- Persistent (optional)
|
||||
- High capacity
|
||||
- Cache stampede prevention
|
||||
|
||||
**Cons:**
|
||||
- Network latency
|
||||
- Requires Redis infrastructure
|
||||
- More complex setup
|
||||
|
||||
**Best For:**
|
||||
- Production multi-server deployments
|
||||
- High-traffic applications
|
||||
- Applications requiring cache consistency
|
||||
- Preventing thundering herd problems
|
||||
|
||||
### 3. Memcached Cache
|
||||
|
||||
**Type**: Distributed, volatile
|
||||
**Use Case**: Multi-server deployments, simple caching needs
|
||||
|
||||
**Characteristics:**
|
||||
- Shared across all application instances
|
||||
- No persistence
|
||||
- Simple protocol
|
||||
- No distributed locking
|
||||
- Requires Memcached server
|
||||
|
||||
**Setup:**
|
||||
```go
|
||||
import "github.com/bradfitz/gomemcache/memcache"
|
||||
|
||||
memcacheClient := memcache.New("localhost:11211")
|
||||
db.EnableMemcache(memcacheClient)
|
||||
```
|
||||
|
||||
**Pros:**
|
||||
- Shared cache across servers
|
||||
- Simple and fast
|
||||
- Mature technology
|
||||
- Good performance
|
||||
|
||||
**Cons:**
|
||||
- No distributed locking
|
||||
- No persistence
|
||||
- Cache lost on restart
|
||||
- No cache stampede prevention
|
||||
|
||||
**Best For:**
|
||||
- Legacy infrastructure with Memcached
|
||||
- Simple caching needs
|
||||
- When distributed locking not required
|
||||
|
||||
## Cache Configuration
|
||||
|
||||
### Basic Setup
|
||||
|
||||
```go
|
||||
// In-memory cache
|
||||
db := mysql.New(...)
|
||||
db.UseCache(mysql.NewWeakCache())
|
||||
|
||||
// Redis cache
|
||||
redisClient := redis.NewClient(&redis.Options{
|
||||
Addr: "localhost:6379",
|
||||
})
|
||||
db.EnableRedis(redisClient)
|
||||
|
||||
// Memcached
|
||||
memcacheClient := memcache.New("localhost:11211")
|
||||
db.EnableMemcache(memcacheClient)
|
||||
```
|
||||
|
||||
### Redis Advanced Configuration
|
||||
|
||||
```go
|
||||
redisClient := redis.NewClient(&redis.Options{
|
||||
Addr: "localhost:6379",
|
||||
Password: "secret",
|
||||
DB: 0,
|
||||
DialTimeout: 5 * time.Second,
|
||||
ReadTimeout: 3 * time.Second,
|
||||
WriteTimeout: 3 * time.Second,
|
||||
PoolSize: 10,
|
||||
MinIdleConns: 5,
|
||||
})
|
||||
|
||||
db.EnableRedis(redisClient)
|
||||
```
|
||||
|
||||
### Redis Cluster
|
||||
|
||||
```go
|
||||
redisClient := redis.NewClusterClient(&redis.ClusterOptions{
|
||||
Addrs: []string{
|
||||
"localhost:7000",
|
||||
"localhost:7001",
|
||||
"localhost:7002",
|
||||
},
|
||||
})
|
||||
|
||||
db.EnableRedis(redisClient)
|
||||
```
|
||||
|
||||
### Environment Configuration
|
||||
|
||||
Configure cache behavior via environment variables:
|
||||
|
||||
```bash
|
||||
# Redis lock retry delay (default: 0.020 seconds)
|
||||
export COOL_REDIS_LOCK_RETRY_DELAY=0.050
|
||||
|
||||
# Max query execution time (default: 27 seconds)
|
||||
export COOL_MAX_EXECUTION_TIME_TIME=30s
|
||||
|
||||
# Max retry attempts (default: unlimited)
|
||||
export COOL_MAX_ATTEMPTS=5
|
||||
```
|
||||
|
||||
## TTL Selection
|
||||
|
||||
### TTL Guidelines
|
||||
|
||||
Choose TTL based on data volatility and access patterns:
|
||||
|
||||
| Data Type | Recommended TTL | Rationale |
|
||||
|-----------|----------------|-----------|
|
||||
| User sessions | 5-15 minutes | Frequently changing |
|
||||
| Reference data | 1-24 hours | Rarely changing |
|
||||
| Analytics/Reports | 15-60 minutes | Tolerates staleness |
|
||||
| Real-time data | 0 (no cache) | Must be fresh |
|
||||
| Configuration | 5-60 minutes | Infrequent changes |
|
||||
| Search results | 1-5 minutes | Balance freshness/load |
|
||||
| Product catalogs | 10-30 minutes | Moderate change rate |
|
||||
|
||||
### Dynamic TTL Selection
|
||||
|
||||
```go
|
||||
// Choose TTL based on query type
|
||||
func getCacheTTL(queryType string) time.Duration {
|
||||
switch queryType {
|
||||
case "user_profile":
|
||||
return 10 * time.Minute
|
||||
case "product_catalog":
|
||||
return 30 * time.Minute
|
||||
case "analytics":
|
||||
return time.Hour
|
||||
case "real_time":
|
||||
return 0 // No caching
|
||||
default:
|
||||
return 5 * time.Minute
|
||||
}
|
||||
}
|
||||
|
||||
var users []User
|
||||
err := db.Select(&users,
|
||||
"SELECT `id`, `name`, `email`, `age`, `active`, `created_at`, `updated_at` FROM `users` WHERE `active` = 1",
|
||||
getCacheTTL("user_profile"))
|
||||
```
|
||||
|
||||
### Conditional TTL
|
||||
|
||||
```go
|
||||
// Cache differently based on result size
|
||||
var users []User
|
||||
err := db.Select(&users, "SELECT `id`, `name`, `email`, `age`, `active`, `created_at`, `updated_at` FROM `users` WHERE `status` = @@status", 0,
|
||||
status)
|
||||
|
||||
ttl := 5 * time.Minute
|
||||
if len(users) > 1000 {
|
||||
// Large result set - cache longer to reduce load
|
||||
ttl = 30 * time.Minute
|
||||
}
|
||||
|
||||
// Re-query with caching
|
||||
err = db.Select(&users, "SELECT `id`, `name`, `email`, `age`, `active`, `created_at`, `updated_at` FROM `users` WHERE `status` = @@status", ttl,
|
||||
status)
|
||||
```
|
||||
|
||||
## Multi-Level Caching
|
||||
|
||||
### Layered Cache Strategy
|
||||
|
||||
Combine fast local cache with shared distributed cache:
|
||||
|
||||
```go
|
||||
db.UseCache(mysql.NewMultiCache(
|
||||
mysql.NewWeakCache(), // L1: Fast local cache
|
||||
mysql.NewRedisCache(redisClient), // L2: Shared distributed cache
|
||||
))
|
||||
```
|
||||
|
||||
**How It Works:**
|
||||
1. Check L1 (local weak cache) - fastest
|
||||
2. If miss, check L2 (Redis) - shared
|
||||
3. If miss, query database
|
||||
4. Store result in both L1 and L2
|
||||
|
||||
**Benefits:**
|
||||
- Extremely fast for repeated queries in same process
|
||||
- Shared cache prevents duplicate work across servers
|
||||
- Best of both worlds: speed + consistency
|
||||
|
||||
### Custom Multi-Level Configuration
|
||||
|
||||
```go
|
||||
// Create custom cache layers
|
||||
type CustomCache struct {
|
||||
layers []mysql.Cache
|
||||
}
|
||||
|
||||
func (c *CustomCache) Get(key string) ([]byte, bool) {
|
||||
for _, layer := range c.layers {
|
||||
if val, ok := layer.Get(key); ok {
|
||||
// Backfill previous layers
|
||||
for _, prevLayer := range c.layers {
|
||||
if prevLayer == layer {
|
||||
break
|
||||
}
|
||||
prevLayer.Set(key, val, 0)
|
||||
}
|
||||
return val, true
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
func (c *CustomCache) Set(key string, val []byte, ttl time.Duration) {
|
||||
for _, layer := range c.layers {
|
||||
layer.Set(key, val, ttl)
|
||||
}
|
||||
}
|
||||
|
||||
// Use custom cache
|
||||
cache := &CustomCache{
|
||||
layers: []mysql.Cache{
|
||||
mysql.NewWeakCache(),
|
||||
mysql.NewRedisCache(redis1),
|
||||
mysql.NewRedisCache(redis2), // Backup Redis
|
||||
},
|
||||
}
|
||||
db.UseCache(cache)
|
||||
```
|
||||
|
||||
## Cache Invalidation
|
||||
|
||||
### Automatic Invalidation
|
||||
|
||||
cool-mysql doesn't auto-invalidate on writes. You must handle invalidation explicitly.
|
||||
|
||||
### Handling Cache After Writes
|
||||
|
||||
**Note:** Cache keys are generated internally by `cool-mysql` using SHA256 hashing and are not exposed as a public API. You cannot manually invalidate specific cache entries.
|
||||
|
||||
#### Recommended Pattern: Use SelectWrites
|
||||
|
||||
```go
|
||||
// Write to database
|
||||
err := db.Insert("users", user)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Read from write pool for immediate consistency
|
||||
err = db.SelectWrites(&user,
|
||||
"SELECT `id`, `name`, `email`, `age`, `active`, `created_at`, `updated_at` FROM `users` WHERE `id` = @@id",
|
||||
0, // Don't cache write-pool reads
|
||||
user.ID)
|
||||
```
|
||||
|
||||
#### 3. Tag-Based Invalidation
|
||||
|
||||
```go
|
||||
// Tag queries with invalidation keys
|
||||
const userCacheTag = "users:all"
|
||||
|
||||
// Set cache with tag
|
||||
err := db.Select(&users, "SELECT `id`, `name`, `email`, `age`, `active`, `created_at`, `updated_at` FROM `users`", 10*time.Minute)
|
||||
redisClient.SAdd(ctx, userCacheTag, cacheKey)
|
||||
|
||||
// Invalidate all user queries
|
||||
keys, _ := redisClient.SMembers(ctx, userCacheTag).Result()
|
||||
redisClient.Del(ctx, keys...)
|
||||
redisClient.Del(ctx, userCacheTag)
|
||||
```
|
||||
|
||||
#### 4. TTL-Based Invalidation
|
||||
|
||||
```go
|
||||
// Rely on short TTL for eventual consistency
|
||||
err := db.Select(&users, "SELECT `id`, `name`, `email`, `age`, `active`, `created_at`, `updated_at` FROM `users`",
|
||||
30*time.Second) // Short TTL = frequent refresh
|
||||
```
|
||||
|
||||
### Cache Invalidation Strategies
|
||||
|
||||
| Strategy | Pros | Cons | Best For |
|
||||
|----------|------|------|----------|
|
||||
| Manual invalidation | Precise control | Complex to implement | Critical data |
|
||||
| SelectWrites | Simple, consistent | Bypasses read pool | Read-after-write |
|
||||
| Short TTL | Simple, automatic | Higher DB load | Frequently changing data |
|
||||
| Tag-based | Bulk invalidation | Requires Redis | Related queries |
|
||||
|
||||
## Distributed Locking
|
||||
|
||||
### Cache Stampede Problem
|
||||
|
||||
When cache expires on high-traffic query:
|
||||
1. Multiple requests see cache miss
|
||||
2. All execute same expensive query simultaneously
|
||||
3. Database overload
|
||||
|
||||
### Redis Distributed Locking Solution
|
||||
|
||||
cool-mysql's Redis cache includes distributed locking:
|
||||
|
||||
```go
|
||||
db.EnableRedis(redisClient)
|
||||
|
||||
// Automatic distributed locking
|
||||
err := db.Select(&users, "SELECT `id`, `name`, `email`, `age`, `active`, `created_at`, `updated_at` FROM `users` WHERE `active` = 1", 10*time.Minute)
|
||||
```
|
||||
|
||||
**How It Works:**
|
||||
1. First request gets lock, executes query
|
||||
2. Subsequent requests wait for lock
|
||||
3. First request populates cache
|
||||
4. Waiting requests get result from cache
|
||||
5. Lock automatically released
|
||||
|
||||
### Lock Configuration
|
||||
|
||||
```bash
|
||||
# Configure lock retry delay
|
||||
export COOL_REDIS_LOCK_RETRY_DELAY=0.020 # 20ms between retries
|
||||
```
|
||||
|
||||
### Without Distributed Locking (Memcached)
|
||||
|
||||
Memcached doesn't support distributed locking. Mitigate stampedes with:
|
||||
|
||||
1. **Probabilistic Early Expiration**
|
||||
```go
|
||||
// Refresh cache before expiration
|
||||
func shouldRefresh(ttl time.Duration) bool {
|
||||
// Refresh 10% of requests in last 10% of TTL
|
||||
return rand.Float64() < 0.1
|
||||
}
|
||||
```
|
||||
|
||||
2. **Stale-While-Revalidate**
|
||||
```go
|
||||
// Serve stale data while refreshing
|
||||
// (Requires custom cache implementation)
|
||||
```
|
||||
|
||||
## Performance Optimization
|
||||
|
||||
### Cache Hit Rate Monitoring
|
||||
|
||||
```go
|
||||
type CacheStats struct {
|
||||
Hits int64
|
||||
Misses int64
|
||||
}
|
||||
|
||||
var stats CacheStats
|
||||
|
||||
// Wrap cache to track stats
|
||||
type StatsCache struct {
|
||||
underlying mysql.Cache
|
||||
stats *CacheStats
|
||||
}
|
||||
|
||||
func (c *StatsCache) Get(key string) ([]byte, bool) {
|
||||
val, ok := c.underlying.Get(key)
|
||||
if ok {
|
||||
atomic.AddInt64(&c.stats.Hits, 1)
|
||||
} else {
|
||||
atomic.AddInt64(&c.stats.Misses, 1)
|
||||
}
|
||||
return val, ok
|
||||
}
|
||||
|
||||
// Use stats cache
|
||||
statsCache := &StatsCache{
|
||||
underlying: mysql.NewRedisCache(redisClient),
|
||||
stats: &stats,
|
||||
}
|
||||
db.UseCache(statsCache)
|
||||
|
||||
// Check cache performance
|
||||
hitRate := float64(stats.Hits) / float64(stats.Hits + stats.Misses)
|
||||
fmt.Printf("Cache hit rate: %.2f%%\n", hitRate*100)
|
||||
```
|
||||
|
||||
### Optimizing Cache Keys
|
||||
|
||||
cool-mysql generates cache keys from query + parameters. Optimize by:
|
||||
|
||||
1. **Normalizing Queries**
|
||||
```go
|
||||
// BAD: Different queries, same intent
|
||||
db.Select(&users, "SELECT * FROM `users` WHERE `id` = @@id", ttl, params)
|
||||
db.Select(&users, "SELECT `id`, `name`, `email`, `age`, `active`, `created_at`, `updated_at` FROM `users` WHERE `id` = @@id", ttl, params)
|
||||
// ^ Different cache keys due to whitespace
|
||||
|
||||
// GOOD: Consistent formatting
|
||||
const userByIDQuery = "SELECT `id`, `name`, `email`, `age`, `active`, `created_at`, `updated_at` FROM `users` WHERE `id` = @@id"
|
||||
db.Select(&users, userByIDQuery, ttl, params)
|
||||
```
|
||||
|
||||
2. **Parameter Ordering**
|
||||
```go
|
||||
// Parameter order doesn't matter - they're normalized
|
||||
db.Select(&users, query, ttl,
|
||||
mysql.Params{"status": "active", "age": 18})
|
||||
db.Select(&users, query, ttl,
|
||||
mysql.Params{"age": 18, "status": "active"})
|
||||
// ^ Same cache key
|
||||
```
|
||||
|
||||
### Memory Usage Optimization
|
||||
|
||||
```go
|
||||
// For memory-constrained environments
|
||||
// Use shorter TTLs to reduce memory usage
|
||||
db.UseCache(mysql.NewWeakCache())
|
||||
err := db.Select(&users, "SELECT `id`, `name`, `email`, `age`, `active`, `created_at`, `updated_at` FROM `users`",
|
||||
1*time.Minute) // Short TTL = less memory
|
||||
|
||||
// Or use Redis with maxmemory policy
|
||||
// redis.conf:
|
||||
// maxmemory 100mb
|
||||
// maxmemory-policy allkeys-lru
|
||||
```
|
||||
|
||||
### Network Latency Optimization
|
||||
|
||||
```go
|
||||
// Minimize Redis roundtrips with pipelining
|
||||
// (Requires custom cache implementation)
|
||||
|
||||
// Or use MultiCache for local-first
|
||||
db.UseCache(mysql.NewMultiCache(
|
||||
mysql.NewWeakCache(), // Fast local first
|
||||
mysql.NewRedisCache(redisClient), // Fallback to Redis
|
||||
))
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### 1. Match TTL to Data Volatility
|
||||
|
||||
```go
|
||||
// Frequently changing - short TTL or no cache
|
||||
db.Select(&liveData, query, 0)
|
||||
|
||||
// Rarely changing - long TTL
|
||||
db.Select(&refData, query, 24*time.Hour)
|
||||
```
|
||||
|
||||
### 2. Use SelectWrites After Writes
|
||||
|
||||
```go
|
||||
// Write
|
||||
db.Insert("users", user)
|
||||
|
||||
// Read with consistency
|
||||
db.SelectWrites(&user, query, 0, params)
|
||||
```
|
||||
|
||||
### 3. Cache High-Traffic Queries
|
||||
|
||||
```go
|
||||
// Identify expensive queries
|
||||
// Use longer TTLs for high-traffic, expensive queries
|
||||
db.Select(&results, expensiveQuery, 30*time.Minute)
|
||||
```
|
||||
|
||||
### 4. Don't Over-Cache
|
||||
|
||||
```go
|
||||
// Don't cache everything - adds complexity
|
||||
// Only cache queries that benefit from caching:
|
||||
// - Expensive to compute
|
||||
// - Frequently accessed
|
||||
// - Tolerates staleness
|
||||
```
|
||||
|
||||
### 5. Monitor Cache Performance
|
||||
|
||||
```go
|
||||
// Track hit rates
|
||||
// Tune TTLs based on metrics
|
||||
// Remove caching from low-hit-rate queries
|
||||
```
|
||||
|
||||
### 6. Use MultiCache for Best Performance
|
||||
|
||||
```go
|
||||
// Production setup
|
||||
db.UseCache(mysql.NewMultiCache(
|
||||
mysql.NewWeakCache(), // L1: Fast
|
||||
mysql.NewRedisCache(redisClient), // L2: Shared
|
||||
))
|
||||
```
|
||||
|
||||
### 7. Handle Cache Failures Gracefully
|
||||
|
||||
```go
|
||||
// Cache failures should fallback to database
|
||||
// cool-mysql handles this automatically
|
||||
// Even if Redis is down, queries still work
|
||||
```
|
||||
|
||||
### 8. Consider Cache Warming
|
||||
|
||||
```go
|
||||
// Pre-populate cache for known hot queries
|
||||
func warmCache(db *mysql.Database) {
|
||||
db.Select(&refData, "SELECT `id`, `name`, `code` FROM `countries`", 24*time.Hour)
|
||||
db.Select(&config, "SELECT `key`, `value` FROM `config`", time.Hour)
|
||||
}
|
||||
```
|
||||
|
||||
### 9. Use Appropriate Cache for Environment
|
||||
|
||||
```go
|
||||
// Development
|
||||
db.UseCache(mysql.NewWeakCache())
|
||||
|
||||
// Production
|
||||
db.EnableRedis(redisClient) // Distributed locking + sharing
|
||||
```
|
||||
|
||||
### 10. Document Cache TTLs
|
||||
|
||||
```go
|
||||
const (
|
||||
// Cache TTLs
|
||||
UserProfileTTL = 10 * time.Minute // User data changes moderately
|
||||
ProductCatalogTTL = 30 * time.Minute // Products updated infrequently
|
||||
AnalyticsTTL = time.Hour // Analytics can be stale
|
||||
NoCache = 0 // Real-time data
|
||||
)
|
||||
|
||||
db.Select(&user, query, UserProfileTTL, params)
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### High Cache Miss Rate
|
||||
|
||||
**Symptoms**: Low hit rate, high database load
|
||||
|
||||
**Solutions:**
|
||||
- Increase TTL
|
||||
- Check if queries are identical (whitespace, parameter names)
|
||||
- Verify cache is configured correctly
|
||||
- Check if queries are actually repeated
|
||||
|
||||
### Cache Stampede
|
||||
|
||||
**Symptoms**: Periodic database spikes, slow response during cache expiration
|
||||
|
||||
**Solutions:**
|
||||
- Use Redis with distributed locking
|
||||
- Implement probabilistic early refresh
|
||||
- Increase TTL to reduce expiration frequency
|
||||
|
||||
### Memory Issues
|
||||
|
||||
**Symptoms**: High memory usage, OOM errors
|
||||
|
||||
**Solutions:**
|
||||
- Reduce TTLs
|
||||
- Use Redis instead of in-memory
|
||||
- Configure Redis maxmemory policy
|
||||
- Cache fewer queries
|
||||
|
||||
### Stale Data
|
||||
|
||||
**Symptoms**: Users see outdated information
|
||||
|
||||
**Solutions:**
|
||||
- Reduce TTL
|
||||
- Use SelectWrites after modifications
|
||||
- Implement cache invalidation
|
||||
- Consider if data should be cached at all
|
||||
794
references/query-patterns.md
Normal file
794
references/query-patterns.md
Normal file
@@ -0,0 +1,794 @@
|
||||
# Query Patterns Guide
|
||||
|
||||
Practical examples and patterns for common cool-mysql query scenarios.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Basic SELECT Patterns](#basic-select-patterns)
|
||||
2. [Named Parameters](#named-parameters)
|
||||
3. [Template Syntax](#template-syntax)
|
||||
4. [Result Mapping](#result-mapping)
|
||||
5. [Streaming with Channels](#streaming-with-channels)
|
||||
6. [Function Receivers](#function-receivers)
|
||||
7. [JSON Handling](#json-handling)
|
||||
8. [Complex Queries](#complex-queries)
|
||||
9. [Raw SQL](#raw-sql)
|
||||
|
||||
## Basic SELECT Patterns
|
||||
|
||||
### Select into Struct Slice
|
||||
|
||||
```go
|
||||
type User struct {
|
||||
ID int `mysql:"id"`
|
||||
Name string `mysql:"name"`
|
||||
Email string `mysql:"email"`
|
||||
CreatedAt time.Time `mysql:"created_at"`
|
||||
}
|
||||
|
||||
var users []User
|
||||
err := db.Select(&users,
|
||||
"SELECT `id`, `name`, `email`, created_at FROM `users` WHERE age > @@minAge",
|
||||
5*time.Minute, // Cache for 5 minutes
|
||||
18)
|
||||
```
|
||||
|
||||
### Select Single Struct
|
||||
|
||||
```go
|
||||
var user User
|
||||
err := db.Select(&user,
|
||||
"SELECT `id`, `name`, `email`, `age`, `active`, `created_at`, `updated_at` FROM `users` WHERE `id` = @@id",
|
||||
0, // No caching
|
||||
123)
|
||||
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
// User not found
|
||||
return fmt.Errorf("user not found")
|
||||
}
|
||||
```
|
||||
|
||||
### Select Single Value
|
||||
|
||||
```go
|
||||
// String value
|
||||
var name string
|
||||
err := db.Select(&name,
|
||||
"SELECT `name` FROM `users` WHERE `id` = @@id",
|
||||
0,
|
||||
123)
|
||||
|
||||
// Integer value
|
||||
var count int
|
||||
err := db.Select(&count,
|
||||
"SELECT COUNT(*) FROM `users` WHERE `active` = @@active",
|
||||
0,
|
||||
1)
|
||||
|
||||
// Time value
|
||||
var lastLogin time.Time
|
||||
err := db.Select(&lastLogin,
|
||||
"SELECT last_login FROM `users` WHERE `id` = @@id",
|
||||
0,
|
||||
123)
|
||||
```
|
||||
|
||||
### Select Multiple Values (First Row Only)
|
||||
|
||||
```go
|
||||
type UserInfo struct {
|
||||
Name string
|
||||
Email string
|
||||
Age int
|
||||
}
|
||||
|
||||
var info UserInfo
|
||||
err := db.Select(&info,
|
||||
"SELECT name, `email`, `age` FROM `users` WHERE `id` = @@id",
|
||||
0,
|
||||
123)
|
||||
```
|
||||
|
||||
## Named Parameters
|
||||
|
||||
### Basic Parameter Usage
|
||||
|
||||
```go
|
||||
// Simple parameters
|
||||
err := db.Select(&users,
|
||||
"SELECT `id`, `name`, `email`, `age`, `active`, `created_at`, `updated_at` FROM `users` WHERE age > @@minAge AND `status` = @@status",
|
||||
0,
|
||||
mysql.Params{"minAge": 18, "status": "active"})
|
||||
```
|
||||
|
||||
### Struct as Parameters
|
||||
|
||||
```go
|
||||
// Use struct fields as parameters
|
||||
filter := struct {
|
||||
MinAge int
|
||||
Status string
|
||||
City string
|
||||
}{
|
||||
MinAge: 18,
|
||||
Status: "active",
|
||||
City: "New York",
|
||||
}
|
||||
|
||||
err := db.Select(&users,
|
||||
"SELECT `id`, `name`, `email`, `age`, `active`, `created_at`, `updated_at` FROM `users` WHERE age > @@MinAge AND `status` = @@Status AND city = @@City",
|
||||
0,
|
||||
filter)
|
||||
```
|
||||
|
||||
### Multiple Parameter Sources
|
||||
|
||||
```go
|
||||
// Parameters are merged (last wins for duplicates)
|
||||
err := db.Select(&users,
|
||||
"SELECT `id`, `name`, `email`, `age`, `active`, `created_at`, `updated_at` FROM `users` WHERE age > @@minAge AND `status` = @@status AND city = @@city",
|
||||
0,
|
||||
mysql.Params{"minAge": 18, "status": "active"},
|
||||
mysql.Params{"city": "New York"},
|
||||
)
|
||||
```
|
||||
|
||||
### Parameter Reuse
|
||||
|
||||
```go
|
||||
// Same parameter used multiple times
|
||||
err := db.Select(&users,
|
||||
"SELECT `id`, `name`, `email`, `age`, `active`, `created_at`, `updated_at` FROM `users`"+
|
||||
" WHERE (`age` BETWEEN @@minAge AND @@maxAge)"+
|
||||
" AND (`created_at` > @@date OR `updated_at` > @@date)",
|
||||
0,
|
||||
mysql.Params{
|
||||
"minAge": 18,
|
||||
"maxAge": 65,
|
||||
"date": time.Now().Add(-7*24*time.Hour),
|
||||
})
|
||||
```
|
||||
|
||||
### Case-Insensitive Parameter Merging
|
||||
|
||||
```go
|
||||
// These parameters are treated as the same (normalized to lowercase)
|
||||
err := db.Select(&users,
|
||||
"SELECT `id`, `name`, `email`, `age`, `active`, `created_at`, `updated_at` FROM `users` WHERE `name` = @@userName",
|
||||
0,
|
||||
mysql.Params{"username": "Alice"}, // lowercase 'u'
|
||||
mysql.Params{"UserName": "Bob"}, // uppercase 'U' - this wins
|
||||
)
|
||||
// Effective parameter: "Bob"
|
||||
```
|
||||
|
||||
## Template Syntax
|
||||
|
||||
### Conditional Query Parts
|
||||
|
||||
```go
|
||||
// Add WHERE conditions dynamically
|
||||
params := mysql.Params{
|
||||
"minAge": 18,
|
||||
"status": "active",
|
||||
}
|
||||
|
||||
query := `
|
||||
SELECT `id`, `name`, `email`, `age`, `active`, `created_at`, `updated_at` FROM `users`
|
||||
WHERE 1=1
|
||||
{{ if .MinAge }}AND `age` > @@minAge{{ end }}
|
||||
{{ if .Status }}AND `status` = @@status{{ end }}
|
||||
`
|
||||
|
||||
var users []User
|
||||
err := db.Select(&users, query, 0, params)
|
||||
```
|
||||
|
||||
### Dynamic ORDER BY
|
||||
|
||||
```go
|
||||
// For dynamic ORDER BY, validate column names (identifiers can't be marshaled)
|
||||
type QueryParams struct {
|
||||
SortBy string
|
||||
SortOrder string
|
||||
}
|
||||
|
||||
// Whitelist allowed columns for safety
|
||||
allowedColumns := map[string]bool{
|
||||
"created_at": true,
|
||||
"name": true,
|
||||
"email": true,
|
||||
}
|
||||
|
||||
params := QueryParams{
|
||||
SortBy: "created_at",
|
||||
SortOrder: "DESC",
|
||||
}
|
||||
|
||||
// Validate before using
|
||||
if !allowedColumns[params.SortBy] {
|
||||
return errors.New("invalid sort column")
|
||||
}
|
||||
allowedOrders := map[string]bool{"ASC": true, "DESC": true}
|
||||
if !allowedOrders[params.SortOrder] {
|
||||
return errors.New("invalid sort order")
|
||||
}
|
||||
|
||||
// Now safe to inject validated identifiers
|
||||
query := `
|
||||
SELECT `id`, `name`, `email`, `age`, `active`, `created_at`, `updated_at` FROM `users`
|
||||
WHERE `active` = 1
|
||||
{{ if .SortBy }}
|
||||
ORDER BY {{ .SortBy }} {{ .SortOrder }}
|
||||
{{ end }}
|
||||
`
|
||||
|
||||
var users []User
|
||||
err := db.Select(&users, query, 0, params)
|
||||
```
|
||||
|
||||
### Conditional JOINs
|
||||
|
||||
```go
|
||||
type SearchParams struct {
|
||||
IncludeOrders bool
|
||||
IncludeAddress bool
|
||||
}
|
||||
|
||||
params := SearchParams{
|
||||
IncludeOrders: true,
|
||||
IncludeAddress: false,
|
||||
}
|
||||
|
||||
query := "SELECT `users`.`id`, `users`.`name`, `users`.`email`, `users`.`age`, `users`.`active`, `users`.`created_at`, `users`.`updated_at`" +
|
||||
" {{ if .IncludeOrders }}, subquery.order_count{{ end }}" +
|
||||
" {{ if .IncludeAddress }}, `addresses`.`city`{{ end }}" +
|
||||
" FROM `users`" +
|
||||
" {{ if .IncludeOrders }}" +
|
||||
" LEFT JOIN (" +
|
||||
" SELECT `user_id`, COUNT(*) as `order_count`" +
|
||||
" FROM `orders`" +
|
||||
" GROUP BY `user_id`" +
|
||||
" ) subquery ON `users`.`id` = subquery.`user_id`" +
|
||||
" {{ end }}" +
|
||||
" {{ if .IncludeAddress }}" +
|
||||
" LEFT JOIN `addresses` ON `users`.`id` = `addresses`.`user_id`" +
|
||||
" {{ end }}"
|
||||
|
||||
var users []User
|
||||
err := db.Select(&users, query, 0, params)
|
||||
```
|
||||
|
||||
### Template with Custom Functions
|
||||
|
||||
```go
|
||||
// Add custom template functions
|
||||
db.AddTemplateFuncs(template.FuncMap{
|
||||
"upper": strings.ToUpper,
|
||||
"quote": func(s string) string { return fmt.Sprintf("'%s'", s) },
|
||||
})
|
||||
|
||||
// Use in query
|
||||
query := `
|
||||
SELECT `id`, `name`, `email`, `age`, `active`, `created_at`, `updated_at` FROM `users`
|
||||
WHERE `status` = {{ quote (upper .Status) }}
|
||||
`
|
||||
|
||||
err := db.Select(&users, query, 0, "active")
|
||||
// Generates: WHERE `status` = 'ACTIVE'
|
||||
```
|
||||
|
||||
### Template Best Practices
|
||||
|
||||
```go
|
||||
// DON'T: Use column names from tags in templates
|
||||
type User struct {
|
||||
Username string `mysql:"user_name"` // Column is "user_name"
|
||||
}
|
||||
|
||||
// WRONG - uses column name
|
||||
query := "SELECT `id`, `name`, `email`, `age`, `active`, `created_at`, `updated_at` FROM `users` {{ if .user_name }}WHERE `name` = @@name{{ end }}"
|
||||
|
||||
// CORRECT - uses field name
|
||||
query := "SELECT `id`, `name`, `email`, `age`, `active`, `created_at`, `updated_at` FROM `users` {{ if .Username }}WHERE `name` = @@name{{ end }}"
|
||||
```
|
||||
|
||||
## Result Mapping
|
||||
|
||||
### Map Results
|
||||
|
||||
```go
|
||||
// Single row as map
|
||||
var row mysql.MapRow
|
||||
err := db.Select(&row,
|
||||
"SELECT `id`, `name`, `email` FROM `users` WHERE `id` = @@id",
|
||||
0,
|
||||
123)
|
||||
fmt.Printf("Name: %v\n", row["name"])
|
||||
|
||||
// Multiple rows as maps
|
||||
var rows mysql.MapRows
|
||||
err := db.Select(&rows,
|
||||
"SELECT `id`, `name`, `email` FROM `users`",
|
||||
0)
|
||||
for _, row := range rows {
|
||||
fmt.Printf("ID: %v, Name: %v\n", row["id"], row["name"])
|
||||
}
|
||||
```
|
||||
|
||||
### Slice Results
|
||||
|
||||
```go
|
||||
// Single row as slice
|
||||
var row mysql.SliceRow
|
||||
err := db.Select(&row,
|
||||
"SELECT `id`, `name`, `email` FROM `users` WHERE `id` = @@id",
|
||||
0,
|
||||
123)
|
||||
fmt.Printf("First column: %v\n", row[0])
|
||||
|
||||
// Multiple rows as slices
|
||||
var rows mysql.SliceRows
|
||||
err := db.Select(&rows,
|
||||
"SELECT `id`, `name`, `email` FROM `users`",
|
||||
0)
|
||||
for _, row := range rows {
|
||||
fmt.Printf("Row: %v\n", row)
|
||||
}
|
||||
```
|
||||
|
||||
### Partial Struct Mapping
|
||||
|
||||
```go
|
||||
// Struct with subset of columns
|
||||
type UserSummary struct {
|
||||
ID int `mysql:"id"`
|
||||
Name string `mysql:"name"`
|
||||
}
|
||||
|
||||
var summaries []UserSummary
|
||||
err := db.Select(&summaries,
|
||||
"SELECT `id`, name FROM `users`",
|
||||
0)
|
||||
```
|
||||
|
||||
### Embedded Structs
|
||||
|
||||
```go
|
||||
type Timestamps struct {
|
||||
CreatedAt time.Time `mysql:"created_at"`
|
||||
UpdatedAt time.Time `mysql:"updated_at"`
|
||||
}
|
||||
|
||||
type User struct {
|
||||
ID int `mysql:"id"`
|
||||
Name string `mysql:"name"`
|
||||
Email string `mysql:"email"`
|
||||
Timestamps
|
||||
}
|
||||
|
||||
var users []User
|
||||
err := db.Select(&users,
|
||||
"SELECT `id`, `name`, `email`, created_at, updated_at FROM `users`",
|
||||
0)
|
||||
```
|
||||
|
||||
### Pointer Fields
|
||||
|
||||
```go
|
||||
type User struct {
|
||||
ID int `mysql:"id"`
|
||||
Name string `mysql:"name"`
|
||||
Email *string `mysql:"email"` // NULL-able
|
||||
LastLogin *time.Time `mysql:"last_login"` // NULL-able
|
||||
}
|
||||
|
||||
var users []User
|
||||
err := db.Select(&users, "SELECT `id`, `name`, `email`, `age`, `active`, `created_at`, `updated_at` FROM `users`", 0)
|
||||
|
||||
for _, user := range users {
|
||||
if user.Email != nil {
|
||||
fmt.Printf("Email: %s\n", *user.Email)
|
||||
}
|
||||
if user.LastLogin != nil {
|
||||
fmt.Printf("Last login: %v\n", *user.LastLogin)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Streaming with Channels
|
||||
|
||||
### Select into Channel
|
||||
|
||||
```go
|
||||
// Stream results to avoid loading all into memory
|
||||
userCh := make(chan User, 100) // Buffered channel
|
||||
|
||||
go func() {
|
||||
defer close(userCh)
|
||||
if err := db.Select(userCh, "SELECT `id`, `name`, `email`, `age`, `active`, `created_at`, `updated_at` FROM `users`", 0); err != nil {
|
||||
log.Printf("Select error: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
// Process as they arrive
|
||||
for user := range userCh {
|
||||
if err := processUser(user); err != nil {
|
||||
log.Printf("Process error: %v", err)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Insert from Channel
|
||||
|
||||
```go
|
||||
// Stream inserts to avoid building large slice
|
||||
userCh := make(chan User, 100)
|
||||
|
||||
// Producer
|
||||
go func() {
|
||||
defer close(userCh)
|
||||
for i := 0; i < 10000; i++ {
|
||||
userCh <- User{
|
||||
Name: fmt.Sprintf("User %d", i),
|
||||
Email: fmt.Sprintf("user%d@example.com", i),
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// Consumer - automatically chunks and inserts
|
||||
if err := db.Insert("users", userCh); err != nil {
|
||||
log.Printf("Insert error: %v", err)
|
||||
}
|
||||
```
|
||||
|
||||
### Channel with Context
|
||||
|
||||
```go
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
userCh := make(chan User, 100)
|
||||
|
||||
go func() {
|
||||
defer close(userCh)
|
||||
db.SelectContext(ctx, userCh, "SELECT `id`, `name`, `email`, `age`, `active`, `created_at`, `updated_at` FROM `users`", 0)
|
||||
}()
|
||||
|
||||
for user := range userCh {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
log.Println("Timeout reached")
|
||||
return ctx.Err()
|
||||
default:
|
||||
processUser(user)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Function Receivers
|
||||
|
||||
### Basic Function Receiver
|
||||
|
||||
```go
|
||||
// Process each row with function
|
||||
err := db.Select(func(u User) {
|
||||
log.Printf("Processing user: %s (%s)", u.Name, u.Email)
|
||||
}, "SELECT `id`, `name`, `email`, `age`, `active`, `created_at`, `updated_at` FROM `users`", 0)
|
||||
```
|
||||
|
||||
### Function Receiver with Error Handling
|
||||
|
||||
```go
|
||||
// Return error to stop iteration
|
||||
var processErr error
|
||||
err := db.Select(func(u User) {
|
||||
if err := validateUser(u); err != nil {
|
||||
processErr = err
|
||||
return
|
||||
}
|
||||
processUser(u)
|
||||
}, "SELECT `id`, `name`, `email`, `age`, `active`, `created_at`, `updated_at` FROM `users`", 0)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if processErr != nil {
|
||||
return processErr
|
||||
}
|
||||
```
|
||||
|
||||
### Aggregation with Function Receiver
|
||||
|
||||
```go
|
||||
// Collect aggregate data
|
||||
var totalAge int
|
||||
var count int
|
||||
|
||||
err := db.Select(func(u User) {
|
||||
totalAge += u.Age
|
||||
count++
|
||||
}, "SELECT age FROM `users` WHERE `active` = 1", 0)
|
||||
|
||||
if count > 0 {
|
||||
avgAge := float64(totalAge) / float64(count)
|
||||
fmt.Printf("Average age: %.2f\n", avgAge)
|
||||
}
|
||||
```
|
||||
|
||||
## JSON Handling
|
||||
|
||||
### JSON Column to Struct Field
|
||||
|
||||
```go
|
||||
type UserMeta struct {
|
||||
Preferences map[string]any `json:"preferences"`
|
||||
Settings map[string]any `json:"settings"`
|
||||
}
|
||||
|
||||
type User struct {
|
||||
ID int `mysql:"id"`
|
||||
Name string `mysql:"name"`
|
||||
Meta UserMeta `mysql:"meta"` // JSON column
|
||||
}
|
||||
|
||||
var users []User
|
||||
err := db.Select(&users,
|
||||
"SELECT `id`, `name`, meta FROM `users`",
|
||||
0)
|
||||
|
||||
for _, user := range users {
|
||||
fmt.Printf("Preferences: %+v\n", user.Meta.Preferences)
|
||||
}
|
||||
```
|
||||
|
||||
### Select as JSON
|
||||
|
||||
```go
|
||||
var result json.RawMessage
|
||||
err := db.SelectJSON(&result,
|
||||
`SELECT JSON_OBJECT(
|
||||
'id', id,
|
||||
'name', `name`,
|
||||
'email', `email`
|
||||
) FROM `users` WHERE `id` = @@id`,
|
||||
0,
|
||||
123)
|
||||
|
||||
fmt.Printf("JSON: %s\n", string(result))
|
||||
```
|
||||
|
||||
### JSON Array Results
|
||||
|
||||
```go
|
||||
var results json.RawMessage
|
||||
err := db.SelectJSON(&results,
|
||||
`SELECT JSON_ARRAYAGG(
|
||||
JSON_OBJECT(
|
||||
'id', id,
|
||||
'name', name
|
||||
)
|
||||
) FROM users`,
|
||||
0)
|
||||
```
|
||||
|
||||
## Complex Queries
|
||||
|
||||
### Subqueries with Named Parameters
|
||||
|
||||
```go
|
||||
query := "SELECT `users`.`id`, `users`.`name`, `users`.`email`, `users`.`age`, `users`.`active`, `users`.`created_at`, `users`.`updated_at`," +
|
||||
" (SELECT COUNT(*) FROM `orders` WHERE `orders`.`user_id` = `users`.`id`) as `order_count`" +
|
||||
" FROM `users`" +
|
||||
" WHERE `users`.`created_at` > @@since" +
|
||||
" AND `users`.`status` = @@status"
|
||||
|
||||
var users []struct {
|
||||
User
|
||||
OrderCount int `mysql:"order_count"`
|
||||
}
|
||||
|
||||
err := db.Select(&users, query, 5*time.Minute,
|
||||
mysql.Params{
|
||||
"since": time.Now().Add(-30*24*time.Hour),
|
||||
"status": "active",
|
||||
})
|
||||
```
|
||||
|
||||
### JOINs with Named Parameters
|
||||
|
||||
```go
|
||||
query := "SELECT" +
|
||||
" `users`.`id`," +
|
||||
" `users`.`name`," +
|
||||
" `users`.`email`," +
|
||||
" `orders`.`order_id`," +
|
||||
" `orders`.`total`" +
|
||||
" FROM `users`" +
|
||||
" INNER JOIN `orders` ON `users`.`id` = `orders`.`user_id`" +
|
||||
" WHERE `users`.`status` = @@status" +
|
||||
" AND `orders`.`created_at` > @@since" +
|
||||
" AND `orders`.`total` > @@minTotal" +
|
||||
" ORDER BY `orders`.`created_at` DESC"
|
||||
|
||||
type UserOrder struct {
|
||||
UserID int `mysql:"id"`
|
||||
Name string `mysql:"name"`
|
||||
Email string `mysql:"email"`
|
||||
OrderID int `mysql:"order_id"`
|
||||
Total float64 `mysql:"total"`
|
||||
}
|
||||
|
||||
var results []UserOrder
|
||||
err := db.Select(&results, query, 0,
|
||||
mysql.Params{
|
||||
"status": "active",
|
||||
"since": time.Now().Add(-7*24*time.Hour),
|
||||
"minTotal": 100.0,
|
||||
})
|
||||
```
|
||||
|
||||
### IN Clause with Multiple Values
|
||||
|
||||
```go
|
||||
// cool-mysql natively supports slices - just pass them directly!
|
||||
ids := []int{1, 2, 3, 4, 5}
|
||||
|
||||
query := "SELECT `id`, `name`, `email`, `age`, `active`, `created_at`, `updated_at` FROM `users` WHERE `id` IN (@@ids)"
|
||||
var users []User
|
||||
err := db.Select(&users, query, 0,
|
||||
ids)
|
||||
// Automatically expands to: SELECT `id`, `name`, `email`, `age`, `active`, `created_at`, `updated_at` FROM `users` WHERE `id` IN (1,2,3,4,5)
|
||||
|
||||
// Works with any slice type
|
||||
emails := []string{"user1@example.com", "user2@example.com"}
|
||||
err = db.Select(&users, "SELECT `id`, `name`, `email`, `age`, `active`, `created_at`, `updated_at` FROM `users` WHERE `email` IN (@@emails)", 0,
|
||||
emails)
|
||||
|
||||
// For very large lists (10,000+ items), consider JSON_TABLE (MySQL 8.0+)
|
||||
// This can be more efficient than IN clause with many values
|
||||
var largeIDs []int // thousands of IDs
|
||||
idsJSON, _ := json.Marshal(largeIDs)
|
||||
query = "SELECT `users`.`id`, `users`.`name`, `users`.`email`, `users`.`age`, `users`.`active`, `users`.`created_at`, `users`.`updated_at`" +
|
||||
" FROM `users`" +
|
||||
" JOIN JSON_TABLE(" +
|
||||
" @@ids," +
|
||||
" '$[*]' COLUMNS(`id` INT PATH '$')" +
|
||||
" ) AS json_ids ON `users`.`id` = json_ids.`id`"
|
||||
err = db.Select(&users, query, 0,
|
||||
string(idsJSON))
|
||||
```
|
||||
|
||||
### Window Functions
|
||||
|
||||
```go
|
||||
query := "SELECT" +
|
||||
" `id`," +
|
||||
" `name`," +
|
||||
" `salary`," +
|
||||
" RANK() OVER (ORDER BY `salary` DESC) as `salary_rank`," +
|
||||
" AVG(`salary`) OVER () as `avg_salary`" +
|
||||
" FROM `employees`" +
|
||||
" WHERE `department` = @@dept"
|
||||
|
||||
type EmployeeStats struct {
|
||||
ID int `mysql:"id"`
|
||||
Name string `mysql:"name"`
|
||||
Salary float64 `mysql:"salary"`
|
||||
SalaryRank int `mysql:"salary_rank"`
|
||||
AvgSalary float64 `mysql:"avg_salary"`
|
||||
}
|
||||
|
||||
var stats []EmployeeStats
|
||||
err := db.Select(&stats, query, 5*time.Minute,
|
||||
"Engineering")
|
||||
```
|
||||
|
||||
## Raw SQL
|
||||
|
||||
### Literal SQL Injection
|
||||
|
||||
```go
|
||||
// Use Raw() for SQL that shouldn't be escaped
|
||||
// WARNING: Never use with user input - SQL injection risk!
|
||||
|
||||
query := `
|
||||
SELECT `id`, `name`, `email`, `age`, `active`, `created_at`, `updated_at` FROM `users`
|
||||
WHERE @@dynamicCondition
|
||||
AND `status` = @@status
|
||||
`
|
||||
|
||||
err := db.Select(&users, query, 0,
|
||||
mysql.Params{
|
||||
"dynamicCondition": mysql.Raw("created_at > NOW() - INTERVAL 7 DAY"),
|
||||
"status": "active", // This IS escaped
|
||||
})
|
||||
```
|
||||
|
||||
### Dynamic Table Names
|
||||
|
||||
```go
|
||||
// Table names can't be parameterized - use fmt.Sprintf carefully
|
||||
tableName := "users" // Validate this!
|
||||
|
||||
query := fmt.Sprintf("SELECT `id`, `name`, `email`, `age`, `active`, `created_at`, `updated_at` FROM %s WHERE `status` = @@status",
|
||||
tableName) // Ensure tableName is validated/sanitized!
|
||||
|
||||
var users []User
|
||||
err := db.Select(&users, query, 0,
|
||||
"active")
|
||||
```
|
||||
|
||||
### CASE Statements with Raw
|
||||
|
||||
```go
|
||||
query := `
|
||||
SELECT
|
||||
id,
|
||||
name,
|
||||
@@statusCase as `status_label`
|
||||
FROM `users`
|
||||
`
|
||||
|
||||
statusCase := mysql.Raw(`
|
||||
CASE status
|
||||
WHEN 1 THEN 'Active'
|
||||
WHEN 2 THEN 'Inactive'
|
||||
WHEN 3 THEN 'Suspended'
|
||||
ELSE 'Unknown'
|
||||
END
|
||||
`)
|
||||
|
||||
type UserWithLabel struct {
|
||||
ID int `mysql:"id"`
|
||||
Name string `mysql:"name"`
|
||||
StatusLabel string `mysql:"status_label"`
|
||||
}
|
||||
|
||||
var users []UserWithLabel
|
||||
err := db.Select(&users, query, 0,
|
||||
statusCase)
|
||||
```
|
||||
|
||||
## Debugging Queries
|
||||
|
||||
### Inspect Interpolated Query
|
||||
|
||||
```go
|
||||
query := "SELECT `id`, `name`, `email`, `age`, `active`, `created_at`, `updated_at` FROM `users` WHERE age > @@minAge AND `status` = @@status"
|
||||
params := mysql.Params{"minAge": 18, "status": "active"}
|
||||
|
||||
replacedQuery, normalizedParams, err := db.InterpolateParams(query, params)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
fmt.Printf("Query: %s\n", replacedQuery)
|
||||
fmt.Printf("Params: %+v\n", normalizedParams)
|
||||
// Query: SELECT `id`, `name`, `email`, `age`, `active`, `created_at`, `updated_at` FROM `users` WHERE age > ? AND `status` = ?
|
||||
// Params: [18 active]
|
||||
```
|
||||
|
||||
### Log Query Execution
|
||||
|
||||
```go
|
||||
// Set up query logging
|
||||
db.SetQueryLogger(func(query string, args []any, duration time.Duration, err error) {
|
||||
log.Printf("[%v] %s %+v (err: %v)", duration, query, args, err)
|
||||
})
|
||||
|
||||
// Now all queries will be logged
|
||||
db.Select(&users, "SELECT `id`, `name`, `email`, `age`, `active`, `created_at`, `updated_at` FROM `users` WHERE age > @@age", 0,
|
||||
18)
|
||||
```
|
||||
|
||||
## Performance Tips
|
||||
|
||||
1. **Use caching for expensive queries**: Set appropriate TTL based on data volatility
|
||||
2. **Stream large result sets**: Use channels instead of loading all into memory
|
||||
3. **Batch inserts**: Use slices or channels instead of individual inserts
|
||||
4. **Use SelectWrites sparingly**: Only when you need read-after-write consistency
|
||||
5. **Index your parameters**: Ensure WHERE clause columns are indexed
|
||||
6. **Avoid SELECT ***: Specify only columns you need for better performance
|
||||
7. **Use templates wisely**: Don't overcomplicate queries - keep them readable
|
||||
8. **Monitor cache hit rates**: Tune TTLs based on actual hit rates
|
||||
652
references/struct-tags.md
Normal file
652
references/struct-tags.md
Normal file
@@ -0,0 +1,652 @@
|
||||
# Struct Tags Reference
|
||||
|
||||
Complete guide to struct tag usage in cool-mysql for controlling column mapping and behavior.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Basic Tag Syntax](#basic-tag-syntax)
|
||||
2. [Tag Options](#tag-options)
|
||||
3. [Default Value Handling](#default-value-handling)
|
||||
4. [Special Characters](#special-characters)
|
||||
5. [Custom Interfaces](#custom-interfaces)
|
||||
6. [Advanced Patterns](#advanced-patterns)
|
||||
7. [Common Gotchas](#common-gotchas)
|
||||
|
||||
## Basic Tag Syntax
|
||||
|
||||
### Column Mapping
|
||||
|
||||
```go
|
||||
type User struct {
|
||||
ID int `mysql:"id"` // Maps to 'id' column
|
||||
Name string `mysql:"name"` // Maps to 'name' column
|
||||
Email string `mysql:"email"` // Maps to 'email' column
|
||||
}
|
||||
```
|
||||
|
||||
**Default Behavior (No Tag):**
|
||||
```go
|
||||
type User struct {
|
||||
ID int // Maps to 'ID' column (exact field name)
|
||||
Name string // Maps to 'Name' column
|
||||
}
|
||||
```
|
||||
|
||||
### Multiple Tags
|
||||
|
||||
```go
|
||||
type User struct {
|
||||
ID int `mysql:"id" json:"id"`
|
||||
Name string `mysql:"name" json:"name"`
|
||||
CreatedAt time.Time `mysql:"created_at" json:"created_at"`
|
||||
}
|
||||
```
|
||||
|
||||
## Tag Options
|
||||
|
||||
### Available Options
|
||||
|
||||
| Option | Syntax | Behavior |
|
||||
|--------|--------|----------|
|
||||
| Column name | `mysql:"column_name"` | Maps to specific column |
|
||||
| Default zero | `mysql:"column_name,defaultzero"` | Use DEFAULT() for zero values |
|
||||
| Omit empty | `mysql:"column_name,omitempty"` | Same as `defaultzero` |
|
||||
| Insert default | `mysql:"column_name,insertDefault"` | Same as `defaultzero` |
|
||||
| Ignore field | `mysql:"-"` | Completely ignore field |
|
||||
|
||||
### Column Name Only
|
||||
|
||||
```go
|
||||
type User struct {
|
||||
UserID int `mysql:"id"` // Field name differs from column name
|
||||
}
|
||||
|
||||
// INSERT INTO `users` (id) VALUES (?)
|
||||
```
|
||||
|
||||
### defaultzero Option
|
||||
|
||||
```go
|
||||
type User struct {
|
||||
ID int `mysql:"id"`
|
||||
Name string `mysql:"name"`
|
||||
CreatedAt time.Time `mysql:"created_at,defaultzero"`
|
||||
}
|
||||
|
||||
// If CreatedAt.IsZero():
|
||||
// INSERT INTO `users` (id, `name`, created_at) VALUES (?, ?, DEFAULT(created_at))
|
||||
// Else:
|
||||
// INSERT INTO `users` (id, `name`, created_at) VALUES (?, ?, ?)
|
||||
```
|
||||
|
||||
### omitempty Option
|
||||
|
||||
```go
|
||||
type User struct {
|
||||
ID int `mysql:"id"`
|
||||
UpdatedAt time.Time `mysql:"updated_at,omitempty"`
|
||||
}
|
||||
|
||||
// Equivalent to defaultzero
|
||||
```
|
||||
|
||||
### insertDefault Option
|
||||
|
||||
```go
|
||||
type User struct {
|
||||
ID int `mysql:"id"`
|
||||
CreatedAt time.Time `mysql:"created_at,insertDefault"`
|
||||
}
|
||||
|
||||
// Equivalent to defaultzero
|
||||
```
|
||||
|
||||
### Ignore Field
|
||||
|
||||
```go
|
||||
type User struct {
|
||||
ID int `mysql:"id"`
|
||||
Password string `mysql:"-"` // Never included in queries
|
||||
internal string // Unexported fields also ignored
|
||||
}
|
||||
|
||||
// INSERT INTO `users` (id) VALUES (?)
|
||||
// Password is never inserted or selected
|
||||
```
|
||||
|
||||
## Default Value Handling
|
||||
|
||||
### When to Use defaultzero
|
||||
|
||||
Use `defaultzero` when:
|
||||
- Database column has a DEFAULT value
|
||||
- You want to use database default for zero values
|
||||
- Common for timestamps with `DEFAULT CURRENT_TIMESTAMP`
|
||||
|
||||
### Database Setup
|
||||
|
||||
```sql
|
||||
CREATE TABLE users (
|
||||
id INT PRIMARY KEY AUTO_INCREMENT,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
);
|
||||
```
|
||||
|
||||
### Struct Definition
|
||||
|
||||
```go
|
||||
type User struct {
|
||||
ID int `mysql:"id"`
|
||||
Name string `mysql:"name"`
|
||||
CreatedAt time.Time `mysql:"created_at,defaultzero"`
|
||||
UpdatedAt time.Time `mysql:"updated_at,defaultzero"`
|
||||
}
|
||||
```
|
||||
|
||||
### Usage
|
||||
|
||||
```go
|
||||
// CreatedAt and UpdatedAt are zero values
|
||||
user := User{
|
||||
Name: "Alice",
|
||||
}
|
||||
|
||||
db.Insert("users", user)
|
||||
// INSERT INTO `users` (name, created_at, updated_at)
|
||||
// VALUES (?, DEFAULT(created_at), DEFAULT(updated_at))
|
||||
// Database sets timestamps automatically
|
||||
```
|
||||
|
||||
### Zero Value Detection
|
||||
|
||||
**Built-in zero values:**
|
||||
- `int`, `int64`, etc.: `0`
|
||||
- `string`: `""`
|
||||
- `bool`: `false`
|
||||
- `time.Time`: `time.Time{}.IsZero()` returns `true`
|
||||
- `*T` (pointers): `nil`
|
||||
- `[]T` (slices): `nil` or `len == 0`
|
||||
|
||||
**Custom zero detection:**
|
||||
Implement `Zeroer` interface (see [Custom Interfaces](#custom-interfaces))
|
||||
|
||||
## Special Characters
|
||||
|
||||
### Hex Encoding
|
||||
|
||||
For column names with special characters, use hex encoding:
|
||||
|
||||
```go
|
||||
type Data struct {
|
||||
// Column name: "column,name"
|
||||
Value string `mysql:"column0x2cname"`
|
||||
}
|
||||
|
||||
// 0x2c is hex for ','
|
||||
```
|
||||
|
||||
### Common Hex Codes
|
||||
|
||||
| Character | Hex Code | Example |
|
||||
|-----------|----------|---------|
|
||||
| `,` | `0x2c` | `column0x2cname` |
|
||||
| `:` | `0x3a` | `column0x3aname` |
|
||||
| `"` | `0x22` | `column0x22name` |
|
||||
| Space | `0x20` | `column0x20name` |
|
||||
|
||||
### Generating Hex Codes
|
||||
|
||||
```go
|
||||
// Get hex code for character
|
||||
char := ','
|
||||
hexCode := fmt.Sprintf("0x%x", char)
|
||||
fmt.Println(hexCode) // 0x2c
|
||||
```
|
||||
|
||||
## Custom Interfaces
|
||||
|
||||
### Zeroer Interface
|
||||
|
||||
Implement custom zero-value detection:
|
||||
|
||||
```go
|
||||
type Zeroer interface {
|
||||
IsZero() bool
|
||||
}
|
||||
```
|
||||
|
||||
**Example:**
|
||||
|
||||
```go
|
||||
type CustomTime struct {
|
||||
time.Time
|
||||
}
|
||||
|
||||
func (ct CustomTime) IsZero() bool {
|
||||
// Consider Unix epoch (0) as zero
|
||||
return ct.Time.IsZero() || ct.Time.Unix() == 0
|
||||
}
|
||||
|
||||
type Event struct {
|
||||
ID int `mysql:"id"`
|
||||
Timestamp CustomTime `mysql:"timestamp,defaultzero"`
|
||||
}
|
||||
|
||||
// If Timestamp.Unix() == 0:
|
||||
// INSERT ... VALUES (..., DEFAULT(timestamp))
|
||||
```
|
||||
|
||||
**Use Cases:**
|
||||
- Custom "empty" definitions
|
||||
- Sentinel values treated as zero
|
||||
- Domain-specific zero logic
|
||||
|
||||
### Valueser Interface
|
||||
|
||||
Implement custom value conversion:
|
||||
|
||||
```go
|
||||
type Valueser interface {
|
||||
Values() []any
|
||||
}
|
||||
```
|
||||
|
||||
**Example:**
|
||||
|
||||
```go
|
||||
type Point struct {
|
||||
X, Y float64
|
||||
}
|
||||
|
||||
func (p Point) Values() []any {
|
||||
return []any{p.X, p.Y}
|
||||
}
|
||||
|
||||
type Location struct {
|
||||
ID int `mysql:"id"`
|
||||
Position Point `mysql:"x,y"` // Note: two columns
|
||||
}
|
||||
|
||||
// INSERT INTO locations (id, x, y) VALUES (?, ?, ?)
|
||||
// Point.Values() returns [X, Y]
|
||||
```
|
||||
|
||||
**Use Cases:**
|
||||
- Mapping Go type to multiple columns
|
||||
- Custom serialization
|
||||
- Complex type conversion
|
||||
|
||||
## Advanced Patterns
|
||||
|
||||
### Embedded Structs
|
||||
|
||||
```go
|
||||
type Timestamps struct {
|
||||
CreatedAt time.Time `mysql:"created_at,defaultzero"`
|
||||
UpdatedAt time.Time `mysql:"updated_at,defaultzero"`
|
||||
}
|
||||
|
||||
type User struct {
|
||||
ID int `mysql:"id"`
|
||||
Name string `mysql:"name"`
|
||||
Timestamps // Embedded fields included
|
||||
}
|
||||
|
||||
// SELECT `id`, `name`, created_at, updated_at FROM `users`
|
||||
```
|
||||
|
||||
### Pointer Fields for NULL Handling
|
||||
|
||||
```go
|
||||
type User struct {
|
||||
ID int `mysql:"id"`
|
||||
Name string `mysql:"name"`
|
||||
Email *string `mysql:"email"` // NULL-able
|
||||
PhoneNumber *string `mysql:"phone_number"` // NULL-able
|
||||
LastLogin *time.Time `mysql:"last_login"` // NULL-able
|
||||
}
|
||||
|
||||
// Nil pointer = NULL in database
|
||||
user := User{
|
||||
ID: 1,
|
||||
Name: "Alice",
|
||||
Email: nil, // Will be NULL in database
|
||||
}
|
||||
|
||||
db.Insert("users", user)
|
||||
// INSERT INTO `users` (id, `name`, `email`, phone_number, last_login)
|
||||
// VALUES (?, ?, NULL, NULL, NULL)
|
||||
```
|
||||
|
||||
### Partial Struct Selects
|
||||
|
||||
```go
|
||||
// Full struct
|
||||
type User struct {
|
||||
ID int `mysql:"id"`
|
||||
Name string `mysql:"name"`
|
||||
Email string `mysql:"email"`
|
||||
CreatedAt time.Time `mysql:"created_at"`
|
||||
UpdatedAt time.Time `mysql:"updated_at"`
|
||||
}
|
||||
|
||||
// Partial struct for specific query
|
||||
type UserSummary struct {
|
||||
ID int `mysql:"id"`
|
||||
Name string `mysql:"name"`
|
||||
}
|
||||
|
||||
var summaries []UserSummary
|
||||
db.Select(&summaries, "SELECT `id`, name FROM `users`", 0)
|
||||
// Only maps id and name columns
|
||||
```
|
||||
|
||||
### JSON Column Mapping
|
||||
|
||||
```go
|
||||
type UserMeta struct {
|
||||
Theme string `json:"theme"`
|
||||
Preferences map[string]interface{} `json:"preferences"`
|
||||
}
|
||||
|
||||
type User struct {
|
||||
ID int `mysql:"id"`
|
||||
Name string `mysql:"name"`
|
||||
Meta UserMeta `mysql:"meta"` // JSON column in MySQL
|
||||
}
|
||||
|
||||
// cool-mysql automatically marshals/unmarshals JSON
|
||||
db.Insert("users", User{
|
||||
ID: 1,
|
||||
Name: "Alice",
|
||||
Meta: UserMeta{
|
||||
Theme: "dark",
|
||||
Preferences: map[string]interface{}{"notifications": true},
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
### Ignored Fields with Computed Values
|
||||
|
||||
```go
|
||||
type User struct {
|
||||
ID int `mysql:"id"`
|
||||
FirstName string `mysql:"first_name"`
|
||||
LastName string `mysql:"last_name"`
|
||||
FullName string `mysql:"-"` // Computed, not in DB
|
||||
}
|
||||
|
||||
var users []User
|
||||
db.Select(&users, "SELECT `id`, first_name, last_name FROM `users`", 0)
|
||||
|
||||
// Compute FullName after query
|
||||
for i := range users {
|
||||
users[i].FullName = users[i].FirstName + " " + users[i].LastName
|
||||
}
|
||||
```
|
||||
|
||||
## Common Gotchas
|
||||
|
||||
### 1. Tag Takes Precedence Over Field Name
|
||||
|
||||
```go
|
||||
type User struct {
|
||||
UserID int `mysql:"id"` // Column is 'id', not 'UserID'
|
||||
}
|
||||
|
||||
// Query must use actual column name
|
||||
db.Select(&user, "SELECT id FROM `users` WHERE `id` = @@id", 0,
|
||||
1)
|
||||
```
|
||||
|
||||
### 2. Templates Use Field Names, Not Column Names
|
||||
|
||||
```go
|
||||
type User struct {
|
||||
Username string `mysql:"user_name"` // Column: user_name, Field: Username
|
||||
}
|
||||
|
||||
// CORRECT - uses field name
|
||||
query := "SELECT `id`, `name`, `email`, `age`, `active`, `created_at`, `updated_at` FROM `users` {{ if .Username }}WHERE `user_name` = @@name{{ end }}"
|
||||
|
||||
// WRONG - user_name is column, not field
|
||||
query := "SELECT `id`, `name`, `email`, `age`, `active`, `created_at`, `updated_at` FROM `users` {{ if .user_name }}WHERE `user_name` = @@name{{ end }}"
|
||||
```
|
||||
|
||||
### 3. Unexported Fields Are Ignored
|
||||
|
||||
```go
|
||||
type User struct {
|
||||
ID int `mysql:"id"`
|
||||
name string `mysql:"name"` // Ignored - unexported
|
||||
}
|
||||
|
||||
// Only ID is inserted
|
||||
db.Insert("users", user)
|
||||
```
|
||||
|
||||
### 4. Multiple Option Order Doesn't Matter
|
||||
|
||||
```go
|
||||
// These are equivalent
|
||||
`mysql:"column_name,defaultzero"`
|
||||
`mysql:"column_name,omitempty"`
|
||||
`mysql:"column_name,insertDefault"`
|
||||
```
|
||||
|
||||
### 5. Embedded Struct Tag Conflicts
|
||||
|
||||
```go
|
||||
type Base struct {
|
||||
ID int `mysql:"id"`
|
||||
}
|
||||
|
||||
type User struct {
|
||||
Base
|
||||
ID int `mysql:"user_id"` // Shadows Base.ID
|
||||
}
|
||||
|
||||
// User.ID takes precedence
|
||||
```
|
||||
|
||||
### 6. Zero Values vs NULL
|
||||
|
||||
```go
|
||||
// Zero value != NULL
|
||||
type User struct {
|
||||
Age int `mysql:"age"` // 0 is inserted, not NULL
|
||||
}
|
||||
|
||||
// Use pointer for NULL
|
||||
type User struct {
|
||||
Age *int `mysql:"age"` // nil = NULL, 0 = 0
|
||||
}
|
||||
```
|
||||
|
||||
### 7. defaultzero Doesn't Affect SELECT
|
||||
|
||||
```go
|
||||
type User struct {
|
||||
CreatedAt time.Time `mysql:"created_at,defaultzero"`
|
||||
}
|
||||
|
||||
// defaultzero only affects INSERT/UPSERT, not SELECT
|
||||
db.Select(&users, "SELECT `id`, `name`, `email`, `age`, `active`, `created_at`, `updated_at` FROM `users`", 0)
|
||||
// CreatedAt is populated from database regardless of tag
|
||||
```
|
||||
|
||||
## Tag Comparison
|
||||
|
||||
### json vs mysql Tags
|
||||
|
||||
```go
|
||||
type User struct {
|
||||
ID int `mysql:"id" json:"userId"` // DB: id, JSON: userId
|
||||
Name string `mysql:"name" json:"name"` // Same for both
|
||||
}
|
||||
|
||||
// MySQL: id, name
|
||||
// JSON: userId, name
|
||||
```
|
||||
|
||||
### When Tags Differ
|
||||
|
||||
```go
|
||||
type User struct {
|
||||
DatabaseID int `mysql:"id"` // Database column
|
||||
UserName string `mysql:"username"` // Database column
|
||||
ComputedRank int `mysql:"-"` // Not in database
|
||||
}
|
||||
|
||||
// Database columns: id, username
|
||||
// Struct fields: DatabaseID, UserName, ComputedRank
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### 1. Explicit Tags for Clarity
|
||||
|
||||
```go
|
||||
// GOOD - explicit tags
|
||||
type User struct {
|
||||
ID int `mysql:"id"`
|
||||
Name string `mysql:"name"`
|
||||
}
|
||||
|
||||
// OKAY - relies on field names matching columns
|
||||
type User struct {
|
||||
ID int
|
||||
Name string
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Use defaultzero for Timestamps
|
||||
|
||||
```go
|
||||
type User struct {
|
||||
CreatedAt time.Time `mysql:"created_at,defaultzero"`
|
||||
UpdatedAt time.Time `mysql:"updated_at,defaultzero"`
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Use Pointers for NULL-able Columns
|
||||
|
||||
```go
|
||||
type User struct {
|
||||
Email *string `mysql:"email"` // Can be NULL
|
||||
LastLogin *time.Time `mysql:"last_login"` // Can be NULL
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Ignore Computed Fields
|
||||
|
||||
```go
|
||||
type User struct {
|
||||
FirstName string `mysql:"first_name"`
|
||||
LastName string `mysql:"last_name"`
|
||||
FullName string `mysql:"-"` // Computed field
|
||||
}
|
||||
```
|
||||
|
||||
### 5. Document Custom Interfaces
|
||||
|
||||
```go
|
||||
// CustomTime implements Zeroer for custom zero detection
|
||||
type CustomTime struct {
|
||||
time.Time
|
||||
}
|
||||
|
||||
func (ct CustomTime) IsZero() bool {
|
||||
return ct.Time.Unix() <= 0
|
||||
}
|
||||
```
|
||||
|
||||
### 6. Consistent Naming Convention
|
||||
|
||||
```go
|
||||
// GOOD - consistent snake_case in tags
|
||||
type User struct {
|
||||
ID int `mysql:"id"`
|
||||
FirstName string `mysql:"first_name"`
|
||||
LastName string `mysql:"last_name"`
|
||||
}
|
||||
|
||||
// AVOID - mixing conventions
|
||||
type User struct {
|
||||
ID int `mysql:"id"`
|
||||
FirstName string `mysql:"firstName"` // camelCase
|
||||
LastName string `mysql:"last_name"` // snake_case
|
||||
}
|
||||
```
|
||||
|
||||
### 7. Test Zero Value Behavior
|
||||
|
||||
```go
|
||||
func TestUserInsertDefaults(t *testing.T) {
|
||||
user := User{Name: "Alice"} // CreatedAt is zero
|
||||
err := db.Insert("users", user)
|
||||
// Verify database used DEFAULT value
|
||||
}
|
||||
```
|
||||
|
||||
## Examples
|
||||
|
||||
### Complete User Struct
|
||||
|
||||
```go
|
||||
type User struct {
|
||||
// Primary key
|
||||
ID int `mysql:"id"`
|
||||
|
||||
// Basic fields
|
||||
Email string `mysql:"email"`
|
||||
Username string `mysql:"username"`
|
||||
|
||||
// NULL-able fields
|
||||
FirstName *string `mysql:"first_name"`
|
||||
LastName *string `mysql:"last_name"`
|
||||
PhoneNumber *string `mysql:"phone_number"`
|
||||
LastLogin *time.Time `mysql:"last_login"`
|
||||
|
||||
// Timestamps with defaults
|
||||
CreatedAt time.Time `mysql:"created_at,defaultzero"`
|
||||
UpdatedAt time.Time `mysql:"updated_at,defaultzero"`
|
||||
|
||||
// JSON column
|
||||
Metadata json.RawMessage `mysql:"metadata"`
|
||||
|
||||
// Ignored fields
|
||||
Password string `mysql:"-"` // Never persisted
|
||||
PasswordHash string `mysql:"password_hash"`
|
||||
}
|
||||
```
|
||||
|
||||
### Product with Custom Types
|
||||
|
||||
```go
|
||||
type Decimal struct {
|
||||
Value *big.Float
|
||||
}
|
||||
|
||||
func (d Decimal) Values() []any {
|
||||
if d.Value == nil {
|
||||
return []any{nil}
|
||||
}
|
||||
f, _ := d.Value.Float64()
|
||||
return []any{f}
|
||||
}
|
||||
|
||||
func (d Decimal) IsZero() bool {
|
||||
return d.Value == nil || d.Value.Cmp(big.NewFloat(0)) == 0
|
||||
}
|
||||
|
||||
type Product struct {
|
||||
ID int `mysql:"id"`
|
||||
Name string `mysql:"name"`
|
||||
Price Decimal `mysql:"price,defaultzero"`
|
||||
Description *string `mysql:"description"`
|
||||
CreatedAt time.Time `mysql:"created_at,defaultzero"`
|
||||
}
|
||||
```
|
||||
884
references/testing-patterns.md
Normal file
884
references/testing-patterns.md
Normal file
@@ -0,0 +1,884 @@
|
||||
# Testing Patterns Guide
|
||||
|
||||
Complete guide to testing applications that use cool-mysql, including mocking strategies and test patterns.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Testing Strategies](#testing-strategies)
|
||||
2. [Using sqlmock](#using-sqlmock)
|
||||
3. [Test Database Setup](#test-database-setup)
|
||||
4. [Testing Patterns](#testing-patterns)
|
||||
5. [Context-Based Testing](#context-based-testing)
|
||||
6. [Testing Caching](#testing-caching)
|
||||
7. [Integration Testing](#integration-testing)
|
||||
8. [Best Practices](#best-practices)
|
||||
|
||||
## Testing Strategies
|
||||
|
||||
### Three Approaches
|
||||
|
||||
| Approach | Pros | Cons | Best For |
|
||||
|----------|------|------|----------|
|
||||
| **sqlmock** | Fast, no DB needed, precise control | Manual setup, brittle | Unit tests |
|
||||
| **Test Database** | Real MySQL behavior | Slower, requires DB | Integration tests |
|
||||
| **In-Memory DB** | Fast, real SQL | Limited MySQL features | Quick tests |
|
||||
|
||||
### When to Use Each
|
||||
|
||||
**sqlmock:**
|
||||
- Unit testing business logic
|
||||
- Testing error handling
|
||||
- CI/CD pipelines without database
|
||||
- Rapid iteration
|
||||
|
||||
**Test Database:**
|
||||
- Integration testing
|
||||
- Testing complex queries
|
||||
- Verifying MySQL-specific behavior
|
||||
- End-to-end tests
|
||||
|
||||
**In-Memory (SQLite):**
|
||||
- Quick local tests
|
||||
- Testing SQL logic (not MySQL-specific)
|
||||
- Prototyping
|
||||
|
||||
## Using sqlmock
|
||||
|
||||
### Setup
|
||||
|
||||
```go
|
||||
import (
|
||||
"testing"
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
mysqlDriver "github.com/go-sql-driver/mysql"
|
||||
"github.com/StirlingMarketingGroup/cool-mysql"
|
||||
)
|
||||
|
||||
func setupMockDB(t *testing.T) (*mysql.Database, sqlmock.Sqlmock) {
|
||||
// Create mock SQL connection
|
||||
mockDB, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create mock: %v", err)
|
||||
}
|
||||
|
||||
// Create cool-mysql Database from mock connection
|
||||
db, err := mysql.NewFromConn(mockDB, mockDB)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create db: %v", err)
|
||||
}
|
||||
|
||||
return db, mock
|
||||
}
|
||||
```
|
||||
|
||||
### Basic Select Test
|
||||
|
||||
```go
|
||||
func TestSelectUsers(t *testing.T) {
|
||||
db, mock := setupMockDB(t)
|
||||
defer mock.ExpectClose()
|
||||
|
||||
// Define expected query and result
|
||||
rows := sqlmock.NewRows([]string{"id", "name", "email"}).
|
||||
AddRow(1, "Alice", "alice@example.com").
|
||||
AddRow(2, "Bob", "bob@example.com")
|
||||
|
||||
mock.ExpectQuery("SELECT (.+) FROM `users` WHERE age > ?").
|
||||
WithArgs(18).
|
||||
WillReturnRows(rows)
|
||||
|
||||
// Execute query
|
||||
var users []User
|
||||
err := db.Select(&users,
|
||||
"SELECT `id`, `name`, `email`, `age`, `active`, `created_at`, `updated_at` FROM `users` WHERE age > @@minAge",
|
||||
0,
|
||||
18)
|
||||
|
||||
// Verify
|
||||
if err != nil {
|
||||
t.Errorf("Select failed: %v", err)
|
||||
}
|
||||
|
||||
if len(users) != 2 {
|
||||
t.Errorf("Expected 2 users, got %d", len(users))
|
||||
}
|
||||
|
||||
if users[0].Name != "Alice" {
|
||||
t.Errorf("Expected Alice, got %s", users[0].Name)
|
||||
}
|
||||
|
||||
// Verify all expectations met
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("Unfulfilled expectations: %v", err)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Insert Test
|
||||
|
||||
```go
|
||||
func TestInsertUser(t *testing.T) {
|
||||
db, mock := setupMockDB(t)
|
||||
defer mock.ExpectClose()
|
||||
|
||||
user := User{
|
||||
ID: 1,
|
||||
Name: "Alice",
|
||||
Email: "alice@example.com",
|
||||
}
|
||||
|
||||
// Expect INSERT statement
|
||||
mock.ExpectExec("INSERT INTO `users`").
|
||||
WithArgs(1, "Alice", "alice@example.com").
|
||||
WillReturnResult(sqlmock.NewResult(1, 1))
|
||||
|
||||
// Execute insert
|
||||
err := db.Insert("users", user)
|
||||
|
||||
// Verify
|
||||
if err != nil {
|
||||
t.Errorf("Insert failed: %v", err)
|
||||
}
|
||||
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("Unfulfilled expectations: %v", err)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Update Test
|
||||
|
||||
```go
|
||||
func TestUpdateUser(t *testing.T) {
|
||||
db, mock := setupMockDB(t)
|
||||
defer mock.ExpectClose()
|
||||
|
||||
// Expect UPDATE statement
|
||||
mock.ExpectExec("UPDATE `users` SET `name` = \\? WHERE `id` = \\?").
|
||||
WithArgs("Alice Updated", 1).
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
// Execute update
|
||||
err := db.Exec("UPDATE `users` SET `name` = @@name WHERE `id` = @@id",
|
||||
mysql.Params{"name": "Alice Updated", "id": 1})
|
||||
|
||||
// Verify
|
||||
if err != nil {
|
||||
t.Errorf("Update failed: %v", err)
|
||||
}
|
||||
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("Unfulfilled expectations: %v", err)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Error Handling Test
|
||||
|
||||
```go
|
||||
func TestSelectError(t *testing.T) {
|
||||
db, mock := setupMockDB(t)
|
||||
defer mock.ExpectClose()
|
||||
|
||||
// Expect query to return error
|
||||
mock.ExpectQuery("SELECT (.+) FROM `users`").
|
||||
WillReturnError(sql.ErrNoRows)
|
||||
|
||||
// Execute query
|
||||
var user User
|
||||
err := db.Select(&user, "SELECT `id`, `name`, `email`, `age`, `active`, `created_at`, `updated_at` FROM `users` WHERE `id` = @@id", 0,
|
||||
999)
|
||||
|
||||
// Verify error returned
|
||||
if !errors.Is(err, sql.ErrNoRows) {
|
||||
t.Errorf("Expected sql.ErrNoRows, got %v", err)
|
||||
}
|
||||
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("Unfulfilled expectations: %v", err)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Transaction Test
|
||||
|
||||
```go
|
||||
func TestTransaction(t *testing.T) {
|
||||
db, mock := setupMockDB(t)
|
||||
defer mock.ExpectClose()
|
||||
|
||||
// Expect transaction
|
||||
mock.ExpectBegin()
|
||||
mock.ExpectExec("INSERT INTO `users`").
|
||||
WithArgs(1, "Alice", "alice@example.com").
|
||||
WillReturnResult(sqlmock.NewResult(1, 1))
|
||||
mock.ExpectCommit()
|
||||
|
||||
// Execute transaction
|
||||
ctx := context.Background()
|
||||
tx, commit, cancel, err := mysql.GetOrCreateTxFromContext(ctx)
|
||||
defer cancel()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create tx: %v", err)
|
||||
}
|
||||
|
||||
// Store transaction in context so operations use it
|
||||
ctx = mysql.NewContextWithTx(ctx, tx)
|
||||
|
||||
user := User{ID: 1, Name: "Alice", Email: "alice@example.com"}
|
||||
err = db.Insert("users", user)
|
||||
if err != nil {
|
||||
t.Errorf("Insert failed: %v", err)
|
||||
}
|
||||
|
||||
if err := commit(); err != nil {
|
||||
t.Errorf("Commit failed: %v", err)
|
||||
}
|
||||
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("Unfulfilled expectations: %v", err)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Test Database Setup
|
||||
|
||||
### Docker Compose Setup
|
||||
|
||||
```yaml
|
||||
# docker-compose.test.yml
|
||||
version: '3.8'
|
||||
services:
|
||||
mysql-test:
|
||||
image: mysql:8.0
|
||||
environment:
|
||||
MYSQL_ROOT_PASSWORD: testpass
|
||||
MYSQL_DATABASE: testdb
|
||||
ports:
|
||||
- "3307:3306"
|
||||
tmpfs:
|
||||
- /var/lib/mysql # In-memory for speed
|
||||
```
|
||||
|
||||
### Test Helper
|
||||
|
||||
```go
|
||||
// testutil/db.go
|
||||
package testutil
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"testing"
|
||||
"github.com/StirlingMarketingGroup/cool-mysql"
|
||||
)
|
||||
|
||||
func SetupTestDB(t *testing.T) *mysql.Database {
|
||||
db, err := mysql.New(
|
||||
"root", "testpass", "testdb", "localhost", 3307,
|
||||
"root", "testpass", "testdb", "localhost", 3307,
|
||||
"utf8mb4_unicode_ci",
|
||||
"UTC",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to connect to test DB: %v", err)
|
||||
}
|
||||
|
||||
// Clean database before test
|
||||
cleanDB(t, db)
|
||||
|
||||
// Setup schema
|
||||
setupSchema(t, db)
|
||||
|
||||
return db
|
||||
}
|
||||
|
||||
func cleanDB(t *testing.T, db *mysql.Database) {
|
||||
tables := []string{"users", "orders", "products"}
|
||||
for _, table := range tables {
|
||||
db.Exec("DROP TABLE IF EXISTS " + table)
|
||||
}
|
||||
}
|
||||
|
||||
func setupSchema(t *testing.T, db *mysql.Database) {
|
||||
schema := `
|
||||
CREATE TABLE users (
|
||||
id INT PRIMARY KEY AUTO_INCREMENT,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
email VARCHAR(255) UNIQUE NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`
|
||||
if err := db.Exec(schema); err != nil {
|
||||
t.Fatalf("Failed to create schema: %v", err)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Using Test Database
|
||||
|
||||
```go
|
||||
func TestInsertUserIntegration(t *testing.T) {
|
||||
db := testutil.SetupTestDB(t)
|
||||
|
||||
user := User{
|
||||
Name: "Alice",
|
||||
Email: "alice@example.com",
|
||||
}
|
||||
|
||||
// Insert user
|
||||
err := db.Insert("users", user)
|
||||
if err != nil {
|
||||
t.Fatalf("Insert failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify insertion
|
||||
var retrieved User
|
||||
err = db.Select(&retrieved,
|
||||
"SELECT `id`, `name`, `email`, `age`, `active`, `created_at`, `updated_at` FROM `users` WHERE `email` = @@email",
|
||||
0,
|
||||
"alice@example.com")
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("Select failed: %v", err)
|
||||
}
|
||||
|
||||
if retrieved.Name != "Alice" {
|
||||
t.Errorf("Expected Alice, got %s", retrieved.Name)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Testing Patterns
|
||||
|
||||
### Table-Driven Tests
|
||||
|
||||
```go
|
||||
func TestSelectUsers(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
minAge int
|
||||
expected []User
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
name: "adults only",
|
||||
minAge: 18,
|
||||
expected: []User{
|
||||
{ID: 1, Name: "Alice", Age: 25},
|
||||
{ID: 2, Name: "Bob", Age: 30},
|
||||
},
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "no results",
|
||||
minAge: 100,
|
||||
expected: []User{},
|
||||
expectError: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
db, mock := setupMockDB(t)
|
||||
defer mock.ExpectClose()
|
||||
|
||||
rows := sqlmock.NewRows([]string{"id", "name", "age"})
|
||||
for _, u := range tt.expected {
|
||||
rows.AddRow(u.ID, u.Name, u.Age)
|
||||
}
|
||||
|
||||
mock.ExpectQuery("SELECT (.+) FROM `users`").
|
||||
WithArgs(tt.minAge).
|
||||
WillReturnRows(rows)
|
||||
|
||||
var users []User
|
||||
err := db.Select(&users,
|
||||
"SELECT `id`, `name`, `email`, `age`, `active`, `created_at`, `updated_at` FROM `users` WHERE age > @@minAge",
|
||||
0,
|
||||
tt.minAge)
|
||||
|
||||
if tt.expectError && err == nil {
|
||||
t.Error("Expected error, got nil")
|
||||
}
|
||||
|
||||
if !tt.expectError && err != nil {
|
||||
t.Errorf("Unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if len(users) != len(tt.expected) {
|
||||
t.Errorf("Expected %d users, got %d",
|
||||
len(tt.expected), len(users))
|
||||
}
|
||||
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("Unfulfilled expectations: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Testing Named Parameters
|
||||
|
||||
```go
|
||||
func TestNamedParameters(t *testing.T) {
|
||||
db, mock := setupMockDB(t)
|
||||
defer mock.ExpectClose()
|
||||
|
||||
// cool-mysql converts @@param to ? internally
|
||||
mock.ExpectQuery("SELECT (.+) FROM `users` WHERE age > \\? AND `status` = \\?").
|
||||
WithArgs(18, "active").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "name"}))
|
||||
|
||||
var users []User
|
||||
err := db.Select(&users,
|
||||
"SELECT `id`, `name`, `email`, `age`, `active`, `created_at`, `updated_at` FROM `users` WHERE age > @@minAge AND `status` = @@status",
|
||||
0,
|
||||
mysql.Params{"minAge": 18, "status": "active"})
|
||||
|
||||
if err != nil {
|
||||
t.Errorf("Query failed: %v", err)
|
||||
}
|
||||
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("Unfulfilled expectations: %v", err)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Testing Struct Tags
|
||||
|
||||
```go
|
||||
func TestStructTagMapping(t *testing.T) {
|
||||
db, mock := setupMockDB(t)
|
||||
defer mock.ExpectClose()
|
||||
|
||||
type CustomUser struct {
|
||||
UserID int `mysql:"id"`
|
||||
Name string `mysql:"user_name"`
|
||||
}
|
||||
|
||||
// Expect query with actual column names
|
||||
rows := sqlmock.NewRows([]string{"id", "user_name"}).
|
||||
AddRow(1, "Alice")
|
||||
|
||||
mock.ExpectQuery("SELECT `id`, user_name FROM `users`").
|
||||
WillReturnRows(rows)
|
||||
|
||||
var users []CustomUser
|
||||
err := db.Select(&users, "SELECT `id`, user_name FROM `users`", 0)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("Query failed: %v", err)
|
||||
}
|
||||
|
||||
if users[0].UserID != 1 {
|
||||
t.Errorf("Expected UserID=1, got %d", users[0].UserID)
|
||||
}
|
||||
|
||||
if users[0].Name != "Alice" {
|
||||
t.Errorf("Expected Name=Alice, got %s", users[0].Name)
|
||||
}
|
||||
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("Unfulfilled expectations: %v", err)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Context-Based Testing
|
||||
|
||||
### Testing with Context
|
||||
|
||||
```go
|
||||
func TestSelectWithContext(t *testing.T) {
|
||||
db, mock := setupMockDB(t)
|
||||
defer mock.ExpectClose()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
rows := sqlmock.NewRows([]string{"id", "name"}).
|
||||
AddRow(1, "Alice")
|
||||
|
||||
mock.ExpectQuery("SELECT (.+) FROM `users`").
|
||||
WillReturnRows(rows)
|
||||
|
||||
var users []User
|
||||
err := db.SelectContext(ctx, &users, "SELECT `id`, `name`, `email`, `age`, `active`, `created_at`, `updated_at` FROM `users`", 0)
|
||||
|
||||
if err != nil {
|
||||
t.Errorf("Query failed: %v", err)
|
||||
}
|
||||
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("Unfulfilled expectations: %v", err)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Testing Context Cancellation
|
||||
|
||||
```go
|
||||
func TestContextCancellation(t *testing.T) {
|
||||
db, mock := setupMockDB(t)
|
||||
defer mock.ExpectClose()
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel() // Cancel immediately
|
||||
|
||||
mock.ExpectQuery("SELECT (.+) FROM `users`").
|
||||
WillDelayFor(100 * time.Millisecond)
|
||||
|
||||
var users []User
|
||||
err := db.SelectContext(ctx, &users, "SELECT `id`, `name`, `email`, `age`, `active`, `created_at`, `updated_at` FROM `users`", 0)
|
||||
|
||||
if err == nil {
|
||||
t.Error("Expected context cancellation error")
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Testing Database in Context
|
||||
|
||||
```go
|
||||
func TestDatabaseInContext(t *testing.T) {
|
||||
db := testutil.SetupTestDB(t)
|
||||
|
||||
// Store DB in context
|
||||
ctx := mysql.NewContext(context.Background(), db)
|
||||
|
||||
// Retrieve DB from context
|
||||
retrievedDB := mysql.FromContext(ctx)
|
||||
|
||||
if retrievedDB == nil {
|
||||
t.Error("Expected database in context")
|
||||
}
|
||||
|
||||
// Use DB from context
|
||||
var users []User
|
||||
err := retrievedDB.Select(&users, "SELECT `id`, `name`, `email`, `age`, `active`, `created_at`, `updated_at` FROM `users`", 0)
|
||||
|
||||
if err != nil {
|
||||
t.Errorf("Query failed: %v", err)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Testing Caching
|
||||
|
||||
### Testing Cache Hits
|
||||
|
||||
```go
|
||||
func TestCacheHit(t *testing.T) {
|
||||
db, mock := setupMockDB(t)
|
||||
defer mock.ExpectClose()
|
||||
|
||||
// Enable weak cache for testing
|
||||
db.UseCache(mysql.NewWeakCache())
|
||||
|
||||
rows := sqlmock.NewRows([]string{"id", "name"}).
|
||||
AddRow(1, "Alice")
|
||||
|
||||
// First query - cache miss
|
||||
mock.ExpectQuery("SELECT (.+) FROM `users`").
|
||||
WillReturnRows(rows)
|
||||
|
||||
var users1 []User
|
||||
err := db.Select(&users1, "SELECT `id`, `name`, `email`, `age`, `active`, `created_at`, `updated_at` FROM `users`", 5*time.Minute)
|
||||
if err != nil {
|
||||
t.Fatalf("First query failed: %v", err)
|
||||
}
|
||||
|
||||
// Second query - cache hit (no DB query expected)
|
||||
var users2 []User
|
||||
err = db.Select(&users2, "SELECT `id`, `name`, `email`, `age`, `active`, `created_at`, `updated_at` FROM `users`", 5*time.Minute)
|
||||
if err != nil {
|
||||
t.Fatalf("Second query failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify same results
|
||||
if len(users1) != len(users2) {
|
||||
t.Error("Cache returned different results")
|
||||
}
|
||||
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("Unfulfilled expectations: %v", err)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Testing Cache Bypass
|
||||
|
||||
```go
|
||||
func TestCacheBypass(t *testing.T) {
|
||||
db, mock := setupMockDB(t)
|
||||
defer mock.ExpectClose()
|
||||
|
||||
db.UseCache(mysql.NewWeakCache())
|
||||
|
||||
rows := sqlmock.NewRows([]string{"id", "name"}).
|
||||
AddRow(1, "Alice")
|
||||
|
||||
// Each query with TTL=0 should hit database
|
||||
mock.ExpectQuery("SELECT (.+) FROM `users`").
|
||||
WillReturnRows(rows)
|
||||
mock.ExpectQuery("SELECT (.+) FROM `users`").
|
||||
WillReturnRows(rows)
|
||||
|
||||
var users []User
|
||||
db.Select(&users, "SELECT `id`, `name`, `email`, `age`, `active`, `created_at`, `updated_at` FROM `users`", 0) // TTL=0, no cache
|
||||
db.Select(&users, "SELECT `id`, `name`, `email`, `age`, `active`, `created_at`, `updated_at` FROM `users`", 0) // TTL=0, no cache
|
||||
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("Expected 2 queries, got different: %v", err)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Integration Testing
|
||||
|
||||
### End-to-End Test
|
||||
|
||||
```go
|
||||
func TestUserWorkflow(t *testing.T) {
|
||||
db := testutil.SetupTestDB(t)
|
||||
|
||||
// Create user
|
||||
user := User{
|
||||
Name: "Alice",
|
||||
Email: "alice@example.com",
|
||||
}
|
||||
|
||||
err := db.Insert("users", user)
|
||||
if err != nil {
|
||||
t.Fatalf("Insert failed: %v", err)
|
||||
}
|
||||
|
||||
// Query user
|
||||
var retrieved User
|
||||
err = db.Select(&retrieved,
|
||||
"SELECT `id`, `name`, `email`, `age`, `active`, `created_at`, `updated_at` FROM `users` WHERE `email` = @@email",
|
||||
0,
|
||||
"alice@example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("Select failed: %v", err)
|
||||
}
|
||||
|
||||
// Update user
|
||||
err = db.Exec("UPDATE `users` SET `name` = @@name WHERE `email` = @@email",
|
||||
mysql.Params{"name": "Alice Updated", "email": "alice@example.com"})
|
||||
if err != nil {
|
||||
t.Fatalf("Update failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify update
|
||||
err = db.Select(&retrieved,
|
||||
"SELECT `id`, `name`, `email`, `age`, `active`, `created_at`, `updated_at` FROM `users` WHERE `email` = @@email",
|
||||
0,
|
||||
"alice@example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("Select after update failed: %v", err)
|
||||
}
|
||||
|
||||
if retrieved.Name != "Alice Updated" {
|
||||
t.Errorf("Expected 'Alice Updated', got '%s'", retrieved.Name)
|
||||
}
|
||||
|
||||
// Delete user
|
||||
err = db.Exec("DELETE FROM `users` WHERE `email` = @@email",
|
||||
"alice@example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("Delete failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify deletion
|
||||
err = db.Select(&retrieved,
|
||||
"SELECT `id`, `name`, `email`, `age`, `active`, `created_at`, `updated_at` FROM `users` WHERE `email` = @@email",
|
||||
0,
|
||||
"alice@example.com")
|
||||
if !errors.Is(err, sql.ErrNoRows) {
|
||||
t.Error("Expected user to be deleted")
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### 1. Use Helper Functions
|
||||
|
||||
```go
|
||||
func expectUserQuery(mock sqlmock.Sqlmock, users []User) {
|
||||
rows := sqlmock.NewRows([]string{"id", "name", "email"})
|
||||
for _, u := range users {
|
||||
rows.AddRow(u.ID, u.Name, u.Email)
|
||||
}
|
||||
mock.ExpectQuery("SELECT (.+) FROM `users`").WillReturnRows(rows)
|
||||
}
|
||||
|
||||
func TestWithHelper(t *testing.T) {
|
||||
db, mock := setupMockDB(t)
|
||||
defer mock.ExpectClose()
|
||||
|
||||
expectedUsers := []User{{ID: 1, Name: "Alice", Email: "alice@example.com"}}
|
||||
expectUserQuery(mock, expectedUsers)
|
||||
|
||||
var users []User
|
||||
db.Select(&users, "SELECT `id`, `name`, `email`, `age`, `active`, `created_at`, `updated_at` FROM `users`", 0)
|
||||
|
||||
// Assertions...
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Test Error Paths
|
||||
|
||||
```go
|
||||
func TestInsertDuplicateEmail(t *testing.T) {
|
||||
db, mock := setupMockDB(t)
|
||||
defer mock.ExpectClose()
|
||||
|
||||
mock.ExpectExec("INSERT INTO `users`").
|
||||
WillReturnError(&mysqlDriver.MySQLError{Number: 1062}) // Duplicate entry
|
||||
|
||||
user := User{Name: "Alice", Email: "alice@example.com"}
|
||||
err := db.Insert("users", user)
|
||||
|
||||
if err == nil {
|
||||
t.Error("Expected duplicate key error")
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Clean Up Resources
|
||||
|
||||
```go
|
||||
func TestWithCleanup(t *testing.T) {
|
||||
db, mock := setupMockDB(t)
|
||||
t.Cleanup(func() {
|
||||
mock.ExpectClose()
|
||||
// Any other cleanup
|
||||
})
|
||||
|
||||
// Test code...
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Test Concurrent Access
|
||||
|
||||
```go
|
||||
func TestConcurrentAccess(t *testing.T) {
|
||||
db := testutil.SetupTestDB(t)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
errors := make(chan error, 10)
|
||||
|
||||
for i := 0; i < 10; i++ {
|
||||
wg.Add(1)
|
||||
go func(id int) {
|
||||
defer wg.Done()
|
||||
|
||||
user := User{
|
||||
Name: fmt.Sprintf("User%d", id),
|
||||
Email: fmt.Sprintf("user%d@example.com", id),
|
||||
}
|
||||
|
||||
if err := db.Insert("users", user); err != nil {
|
||||
errors <- err
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
close(errors)
|
||||
|
||||
for err := range errors {
|
||||
t.Errorf("Concurrent insert failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify all users inserted
|
||||
var count int64
|
||||
count, err := db.Count("SELECT COUNT(*) FROM `users`", 0)
|
||||
if err != nil {
|
||||
t.Fatalf("Count failed: %v", err)
|
||||
}
|
||||
|
||||
if count != 10 {
|
||||
t.Errorf("Expected 10 users, got %d", count)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 5. Use Subtests
|
||||
|
||||
```go
|
||||
func TestUserOperations(t *testing.T) {
|
||||
db := testutil.SetupTestDB(t)
|
||||
|
||||
t.Run("Insert", func(t *testing.T) {
|
||||
user := User{Name: "Alice", Email: "alice@example.com"}
|
||||
err := db.Insert("users", user)
|
||||
if err != nil {
|
||||
t.Fatalf("Insert failed: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Select", func(t *testing.T) {
|
||||
var users []User
|
||||
err := db.Select(&users, "SELECT `id`, `name`, `email`, `age`, `active`, `created_at`, `updated_at` FROM `users`", 0)
|
||||
if err != nil {
|
||||
t.Fatalf("Select failed: %v", err)
|
||||
}
|
||||
if len(users) == 0 {
|
||||
t.Error("Expected at least one user")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Update", func(t *testing.T) {
|
||||
err := db.Exec("UPDATE `users` SET `name` = @@name WHERE `email` = @@email",
|
||||
mysql.Params{"name": "Alice Updated", "email": "alice@example.com"})
|
||||
if err != nil {
|
||||
t.Fatalf("Update failed: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
### 6. Verify Expectations
|
||||
|
||||
```go
|
||||
func TestAlwaysVerify(t *testing.T) {
|
||||
db, mock := setupMockDB(t)
|
||||
defer func() {
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("Unfulfilled expectations: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
// Test code...
|
||||
}
|
||||
```
|
||||
|
||||
### 7. Test Parameter Interpolation
|
||||
|
||||
```go
|
||||
func TestParameterInterpolation(t *testing.T) {
|
||||
db, _ := setupMockDB(t)
|
||||
|
||||
query := "SELECT `id`, `name`, `email`, `age`, `active`, `created_at`, `updated_at` FROM `users` WHERE age > @@minAge AND `status` = @@status"
|
||||
params := mysql.Params{"minAge": 18, "status": "active"}
|
||||
|
||||
replacedQuery, normalizedParams, err := db.InterpolateParams(query, params)
|
||||
if err != nil {
|
||||
t.Fatalf("InterpolateParams failed: %v", err)
|
||||
}
|
||||
|
||||
expectedQuery := "SELECT `id`, `name`, `email`, `age`, `active`, `created_at`, `updated_at` FROM `users` WHERE age > ? AND `status` = ?"
|
||||
if replacedQuery != expectedQuery {
|
||||
t.Errorf("Expected query '%s', got '%s'", expectedQuery, replacedQuery)
|
||||
}
|
||||
|
||||
if len(normalizedParams) != 2 {
|
||||
t.Errorf("Expected 2 params, got %d", len(normalizedParams))
|
||||
}
|
||||
}
|
||||
```
|
||||
Reference in New Issue
Block a user