commit 322d28c7eba3ead6112cce0bccd4e00b16a7fcf1 Author: Zhongwei Li Date: Sun Nov 30 08:58:35 2025 +0800 Initial commit diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json new file mode 100644 index 0000000..8659d38 --- /dev/null +++ b/.claude-plugin/plugin.json @@ -0,0 +1,12 @@ +{ + "name": "cool-mysql", + "description": "Comprehensive guide for cool-mysql Go library - MySQL helper with dual connection pools, named parameters, template syntax, caching, and advanced query patterns", + "version": "0.0.0-2025.11.28", + "author": { + "name": "Stirling Marketing Group", + "email": "zhongweili@tubi.tv" + }, + "skills": [ + "./" + ] +} \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..8e898e7 --- /dev/null +++ b/README.md @@ -0,0 +1,3 @@ +# cool-mysql + +Comprehensive guide for cool-mysql Go library - MySQL helper with dual connection pools, named parameters, template syntax, caching, and advanced query patterns diff --git a/SKILL.md b/SKILL.md new file mode 100644 index 0000000..0eca4e2 --- /dev/null +++ b/SKILL.md @@ -0,0 +1,518 @@ +--- +name: cool-mysql +description: Use this skill when working with the cool-mysql library for Go. This skill provides comprehensive guidance on using cool-mysql's MySQL helper functions, including dual connection pools, named parameters, template syntax, caching strategies, and advanced query patterns. Apply when writing database code, optimizing queries, setting up caching, or migrating from database/sql. +--- + +# cool-mysql MySQL Helper Library + +## Overview + +`cool-mysql` is a MySQL helper library for Go that wraps `database/sql` with MySQL-specific conveniences while keeping the underlying interfaces intact. The library reduces boilerplate code for common database operations while providing advanced features like caching, automatic retries, and dual read/write connection pools. + +**Core Philosophy:** +- Keep `database/sql` interfaces intact +- Provide conveniences without hiding MySQL behavior +- Focus on productivity without sacrificing control +- Type-safe operations with flexible result mapping + +## When to Use This Skill + +Use this skill when: +- Writing MySQL database operations in Go +- Setting up database connections with read/write separation +- Implementing caching strategies for queries +- Working with struct mappings and MySQL columns +- Migrating from `database/sql` to `cool-mysql` +- Optimizing query performance +- Handling transactions with proper context management +- Debugging query issues or understanding error handling +- Implementing CRUD operations, upserts, or batch inserts + +## Core Concepts + +### 1. Dual Connection Pools + +`cool-mysql` maintains separate connection pools for reads and writes to optimize for read-heavy workloads. + +**Default Behavior:** +- `Select()`, `SelectJSON()`, `Count()`, `Exists()` → Read pool +- `Insert()`, `Upsert()`, `Exec()` → Write pool +- `SelectWrites()`, `ExistsWrites()` → Write pool (for read-after-write consistency) + +**When to use SelectWrites():** +Use immediately after writing data when you need consistency: +```go +db.Insert("users", user) +// Need immediate consistency - use write pool +db.SelectWrites(&user, "SELECT `id`, `name`, `email`, `age`, `active`, `created_at`, `updated_at` FROM `users` WHERE `id` = @@id", 0, user.ID) +``` + +### 2. Named Parameters + +cool-mysql uses `@@paramName` syntax instead of positional `?` placeholders. + +**Key Points:** +- Parameters are case-insensitive when merged +- Structs can be used directly as parameters (field names → parameter names) +- Use `mysql.Params{"key": value}` for explicit parameters +- Use `mysql.Raw()` to inject literal SQL (not escaped) + +**Example:** +```go +// Named parameters +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 +user := User{ID: 1, Name: "Alice"} +db.Exec("UPDATE `users` SET `name` = @@Name WHERE `id` = @@ID", user) + +// Raw SQL injection +db.Select(&users, "SELECT `id`, `name`, `email`, `age`, `active`, `created_at`, `updated_at` FROM `users` WHERE @@condition", 0, + mysql.Raw("created_at > NOW() - INTERVAL 1 DAY")) +``` + +### 3. Template Syntax + +cool-mysql supports Go template syntax for conditional query logic. + +**Important Distinctions:** +- Template variables use **field names** (`.Name`), not column names from tags +- Template processing happens **before** parameter interpolation +- Access parameters directly as fields: `.ParamName` + +**CRITICAL: Marshaling Template Values** + +When injecting VALUES (not identifiers) via templates, you MUST use the `marshal` pipe: + +```go +// ✅ CORRECT - Use @@param for values (automatically marshaled) +query := "SELECT `id`, `name`, `email`, `age`, `active`, `created_at`, `updated_at` FROM `users` WHERE {{ if .MinAge }}`age` > @@minAge{{ end }}" + +// ✅ CORRECT - Use | marshal when injecting value directly in template +query := "SELECT `id`, `name`, `email`, `age`, `active`, `created_at`, `updated_at` FROM `users` WHERE `name` = {{ .Name | marshal }}" + +// ❌ WRONG - Direct injection without marshal causes syntax errors +query := "SELECT `id`, `name`, `email`, `age`, `active`, `created_at`, `updated_at` FROM `users` WHERE `name` = {{ .Name }}" // BROKEN! + +// ✅ CORRECT - Identifiers (column names) validated, then injected +if !allowedColumns[sortBy] { return errors.New("invalid column") } +query := "SELECT `id`, `name`, `email`, `age`, `active`, `created_at`, `updated_at` FROM `users` ORDER BY {{ .SortBy }}" // OK - validated identifier +``` + +**Best Practice:** Use `@@param` syntax for values. Only use template injection with `| marshal` when you need conditional value logic. + +**Example:** +```go +db.Select(&users, + "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 }}", + 0, + mysql.Params{"minAge": 18, "status": "active"}) +``` + +### 4. Caching + +cool-mysql provides pluggable caching with support for Redis, Memcached, or in-memory storage. + +**Cache TTL:** +- `0` = No caching (always query database) +- `> 0` = Cache for specified duration (e.g., `5*time.Minute`) + +**Cache Setup:** +```go +// Redis (with distributed locking) +db.EnableRedis(redisClient) + +// Memcached +db.EnableMemcache(memcacheClient) + +// In-memory (weak pointers, GC-managed) +db.UseCache(mysql.NewWeakCache()) + +// Layered caching (fast local + shared distributed) +db.UseCache(mysql.NewMultiCache( + mysql.NewWeakCache(), // L1: Fast local cache + mysql.NewRedisCache(redis), // L2: Shared distributed cache +)) +``` + +**Only SELECT operations are cached** - writes always hit the database. + +### 5. Struct Tag Mapping + +Control column mapping and behavior with `mysql` struct tags. + +**Tag Options:** +- `mysql:"column_name"` - Map to database column +- `mysql:"column_name,defaultzero"` - Write `DEFAULT(column_name)` for zero values +- `mysql:"column_name,omitempty"` - Same as `defaultzero` +- `mysql:"column_name,insertDefault"` - Same as `defaultzero` +- `mysql:"-"` - Completely ignore this field +- `mysql:"column0x2cname"` - Hex encoding for special characters (becomes `column,name`) + +**Example:** +```go +type User struct { + ID int `mysql:"id"` + Name string `mysql:"name"` + Email string `mysql:"email"` + CreatedAt time.Time `mysql:"created_at,defaultzero"` // Use DB default on zero value + UpdatedAt time.Time `mysql:"updated_at,defaultzero"` + Password string `mysql:"-"` // Never include in queries +} +``` + +## Quick Start Guide + +### Creating a Database Connection + +**From connection parameters:** +```go +db, err := mysql.New( + wUser, wPass, wSchema, wHost, wPort, // Write connection + rUser, rPass, rSchema, rHost, rPort, // Read connection + collation, // e.g., "utf8mb4_unicode_ci" + timeZone, // e.g., "America/New_York" +) +``` + +**From DSN strings:** +```go +db, err := mysql.NewFromDSN(writesDSN, readsDSN) +``` + +**From existing connections:** +```go +db, err := mysql.NewFromConn(writesConn, readsConn) +``` + +### Basic Query Patterns + +**Select into struct slice:** +```go +var users []User +err := db.Select(&users, "SELECT `id`, `name`, `email`, `age`, `active`, `created_at`, `updated_at` FROM `users` WHERE `age` > @@minAge", 0, 18) +``` + +**Select single value:** +```go +var name string +err := db.Select(&name, "SELECT `name` FROM `users` WHERE `id` = @@id", 0, 1) +// Returns sql.ErrNoRows if not found +``` + +**Count records:** +```go +count, err := db.Count("SELECT COUNT(*) FROM `users` WHERE `active` = @@active", 0, 1) +``` + +**Check existence:** +```go +exists, err := db.Exists("SELECT 1 FROM `users` WHERE `email` = @@email", 0, "user@example.com") +``` + +**Insert data:** +```go +// Single insert +user := User{Name: "Alice", Email: "alice@example.com"} +err := db.Insert("users", user) + +// Batch insert (automatically chunked) +users := []User{{Name: "Bob"}, {Name: "Charlie"}} +err := db.Insert("users", users) +``` + +**Upsert (INSERT ... ON DUPLICATE KEY UPDATE):** +```go +err := db.Upsert( + "users", // table + []string{"email"}, // unique columns + []string{"name", "updated_at"}, // columns to update on conflict + "", // optional WHERE clause + user, // data +) +``` + +**Execute query:** +```go +err := db.Exec("UPDATE `users` SET `active` = 1 WHERE `id` = @@id", 1) +``` + +## Migration Guide from database/sql + +### Key Differences + +| database/sql | cool-mysql | Notes | +|--------------|------------|-------| +| `?` placeholders | `@@paramName` | Named parameters are case-insensitive | +| `db.Query()` + `rows.Scan()` | `db.Select(&result, query, cacheTTL, params)` | Automatic scanning into structs | +| Manual connection pools | Dual pools (read/write) | Automatic routing based on operation | +| No caching | Built-in caching | Pass TTL as second parameter | +| `sql.ErrNoRows` always | `sql.ErrNoRows` for single values only | Slices return empty, not error | +| Manual chunking | Automatic chunking | Insert operations respect `max_allowed_packet` | +| No retry logic | Automatic retries | Handles deadlocks, timeouts, connection losses | + +### Migration Pattern + +**Before (database/sql):** +```go +rows, err := db.Query("SELECT `id`, `name`, `email` FROM `users` WHERE `age` > ?", 18) +if err != nil { + return err +} +defer rows.Close() + +var users []User +for rows.Next() { + var u User + if err := rows.Scan(&u.ID, &u.Name, &u.Email); err != nil { + return err + } + users = append(users, u) +} +return rows.Err() +``` + +**After (cool-mysql):** +```go +var users []User +return db.Select(&users, "SELECT `id`, `name`, `email`, `age`, `active`, `created_at`, `updated_at` FROM `users` WHERE `age` > @@minAge", 0, 18) +``` + +## Best Practices + +### Parameter Handling + +**DO:** +- Use `@@paramName` syntax consistently +- Use `mysql.Params{}` for clarity +- Use structs as parameters when appropriate +- Use `mysql.Raw()` for literal SQL that shouldn't be escaped + +**DON'T:** +- Mix `?` and `@@` syntax (use `@@` exclusively) +- Assume parameters are case-sensitive (they're normalized) +- Inject user input with `mysql.Raw()` (SQL injection risk) + +### Template Usage + +**DO:** +- Use templates for conditional query logic +- Use `@@param` for values (preferred - automatically marshaled) +- Use `{{.Field | marshal}}` when injecting values directly in templates +- Validate/whitelist identifiers (column names) before template injection +- Reference parameters by field name: `.ParamName` +- Add custom template functions with `db.AddTemplateFuncs()` + +**DON'T:** +- Inject values without marshal: `{{.Name}}` causes syntax errors +- Use column names in templates (use field names) +- Forget that templates process before parameter interpolation +- Use templates when named parameters suffice +- Inject user-controlled identifiers without validation + +### Caching Strategy + +**DO:** +- Use `0` TTL for frequently-changing data +- Use longer TTLs (5-60 minutes) for stable reference data +- Use `SelectWrites()` immediately after writes for consistency +- Consider `MultiCache` for high-traffic applications +- Enable Redis distributed locking to prevent cache stampedes + +**DON'T:** +- Cache writes (they're automatically skipped) +- Use same TTL for all queries (tune based on data volatility) +- Forget that cache keys include query + parameters + +### Struct Tags + +**DO:** +- Use `defaultzero` for timestamp columns with DB defaults +- Use `mysql:"-"` to exclude sensitive fields +- Use hex encoding for column names with special characters +- Implement `Zeroer` interface for custom zero-value detection + +**DON'T:** +- Forget that tag column names override field names +- Mix `json` tags with `mysql` tags without testing + +### Error Handling + +**DO:** +- Check for `sql.ErrNoRows` when selecting single values +- Rely on automatic retries for transient errors (deadlocks, timeouts) +- Use `ExecResult()` when you need `LastInsertId()` or `RowsAffected()` + +**DON'T:** +- Expect `sql.ErrNoRows` when selecting into slices (returns empty slice) +- Implement manual retry logic (already built-in) + +### Performance Optimization + +**DO:** +- Use channels for memory-efficient streaming of large datasets +- Use `SelectWrites()` sparingly (only when consistency required) +- Enable caching for expensive or frequent queries +- Use batch operations (slices/channels) for large inserts + +**DON'T:** +- Load entire large result sets into memory when streaming is possible +- Use `SelectWrites()` as default (defeats read pool optimization) +- Cache everything (tune TTL based on access patterns) + +## Advanced Patterns + +### Streaming with Channels + +**Select into channel:** +```go +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) +}() + +for user := range userCh { + // Process user +} +``` + +**Insert from channel:** +```go +userCh := make(chan User) +go func() { + for _, u := range users { + userCh <- u + } + close(userCh) +}() + +err := db.Insert("users", userCh) +``` + +### Function Receivers + +```go +err := db.Select(func(u User) { + log.Printf("Processing user: %s", u.Name) +}, "SELECT `id`, `name`, `email`, `age`, `active`, `created_at`, `updated_at` FROM `users`", 0) +``` + +### Transaction Management + +```go +tx, commit, cancel, err := mysql.GetOrCreateTxFromContext(ctx) +defer cancel() +if err != nil { + return err +} + +// Store transaction in context +ctx = mysql.NewContextWithTx(ctx, tx) + +// Do database operations... + +if err := commit(); err != nil { + return err +} +``` + +### Custom Interfaces + +**Custom zero detection:** +```go +type CustomTime struct { + time.Time +} + +func (ct CustomTime) IsZero() bool { + return ct.Time.IsZero() || ct.Time.Unix() == 0 +} +``` + +**Custom value conversion:** +```go +type Point struct { + X, Y float64 +} + +func (p Point) Values() []any { + return []any{p.X, p.Y} +} +``` + +## Environment Variables + +Configure behavior via environment variables: + +- `COOL_MAX_EXECUTION_TIME_TIME` - Max query execution time (default: 27s) +- `COOL_MAX_ATTEMPTS` - Max retry attempts (default: unlimited) +- `COOL_REDIS_LOCK_RETRY_DELAY` - Lock retry delay (default: 0.020s) +- `COOL_MYSQL_MAX_QUERY_LOG_LENGTH` - Max query length in logs (default: 4096 bytes) + +## Bundled Resources + +This skill includes comprehensive reference documentation and working examples: + +### Reference Documentation (`references/`) + +- **api-reference.md** - Complete API documentation for all methods +- **query-patterns.md** - Query pattern examples and best practices +- **caching-guide.md** - Detailed caching strategies and configuration +- **struct-tags.md** - Comprehensive struct tag reference +- **testing-patterns.md** - Testing approaches with sqlmock + +To access reference documentation: +``` +Read references/api-reference.md for complete API documentation +Read references/query-patterns.md for query examples +Read references/caching-guide.md for caching strategies +Read references/struct-tags.md for struct tag details +Read references/testing-patterns.md for testing patterns +``` + +### Working Examples (`examples/`) + +- **basic-crud.go** - Simple CRUD operations +- **advanced-queries.go** - Templates, channels, function receivers +- **caching-setup.go** - Cache configuration examples +- **transaction-patterns.go** - Transaction handling patterns +- **upsert-examples.go** - Upsert use cases + +To access examples: +``` +Read examples/basic-crud.go for basic patterns +Read examples/advanced-queries.go for advanced usage +Read examples/caching-setup.go for cache setup +Read examples/transaction-patterns.go for transactions +Read examples/upsert-examples.go for upsert patterns +``` + +## Common Gotchas + +1. **Empty Result Handling**: Selecting into slice returns empty slice (not `sql.ErrNoRows`); selecting into single value returns `sql.ErrNoRows` + +2. **Template vs Column Names**: Templates use field names (`.Name`), not column names from tags + +3. **Cache Keys**: Include both query and parameters, so identical queries with different params cache separately + +4. **Read/Write Consistency**: Use `SelectWrites()` immediately after writes, not `Select()` + +5. **Struct Tag Priority**: `mysql` tag overrides field name for column mapping + +6. **Parameter Case**: Parameters are case-insensitive when merged (normalized to lowercase) + +7. **Automatic Chunking**: Large inserts automatically chunk based on `max_allowed_packet` + +8. **Retry Behavior**: Automatic retries for error codes 1213 (deadlock), 1205 (lock timeout), 2006 (server gone), 2013 (connection lost) + +## Next Steps + +- Read `references/api-reference.md` for complete API documentation +- Check `examples/basic-crud.go` to see common patterns in action +- Review `references/caching-guide.md` for caching best practices +- Study `references/struct-tags.md` for advanced struct mapping +- Explore `examples/advanced-queries.go` for complex query patterns diff --git a/examples/advanced-queries.go b/examples/advanced-queries.go new file mode 100644 index 0000000..5a99c89 --- /dev/null +++ b/examples/advanced-queries.go @@ -0,0 +1,725 @@ +// Package examples demonstrates advanced query patterns with cool-mysql +package examples + +import ( + "encoding/json" + "fmt" + "html/template" + "log" + "strings" + "time" + + mysql "github.com/StirlingMarketingGroup/cool-mysql" +) + +// AdvancedQueryExamples demonstrates advanced query patterns +func AdvancedQueryExamples() { + db, err := setupDatabase() + if err != nil { + log.Fatalf("Failed to setup database: %v", err) + } + + // Template queries + fmt.Println("=== TEMPLATE QUERY EXAMPLES ===") + templateExamples(db) + + // Channel streaming + fmt.Println("\n=== CHANNEL STREAMING EXAMPLES ===") + channelExamples(db) + + // Function receivers + fmt.Println("\n=== FUNCTION RECEIVER EXAMPLES ===") + functionReceiverExamples(db) + + // JSON handling + fmt.Println("\n=== JSON HANDLING EXAMPLES ===") + jsonExamples(db) + + // Raw SQL + fmt.Println("\n=== RAW SQL EXAMPLES ===") + rawSQLExamples(db) + + // Complex queries + fmt.Println("\n=== COMPLEX QUERY EXAMPLES ===") + complexQueryExamples(db) +} + +// templateExamples demonstrates Go template syntax in queries +func templateExamples(db *mysql.Database) { + // Example 1: Conditional WHERE clause + fmt.Println("1. Conditional WHERE clause") + + type SearchParams struct { + MinAge int + Status string + Name string + } + + // Search with all parameters + params := SearchParams{ + MinAge: 25, + Status: "active", + Name: "Alice", + } + + 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 }}" + + " {{ if .Name }}AND `name` LIKE CONCAT('%', @@Name, '%'){{ end }}" + + var users []User + err := db.Select(&users, query, 0, params) + if err != nil { + log.Printf("Template query failed: %v", err) + } else { + fmt.Printf("✓ Found %d users with filters\n", len(users)) + } + + // Example 2: Dynamic ORDER BY (with validation) + fmt.Println("\n2. Dynamic ORDER BY with whitelisting") + + type SortParams struct { + SortBy string + SortOrder string + } + + // Whitelist allowed columns - identifiers can't be marshaled + allowedColumns := map[string]bool{ + "created_at": true, + "name": true, + "age": true, + } + + sortParams := SortParams{ + SortBy: "created_at", + SortOrder: "DESC", + } + + // Validate before using in query + if !allowedColumns[sortParams.SortBy] { + log.Printf("Invalid sort column: %s", sortParams.SortBy) + return + } + + sortQuery := "SELECT `id`, `name`, `email`, `age`, `active`, `created_at`, `updated_at` FROM `users`" + + " WHERE `active` = 1" + + " {{ if .SortBy }}" + + " ORDER BY {{ .SortBy }} {{ .SortOrder }}" + + " {{ end }}" + + err = db.Select(&users, sortQuery, 0, sortParams) + if err != nil { + log.Printf("Sort query failed: %v", err) + } else { + fmt.Printf("✓ Users sorted by %s %s\n", sortParams.SortBy, sortParams.SortOrder) + } + + // Example 3: Conditional JOINs + fmt.Println("\n3. Conditional JOINs") + + type JoinParams struct { + IncludeOrders bool + IncludeAddress bool + IncludeMetadata bool + } + + joinParams := JoinParams{ + IncludeOrders: true, + IncludeAddress: false, + } + + joinQuery := "SELECT `users`.`id`, `users`.`name`, `users`.`email`, `users`.`age`, `users`.`active`, `users`.`created_at`, `users`.`updated_at`" + + " {{ if .IncludeOrders }}, COUNT(`orders`.`id`) as `order_count`{{ end }}" + + " {{ if .IncludeAddress }}, `addresses`.`city`{{ end }}" + + " FROM `users`" + + " {{ if .IncludeOrders }}" + + " LEFT JOIN `orders` ON `users`.`id` = `orders`.`user_id`" + + " {{ end }}" + + " {{ if .IncludeAddress }}" + + " LEFT JOIN `addresses` ON `users`.`id` = `addresses`.`user_id`" + + " {{ end }}" + + " GROUP BY `users`.`id`" + + err = db.Select(&users, joinQuery, 0, joinParams) + if err != nil { + log.Printf("Join query failed: %v", err) + } else { + fmt.Println("✓ Query with conditional joins executed") + } + + // Example 4: Custom template functions + fmt.Println("\n4. Custom template functions") + + // Add custom functions + db.AddTemplateFuncs(template.FuncMap{ + "upper": strings.ToUpper, + "lower": strings.ToLower, + "quote": func(s string) string { return fmt.Sprintf("'%s'", s) }, + }) + + type CaseParams struct { + SearchTerm string + CaseSensitive bool + UseWildcard bool + } + + caseParams := CaseParams{ + SearchTerm: "alice", + CaseSensitive: false, + UseWildcard: true, + } + + // IMPORTANT: Template values must be marshaled with | marshal + caseQuery := "SELECT `id`, `name`, `email`, `age`, `active`, `created_at`, `updated_at` FROM `users`" + + " WHERE {{ if not .CaseSensitive }}UPPER(`name`){{ else }}`name`{{ end }} LIKE CONCAT('%', {{ .SearchTerm | marshal }}, '%')" + + err = db.Select(&users, caseQuery, 0, caseParams) + if err != nil { + log.Printf("Custom function query failed: %v", err) + } else { + fmt.Println("✓ Query with custom template functions executed") + } + + // Example 5: Complex conditional logic + fmt.Println("\n5. Complex conditional logic") + + type FilterParams struct { + AgeRange []int + Statuses []string + DateFrom time.Time + DateTo time.Time + ActiveOnly bool + } + + filterParams := FilterParams{ + AgeRange: []int{25, 40}, + ActiveOnly: true, + DateFrom: time.Now().Add(-30 * 24 * time.Hour), + } + + filterQuery := "SELECT `id`, `name`, `email`, `age`, `active`, `created_at`, `updated_at` FROM `users`" + + " WHERE 1=1" + + " {{ if .AgeRange }}" + + " AND `age` BETWEEN @@AgeMin AND @@AgeMax" + + " {{ end }}" + + " {{ if .ActiveOnly }}" + + " AND `active` = 1" + + " {{ end }}" + + " {{ if not .DateFrom.IsZero }}" + + " AND `created_at` >= @@DateFrom" + + " {{ end }}" + + " {{ if not .DateTo.IsZero }}" + + " AND `created_at` <= @@DateTo" + + " {{ end }}" + + queryParams := mysql.Params{ + "AgeMin": filterParams.AgeRange[0], + "AgeMax": filterParams.AgeRange[1], + "DateFrom": filterParams.DateFrom, + } + + err = db.Select(&users, filterQuery, 0, queryParams, filterParams) + if err != nil { + log.Printf("Complex filter query failed: %v", err) + } else { + fmt.Printf("✓ Found %d users with complex filters\n", len(users)) + } +} + +// channelExamples demonstrates streaming with channels +func channelExamples(db *mysql.Database) { + // Example 1: Stream SELECT results + fmt.Println("1. Stream SELECT results to channel") + + userCh := make(chan User, 10) // Buffered channel + + go func() { + defer close(userCh) + err := db.Select(userCh, "SELECT `id`, `name`, `email`, `age`, `active`, `created_at`, `updated_at` FROM `users` WHERE `active` = 1", 0) + if err != nil { + log.Printf("Channel select failed: %v", err) + } + }() + + count := 0 + for user := range userCh { + fmt.Printf(" Received: %s (%s)\n", user.Name, user.Email) + count++ + if count >= 5 { + fmt.Println(" (showing first 5 results)") + // Drain remaining + for range userCh { + } + break + } + } + + // Example 2: Stream INSERT from channel + fmt.Println("\n2. Stream INSERT from channel") + + insertCh := make(chan User, 10) + + go func() { + defer close(insertCh) + for i := 0; i < 100; i++ { + insertCh <- User{ + Name: fmt.Sprintf("StreamUser%d", i), + Email: fmt.Sprintf("stream%d@example.com", i), + Age: 20 + (i % 50), + Active: i%2 == 0, + } + } + }() + + err := db.Insert("users", insertCh) + if err != nil { + log.Printf("Channel insert failed: %v", err) + } else { + fmt.Println("✓ Streamed 100 users for insertion") + } + + // Example 3: Transform while streaming + fmt.Println("\n3. Transform data while streaming") + + type EnrichedUser struct { + User + Category string + Priority int + } + + rawUserCh := make(chan User, 10) + enrichedCh := make(chan EnrichedUser, 10) + + // Producer: fetch users + go func() { + defer close(rawUserCh) + db.Select(rawUserCh, "SELECT `id`, `name`, `email`, `age`, `active`, `created_at`, `updated_at` FROM `users` LIMIT @@limit", 0, 50) + }() + + // Transformer: enrich data + go func() { + defer close(enrichedCh) + for user := range rawUserCh { + enriched := EnrichedUser{ + User: user, + } + + // Add category based on age + if user.Age < 25 { + enriched.Category = "Young" + enriched.Priority = 1 + } else if user.Age < 40 { + enriched.Category = "Middle" + enriched.Priority = 2 + } else { + enriched.Category = "Senior" + enriched.Priority = 3 + } + + enrichedCh <- enriched + } + }() + + // Consumer: process enriched data + processed := 0 + for enriched := range enrichedCh { + processed++ + _ = enriched // Process enriched user + } + + fmt.Printf("✓ Processed %d enriched users\n", processed) +} + +// functionReceiverExamples demonstrates function receivers +func functionReceiverExamples(db *mysql.Database) { + // Example 1: Process each row with function + fmt.Println("1. Process rows with function") + + count := 0 + err := db.Select(func(u User) { + count++ + if count <= 3 { + fmt.Printf(" Processing: %s (Age: %d)\n", u.Name, u.Age) + } + }, "SELECT `id`, `name`, `email`, `age`, `active`, `created_at`, `updated_at` FROM `users` WHERE `active` = 1", 0) + + if err != nil { + log.Printf("Function receiver failed: %v", err) + } else { + fmt.Printf("✓ Processed %d users\n", count) + } + + // Example 2: Aggregate data with function + fmt.Println("\n2. Aggregate data with function") + + var totalAge int + var userCount int + + err = db.Select(func(u User) { + totalAge += u.Age + userCount++ + }, "SELECT `id`, `name`, `email`, `age`, `active`, `created_at`, `updated_at` FROM `users`", 0) + + if err != nil { + log.Printf("Aggregation failed: %v", err) + } else if userCount > 0 { + avgAge := float64(totalAge) / float64(userCount) + fmt.Printf("✓ Average age: %.2f (%d users)\n", avgAge, userCount) + } + + // Example 3: Conditional processing + fmt.Println("\n3. Conditional processing with function") + + type Stats struct { + YoungCount int + MiddleCount int + SeniorCount int + } + + stats := Stats{} + + err = db.Select(func(u User) { + switch { + case u.Age < 25: + stats.YoungCount++ + case u.Age < 40: + stats.MiddleCount++ + default: + stats.SeniorCount++ + } + }, "SELECT `id`, `name`, `email`, `age`, `active`, `created_at`, `updated_at` FROM `users`", 0) + + if err != nil { + log.Printf("Stats failed: %v", err) + } else { + fmt.Printf("✓ Age distribution: Young=%d, Middle=%d, Senior=%d\n", + stats.YoungCount, stats.MiddleCount, stats.SeniorCount) + } + + // Example 4: Early termination pattern + fmt.Println("\n4. Early termination with function") + + found := false + targetEmail := "alice@example.com" + + err = db.Select(func(u User) { + if u.Email == targetEmail { + found = true + fmt.Printf("✓ Found user: %s\n", u.Name) + // Note: Can't actually stop iteration early + // This is a limitation of function receivers + } + }, "SELECT `id`, `name`, `email`, `age`, `active`, `created_at`, `updated_at` FROM `users`", 0) + + if err != nil { + log.Printf("Search failed: %v", err) + } else if !found { + fmt.Println("✗ User not found") + } +} + +// jsonExamples demonstrates JSON handling +func jsonExamples(db *mysql.Database) { + // Example 1: Store JSON in struct field + fmt.Println("1. Store JSON column in struct") + + type UserWithMeta struct { + ID int `mysql:"id"` + Name string `mysql:"name"` + Email string `mysql:"email"` + Metadata json.RawMessage `mysql:"metadata"` // JSON column + } + + userMeta := UserWithMeta{ + Name: "JSONUser", + Email: "json@example.com", + Metadata: json.RawMessage(`{ + "theme": "dark", + "language": "en", + "notifications": true + }`), + } + + err := db.Insert("users", userMeta) + if err != nil { + log.Printf("JSON insert failed: %v", err) + } else { + fmt.Println("✓ User with JSON metadata inserted") + } + + // Example 2: Select JSON as RawMessage + fmt.Println("\n2. Select JSON column") + + var usersWithMeta []UserWithMeta + err = db.Select(&usersWithMeta, + "SELECT `id`, `name`, `email`, metadata FROM `users` WHERE metadata IS NOT NULL LIMIT @@limit", + 0, + 5) + + if err != nil { + log.Printf("JSON select failed: %v", err) + } else { + fmt.Printf("✓ Retrieved %d users with metadata\n", len(usersWithMeta)) + for _, u := range usersWithMeta { + fmt.Printf(" %s: %s\n", u.Name, string(u.Metadata)) + } + } + + // Example 3: SelectJSON for JSON result + fmt.Println("\n3. SelectJSON for JSON object") + + var jsonResult json.RawMessage + err = db.SelectJSON(&jsonResult, + "SELECT JSON_OBJECT("+ + " 'id', `id`,"+ + " 'name', `name`,"+ + " 'email', `email`,"+ + " 'age', `age`"+ + " ) FROM `users` WHERE `id` = @@id", + 0, + 1) + + if err != nil { + log.Printf("SelectJSON failed: %v", err) + } else { + fmt.Printf("✓ JSON result: %s\n", string(jsonResult)) + } + + // Example 4: SelectJSON for JSON array + fmt.Println("\n4. SelectJSON for JSON array") + + var jsonArray json.RawMessage + err = db.SelectJSON(&jsonArray, + "SELECT JSON_ARRAYAGG("+ + " JSON_OBJECT("+ + " 'name', `name`,"+ + " 'email', `email`"+ + " )"+ + " ) FROM `users` WHERE `active` = 1 LIMIT @@limit", + 0, + 5) + + if err != nil { + log.Printf("SelectJSON array failed: %v", err) + } else { + fmt.Printf("✓ JSON array result: %s\n", string(jsonArray)) + } +} + +// rawSQLExamples demonstrates using mysql.Raw for literal SQL +func rawSQLExamples(db *mysql.Database) { + // Example 1: Raw SQL in WHERE clause + fmt.Println("1. Raw SQL for complex condition") + + var users []User + err := db.Select(&users, + "SELECT `id`, `name`, `email`, `age`, `active`, `created_at`, `updated_at` FROM `users` WHERE @@condition", + 0, + mysql.Raw("created_at > NOW() - INTERVAL 7 DAY")) + + if err != nil { + log.Printf("Raw SQL query failed: %v", err) + } else { + fmt.Printf("✓ Found %d users from last 7 days\n", len(users)) + } + + // Example 2: Raw SQL for CASE statement + fmt.Println("\n2. Raw SQL for CASE statement") + + type UserWithLabel struct { + Name string `mysql:"name"` + Label string `mysql:"label"` + } + + caseSQL := mysql.Raw(` + CASE + WHEN age < 25 THEN 'Young' + WHEN age < 40 THEN 'Middle' + ELSE 'Senior' + END + `) + + var labeled []UserWithLabel + err = db.Select(&labeled, + "SELECT name, @@ageCase as `label` FROM `users`", + 0, + caseSQL) + + if err != nil { + log.Printf("CASE query failed: %v", err) + } else { + fmt.Printf("✓ Retrieved %d users with age labels\n", len(labeled)) + for i, u := range labeled { + if i < 3 { + fmt.Printf(" %s: %s\n", u.Name, u.Label) + } + } + } + + // Example 3: Raw SQL for subquery + fmt.Println("\n3. Raw SQL for subquery") + + subquery := mysql.Raw("(SELECT AVG(age) FROM `users` WHERE `active` = 1)") + + err = db.Select(&users, + "SELECT `id`, `name`, `email`, `age`, `active`, `created_at`, `updated_at` FROM `users` WHERE age > @@avgAge", + 0, + subquery) + + if err != nil { + log.Printf("Subquery failed: %v", err) + } else { + fmt.Printf("✓ Found %d users above average age\n", len(users)) + } + + // Example 4: WARNING - Never use Raw with user input! + fmt.Println("\n4. WARNING: Raw SQL security example") + + // DANGEROUS - SQL injection risk! + // userInput := "'; DROP TABLE users; --" + // db.Select(&users, "SELECT `id`, `name`, `email`, `age`, `active`, `created_at`, `updated_at` FROM `users` WHERE `name` = @@name", 0, + // mysql.Params{"name": mysql.Raw(userInput)}) + + // SAFE - use regular parameter + safeInput := "Alice'; DROP TABLE users; --" + err = db.Select(&users, "SELECT `id`, `name`, `email`, `age`, `active`, `created_at`, `updated_at` FROM `users` WHERE `name` = @@name", 0, safeInput) // Properly escaped + + if err != nil { + log.Printf("Safe query failed: %v", err) + } else { + fmt.Println("✓ User input safely escaped (no SQL injection)") + } +} + +// complexQueryExamples demonstrates complex query patterns +func complexQueryExamples(db *mysql.Database) { + // Example 1: Subquery with named parameters + fmt.Println("1. Subquery with parameters") + + type UserWithOrderCount struct { + User + OrderCount int `mysql:"order_count"` + } + + var usersWithOrders []UserWithOrderCount + err := db.Select(&usersWithOrders, + "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`.`active` = @@active", + 5*time.Minute, + mysql.Params{ + "since": time.Now().Add(-30 * 24 * time.Hour), + "active": true, + }) + + if err != nil { + log.Printf("Subquery failed: %v", err) + } else { + fmt.Printf("✓ Retrieved %d active users with order counts\n", len(usersWithOrders)) + } + + // Example 2: JOIN with aggregation + fmt.Println("\n2. JOIN with aggregation") + + query := "SELECT" + + " `users`.`id`," + + " `users`.`name`," + + " `users`.`email`," + + " COUNT(`orders`.`id`) as `order_count`," + + " SUM(`orders`.`total`) as `total_spent`" + + " FROM `users`" + + " LEFT JOIN `orders` ON `users`.`id` = `orders`.`user_id`" + + " WHERE `users`.`active` = @@active" + + " GROUP BY `users`.`id`, `users`.`name`, `users`.`email`" + + " HAVING COUNT(`orders`.`id`) > @@minOrders" + + " ORDER BY total_spent DESC" + + " LIMIT @@limit" + + type UserStats struct { + ID int `mysql:"id"` + Name string `mysql:"name"` + Email string `mysql:"email"` + OrderCount int `mysql:"order_count"` + TotalSpent float64 `mysql:"total_spent"` + } + + var stats []UserStats + err = db.Select(&stats, query, 10*time.Minute, + mysql.Params{ + "active": true, + "minOrders": 5, + "limit": 10, + }) + + if err != nil { + log.Printf("Aggregation query failed: %v", err) + } else { + fmt.Printf("✓ Top %d spenders retrieved\n", len(stats)) + } + + // Example 3: Window function + fmt.Println("\n3. Window function query") + + windowQuery := "SELECT" + + " `id`," + + " `name`," + + " `age`," + + " RANK() OVER (ORDER BY `age` DESC) as `age_rank`," + + " AVG(`age`) OVER () as `avg_age`" + + " FROM `users`" + + " WHERE `active` = @@active" + + " LIMIT @@limit" + + type UserWithRank struct { + ID int `mysql:"id"` + Name string `mysql:"name"` + Age int `mysql:"age"` + AgeRank int `mysql:"age_rank"` + AvgAge float64 `mysql:"avg_age"` + } + + var ranked []UserWithRank + err = db.Select(&ranked, windowQuery, 5*time.Minute, + mysql.Params{ + "active": true, + "limit": 20, + }) + + if err != nil { + log.Printf("Window function query failed: %v", err) + } else { + fmt.Printf("✓ Retrieved %d users with age ranking\n", len(ranked)) + } + + // Example 4: CTE (Common Table Expression) + fmt.Println("\n4. CTE query") + + cteQuery := "WITH recent_users AS (" + + " SELECT `id`, `name`, `email`, `age`, `active`, `created_at`, `updated_at` FROM `users`" + + " WHERE `created_at` > @@since" + + " )," + + " active_recent AS (" + + " SELECT `id`, `name`, `email`, `age`, `active`, `created_at`, `updated_at` FROM recent_users" + + " WHERE `active` = @@active" + + " )" + + " SELECT `id`, `name`, `email`, `age`, `active`, `created_at`, `updated_at` FROM active_recent" + + " ORDER BY `created_at` DESC" + + " LIMIT @@limit" + + var cteUsers []User + err = db.Select(&cteUsers, cteQuery, 5*time.Minute, + mysql.Params{ + "since": time.Now().Add(-7 * 24 * time.Hour), + "active": true, + "limit": 10, + }) + + if err != nil { + log.Printf("CTE query failed: %v", err) + } else { + fmt.Printf("✓ Retrieved %d recent active users via CTE\n", len(cteUsers)) + } +} diff --git a/examples/basic-crud.go b/examples/basic-crud.go new file mode 100644 index 0000000..f419068 --- /dev/null +++ b/examples/basic-crud.go @@ -0,0 +1,517 @@ +// Package examples demonstrates basic CRUD operations with cool-mysql +package examples + +import ( + "context" + "database/sql" + "errors" + "fmt" + "log" + "time" + + mysql "github.com/StirlingMarketingGroup/cool-mysql" +) + +// User represents a user in the database +type User struct { + ID int `mysql:"id"` + Name string `mysql:"name"` + Email string `mysql:"email"` + Age int `mysql:"age"` + Active bool `mysql:"active"` + CreatedAt time.Time `mysql:"created_at,defaultzero"` + UpdatedAt time.Time `mysql:"updated_at,defaultzero"` +} + +// BasicCRUDExamples demonstrates basic Create, Read, Update, Delete operations +func BasicCRUDExamples() { + // Setup database connection + db, err := setupDatabase() + if err != nil { + log.Fatalf("Failed to setup database: %v", err) + } + + // Create + fmt.Println("=== CREATE EXAMPLES ===") + createExamples(db) + + // Read + fmt.Println("\n=== READ EXAMPLES ===") + readExamples(db) + + // Update + fmt.Println("\n=== UPDATE EXAMPLES ===") + updateExamples(db) + + // Delete + fmt.Println("\n=== DELETE EXAMPLES ===") + deleteExamples(db) + + // Utility queries + fmt.Println("\n=== UTILITY EXAMPLES ===") + utilityExamples(db) +} + +// setupDatabase creates a connection to MySQL +func setupDatabase() (*mysql.Database, error) { + // Create database connection with read/write pools + db, err := mysql.New( + "root", // write user + "password", // write password + "mydb", // write schema + "localhost", // write host + 3306, // write port + "root", // read user + "password", // read password + "mydb", // read schema + "localhost", // read host + 3306, // read port + "utf8mb4_unicode_ci", // collation + time.UTC, // timezone + ) + if err != nil { + return nil, fmt.Errorf("failed to create database: %w", err) + } + + return db, nil +} + +// createExamples demonstrates INSERT operations +func createExamples(db *mysql.Database) { + // Example 1: Insert single user + fmt.Println("1. Insert single user") + user := User{ + Name: "Alice", + Email: "alice@example.com", + Age: 25, + Active: true, + } + + err := db.Insert("users", user) + if err != nil { + log.Printf("Insert failed: %v", err) + } else { + fmt.Println("✓ User inserted successfully") + } + + // Example 2: Insert with explicit zero values + fmt.Println("\n2. Insert with timestamp defaults") + userWithDefaults := User{ + Name: "Bob", + Email: "bob@example.com", + Age: 30, + Active: true, + // CreatedAt and UpdatedAt are zero values + // With ,defaultzero tag, database will use DEFAULT values + } + + err = db.Insert("users", userWithDefaults) + if err != nil { + log.Printf("Insert failed: %v", err) + } else { + fmt.Println("✓ User inserted with database defaults") + } + + // Example 3: Batch insert + fmt.Println("\n3. Batch insert multiple users") + users := []User{ + {Name: "Charlie", Email: "charlie@example.com", Age: 28, Active: true}, + {Name: "Diana", Email: "diana@example.com", Age: 32, Active: true}, + {Name: "Eve", Email: "eve@example.com", Age: 27, Active: false}, + } + + err = db.Insert("users", users) + if err != nil { + log.Printf("Batch insert failed: %v", err) + } else { + fmt.Printf("✓ Batch inserted %d users\n", len(users)) + } + + // Example 4: Insert with context + fmt.Println("\n4. Insert with context timeout") + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + contextUser := User{ + Name: "Frank", + Email: "frank@example.com", + Age: 35, + Active: true, + } + + err = db.InsertContext(ctx, "users", contextUser) + if err != nil { + log.Printf("Insert with context failed: %v", err) + } else { + fmt.Println("✓ User inserted with context") + } +} + +// readExamples demonstrates SELECT operations +func readExamples(db *mysql.Database) { + // Example 1: Select all users into slice + fmt.Println("1. Select all users") + var allUsers []User + err := db.Select(&allUsers, "SELECT `id`, `name`, `email`, `age`, `active`, `created_at`, `updated_at` FROM `users`", 0) + if err != nil { + log.Printf("Select all failed: %v", err) + } else { + fmt.Printf("✓ Found %d users\n", len(allUsers)) + for _, u := range allUsers { + fmt.Printf(" - %s (%s), Age: %d\n", u.Name, u.Email, u.Age) + } + } + + // Example 2: Select with named parameters + fmt.Println("\n2. Select users with age filter") + var adults []User + err = db.Select(&adults, + "SELECT `id`, `name`, `email`, `age`, `active`, `created_at`, `updated_at` FROM `users` WHERE age >= @@minAge", + 0, // No caching + 25) + if err != nil { + log.Printf("Select with filter failed: %v", err) + } else { + fmt.Printf("✓ Found %d users aged 25+\n", len(adults)) + } + + // Example 3: Select single user + fmt.Println("\n3. Select single user by email") + var user User + err = db.Select(&user, + "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) { + fmt.Println("✗ User not found") + } else if err != nil { + log.Printf("Select failed: %v", err) + } else { + fmt.Printf("✓ Found user: %s (ID: %d)\n", user.Name, user.ID) + } + + // Example 4: Select single value + fmt.Println("\n4. Select single value (name)") + var name string + err = db.Select(&name, + "SELECT `name` FROM `users` WHERE `email` = @@email", + 0, + "bob@example.com") + if err != nil { + log.Printf("Select name failed: %v", err) + } else { + fmt.Printf("✓ User name: %s\n", name) + } + + // Example 5: Select with multiple conditions + fmt.Println("\n5. Select with multiple conditions") + var activeAdults []User + err = db.Select(&activeAdults, + "SELECT `id`, `name`, `email`, `age`, `active`, `created_at`, `updated_at` FROM `users`"+ + " WHERE `age` >= @@minAge"+ + " AND `active` = @@active"+ + " ORDER BY `name`", + 0, + mysql.Params{ + "minAge": 25, + "active": true, + }) + if err != nil { + log.Printf("Select failed: %v", err) + } else { + fmt.Printf("✓ Found %d active adult users\n", len(activeAdults)) + } + + // Example 6: Select with caching + fmt.Println("\n6. Select with caching (5 minute TTL)") + var cachedUsers []User + err = db.Select(&cachedUsers, + "SELECT `id`, `name`, `email`, `age`, `active`, `created_at`, `updated_at` FROM `users` WHERE `active` = @@active", + 5*time.Minute, // Cache for 5 minutes + true) + if err != nil { + log.Printf("Cached select failed: %v", err) + } else { + fmt.Printf("✓ Found %d active users (cached)\n", len(cachedUsers)) + } + + // Example 7: Select with context + fmt.Println("\n7. Select with context timeout") + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + var contextUsers []User + err = db.SelectContext(ctx, &contextUsers, + "SELECT `id`, `name`, `email`, `age`, `active`, `created_at`, `updated_at` FROM `users` LIMIT @@limit", + 0, + 10) + if err != nil { + log.Printf("Select with context failed: %v", err) + } else { + fmt.Printf("✓ Found %d users with context\n", len(contextUsers)) + } +} + +// updateExamples demonstrates UPDATE operations +func updateExamples(db *mysql.Database) { + // Example 1: Simple update + fmt.Println("1. Update user name") + err := db.Exec( + "UPDATE `users` SET `name` = @@name WHERE `email` = @@email", + mysql.Params{ + "name": "Alice Smith", + "email": "alice@example.com", + }) + if err != nil { + log.Printf("Update failed: %v", err) + } else { + fmt.Println("✓ User name updated") + } + + // Example 2: Update with result + fmt.Println("\n2. Update with result check") + result, err := db.ExecResult( + "UPDATE `users` SET `age` = @@age WHERE `email` = @@email", + mysql.Params{ + "age": 26, + "email": "alice@example.com", + }) + if err != nil { + log.Printf("Update failed: %v", err) + } else { + rowsAffected, _ := result.RowsAffected() + fmt.Printf("✓ Updated %d row(s)\n", rowsAffected) + } + + // Example 3: Update multiple rows + fmt.Println("\n3. Update multiple rows") + result, err = db.ExecResult( + "UPDATE `users` SET `active` = @@active WHERE age < @@maxAge", + mysql.Params{ + "active": false, + "maxAge": 25, + }) + if err != nil { + log.Printf("Bulk update failed: %v", err) + } else { + rowsAffected, _ := result.RowsAffected() + fmt.Printf("✓ Deactivated %d user(s)\n", rowsAffected) + } + + // Example 4: Update with current timestamp + fmt.Println("\n4. Update timestamp") + err = db.Exec( + "UPDATE `users` SET `updated_at` = NOW() WHERE `email` = @@email", + "bob@example.com") + if err != nil { + log.Printf("Update timestamp failed: %v", err) + } else { + fmt.Println("✓ Timestamp updated") + } + + // Example 5: Conditional update + fmt.Println("\n5. Conditional update (only if age is current value)") + err = db.Exec( + "UPDATE `users`"+ + " SET `age` = @@newAge"+ + " WHERE `email` = @@email"+ + " AND `age` = @@currentAge", + mysql.Params{ + "newAge": 27, + "email": "charlie@example.com", + "currentAge": 28, + }) + if err != nil { + log.Printf("Conditional update failed: %v", err) + } else { + fmt.Println("✓ Conditional update executed") + } + + // Example 6: Update with context + fmt.Println("\n6. Update with context") + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + err = db.ExecContext(ctx, + "UPDATE `users` SET `active` = @@active WHERE age > @@age", + mysql.Params{ + "active": true, + "age": 30, + }) + if err != nil { + log.Printf("Update with context failed: %v", err) + } else { + fmt.Println("✓ Update executed with context") + } +} + +// deleteExamples demonstrates DELETE operations +func deleteExamples(db *mysql.Database) { + // Example 1: Delete single record + fmt.Println("1. Delete single user") + err := db.Exec( + "DELETE FROM `users` WHERE `email` = @@email", + "eve@example.com") + if err != nil { + log.Printf("Delete failed: %v", err) + } else { + fmt.Println("✓ User deleted") + } + + // Example 2: Delete with result check + fmt.Println("\n2. Delete with result check") + result, err := db.ExecResult( + "DELETE FROM `users` WHERE `email` = @@email", + "frank@example.com") + if err != nil { + log.Printf("Delete failed: %v", err) + } else { + rowsAffected, _ := result.RowsAffected() + if rowsAffected == 0 { + fmt.Println("✗ No user found to delete") + } else { + fmt.Printf("✓ Deleted %d user(s)\n", rowsAffected) + } + } + + // Example 3: Delete multiple records + fmt.Println("\n3. Delete inactive users") + result, err = db.ExecResult( + "DELETE FROM `users` WHERE `active` = @@active", + false) + if err != nil { + log.Printf("Bulk delete failed: %v", err) + } else { + rowsAffected, _ := result.RowsAffected() + fmt.Printf("✓ Deleted %d inactive user(s)\n", rowsAffected) + } + + // Example 4: Delete with age condition + fmt.Println("\n4. Delete users under age threshold") + result, err = db.ExecResult( + "DELETE FROM `users` WHERE age < @@minAge", + 18) + if err != nil { + log.Printf("Delete failed: %v", err) + } else { + rowsAffected, _ := result.RowsAffected() + fmt.Printf("✓ Deleted %d user(s) under 18\n", rowsAffected) + } + + // Example 5: Delete with context + fmt.Println("\n5. Delete with context") + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + err = db.ExecContext(ctx, + "DELETE FROM `users` WHERE `created_at` < @@cutoff", + time.Now().Add(-365*24*time.Hour)) + if err != nil { + log.Printf("Delete with context failed: %v", err) + } else { + fmt.Println("✓ Old users deleted with context") + } +} + +// utilityExamples demonstrates utility query methods +func utilityExamples(db *mysql.Database) { + // Example 1: Count users + fmt.Println("1. Count all users") + count, err := db.Count( + "SELECT COUNT(*) FROM `users`", + 0) // No caching + if err != nil { + log.Printf("Count failed: %v", err) + } else { + fmt.Printf("✓ Total users: %d\n", count) + } + + // Example 2: Count with condition + fmt.Println("\n2. Count active users") + activeCount, err := db.Count( + "SELECT COUNT(*) FROM `users` WHERE `active` = @@active", + 0, + true) + if err != nil { + log.Printf("Count failed: %v", err) + } else { + fmt.Printf("✓ Active users: %d\n", activeCount) + } + + // Example 3: Count with caching + fmt.Println("\n3. Count with caching") + cachedCount, err := db.Count( + "SELECT COUNT(*) FROM `users`", + 5*time.Minute) // Cache for 5 minutes + if err != nil { + log.Printf("Cached count failed: %v", err) + } else { + fmt.Printf("✓ Cached user count: %d\n", cachedCount) + } + + // Example 4: Check if user exists + fmt.Println("\n4. Check if email exists") + exists, err := db.Exists( + "SELECT 1 FROM `users` WHERE `email` = @@email", + 0, + "alice@example.com") + if err != nil { + log.Printf("Exists check failed: %v", err) + } else { + if exists { + fmt.Println("✓ Email exists in database") + } else { + fmt.Println("✗ Email not found") + } + } + + // Example 5: Check existence with multiple conditions + fmt.Println("\n5. Check if active adult exists") + exists, err = db.Exists( + "SELECT 1 FROM `users`"+ + " WHERE `active` = @@active"+ + " AND `age` >= @@minAge", + 0, + mysql.Params{ + "active": true, + "minAge": 25, + }) + if err != nil { + log.Printf("Exists check failed: %v", err) + } else { + if exists { + fmt.Println("✓ Active adult user exists") + } else { + fmt.Println("✗ No active adult users found") + } + } + + // Example 6: Read-after-write with SelectWrites + fmt.Println("\n6. Read-after-write consistency") + + // Insert user + newUser := User{ + Name: "Grace", + Email: "grace@example.com", + Age: 29, + Active: true, + } + err = db.Insert("users", newUser) + if err != nil { + log.Printf("Insert failed: %v", err) + return + } + + // Immediately read using write pool for consistency + var retrieved User + err = db.SelectWrites(&retrieved, + "SELECT `id`, `name`, `email`, `age`, `active`, `created_at`, `updated_at` FROM `users` WHERE `email` = @@email", + 0, // Don't cache writes + "grace@example.com") + if err != nil { + log.Printf("SelectWrites failed: %v", err) + } else { + fmt.Printf("✓ User retrieved immediately after insert: %s (ID: %d)\n", + retrieved.Name, retrieved.ID) + } +} diff --git a/examples/caching-setup.go b/examples/caching-setup.go new file mode 100644 index 0000000..0401284 --- /dev/null +++ b/examples/caching-setup.go @@ -0,0 +1,534 @@ +// Package examples demonstrates caching configuration with cool-mysql +package examples + +import ( + "context" + "fmt" + "log" + "time" + + "github.com/bradfitz/gomemcache/memcache" + "github.com/redis/go-redis/v9" + mysql "github.com/StirlingMarketingGroup/cool-mysql" +) + +// CachingExamples demonstrates various caching setups and strategies +func CachingExamples() { + fmt.Println("=== CACHING SETUP EXAMPLES ===") + + // In-memory caching + fmt.Println("\n1. In-Memory Weak Cache") + weakCacheExample() + + // Redis caching + fmt.Println("\n2. Redis Cache") + redisCacheExample() + + // Redis Cluster caching + fmt.Println("\n3. Redis Cluster Cache") + redisClusterExample() + + // Memcached caching + fmt.Println("\n4. Memcached Cache") + memcachedCacheExample() + + // Multi-level caching + fmt.Println("\n5. Multi-Level Cache") + multiCacheExample() + + // Cache strategies + fmt.Println("\n6. Cache Strategies") + cacheStrategiesExample() + + // Performance benchmark + fmt.Println("\n7. Performance Benchmark") + db, err := setupDatabase() + if err != nil { + log.Printf("Setup failed: %v", err) + return + } + performanceBenchmark(db) + + // Cache key debugging + fmt.Println("\n8. Cache Key Debugging") + cacheKeyDebug(db) +} + +// weakCacheExample demonstrates in-memory weak cache +func weakCacheExample() { + db, err := setupDatabase() + if err != nil { + log.Printf("Setup failed: %v", err) + return + } + + // Enable in-memory weak cache + db.UseCache(mysql.NewWeakCache()) + + fmt.Println("✓ Weak cache enabled (GC-managed, local only)") + + // First query - cache miss + start := time.Now() + var users []User + err = db.Select(&users, + "SELECT `id`, `name`, `email`, `age`, `active`, `created_at`, `updated_at` FROM `users` WHERE `active` = @@active", + 5*time.Minute, // Cache for 5 minutes + true) + duration1 := time.Since(start) + + if err != nil { + log.Printf("First query failed: %v", err) + return + } + fmt.Printf(" First query (cache miss): %v, %d users\n", duration1, len(users)) + + // Second query - cache hit + start = time.Now() + err = db.Select(&users, + "SELECT `id`, `name`, `email`, `age`, `active`, `created_at`, `updated_at` FROM `users` WHERE `active` = @@active", + 5*time.Minute, + true) + duration2 := time.Since(start) + + if err != nil { + log.Printf("Second query failed: %v", err) + return + } + fmt.Printf(" Second query (cache hit): %v, %d users\n", duration2, len(users)) + fmt.Printf(" Speedup: %.2fx faster\n", float64(duration1)/float64(duration2)) +} + +// redisCacheExample demonstrates Redis cache setup +func redisCacheExample() { + db, err := setupDatabase() + if err != nil { + log.Printf("Setup failed: %v", err) + return + } + + // Setup Redis client + redisClient := redis.NewClient(&redis.Options{ + Addr: "localhost:6379", + Password: "", // no password + DB: 0, // default DB + DialTimeout: 5 * time.Second, + ReadTimeout: 3 * time.Second, + WriteTimeout: 3 * time.Second, + PoolSize: 10, + MinIdleConns: 5, + }) + + // Test Redis connection + ctx := context.Background() + _, err = redisClient.Ping(ctx).Result() + if err != nil { + log.Printf("Redis connection failed: %v", err) + log.Println(" Skipping Redis cache example") + return + } + + // Enable Redis cache + db.EnableRedis(redisClient) + fmt.Println("✓ Redis cache enabled (distributed, with locking)") + + // Query with caching + var users []User + err = db.Select(&users, + "SELECT `id`, `name`, `email`, `age`, `active`, `created_at`, `updated_at` FROM `users` WHERE age > @@minAge", + 10*time.Minute, // Cache for 10 minutes + 18) + + if err != nil { + log.Printf("Redis cached query failed: %v", err) + return + } + + fmt.Printf(" Cached %d users in Redis\n", len(users)) + fmt.Println(" ✓ Cache shared across all application instances") + fmt.Println(" ✓ Distributed locking prevents cache stampedes") +} + +// redisClusterExample demonstrates Redis Cluster setup +func redisClusterExample() { + db, err := setupDatabase() + if err != nil { + log.Printf("Setup failed: %v", err) + return + } + + // Setup Redis Cluster client + redisCluster := redis.NewClusterClient(&redis.ClusterOptions{ + Addrs: []string{ + "localhost:7000", + "localhost:7001", + "localhost:7002", + }, + DialTimeout: 5 * time.Second, + ReadTimeout: 3 * time.Second, + WriteTimeout: 3 * time.Second, + PoolSize: 10, + }) + + // Test cluster connection + ctx := context.Background() + _, err = redisCluster.Ping(ctx).Result() + if err != nil { + log.Printf("Redis cluster connection failed: %v", err) + log.Println(" Skipping Redis cluster example") + return + } + + // Enable Redis cluster cache + // Note: EnableRedis works with both single-node and cluster + db.EnableRedis(redisCluster) + fmt.Println("✓ Redis Cluster cache enabled") + + // Query with caching + var users []User + err = db.Select(&users, "SELECT `id`, `name`, `email`, `age`, `active`, `created_at`, `updated_at` FROM `users` LIMIT @@limit", 5*time.Minute, 100) + + if err != nil { + log.Printf("Cluster cached query failed: %v", err) + return + } + + fmt.Printf(" Cached %d users in Redis Cluster\n", len(users)) +} + +// memcachedCacheExample demonstrates Memcached cache setup +func memcachedCacheExample() { + db, err := setupDatabase() + if err != nil { + log.Printf("Setup failed: %v", err) + return + } + + // Setup Memcached client + memcacheClient := memcache.New("localhost:11211") + memcacheClient.Timeout = 3 * time.Second + memcacheClient.MaxIdleConns = 10 + + // Test Memcached connection + err = memcacheClient.Ping() + if err != nil { + log.Printf("Memcached connection failed: %v", err) + log.Println(" Skipping Memcached example") + return + } + + // Enable Memcached + db.EnableMemcache(memcacheClient) + fmt.Println("✓ Memcached cache enabled (distributed, simple)") + + // Query with caching + var users []User + err = db.Select(&users, + "SELECT `id`, `name`, `email`, `age`, `active`, `created_at`, `updated_at` FROM `users` WHERE `active` = @@active", + 15*time.Minute, // Cache for 15 minutes + true) + + if err != nil { + log.Printf("Memcached query failed: %v", err) + return + } + + fmt.Printf(" Cached %d users in Memcached\n", len(users)) + fmt.Println(" ⚠ No distributed locking (potential cache stampedes)") +} + +// multiCacheExample demonstrates multi-level caching +func multiCacheExample() { + db, err := setupDatabase() + if err != nil { + log.Printf("Setup failed: %v", err) + return + } + + // Setup Redis + redisClient := redis.NewClient(&redis.Options{ + Addr: "localhost:6379", + }) + + ctx := context.Background() + _, err = redisClient.Ping(ctx).Result() + if err != nil { + log.Printf("Redis unavailable, using weak cache only") + db.UseCache(mysql.NewWeakCache()) + return + } + + // Create multi-level cache + // L1: Fast local weak cache + // L2: Shared Redis cache + multiCache := mysql.NewMultiCache( + mysql.NewWeakCache(), // L1: In-memory + mysql.NewRedisCache(redisClient), // L2: Redis + ) + + db.UseCache(multiCache) + fmt.Println("✓ Multi-level cache enabled") + fmt.Println(" L1: In-memory weak cache (fastest)") + fmt.Println(" L2: Redis distributed cache (shared)") + + // First query - cold cache (misses both levels) + start := time.Now() + var users []User + err = db.Select(&users, + "SELECT `id`, `name`, `email`, `age`, `active`, `created_at`, `updated_at` FROM `users` WHERE age > @@minAge", + 10*time.Minute, + 21) + cold := time.Since(start) + + if err != nil { + log.Printf("Cold cache query failed: %v", err) + return + } + fmt.Printf("\n Cold cache (DB query): %v, %d users\n", cold, len(users)) + + // Second query - warm cache (hits L1) + start = time.Now() + err = db.Select(&users, + "SELECT `id`, `name`, `email`, `age`, `active`, `created_at`, `updated_at` FROM `users` WHERE age > @@minAge", + 10*time.Minute, + 21) + warm := time.Since(start) + + if err != nil { + log.Printf("Warm cache query failed: %v", err) + return + } + fmt.Printf(" Warm cache (L1 hit): %v\n", warm) + fmt.Printf(" Speedup: %.2fx faster\n", float64(cold)/float64(warm)) +} + +// cacheStrategiesExample demonstrates different caching strategies +func cacheStrategiesExample() { + db, err := setupDatabase() + if err != nil { + log.Printf("Setup failed: %v", err) + return + } + + db.UseCache(mysql.NewWeakCache()) + + // Strategy 1: No caching for real-time data + fmt.Println("\nStrategy 1: No caching (TTL = 0)") + var liveUsers []User + err = db.Select(&liveUsers, + "SELECT `id`, `name`, `email`, `age`, `active`, `created_at`, `updated_at` FROM `users` WHERE `last_active` > @@since", + 0, // No caching + time.Now().Add(-5*time.Minute)) + + if err != nil { + log.Printf("Live query failed: %v", err) + } else { + fmt.Printf(" ✓ %d active users (always fresh)\n", len(liveUsers)) + } + + // Strategy 2: Short TTL for frequently changing data + fmt.Println("\nStrategy 2: Short TTL (30 seconds)") + err = db.Select(&liveUsers, + "SELECT `id`, `name`, `email`, `age`, `active`, `created_at`, `updated_at` FROM `users` WHERE `active` = @@active", + 30*time.Second, // Short TTL + true) + + if err != nil { + log.Printf("Short TTL query failed: %v", err) + } else { + fmt.Println(" ✓ Balance freshness and performance") + } + + // Strategy 3: Long TTL for reference data + fmt.Println("\nStrategy 3: Long TTL (1 hour)") + type Country struct { + ID int `mysql:"id"` + Name string `mysql:"name"` + Code string `mysql:"code"` + } + + var countries []Country + err = db.Select(&countries, + "SELECT `id`, `name`, `code` FROM `countries`", + time.Hour, // Long TTL for reference data + ) + + if err != nil { + log.Printf("Long TTL query failed: %v", err) + } else { + fmt.Printf(" ✓ %d countries (rarely changes)\n", len(countries)) + } + + // Strategy 4: Conditional caching based on result size + fmt.Println("\nStrategy 4: Conditional caching") + conditionalCacheQuery(db) + + // Strategy 5: Read-after-write with SelectWrites + fmt.Println("\nStrategy 5: Read-after-write consistency") + readAfterWriteExample(db) +} + +// conditionalCacheQuery demonstrates dynamic TTL selection +func conditionalCacheQuery(db *mysql.Database) { + // First query to check 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, // No cache for initial check + "active") + + if err != nil { + log.Printf("Initial query failed: %v", err) + return + } + + // Choose TTL based on result size + var ttl time.Duration + if len(users) > 1000 { + ttl = 30 * time.Minute // Large result - cache longer + fmt.Println(" Large result set (>1000) - using 30min TTL") + } else if len(users) > 100 { + ttl = 10 * time.Minute // Medium result - moderate TTL + fmt.Println(" Medium result set (100-1000) - using 10min TTL") + } else { + ttl = 2 * time.Minute // Small result - short TTL + fmt.Println(" Small result set (<100) - using 2min TTL") + } + + // Re-query with appropriate TTL + err = db.Select(&users, + "SELECT `id`, `name`, `email`, `age`, `active`, `created_at`, `updated_at` FROM `users` WHERE `status` = @@status", + ttl, + "active") + + if err != nil { + log.Printf("Cached query failed: %v", err) + } else { + fmt.Printf(" ✓ %d users cached with TTL=%v\n", len(users), ttl) + } +} + +// readAfterWriteExample demonstrates read-after-write pattern +func readAfterWriteExample(db *mysql.Database) { + // Insert new user + newUser := User{ + Name: "CacheUser", + Email: "cache@example.com", + Age: 28, + Active: true, + } + + err := db.Insert("users", newUser) + if err != nil { + log.Printf("Insert failed: %v", err) + return + } + + fmt.Println(" User inserted") + + // WRONG: Using Select() might read from stale cache or replica + // var user User + // db.Select(&user, "SELECT `id`, `name`, `email`, `age`, `active`, `created_at`, `updated_at` FROM `users` WHERE `email` = @@email", + // 5*time.Minute, mysql.Params{"email": "cache@example.com"}) + + // CORRECT: Use SelectWrites for read-after-write consistency + var user User + err = db.SelectWrites(&user, + "SELECT `id`, `name`, `email`, `age`, `active`, `created_at`, `updated_at` FROM `users` WHERE `email` = @@email", + 0, // Don't cache write-pool reads + "cache@example.com") + + if err != nil { + log.Printf("SelectWrites failed: %v", err) + return + } + + fmt.Printf(" ✓ User retrieved immediately (ID: %d)\n", user.ID) + fmt.Println(" ✓ Used write pool for consistency") +} + +// performanceBenchmark compares cache vs no-cache performance +func performanceBenchmark(db *mysql.Database) { + fmt.Println("\nPerformance Benchmark: Cache vs No-Cache") + + query := "SELECT `id`, `name`, `email`, `age`, `active`, `created_at`, `updated_at` FROM `users` WHERE `active` = @@active" + param := true + + // Benchmark without cache + start := time.Now() + iterations := 100 + for i := 0; i < iterations; i++ { + var users []User + db.Select(&users, query, 0, param) // No cache + } + noCacheDuration := time.Since(start) + avgNoCache := noCacheDuration / time.Duration(iterations) + + fmt.Printf(" Without cache: %v total, %v avg per query\n", + noCacheDuration, avgNoCache) + + // Enable cache + db.UseCache(mysql.NewWeakCache()) + + // Warm up cache + var warmup []User + db.Select(&warmup, query, 5*time.Minute, param) + + // Benchmark with cache + start = time.Now() + for i := 0; i < iterations; i++ { + var users []User + db.Select(&users, query, 5*time.Minute, param) // With cache + } + cacheDuration := time.Since(start) + avgCache := cacheDuration / time.Duration(iterations) + + fmt.Printf(" With cache: %v total, %v avg per query\n", + cacheDuration, avgCache) + fmt.Printf(" Speedup: %.2fx faster with cache\n", + float64(noCacheDuration)/float64(cacheDuration)) +} + +// cacheKeyDebug demonstrates understanding cache keys +func cacheKeyDebug(db *mysql.Database) { + fmt.Println("\nCache Key Understanding") + + db.UseCache(mysql.NewWeakCache()) + + // Same query, same params = same cache key + fmt.Println(" 1. Identical queries share cache:") + var users1, users2 []User + + db.Select(&users1, "SELECT `id`, `name`, `email`, `age`, `active`, `created_at`, `updated_at` FROM `users` WHERE age > @@minAge", 5*time.Minute, + 18) + + // This hits cache + db.Select(&users2, "SELECT `id`, `name`, `email`, `age`, `active`, `created_at`, `updated_at` FROM `users` WHERE age > @@minAge", 5*time.Minute, + 18) + + fmt.Println(" ✓ Second query used cached result") + + // Different params = different cache key + fmt.Println("\n 2. Different params = different cache:") + var users3 []User + db.Select(&users3, "SELECT `id`, `name`, `email`, `age`, `active`, `created_at`, `updated_at` FROM `users` WHERE age > @@minAge", 5*time.Minute, + 25) // Different param value + + fmt.Println(" ✓ Different parameters bypass cache") + + // Parameter order doesn't matter + fmt.Println("\n 3. Parameter order normalized:") + var users4, users5 []User + + db.Select(&users4, + "SELECT `id`, `name`, `email`, `age`, `active`, `created_at`, `updated_at` FROM `users` WHERE age > @@minAge AND `active` = @@active", + 5*time.Minute, + mysql.Params{"minAge": 18, "active": true}) + + // Same cache even though params in different order + db.Select(&users5, + "SELECT `id`, `name`, `email`, `age`, `active`, `created_at`, `updated_at` FROM `users` WHERE age > @@minAge AND `active` = @@active", + 5*time.Minute, + mysql.Params{"active": true, "minAge": 18}) // Reversed + + fmt.Println(" ✓ Parameter order doesn't affect cache key") +} diff --git a/examples/transaction-patterns.go b/examples/transaction-patterns.go new file mode 100644 index 0000000..bae8edc --- /dev/null +++ b/examples/transaction-patterns.go @@ -0,0 +1,611 @@ +// Package examples demonstrates transaction patterns with cool-mysql +package examples + +import ( + "context" + "database/sql" + "fmt" + "log" + "strings" + + mysql "github.com/StirlingMarketingGroup/cool-mysql" +) + +// TransactionExamples demonstrates transaction handling patterns +func TransactionExamples() { + fmt.Println("=== TRANSACTION EXAMPLES ===") + + db, err := setupDatabase() + if err != nil { + log.Printf("Setup failed: %v", err) + return + } + + // Basic transaction + fmt.Println("\n1. Basic Transaction") + basicTransactionExample() + + // Nested transaction handling + fmt.Println("\n2. Nested Transaction (Context-Based)") + nestedTransactionExample() + + // Rollback on error + fmt.Println("\n3. Automatic Rollback on Error") + rollbackExample() + + // Complex transaction + fmt.Println("\n4. Complex Multi-Step Transaction") + complexTransactionExample() + + // Batch transaction + fmt.Println("\n5. Batch Transaction") + batchTransactionExample(context.Background(), db) + + // Transaction with retry + fmt.Println("\n6. Transaction with Retry Logic") + transactionWithRetry(context.Background(), db) + + // Savepoint example + fmt.Println("\n7. Savepoint Pattern") + savepointExample(context.Background(), db) + + // Isolation level + fmt.Println("\n8. Custom Isolation Level") + isolationLevelExample(context.Background(), db) +} + +// basicTransactionExample demonstrates basic transaction usage +func basicTransactionExample() { + db, err := setupDatabase() + if err != nil { + log.Printf("Setup failed: %v", err) + return + } + + ctx := context.Background() + + // Get or create transaction + tx, commit, cancel, err := mysql.GetOrCreateTxFromContext(ctx) + defer cancel() // Always safe to call - rolls back if commit() not called + if err != nil { + log.Printf("Failed to create transaction: %v", err) + return + } + + // Store transaction in context + ctx = mysql.NewContextWithTx(ctx, tx) + + // Execute operations in transaction + user := User{ + Name: "TxUser1", + Email: "tx1@example.com", + Age: 30, + Active: true, + } + + err = db.Insert("users", user) + if err != nil { + log.Printf("Insert failed: %v", err) + return // cancel() will rollback + } + + fmt.Println(" User inserted in transaction") + + // Update in same transaction + err = db.Exec("UPDATE `users` SET `age` = @@age WHERE `email` = @@email", + mysql.Params{ + "age": 31, + "email": "tx1@example.com", + }) + + if err != nil { + log.Printf("Update failed: %v", err) + return // cancel() will rollback + } + + fmt.Println(" User updated in transaction") + + // Commit transaction + if err := commit(); err != nil { + log.Printf("Commit failed: %v", err) + return + } + + fmt.Println("✓ Transaction committed successfully") +} + +// nestedTransactionExample demonstrates nested transaction handling +func nestedTransactionExample() { + db, err := setupDatabase() + if err != nil { + log.Printf("Setup failed: %v", err) + return + } + + ctx := context.Background() + + // Start outer transaction + err = outerTransaction(ctx, db) + if err != nil { + log.Printf("Outer transaction failed: %v", err) + return + } + + fmt.Println("✓ Nested transactions completed") +} + +func outerTransaction(ctx context.Context, db *mysql.Database) error { + // Get or create transaction + tx, commit, cancel, err := mysql.GetOrCreateTxFromContext(ctx) + defer cancel() + if err != nil { + return fmt.Errorf("outer tx failed: %w", err) + } + + // Store in context + ctx = mysql.NewContextWithTx(ctx, tx) + + fmt.Println(" Started outer transaction") + + // Insert user + user := User{ + Name: "OuterTxUser", + Email: "outer@example.com", + Age: 25, + Active: true, + } + + err = db.Insert("users", user) + if err != nil { + return fmt.Errorf("outer insert failed: %w", err) + } + + fmt.Println(" Outer: User inserted") + + // Call inner function with same context + // GetOrCreateTxFromContext will return existing transaction + err = innerTransaction(ctx, db) + if err != nil { + return fmt.Errorf("inner tx failed: %w", err) + } + + // Commit outer transaction + if err := commit(); err != nil { + return fmt.Errorf("outer commit failed: %w", err) + } + + fmt.Println(" Outer transaction committed") + return nil +} + +func innerTransaction(ctx context.Context, db *mysql.Database) error { + // Get or create transaction (will reuse existing from context) + tx, commit, cancel, err := mysql.GetOrCreateTxFromContext(ctx) + defer cancel() + if err != nil { + return fmt.Errorf("inner tx failed: %w", err) + } + + // Transaction already in context, so this is a no-op + ctx = mysql.NewContextWithTx(ctx, tx) + + fmt.Println(" Inner: Reusing outer transaction") + + // Update user + err = db.Exec("UPDATE `users` SET `age` = @@age WHERE `email` = @@email", + mysql.Params{ + "age": 26, + "email": "outer@example.com", + }) + + if err != nil { + return fmt.Errorf("inner update failed: %w", err) + } + + fmt.Println(" Inner: User updated") + + // Commit (safe to call, won't actually commit until outer commits) + if err := commit(); err != nil { + return fmt.Errorf("inner commit failed: %w", err) + } + + fmt.Println(" Inner: Operations complete") + return nil +} + +// rollbackExample demonstrates automatic rollback on error +func rollbackExample() { + db, err := setupDatabase() + if err != nil { + log.Printf("Setup failed: %v", err) + return + } + + ctx := context.Background() + + err = failingTransaction(ctx, db) + if err != nil { + fmt.Printf(" Transaction failed as expected: %v\n", err) + fmt.Println("✓ Transaction automatically rolled back") + } + + // Verify rollback - user should not exist + var user User + err = db.Select(&user, + "SELECT `id`, `name`, `email`, `age`, `active`, `created_at`, `updated_at` FROM `users` WHERE `email` = @@email", + 0, + "rollback@example.com") + + if err == sql.ErrNoRows { + fmt.Println("✓ Verified: User was not inserted (rollback worked)") + } else if err != nil { + log.Printf("Verification query failed: %v", err) + } else { + log.Println("✗ Error: User exists (rollback failed)") + } +} + +func failingTransaction(ctx context.Context, db *mysql.Database) error { + tx, _, cancel, err := mysql.GetOrCreateTxFromContext(ctx) + defer cancel() // Will rollback since we don't call commit + if err != nil { + return err + } + + ctx = mysql.NewContextWithTx(ctx, tx) + + // Insert user + user := User{ + Name: "RollbackUser", + Email: "rollback@example.com", + Age: 28, + Active: true, + } + + err = db.Insert("users", user) + if err != nil { + return err + } + + fmt.Println(" User inserted") + + // Simulate error before commit + fmt.Println(" Simulating error...") + return fmt.Errorf("simulated error - transaction will rollback") + + // commit() is never called, so cancel() will rollback +} + +// complexTransactionExample demonstrates a complex multi-step transaction +func complexTransactionExample() { + db, err := setupDatabase() + if err != nil { + log.Printf("Setup failed: %v", err) + return + } + + ctx := context.Background() + + err = transferFunds(ctx, db, "user1@example.com", "user2@example.com", 100) + if err != nil { + log.Printf("Transfer failed: %v", err) + return + } + + fmt.Println("✓ Complex transaction completed") +} + +// transferFunds demonstrates a bank transfer-like transaction +func transferFunds(ctx context.Context, db *mysql.Database, fromEmail, toEmail string, amount int) error { + tx, commit, cancel, err := mysql.GetOrCreateTxFromContext(ctx) + defer cancel() + if err != nil { + return fmt.Errorf("transaction start failed: %w", err) + } + + ctx = mysql.NewContextWithTx(ctx, tx) + + fmt.Printf(" Starting transfer: %d from %s to %s\n", amount, fromEmail, toEmail) + + // Step 1: Check sender balance + type Account struct { + Email string `mysql:"email"` + Balance int `mysql:"balance"` + } + + var sender Account + err = db.SelectWrites(&sender, + "SELECT email, balance FROM `accounts` WHERE `email` = @@email FOR UPDATE", + 0, // Use write pool for transaction + fromEmail) + + if err == sql.ErrNoRows { + return fmt.Errorf("sender account not found") + } else if err != nil { + return fmt.Errorf("failed to fetch sender: %w", err) + } + + fmt.Printf(" Sender balance: %d\n", sender.Balance) + + // Step 2: Verify sufficient funds + if sender.Balance < amount { + return fmt.Errorf("insufficient funds: have %d, need %d", sender.Balance, amount) + } + + // Step 3: Check receiver exists + var receiver Account + err = db.SelectWrites(&receiver, + "SELECT email, balance FROM `accounts` WHERE `email` = @@email FOR UPDATE", + 0, + toEmail) + + if err == sql.ErrNoRows { + return fmt.Errorf("receiver account not found") + } else if err != nil { + return fmt.Errorf("failed to fetch receiver: %w", err) + } + + fmt.Printf(" Receiver balance: %d\n", receiver.Balance) + + // Step 4: Deduct from sender + result, err := db.ExecResult( + "UPDATE accounts SET `balance` = balance - @@amount WHERE `email` = @@email", + mysql.Params{ + "amount": amount, + "email": fromEmail, + }) + + if err != nil { + return fmt.Errorf("failed to deduct from sender: %w", err) + } + + rows, _ := result.RowsAffected() + if rows == 0 { + return fmt.Errorf("sender update affected 0 rows") + } + + fmt.Printf(" Deducted %d from sender\n", amount) + + // Step 5: Add to receiver + result, err = db.ExecResult( + "UPDATE accounts SET `balance` = balance + @@amount WHERE `email` = @@email", + mysql.Params{ + "amount": amount, + "email": toEmail, + }) + + if err != nil { + return fmt.Errorf("failed to add to receiver: %w", err) + } + + rows, _ = result.RowsAffected() + if rows == 0 { + return fmt.Errorf("receiver update affected 0 rows") + } + + fmt.Printf(" Added %d to receiver\n", amount) + + // Step 6: Record transaction log + type TransactionLog struct { + FromEmail string `mysql:"from_email"` + ToEmail string `mysql:"to_email"` + Amount int `mysql:"amount"` + } + + txLog := TransactionLog{ + FromEmail: fromEmail, + ToEmail: toEmail, + Amount: amount, + } + + err = db.Insert("transaction_logs", txLog) + if err != nil { + return fmt.Errorf("failed to log transaction: %w", err) + } + + fmt.Println(" Transaction logged") + + // Step 7: Commit all changes atomically + if err := commit(); err != nil { + return fmt.Errorf("commit failed: %w", err) + } + + fmt.Println(" All changes committed atomically") + return nil +} + +// transactionWithRetry demonstrates transaction retry pattern +func transactionWithRetry(ctx context.Context, db *mysql.Database) error { + maxRetries := 3 + var err error + + for attempt := 1; attempt <= maxRetries; attempt++ { + err = attemptTransaction(ctx, db) + if err == nil { + return nil // Success + } + + // Check if error is retryable (e.g., deadlock) + if !isRetryableError(err) { + return err + } + + fmt.Printf(" Attempt %d failed (retryable): %v\n", attempt, err) + + if attempt < maxRetries { + fmt.Printf(" Retrying... (%d/%d)\n", attempt+1, maxRetries) + } + } + + return fmt.Errorf("transaction failed after %d attempts: %w", maxRetries, err) +} + +func attemptTransaction(ctx context.Context, db *mysql.Database) error { + tx, commit, cancel, err := mysql.GetOrCreateTxFromContext(ctx) + defer cancel() + if err != nil { + return err + } + + ctx = mysql.NewContextWithTx(ctx, tx) + + // Perform transaction operations... + err = db.Insert("users", User{ + Name: "RetryUser", + Email: "retry@example.com", + Age: 29, + Active: true, + }) + + if err != nil { + return err + } + + return commit() +} + +func isRetryableError(err error) bool { + // Check for MySQL deadlock or lock timeout errors + // Note: cool-mysql already handles automatic retries for these + // This is just an example of manual retry logic + if err == nil { + return false + } + + errStr := err.Error() + return strings.Contains(errStr, "deadlock") || + strings.Contains(errStr, "lock wait timeout") || + strings.Contains(errStr, "try restarting transaction") +} + +// Batch transaction example +func batchTransactionExample(ctx context.Context, db *mysql.Database) error { + fmt.Println("\nBatch Transaction Example") + + tx, commit, cancel, err := mysql.GetOrCreateTxFromContext(ctx) + defer cancel() + if err != nil { + return err + } + + ctx = mysql.NewContextWithTx(ctx, tx) + + // Insert multiple users in single transaction + users := []User{ + {Name: "BatchUser1", Email: "batch1@example.com", Age: 20, Active: true}, + {Name: "BatchUser2", Email: "batch2@example.com", Age: 21, Active: true}, + {Name: "BatchUser3", Email: "batch3@example.com", Age: 22, Active: true}, + } + + err = db.Insert("users", users) + if err != nil { + return fmt.Errorf("batch insert failed: %w", err) + } + + fmt.Printf(" Inserted %d users in transaction\n", len(users)) + + // Commit + if err := commit(); err != nil { + return fmt.Errorf("batch commit failed: %w", err) + } + + fmt.Println("✓ Batch transaction committed") + return nil +} + +// savepoint example (MySQL specific) +func savepointExample(ctx context.Context, db *mysql.Database) error { + fmt.Println("\nSavepoint Example (Advanced)") + + tx, commit, cancel, err := mysql.GetOrCreateTxFromContext(ctx) + defer cancel() + if err != nil { + return err + } + + ctx = mysql.NewContextWithTx(ctx, tx) + + // Insert first user + user1 := User{Name: "SavepointUser1", Email: "sp1@example.com", Age: 25, Active: true} + err = db.Insert("users", user1) + if err != nil { + return err + } + fmt.Println(" User 1 inserted") + + // Create savepoint + err = db.Exec("SAVEPOINT sp1") + if err != nil { + return fmt.Errorf("savepoint creation failed: %w", err) + } + fmt.Println(" Savepoint 'sp1' created") + + // Insert second user + user2 := User{Name: "SavepointUser2", Email: "sp2@example.com", Age: 26, Active: true} + err = db.Insert("users", user2) + if err != nil { + return err + } + fmt.Println(" User 2 inserted") + + // Simulate error and rollback to savepoint + fmt.Println(" Simulating error, rolling back to savepoint...") + err = db.Exec("ROLLBACK TO SAVEPOINT sp1") + if err != nil { + return fmt.Errorf("rollback to savepoint failed: %w", err) + } + + fmt.Println(" Rolled back to savepoint (User 2 not saved)") + + // Insert different user + user3 := User{Name: "SavepointUser3", Email: "sp3@example.com", Age: 27, Active: true} + err = db.Insert("users", user3) + if err != nil { + return err + } + fmt.Println(" User 3 inserted") + + // Commit transaction + if err := commit(); err != nil { + return fmt.Errorf("commit failed: %w", err) + } + + fmt.Println("✓ Transaction committed (User 1 and 3 saved, User 2 rolled back)") + return nil +} + +// readCommittedIsolation example +func isolationLevelExample(ctx context.Context, db *mysql.Database) error { + fmt.Println("\nIsolation Level Example") + + // Set isolation level before transaction + err := db.Exec("SET TRANSACTION ISOLATION LEVEL READ COMMITTED") + if err != nil { + return fmt.Errorf("set isolation level failed: %w", err) + } + + fmt.Println(" Isolation level set to READ COMMITTED") + + tx, commit, cancel, err := mysql.GetOrCreateTxFromContext(ctx) + defer cancel() + if err != nil { + return err + } + + ctx = mysql.NewContextWithTx(ctx, tx) + + // Transaction operations... + var users []User + err = db.SelectWrites(&users, "SELECT `id`, `name`, `email`, `age`, `active`, `created_at`, `updated_at` FROM `users` WHERE `active` = 1", 0) + if err != nil { + return err + } + + fmt.Printf(" Read %d users with READ COMMITTED isolation\n", len(users)) + + if err := commit(); err != nil { + return fmt.Errorf("commit failed: %w", err) + } + + return nil +} diff --git a/examples/upsert-examples.go b/examples/upsert-examples.go new file mode 100644 index 0000000..423ef77 --- /dev/null +++ b/examples/upsert-examples.go @@ -0,0 +1,707 @@ +// Package examples demonstrates upsert patterns with cool-mysql +package examples + +import ( + "fmt" + "log" + "time" + + mysql "github.com/StirlingMarketingGroup/cool-mysql" +) + +// UpsertExamples demonstrates INSERT ... ON DUPLICATE KEY UPDATE patterns +func UpsertExamples() { + fmt.Println("=== UPSERT EXAMPLES ===") + + // Basic upsert + fmt.Println("\n1. Basic Upsert (by unique key)") + basicUpsertExample() + + // Upsert with multiple unique columns + fmt.Println("\n2. Upsert with Composite Unique Key") + compositeKeyUpsertExample() + + // Conditional upsert + fmt.Println("\n3. Conditional Upsert with WHERE clause") + conditionalUpsertExample() + + // Batch upsert + fmt.Println("\n4. Batch Upsert") + batchUpsertExample() + + // Selective column updates + fmt.Println("\n5. Selective Column Updates") + selectiveUpdateExample() + + // Timestamp tracking + fmt.Println("\n6. Timestamp Tracking with Upsert") + timestampUpsertExample() + + // Increment counter + fmt.Println("\n7. Increment Counter Pattern") + incrementCounterExample() + + // Upsert from channel + fmt.Println("\n8. Upsert from Channel (streaming)") + upsertFromChannelExample() + + // Upsert vs Insert Ignore + fmt.Println("\n9. Upsert vs Insert Ignore") + UpsertOrIgnoreExample() +} + +// basicUpsertExample demonstrates simple upsert by email +func basicUpsertExample() { + db, err := setupDatabase() + if err != nil { + log.Printf("Setup failed: %v", err) + return + } + + // Define user with unique email + user := User{ + Name: "UpsertUser", + Email: "upsert@example.com", // Unique key + Age: 30, + Active: true, + } + + // First upsert - INSERT (user doesn't exist) + err = db.Upsert( + "users", // table + []string{"email"}, // unique columns (conflict detection) + []string{"name", "age", "active"}, // columns to update on conflict + "", // no WHERE clause + user, // data + ) + + if err != nil { + log.Printf("First upsert failed: %v", err) + return + } + + fmt.Println(" First upsert: User inserted") + + // Second upsert - UPDATE (user exists) + user.Name = "UpsertUser Updated" + user.Age = 31 + + err = db.Upsert( + "users", + []string{"email"}, + []string{"name", "age", "active"}, + "", + user, + ) + + if err != nil { + log.Printf("Second upsert failed: %v", err) + return + } + + fmt.Println(" Second upsert: User updated") + + // Verify result + var retrieved User + err = db.Select(&retrieved, + "SELECT `id`, `name`, `email`, `age`, `active`, `created_at`, `updated_at` FROM `users` WHERE `email` = @@email", + 0, + "upsert@example.com") + + if err != nil { + log.Printf("Verification failed: %v", err) + return + } + + fmt.Printf("✓ Final state: Name='%s', Age=%d\n", retrieved.Name, retrieved.Age) +} + +// compositeKeyUpsertExample demonstrates upsert with multiple unique columns +func compositeKeyUpsertExample() { + db, err := setupDatabase() + if err != nil { + log.Printf("Setup failed: %v", err) + return + } + + type ProductInventory struct { + StoreID int `mysql:"store_id"` + ProductID int `mysql:"product_id"` + Quantity int `mysql:"quantity"` + Price float64 `mysql:"price"` + } + + // Initial inventory + inventory := ProductInventory{ + StoreID: 1, + ProductID: 100, + Quantity: 50, + Price: 19.99, + } + + // First upsert - INSERT + err = db.Upsert( + "inventory", + []string{"store_id", "product_id"}, // Composite unique key + []string{"quantity", "price"}, // Update these on conflict + "", + inventory, + ) + + if err != nil { + log.Printf("First upsert failed: %v", err) + return + } + + fmt.Println(" Inventory created: Store 1, Product 100, Qty=50, Price=$19.99") + + // Second upsert - UPDATE existing inventory + inventory.Quantity = 75 + inventory.Price = 17.99 + + err = db.Upsert( + "inventory", + []string{"store_id", "product_id"}, + []string{"quantity", "price"}, + "", + inventory, + ) + + if err != nil { + log.Printf("Second upsert failed: %v", err) + return + } + + fmt.Println(" Inventory updated: Qty=75, Price=$17.99") + fmt.Println("✓ Composite key upsert successful") +} + +// conditionalUpsertExample demonstrates upsert with WHERE clause +func conditionalUpsertExample() { + db, err := setupDatabase() + if err != nil { + log.Printf("Setup failed: %v", err) + return + } + + type Document struct { + ID int `mysql:"id"` + Title string `mysql:"title"` + Content string `mysql:"content"` + Version int `mysql:"version"` + UpdatedAt time.Time `mysql:"updated_at"` + } + + // Initial document + doc := Document{ + ID: 1, + Title: "My Document", + Content: "Version 1 content", + Version: 1, + UpdatedAt: time.Now(), + } + + // Insert initial version + err = db.Insert("documents", doc) + if err != nil { + log.Printf("Insert failed: %v", err) + return + } + + fmt.Println(" Document v1 created") + + // Upsert with condition: only update if newer + doc.Content = "Version 2 content" + doc.Version = 2 + doc.UpdatedAt = time.Now() + + err = db.Upsert( + "documents", + []string{"id"}, + []string{"title", "content", "version", "updated_at"}, + "version < VALUES(version)", // Only update if new version is higher + doc, + ) + + if err != nil { + log.Printf("Conditional upsert failed: %v", err) + return + } + + fmt.Println(" Document updated to v2 (condition met)") + + // Try to upsert with older version (should not update) + oldDoc := Document{ + ID: 1, + Title: "My Document", + Content: "Old content", + Version: 1, // Older version + UpdatedAt: time.Now().Add(-time.Hour), + } + + err = db.Upsert( + "documents", + []string{"id"}, + []string{"title", "content", "version", "updated_at"}, + "version < VALUES(version)", + oldDoc, + ) + + if err != nil { + log.Printf("Old version upsert failed: %v", err) + return + } + + fmt.Println(" Old version upsert executed (but condition prevented update)") + + // Verify current version + var current Document + err = db.Select(¤t, + "SELECT `id`, `title`, `content`, `version`, `updated_at` FROM `documents` WHERE `id` = @@id", + 0, + 1) + + if err != nil { + log.Printf("Verification failed: %v", err) + return + } + + fmt.Printf("✓ Final version: %d (conditional update worked)\n", current.Version) +} + +// batchUpsertExample demonstrates upserting multiple records +func batchUpsertExample() { + db, err := setupDatabase() + if err != nil { + log.Printf("Setup failed: %v", err) + return + } + + type Setting struct { + Key string `mysql:"key"` + Value string `mysql:"value"` + } + + // Batch of settings + settings := []Setting{ + {Key: "theme", Value: "dark"}, + {Key: "language", Value: "en"}, + {Key: "notifications", Value: "enabled"}, + {Key: "timezone", Value: "UTC"}, + } + + // Upsert all settings + err = db.Upsert( + "settings", + []string{"key"}, // Unique on key + []string{"value"}, // Update value on conflict + "", + settings, + ) + + if err != nil { + log.Printf("Batch upsert failed: %v", err) + return + } + + fmt.Printf(" Inserted/updated %d settings\n", len(settings)) + + // Update some settings + updatedSettings := []Setting{ + {Key: "theme", Value: "light"}, // Changed + {Key: "language", Value: "es"}, // Changed + {Key: "notifications", Value: "enabled"}, // Same + {Key: "font_size", Value: "14"}, // New + } + + err = db.Upsert( + "settings", + []string{"key"}, + []string{"value"}, + "", + updatedSettings, + ) + + if err != nil { + log.Printf("Update batch upsert failed: %v", err) + return + } + + fmt.Printf(" Updated batch: 2 changed, 1 same, 1 new\n") + + // Verify results + var allSettings []Setting + err = db.Select(&allSettings, + "SELECT `key`, `value` FROM `settings` ORDER BY key", + 0) + + if err != nil { + log.Printf("Verification failed: %v", err) + return + } + + fmt.Printf("✓ Total settings: %d\n", len(allSettings)) + for _, s := range allSettings { + fmt.Printf(" %s = %s\n", s.Key, s.Value) + } +} + +// selectiveUpdateExample demonstrates updating only specific columns +func selectiveUpdateExample() { + db, err := setupDatabase() + if err != nil { + log.Printf("Setup failed: %v", err) + return + } + + type UserProfile struct { + Email string `mysql:"email"` + Name string `mysql:"name"` + Bio string `mysql:"bio"` + Avatar string `mysql:"avatar"` + UpdatedAt time.Time `mysql:"updated_at,defaultzero"` + } + + // Initial profile + profile := UserProfile{ + Email: "profile@example.com", + Name: "John Doe", + Bio: "Software Developer", + Avatar: "avatar1.jpg", + UpdatedAt: time.Now(), + } + + err = db.Insert("user_profiles", profile) + if err != nil { + log.Printf("Insert failed: %v", err) + return + } + + fmt.Println(" Profile created") + + // Update only name and bio (not avatar) + profile.Name = "John Smith" + profile.Bio = "Senior Software Developer" + // Avatar unchanged + + err = db.Upsert( + "user_profiles", + []string{"email"}, + []string{"name", "bio", "updated_at"}, // Don't include avatar + "", + profile, + ) + + if err != nil { + log.Printf("Selective update failed: %v", err) + return + } + + fmt.Println(" Updated name and bio (avatar unchanged)") + + // Later, update only avatar + profile.Avatar = "avatar2.jpg" + + err = db.Upsert( + "user_profiles", + []string{"email"}, + []string{"avatar", "updated_at"}, // Only avatar + "", + profile, + ) + + if err != nil { + log.Printf("Avatar update failed: %v", err) + return + } + + fmt.Println(" Updated avatar only") + + // Verify + var final UserProfile + err = db.Select(&final, + "SELECT `email`, `name`, `bio`, `avatar`, `updated_at` FROM `user_profiles` WHERE `email` = @@email", + 0, + "profile@example.com") + + if err != nil { + log.Printf("Verification failed: %v", err) + return + } + + fmt.Printf("✓ Final: Name='%s', Bio='%s', Avatar='%s'\n", + final.Name, final.Bio, final.Avatar) +} + +// timestampUpsertExample demonstrates tracking created_at and updated_at +func timestampUpsertExample() { + db, err := setupDatabase() + if err != nil { + log.Printf("Setup failed: %v", err) + return + } + + type Article struct { + Slug string `mysql:"slug"` + Title string `mysql:"title"` + Content string `mysql:"content"` + Views int `mysql:"views"` + CreatedAt time.Time `mysql:"created_at,defaultzero"` + UpdatedAt time.Time `mysql:"updated_at,defaultzero"` + } + + // Initial article + article := Article{ + Slug: "my-article", + Title: "My Article", + Content: "Initial content", + Views: 0, + } + + // First upsert - INSERT + // CreatedAt and UpdatedAt will use database defaults + err = db.Upsert( + "articles", + []string{"slug"}, + []string{"title", "content", "views", "updated_at"}, + "", + article, + ) + + if err != nil { + log.Printf("Insert failed: %v", err) + return + } + + fmt.Println(" Article created (created_at set by DB)") + + // Get the article to see timestamps + var inserted Article + err = db.Select(&inserted, + "SELECT `slug`, `title`, `content`, `views`, `created_at`, `updated_at` FROM `articles` WHERE slug = @@slug", + 0, + "my-article") + + if err != nil { + log.Printf("Select failed: %v", err) + return + } + + fmt.Printf(" Created at: %s\n", inserted.CreatedAt.Format(time.RFC3339)) + + time.Sleep(2 * time.Second) // Wait to see time difference + + // Update article + article.Title = "My Updated Article" + article.Content = "Updated content" + article.Views = inserted.Views + 10 + + err = db.Upsert( + "articles", + []string{"slug"}, + []string{"title", "content", "views", "updated_at"}, + "", // Don't update created_at + article, + ) + + if err != nil { + log.Printf("Update failed: %v", err) + return + } + + fmt.Println(" Article updated (updated_at changed, created_at preserved)") + + // Verify timestamps + var updated Article + err = db.Select(&updated, + "SELECT `slug`, `title`, `content`, `views`, `created_at`, `updated_at` FROM `articles` WHERE slug = @@slug", + 0, + "my-article") + + if err != nil { + log.Printf("Verification failed: %v", err) + return + } + + fmt.Printf(" Created at: %s (unchanged)\n", updated.CreatedAt.Format(time.RFC3339)) + fmt.Printf(" Updated at: %s (newer)\n", updated.UpdatedAt.Format(time.RFC3339)) + fmt.Printf("✓ Timestamps tracked correctly\n") +} + +// incrementCounterExample demonstrates atomic counter updates +func incrementCounterExample() { + db, err := setupDatabase() + if err != nil { + log.Printf("Setup failed: %v", err) + return + } + + fmt.Println("\nIncrement Counter Example") + + type PageView struct { + Page string `mysql:"page"` + Views int `mysql:"views"` + } + + page := "homepage" + + // Increment views (insert with 1 if not exists, increment if exists) + // This uses MySQL's VALUES() function to reference the insert value + viewRecord := PageView{ + Page: page, + Views: 1, // Initial value for insert + } + + // Custom upsert to increment on duplicate + err = db.Exec( + "INSERT INTO `page_views` (`page`, `views`)"+ + " VALUES (@@page, @@views)"+ + " ON DUPLICATE KEY UPDATE `views` = `views` + @@views", + mysql.Params{ + "page": viewRecord.Page, + "views": viewRecord.Views, + }) + + if err != nil { + log.Printf("Increment failed: %v", err) + return + } + + fmt.Printf(" View count incremented for %s\n", page) + + // Increment again + err = db.Exec( + "INSERT INTO `page_views` (`page`, `views`)"+ + " VALUES (@@page, @@views)"+ + " ON DUPLICATE KEY UPDATE `views` = `views` + @@views", + mysql.Params{ + "page": page, + "views": 1, + }) + + if err != nil { + log.Printf("Second increment failed: %v", err) + return + } + + // Check total views + var totalViews int + err = db.Select(&totalViews, + "SELECT views FROM `page_views` WHERE page = @@page", + 0, + page) + + if err != nil { + log.Printf("Select views failed: %v", err) + return + } + + fmt.Printf("✓ Total views for %s: %d\n", page, totalViews) +} + +// upsertFromChannelExample demonstrates streaming upserts +func upsertFromChannelExample() { + db, err := setupDatabase() + if err != nil { + log.Printf("Setup failed: %v", err) + return + } + + fmt.Println("\nUpsert from Channel Example") + + type Metric struct { + MetricName string `mysql:"metric_name"` + Value float64 `mysql:"value"` + Timestamp time.Time `mysql:"timestamp,defaultzero"` + } + + // Create channel of metrics + metricCh := make(chan Metric, 100) + + // Producer: generate metrics + go func() { + defer close(metricCh) + metrics := []string{"cpu", "memory", "disk", "network"} + for i := 0; i < 100; i++ { + metricCh <- Metric{ + MetricName: metrics[i%len(metrics)], + Value: float64(i), + Timestamp: time.Now(), + } + } + }() + + // Upsert from channel + err = db.Upsert( + "metrics", + []string{"metric_name"}, + []string{"value", "timestamp"}, + "", + metricCh, + ) + + if err != nil { + log.Printf("Channel upsert failed: %v", err) + return + } + + fmt.Println("✓ Streamed 100 metric upserts") + + // Verify + var count int + count, err = db.Count("SELECT COUNT(*) FROM `metrics`", 0) + if err != nil { + log.Printf("Count failed: %v", err) + return + } + + fmt.Printf(" Total unique metrics: %d\n", count) +} + +// UpsertOrIgnoreExample demonstrates choosing between update and ignore +func UpsertOrIgnoreExample() { + db, err := setupDatabase() + if err != nil { + log.Printf("Setup failed: %v", err) + return + } + + fmt.Println("\nUpsert vs Insert Ignore Example") + + type UniqueEmail struct { + Email string `mysql:"email"` + Count int `mysql:"count"` + } + + // With UPSERT - updates on duplicate + email1 := UniqueEmail{Email: "test1@example.com", Count: 1} + err = db.Upsert( + "email_counts", + []string{"email"}, + []string{"count"}, + "", + email1, + ) + fmt.Println(" Upsert: Inserts or updates") + + // With INSERT IGNORE - silently ignores duplicate + err = db.Exec( + "INSERT IGNORE INTO `email_counts` (email, count) VALUES (@@email, @@count)", + mysql.Params{"email": "test1@example.com", "count": 999}) + + fmt.Println(" Insert Ignore: Keeps original value on duplicate") + + // Verify - count should still be 1 (ignore worked) + var result UniqueEmail + err = db.Select(&result, + "SELECT `email`, `count` FROM `email_counts` WHERE `email` = @@email", + 0, + "test1@example.com") + + if err != nil { + log.Printf("Verification failed: %v", err) + return + } + + fmt.Printf("✓ Count=%d (INSERT IGNORE kept original)\n", result.Count) +} diff --git a/plugin.lock.json b/plugin.lock.json new file mode 100644 index 0000000..8923394 --- /dev/null +++ b/plugin.lock.json @@ -0,0 +1,85 @@ +{ + "$schema": "internal://schemas/plugin.lock.v1.json", + "pluginId": "gh:StirlingMarketingGroup/cool-mysql:.claude/skills/cool-mysql", + "normalized": { + "repo": null, + "ref": "refs/tags/v20251128.0", + "commit": "34c0180280fa340253f8abb62e925db1a962b99d", + "treeHash": "25649f306022076993da9a68020db65922c8f25a642b92b9cd11a9f90027dbf0", + "generatedAt": "2025-11-28T10:12:49.515283Z", + "toolVersion": "publish_plugins.py@0.2.0" + }, + "origin": { + "remote": "git@github.com:zhongweili/42plugin-data.git", + "branch": "master", + "commit": "aa1497ed0949fd50e99e70d6324a29c5b34f9390", + "repoRoot": "/Users/zhongweili/projects/openmind/42plugin-data" + }, + "manifest": { + "name": "cool-mysql", + "description": "Comprehensive guide for cool-mysql Go library - MySQL helper with dual connection pools, named parameters, template syntax, caching, and advanced query patterns", + "version": null + }, + "content": { + "files": [ + { + "path": "README.md", + "sha256": "818751d7ad2db0c435738b0e7c464bdc1b3b5ef125c39b90187b0fd92c4683db" + }, + { + "path": "SKILL.md", + "sha256": "71c7038291daa7b6e032373665a79adaa6ccf3235708e3ff3394ba03252dbc61" + }, + { + "path": "references/query-patterns.md", + "sha256": "dc4953d6868e86b5a0ee3264f4a20b3a18ec7915167afdf27aa5b81f9f82c221" + }, + { + "path": "references/struct-tags.md", + "sha256": "82e64aedfd0048db171c7ce0539c0d4710728e8c946de022f735f7244f2c66f5" + }, + { + "path": "references/caching-guide.md", + "sha256": "a422f96300ab93af34cb9bfcb4a043176342e9609a873b76830642c07f2c0cf3" + }, + { + "path": "references/testing-patterns.md", + "sha256": "3ea21a3482f7ffbac5ae0c274b108732f1b612dbdaa26794aed7a931d85ae5fd" + }, + { + "path": "references/api-reference.md", + "sha256": "81acaace8fb78dadbab09c5499956882c2dce2fbe77e71b27dfdddc4de015f8a" + }, + { + "path": "examples/basic-crud.go", + "sha256": "ba3022b5567fab5bc10853e45d02be862d3413e80bfb4bf841a259392f5ba222" + }, + { + "path": "examples/upsert-examples.go", + "sha256": "f5cdf080cece284d5eb31a24028355c81dd0733608bb7720eff576be2b6cbe99" + }, + { + "path": "examples/advanced-queries.go", + "sha256": "ad278e8539ee60fafaec3a86c84c339b0f6bb78552e82cc18846e92e730928ce" + }, + { + "path": "examples/caching-setup.go", + "sha256": "3a7933279518646d31a2ed51d9961883788d4345c2e7f28cfb961d794bf25cf6" + }, + { + "path": "examples/transaction-patterns.go", + "sha256": "7161ffe6726b7cea5f7314affdeeb7c65a98dfdb69f69c048fbb1ccab6e28e9d" + }, + { + "path": ".claude-plugin/plugin.json", + "sha256": "661e81c25dd221cd4ae38d538bbeae6dd65fec9226370dfba036d1467ac8a05e" + } + ], + "dirSha256": "25649f306022076993da9a68020db65922c8f25a642b92b9cd11a9f90027dbf0" + }, + "security": { + "scannedAt": null, + "scannerVersion": null, + "flags": [] + } +} \ No newline at end of file diff --git a/references/api-reference.md b/references/api-reference.md new file mode 100644 index 0000000..bf2216f --- /dev/null +++ b/references/api-reference.md @@ -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 [] +``` diff --git a/references/caching-guide.md b/references/caching-guide.md new file mode 100644 index 0000000..8606972 --- /dev/null +++ b/references/caching-guide.md @@ -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 diff --git a/references/query-patterns.md b/references/query-patterns.md new file mode 100644 index 0000000..0291aca --- /dev/null +++ b/references/query-patterns.md @@ -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 diff --git a/references/struct-tags.md b/references/struct-tags.md new file mode 100644 index 0000000..3c4ec2f --- /dev/null +++ b/references/struct-tags.md @@ -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"` +} +``` diff --git a/references/testing-patterns.md b/references/testing-patterns.md new file mode 100644 index 0000000..7407954 --- /dev/null +++ b/references/testing-patterns.md @@ -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)) + } +} +```