@aginix/vulcan-data-provider 0.1.0
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/README.md +115 -0
- package/build/index.d.ts +4 -0
- package/build/index.js +4 -0
- package/build/src/data_provider.d.ts +32 -0
- package/build/src/data_provider.js +212 -0
- package/build/src/types.d.ts +97 -0
- package/build/src/types.js +65 -0
- package/build/src/url_builder.d.ts +60 -0
- package/build/src/url_builder.js +168 -0
- package/package.json +149 -0
package/README.md
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# @aginix/vulcan-data-provider
|
|
2
|
+
|
|
3
|
+
[React Admin](https://marmelab.com/react-admin/) data provider for APIs built
|
|
4
|
+
with [`@aginix/adonis-vulcan`](../adonis-vulcan)'s `BaseResourceController`.
|
|
5
|
+
|
|
6
|
+
It speaks the same PostgREST-style filter grammar that `BaseResourceController`
|
|
7
|
+
parses on the server side (`?status=eq.active&age=gte.18`), so any controller
|
|
8
|
+
that `extends BaseResourceController` is wired up automatically.
|
|
9
|
+
|
|
10
|
+
## Install
|
|
11
|
+
|
|
12
|
+
```sh
|
|
13
|
+
pnpm add @aginix/vulcan-data-provider react-admin
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
`react-admin` (or `ra-core`) is a peer dependency.
|
|
17
|
+
|
|
18
|
+
## Usage
|
|
19
|
+
|
|
20
|
+
```tsx
|
|
21
|
+
import { Admin, Resource, fetchUtils } from 'react-admin'
|
|
22
|
+
import { vulcanDataProvider } from '@aginix/vulcan-data-provider'
|
|
23
|
+
|
|
24
|
+
import { PostList, PostEdit, PostCreate } from './posts'
|
|
25
|
+
|
|
26
|
+
const dataProvider = vulcanDataProvider({
|
|
27
|
+
apiUrl: '/api',
|
|
28
|
+
httpClient: fetchUtils.fetchJson,
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
export const App = () => (
|
|
32
|
+
<Admin dataProvider={dataProvider}>
|
|
33
|
+
<Resource name="posts" list={PostList} edit={PostEdit} create={PostCreate} />
|
|
34
|
+
</Admin>
|
|
35
|
+
)
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## How it maps to the API
|
|
39
|
+
|
|
40
|
+
| react-admin call | HTTP method + URL |
|
|
41
|
+
| ------------------- | --------------------------------------------------- |
|
|
42
|
+
| `getList` | `GET /<resource>?page=&perPage=&order=&<filters>` |
|
|
43
|
+
| `getOne` | `GET /<resource>/<id>` |
|
|
44
|
+
| `getMany` | `GET /<resource>?id=in.(...)&page=1&perPage=<n>` |
|
|
45
|
+
| `getManyReference` | `GET /<resource>?<target>=eq.<id>&...` |
|
|
46
|
+
| `create` | `POST /<resource>` |
|
|
47
|
+
| `update` | `PUT /<resource>/<id>` (only changed fields) |
|
|
48
|
+
| `updateMany` | `PUT /<resource>/<id>` looped (no batch endpoint) |
|
|
49
|
+
| `delete` | `DELETE /<resource>/<id>` |
|
|
50
|
+
| `deleteMany` | `DELETE /<resource>/<id>` looped |
|
|
51
|
+
|
|
52
|
+
Response envelope (matches `ResourceSerializer` / Lucid pagination):
|
|
53
|
+
|
|
54
|
+
```jsonc
|
|
55
|
+
// list
|
|
56
|
+
{ "data": [...], "metadata": { "total": 99, "perPage": 25, "currentPage": 1, ... } }
|
|
57
|
+
|
|
58
|
+
// single
|
|
59
|
+
{ "data": { ... } }
|
|
60
|
+
|
|
61
|
+
// destroy → 204 No Content
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## Filter grammar
|
|
65
|
+
|
|
66
|
+
The data provider reads react-admin's `filter` object and emits
|
|
67
|
+
PostgREST-flavored query strings that `@aginix/adonis-vulcan`'s `FilterParser`
|
|
68
|
+
understands.
|
|
69
|
+
|
|
70
|
+
| Filter shape | URL fragment |
|
|
71
|
+
| ------------------------------------------------- | ----------------------------------------- |
|
|
72
|
+
| `{ status: 'active' }` | `status=eq.active` |
|
|
73
|
+
| `{ 'age@gt': 18 }` | `age=gt.18` |
|
|
74
|
+
| `{ 'name@like': 'foo bar' }` | `name=like.*foo*&name=like.*bar*` |
|
|
75
|
+
| `{ tags: ['admin', 'user'] }` | `tags=in.(admin,user)` |
|
|
76
|
+
| `{ q: 'search' }` | `q=search` (no operator) |
|
|
77
|
+
| `{ faculty: { name: 'CS' } }` | `faculty.name=eq.CS` (embedded filter) |
|
|
78
|
+
| `{ 'curriculums.exists': 'true' }` | `curriculums.exists=true` (special key) |
|
|
79
|
+
|
|
80
|
+
Set `defaultListOp` on the config to change the implicit operator from `eq` to
|
|
81
|
+
something else (e.g. `'ilike'` for free-text fields).
|
|
82
|
+
|
|
83
|
+
## Compound primary keys
|
|
84
|
+
|
|
85
|
+
For resources whose primary key is more than one column, register the column
|
|
86
|
+
list when constructing the provider:
|
|
87
|
+
|
|
88
|
+
```ts
|
|
89
|
+
import { vulcanDataProvider, defaultPrimaryKeys } from '@aginix/vulcan-data-provider'
|
|
90
|
+
|
|
91
|
+
defaultPrimaryKeys.set('enrollments', ['student_id', 'course_id'])
|
|
92
|
+
|
|
93
|
+
const dataProvider = vulcanDataProvider({
|
|
94
|
+
apiUrl: '/api',
|
|
95
|
+
httpClient: fetchUtils.fetchJson,
|
|
96
|
+
primaryKeys: defaultPrimaryKeys,
|
|
97
|
+
})
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
The provider then synthesizes a virtual `id` (a JSON-encoded array of the key
|
|
101
|
+
parts) so react-admin's record-by-id flow keeps working transparently.
|
|
102
|
+
|
|
103
|
+
## Configuration
|
|
104
|
+
|
|
105
|
+
```ts
|
|
106
|
+
type VulcanDataProviderConfig = {
|
|
107
|
+
apiUrl: string
|
|
108
|
+
httpClient: (url: string, options?: any) => Promise<{ status, headers, body, json }>
|
|
109
|
+
defaultListOp?: VulcanOperator // default: 'eq'
|
|
110
|
+
sortOrder?: VulcanSortOrder // default: ASC nullsLast / DESC nullsFirst
|
|
111
|
+
primaryKeys?: Map<string, readonly string[]> // default: ['id'] for every resource
|
|
112
|
+
pageParam?: string // default: 'page'
|
|
113
|
+
perPageParam?: string // default: 'perPage'
|
|
114
|
+
}
|
|
115
|
+
```
|
package/build/index.d.ts
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export * from './src/types.js';
|
|
2
|
+
export { parseFilters, getPrimaryKey, decodeId, encodeId, removePrimaryKey, dataWithVirtualId, dataWithoutVirtualId, getQuery, getOrderBy, type ParsedFilters, } from './src/url_builder.js';
|
|
3
|
+
export { vulcanDataProvider, defaultPrimaryKeys } from './src/data_provider.js';
|
|
4
|
+
export { default } from './src/data_provider.js';
|
package/build/index.js
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import { EMBEDDED_SPECIAL_KEYS, VULCAN_OPERATORS, VulcanSortOrder } from "./src/types.js";
|
|
2
|
+
import { dataWithVirtualId, dataWithoutVirtualId, decodeId, encodeId, getOrderBy, getPrimaryKey, getQuery, parseFilters, removePrimaryKey } from "./src/url_builder.js";
|
|
3
|
+
import vulcanDataProvider, { defaultPrimaryKeys } from "./src/data_provider.js";
|
|
4
|
+
export { EMBEDDED_SPECIAL_KEYS, VULCAN_OPERATORS, VulcanSortOrder, dataWithVirtualId, dataWithoutVirtualId, decodeId, vulcanDataProvider as default, vulcanDataProvider, defaultPrimaryKeys, encodeId, getOrderBy, getPrimaryKey, getQuery, parseFilters, removePrimaryKey };
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { DataProvider } from 'ra-core';
|
|
2
|
+
import { type PrimaryKey, type VulcanDataProviderConfig } from './types.js';
|
|
3
|
+
/**
|
|
4
|
+
* Convenience: a per-resource `Map<resource, PrimaryKey>` you can pass to
|
|
5
|
+
* `vulcanDataProvider({ primaryKeys: defaultPrimaryKeys })` and then `.set()`
|
|
6
|
+
* compound-key resources on. Empty by default — `['id']` is the implicit
|
|
7
|
+
* fallback.
|
|
8
|
+
*/
|
|
9
|
+
export declare const defaultPrimaryKeys: Map<string, PrimaryKey>;
|
|
10
|
+
/**
|
|
11
|
+
* React-admin data provider for APIs built with `@aginix/adonis-vulcan`.
|
|
12
|
+
*
|
|
13
|
+
* Endpoints expected (matches `Route.resource()` + `BaseResourceController`):
|
|
14
|
+
*
|
|
15
|
+
* getList GET /<resource>?page=&perPage=&order=&<filters>
|
|
16
|
+
* getOne GET /<resource>/<id>
|
|
17
|
+
* getMany GET /<resource>?id=in.(...)&page=1&perPage=<count>
|
|
18
|
+
* getManyReference GET /<resource>?<target>=eq.<id>&...
|
|
19
|
+
* create POST /<resource>
|
|
20
|
+
* update PUT /<resource>/<id>
|
|
21
|
+
* updateMany PUT /<resource>/<id> (looped)
|
|
22
|
+
* delete DELETE /<resource>/<id>
|
|
23
|
+
* deleteMany DELETE /<resource>/<id> (looped)
|
|
24
|
+
*
|
|
25
|
+
* Response envelopes:
|
|
26
|
+
*
|
|
27
|
+
* list { data: T[], metadata: { total, perPage, currentPage, lastPage, ... } }
|
|
28
|
+
* single { data: T }
|
|
29
|
+
* destroy 204 No Content
|
|
30
|
+
*/
|
|
31
|
+
export declare const vulcanDataProvider: (config: VulcanDataProviderConfig) => DataProvider;
|
|
32
|
+
export default vulcanDataProvider;
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import { dataWithVirtualId, dataWithoutVirtualId, encodeId, getOrderBy, getPrimaryKey, getQuery, parseFilters, removePrimaryKey } from "./url_builder.js";
|
|
2
|
+
import { HttpError } from "ra-core";
|
|
3
|
+
import qs from "qs";
|
|
4
|
+
import { dequal } from "dequal";
|
|
5
|
+
//#region src/data_provider.ts
|
|
6
|
+
/**
|
|
7
|
+
* Convenience: a per-resource `Map<resource, PrimaryKey>` you can pass to
|
|
8
|
+
* `vulcanDataProvider({ primaryKeys: defaultPrimaryKeys })` and then `.set()`
|
|
9
|
+
* compound-key resources on. Empty by default — `['id']` is the implicit
|
|
10
|
+
* fallback.
|
|
11
|
+
*/
|
|
12
|
+
const defaultPrimaryKeys = /* @__PURE__ */ new Map();
|
|
13
|
+
const computeChanges = (data, previousData) => {
|
|
14
|
+
return Object.keys(data).reduce((acc, key) => {
|
|
15
|
+
if (!dequal(data[key], previousData[key])) acc[key] = data[key];
|
|
16
|
+
return acc;
|
|
17
|
+
}, {});
|
|
18
|
+
};
|
|
19
|
+
/**
|
|
20
|
+
* Map a VineJS-style 422 error body into the
|
|
21
|
+
* `{ errors: { fieldName: VineError } }` shape that
|
|
22
|
+
* react-admin's `<Form>` reads from `HttpError.body`.
|
|
23
|
+
*
|
|
24
|
+
* AdonisJS + VineJS typically produces `{ errors: [{ field, message, rule, ...}] }`.
|
|
25
|
+
* Some setups return the bare array. Both are handled.
|
|
26
|
+
*/
|
|
27
|
+
const checkIfFormValidation = (ex) => {
|
|
28
|
+
if (ex.status === 400 || ex.status === 422) {
|
|
29
|
+
const body = ex.body;
|
|
30
|
+
const list = Array.isArray(body) ? body : Array.isArray(body?.errors) ? body.errors : [];
|
|
31
|
+
if (list.length > 0) {
|
|
32
|
+
const errors = list.reduce((acc, curr) => {
|
|
33
|
+
if (curr && typeof curr.field === "string") acc[curr.field] = curr;
|
|
34
|
+
return acc;
|
|
35
|
+
}, {});
|
|
36
|
+
throw new HttpError(ex.message, ex.status, { errors });
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
throw ex;
|
|
40
|
+
};
|
|
41
|
+
const buildHeaders = (extra) => {
|
|
42
|
+
const headers = new Headers({ Accept: "application/json" });
|
|
43
|
+
if (extra) for (const [key, value] of Object.entries(extra)) headers.set(key, value);
|
|
44
|
+
return headers;
|
|
45
|
+
};
|
|
46
|
+
const unwrapList = (json) => {
|
|
47
|
+
const rows = Array.isArray(json?.data) ? json.data : [];
|
|
48
|
+
return {
|
|
49
|
+
rows,
|
|
50
|
+
total: typeof json?.metadata?.total === "number" ? json.metadata.total : typeof json?.meta?.total === "number" ? json.meta.total : rows.length
|
|
51
|
+
};
|
|
52
|
+
};
|
|
53
|
+
const unwrapItem = (json) => {
|
|
54
|
+
if (json === null || json === void 0) return json;
|
|
55
|
+
if (Object.prototype.hasOwnProperty.call(json, "data")) return json.data;
|
|
56
|
+
return json;
|
|
57
|
+
};
|
|
58
|
+
/**
|
|
59
|
+
* React-admin data provider for APIs built with `@aginix/adonis-vulcan`.
|
|
60
|
+
*
|
|
61
|
+
* Endpoints expected (matches `Route.resource()` + `BaseResourceController`):
|
|
62
|
+
*
|
|
63
|
+
* getList GET /<resource>?page=&perPage=&order=&<filters>
|
|
64
|
+
* getOne GET /<resource>/<id>
|
|
65
|
+
* getMany GET /<resource>?id=in.(...)&page=1&perPage=<count>
|
|
66
|
+
* getManyReference GET /<resource>?<target>=eq.<id>&...
|
|
67
|
+
* create POST /<resource>
|
|
68
|
+
* update PUT /<resource>/<id>
|
|
69
|
+
* updateMany PUT /<resource>/<id> (looped)
|
|
70
|
+
* delete DELETE /<resource>/<id>
|
|
71
|
+
* deleteMany DELETE /<resource>/<id> (looped)
|
|
72
|
+
*
|
|
73
|
+
* Response envelopes:
|
|
74
|
+
*
|
|
75
|
+
* list { data: T[], metadata: { total, perPage, currentPage, lastPage, ... } }
|
|
76
|
+
* single { data: T }
|
|
77
|
+
* destroy 204 No Content
|
|
78
|
+
*/
|
|
79
|
+
const vulcanDataProvider = (config) => {
|
|
80
|
+
const { apiUrl, httpClient, defaultListOp = "eq", sortOrder, primaryKeys, pageParam = "page", perPageParam = "perPage" } = config;
|
|
81
|
+
const stringify = (q) => qs.stringify(q);
|
|
82
|
+
const sendList = async (url, options, primaryKey) => {
|
|
83
|
+
const { json } = await runRequest(httpClient, url, options);
|
|
84
|
+
const { rows, total } = unwrapList(json);
|
|
85
|
+
return {
|
|
86
|
+
data: rows.map((row) => dataWithVirtualId(row, primaryKey)),
|
|
87
|
+
total
|
|
88
|
+
};
|
|
89
|
+
};
|
|
90
|
+
return {
|
|
91
|
+
getList: async (resource, params) => {
|
|
92
|
+
const primaryKey = getPrimaryKey(resource, primaryKeys);
|
|
93
|
+
const { page = 1, perPage = 25 } = params.pagination ?? {};
|
|
94
|
+
const { field, order } = params.sort ?? {};
|
|
95
|
+
const { filter } = parseFilters(params, defaultListOp);
|
|
96
|
+
const query = {
|
|
97
|
+
[pageParam]: page,
|
|
98
|
+
[perPageParam]: perPage,
|
|
99
|
+
...filter
|
|
100
|
+
};
|
|
101
|
+
if (field) query.order = getOrderBy(field, order, primaryKey, sortOrder);
|
|
102
|
+
return sendList(`${apiUrl}/${resource}?${stringify(query)}`, { headers: buildHeaders(params.meta?.headers) }, primaryKey);
|
|
103
|
+
},
|
|
104
|
+
getOne: async (resource, params) => {
|
|
105
|
+
const primaryKey = getPrimaryKey(resource, primaryKeys);
|
|
106
|
+
const { json } = await runRequest(httpClient, `${apiUrl}/${resource}/${encodeURIComponent(String(params.id))}`, { headers: buildHeaders(params.meta?.headers) });
|
|
107
|
+
return { data: dataWithVirtualId(unwrapItem(json), primaryKey) };
|
|
108
|
+
},
|
|
109
|
+
getMany: async (resource, params) => {
|
|
110
|
+
if (!params.ids || params.ids.length === 0) return { data: [] };
|
|
111
|
+
const primaryKey = getPrimaryKey(resource, primaryKeys);
|
|
112
|
+
const { data } = await sendList(`${apiUrl}/${resource}?${stringify({
|
|
113
|
+
...getQuery(primaryKey, params.ids),
|
|
114
|
+
[pageParam]: 1,
|
|
115
|
+
[perPageParam]: params.ids.length
|
|
116
|
+
})}`, { headers: buildHeaders(params.meta?.headers) }, primaryKey);
|
|
117
|
+
return { data };
|
|
118
|
+
},
|
|
119
|
+
getManyReference: async (resource, params) => {
|
|
120
|
+
const primaryKey = getPrimaryKey(resource, primaryKeys);
|
|
121
|
+
const { page = 1, perPage = 25 } = params.pagination ?? {};
|
|
122
|
+
const { field, order } = params.sort ?? {};
|
|
123
|
+
const { filter } = parseFilters(params, defaultListOp);
|
|
124
|
+
const query = {
|
|
125
|
+
...params.target ? { [params.target]: `eq.${params.id}` } : {},
|
|
126
|
+
[pageParam]: page,
|
|
127
|
+
[perPageParam]: perPage,
|
|
128
|
+
...filter
|
|
129
|
+
};
|
|
130
|
+
if (field) query.order = getOrderBy(field, order, primaryKey, sortOrder);
|
|
131
|
+
return sendList(`${apiUrl}/${resource}?${stringify(query)}`, { headers: buildHeaders(params.meta?.headers) }, primaryKey);
|
|
132
|
+
},
|
|
133
|
+
create: async (resource, params) => {
|
|
134
|
+
const primaryKey = getPrimaryKey(resource, primaryKeys);
|
|
135
|
+
const url = `${apiUrl}/${resource}`;
|
|
136
|
+
const body = JSON.stringify(dataWithoutVirtualId(params.data, primaryKey));
|
|
137
|
+
try {
|
|
138
|
+
const { json } = await runRequest(httpClient, url, {
|
|
139
|
+
method: "POST",
|
|
140
|
+
headers: buildHeaders({
|
|
141
|
+
"Content-Type": "application/json",
|
|
142
|
+
...params.meta?.headers ?? {}
|
|
143
|
+
}),
|
|
144
|
+
body
|
|
145
|
+
});
|
|
146
|
+
return { data: dataWithVirtualId(unwrapItem(json), primaryKey) };
|
|
147
|
+
} catch (ex) {
|
|
148
|
+
return checkIfFormValidation(ex);
|
|
149
|
+
}
|
|
150
|
+
},
|
|
151
|
+
update: async (resource, params) => {
|
|
152
|
+
const primaryKey = getPrimaryKey(resource, primaryKeys);
|
|
153
|
+
const url = `${apiUrl}/${resource}/${encodeURIComponent(String(params.id))}`;
|
|
154
|
+
const data = params.data;
|
|
155
|
+
const previousData = params.previousData ?? {};
|
|
156
|
+
const changed = computeChanges(data, previousData);
|
|
157
|
+
if (Object.keys(changed).length === 0) return { data: { ...previousData } };
|
|
158
|
+
const body = JSON.stringify(dataWithoutVirtualId(removePrimaryKey(changed, primaryKey), primaryKey));
|
|
159
|
+
try {
|
|
160
|
+
const { json } = await runRequest(httpClient, url, {
|
|
161
|
+
method: "PUT",
|
|
162
|
+
headers: buildHeaders({
|
|
163
|
+
"Content-Type": "application/json",
|
|
164
|
+
...params.meta?.headers ?? {}
|
|
165
|
+
}),
|
|
166
|
+
body
|
|
167
|
+
});
|
|
168
|
+
return { data: dataWithVirtualId(unwrapItem(json), primaryKey) };
|
|
169
|
+
} catch (ex) {
|
|
170
|
+
return checkIfFormValidation(ex);
|
|
171
|
+
}
|
|
172
|
+
},
|
|
173
|
+
updateMany: async (resource, params) => {
|
|
174
|
+
const primaryKey = getPrimaryKey(resource, primaryKeys);
|
|
175
|
+
const data = params.data;
|
|
176
|
+
const body = JSON.stringify(dataWithoutVirtualId(removePrimaryKey(data, primaryKey), primaryKey));
|
|
177
|
+
const headers = buildHeaders({
|
|
178
|
+
"Content-Type": "application/json",
|
|
179
|
+
...params.meta?.headers ?? {}
|
|
180
|
+
});
|
|
181
|
+
const ids = params.ids ?? [];
|
|
182
|
+
return { data: await Promise.all(ids.map((id) => runRequest(httpClient, `${apiUrl}/${resource}/${encodeURIComponent(String(id))}`, {
|
|
183
|
+
method: "PUT",
|
|
184
|
+
headers,
|
|
185
|
+
body
|
|
186
|
+
}).then(({ json }) => encodeId(unwrapItem(json), primaryKey)))) };
|
|
187
|
+
},
|
|
188
|
+
delete: async (resource, params) => {
|
|
189
|
+
const primaryKey = getPrimaryKey(resource, primaryKeys);
|
|
190
|
+
const { json } = await runRequest(httpClient, `${apiUrl}/${resource}/${encodeURIComponent(String(params.id))}`, {
|
|
191
|
+
method: "DELETE",
|
|
192
|
+
headers: buildHeaders(params.meta?.headers)
|
|
193
|
+
});
|
|
194
|
+
const fallback = params.previousData ?? { id: params.id };
|
|
195
|
+
return { data: json ? dataWithVirtualId(unwrapItem(json), primaryKey) : fallback };
|
|
196
|
+
},
|
|
197
|
+
deleteMany: async (resource, params) => {
|
|
198
|
+
const headers = buildHeaders(params.meta?.headers);
|
|
199
|
+
const ids = params.ids ?? [];
|
|
200
|
+
await Promise.all(ids.map((id) => runRequest(httpClient, `${apiUrl}/${resource}/${encodeURIComponent(String(id))}`, {
|
|
201
|
+
method: "DELETE",
|
|
202
|
+
headers
|
|
203
|
+
})));
|
|
204
|
+
return { data: ids };
|
|
205
|
+
}
|
|
206
|
+
};
|
|
207
|
+
};
|
|
208
|
+
const runRequest = async (httpClient, url, options) => {
|
|
209
|
+
return { json: (await httpClient(url, options))?.json };
|
|
210
|
+
};
|
|
211
|
+
//#endregion
|
|
212
|
+
export { vulcanDataProvider as default, vulcanDataProvider, defaultPrimaryKeys };
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The full set of PostgREST-style operators that the
|
|
3
|
+
* `@aginix/adonis-vulcan` filter pipeline understands.
|
|
4
|
+
*
|
|
5
|
+
* Mirrors the operator list in
|
|
6
|
+
* `@aginix/adonis-vulcan`'s `FilterParser` (`OPERATOR_PATTERN` / `OPERATOR_SQL_MAP`)
|
|
7
|
+
* plus the `not` / `or` / `and` modifiers that wrap conditions.
|
|
8
|
+
*/
|
|
9
|
+
export declare const VULCAN_OPERATORS: readonly ["eq", "gt", "gte", "lt", "lte", "neq", "like", "ilike", "match", "imatch", "in", "is", "fts", "plfts", "phfts", "wfts", "cs", "cd", "ov", "sl", "sr", "nxr", "nxl", "adj", "not", "or", "and"];
|
|
10
|
+
export type VulcanOperator = (typeof VULCAN_OPERATORS)[number];
|
|
11
|
+
/**
|
|
12
|
+
* Special path segments that may appear after a relation name when filtering
|
|
13
|
+
* embedded resources (e.g. `curriculums.exists=true`,
|
|
14
|
+
* `curriculums.order=name.asc`, `curriculums.limit=5`).
|
|
15
|
+
*
|
|
16
|
+
* These are passed through the URL as-is — no `eq.` / `in.` / etc. is
|
|
17
|
+
* prepended, because they are control parameters, not column filters.
|
|
18
|
+
*/
|
|
19
|
+
export declare const EMBEDDED_SPECIAL_KEYS: readonly ["exists", "order", "limit"];
|
|
20
|
+
export type EmbeddedSpecialKey = (typeof EMBEDDED_SPECIAL_KEYS)[number];
|
|
21
|
+
/**
|
|
22
|
+
* Order-by null handling. Format: `<asc-spec>,<desc-spec>` so that a single
|
|
23
|
+
* value covers both directions; the appropriate half is chosen depending on
|
|
24
|
+
* whether the user clicked an ASC or DESC sort header.
|
|
25
|
+
*/
|
|
26
|
+
export declare enum VulcanSortOrder {
|
|
27
|
+
AscendingNullsLastDescendingNullsFirst = "asc,desc",
|
|
28
|
+
AscendingNullsLastDescendingNullsLast = "asc,desc.nullslast",
|
|
29
|
+
AscendingNullsFirstDescendingNullsFirst = "asc.nullsfirst,desc",
|
|
30
|
+
AscendingNullsFirstDescendingNullsLast = "asc.nullsfirst,desc.nullslast"
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* A primary key descriptor. A single string (`['id']`) for the common case,
|
|
34
|
+
* or several strings for compound keys (`['org_id', 'sku']`). When the
|
|
35
|
+
* primary key is compound, the data provider synthesizes a virtual `id` field
|
|
36
|
+
* encoded as a JSON-stringified array of the key parts so react-admin can
|
|
37
|
+
* keep using its expected `id` lookup.
|
|
38
|
+
*/
|
|
39
|
+
export type PrimaryKey = readonly string[];
|
|
40
|
+
/**
|
|
41
|
+
* Fetch-like signature compatible with `fetchUtils.fetchJson` from
|
|
42
|
+
* react-admin. Implementations resolve with `{ status, headers, body, json }`
|
|
43
|
+
* for 2xx responses and reject with an `HttpError` otherwise.
|
|
44
|
+
*/
|
|
45
|
+
export type HttpClient = (url: string, options?: HttpClientOptions) => Promise<HttpClientResponse>;
|
|
46
|
+
export interface HttpClientOptions {
|
|
47
|
+
method?: string;
|
|
48
|
+
headers?: Headers;
|
|
49
|
+
body?: string | null;
|
|
50
|
+
user?: {
|
|
51
|
+
authenticated?: boolean;
|
|
52
|
+
token?: string;
|
|
53
|
+
};
|
|
54
|
+
[key: string]: unknown;
|
|
55
|
+
}
|
|
56
|
+
export interface HttpClientResponse {
|
|
57
|
+
status: number;
|
|
58
|
+
headers: Headers;
|
|
59
|
+
body: string;
|
|
60
|
+
json?: any;
|
|
61
|
+
}
|
|
62
|
+
export interface VulcanDataProviderConfig {
|
|
63
|
+
/**
|
|
64
|
+
* Base URL of the API. e.g. `https://api.example.com` or `/api`. The
|
|
65
|
+
* resource name is appended directly: `<apiUrl>/<resource>`.
|
|
66
|
+
*/
|
|
67
|
+
apiUrl: string;
|
|
68
|
+
/**
|
|
69
|
+
* Fetch-compatible function used for every request. Most consumers pass
|
|
70
|
+
* `fetchUtils.fetchJson` from `react-admin`, optionally wrapped with auth
|
|
71
|
+
* headers.
|
|
72
|
+
*/
|
|
73
|
+
httpClient: HttpClient;
|
|
74
|
+
/**
|
|
75
|
+
* Operator applied to filter values that don't specify one explicitly via
|
|
76
|
+
* the `field@op` syntax. Defaults to `eq`.
|
|
77
|
+
*/
|
|
78
|
+
defaultListOp?: VulcanOperator;
|
|
79
|
+
/**
|
|
80
|
+
* Null handling for ASC / DESC sorts. Defaults to
|
|
81
|
+
* `AscendingNullsLastDescendingNullsFirst` (`asc` / `desc`), which mirrors
|
|
82
|
+
* PostgreSQL's default ordering.
|
|
83
|
+
*/
|
|
84
|
+
sortOrder?: VulcanSortOrder;
|
|
85
|
+
/**
|
|
86
|
+
* Per-resource primary key overrides. Resources missing from the map fall
|
|
87
|
+
* back to `['id']`.
|
|
88
|
+
*/
|
|
89
|
+
primaryKeys?: Map<string, PrimaryKey>;
|
|
90
|
+
/**
|
|
91
|
+
* Pagination input names used by the `BaseResourceController`. Override
|
|
92
|
+
* here when a controller customizes `pagination.pageInput` /
|
|
93
|
+
* `perPageInput`. Defaults to `page` / `perPage`.
|
|
94
|
+
*/
|
|
95
|
+
pageParam?: string;
|
|
96
|
+
perPageParam?: string;
|
|
97
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
//#region src/types.ts
|
|
2
|
+
/**
|
|
3
|
+
* The full set of PostgREST-style operators that the
|
|
4
|
+
* `@aginix/adonis-vulcan` filter pipeline understands.
|
|
5
|
+
*
|
|
6
|
+
* Mirrors the operator list in
|
|
7
|
+
* `@aginix/adonis-vulcan`'s `FilterParser` (`OPERATOR_PATTERN` / `OPERATOR_SQL_MAP`)
|
|
8
|
+
* plus the `not` / `or` / `and` modifiers that wrap conditions.
|
|
9
|
+
*/
|
|
10
|
+
const VULCAN_OPERATORS = [
|
|
11
|
+
"eq",
|
|
12
|
+
"gt",
|
|
13
|
+
"gte",
|
|
14
|
+
"lt",
|
|
15
|
+
"lte",
|
|
16
|
+
"neq",
|
|
17
|
+
"like",
|
|
18
|
+
"ilike",
|
|
19
|
+
"match",
|
|
20
|
+
"imatch",
|
|
21
|
+
"in",
|
|
22
|
+
"is",
|
|
23
|
+
"fts",
|
|
24
|
+
"plfts",
|
|
25
|
+
"phfts",
|
|
26
|
+
"wfts",
|
|
27
|
+
"cs",
|
|
28
|
+
"cd",
|
|
29
|
+
"ov",
|
|
30
|
+
"sl",
|
|
31
|
+
"sr",
|
|
32
|
+
"nxr",
|
|
33
|
+
"nxl",
|
|
34
|
+
"adj",
|
|
35
|
+
"not",
|
|
36
|
+
"or",
|
|
37
|
+
"and"
|
|
38
|
+
];
|
|
39
|
+
/**
|
|
40
|
+
* Special path segments that may appear after a relation name when filtering
|
|
41
|
+
* embedded resources (e.g. `curriculums.exists=true`,
|
|
42
|
+
* `curriculums.order=name.asc`, `curriculums.limit=5`).
|
|
43
|
+
*
|
|
44
|
+
* These are passed through the URL as-is — no `eq.` / `in.` / etc. is
|
|
45
|
+
* prepended, because they are control parameters, not column filters.
|
|
46
|
+
*/
|
|
47
|
+
const EMBEDDED_SPECIAL_KEYS = [
|
|
48
|
+
"exists",
|
|
49
|
+
"order",
|
|
50
|
+
"limit"
|
|
51
|
+
];
|
|
52
|
+
/**
|
|
53
|
+
* Order-by null handling. Format: `<asc-spec>,<desc-spec>` so that a single
|
|
54
|
+
* value covers both directions; the appropriate half is chosen depending on
|
|
55
|
+
* whether the user clicked an ASC or DESC sort header.
|
|
56
|
+
*/
|
|
57
|
+
let VulcanSortOrder = /* @__PURE__ */ function(VulcanSortOrder) {
|
|
58
|
+
VulcanSortOrder["AscendingNullsLastDescendingNullsFirst"] = "asc,desc";
|
|
59
|
+
VulcanSortOrder["AscendingNullsLastDescendingNullsLast"] = "asc,desc.nullslast";
|
|
60
|
+
VulcanSortOrder["AscendingNullsFirstDescendingNullsFirst"] = "asc.nullsfirst,desc";
|
|
61
|
+
VulcanSortOrder["AscendingNullsFirstDescendingNullsLast"] = "asc.nullsfirst,desc.nullslast";
|
|
62
|
+
return VulcanSortOrder;
|
|
63
|
+
}({});
|
|
64
|
+
//#endregion
|
|
65
|
+
export { EMBEDDED_SPECIAL_KEYS, VULCAN_OPERATORS, VulcanSortOrder };
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type { Identifier, SortPayload } from 'ra-core';
|
|
2
|
+
import { type PrimaryKey, type VulcanOperator, VulcanSortOrder } from './types.js';
|
|
3
|
+
export type ParsedFilters = {
|
|
4
|
+
/**
|
|
5
|
+
* A flat record of query-string keys to either a single
|
|
6
|
+
* `<op>.<value>` string (or `(child1,child2)` for `and`/`or`) or an array of
|
|
7
|
+
* those strings when the same key needs to repeat — `qs.stringify` will
|
|
8
|
+
* expand the array into `field=foo&field=bar`, which the server-side
|
|
9
|
+
* `FilterParser` joins back into an AND.
|
|
10
|
+
*/
|
|
11
|
+
filter: Record<string, string | string[]>;
|
|
12
|
+
};
|
|
13
|
+
/**
|
|
14
|
+
* Translate a react-admin `filter` object into the PostgREST-style
|
|
15
|
+
* query-string fragment understood by `@aginix/adonis-vulcan`'s
|
|
16
|
+
* `FilterParser`.
|
|
17
|
+
*
|
|
18
|
+
* Conventions:
|
|
19
|
+
*
|
|
20
|
+
* - `field: value` becomes `field=<defaultListOp>.<value>` (default `eq`).
|
|
21
|
+
* - `field@op: value` overrides the operator (e.g. `age@gt: 18`).
|
|
22
|
+
* - Array values map to `field=in.(v1,v2,v3)`.
|
|
23
|
+
* - `like` / `ilike` split the value on whitespace and emit one
|
|
24
|
+
* `field=like.*token*` per token, which `qs.stringify` turns into repeated
|
|
25
|
+
* keys (joined with AND server-side).
|
|
26
|
+
* - `or` / `and` keys recurse and emit `or=(c1.<op>.v1,c2.<op>.v2)`.
|
|
27
|
+
* - The `q` key is a free-form search term — it is forwarded verbatim
|
|
28
|
+
* (`q=value`), so a filter subclass on the server side can take it via the
|
|
29
|
+
* "custom method takes precedence" hook of `PostgRESTModelFilter`.
|
|
30
|
+
* - Embedded special keys (e.g. `curriculums.exists`) are forwarded verbatim
|
|
31
|
+
* as well — no operator is prepended.
|
|
32
|
+
*/
|
|
33
|
+
export declare const parseFilters: (params: {
|
|
34
|
+
filter?: Record<string, any>;
|
|
35
|
+
}, defaultListOp?: VulcanOperator) => ParsedFilters;
|
|
36
|
+
export declare const getPrimaryKey: (resource: string, primaryKeys?: Map<string, PrimaryKey>) => PrimaryKey;
|
|
37
|
+
export declare const decodeId: (id: Identifier, primaryKey: PrimaryKey) => string[] | number[];
|
|
38
|
+
export declare const encodeId: (data: Record<string, unknown>, primaryKey: PrimaryKey) => Identifier;
|
|
39
|
+
export declare const removePrimaryKey: (data: Record<string, unknown>, primaryKey: PrimaryKey) => Record<string, unknown>;
|
|
40
|
+
/**
|
|
41
|
+
* For compound primary keys, attach a synthetic `id` so react-admin can rely
|
|
42
|
+
* on its standard record-by-id flow. No-op for the regular `['id']` case.
|
|
43
|
+
*/
|
|
44
|
+
export declare const dataWithVirtualId: <T extends Record<string, unknown>>(data: T, primaryKey: PrimaryKey) => T;
|
|
45
|
+
/**
|
|
46
|
+
* Strip the synthetic `id` field before sending a payload back to the server.
|
|
47
|
+
*/
|
|
48
|
+
export declare const dataWithoutVirtualId: (data: Record<string, unknown>, primaryKey: PrimaryKey) => Record<string, unknown>;
|
|
49
|
+
/**
|
|
50
|
+
* Build the filter fragment that selects records by primary key. Works for
|
|
51
|
+
* single ids, multiple ids, and compound primary keys. Used by
|
|
52
|
+
* `getMany`, `updateMany`, and `deleteMany` to address rows in bulk.
|
|
53
|
+
*/
|
|
54
|
+
export declare const getQuery: (primaryKey: PrimaryKey, ids: Identifier | readonly Identifier[] | undefined) => Record<string, string | string[]>;
|
|
55
|
+
/**
|
|
56
|
+
* Format `field` + `order` (`'ASC' | 'DESC'`) into the `order=...` value
|
|
57
|
+
* understood by `FilterParser`. When the sort field is the synthetic `id` and
|
|
58
|
+
* the resource has a compound primary key, expand it across each key part.
|
|
59
|
+
*/
|
|
60
|
+
export declare const getOrderBy: (field: string, order: SortPayload["order"] | undefined, primaryKey: PrimaryKey, sortOrder?: VulcanSortOrder) => string;
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { EMBEDDED_SPECIAL_KEYS } from "./types.js";
|
|
2
|
+
//#region src/url_builder.ts
|
|
3
|
+
const isObject = (obj) => typeof obj === "object" && !Array.isArray(obj) && obj !== null;
|
|
4
|
+
const resolveKeys = (filter, keys) => {
|
|
5
|
+
let result = filter[keys[0]];
|
|
6
|
+
for (let i = 1; i < keys.length; ++i) result = result[keys[i]];
|
|
7
|
+
return result;
|
|
8
|
+
};
|
|
9
|
+
/**
|
|
10
|
+
* `curriculums.exists` / `faculty.order` / etc. The last dotted segment must
|
|
11
|
+
* match one of the `EMBEDDED_SPECIAL_KEYS`.
|
|
12
|
+
*/
|
|
13
|
+
const isEmbeddedSpecialKey = (key) => {
|
|
14
|
+
const parts = key.split(".");
|
|
15
|
+
if (parts.length < 2) return false;
|
|
16
|
+
const last = parts[parts.length - 1].toLowerCase();
|
|
17
|
+
return EMBEDDED_SPECIAL_KEYS.includes(last);
|
|
18
|
+
};
|
|
19
|
+
const isCompoundKey = (primaryKey) => primaryKey.length > 1;
|
|
20
|
+
/**
|
|
21
|
+
* Translate a react-admin `filter` object into the PostgREST-style
|
|
22
|
+
* query-string fragment understood by `@aginix/adonis-vulcan`'s
|
|
23
|
+
* `FilterParser`.
|
|
24
|
+
*
|
|
25
|
+
* Conventions:
|
|
26
|
+
*
|
|
27
|
+
* - `field: value` becomes `field=<defaultListOp>.<value>` (default `eq`).
|
|
28
|
+
* - `field@op: value` overrides the operator (e.g. `age@gt: 18`).
|
|
29
|
+
* - Array values map to `field=in.(v1,v2,v3)`.
|
|
30
|
+
* - `like` / `ilike` split the value on whitespace and emit one
|
|
31
|
+
* `field=like.*token*` per token, which `qs.stringify` turns into repeated
|
|
32
|
+
* keys (joined with AND server-side).
|
|
33
|
+
* - `or` / `and` keys recurse and emit `or=(c1.<op>.v1,c2.<op>.v2)`.
|
|
34
|
+
* - The `q` key is a free-form search term — it is forwarded verbatim
|
|
35
|
+
* (`q=value`), so a filter subclass on the server side can take it via the
|
|
36
|
+
* "custom method takes precedence" hook of `PostgRESTModelFilter`.
|
|
37
|
+
* - Embedded special keys (e.g. `curriculums.exists`) are forwarded verbatim
|
|
38
|
+
* as well — no operator is prepended.
|
|
39
|
+
*/
|
|
40
|
+
const parseFilters = (params, defaultListOp = "eq") => {
|
|
41
|
+
const filter = params.filter ?? {};
|
|
42
|
+
const result = { filter: {} };
|
|
43
|
+
Object.keys(filter).forEach((rawKey) => {
|
|
44
|
+
let key = rawKey;
|
|
45
|
+
let keyArray = [key];
|
|
46
|
+
let values;
|
|
47
|
+
if (key.split("@")[0] !== "" && isObject(filter[key])) {
|
|
48
|
+
let innerVal = filter[key];
|
|
49
|
+
while (isObject(innerVal)) {
|
|
50
|
+
const [innerKey] = Object.keys(innerVal);
|
|
51
|
+
keyArray.push(innerKey);
|
|
52
|
+
innerVal = innerVal[innerKey];
|
|
53
|
+
}
|
|
54
|
+
key = keyArray.join(".");
|
|
55
|
+
values = [innerVal];
|
|
56
|
+
} else values = [filter[rawKey]];
|
|
57
|
+
const splitKey = key.split("@");
|
|
58
|
+
const filterKey = splitKey[0];
|
|
59
|
+
const filterValue = filter[rawKey];
|
|
60
|
+
const isArrayValue = Array.isArray(filterValue);
|
|
61
|
+
const operation = (() => {
|
|
62
|
+
if (splitKey.length === 2) return splitKey[1];
|
|
63
|
+
if (key[0] === "$") return "";
|
|
64
|
+
if (filterKey === "q") return "";
|
|
65
|
+
if (isEmbeddedSpecialKey(filterKey)) return "";
|
|
66
|
+
if (isArrayValue) return "in";
|
|
67
|
+
return defaultListOp;
|
|
68
|
+
})();
|
|
69
|
+
if (operation === "like" || operation === "ilike") {
|
|
70
|
+
const raw = resolveKeys(filter, keyArray);
|
|
71
|
+
values = String(raw).trim().split(/\s+/);
|
|
72
|
+
} else if (operation === "or" || operation === "and") {
|
|
73
|
+
const subResult = parseFilters({ filter: resolveKeys(filter, keyArray) }, defaultListOp);
|
|
74
|
+
const expressions = [];
|
|
75
|
+
Object.entries(subResult.filter).forEach(([subKey, val]) => {
|
|
76
|
+
if (Array.isArray(val)) expressions.push(...val.map((v) => [subKey, v].join(".")));
|
|
77
|
+
else expressions.push([subKey, val].join("."));
|
|
78
|
+
});
|
|
79
|
+
values = [`(${expressions.join(",")})`];
|
|
80
|
+
}
|
|
81
|
+
if (isArrayValue && operation === "in") {
|
|
82
|
+
result.filter[filterKey] = `in.(${filterValue.join(",")})`;
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
values.forEach((value) => {
|
|
86
|
+
const op = (() => {
|
|
87
|
+
if (operation.length === 0) return `${value}`;
|
|
88
|
+
if (operation.includes("like")) return `${operation}.*${value}*`;
|
|
89
|
+
if (operation === "and" || operation === "or") return `${value}`;
|
|
90
|
+
return `${operation}.${value}`;
|
|
91
|
+
})();
|
|
92
|
+
const writeKey = operation === "and" || operation === "or" ? operation : filterKey;
|
|
93
|
+
const existing = result.filter[writeKey];
|
|
94
|
+
if (existing === void 0) result.filter[writeKey] = op;
|
|
95
|
+
else if (Array.isArray(existing)) existing.push(op);
|
|
96
|
+
else result.filter[writeKey] = [existing, op];
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
return result;
|
|
100
|
+
};
|
|
101
|
+
const getPrimaryKey = (resource, primaryKeys) => {
|
|
102
|
+
return primaryKeys?.get(resource) ?? ["id"];
|
|
103
|
+
};
|
|
104
|
+
const decodeId = (id, primaryKey) => {
|
|
105
|
+
if (isCompoundKey(primaryKey)) return JSON.parse(id.toString());
|
|
106
|
+
return [id.toString()];
|
|
107
|
+
};
|
|
108
|
+
const encodeId = (data, primaryKey) => {
|
|
109
|
+
if (isCompoundKey(primaryKey)) return JSON.stringify(primaryKey.map((key) => data[key]));
|
|
110
|
+
return data[primaryKey[0]];
|
|
111
|
+
};
|
|
112
|
+
const removePrimaryKey = (data, primaryKey) => {
|
|
113
|
+
const next = { ...data };
|
|
114
|
+
primaryKey.forEach((key) => {
|
|
115
|
+
delete next[key];
|
|
116
|
+
});
|
|
117
|
+
return next;
|
|
118
|
+
};
|
|
119
|
+
/**
|
|
120
|
+
* For compound primary keys, attach a synthetic `id` so react-admin can rely
|
|
121
|
+
* on its standard record-by-id flow. No-op for the regular `['id']` case.
|
|
122
|
+
*/
|
|
123
|
+
const dataWithVirtualId = (data, primaryKey) => {
|
|
124
|
+
if (primaryKey.length === 1 && primaryKey[0] === "id") return data;
|
|
125
|
+
return Object.assign(data, { id: encodeId(data, primaryKey) });
|
|
126
|
+
};
|
|
127
|
+
/**
|
|
128
|
+
* Strip the synthetic `id` field before sending a payload back to the server.
|
|
129
|
+
*/
|
|
130
|
+
const dataWithoutVirtualId = (data, primaryKey) => {
|
|
131
|
+
if (primaryKey.length === 1 && primaryKey[0] === "id") return data;
|
|
132
|
+
const next = { ...data };
|
|
133
|
+
delete next.id;
|
|
134
|
+
return next;
|
|
135
|
+
};
|
|
136
|
+
/**
|
|
137
|
+
* Build the filter fragment that selects records by primary key. Works for
|
|
138
|
+
* single ids, multiple ids, and compound primary keys. Used by
|
|
139
|
+
* `getMany`, `updateMany`, and `deleteMany` to address rows in bulk.
|
|
140
|
+
*/
|
|
141
|
+
const getQuery = (primaryKey, ids) => {
|
|
142
|
+
if (Array.isArray(ids) && ids.length > 1) {
|
|
143
|
+
if (isCompoundKey(primaryKey)) return { or: `(${ids.map((id) => {
|
|
144
|
+
const parts = decodeId(id, primaryKey);
|
|
145
|
+
return `and(${primaryKey.map((key, i) => `${key}.eq.${parts[i]}`).join(",")})`;
|
|
146
|
+
}).join(",")})` };
|
|
147
|
+
return { [primaryKey[0]]: `in.(${ids.join(",")})` };
|
|
148
|
+
}
|
|
149
|
+
if (ids !== void 0 && ids !== null) {
|
|
150
|
+
const id = (Array.isArray(ids) ? ids[0] : ids).toString();
|
|
151
|
+
const parts = decodeId(id, primaryKey);
|
|
152
|
+
if (isCompoundKey(primaryKey)) return { and: `(${primaryKey.map((key, i) => `${key}.eq.${parts[i]}`).join(",")})` };
|
|
153
|
+
return { [primaryKey[0]]: `eq.${id}` };
|
|
154
|
+
}
|
|
155
|
+
return {};
|
|
156
|
+
};
|
|
157
|
+
/**
|
|
158
|
+
* Format `field` + `order` (`'ASC' | 'DESC'`) into the `order=...` value
|
|
159
|
+
* understood by `FilterParser`. When the sort field is the synthetic `id` and
|
|
160
|
+
* the resource has a compound primary key, expand it across each key part.
|
|
161
|
+
*/
|
|
162
|
+
const getOrderBy = (field, order, primaryKey, sortOrder = "asc,desc") => {
|
|
163
|
+
const direction = sortOrder.split(",")[order === "ASC" ? 0 : 1];
|
|
164
|
+
if (field === "id") return primaryKey.map((key) => `${key}.${direction}`).join(",");
|
|
165
|
+
return `${field}.${direction}`;
|
|
166
|
+
};
|
|
167
|
+
//#endregion
|
|
168
|
+
export { dataWithVirtualId, dataWithoutVirtualId, decodeId, encodeId, getOrderBy, getPrimaryKey, getQuery, parseFilters, removePrimaryKey };
|
package/package.json
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@aginix/vulcan-data-provider",
|
|
3
|
+
"description": "React Admin data provider for APIs built with @aginix/adonis-vulcan",
|
|
4
|
+
"version": "0.1.0",
|
|
5
|
+
"engines": {
|
|
6
|
+
"node": ">=24.0.0"
|
|
7
|
+
},
|
|
8
|
+
"type": "module",
|
|
9
|
+
"files": [
|
|
10
|
+
"build",
|
|
11
|
+
"!build/bin",
|
|
12
|
+
"!build/tests"
|
|
13
|
+
],
|
|
14
|
+
"main": "build/index.js",
|
|
15
|
+
"types": "build/index.d.ts",
|
|
16
|
+
"exports": {
|
|
17
|
+
".": {
|
|
18
|
+
"types": "./build/index.d.ts",
|
|
19
|
+
"default": "./build/index.js"
|
|
20
|
+
},
|
|
21
|
+
"./url_builder": {
|
|
22
|
+
"types": "./build/src/url_builder.d.ts",
|
|
23
|
+
"default": "./build/src/url_builder.js"
|
|
24
|
+
},
|
|
25
|
+
"./types": {
|
|
26
|
+
"types": "./build/src/types.d.ts",
|
|
27
|
+
"default": "./build/src/types.js"
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
"scripts": {
|
|
31
|
+
"typecheck": "tsc --noEmit",
|
|
32
|
+
"lint": "eslint .",
|
|
33
|
+
"format": "prettier --write .",
|
|
34
|
+
"quick:test": "node --import=@poppinss/ts-exec --enable-source-maps bin/test.ts",
|
|
35
|
+
"pretest": "npm run lint",
|
|
36
|
+
"test": "c8 npm run quick:test",
|
|
37
|
+
"precompile": "npm run lint",
|
|
38
|
+
"compile": "tsdown && tsc --emitDeclarationOnly --declaration",
|
|
39
|
+
"build": "npm run compile",
|
|
40
|
+
"release": "release-it",
|
|
41
|
+
"version": "npm run build",
|
|
42
|
+
"prepublishOnly": "npm run build"
|
|
43
|
+
},
|
|
44
|
+
"keywords": [
|
|
45
|
+
"react-admin",
|
|
46
|
+
"data-provider",
|
|
47
|
+
"adonisjs",
|
|
48
|
+
"adonis",
|
|
49
|
+
"vulcan",
|
|
50
|
+
"postgrest",
|
|
51
|
+
"rest",
|
|
52
|
+
"api"
|
|
53
|
+
],
|
|
54
|
+
"author": "n3n",
|
|
55
|
+
"license": "UNLICENSED",
|
|
56
|
+
"dependencies": {
|
|
57
|
+
"dequal": "^2.0.3",
|
|
58
|
+
"qs": "^6.14.0"
|
|
59
|
+
},
|
|
60
|
+
"devDependencies": {
|
|
61
|
+
"@adonisjs/eslint-config": "^3.0.0",
|
|
62
|
+
"@adonisjs/prettier-config": "^1.4.5",
|
|
63
|
+
"@adonisjs/tsconfig": "^2.0.0",
|
|
64
|
+
"@japa/assert": "^4.2.0",
|
|
65
|
+
"@japa/runner": "^5.3.0",
|
|
66
|
+
"@poppinss/ts-exec": "^1.4.4",
|
|
67
|
+
"@release-it/conventional-changelog": "^10.0.5",
|
|
68
|
+
"@types/node": "^25.3.5",
|
|
69
|
+
"@types/qs": "^6.9.18",
|
|
70
|
+
"c8": "^11.0.0",
|
|
71
|
+
"eslint": "^10.0.3",
|
|
72
|
+
"prettier": "^3.8.1",
|
|
73
|
+
"ra-core": "^5.0.0",
|
|
74
|
+
"react-admin": "^5.0.0",
|
|
75
|
+
"release-it": "^19.2.4",
|
|
76
|
+
"tsdown": "^0.21.0",
|
|
77
|
+
"typescript": "^5.9.3"
|
|
78
|
+
},
|
|
79
|
+
"peerDependencies": {
|
|
80
|
+
"ra-core": "^5.0.0",
|
|
81
|
+
"react-admin": "^5.0.0"
|
|
82
|
+
},
|
|
83
|
+
"peerDependenciesMeta": {
|
|
84
|
+
"react-admin": {
|
|
85
|
+
"optional": true
|
|
86
|
+
}
|
|
87
|
+
},
|
|
88
|
+
"publishConfig": {
|
|
89
|
+
"access": "public"
|
|
90
|
+
},
|
|
91
|
+
"tsdown": {
|
|
92
|
+
"entry": [
|
|
93
|
+
"./index.ts",
|
|
94
|
+
"./src/types.ts",
|
|
95
|
+
"./src/url_builder.ts",
|
|
96
|
+
"./src/data_provider.ts"
|
|
97
|
+
],
|
|
98
|
+
"outDir": "./build",
|
|
99
|
+
"clean": true,
|
|
100
|
+
"format": "esm",
|
|
101
|
+
"minify": "dce-only",
|
|
102
|
+
"fixedExtension": false,
|
|
103
|
+
"dts": false,
|
|
104
|
+
"treeshake": false,
|
|
105
|
+
"sourcemaps": false,
|
|
106
|
+
"target": "esnext",
|
|
107
|
+
"external": [
|
|
108
|
+
"ra-core",
|
|
109
|
+
"react-admin",
|
|
110
|
+
"qs",
|
|
111
|
+
"dequal"
|
|
112
|
+
]
|
|
113
|
+
},
|
|
114
|
+
"release-it": {
|
|
115
|
+
"git": {
|
|
116
|
+
"requireCleanWorkingDir": true,
|
|
117
|
+
"requireUpstream": true,
|
|
118
|
+
"commitMessage": "chore(vulcan-data-provider): release ${version}",
|
|
119
|
+
"tagAnnotation": "vulcan-data-provider-v${version}",
|
|
120
|
+
"push": true,
|
|
121
|
+
"tagName": "vulcan-data-provider-v${version}"
|
|
122
|
+
},
|
|
123
|
+
"github": {
|
|
124
|
+
"release": true,
|
|
125
|
+
"releaseName": "@aginix/vulcan-data-provider ${version}"
|
|
126
|
+
},
|
|
127
|
+
"npm": {
|
|
128
|
+
"publish": true,
|
|
129
|
+
"skipChecks": true
|
|
130
|
+
},
|
|
131
|
+
"plugins": {
|
|
132
|
+
"@release-it/conventional-changelog": {
|
|
133
|
+
"preset": {
|
|
134
|
+
"name": "angular"
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
},
|
|
139
|
+
"c8": {
|
|
140
|
+
"reporter": [
|
|
141
|
+
"text",
|
|
142
|
+
"html"
|
|
143
|
+
],
|
|
144
|
+
"exclude": [
|
|
145
|
+
"tests/**"
|
|
146
|
+
]
|
|
147
|
+
},
|
|
148
|
+
"prettier": "@adonisjs/prettier-config"
|
|
149
|
+
}
|