Compare commits

..

4 Commits

Author SHA1 Message Date
Hein
b157379ff8 fix(restheadspec): return 200 OK with empty body instead of 204 on zero results
Frontend clients are sensitive to 204 No Content responses; always return 200
with an empty array/object and rely on X-No-Data-Found header to signal absence
of records.

Also treat "change" as an alias for "update" in recursive CUD processing.
2026-05-19 08:56:11 +02:00
Hein
52752d9c8b fix(bun): adjust field alignment in BunSelectQuery struct
Some checks failed
Build , Vet Test, and Lint / Run Vet Tests (1.24.x) (push) Successful in -33m53s
Build , Vet Test, and Lint / Run Vet Tests (1.23.x) (push) Successful in -33m29s
Build , Vet Test, and Lint / Lint Code (push) Failing after -33m33s
Build , Vet Test, and Lint / Build (push) Successful in -33m41s
Tests / Unit Tests (push) Successful in -34m28s
Tests / Integration Tests (push) Failing after -34m36s
2026-05-18 17:12:32 +02:00
Hein
baca5ad29e fix(bun): add relation alias handling for separate-query preloads
* implement preloadRelationAlias to rewrite WHERE conditions
* update Where method to handle relation alias in queries
2026-05-18 17:12:21 +02:00
Hein
53ab22ce02 fix(nestedCUD): handle error in processChildRelations gracefully 2026-05-18 16:14:24 +02:00
4 changed files with 69 additions and 34 deletions

View File

@@ -289,19 +289,20 @@ func (b *BunAdapter) DriverName() string {
// BunSelectQuery implements SelectQuery for Bun // BunSelectQuery implements SelectQuery for Bun
type BunSelectQuery struct { type BunSelectQuery struct {
query *bun.SelectQuery query *bun.SelectQuery
db bun.IDB // Store DB connection for count queries db bun.IDB // Store DB connection for count queries
hasModel bool // Track if Model() was called hasModel bool // Track if Model() was called
schema string // Separated schema name schema string // Separated schema name
tableName string // Just the table name, without schema tableName string // Just the table name, without schema
entity string entity string
tableAlias string tableAlias string
driverName string // Database driver name (postgres, sqlite, mssql) driverName string // Database driver name (postgres, sqlite, mssql)
inJoinContext bool // Track if we're in a JOIN relation context inJoinContext bool // Track if we're in a JOIN relation context
joinTableAlias string // Alias to use for JOIN conditions joinTableAlias string // Alias to use for JOIN conditions
skipAutoDetect bool // Skip auto-detection to prevent circular calls skipAutoDetect bool // Skip auto-detection to prevent circular calls
customPreloads map[string][]func(common.SelectQuery) common.SelectQuery // Relations to load with custom implementation preloadRelationAlias string // Relation alias used in separate-query preloads (e.g. "tprp" for relation "TPRP")
metricsEnabled bool customPreloads map[string][]func(common.SelectQuery) common.SelectQuery // Relations to load with custom implementation
metricsEnabled bool
} }
func (b *BunSelectQuery) Model(model interface{}) common.SelectQuery { 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 { 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 != "" { if b.inJoinContext && b.joinTableAlias != "" {
query = addTablePrefix(query, 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 != "" { } 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) query = normalizeTableAlias(query, b.tableAlias, b.tableName)
} }
b.query = b.query.Where(query, args...) b.query = b.query.Where(query, args...)
@@ -487,6 +490,30 @@ func normalizeTableAlias(query, expectedAlias, tableName string) string {
return modified 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 { func isJoinKeyword(word string) bool {
switch strings.ToUpper(word) { switch strings.ToUpper(word) {
case "JOIN", "INNER", "LEFT", "RIGHT", "FULL", "OUTER", "CROSS": 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() wrapper.tableAlias = provider.TableAlias()
logger.Debug("Preload relation '%s' using table alias: %s", relation, wrapper.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) // Start with the interface value (not pointer)
current := common.SelectQuery(wrapper) current := common.SelectQuery(wrapper)

View File

@@ -141,7 +141,7 @@ func (p *NestedCUDProcessor) ProcessNestedCUD(
logger.Debug("Skipping insert for %s - no data columns besides _request", tableName) logger.Debug("Skipping insert for %s - no data columns besides _request", tableName)
} }
case "update": case "update", "change":
// Only perform update if we have data to update // Only perform update if we have data to update
if hasData { if hasData {
rows, err := p.processUpdate(ctx, regularData, tableName, data[pkName]) rows, err := p.processUpdate(ctx, regularData, tableName, data[pkName])
@@ -174,7 +174,7 @@ func (p *NestedCUDProcessor) ProcessNestedCUD(
// Process child relations first (for referential integrity) // Process child relations first (for referential integrity)
if err := p.processChildRelations(ctx, "delete", data[pkName], relationFields, result.RelationData, modelType, parentIDs); err != nil { 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) 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]) rows, err := p.processDelete(ctx, tableName, data[pkName])

View File

@@ -14,23 +14,23 @@ func TestNormalizeResultArray_EmptyArrayWhenNoID(t *testing.T) {
handler := &Handler{} handler := &Handler{}
tests := []struct { tests := []struct {
name string name string
input interface{} input interface{}
shouldBeEmptyObj bool shouldBeEmptyObj bool
}{ }{
{ {
name: "nil should return empty object", name: "nil should return empty object",
input: nil, input: nil,
shouldBeEmptyObj: true, shouldBeEmptyObj: true,
}, },
{ {
name: "empty slice should return empty object", name: "empty slice should return empty object",
input: []*EmptyTestModel{}, input: []*EmptyTestModel{},
shouldBeEmptyObj: true, shouldBeEmptyObj: true,
}, },
{ {
name: "single element should return the element", name: "single element should return the element",
input: []*EmptyTestModel{{ID: 1, Name: "test"}}, input: []*EmptyTestModel{{ID: 1, Name: "test"}},
shouldBeEmptyObj: false, shouldBeEmptyObj: false,
}, },
{ {
@@ -138,9 +138,9 @@ 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"]) t.Errorf("Expected X-No-Data-Found header to be 'true', got '%s'", mockWriter.headers["X-No-Data-Found"])
} }
// Check status code is 204 when no records found // Check status code is 200 even when no records found
if mockWriter.statusCode != 204 { if mockWriter.statusCode != 200 {
t.Errorf("Expected status code 204, got %d", mockWriter.statusCode) t.Errorf("Expected status code 200, got %d", mockWriter.statusCode)
} }
// Verify the body is an empty array (list request, SingleRecordAsObject not set) // Verify the body is an empty array (list request, SingleRecordAsObject not set)

View File

@@ -2507,11 +2507,7 @@ func (h *Handler) sendResponseWithOptions(w common.ResponseWriter, data interfac
data = h.normalizeResultArray(data) data = h.normalizeResultArray(data)
} }
if dataLen == 0 { w.WriteHeader(http.StatusOK)
w.WriteHeader(http.StatusNoContent)
} else {
w.WriteHeader(http.StatusOK)
}
if err := w.WriteJSON(data); err != nil { if err := w.WriteJSON(data); err != nil {
logger.Error("Failed to write JSON response: %v", err) logger.Error("Failed to write JSON response: %v", err)