mirror of
https://github.com/bitechdev/ResolveSpec.git
synced 2026-10-06 05:16:27 +00:00
Tests / Integration Tests (push) Skipped
Tests / Unit Tests (push) Successful in 1m37s
Tests / Race Detector (push) Successful in 3m52s
Build , Vet Test, and Lint / Build (push) Successful in 1m33s
Build , Vet Test, and Lint / Run Vet Tests (1.24.x) (push) Successful in 2m16s
Build , Vet Test, and Lint / Run Vet Tests (1.23.x) (push) Successful in 2m19s
Build , Vet Test, and Lint / Lint Code (push) Successful in 2m19s
Bun's ExcludeColumn errors with "can't find column" for scanonly fields because they are not in the table's writable fields. Filter the exclude list to writable bun fields so models with scanonly buffers can insert and update again. Add tests for the adapter and reflection.
1828 lines
58 KiB
Go
1828 lines
58 KiB
Go
package database
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"fmt"
|
|
"reflect"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/uptrace/bun"
|
|
"github.com/uptrace/bun/schema"
|
|
|
|
"github.com/bitechdev/ResolveSpec/pkg/common"
|
|
"github.com/bitechdev/ResolveSpec/pkg/dbtrace"
|
|
"github.com/bitechdev/ResolveSpec/pkg/logger"
|
|
"github.com/bitechdev/ResolveSpec/pkg/modelregistry"
|
|
"github.com/bitechdev/ResolveSpec/pkg/reflection"
|
|
)
|
|
|
|
// QueryDebugHook is a Bun query hook that logs all SQL queries including preloads
|
|
type QueryDebugHook struct{}
|
|
|
|
func (h *QueryDebugHook) BeforeQuery(ctx context.Context, event *bun.QueryEvent) context.Context {
|
|
return ctx
|
|
}
|
|
|
|
func (h *QueryDebugHook) AfterQuery(ctx context.Context, event *bun.QueryEvent) {
|
|
query := event.Query
|
|
duration := time.Since(event.StartTime)
|
|
|
|
if event.Err != nil {
|
|
logger.Error("SQL Query Failed [%s]: %s. Error: %v", duration, query, event.Err)
|
|
} else {
|
|
logger.Debug("SQL Query Success [%s]: %s", duration, query)
|
|
}
|
|
}
|
|
|
|
// debugScanIntoStruct attempts to scan rows into a struct with detailed field-level logging
|
|
// This helps identify which specific field is causing scanning issues
|
|
func debugScanIntoStruct(rows interface{}, dest interface{}) error {
|
|
v := reflect.ValueOf(dest)
|
|
if v.Kind() != reflect.Pointer {
|
|
return fmt.Errorf("dest must be a pointer")
|
|
}
|
|
|
|
v = v.Elem()
|
|
if v.Kind() != reflect.Struct && v.Kind() != reflect.Slice {
|
|
return fmt.Errorf("dest must be pointer to struct or slice")
|
|
}
|
|
|
|
// Log the type being scanned into
|
|
typeName := v.Type().String()
|
|
logger.Debug("Debug scan into type: %s (kind: %s)", typeName, v.Kind())
|
|
|
|
// Handle slice types - inspect the element type
|
|
var structType reflect.Type
|
|
if v.Kind() == reflect.Slice {
|
|
elemType := v.Type().Elem()
|
|
logger.Debug(" Slice element type: %s", elemType)
|
|
|
|
// If slice of pointers, get the underlying type
|
|
if elemType.Kind() == reflect.Pointer {
|
|
structType = elemType.Elem()
|
|
} else {
|
|
structType = elemType
|
|
}
|
|
} else if v.Kind() == reflect.Struct {
|
|
structType = v.Type()
|
|
}
|
|
|
|
// If we have a struct type, log all its fields
|
|
if structType != nil && structType.Kind() == reflect.Struct {
|
|
logger.Debug(" Struct %s has %d fields:", structType.Name(), structType.NumField())
|
|
for i := 0; i < structType.NumField(); i++ {
|
|
field := structType.Field(i)
|
|
|
|
// Log embedded fields specially
|
|
if field.Anonymous {
|
|
logger.Debug(" [%d] EMBEDDED: %s (type: %s, kind: %s, bun:%q)",
|
|
i, field.Name, field.Type, field.Type.Kind(), field.Tag.Get("bun"))
|
|
} else {
|
|
bunTag := field.Tag.Get("bun")
|
|
if bunTag == "" {
|
|
bunTag = "(no tag)"
|
|
}
|
|
logger.Debug(" [%d] %s (type: %s, kind: %s, bun:%q)",
|
|
i, field.Name, field.Type, field.Type.Kind(), bunTag)
|
|
}
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// BunAdapter adapts Bun to work with our Database interface
|
|
// This demonstrates how the abstraction works with different ORMs
|
|
type BunAdapter struct {
|
|
db *bun.DB
|
|
dbMu sync.RWMutex
|
|
dbFactory func() (*bun.DB, error)
|
|
driverName string
|
|
metricsEnabled bool
|
|
}
|
|
|
|
// NewBunAdapter creates a new Bun adapter
|
|
func NewBunAdapter(db *bun.DB) *BunAdapter {
|
|
adapter := &BunAdapter{db: db, metricsEnabled: true}
|
|
// Initialize driver name
|
|
adapter.driverName = adapter.DriverName()
|
|
return adapter
|
|
}
|
|
|
|
// WithDBFactory configures a factory used to reopen the database connection if it is closed.
|
|
func (b *BunAdapter) WithDBFactory(factory func() (*bun.DB, error)) *BunAdapter {
|
|
b.dbFactory = factory
|
|
return b
|
|
}
|
|
|
|
// SetMetricsEnabled enables or disables query metrics for this adapter.
|
|
func (b *BunAdapter) SetMetricsEnabled(enabled bool) *BunAdapter {
|
|
b.metricsEnabled = enabled
|
|
return b
|
|
}
|
|
|
|
func (b *BunAdapter) getDB() *bun.DB {
|
|
b.dbMu.RLock()
|
|
defer b.dbMu.RUnlock()
|
|
return b.db
|
|
}
|
|
|
|
func (b *BunAdapter) reconnectDB() error {
|
|
if b.dbFactory == nil {
|
|
return fmt.Errorf("no db factory configured for reconnect")
|
|
}
|
|
newDB, err := b.dbFactory()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
b.dbMu.Lock()
|
|
b.db = newDB
|
|
b.dbMu.Unlock()
|
|
return nil
|
|
}
|
|
|
|
// EnableQueryDebug enables query debugging which logs all SQL queries including preloads
|
|
// This is useful for debugging preload queries that may be failing
|
|
func (b *BunAdapter) EnableQueryDebug() {
|
|
b.getDB().AddQueryHook(&QueryDebugHook{})
|
|
logger.Info("Bun query debug mode enabled - all SQL queries will be logged")
|
|
}
|
|
|
|
// EnableDetailedScanDebug enables verbose logging of scan operations
|
|
// WARNING: This generates a LOT of log output. Use only for debugging specific issues.
|
|
func (b *BunAdapter) EnableDetailedScanDebug() {
|
|
logger.Info("Detailed scan debugging enabled - will log all field scanning operations")
|
|
// This is a flag that can be checked in scan operations
|
|
// Implementation would require modifying the scan logic
|
|
}
|
|
|
|
// DisableQueryDebug removes all query hooks
|
|
func (b *BunAdapter) DisableQueryDebug() {
|
|
// Create a new DB without hooks
|
|
// Note: Bun doesn't have a RemoveQueryHook, so we'd need to track hooks manually
|
|
logger.Info("To disable query debug, recreate the BunAdapter without adding the hook")
|
|
}
|
|
|
|
func (b *BunAdapter) NewSelect() common.SelectQuery {
|
|
return &BunSelectQuery{
|
|
query: b.getDB().NewSelect(),
|
|
db: b.db,
|
|
driverName: b.driverName,
|
|
metricsEnabled: b.metricsEnabled,
|
|
}
|
|
}
|
|
|
|
func (b *BunAdapter) NewInsert() common.InsertQuery {
|
|
return &BunInsertQuery{query: b.getDB().NewInsert(), driverName: b.driverName, metricsEnabled: b.metricsEnabled}
|
|
}
|
|
|
|
func (b *BunAdapter) NewUpdate() common.UpdateQuery {
|
|
return &BunUpdateQuery{query: b.getDB().NewUpdate(), driverName: b.driverName, metricsEnabled: b.metricsEnabled}
|
|
}
|
|
|
|
func (b *BunAdapter) NewDelete() common.DeleteQuery {
|
|
return &BunDeleteQuery{query: b.getDB().NewDelete(), driverName: b.driverName, metricsEnabled: b.metricsEnabled}
|
|
}
|
|
|
|
func (b *BunAdapter) Exec(ctx context.Context, query string, args ...interface{}) (res common.Result, err error) {
|
|
defer func() {
|
|
if r := recover(); r != nil {
|
|
err = logger.HandlePanic("BunAdapter.Exec", r)
|
|
}
|
|
}()
|
|
startedAt := time.Now()
|
|
operation, schema, entity, table := metricTargetFromRawQuery(query, b.driverName)
|
|
var result sql.Result
|
|
run := func() error { var e error; result, e = b.getDB().ExecContext(ctx, query, args...); return e }
|
|
err = run()
|
|
if isDBClosed(err) {
|
|
if reconnErr := b.reconnectDB(); reconnErr == nil {
|
|
err = run()
|
|
}
|
|
}
|
|
recordQueryMetrics(ctx, b.metricsEnabled, operation, schema, entity, table, startedAt, err)
|
|
return &BunResult{result: result}, err
|
|
}
|
|
|
|
func (b *BunAdapter) Query(ctx context.Context, dest interface{}, query string, args ...interface{}) (err error) {
|
|
defer func() {
|
|
if r := recover(); r != nil {
|
|
err = logger.HandlePanic("BunAdapter.Query", r)
|
|
}
|
|
}()
|
|
startedAt := time.Now()
|
|
operation, schema, entity, table := metricTargetFromRawQuery(query, b.driverName)
|
|
err = b.getDB().NewRaw(query, args...).Scan(ctx, dest)
|
|
if isDBClosed(err) {
|
|
if reconnErr := b.reconnectDB(); reconnErr == nil {
|
|
err = b.getDB().NewRaw(query, args...).Scan(ctx, dest)
|
|
}
|
|
}
|
|
recordQueryMetrics(ctx, b.metricsEnabled, operation, schema, entity, table, startedAt, err)
|
|
return err
|
|
}
|
|
|
|
func (b *BunAdapter) BeginTx(ctx context.Context) (common.Database, error) {
|
|
tx, err := b.getDB().BeginTx(ctx, &sql.TxOptions{})
|
|
if isDBClosed(err) {
|
|
if reconnErr := b.reconnectDB(); reconnErr == nil {
|
|
tx, err = b.getDB().BeginTx(ctx, &sql.TxOptions{})
|
|
}
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &BunTxAdapter{tx: tx, driverName: b.driverName, metricsEnabled: b.metricsEnabled}, nil
|
|
}
|
|
|
|
func (b *BunAdapter) CommitTx(ctx context.Context) error {
|
|
// For Bun, we need to handle this differently
|
|
// This is a simplified implementation
|
|
return nil
|
|
}
|
|
|
|
func (b *BunAdapter) RollbackTx(ctx context.Context) error {
|
|
// For Bun, we need to handle this differently
|
|
// This is a simplified implementation
|
|
return nil
|
|
}
|
|
|
|
func (b *BunAdapter) RunInTransaction(ctx context.Context, fn func(common.Database) error) (err error) {
|
|
defer func() {
|
|
if r := recover(); r != nil {
|
|
err = logger.HandlePanic("BunAdapter.RunInTransaction", r)
|
|
}
|
|
}()
|
|
defer dbtrace.TxBegin(ctx)()
|
|
run := func() error {
|
|
return b.getDB().RunInTx(ctx, &sql.TxOptions{}, func(ctx context.Context, tx bun.Tx) error {
|
|
adapter := &BunTxAdapter{tx: tx, driverName: b.driverName, metricsEnabled: b.metricsEnabled}
|
|
return fn(adapter)
|
|
})
|
|
}
|
|
err = run()
|
|
if isDBClosed(err) {
|
|
if reconnErr := b.reconnectDB(); reconnErr == nil {
|
|
err = run()
|
|
}
|
|
}
|
|
return err
|
|
}
|
|
|
|
func (b *BunAdapter) GetUnderlyingDB() interface{} {
|
|
return b.getDB()
|
|
}
|
|
|
|
// SQLDB implements common.SQLDBProvider.
|
|
func (b *BunAdapter) SQLDB() *sql.DB {
|
|
if db := b.getDB(); db != nil {
|
|
return db.DB
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (b *BunAdapter) DriverName() string {
|
|
// Normalize Bun's dialect name to match the project's canonical vocabulary.
|
|
// Bun returns "pg" for PostgreSQL; the rest of the project uses "postgres".
|
|
// Bun returns "sqlite3" for SQLite; we normalize to "sqlite".
|
|
switch name := b.db.Dialect().Name().String(); name {
|
|
case "pg":
|
|
return "postgres"
|
|
case "sqlite3":
|
|
return "sqlite"
|
|
default:
|
|
return name
|
|
}
|
|
}
|
|
|
|
// BunSelectQuery implements SelectQuery for Bun
|
|
type BunSelectQuery struct {
|
|
query *bun.SelectQuery
|
|
db bun.IDB // Store DB connection for count queries
|
|
hasModel bool // Track if Model() was called
|
|
schema string // Separated schema name
|
|
tableName string // Just the table name, without schema
|
|
entity string
|
|
tableAlias string
|
|
driverName string // Database driver name (postgres, sqlite, mssql)
|
|
inJoinContext bool // Track if we're in a JOIN relation context
|
|
joinTableAlias string // Alias to use for JOIN conditions
|
|
skipAutoDetect bool // Skip auto-detection to prevent circular calls
|
|
preloadRelationAlias string // Relation alias used in separate-query preloads (e.g. "tprp" for relation "TPRP")
|
|
customPreloads map[string][]func(common.SelectQuery) common.SelectQuery // Relations to load with custom implementation
|
|
metricsEnabled bool
|
|
}
|
|
|
|
func (b *BunSelectQuery) Model(model interface{}) common.SelectQuery {
|
|
b.query = b.query.Model(model)
|
|
b.hasModel = true // Mark that we have a model
|
|
b.schema, b.tableName = schemaAndTableFromModel(model, b.driverName)
|
|
if b.tableName == "" {
|
|
b.schema, b.tableName = parseTableName(b.query.GetTableName(), b.driverName)
|
|
}
|
|
b.entity = entityNameFromModel(model, b.tableName)
|
|
|
|
if provider, ok := model.(common.TableAliasProvider); ok {
|
|
b.tableAlias = provider.TableAlias()
|
|
}
|
|
|
|
return b
|
|
}
|
|
|
|
func (b *BunSelectQuery) Table(table string) common.SelectQuery {
|
|
b.query = b.query.Table(table)
|
|
// Check if the table name contains schema (e.g., "schema.table")
|
|
// For SQLite, this will convert "schema.table" to "schema_table"
|
|
b.schema, b.tableName = parseTableName(table, b.driverName)
|
|
if b.entity == "" {
|
|
b.entity = cleanMetricIdentifier(b.tableName)
|
|
}
|
|
return b
|
|
}
|
|
|
|
func (b *BunSelectQuery) Column(columns ...string) common.SelectQuery {
|
|
b.query = b.query.Column(columns...)
|
|
return b
|
|
}
|
|
|
|
func (b *BunSelectQuery) ColumnExpr(query string, args ...interface{}) common.SelectQuery {
|
|
if len(args) > 0 {
|
|
b.query = b.query.ColumnExpr(query, args...)
|
|
} else {
|
|
b.query = b.query.ColumnExpr(query)
|
|
}
|
|
return b
|
|
}
|
|
|
|
func (b *BunSelectQuery) Where(query string, args ...interface{}) common.SelectQuery {
|
|
if b.inJoinContext && b.joinTableAlias != "" {
|
|
query = addTablePrefix(query, b.joinTableAlias)
|
|
} else if b.preloadRelationAlias != "" && b.tableName != "" {
|
|
// Separate-query preload: the caller may have written conditions using the
|
|
// relation name as a prefix (e.g. "TPRP.col"). Bun uses the real table name
|
|
// as the alias, so rewrite any such references to use tableName instead.
|
|
query = replaceRelationAlias(query, b.preloadRelationAlias, b.tableName)
|
|
} else if b.tableAlias != "" && b.tableName != "" {
|
|
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
|
|
}
|
|
|
|
// isAcronymMatch checks if prefix is an acronym of tableName
|
|
// For example, "apil" matches "apiproviderlink" because each letter appears in sequence
|
|
func isAcronymMatch(prefix, tableName string) bool {
|
|
if len(prefix) == 0 || len(tableName) == 0 {
|
|
return false
|
|
}
|
|
|
|
prefixIdx := 0
|
|
for i := 0; i < len(tableName) && prefixIdx < len(prefix); i++ {
|
|
if tableName[i] == prefix[prefixIdx] {
|
|
prefixIdx++
|
|
}
|
|
}
|
|
|
|
// All characters of prefix were found in sequence in tableName
|
|
return prefixIdx == len(prefix)
|
|
}
|
|
|
|
// 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)
|
|
func normalizeTableAlias(query, expectedAlias, tableName string) string {
|
|
// Pattern: <word>.<column> where <word> might be an incorrect alias
|
|
// We'll look for patterns like "APIL.column" and either:
|
|
// 1. Remove the alias prefix if it's clearly meant for this table
|
|
// 2. Leave it alone if it might be referring to another table (JOIN/preload)
|
|
|
|
// Split on spaces and parentheses to find qualified 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 a qualified column reference
|
|
if dotIndex := strings.Index(part, "."); dotIndex > 0 {
|
|
prefix := part[:dotIndex]
|
|
column := part[dotIndex+1:]
|
|
|
|
// 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)) {
|
|
// Prefix matches current table, it's safe but redundant - leave it
|
|
continue
|
|
}
|
|
|
|
// Check if the prefix could plausibly be an alias/acronym for this table
|
|
// Only strip if we're confident it's meant for this table
|
|
// For example: "APIL" could be an acronym for "apiproviderlink"
|
|
prefixLower := strings.ToLower(prefix)
|
|
tableNameLower := strings.ToLower(tableName)
|
|
|
|
// Check if prefix is a substring of table name
|
|
isSubstring := strings.Contains(tableNameLower, prefixLower) && len(prefixLower) > 2
|
|
|
|
// Check if prefix is an acronym of table name
|
|
// e.g., "APIL" matches "ApiProviderLink" (A-p-I-providerL-ink)
|
|
isAcronym := false
|
|
if !isSubstring && len(prefixLower) > 2 {
|
|
isAcronym = isAcronymMatch(prefixLower, tableNameLower)
|
|
}
|
|
|
|
if isSubstring || isAcronym {
|
|
// This looks like it could be an alias for this table - strip it
|
|
logger.Debug("Stripping plausible alias '%s' from WHERE condition, keeping just '%s'", prefix, column)
|
|
// Replace the qualified reference with just the column name
|
|
modified = strings.ReplaceAll(modified, part, column)
|
|
} else {
|
|
// Prefix doesn't match the current table at all
|
|
// It's likely referring to a different table (JOIN/preload)
|
|
// DON'T strip it - leave the qualified reference as-is
|
|
logger.Debug("Keeping qualified reference '%s' - prefix '%s' doesn't match current table '%s'", part, prefix, tableName)
|
|
}
|
|
}
|
|
}
|
|
|
|
return modified
|
|
}
|
|
|
|
// replaceRelationAlias rewrites WHERE conditions written with a relation alias prefix
|
|
// (e.g. "TPRP.col") to use the real table name that bun uses in separate queries
|
|
// (e.g. "t_proposalinstance.col"). Only called for separate-query preload wrappers.
|
|
func replaceRelationAlias(query, relationAlias, tableName string) string {
|
|
if relationAlias == "" || tableName == "" || query == "" {
|
|
return query
|
|
}
|
|
parts := strings.FieldsFunc(query, func(r rune) bool {
|
|
return r == ' ' || r == '(' || r == ')' || r == ','
|
|
})
|
|
modified := query
|
|
for _, part := range parts {
|
|
if dotIndex := strings.Index(part, "."); dotIndex > 0 {
|
|
prefix := part[:dotIndex]
|
|
column := part[dotIndex+1:]
|
|
if strings.EqualFold(prefix, relationAlias) {
|
|
logger.Debug("Replacing relation alias '%s' with table name '%s' in preload WHERE condition", prefix, tableName)
|
|
modified = strings.ReplaceAll(modified, part, tableName+"."+column)
|
|
}
|
|
}
|
|
}
|
|
return modified
|
|
}
|
|
|
|
func isJoinKeyword(word string) bool {
|
|
switch strings.ToUpper(word) {
|
|
case "JOIN", "INNER", "LEFT", "RIGHT", "FULL", "OUTER", "CROSS":
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (b *BunSelectQuery) WhereOr(query string, args ...interface{}) common.SelectQuery {
|
|
b.query = b.query.WhereOr(query, args...)
|
|
return b
|
|
}
|
|
|
|
// WhereGroup wraps the conditions added by fn in one parenthesised group ANDed with the rest.
|
|
func (b *BunSelectQuery) WhereGroup(fn func(common.SelectQuery) common.SelectQuery) common.SelectQuery {
|
|
b.query = b.query.WhereGroup(" AND ", func(q *bun.SelectQuery) *bun.SelectQuery {
|
|
inner := *b
|
|
inner.query = q
|
|
if res, ok := fn(&inner).(*BunSelectQuery); ok {
|
|
return res.query
|
|
}
|
|
return q
|
|
})
|
|
return b
|
|
}
|
|
|
|
func (b *BunSelectQuery) Join(query string, args ...interface{}) common.SelectQuery {
|
|
// Extract optional prefix from args
|
|
// If the last arg is a string that looks like a table prefix, use it
|
|
var prefix string
|
|
sqlArgs := args
|
|
|
|
if len(args) > 0 {
|
|
if lastArg, ok := args[len(args)-1].(string); ok && len(lastArg) < 50 && !strings.Contains(lastArg, " ") {
|
|
// Likely a prefix, not a SQL parameter
|
|
prefix = lastArg
|
|
sqlArgs = args[:len(args)-1]
|
|
}
|
|
}
|
|
|
|
// If no prefix provided, use the table name as prefix (already separated from schema)
|
|
if prefix == "" && b.tableName != "" {
|
|
prefix = b.tableName
|
|
}
|
|
|
|
// If prefix is provided, add it as an alias in the join
|
|
// Bun expects: "JOIN table AS alias ON condition"
|
|
joinClause := query
|
|
if prefix != "" && !strings.Contains(strings.ToUpper(query), " AS ") {
|
|
// If query doesn't already have AS, check if it's a simple table name
|
|
parts := strings.Fields(query)
|
|
if len(parts) > 0 && !isJoinKeyword(parts[0]) {
|
|
// Simple table name, add prefix: "table AS prefix"
|
|
joinClause = fmt.Sprintf("%s AS %s", parts[0], prefix)
|
|
if len(parts) > 1 {
|
|
// Has ON clause: "table ON ..." becomes "table AS prefix ON ..."
|
|
joinClause += " " + strings.Join(parts[1:], " ")
|
|
}
|
|
}
|
|
}
|
|
|
|
b.query = b.query.Join(joinClause, sqlArgs...)
|
|
return b
|
|
}
|
|
|
|
func (b *BunSelectQuery) LeftJoin(query string, args ...interface{}) common.SelectQuery {
|
|
// Extract optional prefix from args
|
|
var prefix string
|
|
sqlArgs := args
|
|
|
|
if len(args) > 0 {
|
|
if lastArg, ok := args[len(args)-1].(string); ok && len(lastArg) < 50 && !strings.Contains(lastArg, " ") {
|
|
prefix = lastArg
|
|
sqlArgs = args[:len(args)-1]
|
|
}
|
|
}
|
|
|
|
// If no prefix provided, use the table name as prefix (already separated from schema)
|
|
if prefix == "" && b.tableName != "" {
|
|
prefix = b.tableName
|
|
}
|
|
|
|
// Construct LEFT JOIN with prefix
|
|
joinClause := query
|
|
if prefix != "" && !strings.Contains(strings.ToUpper(query), " AS ") {
|
|
parts := strings.Fields(query)
|
|
if len(parts) > 0 && !isJoinKeyword(parts[0]) {
|
|
joinClause = fmt.Sprintf("%s AS %s", parts[0], prefix)
|
|
if len(parts) > 1 {
|
|
joinClause += " " + strings.Join(parts[1:], " ")
|
|
}
|
|
}
|
|
}
|
|
|
|
b.query = b.query.Join("LEFT JOIN "+joinClause, sqlArgs...)
|
|
return b
|
|
}
|
|
|
|
func (b *BunSelectQuery) Preload(relation string, conditions ...interface{}) common.SelectQuery {
|
|
// Bun uses Relation() method for preloading
|
|
// For now, we'll just pass the relation name without conditions
|
|
// TODO: Implement proper condition handling for Bun
|
|
b.query = b.query.Relation(relation)
|
|
return b
|
|
}
|
|
|
|
func (b *BunSelectQuery) PreloadRelation(relation string, apply ...func(common.SelectQuery) common.SelectQuery) common.SelectQuery {
|
|
// Check if this relation will likely cause alias truncation FIRST
|
|
// PostgreSQL has a 63-character limit on identifiers
|
|
willTruncate := checkAliasLength(relation)
|
|
|
|
if willTruncate {
|
|
logger.Warn("Preload relation '%s' would generate aliases exceeding PostgreSQL's 63-char limit", relation)
|
|
logger.Info("Using custom preload implementation with separate queries for relation '%s'", relation)
|
|
|
|
// Store this relation for custom post-processing after the main query
|
|
// We'll load it manually with separate queries to avoid JOIN aliases
|
|
if b.customPreloads == nil {
|
|
b.customPreloads = make(map[string][]func(common.SelectQuery) common.SelectQuery)
|
|
}
|
|
b.customPreloads[relation] = apply
|
|
|
|
// Return without calling Bun's Relation() - we'll handle it ourselves
|
|
return b
|
|
}
|
|
|
|
// Auto-detect relationship type and choose optimal loading strategy
|
|
// Skip auto-detection if flag is set (prevents circular calls from JoinRelation)
|
|
if !b.skipAutoDetect {
|
|
model := b.query.GetModel()
|
|
if model != nil && model.Value() != nil {
|
|
// Guard against relations that don't exist on the model. Without this,
|
|
// bun panics inside Count/Scan with `model=X does not have relation="Y"`.
|
|
// Only validate the root segment so nested paths (e.g. "PRM.CHILD") still
|
|
// fall through to bun's native resolution.
|
|
rootRelation := relation
|
|
if idx := strings.Index(rootRelation, "."); idx >= 0 {
|
|
rootRelation = rootRelation[:idx]
|
|
}
|
|
if reflection.GetRelationType(model.Value(), rootRelation) == reflection.RelationUnknown {
|
|
logger.Warn("Skipping preload '%s': relation '%s' is not declared on model %T", relation, rootRelation, model.Value())
|
|
return b
|
|
}
|
|
|
|
relType := reflection.GetRelationType(model.Value(), relation)
|
|
|
|
// Log the detected relationship type
|
|
logger.Debug("PreloadRelation '%s' detected as: %s", relation, relType)
|
|
|
|
if relType.ShouldUseJoin() {
|
|
// If this is a belongs-to or has-one relation that won't exceed limits, use JOIN for better performance
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Use Bun's native Relation() for preloading
|
|
// Note: For relations that would cause truncation, skipAutoDetect is set to true
|
|
// to prevent our auto-detection from adding JOIN optimization
|
|
b.query = b.query.Relation(relation, func(sq *bun.SelectQuery) *bun.SelectQuery {
|
|
defer func() {
|
|
if r := recover(); r != nil {
|
|
_ = logger.HandlePanic("BunSelectQuery.PreloadRelation", r)
|
|
}
|
|
}()
|
|
if len(apply) == 0 {
|
|
return sq
|
|
}
|
|
|
|
// Wrap the incoming *bun.SelectQuery in our adapter
|
|
wrapper := &BunSelectQuery{
|
|
query: sq,
|
|
db: b.db,
|
|
driverName: b.driverName,
|
|
metricsEnabled: b.metricsEnabled,
|
|
}
|
|
|
|
// Try to extract table name and alias from the preload model
|
|
if model := sq.GetModel(); model != nil && model.Value() != nil {
|
|
modelValue := model.Value()
|
|
|
|
// Extract table name if model implements TableNameProvider
|
|
if provider, ok := modelValue.(common.TableNameProvider); ok {
|
|
fullTableName := provider.TableName()
|
|
// For SQLite, this will convert "schema.table" to "schema_table"
|
|
wrapper.schema, wrapper.tableName = parseTableName(fullTableName, b.driverName)
|
|
}
|
|
|
|
// Extract table alias if model implements TableAliasProvider
|
|
if provider, ok := modelValue.(common.TableAliasProvider); ok {
|
|
wrapper.tableAlias = provider.TableAlias()
|
|
logger.Debug("Preload relation '%s' using table alias: %s", relation, wrapper.tableAlias)
|
|
}
|
|
|
|
}
|
|
|
|
// Fallback: if the model didn't provide a table name, ask bun directly.
|
|
if wrapper.tableName == "" {
|
|
wrapper.schema, wrapper.tableName = parseTableName(sq.GetTableName(), b.driverName)
|
|
}
|
|
|
|
// For separate-query preloads (has-many), bun aliases the related table using
|
|
// the actual table name, not the relation name. Record the relation alias so
|
|
// Where() can rewrite conditions like "TPRP.col" to "t_proposalinstance.col".
|
|
wrapper.preloadRelationAlias = strings.ToLower(relation)
|
|
logger.Debug("Preload relation '%s' registered alias '%s' for separate-query WHERE rewriting", relation, wrapper.preloadRelationAlias)
|
|
|
|
// Start with the interface value (not pointer)
|
|
current := common.SelectQuery(wrapper)
|
|
|
|
// Apply each function in sequence
|
|
for _, fn := range apply {
|
|
if fn != nil {
|
|
modified := fn(current)
|
|
current = modified
|
|
}
|
|
}
|
|
|
|
// Extract the final *bun.SelectQuery
|
|
if finalBun, ok := current.(*BunSelectQuery); ok {
|
|
return finalBun.query
|
|
}
|
|
|
|
return sq // fallback
|
|
})
|
|
return b
|
|
}
|
|
|
|
// checkIfRelationAlreadyLoaded checks if a relation is already populated on parent records
|
|
// Returns the collection of related records if already loaded
|
|
func checkIfRelationAlreadyLoaded(parents reflect.Value, relationName string) (reflect.Value, bool) {
|
|
if parents.Len() == 0 {
|
|
return reflect.Value{}, false
|
|
}
|
|
|
|
// Get the first parent to check the relation field
|
|
firstParent := parents.Index(0)
|
|
if firstParent.Kind() == reflect.Pointer {
|
|
firstParent = firstParent.Elem()
|
|
}
|
|
|
|
// Find the relation field
|
|
relationField := firstParent.FieldByName(relationName)
|
|
if !relationField.IsValid() {
|
|
return reflect.Value{}, false
|
|
}
|
|
|
|
// Check if it's a slice (has-many)
|
|
if relationField.Kind() == reflect.Slice {
|
|
// Check if any parent has a non-empty slice
|
|
for i := 0; i < parents.Len(); i++ {
|
|
parent := parents.Index(i)
|
|
if parent.Kind() == reflect.Pointer {
|
|
parent = parent.Elem()
|
|
}
|
|
field := parent.FieldByName(relationName)
|
|
if field.IsValid() && !field.IsNil() && field.Len() > 0 {
|
|
// Already loaded! Collect all related records from all parents
|
|
allRelated := reflect.MakeSlice(field.Type(), 0, field.Len()*parents.Len())
|
|
for j := 0; j < parents.Len(); j++ {
|
|
p := parents.Index(j)
|
|
if p.Kind() == reflect.Pointer {
|
|
p = p.Elem()
|
|
}
|
|
f := p.FieldByName(relationName)
|
|
if f.IsValid() && !f.IsNil() {
|
|
for k := 0; k < f.Len(); k++ {
|
|
allRelated = reflect.Append(allRelated, f.Index(k))
|
|
}
|
|
}
|
|
}
|
|
return allRelated, true
|
|
}
|
|
}
|
|
} else if relationField.Kind() == reflect.Pointer {
|
|
// Check if it's a pointer (has-one/belongs-to)
|
|
if !relationField.IsNil() {
|
|
// Already loaded! Collect all related records from all parents
|
|
relatedType := relationField.Type()
|
|
allRelated := reflect.MakeSlice(reflect.SliceOf(relatedType), 0, parents.Len())
|
|
for j := 0; j < parents.Len(); j++ {
|
|
p := parents.Index(j)
|
|
if p.Kind() == reflect.Pointer {
|
|
p = p.Elem()
|
|
}
|
|
f := p.FieldByName(relationName)
|
|
if f.IsValid() && !f.IsNil() {
|
|
allRelated = reflect.Append(allRelated, f)
|
|
}
|
|
}
|
|
return allRelated, true
|
|
}
|
|
}
|
|
|
|
return reflect.Value{}, false
|
|
}
|
|
|
|
// loadCustomPreloads loads relations that would cause alias truncation using separate queries
|
|
func (b *BunSelectQuery) loadCustomPreloads(ctx context.Context) error {
|
|
model := b.query.GetModel()
|
|
if model == nil || model.Value() == nil {
|
|
return fmt.Errorf("no model to load preloads for")
|
|
}
|
|
|
|
// Get the actual data from the model
|
|
modelValue := reflect.ValueOf(model.Value())
|
|
if modelValue.Kind() == reflect.Pointer {
|
|
modelValue = modelValue.Elem()
|
|
}
|
|
|
|
// We only handle slices of records for now
|
|
if modelValue.Kind() != reflect.Slice {
|
|
logger.Warn("Custom preloads only support slice models currently, got: %v", modelValue.Kind())
|
|
return nil
|
|
}
|
|
|
|
if modelValue.Len() == 0 {
|
|
logger.Debug("No records to load preloads for")
|
|
return nil
|
|
}
|
|
|
|
// For each custom preload relation
|
|
for relation, applyFuncs := range b.customPreloads {
|
|
logger.Info("Loading custom preload for relation: %s", relation)
|
|
|
|
// Parse the relation path (e.g., "MTL.MAL.DEF" -> ["MTL", "MAL", "DEF"])
|
|
relationParts := strings.Split(relation, ".")
|
|
|
|
// Start with the parent records
|
|
currentRecords := modelValue
|
|
|
|
// Load each level of the relation
|
|
for i, relationPart := range relationParts {
|
|
isLastPart := i == len(relationParts)-1
|
|
|
|
logger.Debug("Loading relation part [%d/%d]: %s", i+1, len(relationParts), relationPart)
|
|
|
|
// Check if this level is already loaded by Bun (avoid duplicates)
|
|
existingRecords, alreadyLoaded := checkIfRelationAlreadyLoaded(currentRecords, relationPart)
|
|
if alreadyLoaded && existingRecords.IsValid() && existingRecords.Len() > 0 {
|
|
logger.Info("Relation '%s' already loaded by Bun, using existing %d records", relationPart, existingRecords.Len())
|
|
currentRecords = existingRecords
|
|
continue
|
|
}
|
|
|
|
// Load this level and get the loaded records for the next level
|
|
loadedRecords, err := b.loadRelationLevel(ctx, currentRecords, relationPart, isLastPart, applyFuncs)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to load relation %s (part %s): %w", relation, relationPart, err)
|
|
}
|
|
|
|
// For nested relations, use the loaded records as parents for the next level
|
|
if !isLastPart && loadedRecords.IsValid() && loadedRecords.Len() > 0 {
|
|
logger.Debug("Collected %d records for next level", loadedRecords.Len())
|
|
currentRecords = loadedRecords
|
|
} else if !isLastPart {
|
|
logger.Debug("No records loaded at level %s, stopping nested preload", relationPart)
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// loadRelationLevel loads a single level of a relation for a set of parent records
|
|
// Returns the loaded records (for use as parents in nested preloads) and any error
|
|
func (b *BunSelectQuery) loadRelationLevel(ctx context.Context, parentRecords reflect.Value, relationName string, isLast bool, applyFuncs []func(common.SelectQuery) common.SelectQuery) (reflect.Value, error) {
|
|
if parentRecords.Len() == 0 {
|
|
return reflect.Value{}, nil
|
|
}
|
|
|
|
// Get the first record to inspect the struct type
|
|
firstRecord := parentRecords.Index(0)
|
|
if firstRecord.Kind() == reflect.Pointer {
|
|
firstRecord = firstRecord.Elem()
|
|
}
|
|
|
|
if firstRecord.Kind() != reflect.Struct {
|
|
return reflect.Value{}, fmt.Errorf("expected struct, got %v", firstRecord.Kind())
|
|
}
|
|
|
|
parentType := firstRecord.Type()
|
|
|
|
// Find the relation field in the struct
|
|
structField, found := parentType.FieldByName(relationName)
|
|
if !found {
|
|
return reflect.Value{}, fmt.Errorf("relation field %s not found in struct %s", relationName, parentType.Name())
|
|
}
|
|
|
|
// Parse the bun tag to get relation info
|
|
bunTag := structField.Tag.Get("bun")
|
|
logger.Debug("Relation %s bun tag: %s", relationName, bunTag)
|
|
|
|
relInfo, err := parseRelationTag(bunTag)
|
|
if err != nil {
|
|
return reflect.Value{}, fmt.Errorf("failed to parse relation tag for %s: %w", relationName, err)
|
|
}
|
|
|
|
logger.Debug("Parsed relation: type=%s, join=%s", relInfo.relType, relInfo.joinCondition)
|
|
|
|
// Extract foreign key values from parent records
|
|
fkValues, err := extractForeignKeyValues(parentRecords, relInfo.localKey)
|
|
if err != nil {
|
|
return reflect.Value{}, fmt.Errorf("failed to extract FK values: %w", err)
|
|
}
|
|
|
|
if len(fkValues) == 0 {
|
|
logger.Debug("No foreign key values to load for relation %s", relationName)
|
|
return reflect.Value{}, nil
|
|
}
|
|
|
|
logger.Debug("Loading %d related records for %s (FK values: %v)", len(fkValues), relationName, fkValues)
|
|
|
|
// Get the related model type
|
|
relatedType := structField.Type
|
|
isSlice := relatedType.Kind() == reflect.Slice
|
|
if isSlice {
|
|
relatedType = relatedType.Elem()
|
|
}
|
|
if relatedType.Kind() == reflect.Pointer {
|
|
relatedType = relatedType.Elem()
|
|
}
|
|
|
|
// Create a slice to hold the results
|
|
resultsSlice := reflect.MakeSlice(reflect.SliceOf(reflect.PointerTo(relatedType)), 0, len(fkValues))
|
|
resultsPtr := reflect.New(resultsSlice.Type())
|
|
resultsPtr.Elem().Set(resultsSlice)
|
|
|
|
// Build and execute the query
|
|
query := b.db.NewSelect().Model(resultsPtr.Interface())
|
|
|
|
// Apply WHERE clause: foreign_key IN (values...)
|
|
query = query.Where(fmt.Sprintf("%s IN (?)", relInfo.foreignKey), bun.In(fkValues))
|
|
|
|
// Apply user's functions (if any)
|
|
if isLast && len(applyFuncs) > 0 {
|
|
wrapper := &BunSelectQuery{query: query, db: b.db, driverName: b.driverName, metricsEnabled: b.metricsEnabled}
|
|
for _, fn := range applyFuncs {
|
|
if fn != nil {
|
|
wrapper = fn(wrapper).(*BunSelectQuery)
|
|
query = wrapper.query
|
|
}
|
|
}
|
|
}
|
|
|
|
// Execute the query
|
|
err = query.Scan(ctx)
|
|
if err != nil {
|
|
return reflect.Value{}, fmt.Errorf("failed to load related records: %w", err)
|
|
}
|
|
|
|
loadedRecords := resultsPtr.Elem()
|
|
logger.Info("Loaded %d related records for relation %s", loadedRecords.Len(), relationName)
|
|
|
|
// Associate loaded records back to parent records
|
|
err = associateRelatedRecords(parentRecords, loadedRecords, relationName, relInfo, isSlice)
|
|
if err != nil {
|
|
return reflect.Value{}, err
|
|
}
|
|
|
|
// Return the loaded records for use in nested preloads
|
|
return loadedRecords, nil
|
|
}
|
|
|
|
// relationInfo holds parsed relation metadata
|
|
type relationInfo struct {
|
|
relType string // has-one, has-many, belongs-to
|
|
localKey string // Key in parent table
|
|
foreignKey string // Key in related table
|
|
joinCondition string // Full join condition
|
|
}
|
|
|
|
// parseRelationTag parses the bun:"rel:..." tag
|
|
func parseRelationTag(tag string) (*relationInfo, error) {
|
|
info := &relationInfo{}
|
|
|
|
// Parse tag like: rel:has-one,join:rid_mastertaskitem=rid_mastertaskitem
|
|
parts := strings.Split(tag, ",")
|
|
for _, part := range parts {
|
|
part = strings.TrimSpace(part)
|
|
if strings.HasPrefix(part, "rel:") {
|
|
info.relType = strings.TrimPrefix(part, "rel:")
|
|
} else if strings.HasPrefix(part, "join:") {
|
|
info.joinCondition = strings.TrimPrefix(part, "join:")
|
|
// Parse join: local_key=foreign_key
|
|
joinParts := strings.Split(info.joinCondition, "=")
|
|
if len(joinParts) == 2 {
|
|
info.localKey = strings.TrimSpace(joinParts[0])
|
|
info.foreignKey = strings.TrimSpace(joinParts[1])
|
|
}
|
|
}
|
|
}
|
|
|
|
if info.relType == "" || info.localKey == "" || info.foreignKey == "" {
|
|
return nil, fmt.Errorf("incomplete relation tag: %s", tag)
|
|
}
|
|
|
|
return info, nil
|
|
}
|
|
|
|
// extractForeignKeyValues collects FK values from parent records
|
|
func extractForeignKeyValues(records reflect.Value, fkFieldName string) ([]interface{}, error) {
|
|
values := make([]interface{}, 0, records.Len())
|
|
seenValues := make(map[interface{}]bool)
|
|
|
|
for i := 0; i < records.Len(); i++ {
|
|
record := records.Index(i)
|
|
if record.Kind() == reflect.Pointer {
|
|
record = record.Elem()
|
|
}
|
|
|
|
// Find the FK field - try both exact name and capitalized version
|
|
fkField := record.FieldByName(fkFieldName)
|
|
if !fkField.IsValid() {
|
|
// Try capitalized version
|
|
fkField = record.FieldByName(strings.ToUpper(fkFieldName[:1]) + fkFieldName[1:])
|
|
}
|
|
if !fkField.IsValid() {
|
|
// Try finding by json tag
|
|
for j := 0; j < record.NumField(); j++ {
|
|
field := record.Type().Field(j)
|
|
jsonTag := field.Tag.Get("json")
|
|
bunTag := field.Tag.Get("bun")
|
|
if strings.HasPrefix(jsonTag, fkFieldName) || strings.Contains(bunTag, fkFieldName) {
|
|
fkField = record.Field(j)
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
if !fkField.IsValid() {
|
|
continue // Skip records without FK
|
|
}
|
|
|
|
// Extract the value
|
|
var value interface{}
|
|
if fkField.CanInterface() {
|
|
value = fkField.Interface()
|
|
|
|
// Handle SqlNull types
|
|
if nullType, ok := value.(interface{ IsNull() bool }); ok {
|
|
if nullType.IsNull() {
|
|
continue
|
|
}
|
|
}
|
|
|
|
// Handle types with Int64() method
|
|
if int64er, ok := value.(interface{ Int64() int64 }); ok {
|
|
value = int64er.Int64()
|
|
}
|
|
|
|
// Deduplicate
|
|
if !seenValues[value] {
|
|
values = append(values, value)
|
|
seenValues[value] = true
|
|
}
|
|
}
|
|
}
|
|
|
|
return values, nil
|
|
}
|
|
|
|
// associateRelatedRecords associates loaded records back to parents
|
|
func associateRelatedRecords(parents, related reflect.Value, fieldName string, relInfo *relationInfo, isSlice bool) error {
|
|
logger.Debug("Associating %d related records to %d parents for field '%s'", related.Len(), parents.Len(), fieldName)
|
|
|
|
// Build a map: foreignKey -> related record(s)
|
|
relatedMap := make(map[interface{}][]reflect.Value)
|
|
|
|
for i := 0; i < related.Len(); i++ {
|
|
relRecord := related.Index(i)
|
|
relRecordElem := relRecord
|
|
if relRecordElem.Kind() == reflect.Pointer {
|
|
relRecordElem = relRecordElem.Elem()
|
|
}
|
|
|
|
// Get the foreign key value from the related record - try multiple variations
|
|
fkField := findFieldByName(relRecordElem, relInfo.foreignKey)
|
|
if !fkField.IsValid() {
|
|
logger.Warn("Could not find FK field '%s' in related record type %s", relInfo.foreignKey, relRecordElem.Type().Name())
|
|
continue
|
|
}
|
|
|
|
fkValue := extractFieldValue(fkField)
|
|
if fkValue == nil {
|
|
continue
|
|
}
|
|
|
|
relatedMap[fkValue] = append(relatedMap[fkValue], related.Index(i))
|
|
}
|
|
|
|
logger.Debug("Built related map with %d unique FK values", len(relatedMap))
|
|
|
|
// Associate with parents
|
|
associatedCount := 0
|
|
for i := 0; i < parents.Len(); i++ {
|
|
parentPtr := parents.Index(i)
|
|
parent := parentPtr
|
|
if parent.Kind() == reflect.Pointer {
|
|
parent = parent.Elem()
|
|
}
|
|
|
|
// Get the local key value from parent
|
|
localField := findFieldByName(parent, relInfo.localKey)
|
|
if !localField.IsValid() {
|
|
logger.Warn("Could not find local key field '%s' in parent type %s", relInfo.localKey, parent.Type().Name())
|
|
continue
|
|
}
|
|
|
|
localValue := extractFieldValue(localField)
|
|
if localValue == nil {
|
|
continue
|
|
}
|
|
|
|
// Find matching related records
|
|
matches := relatedMap[localValue]
|
|
if len(matches) == 0 {
|
|
continue
|
|
}
|
|
|
|
// Set the relation field - IMPORTANT: use the pointer, not the elem
|
|
relationField := parent.FieldByName(fieldName)
|
|
if !relationField.IsValid() {
|
|
logger.Warn("Relation field '%s' not found in parent type %s", fieldName, parent.Type().Name())
|
|
continue
|
|
}
|
|
|
|
if !relationField.CanSet() {
|
|
logger.Warn("Relation field '%s' cannot be set (unexported?)", fieldName)
|
|
continue
|
|
}
|
|
|
|
if isSlice {
|
|
// For has-many: replace entire slice (don't append to avoid duplicates)
|
|
newSlice := reflect.MakeSlice(relationField.Type(), 0, len(matches))
|
|
for _, match := range matches {
|
|
newSlice = reflect.Append(newSlice, match)
|
|
}
|
|
relationField.Set(newSlice)
|
|
associatedCount += len(matches)
|
|
logger.Debug("Set has-many field '%s' with %d records for parent %d", fieldName, len(matches), i)
|
|
} else {
|
|
// For has-one/belongs-to: only set if not already set (avoid duplicates)
|
|
if relationField.IsNil() {
|
|
relationField.Set(matches[0])
|
|
associatedCount++
|
|
logger.Debug("Set has-one field '%s' for parent %d", fieldName, i)
|
|
} else {
|
|
logger.Debug("Skipping has-one field '%s' for parent %d (already set)", fieldName, i)
|
|
}
|
|
}
|
|
}
|
|
|
|
logger.Info("Associated %d related records to %d parents for field '%s'", associatedCount, parents.Len(), fieldName)
|
|
return nil
|
|
}
|
|
|
|
// findFieldByName finds a struct field by name, trying multiple variations
|
|
func findFieldByName(v reflect.Value, name string) reflect.Value {
|
|
// Try exact name
|
|
field := v.FieldByName(name)
|
|
if field.IsValid() {
|
|
return field
|
|
}
|
|
|
|
// Try with capital first letter
|
|
if len(name) > 0 {
|
|
capital := strings.ToUpper(name[0:1]) + name[1:]
|
|
field = v.FieldByName(capital)
|
|
if field.IsValid() {
|
|
return field
|
|
}
|
|
}
|
|
|
|
// Try searching by json or bun tag
|
|
t := v.Type()
|
|
for i := 0; i < t.NumField(); i++ {
|
|
f := t.Field(i)
|
|
jsonTag := f.Tag.Get("json")
|
|
bunTag := f.Tag.Get("bun")
|
|
|
|
// Check json tag
|
|
if strings.HasPrefix(jsonTag, name+",") || jsonTag == name {
|
|
return v.Field(i)
|
|
}
|
|
|
|
// Check bun tag for column name
|
|
if strings.Contains(bunTag, name+",") || strings.Contains(bunTag, name+":") {
|
|
return v.Field(i)
|
|
}
|
|
}
|
|
|
|
return reflect.Value{}
|
|
}
|
|
|
|
// extractFieldValue extracts the value from a field, handling SqlNull types
|
|
func extractFieldValue(field reflect.Value) interface{} {
|
|
if !field.CanInterface() {
|
|
return nil
|
|
}
|
|
|
|
value := field.Interface()
|
|
|
|
// Handle SqlNull types
|
|
if nullType, ok := value.(interface{ IsNull() bool }); ok {
|
|
if nullType.IsNull() {
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// Handle types with Int64() method
|
|
if int64er, ok := value.(interface{ Int64() int64 }); ok {
|
|
return int64er.Int64()
|
|
}
|
|
|
|
// Handle types with String() method for comparison
|
|
if stringer, ok := value.(interface{ String() string }); ok {
|
|
return stringer.String()
|
|
}
|
|
|
|
return value
|
|
}
|
|
|
|
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
|
|
// CRITICAL: Set skipAutoDetect flag to prevent circular call
|
|
// (PreloadRelation would detect belongs-to and call JoinRelation again)
|
|
b.skipAutoDetect = true
|
|
defer func() { b.skipAutoDetect = false }()
|
|
return b.PreloadRelation(relation, wrappedApply...)
|
|
}
|
|
|
|
func (b *BunSelectQuery) Order(order string) common.SelectQuery {
|
|
b.query = b.query.Order(order)
|
|
return b
|
|
}
|
|
|
|
func (b *BunSelectQuery) OrderExpr(order string, args ...interface{}) common.SelectQuery {
|
|
b.query = b.query.OrderExpr(order, args...)
|
|
return b
|
|
}
|
|
|
|
func (b *BunSelectQuery) Limit(n int) common.SelectQuery {
|
|
b.query = b.query.Limit(n)
|
|
return b
|
|
}
|
|
|
|
func (b *BunSelectQuery) Offset(n int) common.SelectQuery {
|
|
b.query = b.query.Offset(n)
|
|
return b
|
|
}
|
|
|
|
func (b *BunSelectQuery) Group(group string) common.SelectQuery {
|
|
b.query = b.query.Group(group)
|
|
return b
|
|
}
|
|
|
|
func (b *BunSelectQuery) Having(having string, args ...interface{}) common.SelectQuery {
|
|
b.query = b.query.Having(having, args...)
|
|
return b
|
|
}
|
|
|
|
func (b *BunSelectQuery) Scan(ctx context.Context, dest interface{}) (err error) {
|
|
startedAt := time.Now()
|
|
defer func() {
|
|
if r := recover(); r != nil {
|
|
err = logger.HandlePanic("BunSelectQuery.Scan", r)
|
|
}
|
|
recordQueryMetrics(ctx, b.metricsEnabled, "SELECT", b.schema, b.entity, b.tableName, startedAt, err)
|
|
}()
|
|
if dest == nil {
|
|
err = fmt.Errorf("destination cannot be nil")
|
|
return err
|
|
}
|
|
|
|
err = b.query.Scan(ctx, dest)
|
|
if err != nil {
|
|
sqlStr := b.query.String()
|
|
logger.Error("BunSelectQuery.Scan failed. SQL: %s. Error: %v", sqlStr, err)
|
|
err = common.WrapSQLError(err, sqlStr)
|
|
}
|
|
return err
|
|
}
|
|
|
|
func (b *BunSelectQuery) ScanModel(ctx context.Context) (err error) {
|
|
startedAt := time.Now()
|
|
defer func() {
|
|
if r := recover(); r != nil {
|
|
// Enhanced panic recovery with model information
|
|
model := b.query.GetModel()
|
|
var modelInfo string
|
|
if model != nil && model.Value() != nil {
|
|
modelValue := model.Value()
|
|
modelInfo = fmt.Sprintf("Model type: %T", modelValue)
|
|
|
|
v := reflect.ValueOf(modelValue)
|
|
if v.Kind() == reflect.Pointer {
|
|
v = v.Elem()
|
|
}
|
|
if v.Kind() == reflect.Slice {
|
|
if v.Type().Elem().Kind() == reflect.Pointer {
|
|
modelInfo += fmt.Sprintf(", Slice of: %s", v.Type().Elem().Elem().Name())
|
|
} else {
|
|
modelInfo += fmt.Sprintf(", Slice of: %s", v.Type().Elem().Name())
|
|
}
|
|
} else if v.Kind() == reflect.Struct {
|
|
modelInfo += fmt.Sprintf(", Struct: %s", v.Type().Name())
|
|
}
|
|
}
|
|
|
|
sqlStr := b.query.String()
|
|
logger.Error("Panic in BunSelectQuery.ScanModel: %v. %s. SQL: %s", r, modelInfo, sqlStr)
|
|
err = logger.HandlePanic("BunSelectQuery.ScanModel", r)
|
|
}
|
|
recordQueryMetrics(ctx, b.metricsEnabled, "SELECT", b.schema, b.entity, b.tableName, startedAt, err)
|
|
}()
|
|
if b.query.GetModel() == nil {
|
|
err = fmt.Errorf("model is nil")
|
|
return err
|
|
}
|
|
|
|
// Optional: Enable detailed field-level debugging (set to true to debug)
|
|
const enableDetailedDebug = true
|
|
if enableDetailedDebug {
|
|
model := b.query.GetModel()
|
|
if model != nil && model.Value() != nil {
|
|
if err := debugScanIntoStruct(nil, model.Value()); err != nil {
|
|
logger.Warn("Debug scan inspection failed: %v", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
err = b.query.Scan(ctx)
|
|
if err != nil {
|
|
sqlStr := b.query.String()
|
|
logger.Error("BunSelectQuery.ScanModel failed. SQL: %s. Error: %v", sqlStr, err)
|
|
return common.WrapSQLError(err, sqlStr)
|
|
}
|
|
|
|
// After main query, load custom preloads using separate queries
|
|
if len(b.customPreloads) > 0 {
|
|
logger.Info("Loading %d custom preload(s) with separate queries", len(b.customPreloads))
|
|
if err = b.loadCustomPreloads(ctx); err != nil {
|
|
logger.Error("Failed to load custom preloads: %v", err)
|
|
return err
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (b *BunSelectQuery) Count(ctx context.Context) (count int, err error) {
|
|
startedAt := time.Now()
|
|
defer func() {
|
|
if r := recover(); r != nil {
|
|
err = logger.HandlePanic("BunSelectQuery.Count", r)
|
|
count = 0
|
|
}
|
|
recordQueryMetrics(ctx, b.metricsEnabled, "COUNT", b.schema, b.entity, b.tableName, startedAt, err)
|
|
}()
|
|
// If Model() was set, use bun's native Count() which works properly
|
|
if b.hasModel {
|
|
count, err = b.query.Count(ctx) // assign to named returns, not shadow vars
|
|
if err != nil {
|
|
sqlStr := b.query.String()
|
|
logger.Error("BunSelectQuery.Count failed. SQL: %s. Error: %v", sqlStr, err)
|
|
err = common.WrapSQLError(err, sqlStr)
|
|
}
|
|
return
|
|
}
|
|
|
|
// Otherwise, wrap as subquery to avoid "Model(nil)" error
|
|
// This is needed when only Table() is set without a model
|
|
countQuery := b.db.NewSelect().
|
|
TableExpr("(?) AS subquery", b.query).
|
|
ColumnExpr("COUNT(*)")
|
|
err = countQuery.Scan(ctx, &count)
|
|
if err != nil {
|
|
sqlStr := countQuery.String()
|
|
logger.Error("BunSelectQuery.Count (subquery) failed. SQL: %s. Error: %v", sqlStr, err)
|
|
err = common.WrapSQLError(err, sqlStr)
|
|
}
|
|
return
|
|
}
|
|
|
|
func (b *BunSelectQuery) Exists(ctx context.Context) (exists bool, err error) {
|
|
startedAt := time.Now()
|
|
defer func() {
|
|
if r := recover(); r != nil {
|
|
err = logger.HandlePanic("BunSelectQuery.Exists", r)
|
|
exists = false
|
|
}
|
|
recordQueryMetrics(ctx, b.metricsEnabled, "EXISTS", b.schema, b.entity, b.tableName, startedAt, err)
|
|
}()
|
|
exists, err = b.query.Exists(ctx)
|
|
if err != nil {
|
|
sqlStr := b.query.String()
|
|
logger.Error("BunSelectQuery.Exists failed. SQL: %s. Error: %v", sqlStr, err)
|
|
err = common.WrapSQLError(err, sqlStr)
|
|
}
|
|
return
|
|
}
|
|
|
|
// BunInsertQuery implements InsertQuery for Bun
|
|
type BunInsertQuery struct {
|
|
query *bun.InsertQuery
|
|
values map[string]interface{}
|
|
hasModel bool
|
|
driverName string
|
|
schema string
|
|
tableName string
|
|
entity string
|
|
metricsEnabled bool
|
|
}
|
|
|
|
func (b *BunInsertQuery) Model(model interface{}) common.InsertQuery {
|
|
b.query = b.query.Model(model)
|
|
b.hasModel = true
|
|
b.schema, b.tableName = schemaAndTableFromModel(model, b.driverName)
|
|
if b.tableName == "" {
|
|
b.schema, b.tableName = parseTableName(b.query.GetTableName(), b.driverName)
|
|
}
|
|
b.entity = entityNameFromModel(model, b.tableName)
|
|
return b
|
|
}
|
|
|
|
func (b *BunInsertQuery) Table(table string) common.InsertQuery {
|
|
if b.hasModel {
|
|
return b
|
|
}
|
|
b.query = b.query.Table(table)
|
|
b.schema, b.tableName = parseTableName(table, b.driverName)
|
|
if b.entity == "" {
|
|
b.entity = cleanMetricIdentifier(b.tableName)
|
|
}
|
|
return b
|
|
}
|
|
|
|
func (b *BunInsertQuery) Value(column string, value interface{}) common.InsertQuery {
|
|
if b.values == nil {
|
|
b.values = make(map[string]interface{})
|
|
}
|
|
b.values[column] = value
|
|
return b
|
|
}
|
|
|
|
func (b *BunInsertQuery) OnConflict(action string) common.InsertQuery {
|
|
b.query = b.query.On(action)
|
|
return b
|
|
}
|
|
|
|
// bunWritableExcludes drops columns bun already leaves out of INSERT/UPDATE
|
|
// (scanonly fields) or does not know, since bun's ExcludeColumn errors with
|
|
// "can't find column" for anything that is not in the table's writable fields.
|
|
func bunWritableExcludes(model bun.Model, columns []string) []string {
|
|
tm, ok := model.(interface{ Table() *schema.Table })
|
|
if !ok || tm.Table() == nil {
|
|
return columns
|
|
}
|
|
table := tm.Table()
|
|
writable := make(map[string]struct{}, len(table.Fields))
|
|
for _, f := range table.Fields {
|
|
writable[f.Name] = struct{}{}
|
|
}
|
|
out := make([]string, 0, len(columns))
|
|
for _, c := range columns {
|
|
if _, ok := writable[c]; ok || c == "*" {
|
|
out = append(out, c)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func (b *BunInsertQuery) ExcludeColumn(columns ...string) common.InsertQuery {
|
|
if columns = bunWritableExcludes(b.query.GetModel(), columns); len(columns) > 0 {
|
|
b.query = b.query.ExcludeColumn(columns...)
|
|
}
|
|
return b
|
|
}
|
|
|
|
func (b *BunInsertQuery) Returning(columns ...string) common.InsertQuery {
|
|
if len(columns) > 0 {
|
|
b.query = b.query.Returning(strings.Join(columns, ", "))
|
|
}
|
|
return b
|
|
}
|
|
|
|
func (b *BunInsertQuery) prepareValues() {
|
|
if len(b.values) > 0 {
|
|
if !b.hasModel {
|
|
b.query = b.query.Model(&b.values)
|
|
} else {
|
|
for k, v := range b.values {
|
|
b.query = b.query.Value(k, "?", v)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func (b *BunInsertQuery) Exec(ctx context.Context) (res common.Result, err error) {
|
|
defer func() {
|
|
if r := recover(); r != nil {
|
|
err = logger.HandlePanic("BunInsertQuery.Exec", r)
|
|
}
|
|
}()
|
|
startedAt := time.Now()
|
|
b.prepareValues()
|
|
result, err := b.query.Exec(ctx)
|
|
recordQueryMetrics(ctx, b.metricsEnabled, "INSERT", b.schema, b.entity, b.tableName, startedAt, err)
|
|
return &BunResult{result: result}, err
|
|
}
|
|
|
|
func (b *BunInsertQuery) Scan(ctx context.Context, dest interface{}) (err error) {
|
|
defer func() {
|
|
if r := recover(); r != nil {
|
|
err = logger.HandlePanic("BunInsertQuery.Scan", r)
|
|
}
|
|
}()
|
|
startedAt := time.Now()
|
|
b.prepareValues()
|
|
err = b.query.Scan(ctx, dest)
|
|
recordQueryMetrics(ctx, b.metricsEnabled, "INSERT", b.schema, b.entity, b.tableName, startedAt, err)
|
|
return err
|
|
}
|
|
|
|
// BunUpdateQuery implements UpdateQuery for Bun
|
|
type BunUpdateQuery struct {
|
|
query *bun.UpdateQuery
|
|
model interface{}
|
|
driverName string
|
|
schema string
|
|
tableName string
|
|
entity string
|
|
metricsEnabled bool
|
|
}
|
|
|
|
func (b *BunUpdateQuery) Model(model interface{}) common.UpdateQuery {
|
|
b.query = b.query.Model(model)
|
|
b.model = model
|
|
b.schema, b.tableName = schemaAndTableFromModel(model, b.driverName)
|
|
if b.tableName == "" {
|
|
b.schema, b.tableName = parseTableName(b.query.GetTableName(), b.driverName)
|
|
}
|
|
b.entity = entityNameFromModel(model, b.tableName)
|
|
return b
|
|
}
|
|
|
|
func (b *BunUpdateQuery) Table(table string) common.UpdateQuery {
|
|
b.query = b.query.Table(table)
|
|
b.schema, b.tableName = parseTableName(table, b.driverName)
|
|
if b.entity == "" {
|
|
b.entity = cleanMetricIdentifier(b.tableName)
|
|
}
|
|
if b.model == nil {
|
|
// Try to get table name from table string if model is not set
|
|
|
|
model, err := modelregistry.GetModelByName(table)
|
|
if err == nil {
|
|
b.model = model
|
|
b.entity = entityNameFromModel(model, b.tableName)
|
|
}
|
|
}
|
|
return b
|
|
}
|
|
|
|
func (b *BunUpdateQuery) Set(column string, value interface{}) common.UpdateQuery {
|
|
// Validate column is writable if model is set
|
|
if b.model != nil && !reflection.IsColumnWritable(b.model, column) {
|
|
// Skip scan-only columns
|
|
return b
|
|
}
|
|
b.query = b.query.Set(column+" = ?", value)
|
|
return b
|
|
}
|
|
|
|
func (b *BunUpdateQuery) SetMap(values map[string]interface{}) common.UpdateQuery {
|
|
pkName := reflection.GetPrimaryKeyName(b.model)
|
|
for column, value := range values {
|
|
// Validate column is writable if model is set
|
|
if b.model != nil && !reflection.IsColumnWritable(b.model, column) {
|
|
// Skip scan-only columns
|
|
continue
|
|
}
|
|
if pkName != "" && column == pkName {
|
|
// Skip primary key updates
|
|
continue
|
|
}
|
|
b.query = b.query.Set(column+" = ?", common.ConvertSliceForBun(value))
|
|
}
|
|
return b
|
|
}
|
|
|
|
func (b *BunUpdateQuery) ExcludeColumn(columns ...string) common.UpdateQuery {
|
|
if columns = bunWritableExcludes(b.query.GetModel(), columns); len(columns) > 0 {
|
|
b.query = b.query.ExcludeColumn(columns...)
|
|
}
|
|
return b
|
|
}
|
|
|
|
func (b *BunUpdateQuery) Where(query string, args ...interface{}) common.UpdateQuery {
|
|
b.query = b.query.Where(query, args...)
|
|
return b
|
|
}
|
|
|
|
func (b *BunUpdateQuery) Returning(columns ...string) common.UpdateQuery {
|
|
if len(columns) > 0 {
|
|
b.query = b.query.Returning(strings.Join(columns, ", "))
|
|
}
|
|
return b
|
|
}
|
|
|
|
func (b *BunUpdateQuery) Exec(ctx context.Context) (res common.Result, err error) {
|
|
defer func() {
|
|
if r := recover(); r != nil {
|
|
err = logger.HandlePanic("BunUpdateQuery.Exec", r)
|
|
}
|
|
}()
|
|
startedAt := time.Now()
|
|
result, err := b.query.Exec(ctx)
|
|
if err != nil {
|
|
// Log SQL string for debugging
|
|
sqlStr := b.query.String()
|
|
logger.Error("BunUpdateQuery.Exec failed. SQL: %s. Error: %v", sqlStr, err)
|
|
err = common.WrapSQLError(err, sqlStr)
|
|
}
|
|
recordQueryMetrics(ctx, b.metricsEnabled, "UPDATE", b.schema, b.entity, b.tableName, startedAt, err)
|
|
return &BunResult{result: result}, err
|
|
}
|
|
|
|
// BunDeleteQuery implements DeleteQuery for Bun
|
|
type BunDeleteQuery struct {
|
|
query *bun.DeleteQuery
|
|
driverName string
|
|
schema string
|
|
tableName string
|
|
entity string
|
|
metricsEnabled bool
|
|
}
|
|
|
|
func (b *BunDeleteQuery) Model(model interface{}) common.DeleteQuery {
|
|
b.query = b.query.Model(model)
|
|
b.schema, b.tableName = schemaAndTableFromModel(model, b.driverName)
|
|
if b.tableName == "" {
|
|
b.schema, b.tableName = parseTableName(b.query.GetTableName(), b.driverName)
|
|
}
|
|
b.entity = entityNameFromModel(model, b.tableName)
|
|
return b
|
|
}
|
|
|
|
func (b *BunDeleteQuery) Table(table string) common.DeleteQuery {
|
|
b.query = b.query.Table(table)
|
|
b.schema, b.tableName = parseTableName(table, b.driverName)
|
|
if b.entity == "" {
|
|
b.entity = cleanMetricIdentifier(b.tableName)
|
|
}
|
|
return b
|
|
}
|
|
|
|
func (b *BunDeleteQuery) Where(query string, args ...interface{}) common.DeleteQuery {
|
|
b.query = b.query.Where(query, args...)
|
|
return b
|
|
}
|
|
|
|
func (b *BunDeleteQuery) Exec(ctx context.Context) (res common.Result, err error) {
|
|
defer func() {
|
|
if r := recover(); r != nil {
|
|
err = logger.HandlePanic("BunDeleteQuery.Exec", r)
|
|
}
|
|
}()
|
|
startedAt := time.Now()
|
|
result, err := b.query.Exec(ctx)
|
|
if err != nil {
|
|
// Log SQL string for debugging
|
|
sqlStr := b.query.String()
|
|
logger.Error("BunDeleteQuery.Exec failed. SQL: %s. Error: %v", sqlStr, err)
|
|
err = common.WrapSQLError(err, sqlStr)
|
|
}
|
|
recordQueryMetrics(ctx, b.metricsEnabled, "DELETE", b.schema, b.entity, b.tableName, startedAt, err)
|
|
return &BunResult{result: result}, err
|
|
}
|
|
|
|
// BunResult implements Result for Bun
|
|
type BunResult struct {
|
|
result sql.Result
|
|
}
|
|
|
|
func (b *BunResult) RowsAffected() int64 {
|
|
if b.result == nil {
|
|
return 0
|
|
}
|
|
rows, _ := b.result.RowsAffected()
|
|
return rows
|
|
}
|
|
|
|
func (b *BunResult) LastInsertId() (int64, error) {
|
|
if b.result == nil {
|
|
return 0, nil
|
|
}
|
|
return b.result.LastInsertId()
|
|
}
|
|
|
|
// BunTxAdapter wraps a Bun transaction to implement the Database interface
|
|
type BunTxAdapter struct {
|
|
tx bun.Tx
|
|
driverName string
|
|
metricsEnabled bool
|
|
}
|
|
|
|
func (b *BunTxAdapter) NewSelect() common.SelectQuery {
|
|
return &BunSelectQuery{
|
|
query: b.tx.NewSelect(),
|
|
db: b.tx,
|
|
driverName: b.driverName,
|
|
metricsEnabled: b.metricsEnabled,
|
|
}
|
|
}
|
|
|
|
func (b *BunTxAdapter) NewInsert() common.InsertQuery {
|
|
return &BunInsertQuery{query: b.tx.NewInsert(), driverName: b.driverName, metricsEnabled: b.metricsEnabled}
|
|
}
|
|
|
|
func (b *BunTxAdapter) NewUpdate() common.UpdateQuery {
|
|
return &BunUpdateQuery{query: b.tx.NewUpdate(), driverName: b.driverName, metricsEnabled: b.metricsEnabled}
|
|
}
|
|
|
|
func (b *BunTxAdapter) NewDelete() common.DeleteQuery {
|
|
return &BunDeleteQuery{query: b.tx.NewDelete(), driverName: b.driverName, metricsEnabled: b.metricsEnabled}
|
|
}
|
|
|
|
func (b *BunTxAdapter) Exec(ctx context.Context, query string, args ...interface{}) (common.Result, error) {
|
|
startedAt := time.Now()
|
|
operation, schema, entity, table := metricTargetFromRawQuery(query, b.driverName)
|
|
result, err := b.tx.ExecContext(ctx, query, args...)
|
|
recordQueryMetrics(ctx, b.metricsEnabled, operation, schema, entity, table, startedAt, err)
|
|
return &BunResult{result: result}, err
|
|
}
|
|
|
|
func (b *BunTxAdapter) Query(ctx context.Context, dest interface{}, query string, args ...interface{}) error {
|
|
startedAt := time.Now()
|
|
operation, schema, entity, table := metricTargetFromRawQuery(query, b.driverName)
|
|
err := b.tx.NewRaw(query, args...).Scan(ctx, dest)
|
|
recordQueryMetrics(ctx, b.metricsEnabled, operation, schema, entity, table, startedAt, err)
|
|
return err
|
|
}
|
|
|
|
func (b *BunTxAdapter) BeginTx(ctx context.Context) (common.Database, error) {
|
|
return nil, fmt.Errorf("nested transactions not supported")
|
|
}
|
|
|
|
func (b *BunTxAdapter) CommitTx(ctx context.Context) error {
|
|
return b.tx.Commit()
|
|
}
|
|
|
|
func (b *BunTxAdapter) RollbackTx(ctx context.Context) error {
|
|
return b.tx.Rollback()
|
|
}
|
|
|
|
func (b *BunTxAdapter) RunInTransaction(ctx context.Context, fn func(common.Database) error) error {
|
|
return fn(b) // Already in transaction
|
|
}
|
|
|
|
func (b *BunTxAdapter) GetUnderlyingDB() interface{} {
|
|
return b.tx
|
|
}
|
|
|
|
func (b *BunTxAdapter) DriverName() string {
|
|
return b.driverName
|
|
}
|