mirror of
https://github.com/bitechdev/ResolveSpec.git
synced 2025-12-13 17:10:36 +00:00
Better handling of preloads
This commit is contained in:
parent
9572bfc7b8
commit
db2b7e878e
218
pkg/common/adapters/database/RELATION_LOADING.md
Normal file
218
pkg/common/adapters/database/RELATION_LOADING.md
Normal file
@ -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
|
||||||
@ -140,6 +140,8 @@ type BunSelectQuery struct {
|
|||||||
tableName string // Just the table name, without schema
|
tableName string // Just the table name, without schema
|
||||||
tableAlias string
|
tableAlias string
|
||||||
deferredPreloads []deferredPreload // Preloads to execute as separate queries
|
deferredPreloads []deferredPreload // Preloads to execute as separate queries
|
||||||
|
inJoinContext bool // Track if we're in a JOIN relation context
|
||||||
|
joinTableAlias string // Alias to use for JOIN conditions
|
||||||
}
|
}
|
||||||
|
|
||||||
// deferredPreload represents a preload that will be executed as a separate query
|
// deferredPreload represents a preload that will be executed as a separate query
|
||||||
@ -189,17 +191,67 @@ func (b *BunSelectQuery) ColumnExpr(query string, args ...interface{}) common.Se
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (b *BunSelectQuery) Where(query string, args ...interface{}) common.SelectQuery {
|
func (b *BunSelectQuery) Where(query string, args ...interface{}) common.SelectQuery {
|
||||||
// If we have a table alias defined, check if the query references a different alias
|
// If we're in a JOIN context, add table prefix to unqualified columns
|
||||||
// This can happen in preloads where the user expects a certain alias but Bun generates another
|
if b.inJoinContext && b.joinTableAlias != "" {
|
||||||
if b.tableAlias != "" && b.tableName != "" {
|
query = addTablePrefix(query, b.joinTableAlias)
|
||||||
// Detect if query contains a qualified column reference (e.g., "APIL.column")
|
} else if b.tableAlias != "" && b.tableName != "" {
|
||||||
// and replace it with the unqualified version or the correct alias
|
// If we have a table alias defined, check if the query references a different alias
|
||||||
|
// This can happen in preloads where the user expects a certain alias but Bun generates another
|
||||||
query = normalizeTableAlias(query, b.tableAlias, b.tableName)
|
query = normalizeTableAlias(query, b.tableAlias, b.tableName)
|
||||||
}
|
}
|
||||||
b.query = b.query.Where(query, args...)
|
b.query = b.query.Where(query, args...)
|
||||||
return b
|
return b
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// addTablePrefix adds a table prefix to unqualified column references
|
||||||
|
// This is used in JOIN contexts where conditions must reference the joined table
|
||||||
|
func addTablePrefix(query, tableAlias string) string {
|
||||||
|
if tableAlias == "" || query == "" {
|
||||||
|
return query
|
||||||
|
}
|
||||||
|
|
||||||
|
// Split on spaces and parentheses to find column references
|
||||||
|
parts := strings.FieldsFunc(query, func(r rune) bool {
|
||||||
|
return r == ' ' || r == '(' || r == ')' || r == ','
|
||||||
|
})
|
||||||
|
|
||||||
|
modified := query
|
||||||
|
for _, part := range parts {
|
||||||
|
// Check if this looks like an unqualified column reference
|
||||||
|
// (no dot, and likely a column name before an operator)
|
||||||
|
if !strings.Contains(part, ".") {
|
||||||
|
// Extract potential column name (before = or other operators)
|
||||||
|
for _, op := range []string{"=", "!=", "<>", ">", ">=", "<", "<=", " LIKE ", " IN ", " IS "} {
|
||||||
|
if strings.Contains(part, op) {
|
||||||
|
colName := strings.Split(part, op)[0]
|
||||||
|
colName = strings.TrimSpace(colName)
|
||||||
|
if colName != "" && !isOperatorOrKeyword(colName) {
|
||||||
|
// Add table prefix
|
||||||
|
prefixed := tableAlias + "." + colName + strings.TrimPrefix(part, colName)
|
||||||
|
modified = strings.ReplaceAll(modified, part, prefixed)
|
||||||
|
logger.Debug("Adding table prefix '%s' to column '%s' in JOIN condition", tableAlias, colName)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return modified
|
||||||
|
}
|
||||||
|
|
||||||
|
// isOperatorOrKeyword checks if a string is likely an operator or SQL keyword
|
||||||
|
func isOperatorOrKeyword(s string) bool {
|
||||||
|
s = strings.ToUpper(strings.TrimSpace(s))
|
||||||
|
keywords := []string{"AND", "OR", "NOT", "IN", "IS", "NULL", "TRUE", "FALSE", "LIKE", "BETWEEN"}
|
||||||
|
for _, kw := range keywords {
|
||||||
|
if s == kw {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
// normalizeTableAlias replaces table alias prefixes in SQL conditions
|
// normalizeTableAlias replaces table alias prefixes in SQL conditions
|
||||||
// This handles cases where a user references a table alias that doesn't match
|
// This handles cases where a user references a table alias that doesn't match
|
||||||
// what Bun generates (common in preload contexts)
|
// what Bun generates (common in preload contexts)
|
||||||
@ -226,8 +278,8 @@ func normalizeTableAlias(query, expectedAlias, tableName string) string {
|
|||||||
|
|
||||||
// Check if the prefix matches our expected alias or table name (case-insensitive)
|
// Check if the prefix matches our expected alias or table name (case-insensitive)
|
||||||
if !strings.EqualFold(prefix, expectedAlias) &&
|
if !strings.EqualFold(prefix, expectedAlias) &&
|
||||||
!strings.EqualFold(prefix, tableName) &&
|
!strings.EqualFold(prefix, tableName) &&
|
||||||
!strings.EqualFold(prefix, strings.ToLower(tableName)) {
|
!strings.EqualFold(prefix, strings.ToLower(tableName)) {
|
||||||
// This is a different alias - remove the prefix
|
// This is a different alias - remove the prefix
|
||||||
logger.Debug("Stripping incorrect alias '%s' from WHERE condition, keeping just '%s'", prefix, column)
|
logger.Debug("Stripping incorrect alias '%s' from WHERE condition, keeping just '%s'", prefix, column)
|
||||||
// Replace the qualified reference with just the column name
|
// Replace the qualified reference with just the column name
|
||||||
@ -367,6 +419,27 @@ func (b *BunSelectQuery) Preload(relation string, conditions ...interface{}) com
|
|||||||
// }
|
// }
|
||||||
|
|
||||||
func (b *BunSelectQuery) PreloadRelation(relation string, apply ...func(common.SelectQuery) common.SelectQuery) common.SelectQuery {
|
func (b *BunSelectQuery) PreloadRelation(relation string, apply ...func(common.SelectQuery) common.SelectQuery) common.SelectQuery {
|
||||||
|
// Auto-detect relationship type and choose optimal loading strategy
|
||||||
|
// Get the model from the query if available
|
||||||
|
model := b.query.GetModel()
|
||||||
|
if model != nil && model.Value() != nil {
|
||||||
|
relType := reflection.GetRelationType(model.Value(), relation)
|
||||||
|
|
||||||
|
// Log the detected relationship type
|
||||||
|
logger.Debug("PreloadRelation '%s' detected as: %s", relation, relType)
|
||||||
|
|
||||||
|
// If this is a belongs-to or has-one relation, use JOIN for better performance
|
||||||
|
if relType.ShouldUseJoin() {
|
||||||
|
logger.Info("Using JOIN strategy for %s relation '%s'", relType, relation)
|
||||||
|
return b.JoinRelation(relation, apply...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// For has-many, many-to-many, or unknown: use separate query (safer default)
|
||||||
|
if relType == reflection.RelationHasMany || relType == reflection.RelationManyToMany {
|
||||||
|
logger.Debug("Using separate query for %s relation '%s'", relType, relation)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Check if this relation chain would create problematic long aliases
|
// Check if this relation chain would create problematic long aliases
|
||||||
relationParts := strings.Split(relation, ".")
|
relationParts := strings.Split(relation, ".")
|
||||||
aliasChain := strings.ToLower(strings.Join(relationParts, "__"))
|
aliasChain := strings.ToLower(strings.Join(relationParts, "__"))
|
||||||
@ -473,6 +546,36 @@ func (b *BunSelectQuery) PreloadRelation(relation string, apply ...func(common.S
|
|||||||
return b
|
return b
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (b *BunSelectQuery) JoinRelation(relation string, apply ...func(common.SelectQuery) common.SelectQuery) common.SelectQuery {
|
||||||
|
// JoinRelation uses a LEFT JOIN instead of a separate query
|
||||||
|
// This is more efficient for many-to-one or one-to-one relationships
|
||||||
|
|
||||||
|
logger.Debug("JoinRelation '%s' - Using JOIN strategy with automatic WHERE prefix addition", relation)
|
||||||
|
|
||||||
|
// Wrap the apply functions to automatically add table prefix to WHERE conditions
|
||||||
|
wrappedApply := make([]func(common.SelectQuery) common.SelectQuery, 0, len(apply))
|
||||||
|
for _, fn := range apply {
|
||||||
|
if fn != nil {
|
||||||
|
wrappedFn := func(originalFn func(common.SelectQuery) common.SelectQuery) func(common.SelectQuery) common.SelectQuery {
|
||||||
|
return func(q common.SelectQuery) common.SelectQuery {
|
||||||
|
// Create a special wrapper that adds prefixes to WHERE conditions
|
||||||
|
if bunQuery, ok := q.(*BunSelectQuery); ok {
|
||||||
|
// Mark this query as being in JOIN context
|
||||||
|
bunQuery.inJoinContext = true
|
||||||
|
bunQuery.joinTableAlias = strings.ToLower(relation)
|
||||||
|
}
|
||||||
|
return originalFn(q)
|
||||||
|
}
|
||||||
|
}(fn)
|
||||||
|
wrappedApply = append(wrappedApply, wrappedFn)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use PreloadRelation with the wrapped functions
|
||||||
|
// Bun's Relation() will use JOIN for belongs-to and has-one relations
|
||||||
|
return b.PreloadRelation(relation, wrappedApply...)
|
||||||
|
}
|
||||||
|
|
||||||
func (b *BunSelectQuery) Order(order string) common.SelectQuery {
|
func (b *BunSelectQuery) Order(order string) common.SelectQuery {
|
||||||
b.query = b.query.Order(order)
|
b.query = b.query.Order(order)
|
||||||
return b
|
return b
|
||||||
|
|||||||
@ -104,10 +104,12 @@ func (g *GormAdapter) RunInTransaction(ctx context.Context, fn func(common.Datab
|
|||||||
|
|
||||||
// GormSelectQuery implements SelectQuery for GORM
|
// GormSelectQuery implements SelectQuery for GORM
|
||||||
type GormSelectQuery struct {
|
type GormSelectQuery struct {
|
||||||
db *gorm.DB
|
db *gorm.DB
|
||||||
schema string // Separated schema name
|
schema string // Separated schema name
|
||||||
tableName string // Just the table name, without schema
|
tableName string // Just the table name, without schema
|
||||||
tableAlias string
|
tableAlias string
|
||||||
|
inJoinContext bool // Track if we're in a JOIN relation context
|
||||||
|
joinTableAlias string // Alias to use for JOIN conditions
|
||||||
}
|
}
|
||||||
|
|
||||||
func (g *GormSelectQuery) Model(model interface{}) common.SelectQuery {
|
func (g *GormSelectQuery) Model(model interface{}) common.SelectQuery {
|
||||||
@ -151,10 +153,61 @@ func (g *GormSelectQuery) ColumnExpr(query string, args ...interface{}) common.S
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (g *GormSelectQuery) Where(query string, args ...interface{}) common.SelectQuery {
|
func (g *GormSelectQuery) Where(query string, args ...interface{}) common.SelectQuery {
|
||||||
|
// If we're in a JOIN context, add table prefix to unqualified columns
|
||||||
|
if g.inJoinContext && g.joinTableAlias != "" {
|
||||||
|
query = addTablePrefixGorm(query, g.joinTableAlias)
|
||||||
|
}
|
||||||
g.db = g.db.Where(query, args...)
|
g.db = g.db.Where(query, args...)
|
||||||
return g
|
return g
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// addTablePrefixGorm adds a table prefix to unqualified column references (GORM version)
|
||||||
|
func addTablePrefixGorm(query, tableAlias string) string {
|
||||||
|
if tableAlias == "" || query == "" {
|
||||||
|
return query
|
||||||
|
}
|
||||||
|
|
||||||
|
// Split on spaces and parentheses to find column references
|
||||||
|
parts := strings.FieldsFunc(query, func(r rune) bool {
|
||||||
|
return r == ' ' || r == '(' || r == ')' || r == ','
|
||||||
|
})
|
||||||
|
|
||||||
|
modified := query
|
||||||
|
for _, part := range parts {
|
||||||
|
// Check if this looks like an unqualified column reference
|
||||||
|
if !strings.Contains(part, ".") {
|
||||||
|
// Extract potential column name (before = or other operators)
|
||||||
|
for _, op := range []string{"=", "!=", "<>", ">", ">=", "<", "<=", " LIKE ", " IN ", " IS "} {
|
||||||
|
if strings.Contains(part, op) {
|
||||||
|
colName := strings.Split(part, op)[0]
|
||||||
|
colName = strings.TrimSpace(colName)
|
||||||
|
if colName != "" && !isOperatorOrKeywordGorm(colName) {
|
||||||
|
// Add table prefix
|
||||||
|
prefixed := tableAlias + "." + colName + strings.TrimPrefix(part, colName)
|
||||||
|
modified = strings.ReplaceAll(modified, part, prefixed)
|
||||||
|
logger.Debug("Adding table prefix '%s' to column '%s' in JOIN condition", tableAlias, colName)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return modified
|
||||||
|
}
|
||||||
|
|
||||||
|
// isOperatorOrKeywordGorm checks if a string is likely an operator or SQL keyword (GORM version)
|
||||||
|
func isOperatorOrKeywordGorm(s string) bool {
|
||||||
|
s = strings.ToUpper(strings.TrimSpace(s))
|
||||||
|
keywords := []string{"AND", "OR", "NOT", "IN", "IS", "NULL", "TRUE", "FALSE", "LIKE", "BETWEEN"}
|
||||||
|
for _, kw := range keywords {
|
||||||
|
if s == kw {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
func (g *GormSelectQuery) WhereOr(query string, args ...interface{}) common.SelectQuery {
|
func (g *GormSelectQuery) WhereOr(query string, args ...interface{}) common.SelectQuery {
|
||||||
g.db = g.db.Or(query, args...)
|
g.db = g.db.Or(query, args...)
|
||||||
return g
|
return g
|
||||||
@ -238,6 +291,27 @@ func (g *GormSelectQuery) Preload(relation string, conditions ...interface{}) co
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (g *GormSelectQuery) PreloadRelation(relation string, apply ...func(common.SelectQuery) common.SelectQuery) common.SelectQuery {
|
func (g *GormSelectQuery) PreloadRelation(relation string, apply ...func(common.SelectQuery) common.SelectQuery) common.SelectQuery {
|
||||||
|
// Auto-detect relationship type and choose optimal loading strategy
|
||||||
|
// Get the model from GORM's statement if available
|
||||||
|
if g.db.Statement != nil && g.db.Statement.Model != nil {
|
||||||
|
relType := reflection.GetRelationType(g.db.Statement.Model, relation)
|
||||||
|
|
||||||
|
// Log the detected relationship type
|
||||||
|
logger.Debug("PreloadRelation '%s' detected as: %s", relation, relType)
|
||||||
|
|
||||||
|
// If this is a belongs-to or has-one relation, use JOIN for better performance
|
||||||
|
if relType.ShouldUseJoin() {
|
||||||
|
logger.Info("Using JOIN strategy for %s relation '%s'", relType, relation)
|
||||||
|
return g.JoinRelation(relation, apply...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// For has-many, many-to-many, or unknown: use separate query (safer default)
|
||||||
|
if relType == reflection.RelationHasMany || relType == reflection.RelationManyToMany {
|
||||||
|
logger.Debug("Using separate query for %s relation '%s'", relType, relation)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use GORM's Preload (separate query strategy)
|
||||||
g.db = g.db.Preload(relation, func(db *gorm.DB) *gorm.DB {
|
g.db = g.db.Preload(relation, func(db *gorm.DB) *gorm.DB {
|
||||||
if len(apply) == 0 {
|
if len(apply) == 0 {
|
||||||
return db
|
return db
|
||||||
@ -267,6 +341,42 @@ func (g *GormSelectQuery) PreloadRelation(relation string, apply ...func(common.
|
|||||||
return g
|
return g
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (g *GormSelectQuery) JoinRelation(relation string, apply ...func(common.SelectQuery) common.SelectQuery) common.SelectQuery {
|
||||||
|
// JoinRelation uses a JOIN instead of a separate preload query
|
||||||
|
// This is more efficient for many-to-one or one-to-one relationships
|
||||||
|
// as it avoids additional round trips to the database
|
||||||
|
|
||||||
|
// GORM's Joins() method forces a JOIN for the preload
|
||||||
|
logger.Debug("JoinRelation '%s' - Using GORM Joins() with automatic WHERE prefix addition", relation)
|
||||||
|
|
||||||
|
g.db = g.db.Joins(relation, func(db *gorm.DB) *gorm.DB {
|
||||||
|
if len(apply) == 0 {
|
||||||
|
return db
|
||||||
|
}
|
||||||
|
|
||||||
|
wrapper := &GormSelectQuery{
|
||||||
|
db: db,
|
||||||
|
inJoinContext: true, // Mark as JOIN context
|
||||||
|
joinTableAlias: strings.ToLower(relation), // Use relation name as alias
|
||||||
|
}
|
||||||
|
current := common.SelectQuery(wrapper)
|
||||||
|
|
||||||
|
for _, fn := range apply {
|
||||||
|
if fn != nil {
|
||||||
|
current = fn(current)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if finalGorm, ok := current.(*GormSelectQuery); ok {
|
||||||
|
return finalGorm.db
|
||||||
|
}
|
||||||
|
|
||||||
|
return db
|
||||||
|
})
|
||||||
|
|
||||||
|
return g
|
||||||
|
}
|
||||||
|
|
||||||
func (g *GormSelectQuery) Order(order string) common.SelectQuery {
|
func (g *GormSelectQuery) Order(order string) common.SelectQuery {
|
||||||
g.db = g.db.Order(order)
|
g.db = g.db.Order(order)
|
||||||
return g
|
return g
|
||||||
|
|||||||
@ -38,6 +38,7 @@ type SelectQuery interface {
|
|||||||
LeftJoin(query string, args ...interface{}) SelectQuery
|
LeftJoin(query string, args ...interface{}) SelectQuery
|
||||||
Preload(relation string, conditions ...interface{}) SelectQuery
|
Preload(relation string, conditions ...interface{}) SelectQuery
|
||||||
PreloadRelation(relation string, apply ...func(SelectQuery) SelectQuery) SelectQuery
|
PreloadRelation(relation string, apply ...func(SelectQuery) SelectQuery) SelectQuery
|
||||||
|
JoinRelation(relation string, apply ...func(SelectQuery) SelectQuery) SelectQuery
|
||||||
Order(order string) SelectQuery
|
Order(order string) SelectQuery
|
||||||
Limit(n int) SelectQuery
|
Limit(n int) SelectQuery
|
||||||
Offset(n int) SelectQuery
|
Offset(n int) SelectQuery
|
||||||
|
|||||||
@ -1,7 +1,6 @@
|
|||||||
package common
|
package common
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/bitechdev/ResolveSpec/pkg/logger"
|
"github.com/bitechdev/ResolveSpec/pkg/logger"
|
||||||
@ -9,81 +8,40 @@ import (
|
|||||||
"github.com/bitechdev/ResolveSpec/pkg/reflection"
|
"github.com/bitechdev/ResolveSpec/pkg/reflection"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ValidateAndFixPreloadWhere validates that the WHERE clause for a preload contains
|
// ValidateAndFixPreloadWhere validates and normalizes WHERE clauses for preloads
|
||||||
// the relation prefix (alias). If not present, it attempts to add it to column references.
|
//
|
||||||
// Returns the fixed WHERE clause and an error if it cannot be safely fixed.
|
// NOTE: For preload queries, table aliases from the parent query are not valid since
|
||||||
|
// the preload executes as a separate query with its own table alias. This function
|
||||||
|
// now simply validates basic syntax without requiring or adding prefixes.
|
||||||
|
// The actual alias normalization happens in the database adapter layer.
|
||||||
|
//
|
||||||
|
// Returns the WHERE clause and an error if it contains obviously invalid syntax.
|
||||||
func ValidateAndFixPreloadWhere(where string, relationName string) (string, error) {
|
func ValidateAndFixPreloadWhere(where string, relationName string) (string, error) {
|
||||||
if where == "" {
|
if where == "" {
|
||||||
return where, nil
|
return where, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if the relation name is already present in the WHERE clause
|
where = strings.TrimSpace(where)
|
||||||
lowerWhere := strings.ToLower(where)
|
|
||||||
lowerRelation := strings.ToLower(relationName)
|
|
||||||
|
|
||||||
// Check for patterns like "relation.", "relation ", or just "relation" followed by a dot
|
// Just do basic validation - don't require or add prefixes
|
||||||
if strings.Contains(lowerWhere, lowerRelation+".") ||
|
// The database adapter will handle alias normalization
|
||||||
strings.Contains(lowerWhere, "`"+lowerRelation+"`.") ||
|
|
||||||
strings.Contains(lowerWhere, "\""+lowerRelation+"\".") {
|
// Check if the WHERE clause contains any qualified column references
|
||||||
// Relation prefix is already present
|
// If it does, log a debug message but don't fail - let the adapter handle it
|
||||||
|
if strings.Contains(where, ".") {
|
||||||
|
logger.Debug("Preload WHERE clause for '%s' contains qualified column references: '%s'. "+
|
||||||
|
"Note: In preload context, table aliases from parent query are not available. "+
|
||||||
|
"The database adapter will normalize aliases automatically.", relationName, where)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate that it's not empty or just whitespace
|
||||||
|
if where == "" {
|
||||||
return where, nil
|
return where, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// If the WHERE clause is complex (contains OR, parentheses, subqueries, etc.),
|
// Return the WHERE clause as-is
|
||||||
// we can't safely auto-fix it - require explicit prefix
|
// The BunSelectQuery.Where() method will handle alias normalization via normalizeTableAlias()
|
||||||
if strings.Contains(lowerWhere, " or ") ||
|
return where, nil
|
||||||
strings.Contains(where, "(") ||
|
|
||||||
strings.Contains(where, ")") {
|
|
||||||
return "", fmt.Errorf("preload WHERE condition must reference the relation '%s' (e.g., '%s.column_name'). Complex WHERE clauses with OR/parentheses must explicitly use the relation prefix", relationName, relationName)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try to add the relation prefix to simple column references
|
|
||||||
// This handles basic cases like "column = value" or "column = value AND other_column = value"
|
|
||||||
// Split by AND to handle multiple conditions (case-insensitive)
|
|
||||||
originalConditions := strings.Split(where, " AND ")
|
|
||||||
|
|
||||||
// If uppercase split didn't work, try lowercase
|
|
||||||
if len(originalConditions) == 1 {
|
|
||||||
originalConditions = strings.Split(where, " and ")
|
|
||||||
}
|
|
||||||
|
|
||||||
fixedConditions := make([]string, 0, len(originalConditions))
|
|
||||||
|
|
||||||
for _, cond := range originalConditions {
|
|
||||||
cond = strings.TrimSpace(cond)
|
|
||||||
if cond == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if this condition already has a table prefix (contains a dot)
|
|
||||||
if strings.Contains(cond, ".") {
|
|
||||||
fixedConditions = append(fixedConditions, cond)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if this is a SQL expression/literal that shouldn't be prefixed
|
|
||||||
lowerCond := strings.ToLower(strings.TrimSpace(cond))
|
|
||||||
if IsSQLExpression(lowerCond) {
|
|
||||||
// Don't prefix SQL expressions like "true", "false", "1=1", etc.
|
|
||||||
fixedConditions = append(fixedConditions, cond)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract the column name (first identifier before operator)
|
|
||||||
columnName := ExtractColumnName(cond)
|
|
||||||
if columnName == "" {
|
|
||||||
// Can't identify column name, require explicit prefix
|
|
||||||
return "", fmt.Errorf("preload WHERE condition must reference the relation '%s' (e.g., '%s.column_name'). Cannot auto-fix condition: %s", relationName, relationName, cond)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add relation prefix to the column name only
|
|
||||||
fixedCond := strings.Replace(cond, columnName, relationName+"."+columnName, 1)
|
|
||||||
fixedConditions = append(fixedConditions, fixedCond)
|
|
||||||
}
|
|
||||||
|
|
||||||
fixedWhere := strings.Join(fixedConditions, " AND ")
|
|
||||||
logger.Debug("Auto-fixed preload WHERE clause: '%s' -> '%s'", where, fixedWhere)
|
|
||||||
return fixedWhere, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsSQLExpression checks if a condition is a SQL expression that shouldn't be prefixed
|
// IsSQLExpression checks if a condition is a SQL expression that shouldn't be prefixed
|
||||||
|
|||||||
@ -750,6 +750,118 @@ func ConvertToNumericType(value string, kind reflect.Kind) (interface{}, error)
|
|||||||
return nil, fmt.Errorf("unsupported numeric type: %v", kind)
|
return nil, fmt.Errorf("unsupported numeric type: %v", kind)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RelationType represents the type of database relationship
|
||||||
|
type RelationType string
|
||||||
|
|
||||||
|
const (
|
||||||
|
RelationHasMany RelationType = "has-many" // 1:N - use separate query
|
||||||
|
RelationBelongsTo RelationType = "belongs-to" // N:1 - use JOIN
|
||||||
|
RelationHasOne RelationType = "has-one" // 1:1 - use JOIN
|
||||||
|
RelationManyToMany RelationType = "many-to-many" // M:N - use separate query
|
||||||
|
RelationUnknown RelationType = "unknown"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ShouldUseJoin returns true if the relation type should use a JOIN instead of separate query
|
||||||
|
func (rt RelationType) ShouldUseJoin() bool {
|
||||||
|
return rt == RelationBelongsTo || rt == RelationHasOne
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRelationType inspects the model's struct tags to determine the relationship type
|
||||||
|
// It checks both Bun and GORM tags to identify the relationship cardinality
|
||||||
|
func GetRelationType(model interface{}, fieldName string) RelationType {
|
||||||
|
if model == nil || fieldName == "" {
|
||||||
|
return RelationUnknown
|
||||||
|
}
|
||||||
|
|
||||||
|
modelType := reflect.TypeOf(model)
|
||||||
|
if modelType == nil {
|
||||||
|
return RelationUnknown
|
||||||
|
}
|
||||||
|
|
||||||
|
if modelType.Kind() == reflect.Ptr {
|
||||||
|
modelType = modelType.Elem()
|
||||||
|
}
|
||||||
|
|
||||||
|
if modelType == nil || modelType.Kind() != reflect.Struct {
|
||||||
|
return RelationUnknown
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the field
|
||||||
|
for i := 0; i < modelType.NumField(); i++ {
|
||||||
|
field := modelType.Field(i)
|
||||||
|
|
||||||
|
// Check if field name matches (case-insensitive)
|
||||||
|
if !strings.EqualFold(field.Name, fieldName) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check Bun tags first
|
||||||
|
bunTag := field.Tag.Get("bun")
|
||||||
|
if bunTag != "" && strings.Contains(bunTag, "rel:") {
|
||||||
|
// Parse bun relation tag: rel:has-many, rel:belongs-to, rel:has-one, rel:many-to-many
|
||||||
|
parts := strings.Split(bunTag, ",")
|
||||||
|
for _, part := range parts {
|
||||||
|
part = strings.TrimSpace(part)
|
||||||
|
if strings.HasPrefix(part, "rel:") {
|
||||||
|
relType := strings.TrimPrefix(part, "rel:")
|
||||||
|
switch relType {
|
||||||
|
case "has-many":
|
||||||
|
return RelationHasMany
|
||||||
|
case "belongs-to":
|
||||||
|
return RelationBelongsTo
|
||||||
|
case "has-one":
|
||||||
|
return RelationHasOne
|
||||||
|
case "many-to-many", "m2m":
|
||||||
|
return RelationManyToMany
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check GORM tags
|
||||||
|
gormTag := field.Tag.Get("gorm")
|
||||||
|
if gormTag != "" {
|
||||||
|
// GORM uses different patterns:
|
||||||
|
// - foreignKey: usually indicates belongs-to or has-one
|
||||||
|
// - many2many: indicates many-to-many
|
||||||
|
// - Field type (slice vs pointer) helps determine cardinality
|
||||||
|
|
||||||
|
if strings.Contains(gormTag, "many2many:") {
|
||||||
|
return RelationManyToMany
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check field type for cardinality hints
|
||||||
|
fieldType := field.Type
|
||||||
|
if fieldType.Kind() == reflect.Slice {
|
||||||
|
// Slice indicates has-many or many-to-many
|
||||||
|
return RelationHasMany
|
||||||
|
}
|
||||||
|
if fieldType.Kind() == reflect.Ptr {
|
||||||
|
// Pointer to single struct usually indicates belongs-to or has-one
|
||||||
|
// Check if it has foreignKey (belongs-to) or references (has-one)
|
||||||
|
if strings.Contains(gormTag, "foreignKey:") {
|
||||||
|
return RelationBelongsTo
|
||||||
|
}
|
||||||
|
return RelationHasOne
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to field type inference
|
||||||
|
fieldType := field.Type
|
||||||
|
if fieldType.Kind() == reflect.Slice {
|
||||||
|
// Slice of structs → has-many
|
||||||
|
return RelationHasMany
|
||||||
|
}
|
||||||
|
if fieldType.Kind() == reflect.Ptr || fieldType.Kind() == reflect.Struct {
|
||||||
|
// Single struct → belongs-to (default assumption for safety)
|
||||||
|
// Using belongs-to as default ensures we use JOIN, which is safer
|
||||||
|
return RelationBelongsTo
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return RelationUnknown
|
||||||
|
}
|
||||||
|
|
||||||
// GetRelationModel gets the model type for a relation field
|
// GetRelationModel gets the model type for a relation field
|
||||||
// It searches for the field by name in the following order (case-insensitive):
|
// It searches for the field by name in the following order (case-insensitive):
|
||||||
// 1. Actual field name
|
// 1. Actual field name
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user