Compare commits

...

11 Commits

Author SHA1 Message Date
Hein
cdfb7a67fd Added Single Record as Object feature 2025-11-19 13:58:52 +02:00
Hein
7f5b851669 Empty sort appended bug fix
Some checks failed
Tests / Build (push) Has been cancelled
Tests / Run Tests (1.23.x) (push) Has been cancelled
Tests / Run Tests (1.24.x) (push) Has been cancelled
Tests / Lint Code (push) Has been cancelled
2025-11-11 17:16:59 +02:00
Hein
f0e26b1c0d Fixed and refactored reflection.Len 2025-11-11 17:07:44 +02:00
Hein
1db1b924ef Proper handling of x-preload-col-where 2025-11-11 16:53:02 +02:00
Hein
d9cf23b1dc Fixed column expression bug 2025-11-11 16:39:06 +02:00
Hein
94f013c872 Preload fixes 2025-11-11 15:54:43 +02:00
Hein
c52fcff61d Preload fixes 2025-11-11 15:34:24 +02:00
Hein
ce106fa940 Updated documentation 2025-11-11 14:57:01 +02:00
Hein
37b4b75175 Fixed preload and id fields with GetPrimaryKeyName 2025-11-11 14:32:41 +02:00
Hein
0cef0f75d3 Fixed computed columns 2025-11-11 12:28:53 +02:00
Hein
006dc4a2b2 Using scan model method for better relation handling. e.g bun When querying has-many or many-to-many relationships, you should use Model instead of the dest parameter in Scan 2025-11-11 11:58:41 +02:00
16 changed files with 833 additions and 78 deletions

3
.gitignore vendored
View File

@@ -23,4 +23,5 @@ go.work.sum
# env file
.env
bin/
bin/
test.db

110
.golangci.bck.yml Normal file
View File

@@ -0,0 +1,110 @@
run:
timeout: 5m
tests: true
skip-dirs:
- vendor
- .github
linters:
enable:
- errcheck
- gosimple
- govet
- ineffassign
- staticcheck
- unused
- gofmt
- goimports
- misspell
- gocritic
- revive
- stylecheck
disable:
- typecheck # Can cause issues with generics in some cases
linters-settings:
errcheck:
check-type-assertions: false
check-blank: false
govet:
check-shadowing: false
gofmt:
simplify: true
goimports:
local-prefixes: github.com/bitechdev/ResolveSpec
gocritic:
enabled-checks:
- appendAssign
- assignOp
- boolExprSimplify
- builtinShadow
- captLocal
- caseOrder
- defaultCaseOrder
- dupArg
- dupBranchBody
- dupCase
- dupSubExpr
- elseif
- emptyFallthrough
- equalFold
- flagName
- ifElseChain
- indexAlloc
- initClause
- methodExprCall
- nilValReturn
- rangeExprCopy
- rangeValCopy
- regexpMust
- singleCaseSwitch
- sloppyLen
- stringXbytes
- switchTrue
- typeAssertChain
- typeSwitchVar
- underef
- unlabelStmt
- unnamedResult
- unnecessaryBlock
- weakCond
- yodaStyleExpr
revive:
rules:
- name: exported
disabled: true
- name: package-comments
disabled: true
issues:
exclude-use-default: false
max-issues-per-linter: 0
max-same-issues: 0
# Exclude some linters from running on tests files
exclude-rules:
- path: _test\.go
linters:
- errcheck
- dupl
- gosec
- gocritic
# Ignore "error return value not checked" for defer statements
- linters:
- errcheck
text: "Error return value of .((os\\.)?std(out|err)\\..*|.*Close|.*Flush|os\\.Remove(All)?|.*print(f|ln)?|os\\.(Un)?Setenv). is not checked"
# Ignore complexity in test files
- path: _test\.go
text: "cognitive complexity|cyclomatic complexity"
output:
format: colored-line-number
print-issued-lines: true
print-linter-name: true

58
.vscode/tasks.json vendored
View File

@@ -24,21 +24,63 @@
"type": "go",
"label": "go: test workspace",
"command": "test",
"options": {
"env": {
"CGO_ENABLED": "0"
},
"cwd": "${workspaceFolder}/bin",
"cwd": "${workspaceFolder}"
},
"args": [
"../..."
"-v",
"-race",
"-coverprofile=coverage.out",
"-covermode=atomic",
"./..."
],
"problemMatcher": [
"$go"
],
"group": "build",
"group": {
"kind": "test",
"isDefault": true
},
"presentation": {
"reveal": "always",
"panel": "new"
}
},
{
"type": "shell",
"label": "go: vet workspace",
"command": "go vet ./...",
"options": {
"cwd": "${workspaceFolder}"
},
"problemMatcher": [
"$go"
],
"group": "test"
},
{
"type": "shell",
"label": "go: lint workspace",
"command": "golangci-lint run --timeout=5m",
"options": {
"cwd": "${workspaceFolder}"
},
"problemMatcher": [],
"group": "test"
},
{
"type": "shell",
"label": "go: full test suite",
"dependsOrder": "sequence",
"dependsOn": [
"go: vet workspace",
"go: test workspace"
],
"problemMatcher": [],
"group": {
"kind": "test",
"isDefault": false
}
}
]
}

164
README.md
View File

