mirror of
https://github.com/bitechdev/ResolveSpec.git
synced 2026-02-12 03:26:08 +00:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fd77385dd6 | ||
|
|
b322ef76a2 | ||
|
|
a6c7edb0e4 |
572
pkg/resolvespec/EXAMPLES.md
Normal file
572
pkg/resolvespec/EXAMPLES.md
Normal file
@@ -0,0 +1,572 @@
|
|||||||
|
# ResolveSpec Query Features Examples
|
||||||
|
|
||||||
|
This document provides examples of using the advanced query features in ResolveSpec, including OR logic filters, Custom Operators, and FetchRowNumber.
|
||||||
|
|
||||||
|
## OR Logic in Filters (SearchOr)
|
||||||
|
|
||||||
|
### Basic OR Filter Example
|
||||||
|
|
||||||
|
Find all users with status "active" OR "pending":
|
||||||
|
|
||||||
|
```json
|
||||||
|
POST /users
|
||||||
|
{
|
||||||
|
"operation": "read",
|
||||||
|
"options": {
|
||||||
|
"filters": [
|
||||||
|
{
|
||||||
|
"column": "status",
|
||||||
|
"operator": "eq",
|
||||||
|
"value": "active"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"column": "status",
|
||||||
|
"operator": "eq",
|
||||||
|
"value": "pending",
|
||||||
|
"logic_operator": "OR"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Combined AND/OR Filters
|
||||||
|
|
||||||
|
Find users with (status="active" OR status="pending") AND age >= 18:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"operation": "read",
|
||||||
|
"options": {
|
||||||
|
"filters": [
|
||||||
|
{
|
||||||
|
"column": "status",
|
||||||
|
"operator": "eq",
|
||||||
|
"value": "active"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"column": "status",
|
||||||
|
"operator": "eq",
|
||||||
|
"value": "pending",
|
||||||
|
"logic_operator": "OR"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"column": "age",
|
||||||
|
"operator": "gte",
|
||||||
|
"value": 18
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**SQL Generated:** `WHERE (status = 'active' OR status = 'pending') AND age >= 18`
|
||||||
|
|
||||||
|
**Important Notes:**
|
||||||
|
- By default, filters use AND logic
|
||||||
|
- Consecutive filters with `"logic_operator": "OR"` are automatically grouped with parentheses
|
||||||
|
- This grouping ensures OR conditions don't interfere with AND conditions
|
||||||
|
- You don't need to specify `"logic_operator": "AND"` as it's the default
|
||||||
|
|
||||||
|
### Multiple OR Groups
|
||||||
|
|
||||||
|
You can have multiple separate OR groups:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"operation": "read",
|
||||||
|
"options": {
|
||||||
|
"filters": [
|
||||||
|
{
|
||||||
|
"column": "status",
|
||||||
|
"operator": "eq",
|
||||||
|
"value": "active"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"column": "status",
|
||||||
|
"operator": "eq",
|
||||||
|
"value": "pending",
|
||||||
|
"logic_operator": "OR"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"column": "priority",
|
||||||
|
"operator": "eq",
|
||||||
|
"value": "high"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"column": "priority",
|
||||||
|
"operator": "eq",
|
||||||
|
"value": "urgent",
|
||||||
|
"logic_operator": "OR"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**SQL Generated:** `WHERE (status = 'active' OR status = 'pending') AND (priority = 'high' OR priority = 'urgent')`
|
||||||
|
|
||||||
|
## Custom Operators
|
||||||
|
|
||||||
|
### Simple Custom SQL Condition
|
||||||
|
|
||||||
|
Filter by email domain using custom SQL:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"operation": "read",
|
||||||
|
"options": {
|
||||||
|
"customOperators": [
|
||||||
|
{
|
||||||
|
"name": "company_emails",
|
||||||
|
"sql": "email LIKE '%@company.com'"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Multiple Custom Operators
|
||||||
|
|
||||||
|
Combine multiple custom SQL conditions:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"operation": "read",
|
||||||
|
"options": {
|
||||||
|
"customOperators": [
|
||||||
|
{
|
||||||
|
"name": "recent_active",
|
||||||
|
"sql": "last_login > NOW() - INTERVAL '30 days'"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "high_score",
|
||||||
|
"sql": "score > 1000"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Complex Custom Operator
|
||||||
|
|
||||||
|
Use complex SQL expressions:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"operation": "read",
|
||||||
|
"options": {
|
||||||
|
"customOperators": [
|
||||||
|
{
|
||||||
|
"name": "priority_users",
|
||||||
|
"sql": "(subscription = 'premium' AND points > 500) OR (subscription = 'enterprise')"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Combining Custom Operators with Regular Filters
|
||||||
|
|
||||||
|
Mix custom operators with standard filters:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"operation": "read",
|
||||||
|
"options": {
|
||||||
|
"filters": [
|
||||||
|
{
|
||||||
|
"column": "country",
|
||||||
|
"operator": "eq",
|
||||||
|
"value": "USA"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"customOperators": [
|
||||||
|
{
|
||||||
|
"name": "active_last_month",
|
||||||
|
"sql": "last_activity > NOW() - INTERVAL '1 month'"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Row Numbers
|
||||||
|
|
||||||
|
### Two Ways to Get Row Numbers
|
||||||
|
|
||||||
|
There are two different features for row numbers:
|
||||||
|
|
||||||
|
1. **`fetch_row_number`** - Get the position of ONE specific record in a sorted/filtered set
|
||||||
|
2. **`RowNumber` field in models** - Automatically number all records in the response
|
||||||
|
|
||||||
|
### 1. FetchRowNumber - Get Position of Specific Record
|
||||||
|
|
||||||
|
Get the rank/position of a specific user in a leaderboard. **Important:** When `fetch_row_number` is specified, the response contains **ONLY that specific record**, not all records.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"operation": "read",
|
||||||
|
"options": {
|
||||||
|
"sort": [
|
||||||
|
{
|
||||||
|
"column": "score",
|
||||||
|
"direction": "desc"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"fetch_row_number": "12345"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response - Contains ONLY the specified user:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"data": {
|
||||||
|
"id": 12345,
|
||||||
|
"name": "Alice Smith",
|
||||||
|
"score": 9850,
|
||||||
|
"level": 42
|
||||||
|
},
|
||||||
|
"metadata": {
|
||||||
|
"total": 10000,
|
||||||
|
"count": 1,
|
||||||
|
"filtered": 10000,
|
||||||
|
"row_number": 42
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Result:** User "12345" is ranked #42 out of 10,000 users. The response includes only Alice's data, not the other 9,999 users.
|
||||||
|
|
||||||
|
### Row Number with Filters
|
||||||
|
|
||||||
|
Find position within a filtered subset (e.g., "What's my rank in my country?"):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"operation": "read",
|
||||||
|
"options": {
|
||||||
|
"filters": [
|
||||||
|
{
|
||||||
|
"column": "country",
|
||||||
|
"operator": "eq",
|
||||||
|
"value": "USA"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"column": "status",
|
||||||
|
"operator": "eq",
|
||||||
|
"value": "active"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"sort": [
|
||||||
|
{
|
||||||
|
"column": "score",
|
||||||
|
"direction": "desc"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"fetch_row_number": "12345"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"data": {
|
||||||
|
"id": 12345,
|
||||||
|
"name": "Bob Johnson",
|
||||||
|
"country": "USA",
|
||||||
|
"score": 7200,
|
||||||
|
"status": "active"
|
||||||
|
},
|
||||||
|
"metadata": {
|
||||||
|
"total": 2500,
|
||||||
|
"count": 1,
|
||||||
|
"filtered": 2500,
|
||||||
|
"row_number": 156
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Result:** Bob is ranked #156 out of 2,500 active USA users. Only Bob's record is returned.
|
||||||
|
|
||||||
|
### 2. RowNumber Field - Auto-Number All Records
|
||||||
|
|
||||||
|
If your model has a `RowNumber int64` field, restheadspec will automatically populate it for paginated results.
|
||||||
|
|
||||||
|
**Model Definition:**
|
||||||
|
```go
|
||||||
|
type Player struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Score int64 `json:"score"`
|
||||||
|
RowNumber int64 `json:"row_number"` // Will be auto-populated
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Request (with pagination):**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"operation": "read",
|
||||||
|
"options": {
|
||||||
|
"sort": [{"column": "score", "direction": "desc"}],
|
||||||
|
"limit": 10,
|
||||||
|
"offset": 20
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response - RowNumber automatically set:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"id": 456,
|
||||||
|
"name": "Player21",
|
||||||
|
"score": 8900,
|
||||||
|
"row_number": 21
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 789,
|
||||||
|
"name": "Player22",
|
||||||
|
"score": 8850,
|
||||||
|
"row_number": 22
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 123,
|
||||||
|
"name": "Player23",
|
||||||
|
"score": 8800,
|
||||||
|
"row_number": 23
|
||||||
|
}
|
||||||
|
// ... records 24-30 ...
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**How It Works:**
|
||||||
|
- `row_number = offset + index + 1` (1-based)
|
||||||
|
- With offset=20, first record gets row_number=21
|
||||||
|
- With offset=20, second record gets row_number=22
|
||||||
|
- Perfect for displaying "Rank" in paginated tables
|
||||||
|
|
||||||
|
**Use Case:** Displaying leaderboards with rank numbers:
|
||||||
|
```
|
||||||
|
Rank | Player | Score
|
||||||
|
-----|-----------|-------
|
||||||
|
21 | Player21 | 8900
|
||||||
|
22 | Player22 | 8850
|
||||||
|
23 | Player23 | 8800
|
||||||
|
```
|
||||||
|
|
||||||
|
**Note:** This feature is available in all three packages: resolvespec, restheadspec, and websocketspec.
|
||||||
|
|
||||||
|
### When to Use Each Feature
|
||||||
|
|
||||||
|
| Feature | Use Case | Returns | Performance |
|
||||||
|
|---------|----------|---------|-------------|
|
||||||
|
| `fetch_row_number` | "What's my rank?" | 1 record with position | Fast - 1 record |
|
||||||
|
| `RowNumber` field | "Show top 10 with ranks" | Many records numbered | Fast - simple math |
|
||||||
|
|
||||||
|
**Combined Example - Full Leaderboard UI:**
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Request 1: Get current user's rank
|
||||||
|
const userRank = await api.read({
|
||||||
|
fetch_row_number: currentUserId,
|
||||||
|
sort: [{column: "score", direction: "desc"}]
|
||||||
|
});
|
||||||
|
// Returns: {id: 123, name: "You", score: 7500, row_number: 156}
|
||||||
|
|
||||||
|
// Request 2: Get top 10 with rank numbers
|
||||||
|
const top10 = await api.read({
|
||||||
|
sort: [{column: "score", direction: "desc"}],
|
||||||
|
limit: 10,
|
||||||
|
offset: 0
|
||||||
|
});
|
||||||
|
// Returns: [{row_number: 1, ...}, {row_number: 2, ...}, ...]
|
||||||
|
|
||||||
|
// Display:
|
||||||
|
// "Your Rank: #156"
|
||||||
|
// "Top Players:"
|
||||||
|
// "#1 - Alice - 9999"
|
||||||
|
// "#2 - Bob - 9876"
|
||||||
|
// ...
|
||||||
|
```
|
||||||
|
|
||||||
|
## Complete Example: Advanced Query
|
||||||
|
|
||||||
|
Combine all features for a complex query:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"operation": "read",
|
||||||
|
"options": {
|
||||||
|
"columns": ["id", "name", "email", "score", "status"],
|
||||||
|
"filters": [
|
||||||
|
{
|
||||||
|
"column": "status",
|
||||||
|
"operator": "eq",
|
||||||
|
"value": "active"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"column": "status",
|
||||||
|
"operator": "eq",
|
||||||
|
"value": "trial",
|
||||||
|
"logic_operator": "OR"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"column": "score",
|
||||||
|
"operator": "gte",
|
||||||
|
"value": 100
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"customOperators": [
|
||||||
|
{
|
||||||
|
"name": "recent_activity",
|
||||||
|
"sql": "last_login > NOW() - INTERVAL '7 days'"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "verified_email",
|
||||||
|
"sql": "email_verified = true"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"sort": [
|
||||||
|
{
|
||||||
|
"column": "score",
|
||||||
|
"direction": "desc"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"column": "created_at",
|
||||||
|
"direction": "asc"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"fetch_row_number": "12345",
|
||||||
|
"limit": 50,
|
||||||
|
"offset": 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
This query:
|
||||||
|
- Selects specific columns
|
||||||
|
- Filters for users with status "active" OR "trial"
|
||||||
|
- AND score >= 100
|
||||||
|
- Applies custom SQL conditions for recent activity and verified emails
|
||||||
|
- Sorts by score (descending) then creation date (ascending)
|
||||||
|
- Returns the row number of user "12345" in this filtered/sorted set
|
||||||
|
- Returns 50 records starting from the first one
|
||||||
|
|
||||||
|
## Use Cases
|
||||||
|
|
||||||
|
### 1. Leaderboards - Get Current User's Rank
|
||||||
|
|
||||||
|
Get the current user's position and data (returns only their record):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"operation": "read",
|
||||||
|
"options": {
|
||||||
|
"filters": [
|
||||||
|
{
|
||||||
|
"column": "game_id",
|
||||||
|
"operator": "eq",
|
||||||
|
"value": "game123"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"sort": [
|
||||||
|
{
|
||||||
|
"column": "score",
|
||||||
|
"direction": "desc"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"fetch_row_number": "current_user_id"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Tip:** For full leaderboards, make two requests:
|
||||||
|
1. One with `fetch_row_number` to get user's rank
|
||||||
|
2. One with `limit` and `offset` to get top players list
|
||||||
|
|
||||||
|
### 2. Multi-Status Search
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"operation": "read",
|
||||||
|
"options": {
|
||||||
|
"filters": [
|
||||||
|
{
|
||||||
|
"column": "order_status",
|
||||||
|
"operator": "eq",
|
||||||
|
"value": "pending"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"column": "order_status",
|
||||||
|
"operator": "eq",
|
||||||
|
"value": "processing",
|
||||||
|
"logic_operator": "OR"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"column": "order_status",
|
||||||
|
"operator": "eq",
|
||||||
|
"value": "shipped",
|
||||||
|
"logic_operator": "OR"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Advanced Date Filtering
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"operation": "read",
|
||||||
|
"options": {
|
||||||
|
"customOperators": [
|
||||||
|
{
|
||||||
|
"name": "this_month",
|
||||||
|
"sql": "created_at >= DATE_TRUNC('month', CURRENT_DATE)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "business_hours",
|
||||||
|
"sql": "EXTRACT(HOUR FROM created_at) BETWEEN 9 AND 17"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Security Considerations
|
||||||
|
|
||||||
|
**Warning:** Custom operators allow raw SQL, which can be a security risk if not properly handled:
|
||||||
|
|
||||||
|
1. **Never** directly interpolate user input into custom operator SQL
|
||||||
|
2. Always validate and sanitize custom operator SQL on the backend
|
||||||
|
3. Consider using a whitelist of allowed custom operators
|
||||||
|
4. Use prepared statements or parameterized queries when possible
|
||||||
|
5. Implement proper authorization checks before executing queries
|
||||||
|
|
||||||
|
Example of safe custom operator handling in Go:
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Whitelist of allowed custom operators
|
||||||
|
allowedOperators := map[string]string{
|
||||||
|
"recent_week": "created_at > NOW() - INTERVAL '7 days'",
|
||||||
|
"active_users": "status = 'active' AND last_login > NOW() - INTERVAL '30 days'",
|
||||||
|
"premium_only": "subscription_level = 'premium'",
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate custom operators from request
|
||||||
|
for _, op := range req.Options.CustomOperators {
|
||||||
|
if sql, ok := allowedOperators[op.Name]; ok {
|
||||||
|
op.SQL = sql // Use whitelisted SQL
|
||||||
|
} else {
|
||||||
|
return errors.New("custom operator not allowed: " + op.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -214,6 +214,146 @@ Content-Type: application/json
|
|||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
|
"filters": [
|
||||||
|
{
|
||||||
|
"column": "status",
|
||||||
|
"operator": "eq",
|
||||||
|
"value": "active"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"column": "status",
|
||||||
|
"operator": "eq",
|
||||||
|
"value": "pending",
|
||||||
|
"logic_operator": "OR"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"column": "age",
|
||||||
|
"operator": "gte",
|
||||||
|
"value": 18
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Produces: `WHERE (status = 'active' OR status = 'pending') AND age >= 18`
|
||||||
|
|
||||||
|
This grouping ensures OR conditions don't interfere with other AND conditions in the query.
|
||||||
|
|
||||||
|
### Custom Operators
|
||||||
|
|
||||||
|
Add custom SQL conditions when needed:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"operation": "read",
|
||||||
|
"options": {
|
||||||
|
"customOperators": [
|
||||||
|
{
|
||||||
|
"name": "email_domain_filter",
|
||||||
|
"sql": "LOWER(email) LIKE '%@example.com'"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "recent_records",
|
||||||
|
"sql": "created_at > NOW() - INTERVAL '7 days'"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Custom operators are applied as additional WHERE conditions to your query.
|
||||||
|
|
||||||
|
### Fetch Row Number
|
||||||
|
|
||||||
|
Get the row number (position) of a specific record in the filtered and sorted result set. **When `fetch_row_number` is specified, only that specific record is returned** (not all records).
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"operation": "read",
|
||||||
|
"options": {
|
||||||
|
"filters": [
|
||||||
|
{
|
||||||
|
"column": "status",
|
||||||
|
"operator": "eq",
|
||||||
|
"value": "active"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"sort": [
|
||||||
|
{
|
||||||
|
"column": "score",
|
||||||
|
"direction": "desc"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"fetch_row_number": "12345"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response - Returns ONLY the specified record with its position:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"data": {
|
||||||
|
"id": 12345,
|
||||||
|
"name": "John Doe",
|
||||||
|
"score": 850,
|
||||||
|
"status": "active"
|
||||||
|
},
|
||||||
|
"metadata": {
|
||||||
|
"total": 1000,
|
||||||
|
"count": 1,
|
||||||
|
"filtered": 1000,
|
||||||
|
"row_number": 42
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Use Case:** Perfect for "Show me this user and their ranking" - you get just that one user with their position in the leaderboard.
|
||||||
|
|
||||||
|
**Note:** This is different from the `RowNumber` field feature, which automatically numbers all records in a paginated response based on offset. That feature uses simple math (`offset + index + 1`), while `fetch_row_number` uses SQL window functions to calculate the actual position in a sorted/filtered set. To use the `RowNumber` field feature, simply add a `RowNumber int64` field to your model - it will be automatically populated with the row position based on pagination.
|
||||||
|
|
||||||
|
## Preloading
|
||||||
|
|
||||||
|
Load related entities with custom configuration:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"operation": "read",
|
||||||
|
"options": {
|
||||||
|
"columns": ["id", "name", "email"],
|
||||||
|
"preload": [
|
||||||
|
{
|
||||||
|
"relation": "posts",
|
||||||
|
"columns": ["id", "title", "created_at"],
|
||||||
|
"filters": [
|
||||||
|
{
|
||||||
|
"column": "status",
|
||||||
|
"operator": "eq",
|
||||||
|
"value": "published"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"sort": [
|
||||||
|
{
|
||||||
|
"column": "created_at",
|
||||||
|
"direction": "desc"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"limit": 5
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"relation": "profile",
|
||||||
|
"columns": ["bio", "website"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cursor Pagination
|
||||||
|
|
||||||
|
Efficient pagination for large datasets:
|
||||||
|
|
||||||
### First Request (No Cursor)
|
### First Request (No Cursor)
|
||||||
|
|
||||||
```json
|
```json
|
||||||
@@ -427,7 +567,7 @@ Define virtual columns using SQL expressions:
|
|||||||
// Check permissions
|
// Check permissions
|
||||||
if !userHasPermission(ctx.Context, ctx.Entity) {
|
if !userHasPermission(ctx.Context, ctx.Entity) {
|
||||||
return fmt.Errorf("unauthorized access to %s", ctx.Entity)
|
return fmt.Errorf("unauthorized access to %s", ctx.Entity)
|
||||||
return nil
|
}
|
||||||
|
|
||||||
// Modify query options
|
// Modify query options
|
||||||
if ctx.Options.Limit == nil || *ctx.Options.Limit > 100 {
|
if ctx.Options.Limit == nil || *ctx.Options.Limit > 100 {
|
||||||
@@ -435,17 +575,24 @@ Add custom SQL conditions when needed:
|
|||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
users[i].Email = maskEmail(users[i].Email)
|
})
|
||||||
}
|
|
||||||
// Register an after-read hook (e.g., for data transformation)
|
// Register an after-read hook (e.g., for data transformation)
|
||||||
handler.Hooks().Register(resolvespec.AfterRead, func(ctx *resolvespec.HookContext) error {
|
handler.Hooks().Register(resolvespec.AfterRead, func(ctx *resolvespec.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
|
return nil
|
||||||
})
|
})
|
||||||
|
|
||||||
// Register a before-create hook (e.g., for validation)
|
// Register a before-create hook (e.g., for validation)
|
||||||
handler.Hooks().Register(resolvespec.BeforeCreate, func(ctx *resolvespec.HookContext) error {
|
handler.Hooks().Register(resolvespec.BeforeCreate, func(ctx *resolvespec.HookContext) error {
|
||||||
// Validate data
|
// Validate data
|
||||||
|
if user, ok := ctx.Data.(*User); ok {
|
||||||
|
if user.Email == "" {
|
||||||
return fmt.Errorf("email is required")
|
return fmt.Errorf("email is required")
|
||||||
}
|
}
|
||||||
// Add timestamps
|
// Add timestamps
|
||||||
|
|||||||
143
pkg/resolvespec/filter_test.go
Normal file
143
pkg/resolvespec/filter_test.go
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
package resolvespec
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/bitechdev/ResolveSpec/pkg/common"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestBuildFilterCondition tests the filter condition builder
|
||||||
|
func TestBuildFilterCondition(t *testing.T) {
|
||||||
|
h := &Handler{}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
filter common.FilterOption
|
||||||
|
expectedCondition string
|
||||||
|
expectedArgsCount int
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "Equal operator",
|
||||||
|
filter: common.FilterOption{
|
||||||
|
Column: "status",
|
||||||
|
Operator: "eq",
|
||||||
|
Value: "active",
|
||||||
|
},
|
||||||
|
expectedCondition: "status = ?",
|
||||||
|
expectedArgsCount: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Greater than operator",
|
||||||
|
filter: common.FilterOption{
|
||||||
|
Column: "age",
|
||||||
|
Operator: "gt",
|
||||||
|
Value: 18,
|
||||||
|
},
|
||||||
|
expectedCondition: "age > ?",
|
||||||
|
expectedArgsCount: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "IN operator",
|
||||||
|
filter: common.FilterOption{
|
||||||
|
Column: "status",
|
||||||
|
Operator: "in",
|
||||||
|
Value: []string{"active", "pending"},
|
||||||
|
},
|
||||||
|
expectedCondition: "status IN (?)",
|
||||||
|
expectedArgsCount: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "LIKE operator",
|
||||||
|
filter: common.FilterOption{
|
||||||
|
Column: "email",
|
||||||
|
Operator: "like",
|
||||||
|
Value: "%@example.com",
|
||||||
|
},
|
||||||
|
expectedCondition: "email LIKE ?",
|
||||||
|
expectedArgsCount: 1,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
condition, args := h.buildFilterCondition(tt.filter)
|
||||||
|
|
||||||
|
if condition != tt.expectedCondition {
|
||||||
|
t.Errorf("Expected condition '%s', got '%s'", tt.expectedCondition, condition)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(args) != tt.expectedArgsCount {
|
||||||
|
t.Errorf("Expected %d args, got %d", tt.expectedArgsCount, len(args))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Note: Skip value comparison for slices as they can't be compared with ==
|
||||||
|
// The important part is that args are populated correctly
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestORGrouping tests that consecutive OR filters are properly grouped
|
||||||
|
func TestORGrouping(t *testing.T) {
|
||||||
|
// This is a conceptual test - in practice we'd need a mock SelectQuery
|
||||||
|
// to verify the actual SQL grouping behavior
|
||||||
|
t.Run("Consecutive OR filters should be grouped", func(t *testing.T) {
|
||||||
|
filters := []common.FilterOption{
|
||||||
|
{Column: "status", Operator: "eq", Value: "active"},
|
||||||
|
{Column: "status", Operator: "eq", Value: "pending", LogicOperator: "OR"},
|
||||||
|
{Column: "status", Operator: "eq", Value: "trial", LogicOperator: "OR"},
|
||||||
|
{Column: "age", Operator: "gte", Value: 18},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expected behavior: (status='active' OR status='pending' OR status='trial') AND age>=18
|
||||||
|
// The first three filters should be grouped together
|
||||||
|
// The fourth filter should be separate with AND
|
||||||
|
|
||||||
|
// Count OR groups
|
||||||
|
orGroupCount := 0
|
||||||
|
inORGroup := false
|
||||||
|
|
||||||
|
for i := 1; i < len(filters); i++ {
|
||||||
|
if strings.EqualFold(filters[i].LogicOperator, "OR") && !inORGroup {
|
||||||
|
orGroupCount++
|
||||||
|
inORGroup = true
|
||||||
|
} else if !strings.EqualFold(filters[i].LogicOperator, "OR") {
|
||||||
|
inORGroup = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// We should have detected one OR group
|
||||||
|
if orGroupCount != 1 {
|
||||||
|
t.Errorf("Expected 1 OR group, detected %d", orGroupCount)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Multiple OR groups should be handled correctly", func(t *testing.T) {
|
||||||
|
filters := []common.FilterOption{
|
||||||
|
{Column: "status", Operator: "eq", Value: "active"},
|
||||||
|
{Column: "status", Operator: "eq", Value: "pending", LogicOperator: "OR"},
|
||||||
|
{Column: "priority", Operator: "eq", Value: "high"},
|
||||||
|
{Column: "priority", Operator: "eq", Value: "urgent", LogicOperator: "OR"},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expected: (status='active' OR status='pending') AND (priority='high' OR priority='urgent')
|
||||||
|
// Should have two OR groups
|
||||||
|
|
||||||
|
orGroupCount := 0
|
||||||
|
inORGroup := false
|
||||||
|
|
||||||
|
for i := 1; i < len(filters); i++ {
|
||||||
|
if strings.EqualFold(filters[i].LogicOperator, "OR") && !inORGroup {
|
||||||
|
orGroupCount++
|
||||||
|
inORGroup = true
|
||||||
|
} else if !strings.EqualFold(filters[i].LogicOperator, "OR") {
|
||||||
|
inORGroup = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// We should have detected two OR groups
|
||||||
|
if orGroupCount != 2 {
|
||||||
|
t.Errorf("Expected 2 OR groups, detected %d", orGroupCount)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -280,10 +280,13 @@ func (h *Handler) handleRead(ctx context.Context, w common.ResponseWriter, id st
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply filters
|
// Apply filters with proper grouping for OR logic
|
||||||
for _, filter := range options.Filters {
|
query = h.applyFilters(query, options.Filters)
|
||||||
logger.Debug("Applying filter: %s %s %v", filter.Column, filter.Operator, filter.Value)
|
|
||||||
query = h.applyFilter(query, filter)
|
// Apply custom operators
|
||||||
|
for _, customOp := range options.CustomOperators {
|
||||||
|
logger.Debug("Applying custom operator: %s - %s", customOp.Name, customOp.SQL)
|
||||||
|
query = query.Where(customOp.SQL)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply sorting
|
// Apply sorting
|
||||||
@@ -381,24 +384,105 @@ func (h *Handler) handleRead(ctx context.Context, w common.ResponseWriter, id st
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply pagination
|
// Handle FetchRowNumber if requested
|
||||||
if options.Limit != nil && *options.Limit > 0 {
|
var rowNumber *int64
|
||||||
logger.Debug("Applying limit: %d", *options.Limit)
|
if options.FetchRowNumber != nil && *options.FetchRowNumber != "" {
|
||||||
query = query.Limit(*options.Limit)
|
logger.Debug("Fetching row number for ID: %s", *options.FetchRowNumber)
|
||||||
|
pkName := reflection.GetPrimaryKeyName(model)
|
||||||
|
|
||||||
|
// Build ROW_NUMBER window function SQL
|
||||||
|
rowNumberSQL := "ROW_NUMBER() OVER ("
|
||||||
|
if len(options.Sort) > 0 {
|
||||||
|
rowNumberSQL += "ORDER BY "
|
||||||
|
for i, sort := range options.Sort {
|
||||||
|
if i > 0 {
|
||||||
|
rowNumberSQL += ", "
|
||||||
|
}
|
||||||
|
direction := "ASC"
|
||||||
|
if strings.EqualFold(sort.Direction, "desc") {
|
||||||
|
direction = "DESC"
|
||||||
|
}
|
||||||
|
rowNumberSQL += fmt.Sprintf("%s %s", sort.Column, direction)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rowNumberSQL += ")"
|
||||||
|
|
||||||
|
// Create a query to fetch the row number using a subquery approach
|
||||||
|
// We'll select the PK and row_number, then filter by the target ID
|
||||||
|
type RowNumResult struct {
|
||||||
|
RowNum int64 `bun:"row_num"`
|
||||||
|
}
|
||||||
|
|
||||||
|
rowNumQuery := h.db.NewSelect().Table(tableName).
|
||||||
|
ColumnExpr(fmt.Sprintf("%s AS row_num", rowNumberSQL)).
|
||||||
|
Column(pkName)
|
||||||
|
|
||||||
|
// Apply the same filters as the main query
|
||||||
|
for _, filter := range options.Filters {
|
||||||
|
rowNumQuery = h.applyFilter(rowNumQuery, filter)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply custom operators
|
||||||
|
for _, customOp := range options.CustomOperators {
|
||||||
|
rowNumQuery = rowNumQuery.Where(customOp.SQL)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter for the specific ID we want the row number for
|
||||||
|
rowNumQuery = rowNumQuery.Where(fmt.Sprintf("%s = ?", common.QuoteIdent(pkName)), *options.FetchRowNumber)
|
||||||
|
|
||||||
|
// Execute query to get row number
|
||||||
|
var result RowNumResult
|
||||||
|
if err := rowNumQuery.Scan(ctx, &result); err != nil {
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
// Build filter description for error message
|
||||||
|
filterInfo := fmt.Sprintf("filters: %d", len(options.Filters))
|
||||||
|
if len(options.CustomOperators) > 0 {
|
||||||
|
customOps := make([]string, 0, len(options.CustomOperators))
|
||||||
|
for _, op := range options.CustomOperators {
|
||||||
|
customOps = append(customOps, op.SQL)
|
||||||
|
}
|
||||||
|
filterInfo += fmt.Sprintf(", custom operators: [%s]", strings.Join(customOps, "; "))
|
||||||
|
}
|
||||||
|
logger.Warn("No row found for primary key %s=%s with %s", pkName, *options.FetchRowNumber, filterInfo)
|
||||||
|
} else {
|
||||||
|
logger.Warn("Error fetching row number: %v", err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
rowNumber = &result.RowNum
|
||||||
|
logger.Debug("Found row number: %d", *rowNumber)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if options.Offset != nil && *options.Offset > 0 {
|
|
||||||
logger.Debug("Applying offset: %d", *options.Offset)
|
// Apply pagination (skip if FetchRowNumber is set - we want only that record)
|
||||||
query = query.Offset(*options.Offset)
|
if options.FetchRowNumber == nil || *options.FetchRowNumber == "" {
|
||||||
|
if options.Limit != nil && *options.Limit > 0 {
|
||||||
|
logger.Debug("Applying limit: %d", *options.Limit)
|
||||||
|
query = query.Limit(*options.Limit)
|
||||||
|
}
|
||||||
|
if options.Offset != nil && *options.Offset > 0 {
|
||||||
|
logger.Debug("Applying offset: %d", *options.Offset)
|
||||||
|
query = query.Offset(*options.Offset)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Execute query
|
// Execute query
|
||||||
var result interface{}
|
var result interface{}
|
||||||
if id != "" {
|
if id != "" || (options.FetchRowNumber != nil && *options.FetchRowNumber != "") {
|
||||||
logger.Debug("Querying single record with ID: %s", id)
|
// Single record query - either by URL ID or FetchRowNumber
|
||||||
|
var targetID string
|
||||||
|
if id != "" {
|
||||||
|
targetID = id
|
||||||
|
logger.Debug("Querying single record with URL ID: %s", id)
|
||||||
|
} else {
|
||||||
|
targetID = *options.FetchRowNumber
|
||||||
|
logger.Debug("Querying single record with FetchRowNumber ID: %s", targetID)
|
||||||
|
}
|
||||||
|
|
||||||
// For single record, create a new pointer to the struct type
|
// For single record, create a new pointer to the struct type
|
||||||
singleResult := reflect.New(modelType).Interface()
|
singleResult := reflect.New(modelType).Interface()
|
||||||
|
pkName := reflection.GetPrimaryKeyName(singleResult)
|
||||||
|
|
||||||
query = query.Where(fmt.Sprintf("%s = ?", common.QuoteIdent(reflection.GetPrimaryKeyName(singleResult))), id)
|
query = query.Where(fmt.Sprintf("%s = ?", common.QuoteIdent(pkName)), targetID)
|
||||||
if err := query.Scan(ctx, singleResult); err != nil {
|
if err := query.Scan(ctx, singleResult); err != nil {
|
||||||
logger.Error("Error querying record: %v", err)
|
logger.Error("Error querying record: %v", err)
|
||||||
h.sendError(w, http.StatusInternalServerError, "query_error", "Error executing query", err)
|
h.sendError(w, http.StatusInternalServerError, "query_error", "Error executing query", err)
|
||||||
@@ -418,20 +502,39 @@ func (h *Handler) handleRead(ctx context.Context, w common.ResponseWriter, id st
|
|||||||
|
|
||||||
logger.Info("Successfully retrieved records")
|
logger.Info("Successfully retrieved records")
|
||||||
|
|
||||||
|
// Build metadata
|
||||||
limit := 0
|
limit := 0
|
||||||
if options.Limit != nil {
|
|
||||||
limit = *options.Limit
|
|
||||||
}
|
|
||||||
offset := 0
|
offset := 0
|
||||||
if options.Offset != nil {
|
count := int64(total)
|
||||||
offset = *options.Offset
|
|
||||||
|
// When FetchRowNumber is used, we only return 1 record
|
||||||
|
if options.FetchRowNumber != nil && *options.FetchRowNumber != "" {
|
||||||
|
count = 1
|
||||||
|
// Set the fetched row number on the record
|
||||||
|
if rowNumber != nil {
|
||||||
|
logger.Debug("FetchRowNumber: Setting row number %d on record", *rowNumber)
|
||||||
|
h.setRowNumbersOnRecords(result, int(*rowNumber-1)) // -1 because setRowNumbersOnRecords adds 1
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if options.Limit != nil {
|
||||||
|
limit = *options.Limit
|
||||||
|
}
|
||||||
|
if options.Offset != nil {
|
||||||
|
offset = *options.Offset
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set row numbers on records if RowNumber field exists
|
||||||
|
// Only for multiple records (not when fetching single record)
|
||||||
|
h.setRowNumbersOnRecords(result, offset)
|
||||||
}
|
}
|
||||||
|
|
||||||
h.sendResponse(w, result, &common.Metadata{
|
h.sendResponse(w, result, &common.Metadata{
|
||||||
Total: int64(total),
|
Total: int64(total),
|
||||||
Filtered: int64(total),
|
Filtered: int64(total),
|
||||||
Limit: limit,
|
Count: count,
|
||||||
Offset: offset,
|
Limit: limit,
|
||||||
|
Offset: offset,
|
||||||
|
RowNumber: rowNumber,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1303,29 +1406,161 @@ func (h *Handler) handleDelete(ctx context.Context, w common.ResponseWriter, id
|
|||||||
h.sendResponse(w, recordToDelete, nil)
|
h.sendResponse(w, recordToDelete, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) applyFilter(query common.SelectQuery, filter common.FilterOption) common.SelectQuery {
|
// applyFilters applies all filters with proper grouping for OR logic
|
||||||
|
// Groups consecutive OR filters together to ensure proper query precedence
|
||||||
|
// Example: [A, B(OR), C(OR), D(AND)] => WHERE (A OR B OR C) AND D
|
||||||
|
func (h *Handler) applyFilters(query common.SelectQuery, filters []common.FilterOption) common.SelectQuery {
|
||||||
|
if len(filters) == 0 {
|
||||||
|
return query
|
||||||
|
}
|
||||||
|
|
||||||
|
i := 0
|
||||||
|
for i < len(filters) {
|
||||||
|
// Check if this starts an OR group (current or next filter has OR logic)
|
||||||
|
startORGroup := i+1 < len(filters) && strings.EqualFold(filters[i+1].LogicOperator, "OR")
|
||||||
|
|
||||||
|
if startORGroup {
|
||||||
|
// Collect all consecutive filters that are OR'd together
|
||||||
|
orGroup := []common.FilterOption{filters[i]}
|
||||||
|
j := i + 1
|
||||||
|
for j < len(filters) && strings.EqualFold(filters[j].LogicOperator, "OR") {
|
||||||
|
orGroup = append(orGroup, filters[j])
|
||||||
|
j++
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply the OR group as a single grouped WHERE clause
|
||||||
|
query = h.applyFilterGroup(query, orGroup)
|
||||||
|
i = j
|
||||||
|
} else {
|
||||||
|
// Single filter with AND logic (or first filter)
|
||||||
|
condition, args := h.buildFilterCondition(filters[i])
|
||||||
|
if condition != "" {
|
||||||
|
query = query.Where(condition, args...)
|
||||||
|
}
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return query
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyFilterGroup applies a group of filters that should be OR'd together
|
||||||
|
// Always wraps them in parentheses and applies as a single WHERE clause
|
||||||
|
func (h *Handler) applyFilterGroup(query common.SelectQuery, filters []common.FilterOption) common.SelectQuery {
|
||||||
|
if len(filters) == 0 {
|
||||||
|
return query
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build all conditions and collect args
|
||||||
|
var conditions []string
|
||||||
|
var args []interface{}
|
||||||
|
|
||||||
|
for _, filter := range filters {
|
||||||
|
condition, filterArgs := h.buildFilterCondition(filter)
|
||||||
|
if condition != "" {
|
||||||
|
conditions = append(conditions, condition)
|
||||||
|
args = append(args, filterArgs...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(conditions) == 0 {
|
||||||
|
return query
|
||||||
|
}
|
||||||
|
|
||||||
|
// Single filter - no need for grouping
|
||||||
|
if len(conditions) == 1 {
|
||||||
|
return query.Where(conditions[0], args...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Multiple conditions - group with parentheses and OR
|
||||||
|
groupedCondition := "(" + strings.Join(conditions, " OR ") + ")"
|
||||||
|
return query.Where(groupedCondition, args...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildFilterCondition builds a filter condition and returns it with args
|
||||||
|
func (h *Handler) buildFilterCondition(filter common.FilterOption) (conditionString string, conditionArgs []interface{}) {
|
||||||
|
var condition string
|
||||||
|
var args []interface{}
|
||||||
|
|
||||||
switch filter.Operator {
|
switch filter.Operator {
|
||||||
case "eq":
|
case "eq":
|
||||||
return query.Where(fmt.Sprintf("%s = ?", filter.Column), filter.Value)
|
condition = fmt.Sprintf("%s = ?", filter.Column)
|
||||||
|
args = []interface{}{filter.Value}
|
||||||
case "neq":
|
case "neq":
|
||||||
return query.Where(fmt.Sprintf("%s != ?", filter.Column), filter.Value)
|
condition = fmt.Sprintf("%s != ?", filter.Column)
|
||||||
|
args = []interface{}{filter.Value}
|
||||||
case "gt":
|
case "gt":
|
||||||
return query.Where(fmt.Sprintf("%s > ?", filter.Column), filter.Value)
|
condition = fmt.Sprintf("%s > ?", filter.Column)
|
||||||
|
args = []interface{}{filter.Value}
|
||||||
case "gte":
|
case "gte":
|
||||||
return query.Where(fmt.Sprintf("%s >= ?", filter.Column), filter.Value)
|
condition = fmt.Sprintf("%s >= ?", filter.Column)
|
||||||
|
args = []interface{}{filter.Value}
|
||||||
case "lt":
|
case "lt":
|
||||||
return query.Where(fmt.Sprintf("%s < ?", filter.Column), filter.Value)
|
condition = fmt.Sprintf("%s < ?", filter.Column)
|
||||||
|
args = []interface{}{filter.Value}
|
||||||
case "lte":
|
case "lte":
|
||||||
return query.Where(fmt.Sprintf("%s <= ?", filter.Column), filter.Value)
|
condition = fmt.Sprintf("%s <= ?", filter.Column)
|
||||||
|
args = []interface{}{filter.Value}
|
||||||
case "like":
|
case "like":
|
||||||
return query.Where(fmt.Sprintf("%s LIKE ?", filter.Column), filter.Value)
|
condition = fmt.Sprintf("%s LIKE ?", filter.Column)
|
||||||
|
args = []interface{}{filter.Value}
|
||||||
case "ilike":
|
case "ilike":
|
||||||
return query.Where(fmt.Sprintf("%s ILIKE ?", filter.Column), filter.Value)
|
condition = fmt.Sprintf("%s ILIKE ?", filter.Column)
|
||||||
|
args = []interface{}{filter.Value}
|
||||||
case "in":
|
case "in":
|
||||||
return query.Where(fmt.Sprintf("%s IN (?)", filter.Column), filter.Value)
|
condition = fmt.Sprintf("%s IN (?)", filter.Column)
|
||||||
|
args = []interface{}{filter.Value}
|
||||||
|
default:
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return condition, args
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) applyFilter(query common.SelectQuery, filter common.FilterOption) common.SelectQuery {
|
||||||
|
// Determine which method to use based on LogicOperator
|
||||||
|
useOrLogic := strings.EqualFold(filter.LogicOperator, "OR")
|
||||||
|
|
||||||
|
var condition string
|
||||||
|
var args []interface{}
|
||||||
|
|
||||||
|
switch filter.Operator {
|
||||||
|
case "eq":
|
||||||
|
condition = fmt.Sprintf("%s = ?", filter.Column)
|
||||||
|
args = []interface{}{filter.Value}
|
||||||
|
case "neq":
|
||||||
|
condition = fmt.Sprintf("%s != ?", filter.Column)
|
||||||
|
args = []interface{}{filter.Value}
|
||||||
|
case "gt":
|
||||||
|
condition = fmt.Sprintf("%s > ?", filter.Column)
|
||||||
|
args = []interface{}{filter.Value}
|
||||||
|
case "gte":
|
||||||
|
condition = fmt.Sprintf("%s >= ?", filter.Column)
|
||||||
|
args = []interface{}{filter.Value}
|
||||||
|
case "lt":
|
||||||
|
condition = fmt.Sprintf("%s < ?", filter.Column)
|
||||||
|
args = []interface{}{filter.Value}
|
||||||
|
case "lte":
|
||||||
|
condition = fmt.Sprintf("%s <= ?", filter.Column)
|
||||||
|
args = []interface{}{filter.Value}
|
||||||
|
case "like":
|
||||||
|
condition = fmt.Sprintf("%s LIKE ?", filter.Column)
|
||||||
|
args = []interface{}{filter.Value}
|
||||||
|
case "ilike":
|
||||||
|
condition = fmt.Sprintf("%s ILIKE ?", filter.Column)
|
||||||
|
args = []interface{}{filter.Value}
|
||||||
|
case "in":
|
||||||
|
condition = fmt.Sprintf("%s IN (?)", filter.Column)
|
||||||
|
args = []interface{}{filter.Value}
|
||||||
default:
|
default:
|
||||||
return query
|
return query
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Apply filter with appropriate logic operator
|
||||||
|
if useOrLogic {
|
||||||
|
return query.WhereOr(condition, args...)
|
||||||
|
}
|
||||||
|
return query.Where(condition, args...)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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
|
||||||
@@ -1709,6 +1944,51 @@ func toSnakeCase(s string) string {
|
|||||||
return strings.ToLower(result.String())
|
return strings.ToLower(result.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// setRowNumbersOnRecords sets the RowNumber field on each record if it exists
|
||||||
|
// The row number is calculated as offset + index + 1 (1-based)
|
||||||
|
func (h *Handler) setRowNumbersOnRecords(records interface{}, offset int) {
|
||||||
|
// Get the reflect value of the records
|
||||||
|
recordsValue := reflect.ValueOf(records)
|
||||||
|
if recordsValue.Kind() == reflect.Ptr {
|
||||||
|
recordsValue = recordsValue.Elem()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure it's a slice
|
||||||
|
if recordsValue.Kind() != reflect.Slice {
|
||||||
|
logger.Debug("setRowNumbersOnRecords: records is not a slice, skipping")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Iterate through each record
|
||||||
|
for i := 0; i < recordsValue.Len(); i++ {
|
||||||
|
record := recordsValue.Index(i)
|
||||||
|
|
||||||
|
// Dereference if it's a pointer
|
||||||
|
if record.Kind() == reflect.Ptr {
|
||||||
|
if record.IsNil() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
record = record.Elem()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure it's a struct
|
||||||
|
if record.Kind() != reflect.Struct {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to find and set the RowNumber field
|
||||||
|
rowNumberField := record.FieldByName("RowNumber")
|
||||||
|
if rowNumberField.IsValid() && rowNumberField.CanSet() {
|
||||||
|
// Check if the field is of type int64
|
||||||
|
if rowNumberField.Kind() == reflect.Int64 {
|
||||||
|
rowNum := int64(offset + i + 1)
|
||||||
|
rowNumberField.SetInt(rowNum)
|
||||||
|
logger.Debug("Set RowNumber=%d for record index %d", rowNum, i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// HandleOpenAPI generates and returns the OpenAPI specification
|
// HandleOpenAPI generates and returns the OpenAPI specification
|
||||||
func (h *Handler) HandleOpenAPI(w common.ResponseWriter, r common.Request) {
|
func (h *Handler) HandleOpenAPI(w common.ResponseWriter, r common.Request) {
|
||||||
if h.openAPIGenerator == nil {
|
if h.openAPIGenerator == nil {
|
||||||
|
|||||||
@@ -549,8 +549,30 @@ func (h *Handler) handleRead(ctx context.Context, w common.ResponseWriter, id st
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// If ID is provided, filter by ID
|
// Handle FetchRowNumber before applying ID filter
|
||||||
if id != "" {
|
// This must happen before the query to get the row position, then filter by PK
|
||||||
|
var fetchedRowNumber *int64
|
||||||
|
var fetchRowNumberPKValue string
|
||||||
|
if options.FetchRowNumber != nil && *options.FetchRowNumber != "" {
|
||||||
|
pkName := reflection.GetPrimaryKeyName(model)
|
||||||
|
fetchRowNumberPKValue = *options.FetchRowNumber
|
||||||
|
|
||||||
|
logger.Debug("FetchRowNumber: Fetching row number for PK %s = %s", pkName, fetchRowNumberPKValue)
|
||||||
|
|
||||||
|
rowNum, err := h.FetchRowNumber(ctx, tableName, pkName, fetchRowNumberPKValue, options, model)
|
||||||
|
if err != nil {
|
||||||
|
logger.Error("Failed to fetch row number: %v", err)
|
||||||
|
h.sendError(w, http.StatusBadRequest, "fetch_rownumber_error", "Failed to fetch row number", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchedRowNumber = &rowNum
|
||||||
|
logger.Debug("FetchRowNumber: Row number %d for PK %s = %s", rowNum, pkName, fetchRowNumberPKValue)
|
||||||
|
|
||||||
|
// Now filter the main query to this specific primary key
|
||||||
|
query = query.Where(fmt.Sprintf("%s = ?", common.QuoteIdent(pkName)), fetchRowNumberPKValue)
|
||||||
|
} else if id != "" {
|
||||||
|
// If ID is provided (and not FetchRowNumber), filter by ID
|
||||||
pkName := reflection.GetPrimaryKeyName(model)
|
pkName := reflection.GetPrimaryKeyName(model)
|
||||||
logger.Debug("Filtering by ID=%s: %s", pkName, id)
|
logger.Debug("Filtering by ID=%s: %s", pkName, id)
|
||||||
|
|
||||||
@@ -730,7 +752,14 @@ func (h *Handler) handleRead(ctx context.Context, w common.ResponseWriter, id st
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Set row numbers on each record if the model has a RowNumber field
|
// Set row numbers on each record if the model has a RowNumber field
|
||||||
h.setRowNumbersOnRecords(modelPtr, offset)
|
// If FetchRowNumber was used, set the fetched row number instead of offset-based
|
||||||
|
if fetchedRowNumber != nil {
|
||||||
|
// FetchRowNumber: set the actual row position on the record
|
||||||
|
logger.Debug("FetchRowNumber: Setting row number %d on record", *fetchedRowNumber)
|
||||||
|
h.setRowNumbersOnRecords(modelPtr, int(*fetchedRowNumber-1)) // -1 because setRowNumbersOnRecords adds 1
|
||||||
|
} else {
|
||||||
|
h.setRowNumbersOnRecords(modelPtr, offset)
|
||||||
|
}
|
||||||
|
|
||||||
metadata := &common.Metadata{
|
metadata := &common.Metadata{
|
||||||
Total: int64(total),
|
Total: int64(total),
|
||||||
@@ -740,21 +769,10 @@ func (h *Handler) handleRead(ctx context.Context, w common.ResponseWriter, id st
|
|||||||
Offset: offset,
|
Offset: offset,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch row number for a specific record if requested
|
// If FetchRowNumber was used, also set it in metadata
|
||||||
if options.FetchRowNumber != nil && *options.FetchRowNumber != "" {
|
if fetchedRowNumber != nil {
|
||||||
pkName := reflection.GetPrimaryKeyName(model)
|
metadata.RowNumber = fetchedRowNumber
|
||||||
pkValue := *options.FetchRowNumber
|
logger.Debug("FetchRowNumber: Row number %d set in metadata", *fetchedRowNumber)
|
||||||
|
|
||||||
logger.Debug("Fetching row number for specific PK %s = %s", pkName, pkValue)
|
|
||||||
|
|
||||||
rowNum, err := h.FetchRowNumber(ctx, tableName, pkName, pkValue, options, model)
|
|
||||||
if err != nil {
|
|
||||||
logger.Warn("Failed to fetch row number: %v", err)
|
|
||||||
// Don't fail the entire request, just log the warning
|
|
||||||
} else {
|
|
||||||
metadata.RowNumber = &rowNum
|
|
||||||
logger.Debug("Row number for PK %s: %d", pkValue, rowNum)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Execute AfterRead hooks
|
// Execute AfterRead hooks
|
||||||
@@ -2602,21 +2620,8 @@ func (h *Handler) FetchRowNumber(ctx context.Context, tableName string, pkName s
|
|||||||
sortSQL = fmt.Sprintf("%s.%s ASC", tableName, pkName)
|
sortSQL = fmt.Sprintf("%s.%s ASC", tableName, pkName)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build WHERE clauses from filters
|
// Build WHERE clause from filters with proper OR grouping
|
||||||
whereClauses := make([]string, 0)
|
whereSQL := h.buildWhereClauseWithORGrouping(options.Filters, tableName)
|
||||||
for i := range options.Filters {
|
|
||||||
filter := &options.Filters[i]
|
|
||||||
whereClause := h.buildFilterSQL(filter, tableName)
|
|
||||||
if whereClause != "" {
|
|
||||||
whereClauses = append(whereClauses, fmt.Sprintf("(%s)", whereClause))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Combine WHERE clauses
|
|
||||||
whereSQL := ""
|
|
||||||
if len(whereClauses) > 0 {
|
|
||||||
whereSQL = "WHERE " + strings.Join(whereClauses, " AND ")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add custom SQL WHERE if provided
|
// Add custom SQL WHERE if provided
|
||||||
if options.CustomSQLWhere != "" {
|
if options.CustomSQLWhere != "" {
|
||||||
@@ -2664,19 +2669,86 @@ func (h *Handler) FetchRowNumber(ctx context.Context, tableName string, pkName s
|
|||||||
var result []struct {
|
var result []struct {
|
||||||
RN int64 `bun:"rn"`
|
RN int64 `bun:"rn"`
|
||||||
}
|
}
|
||||||
|
logger.Debug("[FetchRowNumber] BEFORE Query call - about to execute raw query")
|
||||||
err := h.db.Query(ctx, &result, queryStr, pkValue)
|
err := h.db.Query(ctx, &result, queryStr, pkValue)
|
||||||
|
logger.Debug("[FetchRowNumber] AFTER Query call - query completed with %d results, err: %v", len(result), err)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, fmt.Errorf("failed to fetch row number: %w", err)
|
return 0, fmt.Errorf("failed to fetch row number: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(result) == 0 {
|
if len(result) == 0 {
|
||||||
return 0, fmt.Errorf("no row found for primary key %s", pkValue)
|
whereInfo := "none"
|
||||||
|
if whereSQL != "" {
|
||||||
|
whereInfo = whereSQL
|
||||||
|
}
|
||||||
|
return 0, fmt.Errorf("no row found for primary key %s=%s with active filters: %s", pkName, pkValue, whereInfo)
|
||||||
}
|
}
|
||||||
|
|
||||||
return result[0].RN, nil
|
return result[0].RN, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// buildFilterSQL converts a filter to SQL WHERE clause string
|
// buildFilterSQL converts a filter to SQL WHERE clause string
|
||||||
|
// buildWhereClauseWithORGrouping builds a WHERE clause from filters with proper OR grouping
|
||||||
|
// Groups consecutive OR filters together to ensure proper SQL precedence
|
||||||
|
// Example: [A, B(OR), C(OR), D(AND)] => WHERE (A OR B OR C) AND D
|
||||||
|
func (h *Handler) buildWhereClauseWithORGrouping(filters []common.FilterOption, tableName string) string {
|
||||||
|
if len(filters) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
var groups []string
|
||||||
|
i := 0
|
||||||
|
|
||||||
|
for i < len(filters) {
|
||||||
|
// Check if this starts an OR group (next filter has OR logic)
|
||||||
|
startORGroup := i+1 < len(filters) && strings.EqualFold(filters[i+1].LogicOperator, "OR")
|
||||||
|
|
||||||
|
if startORGroup {
|
||||||
|
// Collect all consecutive filters that are OR'd together
|
||||||
|
orGroup := []string{}
|
||||||
|
|
||||||
|
// Add current filter
|
||||||
|
filterSQL := h.buildFilterSQL(&filters[i], tableName)
|
||||||
|
if filterSQL != "" {
|
||||||
|
orGroup = append(orGroup, filterSQL)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect remaining OR filters
|
||||||
|
j := i + 1
|
||||||
|
for j < len(filters) && strings.EqualFold(filters[j].LogicOperator, "OR") {
|
||||||
|
filterSQL := h.buildFilterSQL(&filters[j], tableName)
|
||||||
|
if filterSQL != "" {
|
||||||
|
orGroup = append(orGroup, filterSQL)
|
||||||
|
}
|
||||||
|
j++
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group OR filters with parentheses
|
||||||
|
if len(orGroup) > 0 {
|
||||||
|
if len(orGroup) == 1 {
|
||||||
|
groups = append(groups, orGroup[0])
|
||||||
|
} else {
|
||||||
|
groups = append(groups, "("+strings.Join(orGroup, " OR ")+")")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
i = j
|
||||||
|
} else {
|
||||||
|
// Single filter with AND logic (or first filter)
|
||||||
|
filterSQL := h.buildFilterSQL(&filters[i], tableName)
|
||||||
|
if filterSQL != "" {
|
||||||
|
groups = append(groups, filterSQL)
|
||||||
|
}
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(groups) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
return "WHERE " + strings.Join(groups, " AND ")
|
||||||
|
}
|
||||||
|
|
||||||
func (h *Handler) buildFilterSQL(filter *common.FilterOption, tableName string) string {
|
func (h *Handler) buildFilterSQL(filter *common.FilterOption, tableName string) string {
|
||||||
qualifiedColumn := h.qualifyColumnName(filter.Column, tableName)
|
qualifiedColumn := h.qualifyColumnName(filter.Column, tableName)
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"reflect"
|
"reflect"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
@@ -209,10 +210,14 @@ func (h *Handler) handleRead(conn *Connection, msg *Message, hookCtx *HookContex
|
|||||||
var metadata map[string]interface{}
|
var metadata map[string]interface{}
|
||||||
var err error
|
var err error
|
||||||
|
|
||||||
if hookCtx.ID != "" {
|
// Check if FetchRowNumber is specified (treat as single record read)
|
||||||
// Read single record by ID
|
isFetchRowNumber := hookCtx.Options != nil && hookCtx.Options.FetchRowNumber != nil && *hookCtx.Options.FetchRowNumber != ""
|
||||||
|
|
||||||
|
if hookCtx.ID != "" || isFetchRowNumber {
|
||||||
|
// Read single record by ID or FetchRowNumber
|
||||||
data, err = h.readByID(hookCtx)
|
data, err = h.readByID(hookCtx)
|
||||||
metadata = map[string]interface{}{"total": 1}
|
metadata = map[string]interface{}{"total": 1}
|
||||||
|
// The row number is already set on the record itself via setRowNumbersOnRecords
|
||||||
} else {
|
} else {
|
||||||
// Read multiple records
|
// Read multiple records
|
||||||
data, metadata, err = h.readMultiple(hookCtx)
|
data, metadata, err = h.readMultiple(hookCtx)
|
||||||
@@ -509,10 +514,29 @@ func (h *Handler) notifySubscribers(schema, entity string, operation OperationTy
|
|||||||
// CRUD operation implementations
|
// CRUD operation implementations
|
||||||
|
|
||||||
func (h *Handler) readByID(hookCtx *HookContext) (interface{}, error) {
|
func (h *Handler) readByID(hookCtx *HookContext) (interface{}, error) {
|
||||||
|
// Handle FetchRowNumber before building query
|
||||||
|
var fetchedRowNumber *int64
|
||||||
|
pkName := reflection.GetPrimaryKeyName(hookCtx.Model)
|
||||||
|
|
||||||
|
if hookCtx.Options != nil && hookCtx.Options.FetchRowNumber != nil && *hookCtx.Options.FetchRowNumber != "" {
|
||||||
|
fetchRowNumberPKValue := *hookCtx.Options.FetchRowNumber
|
||||||
|
logger.Debug("[WebSocketSpec] FetchRowNumber: Fetching row number for PK %s = %s", pkName, fetchRowNumberPKValue)
|
||||||
|
|
||||||
|
rowNum, err := h.FetchRowNumber(hookCtx.Context, hookCtx.TableName, pkName, fetchRowNumberPKValue, hookCtx.Options, hookCtx.Model)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to fetch row number: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchedRowNumber = &rowNum
|
||||||
|
logger.Debug("[WebSocketSpec] FetchRowNumber: Row number %d for PK %s = %s", rowNum, pkName, fetchRowNumberPKValue)
|
||||||
|
|
||||||
|
// Override ID with FetchRowNumber value
|
||||||
|
hookCtx.ID = fetchRowNumberPKValue
|
||||||
|
}
|
||||||
|
|
||||||
query := h.db.NewSelect().Model(hookCtx.ModelPtr).Table(hookCtx.TableName)
|
query := h.db.NewSelect().Model(hookCtx.ModelPtr).Table(hookCtx.TableName)
|
||||||
|
|
||||||
// Add ID filter
|
// Add ID filter
|
||||||
pkName := reflection.GetPrimaryKeyName(hookCtx.Model)
|
|
||||||
query = query.Where(fmt.Sprintf("%s = ?", pkName), hookCtx.ID)
|
query = query.Where(fmt.Sprintf("%s = ?", pkName), hookCtx.ID)
|
||||||
|
|
||||||
// Apply columns
|
// Apply columns
|
||||||
@@ -532,6 +556,12 @@ func (h *Handler) readByID(hookCtx *HookContext) (interface{}, error) {
|
|||||||
return nil, fmt.Errorf("failed to read record: %w", err)
|
return nil, fmt.Errorf("failed to read record: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Set the fetched row number on the record if FetchRowNumber was used
|
||||||
|
if fetchedRowNumber != nil {
|
||||||
|
logger.Debug("[WebSocketSpec] FetchRowNumber: Setting row number %d on record", *fetchedRowNumber)
|
||||||
|
h.setRowNumbersOnRecords(hookCtx.ModelPtr, int(*fetchedRowNumber-1)) // -1 because setRowNumbersOnRecords adds 1
|
||||||
|
}
|
||||||
|
|
||||||
return hookCtx.ModelPtr, nil
|
return hookCtx.ModelPtr, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -540,10 +570,8 @@ func (h *Handler) readMultiple(hookCtx *HookContext) (data interface{}, metadata
|
|||||||
|
|
||||||
// Apply options (simplified implementation)
|
// Apply options (simplified implementation)
|
||||||
if hookCtx.Options != nil {
|
if hookCtx.Options != nil {
|
||||||
// Apply filters
|
// Apply filters with OR grouping support
|
||||||
for _, filter := range hookCtx.Options.Filters {
|
query = h.applyFilters(query, hookCtx.Options.Filters)
|
||||||
query = query.Where(fmt.Sprintf("%s %s ?", filter.Column, h.getOperatorSQL(filter.Operator)), filter.Value)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply sorting
|
// Apply sorting
|
||||||
for _, sort := range hookCtx.Options.Sort {
|
for _, sort := range hookCtx.Options.Sort {
|
||||||
@@ -578,6 +606,13 @@ func (h *Handler) readMultiple(hookCtx *HookContext) (data interface{}, metadata
|
|||||||
return nil, nil, fmt.Errorf("failed to read records: %w", err)
|
return nil, nil, fmt.Errorf("failed to read records: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Set row numbers on records if RowNumber field exists
|
||||||
|
offset := 0
|
||||||
|
if hookCtx.Options != nil && hookCtx.Options.Offset != nil {
|
||||||
|
offset = *hookCtx.Options.Offset
|
||||||
|
}
|
||||||
|
h.setRowNumbersOnRecords(hookCtx.ModelPtr, offset)
|
||||||
|
|
||||||
// Get count
|
// Get count
|
||||||
metadata = make(map[string]interface{})
|
metadata = make(map[string]interface{})
|
||||||
countQuery := h.db.NewSelect().Model(hookCtx.ModelPtr).Table(hookCtx.TableName)
|
countQuery := h.db.NewSelect().Model(hookCtx.ModelPtr).Table(hookCtx.TableName)
|
||||||
@@ -683,6 +718,133 @@ func (h *Handler) getMetadata(schema, entity string, model interface{}) map[stri
|
|||||||
}
|
}
|
||||||
|
|
||||||
// getOperatorSQL converts filter operator to SQL operator
|
// getOperatorSQL converts filter operator to SQL operator
|
||||||
|
// applyFilters applies all filters with proper grouping for OR logic
|
||||||
|
// Groups consecutive OR filters together to ensure proper query precedence
|
||||||
|
func (h *Handler) applyFilters(query common.SelectQuery, filters []common.FilterOption) common.SelectQuery {
|
||||||
|
if len(filters) == 0 {
|
||||||
|
return query
|
||||||
|
}
|
||||||
|
|
||||||
|
i := 0
|
||||||
|
for i < len(filters) {
|
||||||
|
// Check if this starts an OR group (next filter has OR logic)
|
||||||
|
startORGroup := i+1 < len(filters) && strings.EqualFold(filters[i+1].LogicOperator, "OR")
|
||||||
|
|
||||||
|
if startORGroup {
|
||||||
|
// Collect all consecutive filters that are OR'd together
|
||||||
|
orGroup := []common.FilterOption{filters[i]}
|
||||||
|
j := i + 1
|
||||||
|
for j < len(filters) && strings.EqualFold(filters[j].LogicOperator, "OR") {
|
||||||
|
orGroup = append(orGroup, filters[j])
|
||||||
|
j++
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply the OR group as a single grouped WHERE clause
|
||||||
|
query = h.applyFilterGroup(query, orGroup)
|
||||||
|
i = j
|
||||||
|
} else {
|
||||||
|
// Single filter with AND logic (or first filter)
|
||||||
|
condition, args := h.buildFilterCondition(filters[i])
|
||||||
|
if condition != "" {
|
||||||
|
query = query.Where(condition, args...)
|
||||||
|
}
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return query
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyFilterGroup applies a group of filters that should be OR'd together
|
||||||
|
// Always wraps them in parentheses and applies as a single WHERE clause
|
||||||
|
func (h *Handler) applyFilterGroup(query common.SelectQuery, filters []common.FilterOption) common.SelectQuery {
|
||||||
|
if len(filters) == 0 {
|
||||||
|
return query
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build all conditions and collect args
|
||||||
|
var conditions []string
|
||||||
|
var args []interface{}
|
||||||
|
|
||||||
|
for _, filter := range filters {
|
||||||
|
condition, filterArgs := h.buildFilterCondition(filter)
|
||||||
|
if condition != "" {
|
||||||
|
conditions = append(conditions, condition)
|
||||||
|
args = append(args, filterArgs...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(conditions) == 0 {
|
||||||
|
return query
|
||||||
|
}
|
||||||
|
|
||||||
|
// Single filter - no need for grouping
|
||||||
|
if len(conditions) == 1 {
|
||||||
|
return query.Where(conditions[0], args...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Multiple conditions - group with parentheses and OR
|
||||||
|
groupedCondition := "(" + strings.Join(conditions, " OR ") + ")"
|
||||||
|
return query.Where(groupedCondition, args...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildFilterCondition builds a filter condition and returns it with args
|
||||||
|
func (h *Handler) buildFilterCondition(filter common.FilterOption) (conditionString string, conditionArgs []interface{}) {
|
||||||
|
var condition string
|
||||||
|
var args []interface{}
|
||||||
|
|
||||||
|
operatorSQL := h.getOperatorSQL(filter.Operator)
|
||||||
|
condition = fmt.Sprintf("%s %s ?", filter.Column, operatorSQL)
|
||||||
|
args = []interface{}{filter.Value}
|
||||||
|
|
||||||
|
return condition, args
|
||||||
|
}
|
||||||
|
|
||||||
|
// setRowNumbersOnRecords sets the RowNumber field on each record if it exists
|
||||||
|
// The row number is calculated as offset + index + 1 (1-based)
|
||||||
|
func (h *Handler) setRowNumbersOnRecords(records interface{}, offset int) {
|
||||||
|
// Get the reflect value of the records
|
||||||
|
recordsValue := reflect.ValueOf(records)
|
||||||
|
if recordsValue.Kind() == reflect.Ptr {
|
||||||
|
recordsValue = recordsValue.Elem()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure it's a slice
|
||||||
|
if recordsValue.Kind() != reflect.Slice {
|
||||||
|
logger.Debug("[WebSocketSpec] setRowNumbersOnRecords: records is not a slice, skipping")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Iterate through each record
|
||||||
|
for i := 0; i < recordsValue.Len(); i++ {
|
||||||
|
record := recordsValue.Index(i)
|
||||||
|
|
||||||
|
// Dereference if it's a pointer
|
||||||
|
if record.Kind() == reflect.Ptr {
|
||||||
|
if record.IsNil() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
record = record.Elem()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure it's a struct
|
||||||
|
if record.Kind() != reflect.Struct {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to find and set the RowNumber field
|
||||||
|
rowNumberField := record.FieldByName("RowNumber")
|
||||||
|
if rowNumberField.IsValid() && rowNumberField.CanSet() {
|
||||||
|
// Check if the field is of type int64
|
||||||
|
if rowNumberField.Kind() == reflect.Int64 {
|
||||||
|
rowNum := int64(offset + i + 1)
|
||||||
|
rowNumberField.SetInt(rowNum)
|
||||||
|
logger.Debug("[WebSocketSpec] Set RowNumber=%d for record index %d", rowNum, i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (h *Handler) getOperatorSQL(operator string) string {
|
func (h *Handler) getOperatorSQL(operator string) string {
|
||||||
switch operator {
|
switch operator {
|
||||||
case "eq":
|
case "eq":
|
||||||
@@ -708,6 +870,92 @@ func (h *Handler) getOperatorSQL(operator string) string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FetchRowNumber calculates the row number of a specific record based on sorting and filtering
|
||||||
|
// Returns the 1-based row number of the record with the given primary key value
|
||||||
|
func (h *Handler) FetchRowNumber(ctx context.Context, tableName string, pkName string, pkValue string, options *common.RequestOptions, model interface{}) (int64, error) {
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
logger.Error("[WebSocketSpec] Panic during FetchRowNumber: %v", r)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Build the sort order SQL
|
||||||
|
sortSQL := ""
|
||||||
|
if options != nil && len(options.Sort) > 0 {
|
||||||
|
sortParts := make([]string, 0, len(options.Sort))
|
||||||
|
for _, sort := range options.Sort {
|
||||||
|
if sort.Column == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
direction := "ASC"
|
||||||
|
if strings.EqualFold(sort.Direction, "desc") {
|
||||||
|
direction = "DESC"
|
||||||
|
}
|
||||||
|
sortParts = append(sortParts, fmt.Sprintf("%s %s", sort.Column, direction))
|
||||||
|
}
|
||||||
|
sortSQL = strings.Join(sortParts, ", ")
|
||||||
|
} else {
|
||||||
|
// Default sort by primary key
|
||||||
|
sortSQL = fmt.Sprintf("%s ASC", pkName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build WHERE clause from filters
|
||||||
|
whereSQL := ""
|
||||||
|
var whereArgs []interface{}
|
||||||
|
if options != nil && len(options.Filters) > 0 {
|
||||||
|
var conditions []string
|
||||||
|
for _, filter := range options.Filters {
|
||||||
|
operatorSQL := h.getOperatorSQL(filter.Operator)
|
||||||
|
conditions = append(conditions, fmt.Sprintf("%s.%s %s ?", tableName, filter.Column, operatorSQL))
|
||||||
|
whereArgs = append(whereArgs, filter.Value)
|
||||||
|
}
|
||||||
|
if len(conditions) > 0 {
|
||||||
|
whereSQL = "WHERE " + strings.Join(conditions, " AND ")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build the final query with parameterized PK value
|
||||||
|
queryStr := fmt.Sprintf(`
|
||||||
|
SELECT search.rn
|
||||||
|
FROM (
|
||||||
|
SELECT %[1]s.%[2]s,
|
||||||
|
ROW_NUMBER() OVER(ORDER BY %[3]s) AS rn
|
||||||
|
FROM %[1]s
|
||||||
|
%[4]s
|
||||||
|
) search
|
||||||
|
WHERE search.%[2]s = ?
|
||||||
|
`,
|
||||||
|
tableName, // [1] - table name
|
||||||
|
pkName, // [2] - primary key column name
|
||||||
|
sortSQL, // [3] - sort order SQL
|
||||||
|
whereSQL, // [4] - WHERE clause
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.Debug("[WebSocketSpec] FetchRowNumber query: %s, pkValue: %s", queryStr, pkValue)
|
||||||
|
|
||||||
|
// Append PK value to whereArgs
|
||||||
|
whereArgs = append(whereArgs, pkValue)
|
||||||
|
|
||||||
|
// Execute the raw query with parameterized PK value
|
||||||
|
var result []struct {
|
||||||
|
RN int64 `bun:"rn"`
|
||||||
|
}
|
||||||
|
err := h.db.Query(ctx, &result, queryStr, whereArgs...)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("failed to fetch row number: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(result) == 0 {
|
||||||
|
whereInfo := "none"
|
||||||
|
if whereSQL != "" {
|
||||||
|
whereInfo = whereSQL
|
||||||
|
}
|
||||||
|
return 0, fmt.Errorf("no row found for primary key %s=%s with active filters: %s", pkName, pkValue, whereInfo)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result[0].RN, nil
|
||||||
|
}
|
||||||
|
|
||||||
// Shutdown gracefully shuts down the handler
|
// Shutdown gracefully shuts down the handler
|
||||||
func (h *Handler) Shutdown() {
|
func (h *Handler) Shutdown() {
|
||||||
h.connManager.Shutdown()
|
h.connManager.Shutdown()
|
||||||
|
|||||||
Reference in New Issue
Block a user