From db2b7e878e2a127b489eef8fe8b0f61e326da464 Mon Sep 17 00:00:00 2001 From: Hein Date: Tue, 9 Dec 2025 15:12:17 +0200 Subject: [PATCH] Better handling of preloads --- .../adapters/database/RELATION_LOADING.md | 218 ++++++++++++++++++ pkg/common/adapters/database/bun.go | 117 +++++++++- pkg/common/adapters/database/gorm.go | 118 +++++++++- pkg/common/interfaces.go | 1 + pkg/common/sql_helpers.go | 92 ++------ pkg/reflection/model_utils.go | 112 +++++++++ 6 files changed, 580 insertions(+), 78 deletions(-) create mode 100644 pkg/common/adapters/database/RELATION_LOADING.md diff --git a/pkg/common/adapters/database/RELATION_LOADING.md b/pkg/common/adapters/database/RELATION_LOADING.md new file mode 100644 index 0000000..e10d2c9 --- /dev/null +++ b/pkg/common/adapters/database/RELATION_LOADING.md @@ -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 diff --git a/pkg/common/adapters/database/bun.go b/pkg/common/adapters/database/bun.go index 91c650f..c82b368 100644 --- a/pkg/common/adapters/database/bun.go +++ b/pkg/common/adapters/database/bun.go @@ -140,6 +140,8 @@ type BunSelectQuery struct { tableName string // Just the table name, without schema tableAlias string 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 @@ -189,17 +191,67 @@ func (b *BunSelectQuery) ColumnExpr(query string, args ...interface{}) common.Se } 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 - // This can happen in preloads where the user expects a certain alias but Bun generates another - if b.tableAlias != "" && b.tableName != "" { - // Detect if query contains a qualified column reference (e.g., "APIL.column") - // and replace it with the unqualified version or the correct alias + // If we're in a JOIN context, add table prefix to unqualified columns + if b.inJoinContext && b.joinTableAlias != "" { + query = addTablePrefix(query, b.joinTableAlias) + } else if b.tableAlias != "" && b.tableName != "" { + // 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) } b.query = b.query.Where(query, args...) 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 // This handles cases where a user references a table alias that doesn't match // 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) if !strings.EqualFold(prefix, expectedAlias) && - !strings.EqualFold(prefix, tableName) && - !strings.EqualFold(prefix, strings.ToLower(tableName)) { + !strings.EqualFold(prefix, tableName) && + !strings.EqualFold(prefix, strings.ToLower(tableName)) { // This is a different alias - remove the prefix logger.Debug("Stripping incorrect alias '%s' from WHERE condition, keeping just '%s'", prefix, column) // 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 { + // 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 relationParts := strings.Split(relation, ".") aliasChain := strings.ToLower(strings.Join(relationParts, "__")) @@ -473,6 +546,36 @@ func (b *BunSelectQuery) PreloadRelation(relation string, apply ...func(common.S 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 { b.query = b.query.Order(order) return b diff --git a/pkg/common/adapters/database/gorm.go b/pkg/common/adapters/database/gorm.go index 60817e1..e201814 100644 --- a/pkg/common/adapters/database/gorm.go +++ b/pkg/common/adapters/database/gorm.go @@ -104,10 +104,12 @@ func (g *GormAdapter) RunInTransaction(ctx context.Context, fn func(common.Datab // GormSelectQuery implements SelectQuery for GORM type GormSelectQuery struct { - db *gorm.DB - schema string // Separated schema name - tableName string // Just the table name, without schema - tableAlias string + db *gorm.DB + schema string // Separated schema name + tableName string // Just the table name, without schema + 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 { @@ -151,10 +153,61 @@ func (g *GormSelectQuery) ColumnExpr(query string, args ...interface{}) common.S } 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...) 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 { g.db = g.db.Or(query, args...) 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 { + // 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 { if len(apply) == 0 { return db @@ -267,6 +341,42 @@ func (g *GormSelectQuery) PreloadRelation(relation string, apply ...func(common. 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 { g.db = g.db.Order(order) return g diff --git a/pkg/common/interfaces.go b/pkg/common/interfaces.go index 519664b..9d53c07 100644 --- a/pkg/common/interfaces.go +++ b/pkg/common/interfaces.go @@ -38,6 +38,7 @@ type SelectQuery interface { LeftJoin(query string, args ...interface{}) SelectQuery Preload(relation string, conditions ...interface{}) SelectQuery PreloadRelation(relation string, apply ...func(SelectQuery) SelectQuery) SelectQuery + JoinRelation(relation string, apply ...func(SelectQuery) SelectQuery) SelectQuery Order(order string) SelectQuery Limit(n int) SelectQuery Offset(n int) SelectQuery diff --git a/pkg/common/sql_helpers.go b/pkg/common/sql_helpers.go index 01d7228..31e8638 100644 --- a/pkg/common/sql_helpers.go +++ b/pkg/common/sql_helpers.go @@ -1,7 +1,6 @@ package common import ( - "fmt" "strings" "github.com/bitechdev/ResolveSpec/pkg/logger" @@ -9,81 +8,40 @@ import ( "github.com/bitechdev/ResolveSpec/pkg/reflection" ) -// ValidateAndFixPreloadWhere validates that the WHERE clause for a preload contains -// 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. +// ValidateAndFixPreloadWhere validates and normalizes WHERE clauses for preloads +// +// 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) { if where == "" { return where, nil } - // Check if the relation name is already present in the WHERE clause - lowerWhere := strings.ToLower(where) - lowerRelation := strings.ToLower(relationName) + where = strings.TrimSpace(where) - // Check for patterns like "relation.", "relation ", or just "relation" followed by a dot - if strings.Contains(lowerWhere, lowerRelation+".") || - strings.Contains(lowerWhere, "`"+lowerRelation+"`.") || - strings.Contains(lowerWhere, "\""+lowerRelation+"\".") { - // Relation prefix is already present + // Just do basic validation - don't require or add prefixes + // The database adapter will handle alias normalization + + // Check if the WHERE clause contains any qualified column references + // 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 } - // If the WHERE clause is complex (contains OR, parentheses, subqueries, etc.), - // we can't safely auto-fix it - require explicit prefix - if strings.Contains(lowerWhere, " or ") || - 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 + // Return the WHERE clause as-is + // The BunSelectQuery.Where() method will handle alias normalization via normalizeTableAlias() + return where, nil } // IsSQLExpression checks if a condition is a SQL expression that shouldn't be prefixed diff --git a/pkg/reflection/model_utils.go b/pkg/reflection/model_utils.go index ad8d812..2a25085 100644 --- a/pkg/reflection/model_utils.go +++ b/pkg/reflection/model_utils.go @@ -750,6 +750,118 @@ func ConvertToNumericType(value string, kind reflect.Kind) (interface{}, error) 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 // It searches for the field by name in the following order (case-insensitive): // 1. Actual field name