@taruvi/sdk 1.4.9 → 1.5.0-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/settings.local.json +19 -0
- package/README.md +39 -31
- package/package.json +1 -1
- package/src/client.ts +1 -1
- package/src/index.ts +2 -1
- package/src/lib/auth/AuthClient.ts +3 -25
- package/src/lib/database/DatabaseClient.ts +72 -10
- package/src/lib/database/types.ts +82 -16
- package/src/lib-internal/http/HttpClient.ts +1 -2
- package/src/lib-internal/routes/AuthRoutes.ts +0 -3
- package/src/types.ts +6 -0
- package/src/utils/utils.ts +1 -0
- package/tests/unit/database/DatabaseClient.test.ts +108 -21
- package/tests/unit/edge-cases/robustness.test.ts +7 -7
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"permissions": {
|
|
3
|
+
"allow": [
|
|
4
|
+
"Bash(tree:*)",
|
|
5
|
+
"Bash(npm run build:*)",
|
|
6
|
+
"Bash(npm ls:*)",
|
|
7
|
+
"Bash(npm link:*)",
|
|
8
|
+
"Bash(npx --yes markdownlint-cli:*)",
|
|
9
|
+
"Bash(cat:*)",
|
|
10
|
+
"Bash(test:*)",
|
|
11
|
+
"Bash(ls:*)",
|
|
12
|
+
"WebFetch(domain:www.markdownpaste.com)",
|
|
13
|
+
"WebSearch",
|
|
14
|
+
"WebFetch(domain:supabase.com)"
|
|
15
|
+
],
|
|
16
|
+
"deny": [],
|
|
17
|
+
"ask": []
|
|
18
|
+
}
|
|
19
|
+
}
|
package/README.md
CHANGED
|
@@ -17,7 +17,7 @@ Recent updates to the SDK:
|
|
|
17
17
|
- **App Service**: New service to retrieve app roles with `roles()` method.
|
|
18
18
|
- **User Types**: Added `UserCreateRequest`, `UserResponse`, and `UserDataResponse` types for user management.
|
|
19
19
|
- **Auth Service**: Web UI Flow with `login()`, `signup()`, `logout()` methods that redirect to backend pages. Token refresh with rotation support, `getCurrentUser()` for JWT decoding.
|
|
20
|
-
- **Database Service**: Added `create()` method for creating records. Added `populate()` method for eager loading related records. Comprehensive filter support with Django-style operators (`__gte`, `__lte`, `__icontains`, etc.).
|
|
20
|
+
- **Database Service**: Added `create()` method for creating records. Added `populate()` method for eager loading related records. Comprehensive filter support with Django-style operators (`__gte`, `__lte`, `__icontains`, etc.). Use **`orderBy()`** for sorting: one field (`orderBy('created_at', 'desc')`), multiple fields (`orderBy([{ field, order }])`), or a raw `ordering` string (`orderBy('-salary,hire_date')`). Hierarchy traversal uses **`.include('descendants' | 'ancestors' | 'both')`** — not `FilterOperator`. Import **`PgRangeValue`** for typed PG range columns in row data.
|
|
21
21
|
- **Storage Service**: Added `download()` method. Enhanced filter support with size, date, MIME type, visibility filters. `delete()` now accepts array of paths for bulk deletion.
|
|
22
22
|
- **Client**: Automatic token extraction from URL hash after OAuth callback - no manual token handling needed.
|
|
23
23
|
- **Types**: Comprehensive `StorageFilters` and `DatabaseFilters` interfaces with full operator support.
|
|
@@ -363,43 +363,52 @@ const db = new Database(taruviClient)
|
|
|
363
363
|
await db.from("accounts").delete("record-id").execute()
|
|
364
364
|
```
|
|
365
365
|
|
|
366
|
-
### Filter
|
|
366
|
+
### Filter records
|
|
367
|
+
|
|
368
|
+
`Database` supports **DRF-style flat filters** (`filters(field, operator, value)`) and a **JSON filter tree** sent as the `filters` query param (`filters(tree)`).
|
|
367
369
|
|
|
368
370
|
```typescript
|
|
369
371
|
const db = new Database(taruviClient)
|
|
370
372
|
|
|
371
|
-
//
|
|
373
|
+
// Flat: one condition per call (chain for AND)
|
|
372
374
|
const filtered = await db
|
|
373
375
|
.from("accounts")
|
|
374
|
-
.
|
|
375
|
-
|
|
376
|
-
country: "USA"
|
|
377
|
-
})
|
|
376
|
+
.filters("status", "eq", "active")
|
|
377
|
+
.filters("country", "eq", "USA")
|
|
378
378
|
.execute()
|
|
379
379
|
|
|
380
|
-
//
|
|
380
|
+
// Flat: operators map to `field__suffix` query keys
|
|
381
381
|
const advanced = await db
|
|
382
382
|
.from("accounts")
|
|
383
|
-
.
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
ordering: "-created_at" // Sort by created_at descending
|
|
389
|
-
})
|
|
383
|
+
.filters("age", "gte", 18)
|
|
384
|
+
.filters("age", "lt", 65)
|
|
385
|
+
.filters("name", "icontains", "john")
|
|
386
|
+
.filters("created_at", "gte", "2024-01-01")
|
|
387
|
+
.orderBy("created_at", "desc")
|
|
390
388
|
.execute()
|
|
391
389
|
|
|
392
|
-
// Pagination
|
|
390
|
+
// Pagination (separate methods)
|
|
393
391
|
const paginated = await db
|
|
394
392
|
.from("accounts")
|
|
395
|
-
.
|
|
396
|
-
|
|
397
|
-
pageSize: 20
|
|
398
|
-
})
|
|
393
|
+
.page(1)
|
|
394
|
+
.pageSize(20)
|
|
399
395
|
.execute()
|
|
396
|
+
|
|
397
|
+
// JSON tree → `?filters=<url-encoded JSON>` (e.g. from Refine / your UI)
|
|
398
|
+
import type { BackendFilterTreeRoot } from "@taruvi/sdk"
|
|
399
|
+
const tree: BackendFilterTreeRoot = [
|
|
400
|
+
{
|
|
401
|
+
operator: "and",
|
|
402
|
+
value: [
|
|
403
|
+
{ field: "is_active", operator: "eq", value: true },
|
|
404
|
+
{ field: "hire_date", operator: "lt", value: "2021-01-01" },
|
|
405
|
+
],
|
|
406
|
+
},
|
|
407
|
+
]
|
|
408
|
+
await db.from("employees").filters(tree).execute()
|
|
400
409
|
```
|
|
401
410
|
|
|
402
|
-
### Populate
|
|
411
|
+
### Populate related records
|
|
403
412
|
|
|
404
413
|
Use `populate()` to eager load related records (foreign key relationships):
|
|
405
414
|
|
|
@@ -422,21 +431,20 @@ const invoices = await db
|
|
|
422
431
|
.populate(["customer", "created_by", "items"])
|
|
423
432
|
.execute()
|
|
424
433
|
|
|
425
|
-
// Combine with filters
|
|
434
|
+
// Combine with flat filters + populate
|
|
426
435
|
const recentOrders = await db
|
|
427
436
|
.from("orders")
|
|
428
|
-
.
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
ordering: "-created_at"
|
|
432
|
-
})
|
|
437
|
+
.filters("status", "eq", "completed")
|
|
438
|
+
.filters("created_at", "gte", "2024-01-01")
|
|
439
|
+
.orderBy("created_at", "desc")
|
|
433
440
|
.populate(["customer", "product"])
|
|
434
441
|
.execute()
|
|
435
442
|
|
|
436
|
-
// Combine with pagination
|
|
443
|
+
// Combine with pagination + populate
|
|
437
444
|
const paginatedOrders = await db
|
|
438
445
|
.from("orders")
|
|
439
|
-
.
|
|
446
|
+
.page(1)
|
|
447
|
+
.pageSize(10)
|
|
440
448
|
.populate(["customer"])
|
|
441
449
|
.execute()
|
|
442
450
|
```
|
|
@@ -1285,8 +1293,8 @@ All query-building services use method chaining:
|
|
|
1285
1293
|
// Database
|
|
1286
1294
|
const db = new Database(taruviClient)
|
|
1287
1295
|
await db.from("table").get("id").update(data).execute()
|
|
1288
|
-
await db.from("table").
|
|
1289
|
-
await db.from("table").
|
|
1296
|
+
await db.from("table").filters("status", "eq", "active").execute()
|
|
1297
|
+
await db.from("table").page(1).populate(["related_field"]).execute()
|
|
1290
1298
|
await db.from("table").create({ name: "New" }).execute()
|
|
1291
1299
|
|
|
1292
1300
|
// Storage
|
package/package.json
CHANGED
package/src/client.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { HttpClient } from "./lib-internal/http/HttpClient.js";
|
|
2
|
-
import { TokenClient
|
|
2
|
+
import { TokenClient } from "./lib-internal/token/TokenClient.js";
|
|
3
3
|
import type { TaruviConfig } from "./types.js";
|
|
4
4
|
import packageJson from "../package.json" with { type: "json" };
|
|
5
5
|
|
package/src/index.ts
CHANGED
|
@@ -35,7 +35,8 @@ export type { RoleData, AppSettingsData, RoleResponse, RolesListResponse, AppSet
|
|
|
35
35
|
export type { FunctionRequest, FunctionResponse, FunctionInvocation } from "./lib/functions/types.js"
|
|
36
36
|
|
|
37
37
|
// Database types
|
|
38
|
-
export type { DatabaseRequest, DatabaseResponse, DatabaseSingleResponse, FilterOperator, SortOrder, GraphInclude, GraphFormat, EdgeRequest, EdgeResponse, EdgeDeleteRequest } from "./lib/database/types.js"
|
|
38
|
+
export type { DatabaseRequest, DatabaseResponse, DatabaseSingleResponse, FilterOperator, SortOrder, GraphInclude, GraphFormat, EdgeRequest, EdgeResponse, EdgeDeleteRequest, PgRangeValue, BackendFilterLeafNode, BackendFilterLogicalNode, BackendFilterNode, BackendFilterTreeRoot } from "./lib/database/types.js"
|
|
39
|
+
export { isBackendFilterTreeRoot } from "./lib/database/types.js"
|
|
39
40
|
|
|
40
41
|
// Storage types
|
|
41
42
|
export type { StorageRequest, StorageUpdateRequest, StorageObject, StorageResponse, StorageListResponse, StorageUploadBatchResponse, StorageDeleteBatchResponse } from "./lib/storage/types.js"
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import type { Client } from "../../client.js";
|
|
2
2
|
import type { TaruviResponse } from "../../types.js";
|
|
3
3
|
import type { UserData } from "../users/types.js";
|
|
4
|
-
import { AuthRoutes } from "../../lib-internal/routes/AuthRoutes.js";
|
|
5
4
|
import { UserRoutes } from "../../lib-internal/routes/UserRoutes.js";
|
|
6
5
|
|
|
7
6
|
/**
|
|
@@ -95,33 +94,12 @@ export class Auth {
|
|
|
95
94
|
}
|
|
96
95
|
|
|
97
96
|
/**
|
|
98
|
-
* Check if
|
|
97
|
+
* Check if user is authenticated (has session token)
|
|
99
98
|
*/
|
|
100
|
-
|
|
99
|
+
isUserAuthenticated(): boolean {
|
|
101
100
|
return this.client.tokenClient.isAuthenticated()
|
|
102
101
|
}
|
|
103
102
|
|
|
104
|
-
/**
|
|
105
|
-
* Check if user is authenticated by validating session with the server
|
|
106
|
-
*/
|
|
107
|
-
async isUserAuthenticated(): Promise<boolean> {
|
|
108
|
-
if (!this.hasToken()) return false
|
|
109
|
-
try {
|
|
110
|
-
await this.validateSession()
|
|
111
|
-
return true
|
|
112
|
-
} catch {
|
|
113
|
-
return false
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
/**
|
|
118
|
-
* Validate current session token with auth session endpoint.
|
|
119
|
-
* HttpClient injects X-Session-Token automatically.
|
|
120
|
-
*/
|
|
121
|
-
async validateSession(): Promise<void> {
|
|
122
|
-
await this.client.httpClient.get(AuthRoutes.session())
|
|
123
|
-
}
|
|
124
|
-
|
|
125
103
|
/**
|
|
126
104
|
* Get the current session token
|
|
127
105
|
*/
|
|
@@ -134,7 +112,7 @@ export class Auth {
|
|
|
134
112
|
* @returns Promise with user data or null if not authenticated
|
|
135
113
|
*/
|
|
136
114
|
async getCurrentUser(): Promise<TaruviResponse<UserData> | null> {
|
|
137
|
-
if (!this.
|
|
115
|
+
if (!this.isUserAuthenticated()) {
|
|
138
116
|
return null
|
|
139
117
|
}
|
|
140
118
|
|
|
@@ -2,7 +2,8 @@ import type { Client } from "../../client.js";
|
|
|
2
2
|
import { DatabaseRoutes } from "../../lib-internal/routes/DatabaseRoutes.js";
|
|
3
3
|
import { HttpMethod } from "../../lib-internal/http/types.js";
|
|
4
4
|
import type { TaruviConfig, DatabaseFilters, TaruviResponse } from "../../types.js";
|
|
5
|
-
import type { UrlParams, FilterOperator, SortOrder, GraphInclude, GraphFormat, EdgeRequest, EdgeDeleteRequest } from "./types.js";
|
|
5
|
+
import type { UrlParams, FilterOperator, SortOrder, GraphInclude, GraphFormat, EdgeRequest, EdgeDeleteRequest, BackendFilterTreeRoot } from "./types.js";
|
|
6
|
+
import { isBackendFilterTreeRoot } from "./types.js";
|
|
6
7
|
import { buildQueryString } from "../../utils/utils.js";
|
|
7
8
|
|
|
8
9
|
interface GraphQueryParams {
|
|
@@ -62,17 +63,65 @@ export class Database<T = Record<string, unknown>> {
|
|
|
62
63
|
}
|
|
63
64
|
|
|
64
65
|
// Filter & query methods
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
66
|
+
/**
|
|
67
|
+
* JSON filter tree for the `filters` query param. Must match the platform contract:
|
|
68
|
+
* root is an array of logical nodes `{ operator: "and" | "or", value: [...] }`; leaves are
|
|
69
|
+
* `{ field, operator, value }` where `operator` is a **backend** token (`contains` = case-insensitive
|
|
70
|
+
* substring, `containss` = case-sensitive, `eq`, `in`, …). The SDK only JSON-stringifies this value.
|
|
71
|
+
*/
|
|
72
|
+
filters(tree: BackendFilterTreeRoot): Database<T>
|
|
73
|
+
/**
|
|
74
|
+
* DRF-style flat filter: `field` or `field__operator` query keys.
|
|
75
|
+
*/
|
|
76
|
+
filters(field: string, operator: FilterOperator, value: string | number | boolean | (string | number | boolean)[]): Database<T>
|
|
77
|
+
filters(
|
|
78
|
+
arg0: string | BackendFilterTreeRoot,
|
|
79
|
+
arg1?: FilterOperator,
|
|
80
|
+
arg2?: string | number | boolean | (string | number | boolean)[]
|
|
81
|
+
): Database<T> {
|
|
82
|
+
if (typeof arg0 === 'string' && arg1 !== undefined && arg2 !== undefined) {
|
|
83
|
+
const field = arg0
|
|
84
|
+
const operator = arg1
|
|
85
|
+
const value = arg2
|
|
86
|
+
const filterKey = operator === 'eq' ? field : `${field}__${operator}`
|
|
87
|
+
const filterValue = Array.isArray(value) ? value.join(',') : value
|
|
88
|
+
return new Database<T>(this.client, { ...this.urlParams }, undefined, undefined, {
|
|
89
|
+
...this.queryParams,
|
|
90
|
+
[filterKey]: filterValue
|
|
91
|
+
}, { ...this.graphParams }, this.isEdges)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (arg1 === undefined && arg2 === undefined && isBackendFilterTreeRoot(arg0)) {
|
|
95
|
+
return new Database<T>(this.client, { ...this.urlParams }, undefined, undefined, {
|
|
96
|
+
...this.queryParams,
|
|
97
|
+
filters: JSON.stringify(arg0)
|
|
98
|
+
}, { ...this.graphParams }, this.isEdges)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
throw new TypeError(
|
|
102
|
+
'Database.filters: use filters(tree) with a root array of { operator: "and"|"or", value: [...] }, or filters(field, operator, value).'
|
|
103
|
+
)
|
|
72
104
|
}
|
|
73
105
|
|
|
74
|
-
|
|
75
|
-
|
|
106
|
+
/**
|
|
107
|
+
* Sets the `ordering` query param (DRF-style: `-field` for desc, comma-separated for multiple).
|
|
108
|
+
* - `orderBy('created_at', 'desc')` — one column (optional second arg defaults to `'asc'`)
|
|
109
|
+
* - `orderBy([{ field: 'salary', order: 'desc' }, { field: 'hire_date' }])` — multiple columns
|
|
110
|
+
* - `orderBy('-salary,hire_date')` — raw string (e.g. from `convertRefineSorters`); omit the second arg
|
|
111
|
+
*/
|
|
112
|
+
orderBy(
|
|
113
|
+
fieldOrFields: string | Array<{ field: string; order?: SortOrder }>,
|
|
114
|
+
order?: SortOrder
|
|
115
|
+
): Database<T> {
|
|
116
|
+
let ordering: string
|
|
117
|
+
if (typeof fieldOrFields === 'string') {
|
|
118
|
+
const o = order ?? 'asc'
|
|
119
|
+
ordering = o === 'desc' ? `-${fieldOrFields}` : fieldOrFields
|
|
120
|
+
} else {
|
|
121
|
+
ordering = fieldOrFields
|
|
122
|
+
.map(({ field, order: o = 'asc' }) => (o === 'desc' ? `-${field}` : field))
|
|
123
|
+
.join(',')
|
|
124
|
+
}
|
|
76
125
|
return new Database<T>(this.client, { ...this.urlParams }, undefined, undefined, {
|
|
77
126
|
...this.queryParams,
|
|
78
127
|
ordering
|
|
@@ -100,6 +149,11 @@ export class Database<T = Record<string, unknown>> {
|
|
|
100
149
|
}, { ...this.graphParams }, this.isEdges)
|
|
101
150
|
}
|
|
102
151
|
|
|
152
|
+
/** Populate all first-level relations (`?populate=*`). */
|
|
153
|
+
populateAll(): Database<T> {
|
|
154
|
+
return this.populate(['*'])
|
|
155
|
+
}
|
|
156
|
+
|
|
103
157
|
search(query: string): Database<T> {
|
|
104
158
|
return new Database<T>(this.client, { ...this.urlParams }, undefined, undefined, {
|
|
105
159
|
...this.queryParams,
|
|
@@ -107,6 +161,14 @@ export class Database<T = Record<string, unknown>> {
|
|
|
107
161
|
}, { ...this.graphParams }, this.isEdges)
|
|
108
162
|
}
|
|
109
163
|
|
|
164
|
+
/** Restrict SELECT to named columns (`?fields=a,b,c`). */
|
|
165
|
+
fields(columns: string): Database<T> {
|
|
166
|
+
return new Database<T>(this.client, { ...this.urlParams }, undefined, undefined, {
|
|
167
|
+
...this.queryParams,
|
|
168
|
+
fields: columns
|
|
169
|
+
}, { ...this.graphParams }, this.isEdges)
|
|
170
|
+
}
|
|
171
|
+
|
|
110
172
|
allowedActions(actions: string[]): Database<T> {
|
|
111
173
|
return new Database<T>(this.client, { ...this.urlParams }, undefined, undefined, {
|
|
112
174
|
...this.queryParams,
|
|
@@ -4,7 +4,7 @@ import type { TaruviResponse } from "../../types.js"
|
|
|
4
4
|
|
|
5
5
|
export type DatabaseOperation = HttpMethod
|
|
6
6
|
|
|
7
|
-
// Filter operators matching backend FilterParams.OPERATORS
|
|
7
|
+
// Filter operators matching backend FilterParams.OPERATORS
|
|
8
8
|
export type FilterOperator =
|
|
9
9
|
// Comparison
|
|
10
10
|
| 'eq' // Equal
|
|
@@ -18,29 +18,49 @@ export type FilterOperator =
|
|
|
18
18
|
| 'nin' // Not in array
|
|
19
19
|
| 'ina' // In array (case-insensitive)
|
|
20
20
|
| 'nina' // Not in array (case-insensitive)
|
|
21
|
-
// String contains
|
|
22
|
-
| 'contains' // Contains (case-
|
|
23
|
-
| 'ncontains' // Not contains (case-
|
|
24
|
-
| 'containss' // Contains (case-sensitive
|
|
25
|
-
| 'ncontainss' // Not contains (case-sensitive
|
|
26
|
-
| 'icontains' //
|
|
27
|
-
| 'nicontains' //
|
|
21
|
+
// String contains (backend: plain contains uses ILIKE; *s suffix = case-sensitive LIKE)
|
|
22
|
+
| 'contains' // Contains (case-insensitive, ILIKE)
|
|
23
|
+
| 'ncontains' // Not contains (case-insensitive)
|
|
24
|
+
| 'containss' // Contains (case-sensitive LIKE)
|
|
25
|
+
| 'ncontainss' // Not contains (case-sensitive LIKE)
|
|
26
|
+
| 'icontains' // Alias → contains (case-insensitive)
|
|
27
|
+
| 'nicontains' // Alias → ncontains
|
|
28
28
|
// Starts with
|
|
29
|
-
| 'startswith' // Starts with (case-
|
|
30
|
-
| 'nstartswith' // Not starts with (case-
|
|
31
|
-
| 'startswiths' // Starts with (case-sensitive
|
|
32
|
-
| 'nstartswiths' // Not starts with (case-sensitive
|
|
29
|
+
| 'startswith' // Starts with (case-insensitive)
|
|
30
|
+
| 'nstartswith' // Not starts with (case-insensitive)
|
|
31
|
+
| 'startswiths' // Starts with (case-sensitive LIKE)
|
|
32
|
+
| 'nstartswiths' // Not starts with (case-sensitive LIKE)
|
|
33
33
|
// Ends with
|
|
34
|
-
| 'endswith' // Ends with (case-
|
|
35
|
-
| 'nendswith' // Not ends with (case-
|
|
36
|
-
| 'endswiths' // Ends with (case-sensitive
|
|
37
|
-
| 'nendswiths' // Not ends with (case-sensitive
|
|
34
|
+
| 'endswith' // Ends with (case-insensitive)
|
|
35
|
+
| 'nendswith' // Not ends with (case-insensitive)
|
|
36
|
+
| 'endswiths' // Ends with (case-sensitive LIKE)
|
|
37
|
+
| 'nendswiths' // Not ends with (case-sensitive LIKE)
|
|
38
38
|
// Range
|
|
39
39
|
| 'between' // Between two values
|
|
40
40
|
| 'nbetween' // Not between two values
|
|
41
41
|
// Null checks
|
|
42
42
|
| 'null' // Is null
|
|
43
43
|
| 'nnull' // Is not null
|
|
44
|
+
// Search / pattern
|
|
45
|
+
| 'search' // Full-text search
|
|
46
|
+
| 'like' // SQL LIKE pattern
|
|
47
|
+
| 'ilike' // Case-insensitive LIKE
|
|
48
|
+
// Array containment (PostgreSQL)
|
|
49
|
+
| 'acontains' // Array contains all items (column @> ARRAY[values])
|
|
50
|
+
| 'nacontains' // NOT array contains
|
|
51
|
+
| 'acontainedby' // Array contained by (column <@ ARRAY[values])
|
|
52
|
+
| 'nacontainedby' // NOT array contained by
|
|
53
|
+
| 'aoverlap' // Arrays have overlap (column && ARRAY[values])
|
|
54
|
+
| 'naoverlap' // No overlap
|
|
55
|
+
| 'aelement' // Value exists in array (value = ANY(column))
|
|
56
|
+
| 'naelement' // Value not in array (value != ALL(column))
|
|
57
|
+
// PostgreSQL range type operators
|
|
58
|
+
| 'rcontains' // Range column @> value or [lower, upper]
|
|
59
|
+
| 'rcontainedby' // Range column <@ [lower, upper]
|
|
60
|
+
| 'roverlaps' // Range column && [lower, upper]
|
|
61
|
+
| 'radjacent' // Range column -|- [lower, upper]
|
|
62
|
+
| 'rstrictleft' // Range column << [lower, upper]
|
|
63
|
+
| 'rstrictright' // Range column >> [lower, upper]
|
|
44
64
|
|
|
45
65
|
export type SortOrder = 'asc' | 'desc'
|
|
46
66
|
|
|
@@ -87,4 +107,50 @@ export interface EdgeResponse {
|
|
|
87
107
|
|
|
88
108
|
export interface EdgeDeleteRequest {
|
|
89
109
|
edge_ids: number[]
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export interface PgRangeValue {
|
|
113
|
+
lower: string | number | null
|
|
114
|
+
upper: string | number | null
|
|
115
|
+
bounds: '()' | '(]' | '[)' | '[]'
|
|
116
|
+
empty: boolean
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Leaf node for the backend `filters` JSON query param.
|
|
121
|
+
* Leaf `operator` strings are platform tokens (see taruvi-platform `FIELD_OPERATORS` /
|
|
122
|
+
* `filter_translator`). `contains` = case-insensitive substring; `containss` = case-sensitive.
|
|
123
|
+
*/
|
|
124
|
+
export interface BackendFilterLeafNode {
|
|
125
|
+
field: string
|
|
126
|
+
operator: string
|
|
127
|
+
value: unknown
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Logical `and` / `or` group for the backend `filters` JSON query param. */
|
|
131
|
+
export interface BackendFilterLogicalNode {
|
|
132
|
+
operator: 'and' | 'or'
|
|
133
|
+
value: BackendFilterNode[]
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export type BackendFilterNode = BackendFilterLogicalNode | BackendFilterLeafNode
|
|
137
|
+
|
|
138
|
+
/** Top level of the `filters` JSON payload: an array of logical nodes. */
|
|
139
|
+
export type BackendFilterTreeRoot = BackendFilterLogicalNode[]
|
|
140
|
+
|
|
141
|
+
function isPlainObject(x: unknown): x is Record<string, unknown> {
|
|
142
|
+
return typeof x === 'object' && x !== null && !Array.isArray(x)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function isBackendFilterLogicalNode(x: unknown): x is BackendFilterLogicalNode {
|
|
146
|
+
if (!isPlainObject(x)) return false
|
|
147
|
+
if (x.operator !== 'and' && x.operator !== 'or') return false
|
|
148
|
+
if (!Array.isArray(x.value)) return false
|
|
149
|
+
if ('field' in x) return false
|
|
150
|
+
return true
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Runtime check for the single-arg `filters(tree)` overload. */
|
|
154
|
+
export function isBackendFilterTreeRoot(x: unknown): x is BackendFilterTreeRoot {
|
|
155
|
+
return Array.isArray(x) && x.every(isBackendFilterLogicalNode)
|
|
90
156
|
}
|
|
@@ -12,12 +12,10 @@ import type { ErrorResponseBody } from "../errors/index.js";
|
|
|
12
12
|
* @internal
|
|
13
13
|
*/
|
|
14
14
|
export class HttpClient {
|
|
15
|
-
private config: TaruviConfig
|
|
16
15
|
private tokenClient: TokenClient
|
|
17
16
|
private axiosInstance: AxiosInstance
|
|
18
17
|
|
|
19
18
|
constructor(config: TaruviConfig, tokenClient: TokenClient) {
|
|
20
|
-
this.config = config
|
|
21
19
|
this.tokenClient = tokenClient
|
|
22
20
|
this.axiosInstance = axios.create({ baseURL: config.apiUrl, withCredentials: true })
|
|
23
21
|
this.setupInterceptors()
|
|
@@ -25,6 +23,7 @@ export class HttpClient {
|
|
|
25
23
|
|
|
26
24
|
private setupInterceptors(): void {
|
|
27
25
|
// Request interceptor: attach session token
|
|
26
|
+
console.log("test")
|
|
28
27
|
this.axiosInstance.interceptors.request.use((config: InternalAxiosRequestConfig) => {
|
|
29
28
|
const isFormData = config.data instanceof FormData
|
|
30
29
|
if (!isFormData) {
|
package/src/types.ts
CHANGED
|
@@ -93,6 +93,12 @@ export interface DatabaseFilters {
|
|
|
93
93
|
_group_by?: string
|
|
94
94
|
_having?: string
|
|
95
95
|
|
|
96
|
+
/**
|
|
97
|
+
* JSON filter tree for the `filters` query param (set via `Database.filters(tree)`).
|
|
98
|
+
* Do not use the flat `filters(field, …)` triple overload with `field === 'filters'`.
|
|
99
|
+
*/
|
|
100
|
+
filters?: string
|
|
101
|
+
|
|
96
102
|
// Dynamic filters - allows any field with operators
|
|
97
103
|
[key: string]: string | number | boolean | undefined
|
|
98
104
|
}
|
package/src/utils/utils.ts
CHANGED
|
@@ -18,6 +18,7 @@ export const getRuntimeEnvironment = (): string => {
|
|
|
18
18
|
return 'Server'
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
+
/** Builds a query string. Keys may include pre-flattened bracket names (e.g. CrudFilters `filters[0][field]=...`). */
|
|
21
22
|
export function buildQueryString(queryParams: Record<string, unknown> | undefined): string {
|
|
22
23
|
if (!queryParams || Object.keys(queryParams).length === 0) {
|
|
23
24
|
return ''
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
2
2
|
import { Database } from '../../../src/lib/database/DatabaseClient.js'
|
|
3
3
|
import { Client } from '../../../src/client.js'
|
|
4
|
-
import type { DatabaseResponse, DatabaseSingleResponse, EdgeResponse } from '../../../src/lib/database/types.js'
|
|
4
|
+
import type { BackendFilterTreeRoot, DatabaseResponse, DatabaseSingleResponse, EdgeResponse } from '../../../src/lib/database/types.js'
|
|
5
5
|
import type { TaruviResponse } from '../../../src/types.js'
|
|
6
6
|
|
|
7
7
|
// Mock the Client
|
|
@@ -30,61 +30,61 @@ describe('Database', () => {
|
|
|
30
30
|
})
|
|
31
31
|
})
|
|
32
32
|
|
|
33
|
-
describe('
|
|
33
|
+
describe('filters() flat (triple-arg)', () => {
|
|
34
34
|
it('eq operator uses field name without suffix', async () => {
|
|
35
35
|
mockHttpClient.get.mockResolvedValue([])
|
|
36
|
-
await new Database(mockClient).from('accounts').
|
|
36
|
+
await new Database(mockClient).from('accounts').filters('status', 'eq', 'active').execute()
|
|
37
37
|
expect(mockHttpClient.get).toHaveBeenCalledWith(expect.stringContaining('status=active'))
|
|
38
38
|
})
|
|
39
39
|
|
|
40
40
|
it('gt operator appends __gt suffix', async () => {
|
|
41
41
|
mockHttpClient.get.mockResolvedValue([])
|
|
42
|
-
await new Database(mockClient).from('accounts').
|
|
42
|
+
await new Database(mockClient).from('accounts').filters('age', 'gt', 18).execute()
|
|
43
43
|
expect(mockHttpClient.get).toHaveBeenCalledWith(expect.stringContaining('age__gt=18'))
|
|
44
44
|
})
|
|
45
45
|
|
|
46
46
|
it('gte operator appends __gte suffix', async () => {
|
|
47
47
|
mockHttpClient.get.mockResolvedValue([])
|
|
48
|
-
await new Database(mockClient).from('accounts').
|
|
48
|
+
await new Database(mockClient).from('accounts').filters('age', 'gte', 18).execute()
|
|
49
49
|
expect(mockHttpClient.get).toHaveBeenCalledWith(expect.stringContaining('age__gte=18'))
|
|
50
50
|
})
|
|
51
51
|
|
|
52
52
|
it('lt operator appends __lt suffix', async () => {
|
|
53
53
|
mockHttpClient.get.mockResolvedValue([])
|
|
54
|
-
await new Database(mockClient).from('accounts').
|
|
54
|
+
await new Database(mockClient).from('accounts').filters('age', 'lt', 65).execute()
|
|
55
55
|
expect(mockHttpClient.get).toHaveBeenCalledWith(expect.stringContaining('age__lt=65'))
|
|
56
56
|
})
|
|
57
57
|
|
|
58
58
|
it('lte operator appends __lte suffix', async () => {
|
|
59
59
|
mockHttpClient.get.mockResolvedValue([])
|
|
60
|
-
await new Database(mockClient).from('accounts').
|
|
60
|
+
await new Database(mockClient).from('accounts').filters('age', 'lte', 65).execute()
|
|
61
61
|
expect(mockHttpClient.get).toHaveBeenCalledWith(expect.stringContaining('age__lte=65'))
|
|
62
62
|
})
|
|
63
63
|
|
|
64
64
|
it('icontains operator appends __icontains suffix', async () => {
|
|
65
65
|
mockHttpClient.get.mockResolvedValue([])
|
|
66
|
-
await new Database(mockClient).from('accounts').
|
|
66
|
+
await new Database(mockClient).from('accounts').filters('name', 'icontains', 'john').execute()
|
|
67
67
|
expect(mockHttpClient.get).toHaveBeenCalledWith(expect.stringContaining('name__icontains=john'))
|
|
68
68
|
})
|
|
69
69
|
|
|
70
70
|
it('in operator joins array values with comma', async () => {
|
|
71
71
|
mockHttpClient.get.mockResolvedValue([])
|
|
72
|
-
await new Database(mockClient).from('accounts').
|
|
72
|
+
await new Database(mockClient).from('accounts').filters('status', 'in', ['active', 'pending']).execute()
|
|
73
73
|
expect(mockHttpClient.get).toHaveBeenCalledWith(expect.stringContaining('status__in=active%2Cpending'))
|
|
74
74
|
})
|
|
75
75
|
|
|
76
|
-
it('
|
|
76
|
+
it('null operator appends __null suffix', async () => {
|
|
77
77
|
mockHttpClient.get.mockResolvedValue([])
|
|
78
|
-
await new Database(mockClient).from('accounts').
|
|
79
|
-
expect(mockHttpClient.get).toHaveBeenCalledWith(expect.stringContaining('
|
|
78
|
+
await new Database(mockClient).from('accounts').filters('deleted_at', 'null', true).execute()
|
|
79
|
+
expect(mockHttpClient.get).toHaveBeenCalledWith(expect.stringContaining('deleted_at__null=true'))
|
|
80
80
|
})
|
|
81
81
|
|
|
82
82
|
it('multiple filters chain correctly', async () => {
|
|
83
83
|
mockHttpClient.get.mockResolvedValue([])
|
|
84
84
|
await new Database(mockClient)
|
|
85
85
|
.from('accounts')
|
|
86
|
-
.
|
|
87
|
-
.
|
|
86
|
+
.filters('status', 'eq', 'active')
|
|
87
|
+
.filters('age', 'gte', 18)
|
|
88
88
|
.execute()
|
|
89
89
|
const url = mockHttpClient.get.mock.calls[0][0]
|
|
90
90
|
expect(url).toContain('status=active')
|
|
@@ -92,24 +92,105 @@ describe('Database', () => {
|
|
|
92
92
|
})
|
|
93
93
|
})
|
|
94
94
|
|
|
95
|
-
describe('
|
|
95
|
+
describe('filters() JSON tree', () => {
|
|
96
|
+
const normativeTree: BackendFilterTreeRoot = [
|
|
97
|
+
{
|
|
98
|
+
operator: 'and',
|
|
99
|
+
value: [
|
|
100
|
+
{ field: 'is_active', operator: 'eq', value: true },
|
|
101
|
+
{ field: 'hire_date', operator: 'lt', value: '2021-01-01' },
|
|
102
|
+
{
|
|
103
|
+
operator: 'or',
|
|
104
|
+
value: [
|
|
105
|
+
{ field: 'salary', operator: 'gte', value: 200000 },
|
|
106
|
+
{
|
|
107
|
+
operator: 'and',
|
|
108
|
+
value: [
|
|
109
|
+
{ field: 'title', operator: 'containss', value: 'VP' },
|
|
110
|
+
{ field: 'bonus', operator: 'gte', value: 50000 },
|
|
111
|
+
],
|
|
112
|
+
},
|
|
113
|
+
],
|
|
114
|
+
},
|
|
115
|
+
],
|
|
116
|
+
},
|
|
117
|
+
]
|
|
118
|
+
|
|
119
|
+
it('serializes tree into filters query param (round-trip)', async () => {
|
|
120
|
+
mockHttpClient.get.mockResolvedValue([])
|
|
121
|
+
await new Database(mockClient).from('accounts').filters(normativeTree).execute()
|
|
122
|
+
const url = mockHttpClient.get.mock.calls[0][0] as string
|
|
123
|
+
const q = url.includes('?') ? url.split('?')[1] : ''
|
|
124
|
+
const params = new URLSearchParams(q)
|
|
125
|
+
const raw = params.get('filters')
|
|
126
|
+
expect(raw).toBeTruthy()
|
|
127
|
+
expect(JSON.parse(decodeURIComponent(raw!))).toEqual(normativeTree)
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
it('chains JSON filters with populate', async () => {
|
|
131
|
+
mockHttpClient.get.mockResolvedValue([])
|
|
132
|
+
await new Database(mockClient)
|
|
133
|
+
.from('accounts')
|
|
134
|
+
.filters(normativeTree)
|
|
135
|
+
.populate(['customer'])
|
|
136
|
+
.execute()
|
|
137
|
+
const url = mockHttpClient.get.mock.calls[0][0] as string
|
|
138
|
+
expect(url).toContain('populate=customer')
|
|
139
|
+
const q = url.split('?')[1]
|
|
140
|
+
expect(new URLSearchParams(q).get('filters')).toBeTruthy()
|
|
141
|
+
})
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
describe('orderBy()', () => {
|
|
96
145
|
it('asc order uses field name without prefix', async () => {
|
|
97
146
|
mockHttpClient.get.mockResolvedValue([])
|
|
98
|
-
await new Database(mockClient).from('accounts').
|
|
147
|
+
await new Database(mockClient).from('accounts').orderBy('created_at', 'asc').execute()
|
|
99
148
|
expect(mockHttpClient.get).toHaveBeenCalledWith(expect.stringContaining('ordering=created_at'))
|
|
100
149
|
})
|
|
101
150
|
|
|
102
151
|
it('desc order adds - prefix', async () => {
|
|
103
152
|
mockHttpClient.get.mockResolvedValue([])
|
|
104
|
-
await new Database(mockClient).from('accounts').
|
|
153
|
+
await new Database(mockClient).from('accounts').orderBy('created_at', 'desc').execute()
|
|
105
154
|
expect(mockHttpClient.get).toHaveBeenCalledWith(expect.stringContaining('ordering=-created_at'))
|
|
106
155
|
})
|
|
107
156
|
|
|
108
157
|
it('defaults to asc when order not specified', async () => {
|
|
109
158
|
mockHttpClient.get.mockResolvedValue([])
|
|
110
|
-
await new Database(mockClient).from('accounts').
|
|
159
|
+
await new Database(mockClient).from('accounts').orderBy('name').execute()
|
|
111
160
|
expect(mockHttpClient.get).toHaveBeenCalledWith(expect.stringMatching(/ordering=name(?!-)/))
|
|
112
161
|
})
|
|
162
|
+
|
|
163
|
+
it('comma-joins multiple fields from array (mixed asc/desc)', async () => {
|
|
164
|
+
mockHttpClient.get.mockResolvedValue([])
|
|
165
|
+
await new Database(mockClient)
|
|
166
|
+
.from('accounts')
|
|
167
|
+
.orderBy([
|
|
168
|
+
{ field: 'salary', order: 'desc' },
|
|
169
|
+
{ field: 'hire_date', order: 'asc' },
|
|
170
|
+
])
|
|
171
|
+
.execute()
|
|
172
|
+
expect(mockHttpClient.get).toHaveBeenCalledWith(
|
|
173
|
+
expect.stringContaining('ordering=-salary%2Chire_date')
|
|
174
|
+
)
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
it('accepts pre-built ordering string', async () => {
|
|
178
|
+
mockHttpClient.get.mockResolvedValue([])
|
|
179
|
+
await new Database(mockClient).from('accounts').orderBy('-a,b').execute()
|
|
180
|
+
expect(mockHttpClient.get).toHaveBeenCalledWith(expect.stringContaining('ordering=-a%2Cb'))
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
it('chains with filters without dropping ordering', async () => {
|
|
184
|
+
mockHttpClient.get.mockResolvedValue([])
|
|
185
|
+
await new Database(mockClient)
|
|
186
|
+
.from('accounts')
|
|
187
|
+
.filters('status', 'eq', 'active')
|
|
188
|
+
.orderBy([{ field: 'name', order: 'asc' }])
|
|
189
|
+
.execute()
|
|
190
|
+
const url = mockHttpClient.get.mock.calls[0][0] as string
|
|
191
|
+
expect(url).toContain('status=active')
|
|
192
|
+
expect(url).toContain('ordering=name')
|
|
193
|
+
})
|
|
113
194
|
})
|
|
114
195
|
|
|
115
196
|
describe('pagination', () => {
|
|
@@ -132,6 +213,12 @@ describe('Database', () => {
|
|
|
132
213
|
await new Database(mockClient).from('orders').populate(['customer', 'items']).execute()
|
|
133
214
|
expect(mockHttpClient.get).toHaveBeenCalledWith(expect.stringContaining('populate=customer%2Citems'))
|
|
134
215
|
})
|
|
216
|
+
|
|
217
|
+
it('populateAll sets wildcard populate', async () => {
|
|
218
|
+
mockHttpClient.get.mockResolvedValue([])
|
|
219
|
+
await new Database(mockClient).from('orders').populateAll().execute()
|
|
220
|
+
expect(mockHttpClient.get).toHaveBeenCalledWith(expect.stringContaining('populate=*'))
|
|
221
|
+
})
|
|
135
222
|
})
|
|
136
223
|
|
|
137
224
|
describe('CRUD operations', () => {
|
|
@@ -232,7 +319,7 @@ describe('Database', () => {
|
|
|
232
319
|
mockHttpClient.get.mockResolvedValue([])
|
|
233
320
|
await new Database(mockClient)
|
|
234
321
|
.from('accounts')
|
|
235
|
-
.
|
|
322
|
+
.filters('status', 'eq', 'active')
|
|
236
323
|
.page(1)
|
|
237
324
|
.pageSize(10)
|
|
238
325
|
.execute()
|
|
@@ -486,7 +573,7 @@ describe('Database', () => {
|
|
|
486
573
|
describe('deleteFiltered()', () => {
|
|
487
574
|
it('calls httpClient.delete with filter params in query string', async () => {
|
|
488
575
|
mockHttpClient.delete.mockResolvedValue({ status: 'success' })
|
|
489
|
-
await new Database(mockClient).from('accounts').
|
|
576
|
+
await new Database(mockClient).from('accounts').filters('status', 'eq', 'inactive').deleteFiltered().execute()
|
|
490
577
|
const url = mockHttpClient.delete.mock.calls[0][0]
|
|
491
578
|
expect(url).toContain('status=inactive')
|
|
492
579
|
expect(url).not.toContain('/undefined/')
|
|
@@ -494,7 +581,7 @@ describe('Database', () => {
|
|
|
494
581
|
|
|
495
582
|
it('hits collection endpoint without recordId', async () => {
|
|
496
583
|
mockHttpClient.delete.mockResolvedValue({ status: 'success' })
|
|
497
|
-
await new Database(mockClient).from('accounts').
|
|
584
|
+
await new Database(mockClient).from('accounts').filters('age', 'lt', 18).deleteFiltered().execute()
|
|
498
585
|
expect(mockHttpClient.delete).toHaveBeenCalledWith(
|
|
499
586
|
expect.stringContaining('api/apps/test-app/datatables/accounts/data/?')
|
|
500
587
|
)
|
|
@@ -100,26 +100,26 @@ describe('Encoding edge cases', () => {
|
|
|
100
100
|
|
|
101
101
|
it('encodes filter value with spaces', async () => {
|
|
102
102
|
mockHttpClient.get.mockResolvedValue([])
|
|
103
|
-
await new Database(mockClient).from('accounts').
|
|
103
|
+
await new Database(mockClient).from('accounts').filters('name', 'eq', 'John Doe').execute()
|
|
104
104
|
expect(mockHttpClient.get).toHaveBeenCalledWith(expect.stringContaining('name=John+Doe'))
|
|
105
105
|
})
|
|
106
106
|
|
|
107
107
|
it('encodes filter value with special characters', async () => {
|
|
108
108
|
mockHttpClient.get.mockResolvedValue([])
|
|
109
|
-
await new Database(mockClient).from('accounts').
|
|
109
|
+
await new Database(mockClient).from('accounts').filters('email', 'eq', 'user@example.com').execute()
|
|
110
110
|
expect(mockHttpClient.get).toHaveBeenCalledWith(expect.stringContaining('email=user%40example.com'))
|
|
111
111
|
})
|
|
112
112
|
|
|
113
113
|
it('encodes filter value with ampersand', async () => {
|
|
114
114
|
mockHttpClient.get.mockResolvedValue([])
|
|
115
|
-
await new Database(mockClient).from('accounts').
|
|
115
|
+
await new Database(mockClient).from('accounts').filters('company', 'eq', 'A&B Corp').execute()
|
|
116
116
|
const url = mockHttpClient.get.mock.calls[0][0]
|
|
117
117
|
expect(url).toContain('company=A%26B')
|
|
118
118
|
})
|
|
119
119
|
|
|
120
120
|
it('handles unicode in filter values', async () => {
|
|
121
121
|
mockHttpClient.get.mockResolvedValue([])
|
|
122
|
-
await new Database(mockClient).from('accounts').
|
|
122
|
+
await new Database(mockClient).from('accounts').filters('name', 'icontains', '日本語').execute()
|
|
123
123
|
expect(mockHttpClient.get).toHaveBeenCalledWith(expect.stringContaining('name__icontains='))
|
|
124
124
|
})
|
|
125
125
|
|
|
@@ -137,7 +137,7 @@ describe('Encoding edge cases', () => {
|
|
|
137
137
|
|
|
138
138
|
it('handles empty string filter value', async () => {
|
|
139
139
|
mockHttpClient.get.mockResolvedValue([])
|
|
140
|
-
await new Database(mockClient).from('accounts').
|
|
140
|
+
await new Database(mockClient).from('accounts').filters('status', 'eq', '').execute()
|
|
141
141
|
const url = mockHttpClient.get.mock.calls[0][0]
|
|
142
142
|
expect(url).toContain('status=')
|
|
143
143
|
})
|
|
@@ -149,8 +149,8 @@ describe('Builder immutability', () => {
|
|
|
149
149
|
it('Database: chaining does not mutate original instance', async () => {
|
|
150
150
|
mockHttpClient.get.mockResolvedValue([])
|
|
151
151
|
const base = new Database(mockClient).from('accounts')
|
|
152
|
-
const filtered = base.
|
|
153
|
-
const sorted = base.
|
|
152
|
+
const filtered = base.filters('status', 'eq', 'active')
|
|
153
|
+
const sorted = base.orderBy('name', 'asc')
|
|
154
154
|
|
|
155
155
|
await filtered.execute()
|
|
156
156
|
const filteredUrl = mockHttpClient.get.mock.calls[0][0]
|