@jskit-ai/crud-core 0.1.31 → 0.1.33
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/package.descriptor.mjs +2 -2
- package/package.json +7 -6
- package/src/server/createCrudServiceFromResource.js +14 -60
- package/src/server/listQueryValidators.js +57 -9
- package/src/server/repositoryMethods.js +333 -9
- package/src/server/repositorySupport.js +30 -4
- package/src/server/serviceMethods.js +140 -0
- package/test/createCrudRepositoryFromResource.test.js +306 -0
- package/test/createCrudServiceFromResource.test.js +92 -0
- package/test/listQueryValidators.test.js +60 -0
- package/test/repositorySupport.test.js +59 -0
- package/test/serviceMethods.test.js +161 -0
package/package.descriptor.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
export default Object.freeze({
|
|
2
2
|
packageVersion: 1,
|
|
3
3
|
packageId: "@jskit-ai/crud-core",
|
|
4
|
-
version: "0.1.
|
|
4
|
+
version: "0.1.33",
|
|
5
5
|
kind: "runtime",
|
|
6
6
|
description: "Shared CRUD helpers used by CRUD modules.",
|
|
7
7
|
dependsOn: [
|
|
@@ -26,7 +26,7 @@ export default Object.freeze({
|
|
|
26
26
|
mutations: {
|
|
27
27
|
dependencies: {
|
|
28
28
|
runtime: {
|
|
29
|
-
"@jskit-ai/crud-core": "0.1.
|
|
29
|
+
"@jskit-ai/crud-core": "0.1.33"
|
|
30
30
|
},
|
|
31
31
|
dev: {}
|
|
32
32
|
},
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jskit-ai/crud-core",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.33",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"scripts": {
|
|
6
6
|
"test": "node --test"
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
"./server/createCrudRepositoryFromResource": "./src/server/createCrudRepositoryFromResource.js",
|
|
19
19
|
"./server/lookupProviders": "./src/server/lookupProviders.js",
|
|
20
20
|
"./server/serviceEvents": "./src/server/serviceEvents.js",
|
|
21
|
+
"./server/serviceMethods": "./src/server/serviceMethods.js",
|
|
21
22
|
"./server/fieldAccess": "./src/server/fieldAccess.js",
|
|
22
23
|
"./server/createCrudServiceFromResource": "./src/server/createCrudServiceFromResource.js",
|
|
23
24
|
"./server/crudModuleConfig": "./src/server/crudModuleConfig.js",
|
|
@@ -25,11 +26,11 @@
|
|
|
25
26
|
},
|
|
26
27
|
"dependencies": {
|
|
27
28
|
"@tanstack/vue-query": "^5.90.5",
|
|
28
|
-
"@jskit-ai/kernel": "0.1.
|
|
29
|
-
"@jskit-ai/realtime": "0.1.
|
|
30
|
-
"@jskit-ai/shell-web": "0.1.
|
|
31
|
-
"@jskit-ai/users-core": "0.1.
|
|
32
|
-
"@jskit-ai/users-web": "0.1.
|
|
29
|
+
"@jskit-ai/kernel": "0.1.25",
|
|
30
|
+
"@jskit-ai/realtime": "0.1.24",
|
|
31
|
+
"@jskit-ai/shell-web": "0.1.24",
|
|
32
|
+
"@jskit-ai/users-core": "0.1.35",
|
|
33
|
+
"@jskit-ai/users-web": "0.1.40",
|
|
33
34
|
"typebox": "^1.0.81"
|
|
34
35
|
}
|
|
35
36
|
}
|
|
@@ -1,12 +1,16 @@
|
|
|
1
|
-
import { AppError } from "@jskit-ai/kernel/server/runtime/errors";
|
|
2
|
-
import { requireCrudNamespace } from "../shared/crudNamespaceSupport.js";
|
|
3
|
-
import { createCrudFieldAccessRuntime } from "./fieldAccess.js";
|
|
4
1
|
import { createCrudServiceEvents } from "./serviceEvents.js";
|
|
2
|
+
import {
|
|
3
|
+
createCrudServiceRuntime,
|
|
4
|
+
crudServiceListRecords,
|
|
5
|
+
crudServiceGetRecord,
|
|
6
|
+
crudServiceCreateRecord,
|
|
7
|
+
crudServiceUpdateRecord,
|
|
8
|
+
crudServiceDeleteRecord
|
|
9
|
+
} from "./serviceMethods.js";
|
|
5
10
|
|
|
6
11
|
function createCrudServiceFromResource(resource = {}, { context = "crudService" } = {}) {
|
|
7
|
-
const
|
|
12
|
+
const runtime = createCrudServiceRuntime(resource, { context });
|
|
8
13
|
const baseServiceEvents = createCrudServiceEvents(resource, { context });
|
|
9
|
-
const fieldAccessRuntime = createCrudFieldAccessRuntime(resource, { context });
|
|
10
14
|
|
|
11
15
|
function createBaseService({ repository, fieldAccess = {} } = {}) {
|
|
12
16
|
if (!repository) {
|
|
@@ -14,73 +18,23 @@ function createCrudServiceFromResource(resource = {}, { context = "crudService"
|
|
|
14
18
|
}
|
|
15
19
|
|
|
16
20
|
async function listRecords(query = {}, options = {}) {
|
|
17
|
-
|
|
18
|
-
return fieldAccessRuntime.filterReadableListResult(result, fieldAccess, {
|
|
19
|
-
action: "list",
|
|
20
|
-
query,
|
|
21
|
-
options,
|
|
22
|
-
context: options?.context
|
|
23
|
-
});
|
|
21
|
+
return crudServiceListRecords(runtime, repository, fieldAccess, query, options);
|
|
24
22
|
}
|
|
25
23
|
|
|
26
24
|
async function getRecord(recordId, options = {}) {
|
|
27
|
-
|
|
28
|
-
if (!record) {
|
|
29
|
-
throw new AppError(404, "Record not found.");
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
return fieldAccessRuntime.filterReadableRecord(record, fieldAccess, {
|
|
33
|
-
action: "view",
|
|
34
|
-
recordId,
|
|
35
|
-
options,
|
|
36
|
-
context: options?.context
|
|
37
|
-
});
|
|
25
|
+
return crudServiceGetRecord(runtime, repository, fieldAccess, recordId, options);
|
|
38
26
|
}
|
|
39
27
|
|
|
40
28
|
async function createRecord(payload = {}, options = {}) {
|
|
41
|
-
|
|
42
|
-
action: "create",
|
|
43
|
-
payload,
|
|
44
|
-
options,
|
|
45
|
-
context: options?.context
|
|
46
|
-
});
|
|
47
|
-
const record = await repository.create(writablePayload, options);
|
|
48
|
-
if (!record) {
|
|
49
|
-
throw new Error(`${namespace}Service could not load the created record.`);
|
|
50
|
-
}
|
|
51
|
-
return fieldAccessRuntime.filterReadableRecord(record, fieldAccess, {
|
|
52
|
-
action: "create",
|
|
53
|
-
options,
|
|
54
|
-
context: options?.context
|
|
55
|
-
});
|
|
29
|
+
return crudServiceCreateRecord(runtime, repository, fieldAccess, payload, options);
|
|
56
30
|
}
|
|
57
31
|
|
|
58
32
|
async function updateRecord(recordId, payload = {}, options = {}) {
|
|
59
|
-
|
|
60
|
-
action: "update",
|
|
61
|
-
recordId,
|
|
62
|
-
payload,
|
|
63
|
-
options,
|
|
64
|
-
context: options?.context
|
|
65
|
-
});
|
|
66
|
-
const record = await repository.updateById(recordId, writablePayload, options);
|
|
67
|
-
if (!record) {
|
|
68
|
-
throw new AppError(404, "Record not found.");
|
|
69
|
-
}
|
|
70
|
-
return fieldAccessRuntime.filterReadableRecord(record, fieldAccess, {
|
|
71
|
-
action: "update",
|
|
72
|
-
recordId,
|
|
73
|
-
options,
|
|
74
|
-
context: options?.context
|
|
75
|
-
});
|
|
33
|
+
return crudServiceUpdateRecord(runtime, repository, fieldAccess, recordId, payload, options);
|
|
76
34
|
}
|
|
77
35
|
|
|
78
36
|
async function deleteRecord(recordId, options = {}) {
|
|
79
|
-
|
|
80
|
-
if (!deleted) {
|
|
81
|
-
throw new AppError(404, "Record not found.");
|
|
82
|
-
}
|
|
83
|
-
return deleted;
|
|
37
|
+
return crudServiceDeleteRecord(runtime, repository, fieldAccess, recordId, options);
|
|
84
38
|
}
|
|
85
39
|
|
|
86
40
|
return Object.freeze({
|
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
import { Type } from "typebox";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
normalizeObjectInput,
|
|
4
|
+
positiveIntegerValidator,
|
|
5
|
+
cursorPaginationQueryValidator
|
|
6
|
+
} from "@jskit-ai/kernel/shared/validators";
|
|
3
7
|
import { normalizeText } from "@jskit-ai/kernel/shared/support/normalize";
|
|
4
|
-
import {
|
|
8
|
+
import { resolveCrudParentFilterKeys as resolveSharedCrudParentFilterKeys } from "@jskit-ai/kernel/shared/support/crudLookup";
|
|
5
9
|
|
|
6
10
|
const listSearchQueryValidator = Object.freeze({
|
|
7
11
|
schema: Type.Object(
|
|
@@ -41,16 +45,59 @@ const lookupIncludeQueryValidator = Object.freeze({
|
|
|
41
45
|
}
|
|
42
46
|
});
|
|
43
47
|
|
|
44
|
-
function
|
|
45
|
-
const
|
|
46
|
-
const
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
48
|
+
function resolveCrudListUsesOrderedCursor(list = {}) {
|
|
49
|
+
const orderBy = Array.isArray(list?.orderBy) ? list.orderBy : [];
|
|
50
|
+
for (const entry of orderBy) {
|
|
51
|
+
if (typeof entry === "string" && normalizeText(entry)) {
|
|
52
|
+
return true;
|
|
53
|
+
}
|
|
54
|
+
if (entry && typeof entry === "object" && !Array.isArray(entry) && normalizeText(entry.column)) {
|
|
55
|
+
return true;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function createCrudCursorPaginationQueryValidator(list = {}) {
|
|
63
|
+
if (resolveCrudListUsesOrderedCursor(list) !== true) {
|
|
64
|
+
return cursorPaginationQueryValidator;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return Object.freeze({
|
|
68
|
+
schema: Type.Object(
|
|
69
|
+
{
|
|
70
|
+
cursor: Type.Optional(
|
|
71
|
+
Type.Union([
|
|
72
|
+
positiveIntegerValidator.schema,
|
|
73
|
+
Type.String({ minLength: 1 })
|
|
74
|
+
])
|
|
75
|
+
),
|
|
76
|
+
limit: Type.Optional(positiveIntegerValidator.schema)
|
|
77
|
+
},
|
|
78
|
+
{ additionalProperties: false }
|
|
79
|
+
),
|
|
80
|
+
normalize(payload = {}) {
|
|
81
|
+
const source = normalizeObjectInput(payload);
|
|
82
|
+
const normalized = {};
|
|
83
|
+
|
|
84
|
+
if (Object.hasOwn(source, "cursor")) {
|
|
85
|
+
normalized.cursor = normalizeText(source.cursor);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (Object.hasOwn(source, "limit")) {
|
|
89
|
+
normalized.limit = positiveIntegerValidator.normalize(source.limit);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return normalized;
|
|
93
|
+
}
|
|
51
94
|
});
|
|
52
95
|
}
|
|
53
96
|
|
|
97
|
+
function resolveCrudParentFilterKeys(resource = {}) {
|
|
98
|
+
return resolveSharedCrudParentFilterKeys(resource);
|
|
99
|
+
}
|
|
100
|
+
|
|
54
101
|
function createCrudParentFilterQueryValidator(resource = {}) {
|
|
55
102
|
const keys = resolveCrudParentFilterKeys(resource);
|
|
56
103
|
const schemaProperties = {};
|
|
@@ -80,6 +127,7 @@ function createCrudParentFilterQueryValidator(resource = {}) {
|
|
|
80
127
|
}
|
|
81
128
|
|
|
82
129
|
export {
|
|
130
|
+
createCrudCursorPaginationQueryValidator,
|
|
83
131
|
listSearchQueryValidator,
|
|
84
132
|
lookupIncludeQueryValidator,
|
|
85
133
|
resolveCrudParentFilterKeys,
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { toInsertDateTime } from "@jskit-ai/database-runtime/shared";
|
|
2
2
|
import { applyVisibility, applyVisibilityOwners } from "@jskit-ai/database-runtime/shared/visibility";
|
|
3
|
+
import { AppError } from "@jskit-ai/kernel/server/runtime/errors";
|
|
3
4
|
import { normalizeText, normalizeUniqueTextList } from "@jskit-ai/kernel/shared/support/normalize";
|
|
4
5
|
import { Check, Errors } from "typebox/value";
|
|
5
6
|
import {
|
|
@@ -20,6 +21,14 @@ import {
|
|
|
20
21
|
hydrateCrudLookupRecords
|
|
21
22
|
} from "./lookupHydration.js";
|
|
22
23
|
|
|
24
|
+
const LIST_ORDER_DIRECTION_ASC = "asc";
|
|
25
|
+
const LIST_ORDER_DIRECTION_DESC = "desc";
|
|
26
|
+
const LIST_ORDER_NULLS_FIRST = "first";
|
|
27
|
+
const LIST_ORDER_NULLS_LAST = "last";
|
|
28
|
+
const ORDERED_LIST_CURSOR_VALUE_TYPE_KEY = "__jskitCursorValueType";
|
|
29
|
+
const ORDERED_LIST_CURSOR_VALUE_KEY = "value";
|
|
30
|
+
const ORDERED_LIST_CURSOR_VALUE_TYPE_DATE = "date";
|
|
31
|
+
|
|
23
32
|
function resolveRepositoryDefaults(resource = {}, repositoryMapping = {}) {
|
|
24
33
|
const resourceName = normalizeText(resource.resource);
|
|
25
34
|
const tableName = normalizeText(resource.tableName) || resourceName;
|
|
@@ -59,7 +68,80 @@ function normalizeSearchColumns(searchColumns = [], fallbackColumns = []) {
|
|
|
59
68
|
);
|
|
60
69
|
}
|
|
61
70
|
|
|
62
|
-
function
|
|
71
|
+
function normalizeListOrderDirection(value = LIST_ORDER_DIRECTION_ASC) {
|
|
72
|
+
const normalized = normalizeText(value).toLowerCase();
|
|
73
|
+
if (!normalized) {
|
|
74
|
+
return LIST_ORDER_DIRECTION_ASC;
|
|
75
|
+
}
|
|
76
|
+
if (normalized === LIST_ORDER_DIRECTION_ASC || normalized === LIST_ORDER_DIRECTION_DESC) {
|
|
77
|
+
return normalized;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
throw new TypeError(`crudRepository list.orderBy direction must be "${LIST_ORDER_DIRECTION_ASC}" or "${LIST_ORDER_DIRECTION_DESC}".`);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function normalizeListOrderNulls(value = LIST_ORDER_NULLS_LAST) {
|
|
84
|
+
const normalized = normalizeText(value).toLowerCase();
|
|
85
|
+
if (!normalized) {
|
|
86
|
+
return LIST_ORDER_NULLS_LAST;
|
|
87
|
+
}
|
|
88
|
+
if (normalized === LIST_ORDER_NULLS_FIRST || normalized === LIST_ORDER_NULLS_LAST) {
|
|
89
|
+
return normalized;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
throw new TypeError(`crudRepository list.orderBy nulls must be "${LIST_ORDER_NULLS_FIRST}" or "${LIST_ORDER_NULLS_LAST}".`);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function normalizeListOrderBy(orderBy = [], { idColumn = "id" } = {}) {
|
|
96
|
+
const sourceEntries = Array.isArray(orderBy)
|
|
97
|
+
? orderBy
|
|
98
|
+
: orderBy === null || orderBy === undefined
|
|
99
|
+
? []
|
|
100
|
+
: [orderBy];
|
|
101
|
+
const normalizedIdColumn = normalizeText(idColumn) || "id";
|
|
102
|
+
const normalizedOrderBy = [];
|
|
103
|
+
const seenColumns = new Set();
|
|
104
|
+
|
|
105
|
+
for (const rawEntry of sourceEntries) {
|
|
106
|
+
const sourceEntry = typeof rawEntry === "string"
|
|
107
|
+
? { column: rawEntry }
|
|
108
|
+
: rawEntry;
|
|
109
|
+
if (!sourceEntry || typeof sourceEntry !== "object" || Array.isArray(sourceEntry)) {
|
|
110
|
+
throw new TypeError("crudRepository list.orderBy entries must be objects or column strings.");
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const column = normalizeText(sourceEntry.column);
|
|
114
|
+
if (!column) {
|
|
115
|
+
throw new TypeError("crudRepository list.orderBy entries require column.");
|
|
116
|
+
}
|
|
117
|
+
if (seenColumns.has(column)) {
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
seenColumns.add(column);
|
|
122
|
+
normalizedOrderBy.push(
|
|
123
|
+
Object.freeze({
|
|
124
|
+
column,
|
|
125
|
+
direction: normalizeListOrderDirection(sourceEntry.direction),
|
|
126
|
+
nulls: normalizeListOrderNulls(sourceEntry.nulls)
|
|
127
|
+
})
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (normalizedOrderBy.length > 0 && !seenColumns.has(normalizedIdColumn)) {
|
|
132
|
+
normalizedOrderBy.push(
|
|
133
|
+
Object.freeze({
|
|
134
|
+
column: normalizedIdColumn,
|
|
135
|
+
direction: normalizedOrderBy[normalizedOrderBy.length - 1].direction,
|
|
136
|
+
nulls: LIST_ORDER_NULLS_LAST
|
|
137
|
+
})
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return Object.freeze(normalizedOrderBy);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function resolveListRuntimeConfig(list = {}, fallbackSearchColumns = [], { idColumn = "id" } = {}) {
|
|
63
145
|
const parsedMaxLimit = Number(list?.maxLimit);
|
|
64
146
|
const normalizedMaxLimit = Number.isInteger(parsedMaxLimit) && parsedMaxLimit > 0
|
|
65
147
|
? parsedMaxLimit
|
|
@@ -72,7 +154,176 @@ function resolveListRuntimeConfig(list = {}, fallbackSearchColumns = []) {
|
|
|
72
154
|
return Object.freeze({
|
|
73
155
|
defaultLimit: normalizedDefaultLimit,
|
|
74
156
|
maxLimit: normalizedMaxLimit,
|
|
75
|
-
searchColumns: normalizeSearchColumns(list?.searchColumns, fallbackSearchColumns)
|
|
157
|
+
searchColumns: normalizeSearchColumns(list?.searchColumns, fallbackSearchColumns),
|
|
158
|
+
orderBy: normalizeListOrderBy(list?.orderBy, { idColumn })
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function encodeOrderedListCursorValue(value = null) {
|
|
163
|
+
if (value instanceof Date) {
|
|
164
|
+
return {
|
|
165
|
+
[ORDERED_LIST_CURSOR_VALUE_TYPE_KEY]: ORDERED_LIST_CURSOR_VALUE_TYPE_DATE,
|
|
166
|
+
[ORDERED_LIST_CURSOR_VALUE_KEY]: value.toISOString()
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return value === undefined ? null : value;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function decodeOrderedListCursorValue(value = null) {
|
|
174
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
175
|
+
return value === undefined ? null : value;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const valueType = normalizeText(value[ORDERED_LIST_CURSOR_VALUE_TYPE_KEY]).toLowerCase();
|
|
179
|
+
if (!valueType) {
|
|
180
|
+
return value;
|
|
181
|
+
}
|
|
182
|
+
if (valueType !== ORDERED_LIST_CURSOR_VALUE_TYPE_DATE) {
|
|
183
|
+
return value;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const normalizedValue = normalizeText(value[ORDERED_LIST_CURSOR_VALUE_KEY]);
|
|
187
|
+
if (!normalizedValue) {
|
|
188
|
+
throw new TypeError("Ordered list cursor date values require a non-empty value.");
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const date = new Date(normalizedValue);
|
|
192
|
+
if (Number.isNaN(date.getTime())) {
|
|
193
|
+
throw new TypeError("Ordered list cursor date values must be valid dates.");
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
return date;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function encodeOrderedListCursor(row = null, orderBy = []) {
|
|
200
|
+
if (!row || typeof row !== "object" || Array.isArray(row)) {
|
|
201
|
+
return null;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const normalizedOrderBy = Array.isArray(orderBy) ? orderBy : [];
|
|
205
|
+
if (normalizedOrderBy.length < 1) {
|
|
206
|
+
return null;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const values = normalizedOrderBy.map(({ column }) => (
|
|
210
|
+
Object.hasOwn(row, column) && row[column] !== undefined
|
|
211
|
+
? encodeOrderedListCursorValue(row[column])
|
|
212
|
+
: null
|
|
213
|
+
));
|
|
214
|
+
|
|
215
|
+
return Buffer.from(JSON.stringify({ values }), "utf8").toString("base64url");
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function decodeOrderedListCursor(cursor = "", orderBy = []) {
|
|
219
|
+
const normalizedCursor = normalizeText(cursor);
|
|
220
|
+
const normalizedOrderBy = Array.isArray(orderBy) ? orderBy : [];
|
|
221
|
+
if (!normalizedCursor || normalizedOrderBy.length < 1) {
|
|
222
|
+
return null;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
try {
|
|
226
|
+
const decoded = Buffer.from(normalizedCursor, "base64url").toString("utf8");
|
|
227
|
+
const payload = JSON.parse(decoded);
|
|
228
|
+
const values = Array.isArray(payload?.values) ? payload.values : null;
|
|
229
|
+
if (!values || values.length !== normalizedOrderBy.length) {
|
|
230
|
+
throw new AppError(400, "Invalid cursor.", {
|
|
231
|
+
code: "INVALID_CURSOR"
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
return values.map((value) => decodeOrderedListCursorValue(value));
|
|
236
|
+
} catch (error) {
|
|
237
|
+
if (error instanceof AppError) {
|
|
238
|
+
throw error;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
throw new AppError(400, "Invalid cursor.", {
|
|
242
|
+
code: "INVALID_CURSOR"
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function applyOrderedListCursorEquality(query, descriptor = {}, value = null) {
|
|
248
|
+
if (value === null) {
|
|
249
|
+
query.whereNull(descriptor.column);
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
query.where(descriptor.column, value);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function applyOrderedListCursorAfterBranch(query, descriptor = {}, value = null) {
|
|
257
|
+
const operator = descriptor.direction === LIST_ORDER_DIRECTION_DESC ? "<" : ">";
|
|
258
|
+
|
|
259
|
+
if (value === null) {
|
|
260
|
+
if (descriptor.nulls === LIST_ORDER_NULLS_FIRST) {
|
|
261
|
+
query.whereNotNull(descriptor.column);
|
|
262
|
+
return true;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
return false;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
if (descriptor.nulls === LIST_ORDER_NULLS_LAST) {
|
|
269
|
+
query.where((branchQuery) => {
|
|
270
|
+
branchQuery.where(descriptor.column, operator, value);
|
|
271
|
+
branchQuery.orWhereNull(descriptor.column);
|
|
272
|
+
});
|
|
273
|
+
return true;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
query.where(descriptor.column, operator, value);
|
|
277
|
+
return true;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function canApplyOrderedListCursorAfterBranch(descriptor = {}, value = null) {
|
|
281
|
+
return !(value === null && descriptor.nulls === LIST_ORDER_NULLS_LAST);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function appendOrderedListCursorBranches(query, orderBy = [], cursorValues = [], index = 0, { useOr = false } = {}) {
|
|
285
|
+
const descriptor = orderBy[index];
|
|
286
|
+
if (!descriptor) {
|
|
287
|
+
return false;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
let addedBranch = false;
|
|
291
|
+
const currentValue = cursorValues[index] ?? null;
|
|
292
|
+
|
|
293
|
+
if (canApplyOrderedListCursorAfterBranch(descriptor, currentValue)) {
|
|
294
|
+
const afterMethod = useOr === true ? "orWhere" : "where";
|
|
295
|
+
query[afterMethod]((afterQuery) => {
|
|
296
|
+
applyOrderedListCursorAfterBranch(afterQuery, descriptor, currentValue);
|
|
297
|
+
});
|
|
298
|
+
addedBranch = true;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
if (index >= orderBy.length - 1) {
|
|
302
|
+
return addedBranch;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const equalityMethod = useOr === true || addedBranch === true ? "orWhere" : "where";
|
|
306
|
+
query[equalityMethod]((equalQuery) => {
|
|
307
|
+
applyOrderedListCursorEquality(equalQuery, descriptor, currentValue);
|
|
308
|
+
equalQuery.where((nestedQuery) => {
|
|
309
|
+
appendOrderedListCursorBranches(nestedQuery, orderBy, cursorValues, index + 1);
|
|
310
|
+
});
|
|
311
|
+
});
|
|
312
|
+
return true;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function applyOrderedListCursorFilter(query, { orderBy = [], cursor = "" } = {}) {
|
|
316
|
+
const normalizedOrderBy = Array.isArray(orderBy) ? orderBy : [];
|
|
317
|
+
const cursorValues = decodeOrderedListCursor(cursor, normalizedOrderBy);
|
|
318
|
+
if (!cursorValues) {
|
|
319
|
+
return query;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
return query.where((cursorQuery) => {
|
|
323
|
+
const appended = appendOrderedListCursorBranches(cursorQuery, normalizedOrderBy, cursorValues);
|
|
324
|
+
if (!appended) {
|
|
325
|
+
cursorQuery.whereRaw("1 = 0");
|
|
326
|
+
}
|
|
76
327
|
});
|
|
77
328
|
}
|
|
78
329
|
|
|
@@ -124,18 +375,27 @@ function createCrudRepositoryRuntime(resource = {}, { context = "crudRepository"
|
|
|
124
375
|
const lookupRuntime = createCrudLookupRuntime(resource, {
|
|
125
376
|
outputKeys: repositoryMapping.outputKeys
|
|
126
377
|
});
|
|
378
|
+
const listRuntime = resolveListRuntimeConfig(list, repositoryMapping.listSearchColumns, {
|
|
379
|
+
idColumn: defaults.idColumn
|
|
380
|
+
});
|
|
127
381
|
const { selectColumns } = buildRepositoryColumnMetadata({
|
|
128
382
|
outputKeys: repositoryMapping.outputKeys,
|
|
129
383
|
writeKeys: repositoryMapping.writeKeys,
|
|
130
384
|
columnOverrides: repositoryMapping.columnOverrides
|
|
131
385
|
});
|
|
386
|
+
const normalizedSelectColumns = Object.freeze(
|
|
387
|
+
[...new Set([
|
|
388
|
+
...selectColumns,
|
|
389
|
+
...listRuntime.orderBy.map(({ column }) => column)
|
|
390
|
+
])]
|
|
391
|
+
);
|
|
132
392
|
|
|
133
393
|
return Object.freeze({
|
|
134
394
|
context,
|
|
135
395
|
defaults,
|
|
136
|
-
selectColumns,
|
|
396
|
+
selectColumns: normalizedSelectColumns,
|
|
137
397
|
output,
|
|
138
|
-
list:
|
|
398
|
+
list: listRuntime,
|
|
139
399
|
lookup: lookupRuntime,
|
|
140
400
|
mapping: repositoryMapping
|
|
141
401
|
});
|
|
@@ -372,7 +632,31 @@ async function applyCrudRepositoryAfterWriteHook(
|
|
|
372
632
|
await hook(meta, hookContext);
|
|
373
633
|
}
|
|
374
634
|
|
|
375
|
-
function
|
|
635
|
+
function applyOrderedListControls(dbQuery, orderBy = []) {
|
|
636
|
+
let nextQuery = dbQuery;
|
|
637
|
+
for (const descriptor of Array.isArray(orderBy) ? orderBy : []) {
|
|
638
|
+
if (typeof nextQuery.orderByRaw === "function") {
|
|
639
|
+
nextQuery = nextQuery.orderByRaw(
|
|
640
|
+
descriptor.nulls === LIST_ORDER_NULLS_FIRST
|
|
641
|
+
? "?? is null desc"
|
|
642
|
+
: "?? is null asc",
|
|
643
|
+
[descriptor.column]
|
|
644
|
+
);
|
|
645
|
+
}
|
|
646
|
+
nextQuery = nextQuery.orderBy(descriptor.column, descriptor.direction);
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
return nextQuery;
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
function enforceCrudRepositoryListControls(
|
|
653
|
+
dbQuery,
|
|
654
|
+
{
|
|
655
|
+
idColumn = "id",
|
|
656
|
+
limit = DEFAULT_LIST_LIMIT + 1,
|
|
657
|
+
orderBy = []
|
|
658
|
+
} = {}
|
|
659
|
+
) {
|
|
376
660
|
let nextQuery = dbQuery;
|
|
377
661
|
if (typeof nextQuery.clearOrder === "function") {
|
|
378
662
|
nextQuery = nextQuery.clearOrder();
|
|
@@ -381,6 +665,12 @@ function enforceCrudRepositoryListControls(dbQuery, { idColumn = "id", limit = D
|
|
|
381
665
|
nextQuery = nextQuery.clear("limit");
|
|
382
666
|
}
|
|
383
667
|
|
|
668
|
+
const normalizedOrderBy = Array.isArray(orderBy) ? orderBy : [];
|
|
669
|
+
if (normalizedOrderBy.length > 0) {
|
|
670
|
+
return applyOrderedListControls(nextQuery, normalizedOrderBy)
|
|
671
|
+
.limit(limit);
|
|
672
|
+
}
|
|
673
|
+
|
|
384
674
|
return nextQuery
|
|
385
675
|
.orderBy(idColumn, "asc")
|
|
386
676
|
.limit(limit);
|
|
@@ -421,17 +711,25 @@ async function crudRepositoryList(runtime, knex, query = {}, repositoryOptions =
|
|
|
421
711
|
max: runtime.list.maxLimit
|
|
422
712
|
});
|
|
423
713
|
const hookContextBase = createCrudRepositoryHookContextBase(runtime, repositoryOptions, callOptions);
|
|
714
|
+
const usesOrderedListCursor = runtime.list.orderBy.length > 0;
|
|
424
715
|
let dbQuery = client(tableName)
|
|
425
716
|
.select(...runtime.selectColumns);
|
|
426
717
|
|
|
427
718
|
dbQuery = applyCrudListQueryFilters(dbQuery, {
|
|
428
719
|
idColumn,
|
|
429
720
|
cursor: query?.cursor,
|
|
721
|
+
applyCursor: usesOrderedListCursor !== true,
|
|
430
722
|
q: query?.q,
|
|
431
723
|
searchColumns: runtime.list.searchColumns,
|
|
432
724
|
parentFilters: query,
|
|
433
725
|
parentFilterColumns: runtime.mapping.parentFilterColumns
|
|
434
726
|
});
|
|
727
|
+
if (usesOrderedListCursor) {
|
|
728
|
+
dbQuery = applyOrderedListCursorFilter(dbQuery, {
|
|
729
|
+
orderBy: runtime.list.orderBy,
|
|
730
|
+
cursor: query?.cursor
|
|
731
|
+
});
|
|
732
|
+
}
|
|
435
733
|
|
|
436
734
|
const listHookResult = await applyCrudRepositoryQueryHook(
|
|
437
735
|
dbQuery,
|
|
@@ -450,7 +748,8 @@ async function crudRepositoryList(runtime, knex, query = {}, repositoryOptions =
|
|
|
450
748
|
dbQuery = dbQuery.where(visible);
|
|
451
749
|
dbQuery = enforceCrudRepositoryListControls(dbQuery, {
|
|
452
750
|
idColumn,
|
|
453
|
-
limit: normalizedLimit + 1
|
|
751
|
+
limit: normalizedLimit + 1,
|
|
752
|
+
orderBy: runtime.list.orderBy
|
|
454
753
|
});
|
|
455
754
|
|
|
456
755
|
const rows = await dbQuery;
|
|
@@ -511,7 +810,14 @@ async function crudRepositoryList(runtime, knex, query = {}, repositoryOptions =
|
|
|
511
810
|
);
|
|
512
811
|
}
|
|
513
812
|
|
|
514
|
-
const
|
|
813
|
+
const lastPageRow = pageRows[pageRows.length - 1] || null;
|
|
814
|
+
const nextCursor = hasMore && lastPageRow
|
|
815
|
+
? (
|
|
816
|
+
usesOrderedListCursor
|
|
817
|
+
? encodeOrderedListCursor(lastPageRow, runtime.list.orderBy)
|
|
818
|
+
: String(lastPageRow[idColumn])
|
|
819
|
+
)
|
|
820
|
+
: null;
|
|
515
821
|
let output = {
|
|
516
822
|
items: transformedItems,
|
|
517
823
|
nextCursor
|
|
@@ -828,12 +1134,15 @@ async function crudRepositoryListByForeignIds(
|
|
|
828
1134
|
|
|
829
1135
|
async function crudRepositoryCreate(runtime, knex, payload = {}, repositoryOptions = {}, callOptions = {}, hooks = null) {
|
|
830
1136
|
const { client, tableName } = resolveCrudRepositoryCall(runtime, knex, repositoryOptions, callOptions);
|
|
831
|
-
const methodHooks = normalizeCrudRepositoryHooks(hooks, ["modifyPayload", "modifyQuery", "afterWrite"], {
|
|
1137
|
+
const methodHooks = normalizeCrudRepositoryHooks(hooks, ["modifyPayload", "finalizeInsertPayload", "modifyQuery", "afterWrite"], {
|
|
832
1138
|
context: "crudRepositoryCreate"
|
|
833
1139
|
});
|
|
834
1140
|
const modifyCreatePayload = resolveOptionalCrudRepositoryHook(methodHooks, "modifyPayload", {
|
|
835
1141
|
context: "crudRepositoryCreate"
|
|
836
1142
|
});
|
|
1143
|
+
const finalizeCreateInsertPayload = resolveOptionalCrudRepositoryHook(methodHooks, "finalizeInsertPayload", {
|
|
1144
|
+
context: "crudRepositoryCreate"
|
|
1145
|
+
});
|
|
837
1146
|
const modifyCreateQuery = resolveOptionalCrudRepositoryHook(methodHooks, "modifyQuery", {
|
|
838
1147
|
context: "crudRepositoryCreate"
|
|
839
1148
|
});
|
|
@@ -855,7 +1164,7 @@ async function crudRepositoryCreate(runtime, knex, payload = {}, repositoryOptio
|
|
|
855
1164
|
}
|
|
856
1165
|
);
|
|
857
1166
|
|
|
858
|
-
|
|
1167
|
+
let insertPayload = buildWritePayload(sourcePayload, runtime.mapping.writeKeys, runtime.mapping.columnOverrides);
|
|
859
1168
|
const timestamp = toInsertDateTime();
|
|
860
1169
|
if (runtime.defaults.createdAtColumn && !Object.hasOwn(insertPayload, runtime.defaults.createdAtColumn)) {
|
|
861
1170
|
insertPayload[runtime.defaults.createdAtColumn] = timestamp;
|
|
@@ -863,6 +1172,21 @@ async function crudRepositoryCreate(runtime, knex, payload = {}, repositoryOptio
|
|
|
863
1172
|
if (runtime.defaults.updatedAtColumn && !Object.hasOwn(insertPayload, runtime.defaults.updatedAtColumn)) {
|
|
864
1173
|
insertPayload[runtime.defaults.updatedAtColumn] = timestamp;
|
|
865
1174
|
}
|
|
1175
|
+
insertPayload = await applyCrudRepositoryPayloadHook(
|
|
1176
|
+
insertPayload,
|
|
1177
|
+
finalizeCreateInsertPayload,
|
|
1178
|
+
{
|
|
1179
|
+
payload: sourcePayload,
|
|
1180
|
+
insertPayload: {
|
|
1181
|
+
...insertPayload
|
|
1182
|
+
},
|
|
1183
|
+
...hookContextBase
|
|
1184
|
+
},
|
|
1185
|
+
{
|
|
1186
|
+
context: "crudRepositoryCreate",
|
|
1187
|
+
hookKey: "finalizeInsertPayload"
|
|
1188
|
+
}
|
|
1189
|
+
);
|
|
866
1190
|
|
|
867
1191
|
let withOwners = applyVisibilityOwners(insertPayload, callOptions.visibilityContext);
|
|
868
1192
|
let createQuery = client(tableName);
|