mirror of
https://github.com/bitechdev/ResolveSpec.git
synced 2026-05-18 17:55:16 +00:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
52752d9c8b | ||
|
|
baca5ad29e | ||
|
|
53ab22ce02 | ||
|
|
09a3dc92b9 |
@@ -289,19 +289,20 @@ func (b *BunAdapter) DriverName() string {
|
||||
|
||||
// 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
|
||||
customPreloads map[string][]func(common.SelectQuery) common.SelectQuery // Relations to load with custom implementation
|
||||
metricsEnabled bool
|
||||
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 {
|
||||
@@ -346,12 +347,14 @@ func (b *BunSelectQuery) ColumnExpr(query string, args ...interface{}) common.Se
|
||||
}
|
||||
|
||||
func (b *BunSelectQuery) Where(query string, args ...interface{}) common.SelectQuery {
|
||||
// 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.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 != "" {
|
||||
// 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...)
|
||||
@@ -487,6 +490,30 @@ func normalizeTableAlias(query, expectedAlias, tableName string) string {
|
||||
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":
|
||||
@@ -676,8 +703,20 @@ func (b *BunSelectQuery) PreloadRelation(relation string, apply ...func(common.S
|
||||
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)
|
||||
|
||||
|
||||
@@ -174,7 +174,7 @@ func (p *NestedCUDProcessor) ProcessNestedCUD(
|
||||
// Process child relations first (for referential integrity)
|
||||
if err := p.processChildRelations(ctx, "delete", data[pkName], relationFields, result.RelationData, modelType, parentIDs); err != nil {
|
||||
logger.Error("Failed to process child relations before delete: table=%s, id=%v, relations=%+v, error=%v", tableName, data[pkName], relationFields, err)
|
||||
return nil, fmt.Errorf("failed to process child relations before delete: %w", err)
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
rows, err := p.processDelete(ctx, tableName, data[pkName])
|
||||
|
||||
@@ -9,29 +9,29 @@ import (
|
||||
"github.com/bitechdev/ResolveSpec/pkg/common"
|
||||
)
|
||||
|
||||
// Test that normalizeResultArray returns empty array when no records found without ID
|
||||
// Test that normalizeResultArray returns empty object when no records found (single-record mode)
|
||||
func TestNormalizeResultArray_EmptyArrayWhenNoID(t *testing.T) {
|
||||
handler := &Handler{}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
input interface{}
|
||||
shouldBeEmptyArr bool
|
||||
name string
|
||||
input interface{}
|
||||
shouldBeEmptyObj bool
|
||||
}{
|
||||
{
|
||||
name: "nil should return empty array",
|
||||
input: nil,
|
||||
shouldBeEmptyArr: true,
|
||||
name: "nil should return empty object",
|
||||
input: nil,
|
||||
shouldBeEmptyObj: true,
|
||||
},
|
||||
{
|
||||
name: "empty slice should return empty array",
|
||||
input: []*EmptyTestModel{},
|
||||
shouldBeEmptyArr: true,
|
||||
name: "empty slice should return empty object",
|
||||
input: []*EmptyTestModel{},
|
||||
shouldBeEmptyObj: true,
|
||||
},
|
||||
{
|
||||
name: "single element should return the element",
|
||||
input: []*EmptyTestModel{{ID: 1, Name: "test"}},
|
||||
shouldBeEmptyArr: false,
|
||||
name: "single element should return the element",
|
||||
input: []*EmptyTestModel{{ID: 1, Name: "test"}},
|
||||
shouldBeEmptyObj: false,
|
||||
},
|
||||
{
|
||||
name: "multiple elements should return the slice",
|
||||
@@ -39,7 +39,7 @@ func TestNormalizeResultArray_EmptyArrayWhenNoID(t *testing.T) {
|
||||
{ID: 1, Name: "test1"},
|
||||
{ID: 2, Name: "test2"},
|
||||
},
|
||||
shouldBeEmptyArr: false,
|
||||
shouldBeEmptyObj: false,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -47,25 +47,25 @@ func TestNormalizeResultArray_EmptyArrayWhenNoID(t *testing.T) {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := handler.normalizeResultArray(tt.input)
|
||||
|
||||
// For cases that should return empty array
|
||||
if tt.shouldBeEmptyArr {
|
||||
emptyArr, ok := result.([]interface{})
|
||||
// For cases that should return empty object
|
||||
if tt.shouldBeEmptyObj {
|
||||
emptyObj, ok := result.(map[string]interface{})
|
||||
if !ok {
|
||||
t.Errorf("Expected empty array []interface{}{}, got %T: %v", result, result)
|
||||
t.Errorf("Expected empty object map[string]interface{}{}, got %T: %v", result, result)
|
||||
return
|
||||
}
|
||||
if len(emptyArr) != 0 {
|
||||
t.Errorf("Expected empty array with length 0, got length %d", len(emptyArr))
|
||||
if len(emptyObj) != 0 {
|
||||
t.Errorf("Expected empty object with length 0, got length %d", len(emptyObj))
|
||||
}
|
||||
|
||||
// Verify it serializes to [] and not null
|
||||
// Verify it serializes to {} and not null
|
||||
jsonBytes, err := json.Marshal(result)
|
||||
if err != nil {
|
||||
t.Errorf("Failed to marshal result: %v", err)
|
||||
return
|
||||
}
|
||||
if string(jsonBytes) != "[]" {
|
||||
t.Errorf("Expected JSON '[]', got '%s'", string(jsonBytes))
|
||||
if string(jsonBytes) != "{}" {
|
||||
t.Errorf("Expected JSON '{}', got '%s'", string(jsonBytes))
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -138,12 +138,12 @@ func TestSendResponseWithOptions_NoDataFoundHeader(t *testing.T) {
|
||||
t.Errorf("Expected X-No-Data-Found header to be 'true', got '%s'", mockWriter.headers["X-No-Data-Found"])
|
||||
}
|
||||
|
||||
// Check status code is 200
|
||||
if mockWriter.statusCode != 200 {
|
||||
t.Errorf("Expected status code 200, got %d", mockWriter.statusCode)
|
||||
// Check status code is 204 when no records found
|
||||
if mockWriter.statusCode != 204 {
|
||||
t.Errorf("Expected status code 204, got %d", mockWriter.statusCode)
|
||||
}
|
||||
|
||||
// Verify the body is an empty array
|
||||
// Verify the body is an empty array (list request, SingleRecordAsObject not set)
|
||||
if mockWriter.body == nil {
|
||||
t.Error("Expected body to be set, got nil")
|
||||
} else {
|
||||
|
||||
@@ -2502,14 +2502,16 @@ func (h *Handler) sendResponseWithOptions(w common.ResponseWriter, data interfac
|
||||
w.SetHeader("X-No-Data-Found", "true")
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
|
||||
// Normalize single-record arrays to objects if requested
|
||||
if options != nil && options.SingleRecordAsObject {
|
||||
data = h.normalizeResultArray(data)
|
||||
}
|
||||
|
||||
// Return data as-is without wrapping in common.Response
|
||||
if dataLen == 0 {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
} else {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
if err := w.WriteJSON(data); err != nil {
|
||||
logger.Error("Failed to write JSON response: %v", err)
|
||||
@@ -2520,7 +2522,7 @@ func (h *Handler) sendResponseWithOptions(w common.ResponseWriter, data interfac
|
||||
// Returns the single element if data is a slice/array with exactly one element, otherwise returns data unchanged
|
||||
func (h *Handler) normalizeResultArray(data interface{}) interface{} {
|
||||
if data == nil {
|
||||
return []interface{}{}
|
||||
return map[string]interface{}{}
|
||||
}
|
||||
|
||||
// Use reflection to check if data is a slice or array
|
||||
@@ -2535,15 +2537,15 @@ func (h *Handler) normalizeResultArray(data interface{}) interface{} {
|
||||
// Return the single element
|
||||
return dataValue.Index(0).Interface()
|
||||
} else if dataValue.Len() == 0 {
|
||||
// Keep empty array as empty array, don't convert to empty object
|
||||
return []interface{}{}
|
||||
// Single-record request with no result → empty object
|
||||
return map[string]interface{}{}
|
||||
}
|
||||
}
|
||||
|
||||
if dataValue.Kind() == reflect.String {
|
||||
str := dataValue.String()
|
||||
if str == "" || str == "null" {
|
||||
return []interface{}{}
|
||||
return map[string]interface{}{}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -2552,9 +2554,6 @@ func (h *Handler) normalizeResultArray(data interface{}) interface{} {
|
||||
|
||||
// sendFormattedResponse sends response with formatting options
|
||||
func (h *Handler) sendFormattedResponse(w common.ResponseWriter, data interface{}, metadata *common.Metadata, options ExtendedRequestOptions) {
|
||||
// Normalize single-record arrays to objects if requested
|
||||
httpStatus := http.StatusOK
|
||||
|
||||
// Handle nil data - convert to empty array
|
||||
if data == nil {
|
||||
data = []interface{}{}
|
||||
@@ -2566,8 +2565,10 @@ func (h *Handler) sendFormattedResponse(w common.ResponseWriter, data interface{
|
||||
dataLen := reflection.Len(data)
|
||||
|
||||
// Add X-No-Data-Found header when no records were found
|
||||
httpStatus := http.StatusOK
|
||||
if dataLen == 0 {
|
||||
w.SetHeader("X-No-Data-Found", "true")
|
||||
httpStatus = http.StatusNoContent
|
||||
}
|
||||
|
||||
// Apply normalization after header is set
|
||||
|
||||
Reference in New Issue
Block a user