mirror of
https://github.com/bitechdev/ResolveSpec.git
synced 2025-12-29 15:54:26 +00:00
Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7f5b851669 | ||
|
|
f0e26b1c0d | ||
|
|
1db1b924ef | ||
|
|
d9cf23b1dc | ||
|
|
94f013c872 | ||
|
|
c52fcff61d | ||
|
|
ce106fa940 | ||
|
|
37b4b75175 |
110
.golangci.bck.yml
Normal file
110
.golangci.bck.yml
Normal 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
58
.vscode/tasks.json
vendored
@@ -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
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
110
README.md
110
README.md
@@ -32,8 +32,9 @@ Both share the same core architecture and provide dynamic data querying, relatio
|
||||
- [Lifecycle Hooks](#lifecycle-hooks)
|
||||
- [Cursor Pagination](#cursor-pagination)
|
||||
- [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 +46,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
|
||||
@@ -342,6 +344,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,6 +899,25 @@ 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
|
||||
@@ -826,6 +933,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
|
||||
|
||||
|
||||
@@ -215,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 ¤t (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
|
||||
|
||||
@@ -197,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
|
||||
|
||||
@@ -32,6 +32,7 @@ type SelectQuery interface {
|
||||
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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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, `'`, `''`) + `'`
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -199,7 +200,7 @@ func (h *Handler) handleRead(ctx context.Context, w common.ResponseWriter, id st
|
||||
if len(options.ComputedColumns) > 0 {
|
||||
for _, cu := range options.ComputedColumns {
|
||||
logger.Debug("Applying computed column: %s", cu.Name)
|
||||
query = query.ColumnExpr("(?) AS "+cu.Name, cu.Expression)
|
||||
query = query.ColumnExpr(fmt.Sprintf("(%s) AS %s", cu.Expression, cu.Name))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -249,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)
|
||||
@@ -521,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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -593,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
|
||||
}
|
||||
@@ -659,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
|
||||
}
|
||||
@@ -695,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)
|
||||
|
||||
@@ -706,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)
|
||||
}
|
||||
@@ -745,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)
|
||||
@@ -770,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)
|
||||
@@ -804,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 {
|
||||
@@ -1113,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 {
|
||||
@@ -1128,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)
|
||||
}
|
||||
|
||||
@@ -1186,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())
|
||||
}
|
||||
|
||||
@@ -257,14 +257,28 @@ func (h *Handler) handleRead(ctx context.Context, w common.ResponseWriter, id st
|
||||
if len(options.ComputedQL) > 0 {
|
||||
for colName, colExpr := range options.ComputedQL {
|
||||
logger.Debug("Applying computed column: %s", colName)
|
||||
query = query.ColumnExpr("(?) AS "+colName, colExpr)
|
||||
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("(?) AS "+cu.Name, cu.Expression)
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -274,18 +288,95 @@ func (h *Handler) handleRead(ctx context.Context, w common.ResponseWriter, id st
|
||||
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
|
||||
@@ -326,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
|
||||
@@ -433,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,
|
||||
@@ -794,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
|
||||
@@ -883,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 {
|
||||
@@ -950,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)
|
||||
@@ -1001,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)
|
||||
@@ -1061,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
|
||||
@@ -1441,6 +1534,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"
|
||||
|
||||
@@ -146,7 +146,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"):
|
||||
@@ -341,7 +346,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 +371,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 {
|
||||
|
||||
Reference in New Issue
Block a user