@@ -31,9 +31,12 @@ Both share the same core architecture and provide dynamic data querying, relatio
- [RestHeadSpec: Header-Based API](#restheadspec-header-based-api-1)
- [Lifecycle Hooks](#lifecycle-hooks)
- [Cursor Pagination](#cursor-pagination)
- [Response Formats](#response-formats)
- [Single Record as Object](#single-record-as-object-default-behavior)
- [Example Usage](#example-usage)
- [Recursive CRUD Operations](#recursive-crud-operations-)
- [Testing](#testing)
- [What's New in v2.0](#whats-new-in-v20)
- [What's New](#whats-new)
## Features
@@ -45,6 +48,7 @@ Both share the same core architecture and provide dynamic data querying, relatio
- **Pagination**: Built-in limit/offset and cursor-based pagination
- **Computed Columns**: Define virtual columns for complex calculations
- **Custom Operators**: Add custom SQL conditions when needed
- **🆕 Recursive CRUD Handler**: Automatically handle nested object graphs with foreign key resolution and per-record operation control via `_request` field
### Architecture (v2.0+)
- **🆕 Database Agnostic**: Works with GORM, Bun, or any database layer through adapters
@@ -57,6 +61,7 @@ Both share the same core architecture and provide dynamic data querying, relatio
- **🆕 Lifecycle Hooks**: Before/after hooks for create, read, update, and delete operations
- **🆕 Cursor Pagination**: Efficient cursor-based pagination with complex sort support
- **🆕 Multiple Response Formats**: Simple, detailed, and Syncfusion-compatible formats
- **🆕 Single Record as Object**: Automatically normalize single-element arrays to objects (enabled by default)
- **🆕 Advanced Filtering**: Field filters, search operators, AND/OR logic, and custom SQL
- **🆕 Base64 Encoding**: Support for base64-encoded header values
@@ -161,6 +166,7 @@ restheadspec.SetupMuxRoutes(router, handler)
| `X-Limit` | Limit results | `50` |
| `X-Offset` | Offset for pagination | `100` |
| `X-Clean-JSON` | Remove null/empty fields | `true` |
| `X-Single-Record-As-Object` | Return single records as objects (default: `true`) | `false` |
**Available Operators**: `eq`, `neq`, `gt`, `gte`, `lt`, `lte`, `contains`, `startswith`, `endswith`, `between`, `betweeninclusive`, `in`, `empty`, `notempty`
@@ -301,6 +307,55 @@ RestHeadSpec supports multiple response formats:
}
```
### Single Record as Object (Default Behavior)
By default, RestHeadSpec automatically converts single-element arrays into objects for cleaner API responses. This provides a better developer experience when fetching individual records.
**Default behavior (enabled)**:
```http
GET /public/users/123
```
```json
{
"success": true,
"data": { "id": 123, "name": "John", "email": "john@example.com" }
}
```
Instead of:
```json
{
"success": true,
"data": [{ "id": 123, "name": "John", "email": "john@example.com" }]
}
```
**To disable** (force arrays for consistency):
```http
GET /public/users/123
X-Single-Record-As-Object: false
```
```json
{
"success": true,
"data": [{ "id": 123, "name": "John", "email": "john@example.com" }]
}
```
**How it works**:
- When a query returns exactly **one record**, it's returned as an object
- When a query returns **multiple records**, they're returned as an array
- Set `X-Single-Record-As-Object: false` to always receive arrays
- Works with all response formats (simple, detail, syncfusion)
- Applies to both read operations and create/update returning clauses
**Benefits**:
- Cleaner API responses for single-record queries
- No need to unwrap single-element arrays on the client side
- Better TypeScript/type inference support
- Consistent with common REST API patterns
- Backward compatible via header opt-out
## Example Usage
### Reading Data with Related Entities
@@ -342,6 +397,92 @@ POST /core/users
}
```
### Recursive CRUD Operations (🆕)
ResolveSpec now supports automatic handling of nested object graphs with intelligent foreign key resolution. This allows you to create, update, or delete entire object hierarchies in a single request.
#### Creating Nested Objects
```json
POST /core/users
{
"operation": "create",
"data": {
"name": "John Doe",
"email": "john@example.com",
"posts": [
{
"title": "My First Post",
"content": "Hello World",
"tags": [
{"name": "tech"},
{"name": "programming"}
]
},
{
"title": "Second Post",
"content": "More content"
}
],
"profile": {
"bio": "Software Developer",
"website": "https://example.com"
}
}
}
```
#### Per-Record Operation Control with `_request`
Control individual operations for each nested record using the special `_request` field:
```json
POST /core/users/123
{
"operation": "update",
"data": {
"name": "John Updated",
"posts": [
{
"_request": "insert",
"title": "New Post",
"content": "Fresh content"
},
{
"_request": "update",
"id": 456,
"title": "Updated Post Title"
},
{
"_request": "delete",
"id": 789
}
]
}
}
```
**Supported `_request` values**:
- `insert` - Create a new related record
- `update` - Update an existing related record
- `delete` - Delete a related record
- `upsert` - Create if doesn't exist, update if exists
#### How It Works
1. **Automatic Foreign Key Resolution**: Parent IDs are automatically propagated to child records
2. **Recursive Processing**: Handles nested relationships at any depth
3. **Transaction Safety**: All operations execute within database transactions
4. **Relationship Detection**: Automatically detects belongsTo, hasMany, hasOne, and many2many relationships
5. **Flexible Operations**: Mix create, update, and delete operations in a single request
#### Benefits
- Reduce API round trips for complex object graphs
- Maintain referential integrity automatically
- Simplify client-side code
- Atomic operations with automatic rollback on errors
## Installation
```bash
@@ -811,12 +952,32 @@ This project is licensed under the MIT License - see the [LICENSE](LICENSE) file
### v2.1 (Latest)
**Recursive CRUD Handler (🆕 Nov 11, 2025)**:
- **Nested Object Graphs**: Automatically handle complex object hierarchies with parent-child relationships
- **Foreign Key Resolution**: Automatic propagation of parent IDs to child records
- **Per-Record Operations**: Control create/update/delete operations per record via `_request` field
- **Transaction Safety**: All nested operations execute atomically within database transactions
- **Relationship Detection**: Automatic detection of belongsTo, hasMany, hasOne, and many2many relationships
- **Deep Nesting Support**: Handle relationships at any depth level
- **Mixed Operations**: Combine insert, update, and delete operations in a single request
**Primary Key Improvements (Nov 11, 2025)**:
- **GetPrimaryKeyName**: Enhanced primary key detection for better preload and ID field handling
- **Better GORM/Bun Support**: Improved compatibility with both ORMs for primary key operations
- **Computed Column Support**: Fixed computed columns functionality across handlers
**Database Adapter Enhancements (Nov 11, 2025)**:
- **Bun ORM Relations**: Using Scan model method for better has-many and many-to-many relationship handling
- **Model Method Support**: Enhanced query building with proper model registration
- **Improved Type Safety**: Better handling of relationship queries with type-aware scanning
**RestHeadSpec - Header-Based REST API**:
- **Header-Based Querying**: All query options via HTTP headers instead of request body
- **Lifecycle Hooks**: Before/after hooks for create, read, update, delete operations
- **Cursor Pagination**: Efficient cursor-based pagination with complex sorting
- **Advanced Filtering**: Field filters, search operators, AND/OR logic
- **Multiple Response Formats**: Simple, detailed, and Syncfusion-compatible responses
- **Single Record as Object**: Automatically return single-element arrays as objects (default, toggleable via header)
- **Base64 Support**: Base64-encoded header values for complex queries
- **Type-Aware Filtering**: Automatic type detection and conversion for filters
@@ -826,6 +987,7 @@ This project is licensed under the MIT License - see the [LICENSE](LICENSE) file
- Improved reflection safety
- Fixed COUNT query issues with table aliasing
- Better pointer handling throughout the codebase
- **Comprehensive Test Coverage**: Added standalone CRUD tests for both ResolveSpec and RestHeadSpec
### v2.0

View File

@@ -119,6 +119,12 @@ func (b *BunSelectQuery) Column(columns ...string) common.SelectQuery {
return b
}
func (b *BunSelectQuery) ColumnExpr(query string, args ...interface{}) common.SelectQuery {
b.query = b.query.ColumnExpr(query, args)
return b
}
func (b *BunSelectQuery) Where(query string, args ...interface{}) common.SelectQuery {
b.query = b.query.Where(query, args...)
return b
@@ -209,6 +215,40 @@ func (b *BunSelectQuery) Preload(relation string, conditions ...interface{}) com
return b
}
func (b *BunSelectQuery) PreloadRelation(relation string, apply ...func(common.SelectQuery) common.SelectQuery) common.SelectQuery {
b.query = b.query.Relation(relation, func(sq *bun.SelectQuery) *bun.SelectQuery {
if len(apply) == 0 {
return sq
}
// Wrap the incoming *bun.SelectQuery in our adapter
wrapper := &BunSelectQuery{
query: sq,
db: b.db,
}
// Start with the interface value (not pointer)
current := common.SelectQuery(wrapper)
// Apply each function in sequence
for _, fn := range apply {
if fn != nil {
// Pass &current (pointer to interface variable), fn modifies and returns new interface value
modified := fn(current)
current = modified
}
}
// Extract the final *bun.SelectQuery
if finalBun, ok := current.(*BunSelectQuery); ok {
return finalBun.query
}
return sq // fallback
})
return b
}
func (b *BunSelectQuery) Order(order string) common.SelectQuery {
b.query = b.query.Order(order)
return b
@@ -238,6 +278,10 @@ func (b *BunSelectQuery) Scan(ctx context.Context, dest interface{}) error {
return b.query.Scan(ctx, dest)
}
func (b *BunSelectQuery) ScanModel(ctx context.Context) error {
return b.query.Scan(ctx)
}
func (b *BunSelectQuery) Count(ctx context.Context) (int, error) {
// If Model() was set, use bun's native Count() which works properly
if b.hasModel {

View File

@@ -105,6 +105,11 @@ func (g *GormSelectQuery) Column(columns ...string) common.SelectQuery {
return g
}
func (g *GormSelectQuery) ColumnExpr(query string, args ...interface{}) common.SelectQuery {
g.db = g.db.Select(query, args...)
return g
}
func (g *GormSelectQuery) Where(query string, args ...interface{}) common.SelectQuery {
g.db = g.db.Where(query, args...)
return g
@@ -192,6 +197,36 @@ func (g *GormSelectQuery) Preload(relation string, conditions ...interface{}) co
return g
}
func (g *GormSelectQuery) PreloadRelation(relation string, apply ...func(common.SelectQuery) common.SelectQuery) common.SelectQuery {
g.db = g.db.Preload(relation, func(db *gorm.DB) *gorm.DB {
if len(apply) == 0 {
return db
}
wrapper := &GormSelectQuery{
db: db,
}
current := common.SelectQuery(wrapper)
for _, fn := range apply {
if fn != nil {
modified := fn(current)
current = modified
}
}
if finalBun, ok := current.(*GormSelectQuery); ok {
return finalBun.db
}
return db // fallback
})
return g
}
func (g *GormSelectQuery) Order(order string) common.SelectQuery {
g.db = g.db.Order(order)
return g
@@ -221,6 +256,13 @@ func (g *GormSelectQuery) Scan(ctx context.Context, dest interface{}) error {
return g.db.WithContext(ctx).Find(dest).Error
}
func (g *GormSelectQuery) ScanModel(ctx context.Context) error {
if g.db.Statement.Model == nil {
return fmt.Errorf("ScanModel requires Model() to be set before scanning")
}
return g.db.WithContext(ctx).Find(g.db.Statement.Model).Error
}
func (g *GormSelectQuery) Count(ctx context.Context) (int, error) {
var count int64
err := g.db.WithContext(ctx).Count(&count).Error

View File

@@ -26,11 +26,13 @@ type SelectQuery interface {
Model(model interface{}) SelectQuery
Table(table string) SelectQuery
Column(columns ...string) SelectQuery
ColumnExpr(query string, args ...interface{}) SelectQuery
Where(query string, args ...interface{}) SelectQuery
WhereOr(query string, args ...interface{}) SelectQuery
Join(query string, args ...interface{}) SelectQuery
LeftJoin(query string, args ...interface{}) SelectQuery
Preload(relation string, conditions ...interface{}) SelectQuery
PreloadRelation(relation string, apply ...func(SelectQuery) SelectQuery) SelectQuery
Order(order string) SelectQuery
Limit(n int) SelectQuery
Offset(n int) SelectQuery
@@ -39,6 +41,7 @@ type SelectQuery interface {
// Execution methods
Scan(ctx context.Context, dest interface{}) error
ScanModel(ctx context.Context) error
Count(ctx context.Context) (int, error)
Exists(ctx context.Context) (bool, error)
}

View File

@@ -7,6 +7,7 @@ import (
"strings"
"github.com/bitechdev/ResolveSpec/pkg/logger"
"github.com/bitechdev/ResolveSpec/pkg/reflection"
)
// CRUDRequestProvider interface for models that provide CRUD request strings
@@ -248,7 +249,7 @@ func (p *NestedCUDProcessor) processUpdate(
logger.Debug("Updating %s with ID %v, data: %+v", tableName, id, data)
query := p.db.NewUpdate().Table(tableName).SetMap(data).Where("id = ?", id)
query := p.db.NewUpdate().Table(tableName).SetMap(data).Where(fmt.Sprintf("%s = ?", QuoteIdent(reflection.GetPrimaryKeyName(tableName))), id)
result, err := query.Exec(ctx)
if err != nil {
@@ -268,7 +269,7 @@ func (p *NestedCUDProcessor) processDelete(ctx context.Context, tableName string
logger.Debug("Deleting from %s with ID %v", tableName, id)
query := p.db.NewDelete().Table(tableName).Where("id = ?", id)
query := p.db.NewDelete().Table(tableName).Where(fmt.Sprintf("%s = ?", QuoteIdent(reflection.GetPrimaryKeyName(tableName))), id)
result, err := query.Exec(ctx)
if err != nil {

View File

@@ -35,7 +35,9 @@ type PreloadOption struct {
Relation string `json:"relation"`
Columns []string `json:"columns"`
OmitColumns []string `json:"omit_columns"`
Sort []SortOption `json:"sort"`
Filters []FilterOption `json:"filters"`
Where string `json:"where"`
Limit *int `json:"limit"`
Offset *int `json:"offset"`
Updatable *bool `json:"updateable"` // if true, the relation can be updated

View File

@@ -183,7 +183,8 @@ func (v *ColumnValidator) ValidateRequestOptions(options RequestOptions) error {
}
// Validate Preload columns (if specified)
for _, preload := range options.Preload {
for idx := range options.Preload {
preload := options.Preload[idx]
// Note: We don't validate the relation name itself, as it's a relationship
// Only validate columns if specified for the preload
if err := v.ValidateColumns(preload.Columns); err != nil {
@@ -239,7 +240,8 @@ func (v *ColumnValidator) FilterRequestOptions(options RequestOptions) RequestOp
// Filter Preload columns
validPreloads := make([]PreloadOption, 0, len(options.Preload))
for _, preload := range options.Preload {
for idx := range options.Preload {
preload := options.Preload[idx]
filteredPreload := preload
filteredPreload.Columns = v.FilterValidColumns(preload.Columns)
filteredPreload.OmitColumns = v.FilterValidColumns(preload.OmitColumns)
@@ -270,3 +272,11 @@ func (v *ColumnValidator) GetValidColumns() []string {
}
return columns
}
func QuoteIdent(qualifier string) string {
return `"` + strings.ReplaceAll(qualifier, `"`, `""`) + `"`
}
func QuoteLiteral(value string) string {
return `'` + strings.ReplaceAll(value, `'`, `''`) + `'`
}

View File

@@ -1,9 +1,15 @@
package common
package reflection
import "reflect"
func Len(v any) int {
val := reflect.ValueOf(v)
valKind := val.Kind()
if valKind == reflect.Ptr {
val = val.Elem()
}
switch val.Kind() {
case reflect.Slice, reflect.Array, reflect.Map, reflect.String, reflect.Chan:
return val.Len()

View File

@@ -4,15 +4,31 @@ import (
"reflect"
"strings"
"github.com/bitechdev/ResolveSpec/pkg/common"
"github.com/bitechdev/ResolveSpec/pkg/modelregistry"
)
type PrimaryKeyNameProvider interface {
GetIDName() string
}
// GetPrimaryKeyName extracts the primary key column name from a model
// It first checks if the model implements PrimaryKeyNameProvider (GetIDName method)
// Falls back to reflection to find bun:",pk" tag, then gorm:"primaryKey" tag
func GetPrimaryKeyName(model any) string {
if reflect.TypeOf(model) == nil {
return ""
}
// If we are given a string model name, look up the model
if reflect.TypeOf(model).Kind() == reflect.String {
name := model.(string)
m, err := modelregistry.GetModelByName(name)
if err == nil {
model = m
}
}
// Check if model implements PrimaryKeyNameProvider
if provider, ok := model.(common.PrimaryKeyNameProvider); ok {
if provider, ok := model.(PrimaryKeyNameProvider); ok {
return provider.GetIDName()
}
@@ -22,7 +38,11 @@ func GetPrimaryKeyName(model any) string {
}
// Fall back to GORM tag
return getPrimaryKeyFromReflection(model, "gorm")
if pkName := getPrimaryKeyFromReflection(model, "gorm"); pkName != "" {
return pkName
}
return ""
}
// GetModelColumns extracts all column names from a model using reflection

View File

@@ -11,6 +11,7 @@ import (
"github.com/bitechdev/ResolveSpec/pkg/common"
"github.com/bitechdev/ResolveSpec/pkg/logger"
"github.com/bitechdev/ResolveSpec/pkg/reflection"
)
// Handler handles API requests using database and model abstractions
@@ -196,6 +197,13 @@ func (h *Handler) handleRead(ctx context.Context, w common.ResponseWriter, id st
query = query.Column(options.Columns...)
}
if len(options.ComputedColumns) > 0 {
for _, cu := range options.ComputedColumns {
logger.Debug("Applying computed column: %s", cu.Name)
query = query.ColumnExpr(fmt.Sprintf("(%s) AS %s", cu.Expression, cu.Name))
}
}
// Apply preloading
if len(options.Preload) > 0 {
query = h.applyPreloads(model, query, options.Preload)
@@ -242,7 +250,8 @@ func (h *Handler) handleRead(ctx context.Context, w common.ResponseWriter, id st
logger.Debug("Querying single record with ID: %s", id)
// For single record, create a new pointer to the struct type
singleResult := reflect.New(modelType).Interface()
query = query.Where("id = ?", id)
query = query.Where(fmt.Sprintf("%s = ?", common.QuoteIdent(reflection.GetPrimaryKeyName(singleResult))), id)
if err := query.Scan(ctx, singleResult); err != nil {
logger.Error("Error querying record: %v", err)
h.sendError(w, http.StatusInternalServerError, "query_error", "Error executing query", err)
@@ -514,15 +523,15 @@ func (h *Handler) handleUpdate(ctx context.Context, w common.ResponseWriter, url
// Apply conditions
if urlID != "" {
logger.Debug("Updating by URL ID: %s", urlID)
query = query.Where("id = ?", urlID)
query = query.Where(fmt.Sprintf("%s = ?", common.QuoteIdent(reflection.GetPrimaryKeyName(model))), urlID)
} else if reqID != nil {
switch id := reqID.(type) {
case string:
logger.Debug("Updating by request ID: %s", id)
query = query.Where("id = ?", id)
query = query.Where(fmt.Sprintf("%s = ?", common.QuoteIdent(reflection.GetPrimaryKeyName(model))), id)
case []string:
logger.Debug("Updating by multiple IDs: %v", id)
query = query.Where("id IN (?)", id)
query = query.Where(fmt.Sprintf("%s IN (?)", common.QuoteIdent(reflection.GetPrimaryKeyName(model))), id)
}
}
@@ -586,7 +595,8 @@ func (h *Handler) handleUpdate(ctx context.Context, w common.ResponseWriter, url
err := h.db.RunInTransaction(ctx, func(tx common.Database) error {
for _, item := range updates {
if itemID, ok := item["id"]; ok {
txQuery := tx.NewUpdate().Table(tableName).SetMap(item).Where("id = ?", itemID)
txQuery := tx.NewUpdate().Table(tableName).SetMap(item).Where(fmt.Sprintf("%s = ?", common.QuoteIdent(reflection.GetPrimaryKeyName(model))), itemID)
if _, err := txQuery.Exec(ctx); err != nil {
return err
}
@@ -652,7 +662,8 @@ func (h *Handler) handleUpdate(ctx context.Context, w common.ResponseWriter, url
for _, item := range updates {
if itemMap, ok := item.(map[string]interface{}); ok {
if itemID, ok := itemMap["id"]; ok {
txQuery := tx.NewUpdate().Table(tableName).SetMap(itemMap).Where("id = ?", itemID)
txQuery := tx.NewUpdate().Table(tableName).SetMap(itemMap).Where(fmt.Sprintf("%s = ?", common.QuoteIdent(reflection.GetPrimaryKeyName(model))), itemID)
if _, err := txQuery.Exec(ctx); err != nil {
return err
}
@@ -688,6 +699,7 @@ func (h *Handler) handleDelete(ctx context.Context, w common.ResponseWriter, id
schema := GetSchema(ctx)
entity := GetEntity(ctx)
tableName := GetTableName(ctx)
model := GetModel(ctx)
logger.Info("Deleting records from %s.%s", schema, entity)
@@ -699,7 +711,8 @@ func (h *Handler) handleDelete(ctx context.Context, w common.ResponseWriter, id
logger.Info("Batch delete with %d IDs ([]string)", len(v))
err := h.db.RunInTransaction(ctx, func(tx common.Database) error {
for _, itemID := range v {
query := tx.NewDelete().Table(tableName).Where("id = ?", itemID)
query := tx.NewDelete().Table(tableName).Where(fmt.Sprintf("%s = ?", common.QuoteIdent(reflection.GetPrimaryKeyName(model))), itemID)
if _, err := query.Exec(ctx); err != nil {
return fmt.Errorf("failed to delete record %s: %w", itemID, err)
}
@@ -738,7 +751,7 @@ func (h *Handler) handleDelete(ctx context.Context, w common.ResponseWriter, id
continue // Skip items without ID
}
query := tx.NewDelete().Table(tableName).Where("id = ?", itemID)
query := tx.NewDelete().Table(tableName).Where(fmt.Sprintf("%s = ?", common.QuoteIdent(reflection.GetPrimaryKeyName(model))), itemID)
result, err := query.Exec(ctx)
if err != nil {
return fmt.Errorf("failed to delete record %v: %w", itemID, err)
@@ -763,7 +776,7 @@ func (h *Handler) handleDelete(ctx context.Context, w common.ResponseWriter, id
err := h.db.RunInTransaction(ctx, func(tx common.Database) error {
for _, item := range v {
if itemID, ok := item["id"]; ok && itemID != nil {
query := tx.NewDelete().Table(tableName).Where("id = ?", itemID)
query := tx.NewDelete().Table(tableName).Where(fmt.Sprintf("%s = ?", common.QuoteIdent(reflection.GetPrimaryKeyName(model))), itemID)
result, err := query.Exec(ctx)
if err != nil {
return fmt.Errorf("failed to delete record %v: %w", itemID, err)
@@ -797,7 +810,7 @@ func (h *Handler) handleDelete(ctx context.Context, w common.ResponseWriter, id
return
}
query := h.db.NewDelete().Table(tableName).Where("id = ?", id)
query := h.db.NewDelete().Table(tableName).Where(fmt.Sprintf("%s = ?", common.QuoteIdent(reflection.GetPrimaryKeyName(model))), id)
result, err := query.Exec(ctx)
if err != nil {
@@ -1106,7 +1119,8 @@ func (h *Handler) applyPreloads(model interface{}, query common.SelectQuery, pre
return query
}
for _, preload := range preloads {
for idx := range preloads {
preload := preloads[idx]
logger.Debug("Processing preload for relation: %s", preload.Relation)
relInfo := h.getRelationshipInfo(modelType, preload.Relation)
if relInfo == nil {
@@ -1121,7 +1135,75 @@ func (h *Handler) applyPreloads(model interface{}, query common.SelectQuery, pre
// For now, we'll preload without conditions
// TODO: Implement column selection and filtering for preloads
// This requires a more sophisticated approach with callbacks or query builders
query = query.Preload(relationFieldName)
// Apply preloading
logger.Debug("Applying preload: %s", relationFieldName)
query = query.PreloadRelation(relationFieldName, func(sq common.SelectQuery) common.SelectQuery {
if len(preload.OmitColumns) > 0 {
allCols := reflection.GetModelColumns(model)
// Remove omitted columns
preload.Columns = []string{}
for _, col := range allCols {
addCols := true
for _, omitCol := range preload.OmitColumns {
if col == omitCol {
addCols = false
break
}
}
if addCols {
preload.Columns = append(preload.Columns, col)
}
}
}
if len(preload.Columns) > 0 {
// Ensure foreign key is included in column selection for GORM to establish the relationship
columns := make([]string, len(preload.Columns))
copy(columns, preload.Columns)
// Add foreign key if not already present
if relInfo.foreignKey != "" {
// Convert struct field name (e.g., DepartmentID) to snake_case (e.g., department_id)
foreignKeyColumn := toSnakeCase(relInfo.foreignKey)
hasForeignKey := false
for _, col := range columns {
if col == foreignKeyColumn || col == relInfo.foreignKey {
hasForeignKey = true
break
}
}
if !hasForeignKey {
columns = append(columns, foreignKeyColumn)
}
}
sq = sq.Column(columns...)
}
if len(preload.Filters) > 0 {
for _, filter := range preload.Filters {
sq = h.applyFilter(sq, filter)
}
}
if len(preload.Sort) > 0 {
for _, sort := range preload.Sort {
sq = sq.Order(fmt.Sprintf("%s %s", sort.Column, sort.Direction))
}
}
if len(preload.Where) > 0 {
sq = sq.Where(preload.Where)
}
if preload.Limit != nil && *preload.Limit > 0 {
sq = sq.Limit(*preload.Limit)
}
return sq
})
logger.Debug("Applied Preload for relation: %s (field: %s)", preload.Relation, relationFieldName)
}
@@ -1179,3 +1261,28 @@ func (h *Handler) extractTagValue(tag, key string) string {
}
return ""
}
// toSnakeCase converts a PascalCase or camelCase string to snake_case
func toSnakeCase(s string) string {
var result strings.Builder
runes := []rune(s)
for i := 0; i < len(runes); i++ {
r := runes[i]
if i > 0 && r >= 'A' && r <= 'Z' {
// Check if previous character is lowercase or if next character is lowercase
prevIsLower := runes[i-1] >= 'a' && runes[i-1] <= 'z'
nextIsLower := i+1 < len(runes) && runes[i+1] >= 'a' && runes[i+1] <= 'z'
// Add underscore if this is the start of a new word
// (previous was lowercase OR this is followed by lowercase)
if prevIsLower || nextIsLower {
result.WriteByte('_')
}
}
result.WriteRune(r)
}
return strings.ToLower(result.String())
}

View File

@@ -253,24 +253,130 @@ func (h *Handler) handleRead(ctx context.Context, w common.ResponseWriter, id st
query = query.Table(tableName)
}
// Apply ComputedQL fields if any
if len(options.ComputedQL) > 0 {
for colName, colExpr := range options.ComputedQL {
logger.Debug("Applying computed column: %s", colName)
query = query.ColumnExpr(fmt.Sprintf("(%s) AS %s", colExpr, colName))
for colIndex := range options.Columns {
if options.Columns[colIndex] == colName {
// Remove the computed column from the selected columns to avoid duplication
options.Columns = append(options.Columns[:colIndex], options.Columns[colIndex+1:]...)
break
}
}
}
}
if len(options.ComputedColumns) > 0 {
for _, cu := range options.ComputedColumns {
logger.Debug("Applying computed column: %s", cu.Name)
query = query.ColumnExpr(fmt.Sprintf("(%s) AS %s", cu.Expression, cu.Name))
for colIndex := range options.Columns {
if options.Columns[colIndex] == cu.Name {
// Remove the computed column from the selected columns to avoid duplication
options.Columns = append(options.Columns[:colIndex], options.Columns[colIndex+1:]...)
break
}
}
}
}
// Apply column selection
if len(options.Columns) > 0 {
logger.Debug("Selecting columns: %v", options.Columns)
query = query.Column(options.Columns...)
}
// Apply preloading
for _, preload := range options.Preload {
logger.Debug("Applying preload: %s", preload.Relation)
query = query.Preload(preload.Relation)
}
// Apply expand (LEFT JOIN)
// Apply expand (Just expand to Preload for now)
for _, expand := range options.Expand {
logger.Debug("Applying expand: %s", expand.Relation)
sorts := make([]common.SortOption, 0)
for _, s := range strings.Split(expand.Sort, ",") {
if s == "" {
continue
}
dir := "ASC"
if strings.HasPrefix(s, "-") || strings.HasSuffix(strings.ToUpper(s), " DESC") {
dir = "DESC"
s = strings.TrimPrefix(s, "-")
s = strings.TrimSuffix(strings.ToLower(s), " desc")
}
sorts = append(sorts, common.SortOption{
Column: s, Direction: dir,
})
}
// Note: Expand would require JOIN implementation
// For now, we'll use Preload as a fallback
query = query.Preload(expand.Relation)
// query = query.Preload(expand.Relation)
if options.Preload == nil {
options.Preload = make([]common.PreloadOption, 0)
}
skip := false
for idx := range options.Preload {
if options.Preload[idx].Relation == expand.Relation {
skip = true
continue
}
}
if !skip {
options.Preload = append(options.Preload, common.PreloadOption{
Relation: expand.Relation,
Columns: expand.Columns,
Sort: sorts,
Where: expand.Where,
})
}
}
// Apply preloading
for idx := range options.Preload {
preload := options.Preload[idx]
logger.Debug("Applying preload: %s", preload.Relation)
query = query.PreloadRelation(preload.Relation, func(sq common.SelectQuery) common.SelectQuery {
if len(preload.OmitColumns) > 0 {
allCols := reflection.GetModelColumns(model)
// Remove omitted columns
preload.Columns = []string{}
for _, col := range allCols {
addCols := true
for _, omitCol := range preload.OmitColumns {
if col == omitCol {
addCols = false
break
}
}
if addCols {
preload.Columns = append(preload.Columns, col)
}
}
}
if len(preload.Columns) > 0 {
sq = sq.Column(preload.Columns...)
}
if len(preload.Filters) > 0 {
for _, filter := range preload.Filters {
sq = h.applyFilter(sq, filter, "", false, "AND")
}
}
if len(preload.Sort) > 0 {
for _, sort := range preload.Sort {
sq = sq.Order(fmt.Sprintf("%s %s", sort.Column, sort.Direction))
}
}
if len(preload.Where) > 0 {
sq = sq.Where(preload.Where)
}
if preload.Limit != nil && *preload.Limit > 0 {
sq = sq.Limit(*preload.Limit)
}
return sq
})
}
// Apply DISTINCT if requested
@@ -311,8 +417,10 @@ func (h *Handler) handleRead(ctx context.Context, w common.ResponseWriter, id st
// If ID is provided, filter by ID
if id != "" {
logger.Debug("Filtering by ID: %s", id)
query = query.Where("id = ?", id)
pkName := reflection.GetPrimaryKeyName(model)
logger.Debug("Filtering by ID=%s: %s", pkName, id)
query = query.Where(fmt.Sprintf("%s = ?", common.QuoteIdent(pkName)), id)
}
// Apply sorting
@@ -398,7 +506,7 @@ func (h *Handler) handleRead(ctx context.Context, w common.ResponseWriter, id st
}
// Execute query - modelPtr was already created earlier
if err := query.Scan(ctx, modelPtr); err != nil {
if err := query.ScanModel(ctx); err != nil {
logger.Error("Error executing query: %v", err)
h.sendError(w, http.StatusInternalServerError, "query_error", "Error executing query", err)
return
@@ -418,7 +526,7 @@ func (h *Handler) handleRead(ctx context.Context, w common.ResponseWriter, id st
metadata := &common.Metadata{
Total: int64(total),
Count: int64(common.Len(modelPtr)),
Count: int64(reflection.Len(modelPtr)),
Filtered: int64(total),
Limit: limit,
Offset: offset,
@@ -480,7 +588,7 @@ func (h *Handler) handleCreate(ctx context.Context, w common.ResponseWriter, dat
return
}
logger.Info("Successfully created record with nested data, ID: %v", result.ID)
h.sendResponse(w, result.Data, nil)
h.sendResponseWithOptions(w, result.Data, nil, &options)
return
}
}
@@ -564,7 +672,7 @@ func (h *Handler) handleCreate(ctx context.Context, w common.ResponseWriter, dat
}
logger.Info("Successfully created %d records with nested data", len(results))
h.sendResponse(w, results, nil)
h.sendResponseWithOptions(w, results, nil, &options)
return
}
@@ -681,7 +789,7 @@ func (h *Handler) handleCreate(ctx context.Context, w common.ResponseWriter, dat
return
}
h.sendResponse(w, modelValue, nil)
h.sendResponseWithOptions(w, modelValue, nil, &options)
}
func (h *Handler) handleUpdate(ctx context.Context, w common.ResponseWriter, id string, idPtr *int64, data interface{}, options ExtendedRequestOptions) {
@@ -735,7 +843,7 @@ func (h *Handler) handleUpdate(ctx context.Context, w common.ResponseWriter, id
return
}
logger.Info("Successfully updated record with nested data, rows: %d", result.AffectedRows)
h.sendResponse(w, result.Data, nil)
h.sendResponseWithOptions(w, result.Data, nil, &options)
return
}
@@ -779,13 +887,13 @@ func (h *Handler) handleUpdate(ctx context.Context, w common.ResponseWriter, id
}
query := h.db.NewUpdate().Table(tableName).SetMap(dataMap)
pkName := reflection.GetPrimaryKeyName(model)
// Apply ID filter
switch {
case id != "":
query = query.Where("id = ?", id)
query = query.Where(fmt.Sprintf("%s = ?", common.QuoteIdent(pkName)), id)
case idPtr != nil:
query = query.Where("id = ?", *idPtr)
query = query.Where(fmt.Sprintf("%s = ?", common.QuoteIdent(pkName)), *idPtr)
default:
h.sendError(w, http.StatusBadRequest, "missing_id", "ID is required for update", nil)
return
@@ -824,7 +932,7 @@ func (h *Handler) handleUpdate(ctx context.Context, w common.ResponseWriter, id
return
}
h.sendResponse(w, responseData, nil)
h.sendResponseWithOptions(w, responseData, nil, &options)
}
func (h *Handler) handleDelete(ctx context.Context, w common.ResponseWriter, id string, data interface{}) {
@@ -868,7 +976,7 @@ func (h *Handler) handleDelete(ctx context.Context, w common.ResponseWriter, id
continue
}
query := tx.NewDelete().Table(tableName).Where("id = ?", itemID)
query := tx.NewDelete().Table(tableName).Where(fmt.Sprintf("%s = ?", common.QuoteIdent(reflection.GetPrimaryKeyName(model))), itemID)
result, err := query.Exec(ctx)
if err != nil {
@@ -935,7 +1043,7 @@ func (h *Handler) handleDelete(ctx context.Context, w common.ResponseWriter, id
continue
}
query := tx.NewDelete().Table(tableName).Where("id = ?", itemID)
query := tx.NewDelete().Table(tableName).Where(fmt.Sprintf("%s = ?", common.QuoteIdent(reflection.GetPrimaryKeyName(model))), itemID)
result, err := query.Exec(ctx)
if err != nil {
return fmt.Errorf("failed to delete record %v: %w", itemID, err)
@@ -986,7 +1094,7 @@ func (h *Handler) handleDelete(ctx context.Context, w common.ResponseWriter, id
continue
}
query := tx.NewDelete().Table(tableName).Where("id = ?", itemID)
query := tx.NewDelete().Table(tableName).Where(fmt.Sprintf("%s = ?", common.QuoteIdent(reflection.GetPrimaryKeyName(model))), itemID)
result, err := query.Exec(ctx)
if err != nil {
return fmt.Errorf("failed to delete record %v: %w", itemID, err)
@@ -1046,7 +1154,7 @@ func (h *Handler) handleDelete(ctx context.Context, w common.ResponseWriter, id
return
}
query = query.Where("id = ?", id)
query = query.Where(fmt.Sprintf("%s = ?", common.QuoteIdent(reflection.GetPrimaryKeyName(model))), id)
// Execute BeforeScan hooks - pass query chain so hooks can modify it
hookCtx.Query = query
@@ -1326,6 +1434,16 @@ func (h *Handler) isNullable(field reflect.StructField) bool {
}
func (h *Handler) sendResponse(w common.ResponseWriter, data interface{}, metadata *common.Metadata) {
h.sendResponseWithOptions(w, data, metadata, nil)
}
// sendResponseWithOptions sends a response with optional formatting
func (h *Handler) sendResponseWithOptions(w common.ResponseWriter, data interface{}, metadata *common.Metadata, options *ExtendedRequestOptions) {
// Normalize single-record arrays to objects if requested
if options != nil && options.SingleRecordAsObject {
data = h.normalizeResultArray(data)
}
response := common.Response{
Success: true,
Data: data,
@@ -1337,8 +1455,35 @@ func (h *Handler) sendResponse(w common.ResponseWriter, data interface{}, metada
}
}
// normalizeResultArray converts a single-element array to an object if requested
// 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 data
}
// Use reflection to check if data is a slice or array
dataValue := reflect.ValueOf(data)
if dataValue.Kind() == reflect.Ptr {
dataValue = dataValue.Elem()
}
// Check if it's a slice or array with exactly one element
if (dataValue.Kind() == reflect.Slice || dataValue.Kind() == reflect.Array) && dataValue.Len() == 1 {
// Return the single element
return dataValue.Index(0).Interface()
}
return data
}
// 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
if options.SingleRecordAsObject {
data = h.normalizeResultArray(data)
}
// Clean JSON if requested (remove null/empty fields)
if options.CleanJSON {
data = h.cleanJSON(data)
@@ -1426,6 +1571,9 @@ func (h *Handler) FetchRowNumber(ctx context.Context, tableName string, pkName s
if len(options.Sort) > 0 {
sortParts := make([]string, 0, len(options.Sort))
for _, sort := range options.Sort {
if sort.Column == "" {
continue
}
direction := "ASC"
if strings.EqualFold(sort.Direction, "desc") {
direction = "DESC"

View File

@@ -37,6 +37,9 @@ type ExtendedRequestOptions struct {
// Response format
ResponseFormat string // "simple", "detail", "syncfusion"
// Single record normalization - convert single-element arrays to objects
SingleRecordAsObject bool
// Transaction
AtomicTransaction bool
}
@@ -99,10 +102,11 @@ func (h *Handler) parseOptionsFromHeaders(r common.Request) ExtendedRequestOptio
Sort: make([]common.SortOption, 0),
Preload: make([]common.PreloadOption, 0),
},
AdvancedSQL: make(map[string]string),
ComputedQL: make(map[string]string),
Expand: make([]ExpandOption, 0),
ResponseFormat: "simple", // Default response format
AdvancedSQL: make(map[string]string),
ComputedQL: make(map[string]string),
Expand: make([]ExpandOption, 0),
ResponseFormat: "simple", // Default response format
SingleRecordAsObject: true, // Default: normalize single-element arrays to objects
}
// Get all headers
@@ -146,7 +150,12 @@ func (h *Handler) parseOptionsFromHeaders(r common.Request) ExtendedRequestOptio
// Joins & Relations
case strings.HasPrefix(normalizedKey, "x-preload"):
h.parsePreload(&options, decodedValue)
if strings.HasSuffix(normalizedKey, "-where") {
continue
}
whereClaude := headers[fmt.Sprintf("%s-where", key)]
h.parsePreload(&options, decodedValue, decodeHeaderValue(whereClaude))
case strings.HasPrefix(normalizedKey, "x-expand"):
h.parseExpand(&options, decodedValue)
case strings.HasPrefix(normalizedKey, "x-custom-sql-join"):
@@ -194,6 +203,13 @@ func (h *Handler) parseOptionsFromHeaders(r common.Request) ExtendedRequestOptio
options.ResponseFormat = "detail"
case strings.HasPrefix(normalizedKey, "x-syncfusion"):
options.ResponseFormat = "syncfusion"
case strings.HasPrefix(normalizedKey, "x-single-record-as-object"):
// Parse as boolean - "false" disables, "true" enables (default is true)
if strings.EqualFold(decodedValue, "false") {
options.SingleRecordAsObject = false
} else if strings.EqualFold(decodedValue, "true") {
options.SingleRecordAsObject = true
}
// Transaction Control
case strings.HasPrefix(normalizedKey, "x-transaction-atomic"):
@@ -341,7 +357,15 @@ func (h *Handler) mapSearchOperator(colName, operator, value string) common.Filt
// parsePreload parses x-preload header
// Format: RelationName:field1,field2 or RelationName or multiple separated by |
func (h *Handler) parsePreload(options *ExtendedRequestOptions, value string) {
func (h *Handler) parsePreload(options *ExtendedRequestOptions, values ...string) {
if len(values) == 0 {
return
}
value := values[0]
whereClause := ""
if len(values) > 1 {
whereClause = values[1]
}
if value == "" {
return
}
@@ -358,6 +382,7 @@ func (h *Handler) parsePreload(options *ExtendedRequestOptions, value string) {
parts := strings.SplitN(preloadStr, ":", 2)
preload := common.PreloadOption{
Relation: strings.TrimSpace(parts[0]),
Where: whereClause,
}
if len(parts) == 2 {

View File

@@ -402,25 +402,41 @@ func testRestHeadSpecCRUD(t *testing.T, serverURL string) {
resp := makeRestHeadSpecRequest(t, serverURL, fmt.Sprintf("/restheadspec/departments/%s", deptID), "GET", nil, nil)
assert.Equal(t, http.StatusOK, resp.StatusCode)
// RestHeadSpec may return data directly as array or wrapped in response object
// RestHeadSpec may return data directly as array/object or wrapped in response object
body, err := io.ReadAll(resp.Body)
assert.NoError(t, err, "Failed to read response body")
// Try to decode as array first (simple format)
// Try to decode as array first (simple format - multiple records or disabled SingleRecordAsObject)
var dataArray []interface{}
if err := json.Unmarshal(body, &dataArray); err == nil {
assert.GreaterOrEqual(t, len(dataArray), 1, "Should find department")
logger.Info("Department read successfully (simple format): %s", deptID)
logger.Info("Department read successfully (simple format - array): %s", deptID)
return
}
// Try to decode as standard response object (detail format)
var result map[string]interface{}
if err := json.Unmarshal(body, &result); err == nil {
if success, ok := result["success"]; ok && success != nil && success.(bool) {
if data, ok := result["data"].([]interface{}); ok {
// Try to decode as a single object first (simple format with SingleRecordAsObject enabled)
var singleObj map[string]interface{}
if err := json.Unmarshal(body, &singleObj); err == nil {
// Check if it's a data object (not a response wrapper)
if _, hasSuccess := singleObj["success"]; !hasSuccess {
// This is a direct data object (simple format, single record)
assert.NotEmpty(t, singleObj, "Should find department")
logger.Info("Department read successfully (simple format - single object): %s", deptID)
return
}
// Otherwise it's a standard response object (detail format)
if success, ok := singleObj["success"]; ok && success != nil && success.(bool) {
// Check if data is an array
if data, ok := singleObj["data"].([]interface{}); ok {
assert.GreaterOrEqual(t, len(data), 1, "Should find department")
logger.Info("Department read successfully (detail format): %s", deptID)
logger.Info("Department read successfully (detail format - array): %s", deptID)
return
}
// Check if data is a single object (SingleRecordAsObject feature in detail format)
if data, ok := singleObj["data"].(map[string]interface{}); ok {
assert.NotEmpty(t, data, "Should find department")
logger.Info("Department read successfully (detail format - single object): %s", deptID)
return
}
}
@@ -446,25 +462,41 @@ func testRestHeadSpecCRUD(t *testing.T, serverURL string) {
resp := makeRestHeadSpecRequest(t, serverURL, "/restheadspec/employees", "GET", nil, headers)
assert.Equal(t, http.StatusOK, resp.StatusCode)
// RestHeadSpec may return data directly as array or wrapped in response object
// RestHeadSpec may return data directly as array/object or wrapped in response object
body, err := io.ReadAll(resp.Body)
assert.NoError(t, err, "Failed to read response body")
// Try array format first
// Try array format first (multiple records or disabled SingleRecordAsObject)
var dataArray []interface{}
if err := json.Unmarshal(body, &dataArray); err == nil {
assert.GreaterOrEqual(t, len(dataArray), 1, "Should find at least one employee")
logger.Info("Employees read with filter successfully (simple format), found: %d", len(dataArray))
logger.Info("Employees read with filter successfully (simple format - array), found: %d", len(dataArray))
return
}
// Try standard response format
var result map[string]interface{}
if err := json.Unmarshal(body, &result); err == nil {
if success, ok := result["success"]; ok && success != nil && success.(bool) {
if data, ok := result["data"].([]interface{}); ok {
// Try to decode as a single object (simple format with SingleRecordAsObject enabled)
var singleObj map[string]interface{}
if err := json.Unmarshal(body, &singleObj); err == nil {
// Check if it's a data object (not a response wrapper)
if _, hasSuccess := singleObj["success"]; !hasSuccess {
// This is a direct data object (simple format, single record)
assert.NotEmpty(t, singleObj, "Should find at least one employee")
logger.Info("Employees read with filter successfully (simple format - single object), found: 1")
return
}
// Otherwise it's a standard response object (detail format)
if success, ok := singleObj["success"]; ok && success != nil && success.(bool) {
// Check if data is an array
if data, ok := singleObj["data"].([]interface{}); ok {
assert.GreaterOrEqual(t, len(data), 1, "Should find at least one employee")
logger.Info("Employees read with filter successfully (detail format), found: %d", len(data))
logger.Info("Employees read with filter successfully (detail format - array), found: %d", len(data))
return
}
// Check if data is a single object (SingleRecordAsObject feature in detail format)
if data, ok := singleObj["data"].(map[string]interface{}); ok {
assert.NotEmpty(t, data, "Should find at least one employee")
logger.Info("Employees read with filter successfully (detail format - single object), found: 1")
return
}
}