fix(go.sum): update ResolveSpec dependency to v1.0.87
This commit is contained in:
Generated
Vendored
+218
@@ -0,0 +1,218 @@
|
||||
# Automatic Relation Loading Strategies
|
||||
|
||||
## Overview
|
||||
|
||||
**NEW:** The database adapters now **automatically** choose the optimal loading strategy by inspecting your model's relationship tags!
|
||||
|
||||
Simply use `PreloadRelation()` and the system automatically:
|
||||
- Detects relationship type from Bun/GORM tags
|
||||
- Uses **JOIN** for many-to-one and one-to-one (efficient, no duplication)
|
||||
- Uses **separate query** for one-to-many and many-to-many (avoids duplication)
|
||||
|
||||
## How It Works
|
||||
|
||||
```go
|
||||
// Just write this - the system handles the rest!
|
||||
db.NewSelect().
|
||||
Model(&links).
|
||||
PreloadRelation("Provider"). // ✓ Auto-detects belongs-to → uses JOIN
|
||||
PreloadRelation("Tags"). // ✓ Auto-detects has-many → uses separate query
|
||||
Scan(ctx, &links)
|
||||
```
|
||||
|
||||
### Detection Logic
|
||||
|
||||
The system inspects your model's struct tags:
|
||||
|
||||
**Bun models:**
|
||||
```go
|
||||
type Link struct {
|
||||
Provider *Provider `bun:"rel:belongs-to"` // → Detected: belongs-to → JOIN
|
||||
Tags []Tag `bun:"rel:has-many"` // → Detected: has-many → Separate query
|
||||
}
|
||||
```
|
||||
|
||||
**GORM models:**
|
||||
```go
|
||||
type Link struct {
|
||||
ProviderID int
|
||||
Provider *Provider `gorm:"foreignKey:ProviderID"` // → Detected: belongs-to → JOIN
|
||||
Tags []Tag `gorm:"many2many:link_tags"` // → Detected: many-to-many → Separate query
|
||||
}
|
||||
```
|
||||
|
||||
**Type inference (fallback):**
|
||||
- `[]Type` (slice) → has-many → Separate query
|
||||
- `*Type` (pointer) → belongs-to → JOIN
|
||||
- `Type` (struct) → belongs-to → JOIN
|
||||
|
||||
### What Gets Logged
|
||||
|
||||
Enable debug logging to see strategy selection:
|
||||
|
||||
```go
|
||||
bunAdapter.EnableQueryDebug()
|
||||
```
|
||||
|
||||
**Output:**
|
||||
```
|
||||
DEBUG: PreloadRelation 'Provider' detected as: belongs-to
|
||||
INFO: Using JOIN strategy for belongs-to relation 'Provider'
|
||||
DEBUG: PreloadRelation 'Links' detected as: has-many
|
||||
DEBUG: Using separate query for has-many relation 'Links'
|
||||
```
|
||||
|
||||
## Relationship Types
|
||||
|
||||
| Bun Tag | GORM Pattern | Field Type | Strategy | Why |
|
||||
|---------|--------------|------------|----------|-----|
|
||||
| `rel:has-many` | Slice field | `[]Type` | Separate Query | Avoids duplicating parent data |
|
||||
| `rel:belongs-to` | `foreignKey:` | `*Type` | JOIN | Single parent, no duplication |
|
||||
| `rel:has-one` | Single pointer | `*Type` | JOIN | One-to-one, no duplication |
|
||||
| `rel:many-to-many` | `many2many:` | `[]Type` | Separate Query | Complex join, avoid cartesian |
|
||||
|
||||
## Manual Override
|
||||
|
||||
If you need to force a specific strategy, use `JoinRelation()`:
|
||||
|
||||
```go
|
||||
// Force JOIN even for has-many (not recommended)
|
||||
db.NewSelect().
|
||||
Model(&providers).
|
||||
JoinRelation("Links"). // Explicitly use JOIN
|
||||
Scan(ctx, &providers)
|
||||
```
|
||||
|
||||
## Examples
|
||||
|
||||
### Automatic Strategy Selection (Recommended)
|
||||
|
||||
```go
|
||||
// Example 1: Loading parent provider for each link
|
||||
// System detects belongs-to → uses JOIN automatically
|
||||
db.NewSelect().
|
||||
Model(&links).
|
||||
PreloadRelation("Provider", func(q common.SelectQuery) common.SelectQuery {
|
||||
return q.Where("active = ?", true)
|
||||
}).
|
||||
Scan(ctx, &links)
|
||||
|
||||
// Generated SQL: Single query with JOIN
|
||||
// SELECT links.*, providers.*
|
||||
// FROM links
|
||||
// LEFT JOIN providers ON links.provider_id = providers.id
|
||||
// WHERE providers.active = true
|
||||
|
||||
// Example 2: Loading child links for each provider
|
||||
// System detects has-many → uses separate query automatically
|
||||
db.NewSelect().
|
||||
Model(&providers).
|
||||
PreloadRelation("Links", func(q common.SelectQuery) common.SelectQuery {
|
||||
return q.Where("active = ?", true)
|
||||
}).
|
||||
Scan(ctx, &providers)
|
||||
|
||||
// Generated SQL: Two queries
|
||||
// Query 1: SELECT * FROM providers
|
||||
// Query 2: SELECT * FROM links
|
||||
// WHERE provider_id IN (1, 2, 3, ...)
|
||||
// AND active = true
|
||||
```
|
||||
|
||||
### Mixed Relationships
|
||||
|
||||
```go
|
||||
type Order struct {
|
||||
ID int
|
||||
CustomerID int
|
||||
Customer *Customer `bun:"rel:belongs-to"` // JOIN
|
||||
Items []Item `bun:"rel:has-many"` // Separate
|
||||
Invoice *Invoice `bun:"rel:has-one"` // JOIN
|
||||
}
|
||||
|
||||
// All three handled optimally!
|
||||
db.NewSelect().
|
||||
Model(&orders).
|
||||
PreloadRelation("Customer"). // → JOIN (many-to-one)
|
||||
PreloadRelation("Items"). // → Separate (one-to-many)
|
||||
PreloadRelation("Invoice"). // → JOIN (one-to-one)
|
||||
Scan(ctx, &orders)
|
||||
```
|
||||
|
||||
## Performance Benefits
|
||||
|
||||
### Before (Manual Strategy Selection)
|
||||
|
||||
```go
|
||||
// You had to remember which to use:
|
||||
.PreloadRelation("Provider") // Should I use PreloadRelation or JoinRelation?
|
||||
.PreloadRelation("Links") // Which is more efficient here?
|
||||
```
|
||||
|
||||
### After (Automatic Selection)
|
||||
|
||||
```go
|
||||
// Just use PreloadRelation everywhere:
|
||||
.PreloadRelation("Provider") // ✓ System uses JOIN automatically
|
||||
.PreloadRelation("Links") // ✓ System uses separate query automatically
|
||||
```
|
||||
|
||||
## Migration Guide
|
||||
|
||||
**No changes needed!** If you're already using `PreloadRelation()`, it now automatically optimizes:
|
||||
|
||||
```go
|
||||
// Before: Always used separate query
|
||||
.PreloadRelation("Provider") // Inefficient: extra round trip
|
||||
|
||||
// After: Automatic optimization
|
||||
.PreloadRelation("Provider") // ✓ Now uses JOIN automatically!
|
||||
```
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### Supported Bun Tags
|
||||
- `rel:has-many` → Separate query
|
||||
- `rel:belongs-to` → JOIN
|
||||
- `rel:has-one` → JOIN
|
||||
- `rel:many-to-many` or `rel:m2m` → Separate query
|
||||
|
||||
### Supported GORM Patterns
|
||||
- `many2many:` tag → Separate query
|
||||
- `foreignKey:` tag → JOIN (belongs-to)
|
||||
- `[]Type` slice without many2many → Separate query (has-many)
|
||||
- `*Type` pointer with foreignKey → JOIN (belongs-to)
|
||||
- `*Type` pointer without foreignKey → JOIN (has-one)
|
||||
|
||||
### Fallback Behavior
|
||||
- `[]Type` (slice) → Separate query (safe default for collections)
|
||||
- `*Type` or `Type` (single) → JOIN (safe default for single relations)
|
||||
- Unknown → Separate query (safest default)
|
||||
|
||||
## Debugging
|
||||
|
||||
To see strategy selection in action:
|
||||
|
||||
```go
|
||||
// Enable debug logging
|
||||
bunAdapter.EnableQueryDebug() // or gormAdapter.EnableQueryDebug()
|
||||
|
||||
// Run your query
|
||||
db.NewSelect().
|
||||
Model(&records).
|
||||
PreloadRelation("RelationName").
|
||||
Scan(ctx, &records)
|
||||
|
||||
// Check logs for:
|
||||
// - "PreloadRelation 'X' detected as: belongs-to"
|
||||
// - "Using JOIN strategy for belongs-to relation 'X'"
|
||||
// - Actual SQL queries executed
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Use PreloadRelation() for everything** - Let the system optimize
|
||||
2. **Define proper relationship tags** - Ensures correct detection
|
||||
3. **Only use JoinRelation() for overrides** - When you know better than auto-detection
|
||||
4. **Enable debug logging during development** - Verify optimal strategies are chosen
|
||||
5. **Trust the system** - It's designed to choose correctly based on relationship type
|
||||
+1770
File diff suppressed because it is too large
Load Diff
+1018
File diff suppressed because it is too large
Load Diff
+1600
File diff suppressed because it is too large
Load Diff
Generated
Vendored
+176
@@ -0,0 +1,176 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
|
||||
_ "github.com/jackc/pgx/v5/stdlib" // PostgreSQL driver
|
||||
|
||||
"github.com/bitechdev/ResolveSpec/pkg/common"
|
||||
)
|
||||
|
||||
// Example demonstrates how to use the PgSQL adapter
|
||||
func ExamplePgSQLAdapter() error {
|
||||
// Connect to PostgreSQL database
|
||||
dsn := "postgres://username:password@localhost:5432/dbname?sslmode=disable"
|
||||
db, err := sql.Open("pgx", dsn)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to open database: %w", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
// Create the PgSQL adapter
|
||||
adapter := NewPgSQLAdapter(db)
|
||||
|
||||
// Enable query debugging (optional)
|
||||
adapter.EnableQueryDebug()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Example 1: Simple SELECT query
|
||||
var results []map[string]interface{}
|
||||
err = adapter.NewSelect().
|
||||
Table("users").
|
||||
Where("age > ?", 18).
|
||||
Order("created_at DESC").
|
||||
Limit(10).
|
||||
Scan(ctx, &results)
|
||||
if err != nil {
|
||||
return fmt.Errorf("select failed: %w", err)
|
||||
}
|
||||
|
||||
// Example 2: INSERT query
|
||||
result, err := adapter.NewInsert().
|
||||
Table("users").
|
||||
Value("name", "John Doe").
|
||||
Value("email", "john@example.com").
|
||||
Value("age", 25).
|
||||
Returning("id").
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("insert failed: %w", err)
|
||||
}
|
||||
fmt.Printf("Rows affected: %d\n", result.RowsAffected())
|
||||
|
||||
// Example 3: UPDATE query
|
||||
result, err = adapter.NewUpdate().
|
||||
Table("users").
|
||||
Set("name", "Jane Doe").
|
||||
Where("id = ?", 1).
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update failed: %w", err)
|
||||
}
|
||||
fmt.Printf("Rows updated: %d\n", result.RowsAffected())
|
||||
|
||||
// Example 4: DELETE query
|
||||
result, err = adapter.NewDelete().
|
||||
Table("users").
|
||||
Where("age < ?", 18).
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete failed: %w", err)
|
||||
}
|
||||
fmt.Printf("Rows deleted: %d\n", result.RowsAffected())
|
||||
|
||||
// Example 5: Using transactions
|
||||
err = adapter.RunInTransaction(ctx, func(tx common.Database) error {
|
||||
// Insert a new user
|
||||
_, err := tx.NewInsert().
|
||||
Table("users").
|
||||
Value("name", "Transaction User").
|
||||
Value("email", "tx@example.com").
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Update another user
|
||||
_, err = tx.NewUpdate().
|
||||
Table("users").
|
||||
Set("verified", true).
|
||||
Where("email = ?", "tx@example.com").
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Both operations succeed or both rollback
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("transaction failed: %w", err)
|
||||
}
|
||||
|
||||
// Example 6: JOIN query
|
||||
err = adapter.NewSelect().
|
||||
Table("users u").
|
||||
Column("u.id", "u.name", "p.title as post_title").
|
||||
LeftJoin("posts p ON p.user_id = u.id").
|
||||
Where("u.active = ?", true).
|
||||
Scan(ctx, &results)
|
||||
if err != nil {
|
||||
return fmt.Errorf("join query failed: %w", err)
|
||||
}
|
||||
|
||||
// Example 7: Aggregation query
|
||||
count, err := adapter.NewSelect().
|
||||
Table("users").
|
||||
Where("active = ?", true).
|
||||
Count(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("count failed: %w", err)
|
||||
}
|
||||
fmt.Printf("Active users: %d\n", count)
|
||||
|
||||
// Example 8: Raw SQL execution
|
||||
_, err = adapter.Exec(ctx, "CREATE INDEX IF NOT EXISTS idx_users_email ON users(email)")
|
||||
if err != nil {
|
||||
return fmt.Errorf("raw exec failed: %w", err)
|
||||
}
|
||||
|
||||
// Example 9: Raw SQL query
|
||||
var users []map[string]interface{}
|
||||
err = adapter.Query(ctx, &users, "SELECT * FROM users WHERE age > $1 LIMIT $2", 18, 10)
|
||||
if err != nil {
|
||||
return fmt.Errorf("raw query failed: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// User is an example model
|
||||
type User struct {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Email string `json:"email"`
|
||||
Age int `json:"age"`
|
||||
}
|
||||
|
||||
// TableName implements common.TableNameProvider
|
||||
func (u User) TableName() string {
|
||||
return "users"
|
||||
}
|
||||
|
||||
// ExampleWithModel demonstrates using models with the PgSQL adapter
|
||||
func ExampleWithModel() error {
|
||||
dsn := "postgres://username:password@localhost:5432/dbname?sslmode=disable"
|
||||
db, err := sql.Open("pgx", dsn)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
adapter := NewPgSQLAdapter(db)
|
||||
ctx := context.Background()
|
||||
|
||||
// Use model with adapter
|
||||
user := User{}
|
||||
err = adapter.NewSelect().
|
||||
Model(&user).
|
||||
Where("id = ?", 1).
|
||||
Scan(ctx, &user)
|
||||
|
||||
return err
|
||||
}
|
||||
Generated
Vendored
+275
@@ -0,0 +1,275 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
|
||||
_ "github.com/jackc/pgx/v5/stdlib"
|
||||
|
||||
"github.com/bitechdev/ResolveSpec/pkg/common"
|
||||
)
|
||||
|
||||
// Example models for demonstrating preload functionality
|
||||
|
||||
// Author model - has many Posts
|
||||
type Author struct {
|
||||
ID int `db:"id"`
|
||||
Name string `db:"name"`
|
||||
Email string `db:"email"`
|
||||
Posts []*Post `bun:"rel:has-many,join:id=author_id"`
|
||||
}
|
||||
|
||||
func (a Author) TableName() string {
|
||||
return "authors"
|
||||
}
|
||||
|
||||
// Post model - belongs to Author, has many Comments
|
||||
type Post struct {
|
||||
ID int `db:"id"`
|
||||
Title string `db:"title"`
|
||||
Content string `db:"content"`
|
||||
AuthorID int `db:"author_id"`
|
||||
Author *Author `bun:"rel:belongs-to,join:author_id=id"`
|
||||
Comments []*Comment `bun:"rel:has-many,join:id=post_id"`
|
||||
}
|
||||
|
||||
func (p Post) TableName() string {
|
||||
return "posts"
|
||||
}
|
||||
|
||||
// Comment model - belongs to Post
|
||||
type Comment struct {
|
||||
ID int `db:"id"`
|
||||
Content string `db:"content"`
|
||||
PostID int `db:"post_id"`
|
||||
Post *Post `bun:"rel:belongs-to,join:post_id=id"`
|
||||
}
|
||||
|
||||
func (c Comment) TableName() string {
|
||||
return "comments"
|
||||
}
|
||||
|
||||
// ExamplePreload demonstrates the Preload functionality
|
||||
func ExamplePreload() error {
|
||||
dsn := "postgres://username:password@localhost:5432/dbname?sslmode=disable"
|
||||
db, err := sql.Open("pgx", dsn)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
adapter := NewPgSQLAdapter(db)
|
||||
ctx := context.Background()
|
||||
|
||||
// Example 1: Simple Preload (uses subquery for has-many)
|
||||
var authors []*Author
|
||||
err = adapter.NewSelect().
|
||||
Model(&Author{}).
|
||||
Table("authors").
|
||||
Preload("Posts"). // Load all posts for each author
|
||||
Scan(ctx, &authors)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Now authors[i].Posts will be populated with their posts
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ExamplePreloadRelation demonstrates smart PreloadRelation with auto-detection
|
||||
func ExamplePreloadRelation() error {
|
||||
dsn := "postgres://username:password@localhost:5432/dbname?sslmode=disable"
|
||||
db, err := sql.Open("pgx", dsn)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
adapter := NewPgSQLAdapter(db)
|
||||
ctx := context.Background()
|
||||
|
||||
// Example 1: PreloadRelation auto-detects has-many (uses subquery)
|
||||
var authors []*Author
|
||||
err = adapter.NewSelect().
|
||||
Model(&Author{}).
|
||||
Table("authors").
|
||||
PreloadRelation("Posts", func(q common.SelectQuery) common.SelectQuery {
|
||||
return q.Where("published = ?", true).Order("created_at DESC")
|
||||
}).
|
||||
Where("active = ?", true).
|
||||
Scan(ctx, &authors)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Example 2: PreloadRelation auto-detects belongs-to (uses JOIN)
|
||||
var posts []*Post
|
||||
err = adapter.NewSelect().
|
||||
Model(&Post{}).
|
||||
Table("posts").
|
||||
PreloadRelation("Author"). // Will use JOIN because it's belongs-to
|
||||
Scan(ctx, &posts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Example 3: Nested preloads
|
||||
err = adapter.NewSelect().
|
||||
Model(&Author{}).
|
||||
Table("authors").
|
||||
PreloadRelation("Posts", func(q common.SelectQuery) common.SelectQuery {
|
||||
// First load posts, then preload comments for each post
|
||||
return q.Limit(10)
|
||||
}).
|
||||
Scan(ctx, &authors)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Manually load nested relationships (two-level preloading)
|
||||
for _, author := range authors {
|
||||
if author.Posts != nil {
|
||||
for _, post := range author.Posts {
|
||||
var comments []*Comment
|
||||
err := adapter.NewSelect().
|
||||
Table("comments").
|
||||
Where("post_id = ?", post.ID).
|
||||
Scan(ctx, &comments)
|
||||
if err == nil {
|
||||
post.Comments = comments
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ExampleJoinRelation demonstrates explicit JOIN loading
|
||||
func ExampleJoinRelation() error {
|
||||
dsn := "postgres://username:password@localhost:5432/dbname?sslmode=disable"
|
||||
db, err := sql.Open("pgx", dsn)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
adapter := NewPgSQLAdapter(db)
|
||||
ctx := context.Background()
|
||||
|
||||
// Example 1: Force JOIN for belongs-to relationship
|
||||
var posts []*Post
|
||||
err = adapter.NewSelect().
|
||||
Model(&Post{}).
|
||||
Table("posts").
|
||||
JoinRelation("Author", func(q common.SelectQuery) common.SelectQuery {
|
||||
return q.Where("active = ?", true)
|
||||
}).
|
||||
Scan(ctx, &posts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Example 2: Multiple JOINs
|
||||
err = adapter.NewSelect().
|
||||
Model(&Post{}).
|
||||
Table("posts p").
|
||||
Column("p.*", "a.name as author_name", "a.email as author_email").
|
||||
LeftJoin("authors a ON a.id = p.author_id").
|
||||
Where("p.published = ?", true).
|
||||
Scan(ctx, &posts)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// ExampleScanModel demonstrates ScanModel with struct destinations
|
||||
func ExampleScanModel() error {
|
||||
dsn := "postgres://username:password@localhost:5432/dbname?sslmode=disable"
|
||||
db, err := sql.Open("pgx", dsn)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
adapter := NewPgSQLAdapter(db)
|
||||
ctx := context.Background()
|
||||
|
||||
// Example 1: Scan single struct
|
||||
author := Author{}
|
||||
err = adapter.NewSelect().
|
||||
Model(&author).
|
||||
Table("authors").
|
||||
Where("id = ?", 1).
|
||||
ScanModel(ctx) // ScanModel automatically uses the model set with Model()
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Example 2: Scan slice of structs
|
||||
authors := []*Author{}
|
||||
err = adapter.NewSelect().
|
||||
Model(&authors).
|
||||
Table("authors").
|
||||
Where("active = ?", true).
|
||||
Limit(10).
|
||||
ScanModel(ctx)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// ExampleCompleteWorkflow demonstrates a complete workflow with preloading
|
||||
func ExampleCompleteWorkflow() error {
|
||||
dsn := "postgres://username:password@localhost:5432/dbname?sslmode=disable"
|
||||
db, err := sql.Open("pgx", dsn)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
adapter := NewPgSQLAdapter(db)
|
||||
adapter.EnableQueryDebug() // Enable query logging
|
||||
ctx := context.Background()
|
||||
|
||||
// Step 1: Create an author
|
||||
author := &Author{
|
||||
Name: "John Doe",
|
||||
Email: "john@example.com",
|
||||
}
|
||||
|
||||
result, err := adapter.NewInsert().
|
||||
Table("authors").
|
||||
Value("name", author.Name).
|
||||
Value("email", author.Email).
|
||||
Returning("id").
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_ = result
|
||||
|
||||
// Step 2: Load author with all their posts
|
||||
var loadedAuthor Author
|
||||
err = adapter.NewSelect().
|
||||
Model(&loadedAuthor).
|
||||
Table("authors").
|
||||
PreloadRelation("Posts", func(q common.SelectQuery) common.SelectQuery {
|
||||
return q.Order("created_at DESC").Limit(5)
|
||||
}).
|
||||
Where("id = ?", 1).
|
||||
ScanModel(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Step 3: Update author name
|
||||
_, err = adapter.NewUpdate().
|
||||
Table("authors").
|
||||
Set("name", "Jane Doe").
|
||||
Where("id = ?", 1).
|
||||
Exec(ctx)
|
||||
|
||||
return err
|
||||
}
|
||||
Generated
Vendored
+335
@@ -0,0 +1,335 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/bitechdev/ResolveSpec/pkg/common"
|
||||
"github.com/bitechdev/ResolveSpec/pkg/metrics"
|
||||
"github.com/bitechdev/ResolveSpec/pkg/reflection"
|
||||
)
|
||||
|
||||
const maxMetricFallbackEntityLength = 120
|
||||
|
||||
func recordQueryMetrics(enabled bool, operation, schema, entity, table string, startedAt time.Time, err error) {
|
||||
if !enabled {
|
||||
return
|
||||
}
|
||||
|
||||
metrics.GetProvider().RecordDBQuery(
|
||||
normalizeMetricOperation(operation),
|
||||
normalizeMetricSchema(schema),
|
||||
normalizeMetricEntity(entity, table),
|
||||
normalizeMetricTable(table),
|
||||
time.Since(startedAt),
|
||||
err,
|
||||
)
|
||||
}
|
||||
|
||||
func normalizeMetricOperation(operation string) string {
|
||||
operation = strings.ToUpper(strings.TrimSpace(operation))
|
||||
if operation == "" {
|
||||
return "UNKNOWN"
|
||||
}
|
||||
return operation
|
||||
}
|
||||
|
||||
func normalizeMetricSchema(schema string) string {
|
||||
schema = cleanMetricIdentifier(schema)
|
||||
if schema == "" {
|
||||
return "default"
|
||||
}
|
||||
return schema
|
||||
}
|
||||
|
||||
func normalizeMetricEntity(entity, table string) string {
|
||||
entity = cleanMetricIdentifier(entity)
|
||||
if entity != "" {
|
||||
return entity
|
||||
}
|
||||
|
||||
table = cleanMetricIdentifier(table)
|
||||
if table != "" {
|
||||
return table
|
||||
}
|
||||
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
func normalizeMetricTable(table string) string {
|
||||
table = cleanMetricIdentifier(table)
|
||||
if table == "" {
|
||||
return "unknown"
|
||||
}
|
||||
return table
|
||||
}
|
||||
|
||||
func entityNameFromModel(model interface{}, table string) string {
|
||||
if model == nil {
|
||||
return cleanMetricIdentifier(table)
|
||||
}
|
||||
|
||||
modelType := reflect.TypeOf(model)
|
||||
for modelType != nil && (modelType.Kind() == reflect.Pointer || modelType.Kind() == reflect.Slice || modelType.Kind() == reflect.Array) {
|
||||
modelType = modelType.Elem()
|
||||
}
|
||||
|
||||
if modelType == nil {
|
||||
return cleanMetricIdentifier(table)
|
||||
}
|
||||
|
||||
if modelType.Kind() == reflect.Struct && modelType.Name() != "" {
|
||||
return reflection.ToSnakeCase(modelType.Name())
|
||||
}
|
||||
|
||||
return cleanMetricIdentifier(table)
|
||||
}
|
||||
|
||||
func schemaAndTableFromModel(model interface{}, driverName string) (schema, table string) {
|
||||
provider, ok := tableNameProviderFromModel(model)
|
||||
if !ok {
|
||||
return "", ""
|
||||
}
|
||||
|
||||
return parseTableName(provider.TableName(), driverName)
|
||||
}
|
||||
|
||||
// tableNameProviderType is cached to avoid repeated reflection on every call.
|
||||
var tableNameProviderType = reflect.TypeOf((*common.TableNameProvider)(nil)).Elem()
|
||||
|
||||
func tableNameProviderFromModel(model interface{}) (common.TableNameProvider, bool) {
|
||||
if model == nil {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
if provider, ok := model.(common.TableNameProvider); ok {
|
||||
return provider, true
|
||||
}
|
||||
|
||||
modelType := reflect.TypeOf(model)
|
||||
for modelType != nil && (modelType.Kind() == reflect.Pointer || modelType.Kind() == reflect.Slice || modelType.Kind() == reflect.Array) {
|
||||
modelType = modelType.Elem()
|
||||
}
|
||||
|
||||
if modelType == nil || modelType.Kind() != reflect.Struct {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// Check whether *T implements TableNameProvider before allocating.
|
||||
ptrType := reflect.PointerTo(modelType)
|
||||
if !ptrType.Implements(tableNameProviderType) && !modelType.Implements(tableNameProviderType) {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
modelValue := reflect.New(modelType)
|
||||
if provider, ok := modelValue.Interface().(common.TableNameProvider); ok {
|
||||
return provider, true
|
||||
}
|
||||
|
||||
if provider, ok := modelValue.Elem().Interface().(common.TableNameProvider); ok {
|
||||
return provider, true
|
||||
}
|
||||
|
||||
return nil, false
|
||||
}
|
||||
|
||||
func metricTargetFromRawQuery(query, driverName string) (operation, schema, entity, table string) {
|
||||
operation = normalizeMetricOperation(firstQueryKeyword(query))
|
||||
tableRef := tableFromRawQuery(query, operation)
|
||||
if tableRef == "" {
|
||||
return operation, "", fallbackMetricEntityFromQuery(query), "unknown"
|
||||
}
|
||||
|
||||
schema, table = parseTableName(tableRef, driverName)
|
||||
entity = cleanMetricIdentifier(table)
|
||||
return operation, schema, entity, table
|
||||
}
|
||||
|
||||
func fallbackMetricEntityFromQuery(query string) string {
|
||||
query = sanitizeMetricQueryShape(query)
|
||||
if query == "" {
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
if len(query) > maxMetricFallbackEntityLength {
|
||||
return query[:maxMetricFallbackEntityLength-3] + "..."
|
||||
}
|
||||
|
||||
return query
|
||||
}
|
||||
|
||||
func sanitizeMetricQueryShape(query string) string {
|
||||
query = strings.TrimSpace(query)
|
||||
if query == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
var out strings.Builder
|
||||
for i := 0; i < len(query); {
|
||||
if query[i] == '\'' {
|
||||
out.WriteByte('?')
|
||||
i++
|
||||
for i < len(query) {
|
||||
if query[i] == '\'' {
|
||||
if i+1 < len(query) && query[i+1] == '\'' {
|
||||
i += 2
|
||||
continue
|
||||
}
|
||||
i++
|
||||
break
|
||||
}
|
||||
i++
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if query[i] == '?' {
|
||||
out.WriteByte('?')
|
||||
i++
|
||||
continue
|
||||
}
|
||||
|
||||
if query[i] == '$' && i+1 < len(query) && isASCIIDigit(query[i+1]) {
|
||||
out.WriteByte('?')
|
||||
i++
|
||||
for i < len(query) && isASCIIDigit(query[i]) {
|
||||
i++
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if query[i] == ':' && (i == 0 || query[i-1] != ':') && i+1 < len(query) && isIdentifierStart(query[i+1]) {
|
||||
out.WriteByte('?')
|
||||
i++
|
||||
for i < len(query) && isIdentifierPart(query[i]) {
|
||||
i++
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if query[i] == '@' && (i == 0 || query[i-1] != '@') && i+1 < len(query) && isIdentifierStart(query[i+1]) {
|
||||
out.WriteByte('?')
|
||||
i++
|
||||
for i < len(query) && isIdentifierPart(query[i]) {
|
||||
i++
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if startsNumericLiteral(query, i) {
|
||||
out.WriteByte('?')
|
||||
i++
|
||||
for i < len(query) && (isASCIIDigit(query[i]) || query[i] == '.') {
|
||||
i++
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
out.WriteByte(query[i])
|
||||
i++
|
||||
}
|
||||
|
||||
return strings.Join(strings.Fields(out.String()), " ")
|
||||
}
|
||||
|
||||
func startsNumericLiteral(query string, idx int) bool {
|
||||
if idx >= len(query) {
|
||||
return false
|
||||
}
|
||||
|
||||
start := idx
|
||||
if query[idx] == '-' {
|
||||
if idx+1 >= len(query) || !isASCIIDigit(query[idx+1]) {
|
||||
return false
|
||||
}
|
||||
start++
|
||||
}
|
||||
|
||||
if !isASCIIDigit(query[start]) {
|
||||
return false
|
||||
}
|
||||
|
||||
if idx > 0 && isIdentifierPart(query[idx-1]) {
|
||||
return false
|
||||
}
|
||||
|
||||
if start+1 < len(query) && query[start] == '0' && (query[start+1] == 'x' || query[start+1] == 'X') {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
func isASCIIDigit(ch byte) bool {
|
||||
return ch >= '0' && ch <= '9'
|
||||
}
|
||||
|
||||
func isIdentifierStart(ch byte) bool {
|
||||
return (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || ch == '_'
|
||||
}
|
||||
|
||||
func isIdentifierPart(ch byte) bool {
|
||||
return isIdentifierStart(ch) || isASCIIDigit(ch)
|
||||
}
|
||||
|
||||
func firstQueryKeyword(query string) string {
|
||||
query = strings.TrimSpace(query)
|
||||
if query == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
fields := strings.Fields(query)
|
||||
if len(fields) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
return fields[0]
|
||||
}
|
||||
|
||||
func tableFromRawQuery(query, operation string) string {
|
||||
tokens := tokenizeQuery(query)
|
||||
if len(tokens) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
switch operation {
|
||||
case "SELECT":
|
||||
return tokenAfter(tokens, "FROM")
|
||||
case "INSERT":
|
||||
return tokenAfter(tokens, "INTO")
|
||||
case "UPDATE":
|
||||
return tokenAfter(tokens, "UPDATE")
|
||||
case "DELETE":
|
||||
return tokenAfter(tokens, "FROM")
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func tokenAfter(tokens []string, keyword string) string {
|
||||
for idx, token := range tokens {
|
||||
if strings.EqualFold(token, keyword) && idx+1 < len(tokens) {
|
||||
return cleanMetricIdentifier(tokens[idx+1])
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func tokenizeQuery(query string) []string {
|
||||
replacer := strings.NewReplacer(
|
||||
"\n", " ",
|
||||
"\t", " ",
|
||||
"(", " ",
|
||||
")", " ",
|
||||
",", " ",
|
||||
)
|
||||
return strings.Fields(replacer.Replace(query))
|
||||
}
|
||||
|
||||
func cleanMetricIdentifier(value string) string {
|
||||
value = strings.TrimSpace(value)
|
||||
value = strings.Trim(value, "\"'`[]")
|
||||
value = strings.TrimRight(value, ";")
|
||||
return value
|
||||
}
|
||||
Generated
Vendored
+132
@@ -0,0 +1,132 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestHelper provides utilities for database testing
|
||||
type TestHelper struct {
|
||||
DB *sql.DB
|
||||
Adapter *PgSQLAdapter
|
||||
t *testing.T
|
||||
}
|
||||
|
||||
// NewTestHelper creates a new test helper
|
||||
func NewTestHelper(t *testing.T, db *sql.DB) *TestHelper {
|
||||
return &TestHelper{
|
||||
DB: db,
|
||||
Adapter: NewPgSQLAdapter(db),
|
||||
t: t,
|
||||
}
|
||||
}
|
||||
|
||||
// CleanupTables truncates all test tables
|
||||
func (h *TestHelper) CleanupTables() {
|
||||
ctx := context.Background()
|
||||
tables := []string{"comments", "posts", "users"}
|
||||
|
||||
for _, table := range tables {
|
||||
_, err := h.DB.ExecContext(ctx, "TRUNCATE TABLE "+table+" CASCADE")
|
||||
require.NoError(h.t, err)
|
||||
}
|
||||
}
|
||||
|
||||
// InsertUser inserts a test user and returns the ID
|
||||
func (h *TestHelper) InsertUser(name, email string, age int) int {
|
||||
ctx := context.Background()
|
||||
result, err := h.Adapter.NewInsert().
|
||||
Table("users").
|
||||
Value("name", name).
|
||||
Value("email", email).
|
||||
Value("age", age).
|
||||
Exec(ctx)
|
||||
|
||||
require.NoError(h.t, err)
|
||||
id, _ := result.LastInsertId()
|
||||
return int(id)
|
||||
}
|
||||
|
||||
// InsertPost inserts a test post and returns the ID
|
||||
func (h *TestHelper) InsertPost(userID int, title, content string, published bool) int {
|
||||
ctx := context.Background()
|
||||
result, err := h.Adapter.NewInsert().
|
||||
Table("posts").
|
||||
Value("user_id", userID).
|
||||
Value("title", title).
|
||||
Value("content", content).
|
||||
Value("published", published).
|
||||
Exec(ctx)
|
||||
|
||||
require.NoError(h.t, err)
|
||||
id, _ := result.LastInsertId()
|
||||
return int(id)
|
||||
}
|
||||
|
||||
// InsertComment inserts a test comment and returns the ID
|
||||
func (h *TestHelper) InsertComment(postID int, content string) int {
|
||||
ctx := context.Background()
|
||||
result, err := h.Adapter.NewInsert().
|
||||
Table("comments").
|
||||
Value("post_id", postID).
|
||||
Value("content", content).
|
||||
Exec(ctx)
|
||||
|
||||
require.NoError(h.t, err)
|
||||
id, _ := result.LastInsertId()
|
||||
return int(id)
|
||||
}
|
||||
|
||||
// AssertUserExists checks if a user exists by email
|
||||
func (h *TestHelper) AssertUserExists(email string) {
|
||||
ctx := context.Background()
|
||||
exists, err := h.Adapter.NewSelect().
|
||||
Table("users").
|
||||
Where("email = ?", email).
|
||||
Exists(ctx)
|
||||
|
||||
require.NoError(h.t, err)
|
||||
require.True(h.t, exists, "User with email %s should exist", email)
|
||||
}
|
||||
|
||||
// AssertUserCount asserts the number of users
|
||||
func (h *TestHelper) AssertUserCount(expected int) {
|
||||
ctx := context.Background()
|
||||
count, err := h.Adapter.NewSelect().
|
||||
Table("users").
|
||||
Count(ctx)
|
||||
|
||||
require.NoError(h.t, err)
|
||||
require.Equal(h.t, expected, count)
|
||||
}
|
||||
|
||||
// GetUserByEmail retrieves a user by email
|
||||
func (h *TestHelper) GetUserByEmail(email string) map[string]interface{} {
|
||||
ctx := context.Background()
|
||||
var results []map[string]interface{}
|
||||
err := h.Adapter.NewSelect().
|
||||
Table("users").
|
||||
Where("email = ?", email).
|
||||
Scan(ctx, &results)
|
||||
|
||||
require.NoError(h.t, err)
|
||||
require.Len(h.t, results, 1, "Expected exactly one user with email %s", email)
|
||||
return results[0]
|
||||
}
|
||||
|
||||
// BeginTestTransaction starts a transaction for testing
|
||||
func (h *TestHelper) BeginTestTransaction() (*PgSQLTxAdapter, func()) {
|
||||
ctx := context.Background()
|
||||
tx, err := h.DB.BeginTx(ctx, nil)
|
||||
require.NoError(h.t, err)
|
||||
|
||||
adapter := &PgSQLTxAdapter{tx: tx}
|
||||
cleanup := func() {
|
||||
tx.Rollback()
|
||||
}
|
||||
|
||||
return adapter, cleanup
|
||||
}
|
||||
+117
@@ -0,0 +1,117 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"strings"
|
||||
|
||||
"github.com/uptrace/bun/dialect/mssqldialect"
|
||||
"github.com/uptrace/bun/dialect/pgdialect"
|
||||
"github.com/uptrace/bun/dialect/sqlitedialect"
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/driver/sqlserver"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/bitechdev/ResolveSpec/pkg/logger"
|
||||
)
|
||||
|
||||
// PostgreSQL identifier length limit (63 bytes + null terminator = 64 bytes total)
|
||||
const postgresIdentifierLimit = 63
|
||||
|
||||
// checkAliasLength checks if a preload relation path will generate aliases that exceed PostgreSQL's limit
|
||||
// Returns true if the alias is likely to be truncated
|
||||
func checkAliasLength(relation string) bool {
|
||||
// Bun generates aliases like: parentalias__childalias__columnname
|
||||
// For nested preloads, it uses the pattern: relation1__relation2__relation3__columnname
|
||||
parts := strings.Split(relation, ".")
|
||||
if len(parts) <= 1 {
|
||||
return false // Single level relations are fine
|
||||
}
|
||||
|
||||
// Calculate the actual alias prefix length that Bun will generate
|
||||
// Bun uses double underscores (__) between each relation level
|
||||
// and converts the relation names to lowercase with underscores
|
||||
aliasPrefix := strings.ToLower(strings.Join(parts, "__"))
|
||||
aliasPrefixLen := len(aliasPrefix)
|
||||
|
||||
// We need to add 2 more underscores for the column name separator plus column name length
|
||||
// Column names in the error were things like "rid_mastertype_hubtype" (23 chars)
|
||||
// To be safe, assume the longest column name could be around 35 chars
|
||||
maxColumnNameLen := 35
|
||||
estimatedMaxLen := aliasPrefixLen + 2 + maxColumnNameLen
|
||||
|
||||
// Check if this would exceed PostgreSQL's identifier limit
|
||||
if estimatedMaxLen > postgresIdentifierLimit {
|
||||
logger.Warn("Preload relation '%s' will generate aliases up to %d chars (prefix: %d + column: %d), exceeding PostgreSQL's %d char limit",
|
||||
relation, estimatedMaxLen, aliasPrefixLen, maxColumnNameLen, postgresIdentifierLimit)
|
||||
return true
|
||||
}
|
||||
|
||||
// Also check if just the prefix is getting close (within 15 chars of limit)
|
||||
// This gives room for column names
|
||||
if aliasPrefixLen > (postgresIdentifierLimit - 15) {
|
||||
logger.Warn("Preload relation '%s' has alias prefix of %d chars, which may cause truncation with longer column names (limit: %d)",
|
||||
relation, aliasPrefixLen, postgresIdentifierLimit)
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// parseTableName splits a table name that may contain schema into separate schema and table
|
||||
// For example: "public.users" -> ("public", "users")
|
||||
//
|
||||
// "users" -> ("", "users")
|
||||
//
|
||||
// For SQLite, schema.table is translated to schema_table since SQLite doesn't support schemas
|
||||
// in the same way as PostgreSQL/MSSQL
|
||||
func parseTableName(fullTableName, driverName string) (schema, table string) {
|
||||
if idx := strings.LastIndex(fullTableName, "."); idx != -1 {
|
||||
schema = fullTableName[:idx]
|
||||
table = fullTableName[idx+1:]
|
||||
|
||||
// For SQLite, convert schema.table to schema_table
|
||||
if driverName == "sqlite" || driverName == "sqlite3" {
|
||||
table = schema + "_" + table
|
||||
schema = ""
|
||||
}
|
||||
return schema, table
|
||||
}
|
||||
return "", fullTableName
|
||||
}
|
||||
|
||||
// GetPostgresDialect returns a Bun PostgreSQL dialect
|
||||
func GetPostgresDialect() *pgdialect.Dialect {
|
||||
return pgdialect.New()
|
||||
}
|
||||
|
||||
// GetSQLiteDialect returns a Bun SQLite dialect
|
||||
func GetSQLiteDialect() *sqlitedialect.Dialect {
|
||||
return sqlitedialect.New()
|
||||
}
|
||||
|
||||
// GetMSSQLDialect returns a Bun MSSQL dialect
|
||||
func GetMSSQLDialect() *mssqldialect.Dialect {
|
||||
return mssqldialect.New()
|
||||
}
|
||||
|
||||
// GetPostgresDialector returns a GORM PostgreSQL dialector
|
||||
func GetPostgresDialector(db *sql.DB) gorm.Dialector {
|
||||
return postgres.New(postgres.Config{
|
||||
Conn: db,
|
||||
})
|
||||
}
|
||||
|
||||
// GetSQLiteDialector returns a GORM SQLite dialector
|
||||
func GetSQLiteDialector(db *sql.DB) gorm.Dialector {
|
||||
return sqlite.Dialector{
|
||||
Conn: db,
|
||||
}
|
||||
}
|
||||
|
||||
// GetMSSQLDialector returns a GORM MSSQL dialector
|
||||
func GetMSSQLDialector(db *sql.DB) gorm.Dialector {
|
||||
return sqlserver.New(sqlserver.Config{
|
||||
Conn: db,
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user