mirror of
https://github.com/bitechdev/ResolveSpec.git
synced 2025-12-31 17:28:58 +00:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3eb17666bf | ||
|
|
c8704c07dd | ||
|
|
fc82a9bc50 | ||
|
|
c26ea3cd61 | ||
|
|
a5d97cc07b | ||
|
|
0899ba5029 |
2
LICENSE
2
LICENSE
@@ -1,6 +1,6 @@
|
|||||||
MIT License
|
MIT License
|
||||||
|
|
||||||
Copyright (c) 2025 Warky Devs Pty Ltd
|
Copyright (c) 2025
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
|||||||
388
README.md
388
README.md
@@ -1,9 +1,16 @@
|
|||||||
# 📜 ResolveSpec 📜
|
# 📜 ResolveSpec 📜
|
||||||
|
|
||||||
ResolveSpec is a flexible and powerful REST API specification and implementation that provides GraphQL-like capabilities while maintaining REST simplicity. It allows for dynamic data querying, relationship preloading, and complex filtering through a clean, URL-based interface.
|
ResolveSpec is a flexible and powerful REST API specification and implementation that provides GraphQL-like capabilities while maintaining REST simplicity. It offers **two complementary approaches**:
|
||||||
|
|
||||||
|
1. **ResolveSpec** - Body-based API with JSON request options
|
||||||
|
2. **RestHeadSpec** - Header-based API where query options are passed via HTTP headers
|
||||||
|
|
||||||
|
Both share the same core architecture and provide dynamic data querying, relationship preloading, and complex filtering.
|
||||||
|
|
||||||
**🆕 New in v2.0**: Database-agnostic architecture with support for GORM, Bun, and other ORMs. Router-flexible design works with Gorilla Mux, Gin, Echo, and more.
|
**🆕 New in v2.0**: Database-agnostic architecture with support for GORM, Bun, and other ORMs. Router-flexible design works with Gorilla Mux, Gin, Echo, and more.
|
||||||
|
|
||||||
|
**🆕 New in v2.1**: RestHeadSpec (HeaderSpec) - Header-based REST API with lifecycle hooks, cursor pagination, and advanced filtering.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
## Table of Contents
|
## Table of Contents
|
||||||
@@ -11,30 +18,46 @@ ResolveSpec is a flexible and powerful REST API specification and implementation
|
|||||||
- [Features](#features)
|
- [Features](#features)
|
||||||
- [Installation](#installation)
|
- [Installation](#installation)
|
||||||
- [Quick Start](#quick-start)
|
- [Quick Start](#quick-start)
|
||||||
|
- [ResolveSpec (Body-Based API)](#resolvespec-body-based-api)
|
||||||
|
- [RestHeadSpec (Header-Based API)](#restheadspec-header-based-api)
|
||||||
- [Existing Code (Backward Compatible)](#option-1-existing-code-backward-compatible)
|
- [Existing Code (Backward Compatible)](#option-1-existing-code-backward-compatible)
|
||||||
- [New Database-Agnostic API](#option-2-new-database-agnostic-api)
|
- [New Database-Agnostic API](#option-2-new-database-agnostic-api)
|
||||||
- [Router Integration](#router-integration)
|
- [Router Integration](#router-integration)
|
||||||
- [Migration from v1.x](#migration-from-v1x)
|
- [Migration from v1.x](#migration-from-v1x)
|
||||||
- [Architecture](#architecture)
|
- [Architecture](#architecture)
|
||||||
- [API Structure](#api-structure)
|
- [API Structure](#api-structure)
|
||||||
|
- [RestHeadSpec: Header-Based API](#restheadspec-header-based-api-1)
|
||||||
|
- [Lifecycle Hooks](#lifecycle-hooks)
|
||||||
|
- [Cursor Pagination](#cursor-pagination)
|
||||||
- [Example Usage](#example-usage)
|
- [Example Usage](#example-usage)
|
||||||
- [Testing](#testing)
|
- [Testing](#testing)
|
||||||
- [What's New in v2.0](#whats-new-in-v20)
|
- [What's New in v2.0](#whats-new-in-v20)
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
|
### Core Features
|
||||||
- **Dynamic Data Querying**: Select specific columns and relationships to return
|
- **Dynamic Data Querying**: Select specific columns and relationships to return
|
||||||
- **Relationship Preloading**: Load related entities with custom column selection and filters
|
- **Relationship Preloading**: Load related entities with custom column selection and filters
|
||||||
- **Complex Filtering**: Apply multiple filters with various operators
|
- **Complex Filtering**: Apply multiple filters with various operators
|
||||||
- **Sorting**: Multi-column sort support
|
- **Sorting**: Multi-column sort support
|
||||||
- **Pagination**: Built-in limit and offset support
|
- **Pagination**: Built-in limit/offset and cursor-based pagination
|
||||||
- **Computed Columns**: Define virtual columns for complex calculations
|
- **Computed Columns**: Define virtual columns for complex calculations
|
||||||
- **Custom Operators**: Add custom SQL conditions when needed
|
- **Custom Operators**: Add custom SQL conditions when needed
|
||||||
|
|
||||||
|
### Architecture (v2.0+)
|
||||||
- **🆕 Database Agnostic**: Works with GORM, Bun, or any database layer through adapters
|
- **🆕 Database Agnostic**: Works with GORM, Bun, or any database layer through adapters
|
||||||
- **🆕 Router Flexible**: Integrates with Gorilla Mux, Gin, Echo, or custom routers
|
- **🆕 Router Flexible**: Integrates with Gorilla Mux, Gin, Echo, or custom routers
|
||||||
- **🆕 Backward Compatible**: Existing code works without changes
|
- **🆕 Backward Compatible**: Existing code works without changes
|
||||||
- **🆕 Better Testing**: Mockable interfaces for easy unit testing
|
- **🆕 Better Testing**: Mockable interfaces for easy unit testing
|
||||||
|
|
||||||
|
### RestHeadSpec (v2.1+)
|
||||||
|
- **🆕 Header-Based API**: All query options passed via HTTP headers instead of request body
|
||||||
|
- **🆕 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
|
||||||
|
- **🆕 Advanced Filtering**: Field filters, search operators, AND/OR logic, and custom SQL
|
||||||
|
- **🆕 Base64 Encoding**: Support for base64-encoded header values
|
||||||
|
|
||||||
## API Structure
|
## API Structure
|
||||||
|
|
||||||
### URL Patterns
|
### URL Patterns
|
||||||
@@ -66,6 +89,216 @@ ResolveSpec is a flexible and powerful REST API specification and implementation
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## RestHeadSpec: Header-Based API
|
||||||
|
|
||||||
|
RestHeadSpec provides an alternative REST API approach where all query options are passed via HTTP headers instead of the request body. This provides cleaner separation between data and metadata.
|
||||||
|
|
||||||
|
### Quick Example
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /public/users HTTP/1.1
|
||||||
|
Host: api.example.com
|
||||||
|
X-Select-Fields: id,name,email,department_id
|
||||||
|
X-Preload: department:id,name
|
||||||
|
X-FieldFilter-Status: active
|
||||||
|
X-SearchOp-Gte-Age: 18
|
||||||
|
X-Sort: -created_at,+name
|
||||||
|
X-Limit: 50
|
||||||
|
X-DetailApi: true
|
||||||
|
```
|
||||||
|
|
||||||
|
### Setup with GORM
|
||||||
|
|
||||||
|
```go
|
||||||
|
import "github.com/bitechdev/ResolveSpec/pkg/restheadspec"
|
||||||
|
import "github.com/gorilla/mux"
|
||||||
|
|
||||||
|
// Create handler
|
||||||
|
handler := restheadspec.NewHandlerWithGORM(db)
|
||||||
|
|
||||||
|
// Register models using schema.table format
|
||||||
|
handler.Registry.RegisterModel("public.users", &User{})
|
||||||
|
handler.Registry.RegisterModel("public.posts", &Post{})
|
||||||
|
|
||||||
|
// Setup routes
|
||||||
|
router := mux.NewRouter()
|
||||||
|
restheadspec.SetupMuxRoutes(router, handler)
|
||||||
|
|
||||||
|
// Start server
|
||||||
|
http.ListenAndServe(":8080", router)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Setup with Bun ORM
|
||||||
|
|
||||||
|
```go
|
||||||
|
import "github.com/bitechdev/ResolveSpec/pkg/restheadspec"
|
||||||
|
import "github.com/uptrace/bun"
|
||||||
|
|
||||||
|
// Create handler with Bun
|
||||||
|
handler := restheadspec.NewHandlerWithBun(bunDB)
|
||||||
|
|
||||||
|
// Register models
|
||||||
|
handler.Registry.RegisterModel("public.users", &User{})
|
||||||
|
|
||||||
|
// Setup routes (same as GORM)
|
||||||
|
router := mux.NewRouter()
|
||||||
|
restheadspec.SetupMuxRoutes(router, handler)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Common Headers
|
||||||
|
|
||||||
|
| Header | Description | Example |
|
||||||
|
|--------|-------------|---------|
|
||||||
|
| `X-Select-Fields` | Columns to include | `id,name,email` |
|
||||||
|
| `X-Not-Select-Fields` | Columns to exclude | `password,internal_notes` |
|
||||||
|
| `X-FieldFilter-{col}` | Exact match filter | `X-FieldFilter-Status: active` |
|
||||||
|
| `X-SearchFilter-{col}` | Fuzzy search (ILIKE) | `X-SearchFilter-Name: john` |
|
||||||
|
| `X-SearchOp-{op}-{col}` | Filter with operator | `X-SearchOp-Gte-Age: 18` |
|
||||||
|
| `X-Preload` | Preload relations | `posts:id,title` |
|
||||||
|
| `X-Sort` | Sort columns | `-created_at,+name` |
|
||||||
|
| `X-Limit` | Limit results | `50` |
|
||||||
|
| `X-Offset` | Offset for pagination | `100` |
|
||||||
|
| `X-Clean-JSON` | Remove null/empty fields | `true` |
|
||||||
|
|
||||||
|
**Available Operators**: `eq`, `neq`, `gt`, `gte`, `lt`, `lte`, `contains`, `startswith`, `endswith`, `between`, `betweeninclusive`, `in`, `empty`, `notempty`
|
||||||
|
|
||||||
|
For complete header documentation, see [pkg/restheadspec/HEADERS.md](pkg/restheadspec/HEADERS.md).
|
||||||
|
|
||||||
|
### Lifecycle Hooks
|
||||||
|
|
||||||
|
RestHeadSpec supports lifecycle hooks for all CRUD operations:
|
||||||
|
|
||||||
|
```go
|
||||||
|
import "github.com/bitechdev/ResolveSpec/pkg/restheadspec"
|
||||||
|
|
||||||
|
// Create handler
|
||||||
|
handler := restheadspec.NewHandlerWithGORM(db)
|
||||||
|
|
||||||
|
// Register a before-read hook (e.g., for authorization)
|
||||||
|
handler.Hooks.Register(restheadspec.BeforeRead, func(ctx *restheadspec.HookContext) error {
|
||||||
|
// Check permissions
|
||||||
|
if !userHasPermission(ctx.Context, ctx.Entity) {
|
||||||
|
return fmt.Errorf("unauthorized access to %s", ctx.Entity)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Modify query options
|
||||||
|
ctx.Options.Limit = ptr(100) // Enforce max limit
|
||||||
|
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
// Register an after-read hook (e.g., for data transformation)
|
||||||
|
handler.Hooks.Register(restheadspec.AfterRead, func(ctx *restheadspec.HookContext) error {
|
||||||
|
// Transform or filter results
|
||||||
|
if users, ok := ctx.Result.([]User); ok {
|
||||||
|
for i := range users {
|
||||||
|
users[i].Email = maskEmail(users[i].Email)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
// Register a before-create hook (e.g., for validation)
|
||||||
|
handler.Hooks.Register(restheadspec.BeforeCreate, func(ctx *restheadspec.HookContext) error {
|
||||||
|
// Validate data
|
||||||
|
if user, ok := ctx.Data.(*User); ok {
|
||||||
|
if user.Email == "" {
|
||||||
|
return fmt.Errorf("email is required")
|
||||||
|
}
|
||||||
|
// Add timestamps
|
||||||
|
user.CreatedAt = time.Now()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
**Available Hook Types**:
|
||||||
|
- `BeforeRead`, `AfterRead`
|
||||||
|
- `BeforeCreate`, `AfterCreate`
|
||||||
|
- `BeforeUpdate`, `AfterUpdate`
|
||||||
|
- `BeforeDelete`, `AfterDelete`
|
||||||
|
|
||||||
|
**HookContext** provides:
|
||||||
|
- `Context`: Request context
|
||||||
|
- `Handler`: Access to handler, database, and registry
|
||||||
|
- `Schema`, `Entity`, `TableName`: Request info
|
||||||
|
- `Model`: The registered model type
|
||||||
|
- `Options`: Parsed request options (filters, sorting, etc.)
|
||||||
|
- `ID`: Record ID (for single-record operations)
|
||||||
|
- `Data`: Request data (for create/update)
|
||||||
|
- `Result`: Operation result (for after hooks)
|
||||||
|
- `Writer`: Response writer (allows hooks to modify response)
|
||||||
|
|
||||||
|
### Cursor Pagination
|
||||||
|
|
||||||
|
RestHeadSpec supports efficient cursor-based pagination for large datasets:
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /public/posts HTTP/1.1
|
||||||
|
X-Sort: -created_at,+id
|
||||||
|
X-Limit: 50
|
||||||
|
X-Cursor-Forward: <cursor_token>
|
||||||
|
```
|
||||||
|
|
||||||
|
**How it works**:
|
||||||
|
1. First request returns results + cursor token in response
|
||||||
|
2. Subsequent requests use `X-Cursor-Forward` or `X-Cursor-Backward`
|
||||||
|
3. Cursor maintains consistent ordering even with data changes
|
||||||
|
4. Supports complex multi-column sorting
|
||||||
|
|
||||||
|
**Benefits over offset pagination**:
|
||||||
|
- Consistent results when data changes
|
||||||
|
- Better performance for large offsets
|
||||||
|
- Prevents "skipped" or duplicate records
|
||||||
|
- Works with complex sort expressions
|
||||||
|
|
||||||
|
**Example with hooks**:
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Enable cursor pagination in a hook
|
||||||
|
handler.Hooks.Register(restheadspec.BeforeRead, func(ctx *restheadspec.HookContext) error {
|
||||||
|
// For large tables, enforce cursor pagination
|
||||||
|
if ctx.Entity == "posts" && ctx.Options.Offset != nil && *ctx.Options.Offset > 1000 {
|
||||||
|
return fmt.Errorf("use cursor pagination for large offsets")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### Response Formats
|
||||||
|
|
||||||
|
RestHeadSpec supports multiple response formats:
|
||||||
|
|
||||||
|
**1. Simple Format** (`X-SimpleApi: true`):
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{ "id": 1, "name": "John" },
|
||||||
|
{ "id": 2, "name": "Jane" }
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
**2. Detail Format** (`X-DetailApi: true`, default):
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"data": [...],
|
||||||
|
"metadata": {
|
||||||
|
"total": 100,
|
||||||
|
"filtered": 100,
|
||||||
|
"limit": 50,
|
||||||
|
"offset": 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**3. Syncfusion Format** (`X-Syncfusion: true`):
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"result": [...],
|
||||||
|
"count": 100
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
## Example Usage
|
## Example Usage
|
||||||
|
|
||||||
### Reading Data with Related Entities
|
### Reading Data with Related Entities
|
||||||
@@ -110,17 +343,73 @@ POST /core/users
|
|||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
go get github.com/Warky-Devs/ResolveSpec
|
go get github.com/bitechdev/ResolveSpec
|
||||||
```
|
```
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
|
### ResolveSpec (Body-Based API)
|
||||||
|
|
||||||
|
ResolveSpec uses JSON request bodies to specify query options:
|
||||||
|
|
||||||
|
```go
|
||||||
|
import "github.com/bitechdev/ResolveSpec/pkg/resolvespec"
|
||||||
|
|
||||||
|
// Create handler
|
||||||
|
handler := resolvespec.NewAPIHandler(gormDB)
|
||||||
|
handler.RegisterModel("core", "users", &User{})
|
||||||
|
|
||||||
|
// Setup routes
|
||||||
|
router := mux.NewRouter()
|
||||||
|
resolvespec.SetupRoutes(router, handler)
|
||||||
|
|
||||||
|
// Client makes POST request with body:
|
||||||
|
// POST /core/users
|
||||||
|
// {
|
||||||
|
// "operation": "read",
|
||||||
|
// "options": {
|
||||||
|
// "columns": ["id", "name", "email"],
|
||||||
|
// "filters": [{"column": "status", "operator": "eq", "value": "active"}],
|
||||||
|
// "limit": 10
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
```
|
||||||
|
|
||||||
|
### RestHeadSpec (Header-Based API)
|
||||||
|
|
||||||
|
RestHeadSpec uses HTTP headers for query options instead of request body:
|
||||||
|
|
||||||
|
```go
|
||||||
|
import "github.com/bitechdev/ResolveSpec/pkg/restheadspec"
|
||||||
|
|
||||||
|
// Create handler with GORM
|
||||||
|
handler := restheadspec.NewHandlerWithGORM(db)
|
||||||
|
|
||||||
|
// Register models (schema.table format)
|
||||||
|
handler.Registry.RegisterModel("public.users", &User{})
|
||||||
|
handler.Registry.RegisterModel("public.posts", &Post{})
|
||||||
|
|
||||||
|
// Setup routes with Mux
|
||||||
|
muxRouter := mux.NewRouter()
|
||||||
|
restheadspec.SetupMuxRoutes(muxRouter, handler)
|
||||||
|
|
||||||
|
// Client makes GET request with headers:
|
||||||
|
// GET /public/users
|
||||||
|
// X-Select-Fields: id,name,email
|
||||||
|
// X-FieldFilter-Status: active
|
||||||
|
// X-Limit: 10
|
||||||
|
// X-Sort: -created_at
|
||||||
|
// X-Preload: posts:id,title
|
||||||
|
```
|
||||||
|
|
||||||
|
See [RestHeadSpec: Header-Based API](#restheadspec-header-based-api-1) for complete header documentation.
|
||||||
|
|
||||||
### Option 1: Existing Code (Backward Compatible)
|
### Option 1: Existing Code (Backward Compatible)
|
||||||
|
|
||||||
Your existing code continues to work without any changes:
|
Your existing code continues to work without any changes:
|
||||||
|
|
||||||
```go
|
```go
|
||||||
import "github.com/Warky-Devs/ResolveSpec/pkg/resolvespec"
|
import "github.com/bitechdev/ResolveSpec/pkg/resolvespec"
|
||||||
|
|
||||||
// This still works exactly as before
|
// This still works exactly as before
|
||||||
handler := resolvespec.NewAPIHandler(gormDB)
|
handler := resolvespec.NewAPIHandler(gormDB)
|
||||||
@@ -131,12 +420,36 @@ handler.RegisterModel("core", "users", &User{})
|
|||||||
|
|
||||||
ResolveSpec v2.0 introduces a new database and router abstraction layer while maintaining **100% backward compatibility**. Your existing code will continue to work without any changes.
|
ResolveSpec v2.0 introduces a new database and router abstraction layer while maintaining **100% backward compatibility**. Your existing code will continue to work without any changes.
|
||||||
|
|
||||||
|
### Repository Path Migration
|
||||||
|
|
||||||
|
**IMPORTANT**: The repository has moved from `github.com/Warky-Devs/ResolveSpec` to `github.com/bitechdev/ResolveSpec`.
|
||||||
|
|
||||||
|
To update your imports:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Update go.mod
|
||||||
|
go mod edit -replace github.com/Warky-Devs/ResolveSpec=github.com/bitechdev/ResolveSpec@latest
|
||||||
|
go mod tidy
|
||||||
|
|
||||||
|
# Or update imports manually in your code
|
||||||
|
# Old: import "github.com/Warky-Devs/ResolveSpec/pkg/resolvespec"
|
||||||
|
# New: import "github.com/bitechdev/ResolveSpec/pkg/resolvespec"
|
||||||
|
```
|
||||||
|
|
||||||
|
Alternatively, use find and replace in your project:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
find . -type f -name "*.go" -exec sed -i 's|github.com/Warky-Devs/ResolveSpec|github.com/bitechdev/ResolveSpec|g' {} +
|
||||||
|
go mod tidy
|
||||||
|
```
|
||||||
|
|
||||||
### Migration Timeline
|
### Migration Timeline
|
||||||
|
|
||||||
1. **Phase 1**: Continue using existing API (no changes needed)
|
1. **Phase 1**: Update repository path (see above)
|
||||||
2. **Phase 2**: Gradually adopt new constructors when convenient
|
2. **Phase 2**: Continue using existing API (no changes needed)
|
||||||
3. **Phase 3**: Switch to interface-based approach for new features
|
3. **Phase 3**: Gradually adopt new constructors when convenient
|
||||||
4. **Phase 4**: Optionally switch database backends
|
4. **Phase 4**: Switch to interface-based approach for new features
|
||||||
|
5. **Phase 5**: Optionally switch database backends or try RestHeadSpec
|
||||||
|
|
||||||
### Detailed Migration Guide
|
### Detailed Migration Guide
|
||||||
|
|
||||||
@@ -144,6 +457,26 @@ For detailed migration instructions, examples, and best practices, see [MIGRATIO
|
|||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
|
### Two Complementary APIs
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────┐
|
||||||
|
│ ResolveSpec Framework │
|
||||||
|
├─────────────────────┬───────────────────────────────┤
|
||||||
|
│ ResolveSpec │ RestHeadSpec │
|
||||||
|
│ (Body-based) │ (Header-based) │
|
||||||
|
├─────────────────────┴───────────────────────────────┤
|
||||||
|
│ Common Core Components │
|
||||||
|
│ • Model Registry • Filters • Preloading │
|
||||||
|
│ • Sorting • Pagination • Type System │
|
||||||
|
└──────────────────────┬──────────────────────────────┘
|
||||||
|
↓
|
||||||
|
┌──────────────────────────────┐
|
||||||
|
│ Database Abstraction │
|
||||||
|
│ [GORM] [Bun] [Custom] │
|
||||||
|
└──────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
### Database Abstraction Layer
|
### Database Abstraction Layer
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -151,6 +484,8 @@ Your Application Code
|
|||||||
↓
|
↓
|
||||||
Handler (Business Logic)
|
Handler (Business Logic)
|
||||||
↓
|
↓
|
||||||
|
[Hooks & Middleware] (RestHeadSpec only)
|
||||||
|
↓
|
||||||
Database Interface
|
Database Interface
|
||||||
↓
|
↓
|
||||||
[GormAdapter] [BunAdapter] [CustomAdapter]
|
[GormAdapter] [BunAdapter] [CustomAdapter]
|
||||||
@@ -176,7 +511,7 @@ Your Application Code
|
|||||||
|
|
||||||
#### With GORM (Recommended Migration Path)
|
#### With GORM (Recommended Migration Path)
|
||||||
```go
|
```go
|
||||||
import "github.com/Warky-Devs/ResolveSpec/pkg/resolvespec"
|
import "github.com/bitechdev/ResolveSpec/pkg/resolvespec"
|
||||||
|
|
||||||
// Create database adapter
|
// Create database adapter
|
||||||
dbAdapter := resolvespec.NewGormAdapter(gormDB)
|
dbAdapter := resolvespec.NewGormAdapter(gormDB)
|
||||||
@@ -192,7 +527,7 @@ handler := resolvespec.NewHandler(dbAdapter, registry)
|
|||||||
|
|
||||||
#### With Bun ORM
|
#### With Bun ORM
|
||||||
```go
|
```go
|
||||||
import "github.com/Warky-Devs/ResolveSpec/pkg/resolvespec"
|
import "github.com/bitechdev/ResolveSpec/pkg/resolvespec"
|
||||||
import "github.com/uptrace/bun"
|
import "github.com/uptrace/bun"
|
||||||
|
|
||||||
// Create Bun adapter (Bun dependency already included)
|
// Create Bun adapter (Bun dependency already included)
|
||||||
@@ -415,12 +750,32 @@ func TestHandler(t *testing.T) {
|
|||||||
|
|
||||||
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
|
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
|
||||||
|
|
||||||
## What's New in v2.0
|
## What's New
|
||||||
|
|
||||||
### Breaking Changes
|
### v2.1 (Latest)
|
||||||
|
|
||||||
|
**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
|
||||||
|
- **Base64 Support**: Base64-encoded header values for complex queries
|
||||||
|
- **Type-Aware Filtering**: Automatic type detection and conversion for filters
|
||||||
|
|
||||||
|
**Core Improvements**:
|
||||||
|
- Better model registry with schema.table format support
|
||||||
|
- Enhanced validation and error handling
|
||||||
|
- Improved reflection safety
|
||||||
|
- Fixed COUNT query issues with table aliasing
|
||||||
|
- Better pointer handling throughout the codebase
|
||||||
|
|
||||||
|
### v2.0
|
||||||
|
|
||||||
|
**Breaking Changes**:
|
||||||
- **None!** Full backward compatibility maintained
|
- **None!** Full backward compatibility maintained
|
||||||
|
|
||||||
### New Features
|
**New Features**:
|
||||||
- **Database Abstraction**: Support for GORM, Bun, and custom ORMs
|
- **Database Abstraction**: Support for GORM, Bun, and custom ORMs
|
||||||
- **Router Flexibility**: Works with any HTTP router through adapters
|
- **Router Flexibility**: Works with any HTTP router through adapters
|
||||||
- **BunRouter Integration**: Built-in support for uptrace/bunrouter
|
- **BunRouter Integration**: Built-in support for uptrace/bunrouter
|
||||||
@@ -428,7 +783,7 @@ This project is licensed under the MIT License - see the [LICENSE](LICENSE) file
|
|||||||
- **Enhanced Testing**: Mockable interfaces for comprehensive testing
|
- **Enhanced Testing**: Mockable interfaces for comprehensive testing
|
||||||
- **Migration Guide**: Step-by-step migration instructions
|
- **Migration Guide**: Step-by-step migration instructions
|
||||||
|
|
||||||
### Performance Improvements
|
**Performance Improvements**:
|
||||||
- More efficient query building through interface design
|
- More efficient query building through interface design
|
||||||
- Reduced coupling between components
|
- Reduced coupling between components
|
||||||
- Better memory management with interface boundaries
|
- Better memory management with interface boundaries
|
||||||
@@ -436,8 +791,9 @@ This project is licensed under the MIT License - see the [LICENSE](LICENSE) file
|
|||||||
## Acknowledgments
|
## Acknowledgments
|
||||||
|
|
||||||
- Inspired by REST, OData, and GraphQL's flexibility
|
- Inspired by REST, OData, and GraphQL's flexibility
|
||||||
|
- **Header-based approach**: Inspired by REST best practices and clean API design
|
||||||
- **Database Support**: [GORM](https://gorm.io) and [Bun](https://bun.uptrace.dev/)
|
- **Database Support**: [GORM](https://gorm.io) and [Bun](https://bun.uptrace.dev/)
|
||||||
- **Router Support**: Gorilla Mux (built-in), Gin, Echo, and others through adapters
|
- **Router Support**: Gorilla Mux (built-in), BunRouter, Gin, Echo, and others through adapters
|
||||||
- Slogan generated using DALL-E
|
- Slogan generated using DALL-E
|
||||||
- AI used for documentation checking and correction
|
- AI used for documentation checking and correction
|
||||||
- Community feedback and contributions that made v2.0 possible
|
- Community feedback and contributions that made v2.0 and v2.1 possible
|
||||||
@@ -7,11 +7,11 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/Warky-Devs/ResolveSpec/pkg/logger"
|
"github.com/bitechdev/ResolveSpec/pkg/logger"
|
||||||
"github.com/Warky-Devs/ResolveSpec/pkg/modelregistry"
|
"github.com/bitechdev/ResolveSpec/pkg/modelregistry"
|
||||||
"github.com/Warky-Devs/ResolveSpec/pkg/testmodels"
|
"github.com/bitechdev/ResolveSpec/pkg/testmodels"
|
||||||
|
|
||||||
"github.com/Warky-Devs/ResolveSpec/pkg/resolvespec"
|
"github.com/bitechdev/ResolveSpec/pkg/resolvespec"
|
||||||
"github.com/gorilla/mux"
|
"github.com/gorilla/mux"
|
||||||
|
|
||||||
"github.com/glebarez/sqlite"
|
"github.com/glebarez/sqlite"
|
||||||
|
|||||||
2
go.mod
2
go.mod
@@ -1,4 +1,4 @@
|
|||||||
module github.com/Warky-Devs/ResolveSpec
|
module github.com/bitechdev/ResolveSpec
|
||||||
|
|
||||||
go 1.23.0
|
go 1.23.0
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/Warky-Devs/ResolveSpec/pkg/common"
|
"github.com/bitechdev/ResolveSpec/pkg/common"
|
||||||
"github.com/uptrace/bun"
|
"github.com/uptrace/bun"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -192,7 +192,7 @@ func (b *BunSelectQuery) LeftJoin(query string, args ...interface{}) common.Sele
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
b.query = b.query.Join("LEFT JOIN " + joinClause, sqlArgs...)
|
b.query = b.query.Join("LEFT JOIN "+joinClause, sqlArgs...)
|
||||||
return b
|
return b
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/Warky-Devs/ResolveSpec/pkg/common"
|
"github.com/bitechdev/ResolveSpec/pkg/common"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,173 @@
|
|||||||
package database
|
package database
|
||||||
|
|
||||||
import "strings"
|
import (
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/bitechdev/ResolveSpec/pkg/common"
|
||||||
|
)
|
||||||
|
|
||||||
// parseTableName splits a table name that may contain schema into separate schema and table
|
// parseTableName splits a table name that may contain schema into separate schema and table
|
||||||
// For example: "public.users" -> ("public", "users")
|
// For example: "public.users" -> ("public", "users")
|
||||||
// "users" -> ("", "users")
|
//
|
||||||
|
// "users" -> ("", "users")
|
||||||
func parseTableName(fullTableName string) (schema, table string) {
|
func parseTableName(fullTableName string) (schema, table string) {
|
||||||
if idx := strings.LastIndex(fullTableName, "."); idx != -1 {
|
if idx := strings.LastIndex(fullTableName, "."); idx != -1 {
|
||||||
return fullTableName[:idx], fullTableName[idx+1:]
|
return fullTableName[:idx], fullTableName[idx+1:]
|
||||||
}
|
}
|
||||||
return "", fullTableName
|
return "", fullTableName
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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 {
|
||||||
|
// Check if model implements PrimaryKeyNameProvider
|
||||||
|
if provider, ok := model.(common.PrimaryKeyNameProvider); ok {
|
||||||
|
return provider.GetIDName()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try Bun tag first
|
||||||
|
if pkName := getPrimaryKeyFromReflection(model, "bun"); pkName != "" {
|
||||||
|
return pkName
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to GORM tag
|
||||||
|
return getPrimaryKeyFromReflection(model, "gorm")
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetModelColumns extracts all column names from a model using reflection
|
||||||
|
// It checks bun tags first, then gorm tags, then json tags, and finally falls back to lowercase field names
|
||||||
|
func GetModelColumns(model any) []string {
|
||||||
|
var columns []string
|
||||||
|
|
||||||
|
modelType := reflect.TypeOf(model)
|
||||||
|
|
||||||
|
// Unwrap pointers, slices, and arrays to get to the base struct type
|
||||||
|
for modelType != nil && (modelType.Kind() == reflect.Pointer || modelType.Kind() == reflect.Slice || modelType.Kind() == reflect.Array) {
|
||||||
|
modelType = modelType.Elem()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate that we have a struct type
|
||||||
|
if modelType == nil || modelType.Kind() != reflect.Struct {
|
||||||
|
return columns
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := 0; i < modelType.NumField(); i++ {
|
||||||
|
field := modelType.Field(i)
|
||||||
|
|
||||||
|
// Get column name using the same logic as primary key extraction
|
||||||
|
columnName := getColumnNameFromField(field)
|
||||||
|
|
||||||
|
if columnName != "" {
|
||||||
|
columns = append(columns, columnName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return columns
|
||||||
|
}
|
||||||
|
|
||||||
|
// getColumnNameFromField extracts the column name from a struct field
|
||||||
|
// Priority: bun tag -> gorm tag -> json tag -> lowercase field name
|
||||||
|
func getColumnNameFromField(field reflect.StructField) string {
|
||||||
|
// Try bun tag first
|
||||||
|
bunTag := field.Tag.Get("bun")
|
||||||
|
if bunTag != "" && bunTag != "-" {
|
||||||
|
if colName := extractColumnFromBunTag(bunTag); colName != "" {
|
||||||
|
return colName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try gorm tag
|
||||||
|
gormTag := field.Tag.Get("gorm")
|
||||||
|
if gormTag != "" && gormTag != "-" {
|
||||||
|
if colName := extractColumnFromGormTag(gormTag); colName != "" {
|
||||||
|
return colName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to json tag
|
||||||
|
jsonTag := field.Tag.Get("json")
|
||||||
|
if jsonTag != "" && jsonTag != "-" {
|
||||||
|
// Extract just the field name before any options
|
||||||
|
parts := strings.Split(jsonTag, ",")
|
||||||
|
if len(parts) > 0 && parts[0] != "" {
|
||||||
|
return parts[0]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Last resort: use field name in lowercase
|
||||||
|
return strings.ToLower(field.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// getPrimaryKeyFromReflection uses reflection to find the primary key field
|
||||||
|
func getPrimaryKeyFromReflection(model any, ormType string) string {
|
||||||
|
val := reflect.ValueOf(model)
|
||||||
|
if val.Kind() == reflect.Pointer {
|
||||||
|
val = val.Elem()
|
||||||
|
}
|
||||||
|
|
||||||
|
if val.Kind() != reflect.Struct {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
typ := val.Type()
|
||||||
|
for i := 0; i < typ.NumField(); i++ {
|
||||||
|
field := typ.Field(i)
|
||||||
|
|
||||||
|
switch ormType {
|
||||||
|
case "gorm":
|
||||||
|
// Check for gorm tag with primaryKey
|
||||||
|
gormTag := field.Tag.Get("gorm")
|
||||||
|
if strings.Contains(gormTag, "primaryKey") {
|
||||||
|
// Try to extract column name from gorm tag
|
||||||
|
if colName := extractColumnFromGormTag(gormTag); colName != "" {
|
||||||
|
return colName
|
||||||
|
}
|
||||||
|
// Fall back to json tag
|
||||||
|
if jsonTag := field.Tag.Get("json"); jsonTag != "" {
|
||||||
|
return strings.Split(jsonTag, ",")[0]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "bun":
|
||||||
|
// Check for bun tag with pk flag
|
||||||
|
bunTag := field.Tag.Get("bun")
|
||||||
|
if strings.Contains(bunTag, "pk") {
|
||||||
|
// Extract column name from bun tag
|
||||||
|
if colName := extractColumnFromBunTag(bunTag); colName != "" {
|
||||||
|
return colName
|
||||||
|
}
|
||||||
|
// Fall back to json tag
|
||||||
|
if jsonTag := field.Tag.Get("json"); jsonTag != "" {
|
||||||
|
return strings.Split(jsonTag, ",")[0]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractColumnFromGormTag extracts the column name from a gorm tag
|
||||||
|
// Example: "column:id;primaryKey" -> "id"
|
||||||
|
func extractColumnFromGormTag(tag string) string {
|
||||||
|
parts := strings.Split(tag, ";")
|
||||||
|
for _, part := range parts {
|
||||||
|
part = strings.TrimSpace(part)
|
||||||
|
if colName, found := strings.CutPrefix(part, "column:"); found {
|
||||||
|
return colName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractColumnFromBunTag extracts the column name from a bun tag
|
||||||
|
// Example: "id,pk" -> "id"
|
||||||
|
// Example: ",pk" -> "" (will fall back to json tag)
|
||||||
|
func extractColumnFromBunTag(tag string) string {
|
||||||
|
parts := strings.Split(tag, ",")
|
||||||
|
if len(parts) > 0 && parts[0] != "" {
|
||||||
|
return parts[0]
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|||||||
233
pkg/common/adapters/database/utils_test.go
Normal file
233
pkg/common/adapters/database/utils_test.go
Normal file
@@ -0,0 +1,233 @@
|
|||||||
|
package database
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Test models for GORM
|
||||||
|
type GormModelWithGetIDName struct {
|
||||||
|
ID int `gorm:"column:rid_test;primaryKey" json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m GormModelWithGetIDName) GetIDName() string {
|
||||||
|
return "rid_test"
|
||||||
|
}
|
||||||
|
|
||||||
|
type GormModelWithColumnTag struct {
|
||||||
|
ID int `gorm:"column:custom_id;primaryKey" json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type GormModelWithJSONFallback struct {
|
||||||
|
ID int `gorm:"primaryKey" json:"user_id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test models for Bun
|
||||||
|
type BunModelWithGetIDName struct {
|
||||||
|
ID int `bun:"rid_test,pk" json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m BunModelWithGetIDName) GetIDName() string {
|
||||||
|
return "rid_test"
|
||||||
|
}
|
||||||
|
|
||||||
|
type BunModelWithColumnTag struct {
|
||||||
|
ID int `bun:"custom_id,pk" json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type BunModelWithJSONFallback struct {
|
||||||
|
ID int `bun:",pk" json:"user_id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetPrimaryKeyName(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
model any
|
||||||
|
expected string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "GORM model with GetIDName method",
|
||||||
|
model: GormModelWithGetIDName{},
|
||||||
|
expected: "rid_test",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "GORM model with column tag",
|
||||||
|
model: GormModelWithColumnTag{},
|
||||||
|
expected: "custom_id",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "GORM model with JSON fallback",
|
||||||
|
model: GormModelWithJSONFallback{},
|
||||||
|
expected: "user_id",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "GORM model pointer with GetIDName",
|
||||||
|
model: &GormModelWithGetIDName{},
|
||||||
|
expected: "rid_test",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "GORM model pointer with column tag",
|
||||||
|
model: &GormModelWithColumnTag{},
|
||||||
|
expected: "custom_id",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Bun model with GetIDName method",
|
||||||
|
model: BunModelWithGetIDName{},
|
||||||
|
expected: "rid_test",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Bun model with column tag",
|
||||||
|
model: BunModelWithColumnTag{},
|
||||||
|
expected: "custom_id",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Bun model with JSON fallback",
|
||||||
|
model: BunModelWithJSONFallback{},
|
||||||
|
expected: "user_id",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Bun model pointer with GetIDName",
|
||||||
|
model: &BunModelWithGetIDName{},
|
||||||
|
expected: "rid_test",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Bun model pointer with column tag",
|
||||||
|
model: &BunModelWithColumnTag{},
|
||||||
|
expected: "custom_id",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
result := GetPrimaryKeyName(tt.model)
|
||||||
|
if result != tt.expected {
|
||||||
|
t.Errorf("GetPrimaryKeyName() = %v, want %v", result, tt.expected)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractColumnFromGormTag(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
tag string
|
||||||
|
expected string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "column tag with primaryKey",
|
||||||
|
tag: "column:rid_test;primaryKey",
|
||||||
|
expected: "rid_test",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "column tag with spaces",
|
||||||
|
tag: "column:user_id ; primaryKey ; autoIncrement",
|
||||||
|
expected: "user_id",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "no column tag",
|
||||||
|
tag: "primaryKey;autoIncrement",
|
||||||
|
expected: "",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
result := extractColumnFromGormTag(tt.tag)
|
||||||
|
if result != tt.expected {
|
||||||
|
t.Errorf("extractColumnFromGormTag() = %v, want %v", result, tt.expected)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractColumnFromBunTag(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
tag string
|
||||||
|
expected string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "column name with pk flag",
|
||||||
|
tag: "rid_test,pk",
|
||||||
|
expected: "rid_test",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "only pk flag",
|
||||||
|
tag: ",pk",
|
||||||
|
expected: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "column with multiple flags",
|
||||||
|
tag: "user_id,pk,autoincrement",
|
||||||
|
expected: "user_id",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
result := extractColumnFromBunTag(tt.tag)
|
||||||
|
if result != tt.expected {
|
||||||
|
t.Errorf("extractColumnFromBunTag() = %v, want %v", result, tt.expected)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetModelColumns(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
model any
|
||||||
|
expected []string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "Bun model with multiple columns",
|
||||||
|
model: BunModelWithColumnTag{},
|
||||||
|
expected: []string{"custom_id", "name"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "GORM model with multiple columns",
|
||||||
|
model: GormModelWithColumnTag{},
|
||||||
|
expected: []string{"custom_id", "name"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Bun model pointer",
|
||||||
|
model: &BunModelWithColumnTag{},
|
||||||
|
expected: []string{"custom_id", "name"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "GORM model pointer",
|
||||||
|
model: &GormModelWithColumnTag{},
|
||||||
|
expected: []string{"custom_id", "name"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Bun model with JSON fallback",
|
||||||
|
model: BunModelWithJSONFallback{},
|
||||||
|
expected: []string{"user_id", "name"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "GORM model with JSON fallback",
|
||||||
|
model: GormModelWithJSONFallback{},
|
||||||
|
expected: []string{"user_id", "name"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
result := GetModelColumns(tt.model)
|
||||||
|
if len(result) != len(tt.expected) {
|
||||||
|
t.Errorf("GetModelColumns() returned %d columns, want %d", len(result), len(tt.expected))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for i, col := range result {
|
||||||
|
if col != tt.expected[i] {
|
||||||
|
t.Errorf("GetModelColumns()[%d] = %v, want %v", i, col, tt.expected[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@ package router
|
|||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/Warky-Devs/ResolveSpec/pkg/common"
|
"github.com/bitechdev/ResolveSpec/pkg/common"
|
||||||
"github.com/uptrace/bunrouter"
|
"github.com/uptrace/bunrouter"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -190,4 +190,3 @@ func DefaultBunRouterConfig() *BunRouterConfig {
|
|||||||
HandleOPTIONS: true,
|
HandleOPTIONS: true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/Warky-Devs/ResolveSpec/pkg/common"
|
"github.com/bitechdev/ResolveSpec/pkg/common"
|
||||||
"github.com/gorilla/mux"
|
"github.com/gorilla/mux"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -131,6 +131,11 @@ type TableNameProvider interface {
|
|||||||
TableName() string
|
TableName() string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PrimaryKeyNameProvider interface for models that provide primary key column names
|
||||||
|
type PrimaryKeyNameProvider interface {
|
||||||
|
GetIDName() string
|
||||||
|
}
|
||||||
|
|
||||||
// SchemaProvider interface for models that provide schema names
|
// SchemaProvider interface for models that provide schema names
|
||||||
type SchemaProvider interface {
|
type SchemaProvider interface {
|
||||||
SchemaName() string
|
SchemaName() string
|
||||||
|
|||||||
@@ -37,9 +37,10 @@ type PreloadOption struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type FilterOption struct {
|
type FilterOption struct {
|
||||||
Column string `json:"column"`
|
Column string `json:"column"`
|
||||||
Operator string `json:"operator"`
|
Operator string `json:"operator"`
|
||||||
Value interface{} `json:"value"`
|
Value interface{} `json:"value"`
|
||||||
|
LogicOperator string `json:"logic_operator"` // "AND" or "OR" - how this filter combines with previous filters
|
||||||
}
|
}
|
||||||
|
|
||||||
type SortOption struct {
|
type SortOption struct {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import (
|
|||||||
"reflect"
|
"reflect"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/Warky-Devs/ResolveSpec/pkg/logger"
|
"github.com/bitechdev/ResolveSpec/pkg/logger"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ColumnValidator validates column names against a model's fields
|
// ColumnValidator validates column names against a model's fields
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ import (
|
|||||||
"runtime/debug"
|
"runtime/debug"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/Warky-Devs/ResolveSpec/pkg/common"
|
"github.com/bitechdev/ResolveSpec/pkg/common"
|
||||||
"github.com/Warky-Devs/ResolveSpec/pkg/logger"
|
"github.com/bitechdev/ResolveSpec/pkg/logger"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Handler handles API requests using database and model abstractions
|
// Handler handles API requests using database and model abstractions
|
||||||
@@ -175,11 +175,14 @@ func (h *Handler) handleRead(ctx context.Context, w common.ResponseWriter, id st
|
|||||||
sliceType := reflect.SliceOf(reflect.PointerTo(modelType))
|
sliceType := reflect.SliceOf(reflect.PointerTo(modelType))
|
||||||
modelPtr := reflect.New(sliceType).Interface()
|
modelPtr := reflect.New(sliceType).Interface()
|
||||||
|
|
||||||
// Start with Model() to avoid "Model(nil)" errors in Count()
|
// Start with Model() using the slice pointer to avoid "Model(nil)" errors in Count()
|
||||||
query := h.db.NewSelect().Model(model)
|
// Bun's Model() accepts both single pointers and slice pointers
|
||||||
|
query := h.db.NewSelect().Model(modelPtr)
|
||||||
|
|
||||||
// Only set Table() if the model doesn't provide a table name
|
// Only set Table() if the model doesn't provide a table name via the underlying type
|
||||||
if provider, ok := model.(common.TableNameProvider); !ok || provider.TableName() == "" {
|
// Create a temporary instance to check for TableNameProvider
|
||||||
|
tempInstance := reflect.New(modelType).Interface()
|
||||||
|
if provider, ok := tempInstance.(common.TableNameProvider); !ok || provider.TableName() == "" {
|
||||||
query = query.Table(tableName)
|
query = query.Table(tableName)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,9 +3,9 @@ package resolvespec
|
|||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/Warky-Devs/ResolveSpec/pkg/common/adapters/database"
|
"github.com/bitechdev/ResolveSpec/pkg/common/adapters/database"
|
||||||
"github.com/Warky-Devs/ResolveSpec/pkg/common/adapters/router"
|
"github.com/bitechdev/ResolveSpec/pkg/common/adapters/router"
|
||||||
"github.com/Warky-Devs/ResolveSpec/pkg/modelregistry"
|
"github.com/bitechdev/ResolveSpec/pkg/modelregistry"
|
||||||
"github.com/gorilla/mux"
|
"github.com/gorilla/mux"
|
||||||
"github.com/uptrace/bun"
|
"github.com/uptrace/bun"
|
||||||
"github.com/uptrace/bunrouter"
|
"github.com/uptrace/bunrouter"
|
||||||
|
|||||||
223
pkg/restheadspec/cursor.go
Normal file
223
pkg/restheadspec/cursor.go
Normal file
@@ -0,0 +1,223 @@
|
|||||||
|
package restheadspec
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/bitechdev/ResolveSpec/pkg/common"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CursorDirection defines pagination direction
|
||||||
|
type CursorDirection int
|
||||||
|
|
||||||
|
const (
|
||||||
|
CursorForward CursorDirection = 1
|
||||||
|
CursorBackward CursorDirection = -1
|
||||||
|
)
|
||||||
|
|
||||||
|
// GetCursorFilter generates a SQL `EXISTS` subquery for cursor-based pagination.
|
||||||
|
// It uses the current request's sort, cursor, joins (via Expand), and CQL (via ComputedQL).
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - tableName: name of the main table (e.g. "post")
|
||||||
|
// - pkName: primary key column (e.g. "id")
|
||||||
|
// - modelColumns: optional list of valid main-table columns (for validation). Pass nil to skip.
|
||||||
|
// - expandJoins: optional map[alias]string of JOIN clauses (e.g. "user": "LEFT JOIN user ON ...")
|
||||||
|
//
|
||||||
|
// Returns SQL snippet to embed in WHERE clause.
|
||||||
|
func (opts *ExtendedRequestOptions) GetCursorFilter(
|
||||||
|
tableName string,
|
||||||
|
pkName string,
|
||||||
|
modelColumns []string, // optional: for validation
|
||||||
|
expandJoins map[string]string, // optional: alias → JOIN SQL
|
||||||
|
) (string, error) {
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------- //
|
||||||
|
// 1. Determine active cursor
|
||||||
|
// --------------------------------------------------------------------- //
|
||||||
|
cursorID, direction := opts.getActiveCursor()
|
||||||
|
if cursorID == "" {
|
||||||
|
return "", fmt.Errorf("no cursor provided for table %s", tableName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------- //
|
||||||
|
// 2. Extract sort columns
|
||||||
|
// --------------------------------------------------------------------- //
|
||||||
|
sortItems := opts.getSortColumns()
|
||||||
|
if len(sortItems) == 0 {
|
||||||
|
return "", fmt.Errorf("no sort columns defined")
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------- //
|
||||||
|
// 3. Prepare
|
||||||
|
// --------------------------------------------------------------------- //
|
||||||
|
var whereClauses []string
|
||||||
|
joinSQL := ""
|
||||||
|
reverse := direction < 0
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------- //
|
||||||
|
// 4. Process each sort column
|
||||||
|
// --------------------------------------------------------------------- //
|
||||||
|
for _, s := range sortItems {
|
||||||
|
col := strings.TrimSpace(s.Column)
|
||||||
|
if col == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse: "user.name desc nulls last"
|
||||||
|
parts := strings.Split(col, ".")
|
||||||
|
field := strings.TrimSpace(parts[len(parts)-1])
|
||||||
|
prefix := strings.Join(parts[:len(parts)-1], ".")
|
||||||
|
|
||||||
|
// Direction from struct or string
|
||||||
|
desc := strings.EqualFold(s.Direction, "desc") ||
|
||||||
|
strings.Contains(strings.ToLower(field), "desc")
|
||||||
|
field = opts.cleanSortField(field)
|
||||||
|
|
||||||
|
if reverse {
|
||||||
|
desc = !desc
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve column
|
||||||
|
cursorCol, targetCol, isJoin, err := opts.resolveColumn(
|
||||||
|
field, prefix, tableName, modelColumns,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("WARN: Skipping invalid sort column %q: %v\n", col, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle joins
|
||||||
|
if isJoin && expandJoins != nil {
|
||||||
|
if joinClause, ok := expandJoins[prefix]; ok {
|
||||||
|
jSQL, cRef := rewriteJoin(joinClause, tableName, prefix)
|
||||||
|
joinSQL = jSQL
|
||||||
|
cursorCol = cRef + "." + field
|
||||||
|
targetCol = prefix + "." + field
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build inequality
|
||||||
|
op := "<"
|
||||||
|
if desc {
|
||||||
|
op = ">"
|
||||||
|
}
|
||||||
|
whereClauses = append(whereClauses, fmt.Sprintf("%s %s %s", cursorCol, op, targetCol))
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(whereClauses) == 0 {
|
||||||
|
return "", fmt.Errorf("no valid sort columns after filtering")
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------- //
|
||||||
|
// 5. Build priority OR-AND chain
|
||||||
|
// --------------------------------------------------------------------- //
|
||||||
|
orSQL := buildPriorityChain(whereClauses)
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------- //
|
||||||
|
// 6. Final EXISTS subquery
|
||||||
|
// --------------------------------------------------------------------- //
|
||||||
|
query := fmt.Sprintf(`EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM %s cursor_select
|
||||||
|
%s
|
||||||
|
WHERE cursor_select.%s = %s
|
||||||
|
AND (%s)
|
||||||
|
)`,
|
||||||
|
tableName,
|
||||||
|
joinSQL,
|
||||||
|
pkName,
|
||||||
|
cursorID,
|
||||||
|
orSQL,
|
||||||
|
)
|
||||||
|
|
||||||
|
return query, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------------- //
|
||||||
|
// Helper: get active cursor (forward or backward)
|
||||||
|
func (opts *ExtendedRequestOptions) getActiveCursor() (id string, direction CursorDirection) {
|
||||||
|
if opts.CursorForward != "" {
|
||||||
|
return opts.CursorForward, CursorForward
|
||||||
|
}
|
||||||
|
if opts.CursorBackward != "" {
|
||||||
|
return opts.CursorBackward, CursorBackward
|
||||||
|
}
|
||||||
|
return "", 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper: extract sort columns
|
||||||
|
func (opts *ExtendedRequestOptions) getSortColumns() []common.SortOption {
|
||||||
|
if opts.RequestOptions.Sort != nil {
|
||||||
|
return opts.RequestOptions.Sort
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper: clean sort field (remove desc, asc, nulls)
|
||||||
|
func (opts *ExtendedRequestOptions) cleanSortField(field string) string {
|
||||||
|
f := strings.ToLower(field)
|
||||||
|
for _, token := range []string{"desc", "asc", "nulls last", "nulls first"} {
|
||||||
|
f = strings.ReplaceAll(f, token, "")
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(f)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper: resolve column (main, JSON, CQL, join)
|
||||||
|
func (opts *ExtendedRequestOptions) resolveColumn(
|
||||||
|
field, prefix, tableName string,
|
||||||
|
modelColumns []string,
|
||||||
|
) (cursorCol, targetCol string, isJoin bool, err error) {
|
||||||
|
|
||||||
|
// JSON field
|
||||||
|
if strings.Contains(field, "->") {
|
||||||
|
return "cursor_select." + field, tableName + "." + field, false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CQL via ComputedQL
|
||||||
|
if strings.Contains(strings.ToLower(field), "cql") && opts.ComputedQL != nil {
|
||||||
|
if expr, ok := opts.ComputedQL[field]; ok {
|
||||||
|
return "cursor_select." + expr, expr, false, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Main table column
|
||||||
|
if modelColumns != nil {
|
||||||
|
for _, col := range modelColumns {
|
||||||
|
if strings.EqualFold(col, field) {
|
||||||
|
return "cursor_select." + field, tableName + "." + field, false, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// No validation → allow all main-table fields
|
||||||
|
return "cursor_select." + field, tableName + "." + field, false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Joined column
|
||||||
|
if prefix != "" && prefix != tableName {
|
||||||
|
return "", "", true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", "", false, fmt.Errorf("invalid column: %s", field)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------------- //
|
||||||
|
// Helper: rewrite JOIN clause for cursor subquery
|
||||||
|
func rewriteJoin(joinClause, mainTable, alias string) (joinSQL, cursorAlias string) {
|
||||||
|
joinSQL = strings.ReplaceAll(joinClause, mainTable+".", "cursor_select.")
|
||||||
|
cursorAlias = "cursor_select_" + alias
|
||||||
|
joinSQL = strings.ReplaceAll(joinSQL, " "+alias+" ", " "+cursorAlias+" ")
|
||||||
|
joinSQL = strings.ReplaceAll(joinSQL, " "+alias+".", " "+cursorAlias+".")
|
||||||
|
return joinSQL, cursorAlias
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------------- //
|
||||||
|
// Helper: build OR-AND priority chain
|
||||||
|
func buildPriorityChain(clauses []string) string {
|
||||||
|
var or []string
|
||||||
|
for i := 0; i < len(clauses); i++ {
|
||||||
|
and := strings.Join(clauses[:i+1], "\n AND ")
|
||||||
|
or = append(or, "("+and+")")
|
||||||
|
}
|
||||||
|
return strings.Join(or, "\n OR ")
|
||||||
|
}
|
||||||
@@ -9,8 +9,9 @@ import (
|
|||||||
"runtime/debug"
|
"runtime/debug"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/Warky-Devs/ResolveSpec/pkg/common"
|
"github.com/bitechdev/ResolveSpec/pkg/common"
|
||||||
"github.com/Warky-Devs/ResolveSpec/pkg/logger"
|
"github.com/bitechdev/ResolveSpec/pkg/common/adapters/database"
|
||||||
|
"github.com/bitechdev/ResolveSpec/pkg/logger"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Handler handles API requests using database and model abstractions
|
// Handler handles API requests using database and model abstractions
|
||||||
@@ -18,6 +19,7 @@ import (
|
|||||||
type Handler struct {
|
type Handler struct {
|
||||||
db common.Database
|
db common.Database
|
||||||
registry common.ModelRegistry
|
registry common.ModelRegistry
|
||||||
|
hooks *HookRegistry
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewHandler creates a new API handler with database and registry abstractions
|
// NewHandler creates a new API handler with database and registry abstractions
|
||||||
@@ -25,9 +27,16 @@ func NewHandler(db common.Database, registry common.ModelRegistry) *Handler {
|
|||||||
return &Handler{
|
return &Handler{
|
||||||
db: db,
|
db: db,
|
||||||
registry: registry,
|
registry: registry,
|
||||||
|
hooks: NewHookRegistry(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Hooks returns the hook registry for this handler
|
||||||
|
// Use this to register custom hooks for operations
|
||||||
|
func (h *Handler) Hooks() *HookRegistry {
|
||||||
|
return h.hooks
|
||||||
|
}
|
||||||
|
|
||||||
// handlePanic is a helper function to handle panics with stack traces
|
// handlePanic is a helper function to handle panics with stack traces
|
||||||
func (h *Handler) handlePanic(w common.ResponseWriter, method string, err interface{}) {
|
func (h *Handler) handlePanic(w common.ResponseWriter, method string, err interface{}) {
|
||||||
stack := debug.Stack()
|
stack := debug.Stack()
|
||||||
@@ -184,6 +193,25 @@ func (h *Handler) handleRead(ctx context.Context, w common.ResponseWriter, id st
|
|||||||
tableName := GetTableName(ctx)
|
tableName := GetTableName(ctx)
|
||||||
model := GetModel(ctx)
|
model := GetModel(ctx)
|
||||||
|
|
||||||
|
// Execute BeforeRead hooks
|
||||||
|
hookCtx := &HookContext{
|
||||||
|
Context: ctx,
|
||||||
|
Handler: h,
|
||||||
|
Schema: schema,
|
||||||
|
Entity: entity,
|
||||||
|
TableName: tableName,
|
||||||
|
Model: model,
|
||||||
|
Options: options,
|
||||||
|
ID: id,
|
||||||
|
Writer: w,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.hooks.Execute(BeforeRead, hookCtx); err != nil {
|
||||||
|
logger.Error("BeforeRead hook failed: %v", err)
|
||||||
|
h.sendError(w, http.StatusBadRequest, "hook_error", "Hook execution failed", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Validate and unwrap model type to get base struct
|
// Validate and unwrap model type to get base struct
|
||||||
modelType := reflect.TypeOf(model)
|
modelType := reflect.TypeOf(model)
|
||||||
for modelType != nil && (modelType.Kind() == reflect.Ptr || modelType.Kind() == reflect.Slice || modelType.Kind() == reflect.Array) {
|
for modelType != nil && (modelType.Kind() == reflect.Ptr || modelType.Kind() == reflect.Slice || modelType.Kind() == reflect.Array) {
|
||||||
@@ -201,11 +229,14 @@ func (h *Handler) handleRead(ctx context.Context, w common.ResponseWriter, id st
|
|||||||
|
|
||||||
logger.Info("Reading records from %s.%s", schema, entity)
|
logger.Info("Reading records from %s.%s", schema, entity)
|
||||||
|
|
||||||
// Start with Model() to avoid "Model(nil)" errors in Count()
|
// Start with Model() using the slice pointer to avoid "Model(nil)" errors in Count()
|
||||||
query := h.db.NewSelect().Model(model)
|
// Bun's Model() accepts both single pointers and slice pointers
|
||||||
|
query := h.db.NewSelect().Model(modelPtr)
|
||||||
|
|
||||||
// Only set Table() if the model doesn't provide a table name
|
// Only set Table() if the model doesn't provide a table name via the underlying type
|
||||||
if provider, ok := model.(common.TableNameProvider); !ok || provider.TableName() == "" {
|
// Create a temporary instance to check for TableNameProvider
|
||||||
|
tempInstance := reflect.New(modelType).Interface()
|
||||||
|
if provider, ok := tempInstance.(common.TableNameProvider); !ok || provider.TableName() == "" {
|
||||||
query = query.Table(tableName)
|
query = query.Table(tableName)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -236,10 +267,21 @@ func (h *Handler) handleRead(ctx context.Context, w common.ResponseWriter, id st
|
|||||||
// This may need to be handled differently per database adapter
|
// This may need to be handled differently per database adapter
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply filters
|
// Apply filters - validate and adjust for column types first
|
||||||
for _, filter := range options.Filters {
|
for i := range options.Filters {
|
||||||
logger.Debug("Applying filter: %s %s %v", filter.Column, filter.Operator, filter.Value)
|
filter := &options.Filters[i]
|
||||||
query = h.applyFilter(query, filter)
|
|
||||||
|
// Validate and adjust filter based on column type
|
||||||
|
castInfo := h.ValidateAndAdjustFilterForColumnType(filter, model)
|
||||||
|
|
||||||
|
// Default to AND if LogicOperator is not set
|
||||||
|
logicOp := filter.LogicOperator
|
||||||
|
if logicOp == "" {
|
||||||
|
logicOp = "AND"
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Debug("Applying filter: %s %s %v (needsCast=%v, logic=%s)", filter.Column, filter.Operator, filter.Value, castInfo.NeedsCast, logicOp)
|
||||||
|
query = h.applyFilter(query, *filter, tableName, castInfo.NeedsCast, logicOp)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply custom SQL WHERE clause (AND condition)
|
// Apply custom SQL WHERE clause (AND condition)
|
||||||
@@ -296,6 +338,39 @@ func (h *Handler) handleRead(ctx context.Context, w common.ResponseWriter, id st
|
|||||||
query = query.Offset(*options.Offset)
|
query = query.Offset(*options.Offset)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Apply cursor-based pagination
|
||||||
|
if len(options.CursorForward) > 0 || len(options.CursorBackward) > 0 {
|
||||||
|
logger.Debug("Applying cursor pagination")
|
||||||
|
|
||||||
|
// Get primary key name
|
||||||
|
pkName := database.GetPrimaryKeyName(model)
|
||||||
|
|
||||||
|
// Extract model columns for validation using the generic database function
|
||||||
|
modelColumns := database.GetModelColumns(model)
|
||||||
|
|
||||||
|
// Build expand joins map (if needed in future)
|
||||||
|
var expandJoins map[string]string
|
||||||
|
if len(options.Expand) > 0 {
|
||||||
|
expandJoins = make(map[string]string)
|
||||||
|
// TODO: Build actual JOIN SQL for each expand relation
|
||||||
|
// For now, pass empty map as joins are handled via Preload
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get cursor filter SQL
|
||||||
|
cursorFilter, err := options.GetCursorFilter(tableName, pkName, modelColumns, expandJoins)
|
||||||
|
if err != nil {
|
||||||
|
logger.Error("Error building cursor filter: %v", err)
|
||||||
|
h.sendError(w, http.StatusBadRequest, "cursor_error", "Invalid cursor pagination", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply cursor filter to query
|
||||||
|
if cursorFilter != "" {
|
||||||
|
logger.Debug("Applying cursor filter: %s", cursorFilter)
|
||||||
|
query = query.Where(cursorFilter)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Execute query - modelPtr was already created earlier
|
// Execute query - modelPtr was already created earlier
|
||||||
if err := query.Scan(ctx, modelPtr); err != nil {
|
if err := query.Scan(ctx, modelPtr); err != nil {
|
||||||
logger.Error("Error executing query: %v", err)
|
logger.Error("Error executing query: %v", err)
|
||||||
@@ -319,6 +394,16 @@ func (h *Handler) handleRead(ctx context.Context, w common.ResponseWriter, id st
|
|||||||
Offset: offset,
|
Offset: offset,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Execute AfterRead hooks
|
||||||
|
hookCtx.Result = modelPtr
|
||||||
|
hookCtx.Error = nil
|
||||||
|
|
||||||
|
if err := h.hooks.Execute(AfterRead, hookCtx); err != nil {
|
||||||
|
logger.Error("AfterRead hook failed: %v", err)
|
||||||
|
h.sendError(w, http.StatusInternalServerError, "hook_error", "Hook execution failed", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
h.sendFormattedResponse(w, modelPtr, metadata, options)
|
h.sendFormattedResponse(w, modelPtr, metadata, options)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -337,6 +422,28 @@ func (h *Handler) handleCreate(ctx context.Context, w common.ResponseWriter, dat
|
|||||||
|
|
||||||
logger.Info("Creating record in %s.%s", schema, entity)
|
logger.Info("Creating record in %s.%s", schema, entity)
|
||||||
|
|
||||||
|
// Execute BeforeCreate hooks
|
||||||
|
hookCtx := &HookContext{
|
||||||
|
Context: ctx,
|
||||||
|
Handler: h,
|
||||||
|
Schema: schema,
|
||||||
|
Entity: entity,
|
||||||
|
TableName: tableName,
|
||||||
|
Model: model,
|
||||||
|
Options: options,
|
||||||
|
Data: data,
|
||||||
|
Writer: w,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.hooks.Execute(BeforeCreate, hookCtx); err != nil {
|
||||||
|
logger.Error("BeforeCreate hook failed: %v", err)
|
||||||
|
h.sendError(w, http.StatusBadRequest, "hook_error", "Hook execution failed", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use potentially modified data from hook context
|
||||||
|
data = hookCtx.Data
|
||||||
|
|
||||||
// Handle batch creation
|
// Handle batch creation
|
||||||
dataValue := reflect.ValueOf(data)
|
dataValue := reflect.ValueOf(data)
|
||||||
if dataValue.Kind() == reflect.Slice || dataValue.Kind() == reflect.Array {
|
if dataValue.Kind() == reflect.Slice || dataValue.Kind() == reflect.Array {
|
||||||
@@ -371,6 +478,16 @@ func (h *Handler) handleCreate(ctx context.Context, w common.ResponseWriter, dat
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Execute AfterCreate hooks for batch creation
|
||||||
|
hookCtx.Result = map[string]interface{}{"created": dataValue.Len()}
|
||||||
|
hookCtx.Error = nil
|
||||||
|
|
||||||
|
if err := h.hooks.Execute(AfterCreate, hookCtx); err != nil {
|
||||||
|
logger.Error("AfterCreate hook failed: %v", err)
|
||||||
|
h.sendError(w, http.StatusInternalServerError, "hook_error", "Hook execution failed", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
h.sendResponse(w, map[string]interface{}{"created": dataValue.Len()}, nil)
|
h.sendResponse(w, map[string]interface{}{"created": dataValue.Len()}, nil)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -396,6 +513,16 @@ func (h *Handler) handleCreate(ctx context.Context, w common.ResponseWriter, dat
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Execute AfterCreate hooks for single record creation
|
||||||
|
hookCtx.Result = modelValue
|
||||||
|
hookCtx.Error = nil
|
||||||
|
|
||||||
|
if err := h.hooks.Execute(AfterCreate, hookCtx); err != nil {
|
||||||
|
logger.Error("AfterCreate hook failed: %v", err)
|
||||||
|
h.sendError(w, http.StatusInternalServerError, "hook_error", "Hook execution failed", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
h.sendResponse(w, modelValue, nil)
|
h.sendResponse(w, modelValue, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -410,9 +537,33 @@ func (h *Handler) handleUpdate(ctx context.Context, w common.ResponseWriter, id
|
|||||||
schema := GetSchema(ctx)
|
schema := GetSchema(ctx)
|
||||||
entity := GetEntity(ctx)
|
entity := GetEntity(ctx)
|
||||||
tableName := GetTableName(ctx)
|
tableName := GetTableName(ctx)
|
||||||
|
model := GetModel(ctx)
|
||||||
|
|
||||||
logger.Info("Updating record in %s.%s", schema, entity)
|
logger.Info("Updating record in %s.%s", schema, entity)
|
||||||
|
|
||||||
|
// Execute BeforeUpdate hooks
|
||||||
|
hookCtx := &HookContext{
|
||||||
|
Context: ctx,
|
||||||
|
Handler: h,
|
||||||
|
Schema: schema,
|
||||||
|
Entity: entity,
|
||||||
|
TableName: tableName,
|
||||||
|
Model: model,
|
||||||
|
Options: options,
|
||||||
|
ID: id,
|
||||||
|
Data: data,
|
||||||
|
Writer: w,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.hooks.Execute(BeforeUpdate, hookCtx); err != nil {
|
||||||
|
logger.Error("BeforeUpdate hook failed: %v", err)
|
||||||
|
h.sendError(w, http.StatusBadRequest, "hook_error", "Hook execution failed", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use potentially modified data from hook context
|
||||||
|
data = hookCtx.Data
|
||||||
|
|
||||||
// Convert data to map
|
// Convert data to map
|
||||||
dataMap, ok := data.(map[string]interface{})
|
dataMap, ok := data.(map[string]interface{})
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -448,9 +599,20 @@ func (h *Handler) handleUpdate(ctx context.Context, w common.ResponseWriter, id
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
h.sendResponse(w, map[string]interface{}{
|
// Execute AfterUpdate hooks
|
||||||
|
responseData := map[string]interface{}{
|
||||||
"updated": result.RowsAffected(),
|
"updated": result.RowsAffected(),
|
||||||
}, nil)
|
}
|
||||||
|
hookCtx.Result = responseData
|
||||||
|
hookCtx.Error = nil
|
||||||
|
|
||||||
|
if err := h.hooks.Execute(AfterUpdate, hookCtx); err != nil {
|
||||||
|
logger.Error("AfterUpdate hook failed: %v", err)
|
||||||
|
h.sendError(w, http.StatusInternalServerError, "hook_error", "Hook execution failed", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
h.sendResponse(w, responseData, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) handleDelete(ctx context.Context, w common.ResponseWriter, id string) {
|
func (h *Handler) handleDelete(ctx context.Context, w common.ResponseWriter, id string) {
|
||||||
@@ -464,9 +626,28 @@ func (h *Handler) handleDelete(ctx context.Context, w common.ResponseWriter, id
|
|||||||
schema := GetSchema(ctx)
|
schema := GetSchema(ctx)
|
||||||
entity := GetEntity(ctx)
|
entity := GetEntity(ctx)
|
||||||
tableName := GetTableName(ctx)
|
tableName := GetTableName(ctx)
|
||||||
|
model := GetModel(ctx)
|
||||||
|
|
||||||
logger.Info("Deleting record from %s.%s", schema, entity)
|
logger.Info("Deleting record from %s.%s", schema, entity)
|
||||||
|
|
||||||
|
// Execute BeforeDelete hooks
|
||||||
|
hookCtx := &HookContext{
|
||||||
|
Context: ctx,
|
||||||
|
Handler: h,
|
||||||
|
Schema: schema,
|
||||||
|
Entity: entity,
|
||||||
|
TableName: tableName,
|
||||||
|
Model: model,
|
||||||
|
ID: id,
|
||||||
|
Writer: w,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.hooks.Execute(BeforeDelete, hookCtx); err != nil {
|
||||||
|
logger.Error("BeforeDelete hook failed: %v", err)
|
||||||
|
h.sendError(w, http.StatusBadRequest, "hook_error", "Hook execution failed", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
query := h.db.NewDelete().Table(tableName)
|
query := h.db.NewDelete().Table(tableName)
|
||||||
|
|
||||||
if id == "" {
|
if id == "" {
|
||||||
@@ -483,60 +664,112 @@ func (h *Handler) handleDelete(ctx context.Context, w common.ResponseWriter, id
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
h.sendResponse(w, map[string]interface{}{
|
// Execute AfterDelete hooks
|
||||||
|
responseData := map[string]interface{}{
|
||||||
"deleted": result.RowsAffected(),
|
"deleted": result.RowsAffected(),
|
||||||
}, nil)
|
}
|
||||||
|
hookCtx.Result = responseData
|
||||||
|
hookCtx.Error = nil
|
||||||
|
|
||||||
|
if err := h.hooks.Execute(AfterDelete, hookCtx); err != nil {
|
||||||
|
logger.Error("AfterDelete hook failed: %v", err)
|
||||||
|
h.sendError(w, http.StatusInternalServerError, "hook_error", "Hook execution failed", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
h.sendResponse(w, responseData, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) applyFilter(query common.SelectQuery, filter common.FilterOption) common.SelectQuery {
|
// qualifyColumnName ensures column name is fully qualified with table name if not already
|
||||||
|
func (h *Handler) qualifyColumnName(columnName, fullTableName string) string {
|
||||||
|
// Check if column already has a table/schema prefix (contains a dot)
|
||||||
|
if strings.Contains(columnName, ".") {
|
||||||
|
return columnName
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no table name provided, return column as-is
|
||||||
|
if fullTableName == "" {
|
||||||
|
return columnName
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract just the table name from "schema.table" format
|
||||||
|
// Only use the table name part, not the schema
|
||||||
|
tableOnly := fullTableName
|
||||||
|
if idx := strings.LastIndex(fullTableName, "."); idx != -1 {
|
||||||
|
tableOnly = fullTableName[idx+1:]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return column qualified with just the table name
|
||||||
|
return fmt.Sprintf("%s.%s", tableOnly, columnName)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) applyFilter(query common.SelectQuery, filter common.FilterOption, tableName string, needsCast bool, logicOp string) common.SelectQuery {
|
||||||
|
// Qualify the column name with table name if not already qualified
|
||||||
|
qualifiedColumn := h.qualifyColumnName(filter.Column, tableName)
|
||||||
|
|
||||||
|
// Apply casting to text if needed for non-numeric columns or non-numeric values
|
||||||
|
if needsCast {
|
||||||
|
qualifiedColumn = fmt.Sprintf("CAST(%s AS TEXT)", qualifiedColumn)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to apply the correct Where method based on logic operator
|
||||||
|
applyWhere := func(condition string, args ...interface{}) common.SelectQuery {
|
||||||
|
if logicOp == "OR" {
|
||||||
|
return query.WhereOr(condition, args...)
|
||||||
|
}
|
||||||
|
return query.Where(condition, args...)
|
||||||
|
}
|
||||||
|
|
||||||
switch strings.ToLower(filter.Operator) {
|
switch strings.ToLower(filter.Operator) {
|
||||||
case "eq", "equals":
|
case "eq", "equals":
|
||||||
return query.Where(fmt.Sprintf("%s = ?", filter.Column), filter.Value)
|
return applyWhere(fmt.Sprintf("%s = ?", qualifiedColumn), filter.Value)
|
||||||
case "neq", "not_equals", "ne":
|
case "neq", "not_equals", "ne":
|
||||||
return query.Where(fmt.Sprintf("%s != ?", filter.Column), filter.Value)
|
return applyWhere(fmt.Sprintf("%s != ?", qualifiedColumn), filter.Value)
|
||||||
case "gt", "greater_than":
|
case "gt", "greater_than":
|
||||||
return query.Where(fmt.Sprintf("%s > ?", filter.Column), filter.Value)
|
return applyWhere(fmt.Sprintf("%s > ?", qualifiedColumn), filter.Value)
|
||||||
case "gte", "greater_than_equals", "ge":
|
case "gte", "greater_than_equals", "ge":
|
||||||
return query.Where(fmt.Sprintf("%s >= ?", filter.Column), filter.Value)
|
return applyWhere(fmt.Sprintf("%s >= ?", qualifiedColumn), filter.Value)
|
||||||
case "lt", "less_than":
|
case "lt", "less_than":
|
||||||
return query.Where(fmt.Sprintf("%s < ?", filter.Column), filter.Value)
|
return applyWhere(fmt.Sprintf("%s < ?", qualifiedColumn), filter.Value)
|
||||||
case "lte", "less_than_equals", "le":
|
case "lte", "less_than_equals", "le":
|
||||||
return query.Where(fmt.Sprintf("%s <= ?", filter.Column), filter.Value)
|
return applyWhere(fmt.Sprintf("%s <= ?", qualifiedColumn), filter.Value)
|
||||||
case "like":
|
case "like":
|
||||||
return query.Where(fmt.Sprintf("%s LIKE ?", filter.Column), filter.Value)
|
return applyWhere(fmt.Sprintf("%s LIKE ?", qualifiedColumn), filter.Value)
|
||||||
case "ilike":
|
case "ilike":
|
||||||
// Use ILIKE for case-insensitive search (PostgreSQL)
|
// Use ILIKE for case-insensitive search (PostgreSQL)
|
||||||
// For other databases, cast to citext or use LOWER()
|
// Column is already cast to TEXT if needed
|
||||||
return query.Where(fmt.Sprintf("CAST(%s AS TEXT) ILIKE ?", filter.Column), filter.Value)
|
return applyWhere(fmt.Sprintf("%s ILIKE ?", qualifiedColumn), filter.Value)
|
||||||
case "in":
|
case "in":
|
||||||
return query.Where(fmt.Sprintf("%s IN (?)", filter.Column), filter.Value)
|
return applyWhere(fmt.Sprintf("%s IN (?)", qualifiedColumn), filter.Value)
|
||||||
case "between":
|
case "between":
|
||||||
// Handle between operator - exclusive (> val1 AND < val2)
|
// Handle between operator - exclusive (> val1 AND < val2)
|
||||||
if values, ok := filter.Value.([]interface{}); ok && len(values) == 2 {
|
if values, ok := filter.Value.([]interface{}); ok && len(values) == 2 {
|
||||||
return query.Where(fmt.Sprintf("%s > ? AND %s < ?", filter.Column, filter.Column), values[0], values[1])
|
return applyWhere(fmt.Sprintf("%s > ? AND %s < ?", qualifiedColumn, qualifiedColumn), values[0], values[1])
|
||||||
} else if values, ok := filter.Value.([]string); ok && len(values) == 2 {
|
} else if values, ok := filter.Value.([]string); ok && len(values) == 2 {
|
||||||
return query.Where(fmt.Sprintf("%s > ? AND %s < ?", filter.Column, filter.Column), values[0], values[1])
|
return applyWhere(fmt.Sprintf("%s > ? AND %s < ?", qualifiedColumn, qualifiedColumn), values[0], values[1])
|
||||||
}
|
}
|
||||||
logger.Warn("Invalid BETWEEN filter value format")
|
logger.Warn("Invalid BETWEEN filter value format")
|
||||||
return query
|
return query
|
||||||
case "between_inclusive":
|
case "between_inclusive":
|
||||||
// Handle between inclusive operator - inclusive (>= val1 AND <= val2)
|
// Handle between inclusive operator - inclusive (>= val1 AND <= val2)
|
||||||
if values, ok := filter.Value.([]interface{}); ok && len(values) == 2 {
|
if values, ok := filter.Value.([]interface{}); ok && len(values) == 2 {
|
||||||
return query.Where(fmt.Sprintf("%s >= ? AND %s <= ?", filter.Column, filter.Column), values[0], values[1])
|
return applyWhere(fmt.Sprintf("%s >= ? AND %s <= ?", qualifiedColumn, qualifiedColumn), values[0], values[1])
|
||||||
} else if values, ok := filter.Value.([]string); ok && len(values) == 2 {
|
} else if values, ok := filter.Value.([]string); ok && len(values) == 2 {
|
||||||
return query.Where(fmt.Sprintf("%s >= ? AND %s <= ?", filter.Column, filter.Column), values[0], values[1])
|
return applyWhere(fmt.Sprintf("%s >= ? AND %s <= ?", qualifiedColumn, qualifiedColumn), values[0], values[1])
|
||||||
}
|
}
|
||||||
logger.Warn("Invalid BETWEEN INCLUSIVE filter value format")
|
logger.Warn("Invalid BETWEEN INCLUSIVE filter value format")
|
||||||
return query
|
return query
|
||||||
case "is_null", "isnull":
|
case "is_null", "isnull":
|
||||||
// Check for NULL values
|
// Check for NULL values - don't use cast for NULL checks
|
||||||
return query.Where(fmt.Sprintf("(%s IS NULL OR %s = '')", filter.Column, filter.Column))
|
colName := h.qualifyColumnName(filter.Column, tableName)
|
||||||
|
return applyWhere(fmt.Sprintf("(%s IS NULL OR %s = '')", colName, colName))
|
||||||
case "is_not_null", "isnotnull":
|
case "is_not_null", "isnotnull":
|
||||||
// Check for NOT NULL values
|
// Check for NOT NULL values - don't use cast for NULL checks
|
||||||
return query.Where(fmt.Sprintf("(%s IS NOT NULL AND %s != '')", filter.Column, filter.Column))
|
colName := h.qualifyColumnName(filter.Column, tableName)
|
||||||
|
return applyWhere(fmt.Sprintf("(%s IS NOT NULL AND %s != '')", colName, colName))
|
||||||
default:
|
default:
|
||||||
logger.Warn("Unknown filter operator: %s, defaulting to equals", filter.Operator)
|
logger.Warn("Unknown filter operator: %s, defaulting to equals", filter.Operator)
|
||||||
return query.Where(fmt.Sprintf("%s = ?", filter.Column), filter.Value)
|
return applyWhere(fmt.Sprintf("%s = ?", qualifiedColumn), filter.Value)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,11 +4,12 @@ import (
|
|||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"reflect"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/Warky-Devs/ResolveSpec/pkg/common"
|
"github.com/bitechdev/ResolveSpec/pkg/common"
|
||||||
"github.com/Warky-Devs/ResolveSpec/pkg/logger"
|
"github.com/bitechdev/ResolveSpec/pkg/logger"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ExtendedRequestOptions extends common.RequestOptions with additional features
|
// ExtendedRequestOptions extends common.RequestOptions with additional features
|
||||||
@@ -235,9 +236,10 @@ func (h *Handler) parseNotSelectFields(options *ExtendedRequestOptions, value st
|
|||||||
func (h *Handler) parseFieldFilter(options *ExtendedRequestOptions, headerKey, value string) {
|
func (h *Handler) parseFieldFilter(options *ExtendedRequestOptions, headerKey, value string) {
|
||||||
colName := strings.TrimPrefix(headerKey, "x-fieldfilter-")
|
colName := strings.TrimPrefix(headerKey, "x-fieldfilter-")
|
||||||
options.Filters = append(options.Filters, common.FilterOption{
|
options.Filters = append(options.Filters, common.FilterOption{
|
||||||
Column: colName,
|
Column: colName,
|
||||||
Operator: "eq",
|
Operator: "eq",
|
||||||
Value: value,
|
Value: value,
|
||||||
|
LogicOperator: "AND", // Default to AND
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -246,9 +248,10 @@ func (h *Handler) parseSearchFilter(options *ExtendedRequestOptions, headerKey,
|
|||||||
colName := strings.TrimPrefix(headerKey, "x-searchfilter-")
|
colName := strings.TrimPrefix(headerKey, "x-searchfilter-")
|
||||||
// Use ILIKE for fuzzy search
|
// Use ILIKE for fuzzy search
|
||||||
options.Filters = append(options.Filters, common.FilterOption{
|
options.Filters = append(options.Filters, common.FilterOption{
|
||||||
Column: colName,
|
Column: colName,
|
||||||
Operator: "ilike",
|
Operator: "ilike",
|
||||||
Value: "%" + value + "%",
|
Value: "%" + value + "%",
|
||||||
|
LogicOperator: "AND", // Default to AND
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -277,70 +280,68 @@ func (h *Handler) parseSearchOp(options *ExtendedRequestOptions, headerKey, valu
|
|||||||
colName := parts[1]
|
colName := parts[1]
|
||||||
|
|
||||||
// Map operator names to filter operators
|
// Map operator names to filter operators
|
||||||
filterOp := h.mapSearchOperator(operator, value)
|
filterOp := h.mapSearchOperator(colName, operator, value)
|
||||||
|
|
||||||
|
// Set the logic operator (AND or OR)
|
||||||
|
filterOp.LogicOperator = logicOp
|
||||||
|
|
||||||
options.Filters = append(options.Filters, filterOp)
|
options.Filters = append(options.Filters, filterOp)
|
||||||
|
|
||||||
// Note: OR logic would need special handling in query builder
|
logger.Debug("%s logic filter: %s %s %v", logicOp, colName, filterOp.Operator, filterOp.Value)
|
||||||
// For now, we'll add a comment to indicate OR logic
|
|
||||||
if logicOp == "OR" {
|
|
||||||
// TODO: Implement OR logic in query builder
|
|
||||||
logger.Debug("OR logic filter: %s %s %v", colName, filterOp.Operator, filterOp.Value)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// mapSearchOperator maps search operator names to filter operators
|
// mapSearchOperator maps search operator names to filter operators
|
||||||
func (h *Handler) mapSearchOperator(operator, value string) common.FilterOption {
|
func (h *Handler) mapSearchOperator(colName, operator, value string) common.FilterOption {
|
||||||
operator = strings.ToLower(operator)
|
operator = strings.ToLower(operator)
|
||||||
|
|
||||||
switch operator {
|
switch operator {
|
||||||
case "contains":
|
case "contains", "contain", "like":
|
||||||
return common.FilterOption{Operator: "ilike", Value: "%" + value + "%"}
|
return common.FilterOption{Column: colName, Operator: "ilike", Value: "%" + value + "%"}
|
||||||
case "beginswith", "startswith":
|
case "beginswith", "startswith":
|
||||||
return common.FilterOption{Operator: "ilike", Value: value + "%"}
|
return common.FilterOption{Column: colName, Operator: "ilike", Value: value + "%"}
|
||||||
case "endswith":
|
case "endswith":
|
||||||
return common.FilterOption{Operator: "ilike", Value: "%" + value}
|
return common.FilterOption{Column: colName, Operator: "ilike", Value: "%" + value}
|
||||||
case "equals", "eq":
|
case "equals", "eq", "=":
|
||||||
return common.FilterOption{Operator: "eq", Value: value}
|
return common.FilterOption{Column: colName, Operator: "eq", Value: value}
|
||||||
case "notequals", "neq", "ne":
|
case "notequals", "neq", "ne", "!=", "<>":
|
||||||
return common.FilterOption{Operator: "neq", Value: value}
|
return common.FilterOption{Column: colName, Operator: "neq", Value: value}
|
||||||
case "greaterthan", "gt":
|
case "greaterthan", "gt", ">":
|
||||||
return common.FilterOption{Operator: "gt", Value: value}
|
return common.FilterOption{Column: colName, Operator: "gt", Value: value}
|
||||||
case "lessthan", "lt":
|
case "lessthan", "lt", "<":
|
||||||
return common.FilterOption{Operator: "lt", Value: value}
|
return common.FilterOption{Column: colName, Operator: "lt", Value: value}
|
||||||
case "greaterthanorequal", "gte", "ge":
|
case "greaterthanorequal", "gte", "ge", ">=":
|
||||||
return common.FilterOption{Operator: "gte", Value: value}
|
return common.FilterOption{Column: colName, Operator: "gte", Value: value}
|
||||||
case "lessthanorequal", "lte", "le":
|
case "lessthanorequal", "lte", "le", "<=":
|
||||||
return common.FilterOption{Operator: "lte", Value: value}
|
return common.FilterOption{Column: colName, Operator: "lte", Value: value}
|
||||||
case "between":
|
case "between":
|
||||||
// Parse between values (format: "value1,value2")
|
// Parse between values (format: "value1,value2")
|
||||||
// Between is exclusive (> value1 AND < value2)
|
// Between is exclusive (> value1 AND < value2)
|
||||||
parts := strings.Split(value, ",")
|
parts := strings.Split(value, ",")
|
||||||
if len(parts) == 2 {
|
if len(parts) == 2 {
|
||||||
return common.FilterOption{Operator: "between", Value: parts}
|
return common.FilterOption{Column: colName, Operator: "between", Value: parts}
|
||||||
}
|
}
|
||||||
return common.FilterOption{Operator: "eq", Value: value}
|
return common.FilterOption{Column: colName, Operator: "eq", Value: value}
|
||||||
case "betweeninclusive":
|
case "betweeninclusive":
|
||||||
// Parse between values (format: "value1,value2")
|
// Parse between values (format: "value1,value2")
|
||||||
// Between inclusive is >= value1 AND <= value2
|
// Between inclusive is >= value1 AND <= value2
|
||||||
parts := strings.Split(value, ",")
|
parts := strings.Split(value, ",")
|
||||||
if len(parts) == 2 {
|
if len(parts) == 2 {
|
||||||
return common.FilterOption{Operator: "between_inclusive", Value: parts}
|
return common.FilterOption{Column: colName, Operator: "between_inclusive", Value: parts}
|
||||||
}
|
}
|
||||||
return common.FilterOption{Operator: "eq", Value: value}
|
return common.FilterOption{Column: colName, Operator: "eq", Value: value}
|
||||||
case "in":
|
case "in":
|
||||||
// Parse IN values (format: "value1,value2,value3")
|
// Parse IN values (format: "value1,value2,value3")
|
||||||
values := strings.Split(value, ",")
|
values := strings.Split(value, ",")
|
||||||
return common.FilterOption{Operator: "in", Value: values}
|
return common.FilterOption{Column: colName, Operator: "in", Value: values}
|
||||||
case "empty", "isnull", "null":
|
case "empty", "isnull", "null":
|
||||||
// Check for NULL or empty string
|
// Check for NULL or empty string
|
||||||
return common.FilterOption{Operator: "is_null", Value: nil}
|
return common.FilterOption{Column: colName, Operator: "is_null", Value: nil}
|
||||||
case "notempty", "isnotnull", "notnull":
|
case "notempty", "isnotnull", "notnull":
|
||||||
// Check for NOT NULL
|
// Check for NOT NULL
|
||||||
return common.FilterOption{Operator: "is_not_null", Value: nil}
|
return common.FilterOption{Column: colName, Operator: "is_not_null", Value: nil}
|
||||||
default:
|
default:
|
||||||
logger.Warn("Unknown search operator: %s, defaulting to equals", operator)
|
logger.Warn("Unknown search operator: %s, defaulting to equals", operator)
|
||||||
return common.FilterOption{Operator: "eq", Value: value}
|
return common.FilterOption{Column: colName, Operator: "eq", Value: value}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -427,10 +428,16 @@ func (h *Handler) parseSorting(options *ExtendedRequestOptions, value string) {
|
|||||||
} else if strings.HasPrefix(field, "+") {
|
} else if strings.HasPrefix(field, "+") {
|
||||||
direction = "ASC"
|
direction = "ASC"
|
||||||
colName = strings.TrimPrefix(field, "+")
|
colName = strings.TrimPrefix(field, "+")
|
||||||
|
} else if strings.HasSuffix(field, " desc") {
|
||||||
|
direction = "DESC"
|
||||||
|
colName = strings.TrimSuffix(field, "desc")
|
||||||
|
} else if strings.HasSuffix(field, " asc") {
|
||||||
|
direction = "ASC"
|
||||||
|
colName = strings.TrimSuffix(field, "asc")
|
||||||
}
|
}
|
||||||
|
|
||||||
options.Sort = append(options.Sort, common.SortOption{
|
options.Sort = append(options.Sort, common.SortOption{
|
||||||
Column: colName,
|
Column: strings.Trim(colName, " "),
|
||||||
Direction: direction,
|
Direction: direction,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -462,3 +469,235 @@ func (h *Handler) parseJSONHeader(value string) (map[string]interface{}, error)
|
|||||||
}
|
}
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// getColumnTypeFromModel uses reflection to determine the Go type of a column in a model
|
||||||
|
func (h *Handler) getColumnTypeFromModel(model interface{}, colName string) reflect.Kind {
|
||||||
|
if model == nil {
|
||||||
|
return reflect.Invalid
|
||||||
|
}
|
||||||
|
|
||||||
|
modelType := reflect.TypeOf(model)
|
||||||
|
// Dereference pointer if needed
|
||||||
|
if modelType.Kind() == reflect.Ptr {
|
||||||
|
modelType = modelType.Elem()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure it's a struct
|
||||||
|
if modelType.Kind() != reflect.Struct {
|
||||||
|
return reflect.Invalid
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the field by JSON tag or field name
|
||||||
|
for i := 0; i < modelType.NumField(); i++ {
|
||||||
|
field := modelType.Field(i)
|
||||||
|
|
||||||
|
// Check JSON tag
|
||||||
|
jsonTag := field.Tag.Get("json")
|
||||||
|
if jsonTag != "" {
|
||||||
|
// Parse JSON tag (format: "name,omitempty")
|
||||||
|
parts := strings.Split(jsonTag, ",")
|
||||||
|
if parts[0] == colName {
|
||||||
|
return field.Type.Kind()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check field name (case-insensitive)
|
||||||
|
if strings.EqualFold(field.Name, colName) {
|
||||||
|
return field.Type.Kind()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check snake_case conversion
|
||||||
|
snakeCaseName := toSnakeCase(field.Name)
|
||||||
|
if snakeCaseName == colName {
|
||||||
|
return field.Type.Kind()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return reflect.Invalid
|
||||||
|
}
|
||||||
|
|
||||||
|
// toSnakeCase converts a string from CamelCase to snake_case
|
||||||
|
func toSnakeCase(s string) string {
|
||||||
|
var result strings.Builder
|
||||||
|
for i, r := range s {
|
||||||
|
if i > 0 && r >= 'A' && r <= 'Z' {
|
||||||
|
result.WriteRune('_')
|
||||||
|
}
|
||||||
|
result.WriteRune(r)
|
||||||
|
}
|
||||||
|
return strings.ToLower(result.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// isNumericType checks if a reflect.Kind is a numeric type
|
||||||
|
func isNumericType(kind reflect.Kind) bool {
|
||||||
|
return kind == reflect.Int || kind == reflect.Int8 || kind == reflect.Int16 ||
|
||||||
|
kind == reflect.Int32 || kind == reflect.Int64 || kind == reflect.Uint ||
|
||||||
|
kind == reflect.Uint8 || kind == reflect.Uint16 || kind == reflect.Uint32 ||
|
||||||
|
kind == reflect.Uint64 || kind == reflect.Float32 || kind == reflect.Float64
|
||||||
|
}
|
||||||
|
|
||||||
|
// isStringType checks if a reflect.Kind is a string type
|
||||||
|
func isStringType(kind reflect.Kind) bool {
|
||||||
|
return kind == reflect.String
|
||||||
|
}
|
||||||
|
|
||||||
|
// isBoolType checks if a reflect.Kind is a boolean type
|
||||||
|
func isBoolType(kind reflect.Kind) bool {
|
||||||
|
return kind == reflect.Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// convertToNumericType converts a string value to the appropriate numeric type
|
||||||
|
func convertToNumericType(value string, kind reflect.Kind) (interface{}, error) {
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
|
||||||
|
switch kind {
|
||||||
|
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
|
||||||
|
// Parse as integer
|
||||||
|
bitSize := 64
|
||||||
|
switch kind {
|
||||||
|
case reflect.Int8:
|
||||||
|
bitSize = 8
|
||||||
|
case reflect.Int16:
|
||||||
|
bitSize = 16
|
||||||
|
case reflect.Int32:
|
||||||
|
bitSize = 32
|
||||||
|
}
|
||||||
|
|
||||||
|
intVal, err := strconv.ParseInt(value, 10, bitSize)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid integer value: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return the appropriate type
|
||||||
|
switch kind {
|
||||||
|
case reflect.Int:
|
||||||
|
return int(intVal), nil
|
||||||
|
case reflect.Int8:
|
||||||
|
return int8(intVal), nil
|
||||||
|
case reflect.Int16:
|
||||||
|
return int16(intVal), nil
|
||||||
|
case reflect.Int32:
|
||||||
|
return int32(intVal), nil
|
||||||
|
case reflect.Int64:
|
||||||
|
return intVal, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
|
||||||
|
// Parse as unsigned integer
|
||||||
|
bitSize := 64
|
||||||
|
switch kind {
|
||||||
|
case reflect.Uint8:
|
||||||
|
bitSize = 8
|
||||||
|
case reflect.Uint16:
|
||||||
|
bitSize = 16
|
||||||
|
case reflect.Uint32:
|
||||||
|
bitSize = 32
|
||||||
|
}
|
||||||
|
|
||||||
|
uintVal, err := strconv.ParseUint(value, 10, bitSize)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid unsigned integer value: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return the appropriate type
|
||||||
|
switch kind {
|
||||||
|
case reflect.Uint:
|
||||||
|
return uint(uintVal), nil
|
||||||
|
case reflect.Uint8:
|
||||||
|
return uint8(uintVal), nil
|
||||||
|
case reflect.Uint16:
|
||||||
|
return uint16(uintVal), nil
|
||||||
|
case reflect.Uint32:
|
||||||
|
return uint32(uintVal), nil
|
||||||
|
case reflect.Uint64:
|
||||||
|
return uintVal, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
case reflect.Float32, reflect.Float64:
|
||||||
|
// Parse as float
|
||||||
|
bitSize := 64
|
||||||
|
if kind == reflect.Float32 {
|
||||||
|
bitSize = 32
|
||||||
|
}
|
||||||
|
|
||||||
|
floatVal, err := strconv.ParseFloat(value, bitSize)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid float value: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if kind == reflect.Float32 {
|
||||||
|
return float32(floatVal), nil
|
||||||
|
}
|
||||||
|
return floatVal, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, fmt.Errorf("unsupported numeric type: %v", kind)
|
||||||
|
}
|
||||||
|
|
||||||
|
// isNumericValue checks if a string value can be parsed as a number
|
||||||
|
func isNumericValue(value string) bool {
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
_, err := strconv.ParseFloat(value, 64)
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ColumnCastInfo holds information about whether a column needs casting
|
||||||
|
type ColumnCastInfo struct {
|
||||||
|
NeedsCast bool
|
||||||
|
IsNumericType bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateAndAdjustFilterForColumnType validates and adjusts a filter based on column type
|
||||||
|
// Returns ColumnCastInfo indicating whether the column should be cast to text in SQL
|
||||||
|
func (h *Handler) ValidateAndAdjustFilterForColumnType(filter *common.FilterOption, model interface{}) ColumnCastInfo {
|
||||||
|
if filter == nil || model == nil {
|
||||||
|
return ColumnCastInfo{NeedsCast: false, IsNumericType: false}
|
||||||
|
}
|
||||||
|
|
||||||
|
colType := h.getColumnTypeFromModel(model, filter.Column)
|
||||||
|
if colType == reflect.Invalid {
|
||||||
|
// Column not found in model, no casting needed
|
||||||
|
logger.Debug("Column %s not found in model, skipping type validation", filter.Column)
|
||||||
|
return ColumnCastInfo{NeedsCast: false, IsNumericType: false}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the input value is numeric
|
||||||
|
valueIsNumeric := false
|
||||||
|
if strVal, ok := filter.Value.(string); ok {
|
||||||
|
strVal = strings.Trim(strVal, "%")
|
||||||
|
valueIsNumeric = isNumericValue(strVal)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Adjust based on column type
|
||||||
|
switch {
|
||||||
|
case isNumericType(colType):
|
||||||
|
// Column is numeric
|
||||||
|
if valueIsNumeric {
|
||||||
|
// Value is numeric - try to convert it
|
||||||
|
if strVal, ok := filter.Value.(string); ok {
|
||||||
|
strVal = strings.Trim(strVal, "%")
|
||||||
|
numericVal, err := convertToNumericType(strVal, colType)
|
||||||
|
if err != nil {
|
||||||
|
logger.Debug("Failed to convert value '%s' to numeric type for column %s, will use text cast", strVal, filter.Column)
|
||||||
|
return ColumnCastInfo{NeedsCast: true, IsNumericType: true}
|
||||||
|
}
|
||||||
|
filter.Value = numericVal
|
||||||
|
}
|
||||||
|
// No cast needed - numeric column with numeric value
|
||||||
|
return ColumnCastInfo{NeedsCast: false, IsNumericType: true}
|
||||||
|
} else {
|
||||||
|
// Value is not numeric - cast column to text for comparison
|
||||||
|
logger.Debug("Non-numeric value for numeric column %s, will cast to text", filter.Column)
|
||||||
|
return ColumnCastInfo{NeedsCast: true, IsNumericType: true}
|
||||||
|
}
|
||||||
|
|
||||||
|
case isStringType(colType):
|
||||||
|
// String columns don't need casting
|
||||||
|
return ColumnCastInfo{NeedsCast: false, IsNumericType: false}
|
||||||
|
|
||||||
|
default:
|
||||||
|
// For bool, time.Time, and other complex types - cast to text
|
||||||
|
logger.Debug("Complex type column %s, will cast to text", filter.Column)
|
||||||
|
return ColumnCastInfo{NeedsCast: true, IsNumericType: false}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
140
pkg/restheadspec/hooks.go
Normal file
140
pkg/restheadspec/hooks.go
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
package restheadspec
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/bitechdev/ResolveSpec/pkg/common"
|
||||||
|
"github.com/bitechdev/ResolveSpec/pkg/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
// HookType defines the type of hook to execute
|
||||||
|
type HookType string
|
||||||
|
|
||||||
|
const (
|
||||||
|
// Read operation hooks
|
||||||
|
BeforeRead HookType = "before_read"
|
||||||
|
AfterRead HookType = "after_read"
|
||||||
|
|
||||||
|
// Create operation hooks
|
||||||
|
BeforeCreate HookType = "before_create"
|
||||||
|
AfterCreate HookType = "after_create"
|
||||||
|
|
||||||
|
// Update operation hooks
|
||||||
|
BeforeUpdate HookType = "before_update"
|
||||||
|
AfterUpdate HookType = "after_update"
|
||||||
|
|
||||||
|
// Delete operation hooks
|
||||||
|
BeforeDelete HookType = "before_delete"
|
||||||
|
AfterDelete HookType = "after_delete"
|
||||||
|
)
|
||||||
|
|
||||||
|
// HookContext contains all the data available to a hook
|
||||||
|
type HookContext struct {
|
||||||
|
Context context.Context
|
||||||
|
Handler *Handler // Reference to the handler for accessing database, registry, etc.
|
||||||
|
Schema string
|
||||||
|
Entity string
|
||||||
|
TableName string
|
||||||
|
Model interface{}
|
||||||
|
Options ExtendedRequestOptions
|
||||||
|
|
||||||
|
// Operation-specific fields
|
||||||
|
ID string
|
||||||
|
Data interface{} // For create/update operations
|
||||||
|
Result interface{} // For after hooks
|
||||||
|
Error error // For after hooks
|
||||||
|
QueryFilter string // For read operations
|
||||||
|
|
||||||
|
// Response writer - allows hooks to modify response
|
||||||
|
Writer common.ResponseWriter
|
||||||
|
}
|
||||||
|
|
||||||
|
// HookFunc is the signature for hook functions
|
||||||
|
// It receives a HookContext and can modify it or return an error
|
||||||
|
// If an error is returned, the operation will be aborted
|
||||||
|
type HookFunc func(*HookContext) error
|
||||||
|
|
||||||
|
// HookRegistry manages all registered hooks
|
||||||
|
type HookRegistry struct {
|
||||||
|
hooks map[HookType][]HookFunc
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewHookRegistry creates a new hook registry
|
||||||
|
func NewHookRegistry() *HookRegistry {
|
||||||
|
return &HookRegistry{
|
||||||
|
hooks: make(map[HookType][]HookFunc),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register adds a new hook for the specified hook type
|
||||||
|
func (r *HookRegistry) Register(hookType HookType, hook HookFunc) {
|
||||||
|
if r.hooks == nil {
|
||||||
|
r.hooks = make(map[HookType][]HookFunc)
|
||||||
|
}
|
||||||
|
r.hooks[hookType] = append(r.hooks[hookType], hook)
|
||||||
|
logger.Info("Registered hook for %s (total: %d)", hookType, len(r.hooks[hookType]))
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegisterMultiple registers a hook for multiple hook types
|
||||||
|
func (r *HookRegistry) RegisterMultiple(hookTypes []HookType, hook HookFunc) {
|
||||||
|
for _, hookType := range hookTypes {
|
||||||
|
r.Register(hookType, hook)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute runs all hooks for the specified type in order
|
||||||
|
// If any hook returns an error, execution stops and the error is returned
|
||||||
|
func (r *HookRegistry) Execute(hookType HookType, ctx *HookContext) error {
|
||||||
|
hooks, exists := r.hooks[hookType]
|
||||||
|
if !exists || len(hooks) == 0 {
|
||||||
|
logger.Debug("No hooks registered for %s", hookType)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Debug("Executing %d hook(s) for %s", len(hooks), hookType)
|
||||||
|
|
||||||
|
for i, hook := range hooks {
|
||||||
|
if err := hook(ctx); err != nil {
|
||||||
|
logger.Error("Hook %d for %s failed: %v", i+1, hookType, err)
|
||||||
|
return fmt.Errorf("hook execution failed: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Debug("All hooks for %s executed successfully", hookType)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear removes all hooks for the specified type
|
||||||
|
func (r *HookRegistry) Clear(hookType HookType) {
|
||||||
|
delete(r.hooks, hookType)
|
||||||
|
logger.Info("Cleared all hooks for %s", hookType)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClearAll removes all registered hooks
|
||||||
|
func (r *HookRegistry) ClearAll() {
|
||||||
|
r.hooks = make(map[HookType][]HookFunc)
|
||||||
|
logger.Info("Cleared all hooks")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count returns the number of hooks registered for a specific type
|
||||||
|
func (r *HookRegistry) Count(hookType HookType) int {
|
||||||
|
if hooks, exists := r.hooks[hookType]; exists {
|
||||||
|
return len(hooks)
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// HasHooks returns true if there are any hooks registered for the specified type
|
||||||
|
func (r *HookRegistry) HasHooks(hookType HookType) bool {
|
||||||
|
return r.Count(hookType) > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAllHookTypes returns all hook types that have registered hooks
|
||||||
|
func (r *HookRegistry) GetAllHookTypes() []HookType {
|
||||||
|
types := make([]HookType, 0, len(r.hooks))
|
||||||
|
for hookType := range r.hooks {
|
||||||
|
types = append(types, hookType)
|
||||||
|
}
|
||||||
|
return types
|
||||||
|
}
|
||||||
197
pkg/restheadspec/hooks_example.go
Normal file
197
pkg/restheadspec/hooks_example.go
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
package restheadspec
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/bitechdev/ResolveSpec/pkg/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
// This file contains example implementations showing how to use hooks
|
||||||
|
// These are just examples - you can implement hooks as needed for your application
|
||||||
|
|
||||||
|
// ExampleLoggingHook logs before and after operations
|
||||||
|
func ExampleLoggingHook(hookType HookType) HookFunc {
|
||||||
|
return func(ctx *HookContext) error {
|
||||||
|
logger.Info("[%s] Operation: %s.%s, ID: %s", hookType, ctx.Schema, ctx.Entity, ctx.ID)
|
||||||
|
if ctx.Data != nil {
|
||||||
|
logger.Debug("[%s] Data: %+v", hookType, ctx.Data)
|
||||||
|
}
|
||||||
|
if ctx.Result != nil {
|
||||||
|
logger.Debug("[%s] Result: %+v", hookType, ctx.Result)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExampleValidationHook validates data before create/update operations
|
||||||
|
func ExampleValidationHook(ctx *HookContext) error {
|
||||||
|
// Example: Ensure certain fields are present
|
||||||
|
if dataMap, ok := ctx.Data.(map[string]interface{}); ok {
|
||||||
|
// Check for required fields
|
||||||
|
requiredFields := []string{"name"} // Add your required fields here
|
||||||
|
for _, field := range requiredFields {
|
||||||
|
if _, exists := dataMap[field]; !exists {
|
||||||
|
return fmt.Errorf("required field missing: %s", field)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExampleAuthorizationHook checks if the user has permission to perform the operation
|
||||||
|
func ExampleAuthorizationHook(ctx *HookContext) error {
|
||||||
|
// Example: Check user permissions from context
|
||||||
|
// userID, ok := ctx.Context.Value("user_id").(string)
|
||||||
|
// if !ok {
|
||||||
|
// return fmt.Errorf("unauthorized: no user in context")
|
||||||
|
// }
|
||||||
|
|
||||||
|
// You can access the handler's database or registry if needed
|
||||||
|
// For example, to check permissions in the database:
|
||||||
|
// query := ctx.Handler.db.NewSelect().Table("permissions")...
|
||||||
|
|
||||||
|
// Add your authorization logic here
|
||||||
|
logger.Debug("Authorization check for %s.%s", ctx.Schema, ctx.Entity)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExampleDataTransformHook modifies data before create/update
|
||||||
|
func ExampleDataTransformHook(ctx *HookContext) error {
|
||||||
|
if dataMap, ok := ctx.Data.(map[string]interface{}); ok {
|
||||||
|
// Example: Add a timestamp or user ID
|
||||||
|
// dataMap["updated_at"] = time.Now()
|
||||||
|
// dataMap["updated_by"] = ctx.Context.Value("user_id")
|
||||||
|
|
||||||
|
// Update the context with modified data
|
||||||
|
ctx.Data = dataMap
|
||||||
|
logger.Debug("Data transformed for %s.%s", ctx.Schema, ctx.Entity)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExampleAuditLogHook creates audit log entries for operations
|
||||||
|
func ExampleAuditLogHook(hookType HookType) HookFunc {
|
||||||
|
return func(ctx *HookContext) error {
|
||||||
|
// Example: Log to audit system
|
||||||
|
auditEntry := map[string]interface{}{
|
||||||
|
"operation": hookType,
|
||||||
|
"schema": ctx.Schema,
|
||||||
|
"entity": ctx.Entity,
|
||||||
|
"table_name": ctx.TableName,
|
||||||
|
"id": ctx.ID,
|
||||||
|
}
|
||||||
|
|
||||||
|
if ctx.Error != nil {
|
||||||
|
auditEntry["error"] = ctx.Error.Error()
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Info("Audit log: %+v", auditEntry)
|
||||||
|
|
||||||
|
// In a real application, you would save this to a database using the handler
|
||||||
|
// Example:
|
||||||
|
// query := ctx.Handler.db.NewInsert().Table("audit_logs").Model(&auditEntry)
|
||||||
|
// if _, err := query.Exec(ctx.Context); err != nil {
|
||||||
|
// logger.Error("Failed to save audit log: %v", err)
|
||||||
|
// }
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExampleCacheInvalidationHook invalidates cache after create/update/delete
|
||||||
|
func ExampleCacheInvalidationHook(ctx *HookContext) error {
|
||||||
|
// Example: Invalidate cache for the entity
|
||||||
|
cacheKey := fmt.Sprintf("%s.%s", ctx.Schema, ctx.Entity)
|
||||||
|
logger.Info("Invalidating cache for: %s", cacheKey)
|
||||||
|
|
||||||
|
// Add your cache invalidation logic here
|
||||||
|
// cache.Delete(cacheKey)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExampleFilterSensitiveDataHook removes sensitive data from responses
|
||||||
|
func ExampleFilterSensitiveDataHook(ctx *HookContext) error {
|
||||||
|
// Example: Remove password fields from results
|
||||||
|
// This would be called in AfterRead hooks
|
||||||
|
logger.Debug("Filtering sensitive data for %s.%s", ctx.Schema, ctx.Entity)
|
||||||
|
|
||||||
|
// Add your data filtering logic here
|
||||||
|
// You would iterate through ctx.Result and remove sensitive fields
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExampleRelatedDataHook fetches related data using the handler's database
|
||||||
|
func ExampleRelatedDataHook(ctx *HookContext) error {
|
||||||
|
// Example: Fetch related data after reading the main entity
|
||||||
|
// This hook demonstrates using ctx.Handler to access the database
|
||||||
|
|
||||||
|
if ctx.Entity == "users" && ctx.Result != nil {
|
||||||
|
// Example: Fetch user's recent activity
|
||||||
|
// userID := ... extract from ctx.Result
|
||||||
|
|
||||||
|
// Use the handler's database to query related data
|
||||||
|
// query := ctx.Handler.db.NewSelect().Table("user_activity").Where("user_id = ?", userID)
|
||||||
|
// var activities []Activity
|
||||||
|
// if err := query.Scan(ctx.Context, &activities); err != nil {
|
||||||
|
// logger.Error("Failed to fetch user activities: %v", err)
|
||||||
|
// return err
|
||||||
|
// }
|
||||||
|
|
||||||
|
// Optionally modify the result to include the related data
|
||||||
|
// if resultMap, ok := ctx.Result.(map[string]interface{}); ok {
|
||||||
|
// resultMap["recent_activities"] = activities
|
||||||
|
// }
|
||||||
|
|
||||||
|
logger.Debug("Fetched related data for user entity")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetupExampleHooks demonstrates how to register hooks on a handler
|
||||||
|
func SetupExampleHooks(handler *Handler) {
|
||||||
|
hooks := handler.Hooks()
|
||||||
|
|
||||||
|
// Register logging hooks for all operations
|
||||||
|
hooks.Register(BeforeRead, ExampleLoggingHook(BeforeRead))
|
||||||
|
hooks.Register(AfterRead, ExampleLoggingHook(AfterRead))
|
||||||
|
hooks.Register(BeforeCreate, ExampleLoggingHook(BeforeCreate))
|
||||||
|
hooks.Register(AfterCreate, ExampleLoggingHook(AfterCreate))
|
||||||
|
hooks.Register(BeforeUpdate, ExampleLoggingHook(BeforeUpdate))
|
||||||
|
hooks.Register(AfterUpdate, ExampleLoggingHook(AfterUpdate))
|
||||||
|
hooks.Register(BeforeDelete, ExampleLoggingHook(BeforeDelete))
|
||||||
|
hooks.Register(AfterDelete, ExampleLoggingHook(AfterDelete))
|
||||||
|
|
||||||
|
// Register validation hooks for create/update
|
||||||
|
hooks.Register(BeforeCreate, ExampleValidationHook)
|
||||||
|
hooks.Register(BeforeUpdate, ExampleValidationHook)
|
||||||
|
|
||||||
|
// Register authorization hooks for all operations
|
||||||
|
hooks.RegisterMultiple([]HookType{
|
||||||
|
BeforeRead, BeforeCreate, BeforeUpdate, BeforeDelete,
|
||||||
|
}, ExampleAuthorizationHook)
|
||||||
|
|
||||||
|
// Register data transform hook for create/update
|
||||||
|
hooks.Register(BeforeCreate, ExampleDataTransformHook)
|
||||||
|
hooks.Register(BeforeUpdate, ExampleDataTransformHook)
|
||||||
|
|
||||||
|
// Register audit log hooks for after operations
|
||||||
|
hooks.Register(AfterCreate, ExampleAuditLogHook(AfterCreate))
|
||||||
|
hooks.Register(AfterUpdate, ExampleAuditLogHook(AfterUpdate))
|
||||||
|
hooks.Register(AfterDelete, ExampleAuditLogHook(AfterDelete))
|
||||||
|
|
||||||
|
// Register cache invalidation for after operations
|
||||||
|
hooks.Register(AfterCreate, ExampleCacheInvalidationHook)
|
||||||
|
hooks.Register(AfterUpdate, ExampleCacheInvalidationHook)
|
||||||
|
hooks.Register(AfterDelete, ExampleCacheInvalidationHook)
|
||||||
|
|
||||||
|
// Register sensitive data filtering for read operations
|
||||||
|
hooks.Register(AfterRead, ExampleFilterSensitiveDataHook)
|
||||||
|
|
||||||
|
// Register related data fetching for read operations
|
||||||
|
hooks.Register(AfterRead, ExampleRelatedDataHook)
|
||||||
|
|
||||||
|
logger.Info("Example hooks registered successfully")
|
||||||
|
}
|
||||||
347
pkg/restheadspec/hooks_test.go
Normal file
347
pkg/restheadspec/hooks_test.go
Normal file
@@ -0,0 +1,347 @@
|
|||||||
|
package restheadspec
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestHookRegistry tests the hook registry functionality
|
||||||
|
func TestHookRegistry(t *testing.T) {
|
||||||
|
registry := NewHookRegistry()
|
||||||
|
|
||||||
|
// Test registering a hook
|
||||||
|
called := false
|
||||||
|
hook := func(ctx *HookContext) error {
|
||||||
|
called = true
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
registry.Register(BeforeRead, hook)
|
||||||
|
|
||||||
|
if registry.Count(BeforeRead) != 1 {
|
||||||
|
t.Errorf("Expected 1 hook, got %d", registry.Count(BeforeRead))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test executing a hook
|
||||||
|
ctx := &HookContext{
|
||||||
|
Context: context.Background(),
|
||||||
|
Schema: "test",
|
||||||
|
Entity: "users",
|
||||||
|
}
|
||||||
|
|
||||||
|
err := registry.Execute(BeforeRead, ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("Hook execution failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !called {
|
||||||
|
t.Error("Hook was not called")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHookExecution tests hook execution order
|
||||||
|
func TestHookExecutionOrder(t *testing.T) {
|
||||||
|
registry := NewHookRegistry()
|
||||||
|
|
||||||
|
order := []int{}
|
||||||
|
|
||||||
|
hook1 := func(ctx *HookContext) error {
|
||||||
|
order = append(order, 1)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
hook2 := func(ctx *HookContext) error {
|
||||||
|
order = append(order, 2)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
hook3 := func(ctx *HookContext) error {
|
||||||
|
order = append(order, 3)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
registry.Register(BeforeCreate, hook1)
|
||||||
|
registry.Register(BeforeCreate, hook2)
|
||||||
|
registry.Register(BeforeCreate, hook3)
|
||||||
|
|
||||||
|
ctx := &HookContext{
|
||||||
|
Context: context.Background(),
|
||||||
|
Schema: "test",
|
||||||
|
Entity: "users",
|
||||||
|
}
|
||||||
|
|
||||||
|
err := registry.Execute(BeforeCreate, ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("Hook execution failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(order) != 3 {
|
||||||
|
t.Errorf("Expected 3 hooks to be called, got %d", len(order))
|
||||||
|
}
|
||||||
|
|
||||||
|
if order[0] != 1 || order[1] != 2 || order[2] != 3 {
|
||||||
|
t.Errorf("Hooks executed in wrong order: %v", order)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHookError tests hook error handling
|
||||||
|
func TestHookError(t *testing.T) {
|
||||||
|
registry := NewHookRegistry()
|
||||||
|
|
||||||
|
executed := []string{}
|
||||||
|
|
||||||
|
hook1 := func(ctx *HookContext) error {
|
||||||
|
executed = append(executed, "hook1")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
hook2 := func(ctx *HookContext) error {
|
||||||
|
executed = append(executed, "hook2")
|
||||||
|
return fmt.Errorf("hook2 error")
|
||||||
|
}
|
||||||
|
|
||||||
|
hook3 := func(ctx *HookContext) error {
|
||||||
|
executed = append(executed, "hook3")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
registry.Register(BeforeUpdate, hook1)
|
||||||
|
registry.Register(BeforeUpdate, hook2)
|
||||||
|
registry.Register(BeforeUpdate, hook3)
|
||||||
|
|
||||||
|
ctx := &HookContext{
|
||||||
|
Context: context.Background(),
|
||||||
|
Schema: "test",
|
||||||
|
Entity: "users",
|
||||||
|
}
|
||||||
|
|
||||||
|
err := registry.Execute(BeforeUpdate, ctx)
|
||||||
|
if err == nil {
|
||||||
|
t.Error("Expected error from hook execution")
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(executed) != 2 {
|
||||||
|
t.Errorf("Expected only 2 hooks to be executed, got %d", len(executed))
|
||||||
|
}
|
||||||
|
|
||||||
|
if executed[0] != "hook1" || executed[1] != "hook2" {
|
||||||
|
t.Errorf("Unexpected execution order: %v", executed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHookDataModification tests modifying data in hooks
|
||||||
|
func TestHookDataModification(t *testing.T) {
|
||||||
|
registry := NewHookRegistry()
|
||||||
|
|
||||||
|
modifyHook := func(ctx *HookContext) error {
|
||||||
|
if dataMap, ok := ctx.Data.(map[string]interface{}); ok {
|
||||||
|
dataMap["modified"] = true
|
||||||
|
ctx.Data = dataMap
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
registry.Register(BeforeCreate, modifyHook)
|
||||||
|
|
||||||
|
data := map[string]interface{}{
|
||||||
|
"name": "test",
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := &HookContext{
|
||||||
|
Context: context.Background(),
|
||||||
|
Schema: "test",
|
||||||
|
Entity: "users",
|
||||||
|
Data: data,
|
||||||
|
}
|
||||||
|
|
||||||
|
err := registry.Execute(BeforeCreate, ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("Hook execution failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
modifiedData := ctx.Data.(map[string]interface{})
|
||||||
|
if !modifiedData["modified"].(bool) {
|
||||||
|
t.Error("Data was not modified by hook")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRegisterMultiple tests registering a hook for multiple types
|
||||||
|
func TestRegisterMultiple(t *testing.T) {
|
||||||
|
registry := NewHookRegistry()
|
||||||
|
|
||||||
|
called := 0
|
||||||
|
hook := func(ctx *HookContext) error {
|
||||||
|
called++
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
registry.RegisterMultiple([]HookType{
|
||||||
|
BeforeRead,
|
||||||
|
BeforeCreate,
|
||||||
|
BeforeUpdate,
|
||||||
|
}, hook)
|
||||||
|
|
||||||
|
if registry.Count(BeforeRead) != 1 {
|
||||||
|
t.Error("Hook not registered for BeforeRead")
|
||||||
|
}
|
||||||
|
if registry.Count(BeforeCreate) != 1 {
|
||||||
|
t.Error("Hook not registered for BeforeCreate")
|
||||||
|
}
|
||||||
|
if registry.Count(BeforeUpdate) != 1 {
|
||||||
|
t.Error("Hook not registered for BeforeUpdate")
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := &HookContext{
|
||||||
|
Context: context.Background(),
|
||||||
|
Schema: "test",
|
||||||
|
Entity: "users",
|
||||||
|
}
|
||||||
|
|
||||||
|
registry.Execute(BeforeRead, ctx)
|
||||||
|
registry.Execute(BeforeCreate, ctx)
|
||||||
|
registry.Execute(BeforeUpdate, ctx)
|
||||||
|
|
||||||
|
if called != 3 {
|
||||||
|
t.Errorf("Expected hook to be called 3 times, got %d", called)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestClearHooks tests clearing hooks
|
||||||
|
func TestClearHooks(t *testing.T) {
|
||||||
|
registry := NewHookRegistry()
|
||||||
|
|
||||||
|
hook := func(ctx *HookContext) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
registry.Register(BeforeRead, hook)
|
||||||
|
registry.Register(BeforeCreate, hook)
|
||||||
|
|
||||||
|
if registry.Count(BeforeRead) != 1 {
|
||||||
|
t.Error("Hook not registered")
|
||||||
|
}
|
||||||
|
|
||||||
|
registry.Clear(BeforeRead)
|
||||||
|
|
||||||
|
if registry.Count(BeforeRead) != 0 {
|
||||||
|
t.Error("Hook not cleared")
|
||||||
|
}
|
||||||
|
|
||||||
|
if registry.Count(BeforeCreate) != 1 {
|
||||||
|
t.Error("Wrong hook was cleared")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestClearAllHooks tests clearing all hooks
|
||||||
|
func TestClearAllHooks(t *testing.T) {
|
||||||
|
registry := NewHookRegistry()
|
||||||
|
|
||||||
|
hook := func(ctx *HookContext) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
registry.Register(BeforeRead, hook)
|
||||||
|
registry.Register(BeforeCreate, hook)
|
||||||
|
registry.Register(BeforeUpdate, hook)
|
||||||
|
|
||||||
|
registry.ClearAll()
|
||||||
|
|
||||||
|
if registry.Count(BeforeRead) != 0 || registry.Count(BeforeCreate) != 0 || registry.Count(BeforeUpdate) != 0 {
|
||||||
|
t.Error("Not all hooks were cleared")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHasHooks tests checking if hooks exist
|
||||||
|
func TestHasHooks(t *testing.T) {
|
||||||
|
registry := NewHookRegistry()
|
||||||
|
|
||||||
|
if registry.HasHooks(BeforeRead) {
|
||||||
|
t.Error("Should not have hooks initially")
|
||||||
|
}
|
||||||
|
|
||||||
|
hook := func(ctx *HookContext) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
registry.Register(BeforeRead, hook)
|
||||||
|
|
||||||
|
if !registry.HasHooks(BeforeRead) {
|
||||||
|
t.Error("Should have hooks after registration")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestGetAllHookTypes tests getting all registered hook types
|
||||||
|
func TestGetAllHookTypes(t *testing.T) {
|
||||||
|
registry := NewHookRegistry()
|
||||||
|
|
||||||
|
hook := func(ctx *HookContext) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
registry.Register(BeforeRead, hook)
|
||||||
|
registry.Register(BeforeCreate, hook)
|
||||||
|
registry.Register(AfterUpdate, hook)
|
||||||
|
|
||||||
|
types := registry.GetAllHookTypes()
|
||||||
|
|
||||||
|
if len(types) != 3 {
|
||||||
|
t.Errorf("Expected 3 hook types, got %d", len(types))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify all expected types are present
|
||||||
|
expectedTypes := map[HookType]bool{
|
||||||
|
BeforeRead: true,
|
||||||
|
BeforeCreate: true,
|
||||||
|
AfterUpdate: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, hookType := range types {
|
||||||
|
if !expectedTypes[hookType] {
|
||||||
|
t.Errorf("Unexpected hook type: %s", hookType)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHookContextHandler tests that hooks can access the handler
|
||||||
|
func TestHookContextHandler(t *testing.T) {
|
||||||
|
registry := NewHookRegistry()
|
||||||
|
|
||||||
|
var capturedHandler *Handler
|
||||||
|
|
||||||
|
hook := func(ctx *HookContext) error {
|
||||||
|
// Verify that the handler is accessible from the context
|
||||||
|
if ctx.Handler == nil {
|
||||||
|
return fmt.Errorf("handler is nil in hook context")
|
||||||
|
}
|
||||||
|
capturedHandler = ctx.Handler
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
registry.Register(BeforeRead, hook)
|
||||||
|
|
||||||
|
// Create a mock handler
|
||||||
|
handler := &Handler{
|
||||||
|
hooks: registry,
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := &HookContext{
|
||||||
|
Context: context.Background(),
|
||||||
|
Handler: handler,
|
||||||
|
Schema: "test",
|
||||||
|
Entity: "users",
|
||||||
|
}
|
||||||
|
|
||||||
|
err := registry.Execute(BeforeRead, ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("Hook execution failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if capturedHandler == nil {
|
||||||
|
t.Error("Handler was not captured from hook context")
|
||||||
|
}
|
||||||
|
|
||||||
|
if capturedHandler != handler {
|
||||||
|
t.Error("Captured handler does not match original handler")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -55,9 +55,9 @@ package restheadspec
|
|||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/Warky-Devs/ResolveSpec/pkg/common/adapters/database"
|
"github.com/bitechdev/ResolveSpec/pkg/common/adapters/database"
|
||||||
"github.com/Warky-Devs/ResolveSpec/pkg/common/adapters/router"
|
"github.com/bitechdev/ResolveSpec/pkg/common/adapters/router"
|
||||||
"github.com/Warky-Devs/ResolveSpec/pkg/modelregistry"
|
"github.com/bitechdev/ResolveSpec/pkg/modelregistry"
|
||||||
"github.com/gorilla/mux"
|
"github.com/gorilla/mux"
|
||||||
"github.com/uptrace/bun"
|
"github.com/uptrace/bun"
|
||||||
"github.com/uptrace/bunrouter"
|
"github.com/uptrace/bunrouter"
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ package testmodels
|
|||||||
import (
|
import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/Warky-Devs/ResolveSpec/pkg/modelregistry"
|
"github.com/bitechdev/ResolveSpec/pkg/modelregistry"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Department represents a company department
|
// Department represents a company department
|
||||||
|
|||||||
@@ -1,71 +1,70 @@
|
|||||||
{
|
{
|
||||||
"name": "@warkypublic/resolvespec-js",
|
"name": "@warkypublic/resolvespec-js",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"description": "Client side library for the ResolveSpec API",
|
"description": "Client side library for the ResolveSpec API",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "./src/index.ts",
|
"main": "./src/index.ts",
|
||||||
"module": "./src/index.ts",
|
"module": "./src/index.ts",
|
||||||
"types": "./src/index.ts",
|
"types": "./src/index.ts",
|
||||||
"publishConfig": {
|
"publishConfig": {
|
||||||
"access": "public",
|
"access": "public",
|
||||||
"main": "./dist/index.js",
|
"main": "./dist/index.js",
|
||||||
"module": "./dist/index.js",
|
"module": "./dist/index.js",
|
||||||
"types": "./dist/index.d.ts"
|
"types": "./dist/index.d.ts"
|
||||||
},
|
},
|
||||||
"files": [
|
"files": [
|
||||||
"dist",
|
"dist",
|
||||||
"bin",
|
"bin",
|
||||||
"README.md"
|
"README.md"
|
||||||
],
|
],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"clean": "rm -rf dist",
|
"clean": "rm -rf dist",
|
||||||
"prepublishOnly": "npm run build",
|
"prepublishOnly": "npm run build",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"lint": "eslint src"
|
"lint": "eslint src"
|
||||||
},
|
},
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"string",
|
"string",
|
||||||
"blob",
|
"blob",
|
||||||
"dependencies",
|
"dependencies",
|
||||||
"workspace",
|
"workspace",
|
||||||
"package",
|
"package",
|
||||||
"cli",
|
"cli",
|
||||||
"tools",
|
"tools",
|
||||||
"npm",
|
"npm",
|
||||||
"yarn",
|
"yarn",
|
||||||
"pnpm"
|
"pnpm"
|
||||||
],
|
],
|
||||||
"author": "Hein (Warkanum) Puth",
|
"author": "Hein (Warkanum) Puth",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"semver": "^7.6.3",
|
"semver": "^7.6.3",
|
||||||
"uuid": "^11.0.3"
|
"uuid": "^11.0.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@changesets/cli": "^2.27.10",
|
"@changesets/cli": "^2.27.10",
|
||||||
"@eslint/js": "^9.16.0",
|
"@eslint/js": "^9.16.0",
|
||||||
"@types/jsdom": "^21.1.7",
|
"@types/jsdom": "^21.1.7",
|
||||||
"eslint": "^9.16.0",
|
"eslint": "^9.16.0",
|
||||||
"globals": "^15.13.0",
|
"globals": "^15.13.0",
|
||||||
"jsdom": "^25.0.1",
|
"jsdom": "^25.0.1",
|
||||||
"typescript": "^5.7.2",
|
"typescript": "^5.7.2",
|
||||||
"typescript-eslint": "^8.17.0",
|
"typescript-eslint": "^8.17.0",
|
||||||
"vite": "^6.0.2",
|
"vite": "^6.0.2",
|
||||||
"vite-plugin-dts": "^4.3.0",
|
"vite-plugin-dts": "^4.3.0",
|
||||||
"vitest": "^2.1.8"
|
"vitest": "^2.1.8"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=14.16"
|
"node": ">=14.16"
|
||||||
},
|
},
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "git+https://github.com/Warky-Devs/ResolveSpec"
|
"url": "git+https://github.com/bitechdev/ResolveSpec"
|
||||||
},
|
},
|
||||||
"bugs": {
|
"bugs": {
|
||||||
"url": "https://github.com/Warky-Devs/ResolveSpec/issues"
|
"url": "https://github.com/bitechdev/ResolveSpec/issues"
|
||||||
},
|
},
|
||||||
"homepage": "https://github.com/Warky-Devs/ResolveSpec#readme",
|
"homepage": "https://github.com/bitechdev/ResolveSpec#readme",
|
||||||
"packageManager": "pnpm@9.6.0+sha512.38dc6fba8dba35b39340b9700112c2fe1e12f10b17134715a4aa98ccf7bb035e76fd981cf0bb384dfa98f8d6af5481c2bef2f4266a24bfa20c34eb7147ce0b5e"
|
"packageManager": "pnpm@9.6.0+sha512.38dc6fba8dba35b39340b9700112c2fe1e12f10b17134715a4aa98ccf7bb035e76fd981cf0bb384dfa98f8d6af5481c2bef2f4266a24bfa20c34eb7147ce0b5e"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -10,10 +10,10 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/Warky-Devs/ResolveSpec/pkg/logger"
|
"github.com/bitechdev/ResolveSpec/pkg/logger"
|
||||||
"github.com/Warky-Devs/ResolveSpec/pkg/modelregistry"
|
"github.com/bitechdev/ResolveSpec/pkg/modelregistry"
|
||||||
"github.com/Warky-Devs/ResolveSpec/pkg/resolvespec"
|
"github.com/bitechdev/ResolveSpec/pkg/resolvespec"
|
||||||
"github.com/Warky-Devs/ResolveSpec/pkg/testmodels"
|
"github.com/bitechdev/ResolveSpec/pkg/testmodels"
|
||||||
"github.com/glebarez/sqlite"
|
"github.com/glebarez/sqlite"
|
||||||
"github.com/gorilla/mux"
|
"github.com/gorilla/mux"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
|
|||||||
157
todo.md
Normal file
157
todo.md
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
# ResolveSpec - TODO List
|
||||||
|
|
||||||
|
This document tracks incomplete features and improvements for the ResolveSpec project.
|
||||||
|
|
||||||
|
## Core Features to Implement
|
||||||
|
|
||||||
|
### 1. Column Selection and Filtering for Preloads
|
||||||
|
**Location:** `pkg/resolvespec/handler.go:730`
|
||||||
|
**Status:** Not Implemented
|
||||||
|
**Description:** Currently, preloads are applied without any column selection or filtering. This feature would allow clients to:
|
||||||
|
- Select specific columns for preloaded relationships
|
||||||
|
- Apply filters to preloaded data
|
||||||
|
- Reduce payload size and improve performance
|
||||||
|
|
||||||
|
**Current Limitation:**
|
||||||
|
```go
|
||||||
|
// 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)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Required Implementation:**
|
||||||
|
- Add support for column selection in preloaded relationships
|
||||||
|
- Implement filtering conditions for preloaded data
|
||||||
|
- Design a callback or query builder approach that works across different ORMs
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. Recursive JSON Cleaning
|
||||||
|
**Location:** `pkg/restheadspec/handler.go:796`
|
||||||
|
**Status:** Partially Implemented (Simplified)
|
||||||
|
**Description:** The current `cleanJSON` function returns data as-is without recursively removing null and empty fields from nested structures.
|
||||||
|
|
||||||
|
**Current Limitation:**
|
||||||
|
```go
|
||||||
|
// This is a simplified implementation
|
||||||
|
// A full implementation would recursively clean nested structures
|
||||||
|
// For now, we'll return the data as-is
|
||||||
|
// TODO: Implement recursive cleaning
|
||||||
|
return data
|
||||||
|
```
|
||||||
|
|
||||||
|
**Required Implementation:**
|
||||||
|
- Recursively traverse nested structures (maps, slices, structs)
|
||||||
|
- Remove null values
|
||||||
|
- Remove empty objects and arrays
|
||||||
|
- Handle edge cases (circular references, pointers, etc.)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. Custom SQL Join Support
|
||||||
|
**Location:** `pkg/restheadspec/headers.go:159`
|
||||||
|
**Status:** Not Implemented
|
||||||
|
**Description:** Support for custom SQL joins via the `X-Custom-SQL-Join` header is currently logged but not executed.
|
||||||
|
|
||||||
|
**Current Limitation:**
|
||||||
|
```go
|
||||||
|
case strings.HasPrefix(normalizedKey, "x-custom-sql-join"):
|
||||||
|
// TODO: Implement custom SQL join
|
||||||
|
logger.Debug("Custom SQL join not yet implemented: %s", decodedValue)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Required Implementation:**
|
||||||
|
- Parse custom SQL join expressions from headers
|
||||||
|
- Apply joins to the query builder
|
||||||
|
- Ensure security (SQL injection prevention)
|
||||||
|
- Support for different join types (INNER, LEFT, RIGHT, FULL)
|
||||||
|
- Works across different database adapters (GORM, Bun)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4. Proper Condition Handling for Bun Preloads
|
||||||
|
**Location:** `pkg/common/adapters/database/bun.go:202`
|
||||||
|
**Status:** Partially Implemented
|
||||||
|
**Description:** The Bun adapter's `Preload` method currently ignores conditions passed to it.
|
||||||
|
|
||||||
|
**Current Limitation:**
|
||||||
|
```go
|
||||||
|
func (b *BunSelectQuery) Preload(relation string, conditions ...interface{}) common.SelectQuery {
|
||||||
|
// Bun uses Relation() method for preloading
|
||||||
|
// For now, we'll just pass the relation name without conditions
|
||||||
|
// TODO: Implement proper condition handling for Bun
|
||||||
|
b.query = b.query.Relation(relation)
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Required Implementation:**
|
||||||
|
- Properly handle condition parameters in Bun's Relation() method
|
||||||
|
- Support filtering on preloaded relationships
|
||||||
|
- Ensure compatibility with GORM's condition syntax where possible
|
||||||
|
- Test with various condition types
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Code Quality Improvements
|
||||||
|
|
||||||
|
### 5. Modernize Go Type Declarations
|
||||||
|
**Location:** `pkg/common/types.go:5, 42, 64, 79`
|
||||||
|
**Status:** Pending
|
||||||
|
**Priority:** Low
|
||||||
|
**Description:** Replace legacy `interface{}` with modern `any` type alias (Go 1.18+).
|
||||||
|
|
||||||
|
**Affected Lines:**
|
||||||
|
- Line 5: Function parameter or return type
|
||||||
|
- Line 42: Function parameter or return type
|
||||||
|
- Line 64: Function parameter or return type
|
||||||
|
- Line 79: Function parameter or return type
|
||||||
|
|
||||||
|
**Benefits:**
|
||||||
|
- More modern and idiomatic Go code
|
||||||
|
- Better readability
|
||||||
|
- Aligns with current Go best practices
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 6. Pre / Post select/update/delete query in transaction.
|
||||||
|
- This will allow us to set a user before doing a select
|
||||||
|
- When making changes, we can have the trigger fire with the correct user.
|
||||||
|
- Maybe wrap the handleRead,Update,Create,Delete handlers in a transaction with context that can abort when the request is cancelled or a configurable timeout is reached.
|
||||||
|
|
||||||
|
## Additional Considerations
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
- Ensure all new features are documented in README.md
|
||||||
|
- Update examples to showcase new functionality
|
||||||
|
- Add migration notes if any breaking changes are introduced
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
- Add unit tests for each new feature
|
||||||
|
- Add integration tests for database adapter compatibility
|
||||||
|
- Ensure backward compatibility is maintained
|
||||||
|
|
||||||
|
### Performance
|
||||||
|
- Profile preload performance with column selection and filtering
|
||||||
|
- Optimize recursive JSON cleaning for large payloads
|
||||||
|
- Benchmark custom SQL join performance
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Priority Ranking
|
||||||
|
|
||||||
|
1. **High Priority**
|
||||||
|
- Column Selection and Filtering for Preloads (#1)
|
||||||
|
- Proper Condition Handling for Bun Preloads (#4)
|
||||||
|
|
||||||
|
2. **Medium Priority**
|
||||||
|
- Custom SQL Join Support (#3)
|
||||||
|
- Recursive JSON Cleaning (#2)
|
||||||
|
|
||||||
|
3. **Low Priority**
|
||||||
|
- Modernize Go Type Declarations (#5)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Last Updated:** 2025-11-07
|
||||||
Reference in New Issue
Block a user