@jskit-ai/crud-core 0.1.26 → 0.1.28
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 +17 -7
- package/src/client/composables/crudClientSupportHelpers.js +5 -17
- package/src/server/createCrudRepositoryFromResource.js +57 -0
- package/src/server/createCrudServiceFromResource.js +101 -0
- package/src/server/crudModuleConfig.js +13 -21
- package/src/server/fieldAccess.js +316 -0
- package/src/server/listQueryValidators.js +87 -0
- package/src/server/lookupHydration.js +546 -0
- package/src/server/lookupPathSupport.js +45 -0
- package/src/server/lookupProviders.js +43 -0
- package/src/server/repositoryMethods.js +381 -0
- package/src/server/repositorySupport.js +205 -0
- package/src/server/serviceEvents.js +53 -0
- package/src/shared/crudFieldMetaSupport.js +54 -0
- package/src/shared/crudNamespaceSupport.js +31 -0
- package/test/createCrudRepositoryFromResource.test.js +731 -0
- package/test/createCrudServiceFromResource.test.js +263 -0
- package/test/crudFieldMetaSupport.test.js +47 -0
- package/test/fieldAccess.test.js +86 -0
- package/test/listQueryValidators.test.js +162 -0
- package/test/lookupProviders.test.js +103 -0
- package/test/repositorySupport.test.js +282 -1
- package/test/serviceEvents.test.js +28 -0
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
import { toInsertDateTime } from "@jskit-ai/database-runtime/shared";
|
|
2
|
+
import { applyVisibility, applyVisibilityOwners } from "@jskit-ai/database-runtime/shared/visibility";
|
|
3
|
+
import { normalizeText, normalizeUniqueTextList } from "@jskit-ai/kernel/shared/support/normalize";
|
|
4
|
+
import { Check, Errors } from "typebox/value";
|
|
5
|
+
import {
|
|
6
|
+
DEFAULT_LIST_LIMIT,
|
|
7
|
+
MAX_LIST_LIMIT,
|
|
8
|
+
buildRepositoryColumnMetadata,
|
|
9
|
+
deriveRepositoryMappingFromResource,
|
|
10
|
+
normalizeCrudListLimit,
|
|
11
|
+
requireCrudTableName,
|
|
12
|
+
applyCrudListQueryFilters,
|
|
13
|
+
buildWritePayload,
|
|
14
|
+
mapRecordRow,
|
|
15
|
+
resolveColumnName,
|
|
16
|
+
resolveCrudIdColumn
|
|
17
|
+
} from "./repositorySupport.js";
|
|
18
|
+
import {
|
|
19
|
+
createCrudLookupRuntime,
|
|
20
|
+
hydrateCrudLookupRecords
|
|
21
|
+
} from "./lookupHydration.js";
|
|
22
|
+
|
|
23
|
+
function resolveRepositoryDefaults(resource = {}, repositoryMapping = {}) {
|
|
24
|
+
const resourceName = normalizeText(resource.resource);
|
|
25
|
+
const tableName = normalizeText(resource.tableName) || resourceName;
|
|
26
|
+
if (!tableName) {
|
|
27
|
+
throw new TypeError("createCrudRepositoryFromResource requires resource.tableName or resource.resource.");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const idColumn = normalizeText(resource.idColumn) || resolveColumnName("id", repositoryMapping.columnOverrides) || "id";
|
|
31
|
+
const createdAtColumn = repositoryMapping.outputKeys.includes("createdAt")
|
|
32
|
+
? resolveColumnName("createdAt", repositoryMapping.columnOverrides)
|
|
33
|
+
: "";
|
|
34
|
+
const updatedAtColumn = repositoryMapping.outputKeys.includes("updatedAt")
|
|
35
|
+
? resolveColumnName("updatedAt", repositoryMapping.columnOverrides)
|
|
36
|
+
: "";
|
|
37
|
+
|
|
38
|
+
return Object.freeze({
|
|
39
|
+
tableName,
|
|
40
|
+
idColumn,
|
|
41
|
+
createdAtColumn,
|
|
42
|
+
updatedAtColumn
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function normalizeSearchColumns(searchColumns = [], fallbackColumns = []) {
|
|
47
|
+
const normalizedConfiguredColumns = (Array.isArray(searchColumns) ? searchColumns : [])
|
|
48
|
+
.map((columnName) => String(columnName || "").trim())
|
|
49
|
+
.filter(Boolean);
|
|
50
|
+
|
|
51
|
+
if (normalizedConfiguredColumns.length > 0) {
|
|
52
|
+
return Object.freeze([...new Set(normalizedConfiguredColumns)]);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return Object.freeze(
|
|
56
|
+
(Array.isArray(fallbackColumns) ? fallbackColumns : [])
|
|
57
|
+
.map((columnName) => String(columnName || "").trim())
|
|
58
|
+
.filter(Boolean)
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function resolveListRuntimeConfig(list = {}, fallbackSearchColumns = []) {
|
|
63
|
+
const parsedMaxLimit = Number(list?.maxLimit);
|
|
64
|
+
const normalizedMaxLimit = Number.isInteger(parsedMaxLimit) && parsedMaxLimit > 0
|
|
65
|
+
? parsedMaxLimit
|
|
66
|
+
: MAX_LIST_LIMIT;
|
|
67
|
+
const normalizedDefaultLimit = normalizeCrudListLimit(list?.defaultLimit, {
|
|
68
|
+
fallback: DEFAULT_LIST_LIMIT,
|
|
69
|
+
max: normalizedMaxLimit
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
return Object.freeze({
|
|
73
|
+
defaultLimit: normalizedDefaultLimit,
|
|
74
|
+
maxLimit: normalizedMaxLimit,
|
|
75
|
+
searchColumns: normalizeSearchColumns(list?.searchColumns, fallbackSearchColumns)
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function resolveRecordOutputValidator(resource = {}, { context = "crudRepository" } = {}) {
|
|
80
|
+
const outputValidator = resource?.operations?.view?.outputValidator;
|
|
81
|
+
if (!outputValidator || typeof outputValidator !== "object" || Array.isArray(outputValidator)) {
|
|
82
|
+
throw new TypeError(`${context} requires resource.operations.view.outputValidator.`);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const schema = outputValidator?.schema;
|
|
86
|
+
if (!schema || typeof schema !== "object" || Array.isArray(schema)) {
|
|
87
|
+
throw new TypeError(`${context} requires resource.operations.view.outputValidator.schema.`);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const normalize = typeof outputValidator.normalize === "function"
|
|
91
|
+
? outputValidator.normalize
|
|
92
|
+
: (payload = {}) => payload;
|
|
93
|
+
|
|
94
|
+
return Object.freeze({
|
|
95
|
+
schema,
|
|
96
|
+
normalize
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function formatOutputValidationError(error = {}) {
|
|
101
|
+
const path = normalizeText(error?.instancePath || error?.path) || "/";
|
|
102
|
+
const message = normalizeText(error?.message) || "invalid output value";
|
|
103
|
+
return `${path} ${message}`;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function normalizeRepositoryOutputRecord(runtime, record = {}, { operation = "read" } = {}) {
|
|
107
|
+
const outputRuntime = runtime?.output || {};
|
|
108
|
+
const normalizedRecord = await outputRuntime.normalize(record);
|
|
109
|
+
if (Check(outputRuntime.schema, normalizedRecord)) {
|
|
110
|
+
return normalizedRecord;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const issues = [...Errors(outputRuntime.schema, normalizedRecord)];
|
|
114
|
+
const formattedIssue = formatOutputValidationError(issues[0]);
|
|
115
|
+
throw new TypeError(
|
|
116
|
+
`${runtime?.context || "crudRepository"} ${operation} output validation failed: ${formattedIssue}.`
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function createCrudRepositoryRuntime(resource = {}, { context = "crudRepository", list = {} } = {}) {
|
|
121
|
+
const repositoryMapping = deriveRepositoryMappingFromResource(resource, { context });
|
|
122
|
+
const defaults = resolveRepositoryDefaults(resource, repositoryMapping);
|
|
123
|
+
const output = resolveRecordOutputValidator(resource, { context });
|
|
124
|
+
const lookupRuntime = createCrudLookupRuntime(resource, {
|
|
125
|
+
outputKeys: repositoryMapping.outputKeys
|
|
126
|
+
});
|
|
127
|
+
const { selectColumns } = buildRepositoryColumnMetadata({
|
|
128
|
+
outputKeys: repositoryMapping.outputKeys,
|
|
129
|
+
writeKeys: repositoryMapping.writeKeys,
|
|
130
|
+
columnOverrides: repositoryMapping.columnOverrides
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
return Object.freeze({
|
|
134
|
+
context,
|
|
135
|
+
defaults,
|
|
136
|
+
selectColumns,
|
|
137
|
+
output,
|
|
138
|
+
list: resolveListRuntimeConfig(list, repositoryMapping.listSearchColumns),
|
|
139
|
+
lookup: lookupRuntime,
|
|
140
|
+
mapping: repositoryMapping
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function resolveCrudRepositoryCall(runtime, knex, repositoryOptions = {}, callOptions = {}) {
|
|
145
|
+
if (!runtime || typeof runtime !== "object" || Array.isArray(runtime)) {
|
|
146
|
+
throw new TypeError("crudRepository methods require runtime.");
|
|
147
|
+
}
|
|
148
|
+
if (typeof knex !== "function") {
|
|
149
|
+
throw new TypeError("crudRepository requires knex.");
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const client = callOptions?.trx || knex;
|
|
153
|
+
const tableName = requireCrudTableName(repositoryOptions?.tableName ?? runtime.defaults?.tableName, {
|
|
154
|
+
context: runtime.context || "crudRepository"
|
|
155
|
+
});
|
|
156
|
+
const idColumn = resolveCrudIdColumn(repositoryOptions?.idColumn, {
|
|
157
|
+
fallback: runtime.defaults?.idColumn || "id"
|
|
158
|
+
});
|
|
159
|
+
const visible = (queryBuilder) => applyVisibility(queryBuilder, callOptions.visibilityContext);
|
|
160
|
+
|
|
161
|
+
return Object.freeze({
|
|
162
|
+
client,
|
|
163
|
+
tableName,
|
|
164
|
+
idColumn,
|
|
165
|
+
visible
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async function crudRepositoryList(runtime, knex, query = {}, repositoryOptions = {}, callOptions = {}) {
|
|
170
|
+
const { client, tableName, idColumn, visible } = resolveCrudRepositoryCall(runtime, knex, repositoryOptions, callOptions);
|
|
171
|
+
const normalizedLimit = normalizeCrudListLimit(query?.limit, {
|
|
172
|
+
fallback: runtime.list.defaultLimit,
|
|
173
|
+
max: runtime.list.maxLimit
|
|
174
|
+
});
|
|
175
|
+
let dbQuery = client(tableName)
|
|
176
|
+
.select(...runtime.selectColumns)
|
|
177
|
+
.where(visible)
|
|
178
|
+
.orderBy(idColumn, "asc")
|
|
179
|
+
.limit(normalizedLimit + 1);
|
|
180
|
+
|
|
181
|
+
dbQuery = applyCrudListQueryFilters(dbQuery, {
|
|
182
|
+
idColumn,
|
|
183
|
+
cursor: query?.cursor,
|
|
184
|
+
q: query?.q,
|
|
185
|
+
searchColumns: runtime.list.searchColumns,
|
|
186
|
+
parentFilters: query,
|
|
187
|
+
parentFilterColumns: runtime.mapping.parentFilterColumns
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
const rows = await dbQuery;
|
|
191
|
+
const hasMore = rows.length > normalizedLimit;
|
|
192
|
+
const pageRows = hasMore ? rows.slice(0, normalizedLimit) : rows;
|
|
193
|
+
const items = [];
|
|
194
|
+
for (const row of pageRows) {
|
|
195
|
+
const mappedRecord = mapRecordRow(row, runtime.mapping.outputKeys, runtime.mapping.columnOverrides);
|
|
196
|
+
if (!mappedRecord) {
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
items.push(await normalizeRepositoryOutputRecord(runtime, mappedRecord, {
|
|
201
|
+
operation: "list"
|
|
202
|
+
}));
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const hydratedItems = await hydrateCrudLookupRecords(items, {
|
|
206
|
+
...runtime.lookup,
|
|
207
|
+
context: runtime.context
|
|
208
|
+
}, {
|
|
209
|
+
include: query?.include,
|
|
210
|
+
mode: "list",
|
|
211
|
+
repositoryOptions,
|
|
212
|
+
callOptions
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
return {
|
|
216
|
+
items: hydratedItems,
|
|
217
|
+
nextCursor: hasMore && items.length > 0 ? String(items[items.length - 1].id) : null
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
async function crudRepositoryFindById(runtime, knex, recordId, repositoryOptions = {}, callOptions = {}) {
|
|
222
|
+
const { client, tableName, idColumn, visible } = resolveCrudRepositoryCall(runtime, knex, repositoryOptions, callOptions);
|
|
223
|
+
const row = await client(tableName)
|
|
224
|
+
.select(...runtime.selectColumns)
|
|
225
|
+
.where(visible)
|
|
226
|
+
.where({
|
|
227
|
+
[idColumn]: Number(recordId)
|
|
228
|
+
})
|
|
229
|
+
.first();
|
|
230
|
+
|
|
231
|
+
const mappedRecord = mapRecordRow(row, runtime.mapping.outputKeys, runtime.mapping.columnOverrides);
|
|
232
|
+
if (!mappedRecord) {
|
|
233
|
+
return null;
|
|
234
|
+
}
|
|
235
|
+
const normalizedRecord = await normalizeRepositoryOutputRecord(runtime, mappedRecord, {
|
|
236
|
+
operation: "findById"
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
const hydrated = await hydrateCrudLookupRecords([normalizedRecord], {
|
|
240
|
+
...runtime.lookup,
|
|
241
|
+
context: runtime.context
|
|
242
|
+
}, {
|
|
243
|
+
include: callOptions?.include,
|
|
244
|
+
mode: "view",
|
|
245
|
+
repositoryOptions,
|
|
246
|
+
callOptions
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
return hydrated[0] || null;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
async function crudRepositoryListByIds(runtime, knex, ids = [], repositoryOptions = {}, callOptions = {}) {
|
|
253
|
+
const { client, tableName, visible } = resolveCrudRepositoryCall(runtime, knex, repositoryOptions, callOptions);
|
|
254
|
+
const lookupValueKey = normalizeText(callOptions?.valueKey) || "id";
|
|
255
|
+
if (!runtime.mapping.outputKeys.includes(lookupValueKey)) {
|
|
256
|
+
throw new TypeError(
|
|
257
|
+
`${runtime.context || "crudRepository"} listByIds requires valueKey "${lookupValueKey}" to exist in output schema.`
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
const lookupColumn = resolveColumnName(lookupValueKey, runtime.mapping.columnOverrides);
|
|
261
|
+
if (!lookupColumn) {
|
|
262
|
+
throw new TypeError(`${runtime.context || "crudRepository"} listByIds requires a valid valueKey.`);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const normalizedIds = normalizeUniqueTextList(ids);
|
|
266
|
+
if (normalizedIds.length < 1) {
|
|
267
|
+
return [];
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const rows = await client(tableName)
|
|
271
|
+
.select(...runtime.selectColumns)
|
|
272
|
+
.where(visible)
|
|
273
|
+
.whereIn(lookupColumn, normalizedIds);
|
|
274
|
+
|
|
275
|
+
const records = [];
|
|
276
|
+
for (const row of rows) {
|
|
277
|
+
const mappedRecord = mapRecordRow(row, runtime.mapping.outputKeys, runtime.mapping.columnOverrides);
|
|
278
|
+
if (!mappedRecord) {
|
|
279
|
+
continue;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
records.push(await normalizeRepositoryOutputRecord(runtime, mappedRecord, {
|
|
283
|
+
operation: "listByIds"
|
|
284
|
+
}));
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const lookupInclude = callOptions?.include === undefined ? "none" : callOptions.include;
|
|
288
|
+
|
|
289
|
+
return hydrateCrudLookupRecords(records, {
|
|
290
|
+
...runtime.lookup,
|
|
291
|
+
context: runtime.context
|
|
292
|
+
}, {
|
|
293
|
+
include: lookupInclude,
|
|
294
|
+
mode: "list",
|
|
295
|
+
repositoryOptions,
|
|
296
|
+
callOptions
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
async function crudRepositoryCreate(runtime, knex, payload = {}, repositoryOptions = {}, callOptions = {}) {
|
|
301
|
+
const { client, tableName } = resolveCrudRepositoryCall(runtime, knex, repositoryOptions, callOptions);
|
|
302
|
+
const insertPayload = buildWritePayload(payload, runtime.mapping.writeKeys, runtime.mapping.columnOverrides);
|
|
303
|
+
const timestamp = toInsertDateTime();
|
|
304
|
+
if (runtime.defaults.createdAtColumn && !Object.hasOwn(insertPayload, runtime.defaults.createdAtColumn)) {
|
|
305
|
+
insertPayload[runtime.defaults.createdAtColumn] = timestamp;
|
|
306
|
+
}
|
|
307
|
+
if (runtime.defaults.updatedAtColumn && !Object.hasOwn(insertPayload, runtime.defaults.updatedAtColumn)) {
|
|
308
|
+
insertPayload[runtime.defaults.updatedAtColumn] = timestamp;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const withOwners = applyVisibilityOwners(insertPayload, callOptions.visibilityContext);
|
|
312
|
+
const [recordId] = await client(tableName).insert(withOwners);
|
|
313
|
+
|
|
314
|
+
return crudRepositoryFindById(runtime, knex, recordId, repositoryOptions, {
|
|
315
|
+
...callOptions,
|
|
316
|
+
trx: client
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
async function crudRepositoryUpdateById(runtime, knex, recordId, patch = {}, repositoryOptions = {}, callOptions = {}) {
|
|
321
|
+
const { client, tableName, idColumn, visible } = resolveCrudRepositoryCall(runtime, knex, repositoryOptions, callOptions);
|
|
322
|
+
const dbPatch = buildWritePayload(patch, runtime.mapping.writeKeys, runtime.mapping.columnOverrides);
|
|
323
|
+
|
|
324
|
+
if (runtime.defaults.updatedAtColumn) {
|
|
325
|
+
dbPatch[runtime.defaults.updatedAtColumn] = toInsertDateTime();
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
if (Object.keys(dbPatch).length < 1) {
|
|
329
|
+
return crudRepositoryFindById(runtime, knex, recordId, repositoryOptions, {
|
|
330
|
+
...callOptions,
|
|
331
|
+
trx: client
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
await client(tableName)
|
|
336
|
+
.where(visible)
|
|
337
|
+
.where({
|
|
338
|
+
[idColumn]: Number(recordId)
|
|
339
|
+
})
|
|
340
|
+
.update(dbPatch);
|
|
341
|
+
|
|
342
|
+
return crudRepositoryFindById(runtime, knex, recordId, repositoryOptions, {
|
|
343
|
+
...callOptions,
|
|
344
|
+
trx: client
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
async function crudRepositoryDeleteById(runtime, knex, recordId, repositoryOptions = {}, callOptions = {}) {
|
|
349
|
+
const { client, tableName, idColumn, visible } = resolveCrudRepositoryCall(runtime, knex, repositoryOptions, callOptions);
|
|
350
|
+
const existing = await crudRepositoryFindById(runtime, knex, recordId, repositoryOptions, {
|
|
351
|
+
...callOptions,
|
|
352
|
+
include: "none",
|
|
353
|
+
trx: client
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
if (!existing) {
|
|
357
|
+
return null;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
await client(tableName)
|
|
361
|
+
.where(visible)
|
|
362
|
+
.where({
|
|
363
|
+
[idColumn]: Number(recordId)
|
|
364
|
+
})
|
|
365
|
+
.delete();
|
|
366
|
+
|
|
367
|
+
return {
|
|
368
|
+
id: existing.id,
|
|
369
|
+
deleted: true
|
|
370
|
+
};
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
export {
|
|
374
|
+
createCrudRepositoryRuntime,
|
|
375
|
+
crudRepositoryList,
|
|
376
|
+
crudRepositoryFindById,
|
|
377
|
+
crudRepositoryListByIds,
|
|
378
|
+
crudRepositoryCreate,
|
|
379
|
+
crudRepositoryUpdateById,
|
|
380
|
+
crudRepositoryDeleteById
|
|
381
|
+
};
|
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
import { normalizeText } from "@jskit-ai/kernel/shared/support/normalize";
|
|
2
2
|
import { normalizeObjectInput } from "@jskit-ai/kernel/shared/validators/inputNormalization";
|
|
3
3
|
import { toSnakeCase } from "@jskit-ai/kernel/shared/support/stringCase";
|
|
4
|
+
import {
|
|
5
|
+
resolveCrudLookupContainerKey,
|
|
6
|
+
resolveCrudLookupFieldKeys
|
|
7
|
+
} from "@jskit-ai/kernel/shared/support/crudLookup";
|
|
8
|
+
import { isCrudRuntimeOutputOnlyFieldKey } from "../shared/crudFieldMetaSupport.js";
|
|
4
9
|
|
|
5
10
|
const DEFAULT_LIST_LIMIT = 20;
|
|
6
11
|
const MAX_LIST_LIMIT = 100;
|
|
@@ -70,6 +75,131 @@ function buildRepositoryColumnMetadata({
|
|
|
70
75
|
});
|
|
71
76
|
}
|
|
72
77
|
|
|
78
|
+
function requireObjectSchemaProperties(schema, { context = "crudRepository", schemaLabel = "schema" } = {}) {
|
|
79
|
+
if (!schema || typeof schema !== "object" || Array.isArray(schema)) {
|
|
80
|
+
throw new TypeError(`${context} requires ${schemaLabel} to be an object schema.`);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const properties = schema.properties;
|
|
84
|
+
if (!properties || typeof properties !== "object" || Array.isArray(properties)) {
|
|
85
|
+
throw new TypeError(`${context} requires ${schemaLabel}.properties.`);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return properties;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function normalizeResourceFieldMetaEntries(fieldMeta = []) {
|
|
92
|
+
if (!Array.isArray(fieldMeta)) {
|
|
93
|
+
return [];
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const normalized = [];
|
|
97
|
+
const seenKeys = new Set();
|
|
98
|
+
for (const rawEntry of fieldMeta) {
|
|
99
|
+
if (!rawEntry || typeof rawEntry !== "object" || Array.isArray(rawEntry)) {
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const key = normalizeText(rawEntry.key);
|
|
104
|
+
if (!key || seenKeys.has(key)) {
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
seenKeys.add(key);
|
|
108
|
+
normalized.push(rawEntry);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return normalized;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function schemaIncludesStringType(schema = {}) {
|
|
115
|
+
if (!schema || typeof schema !== "object" || Array.isArray(schema)) {
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const type = normalizeText(schema.type).toLowerCase();
|
|
120
|
+
if (type === "string") {
|
|
121
|
+
return true;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const variants = Array.isArray(schema.anyOf)
|
|
125
|
+
? schema.anyOf
|
|
126
|
+
: Array.isArray(schema.oneOf)
|
|
127
|
+
? schema.oneOf
|
|
128
|
+
: [];
|
|
129
|
+
return variants.some((entry) => schemaIncludesStringType(entry));
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function deriveRepositoryMappingFromResource(resource = {}, { context = "crudRepository" } = {}) {
|
|
133
|
+
if (!resource || typeof resource !== "object" || Array.isArray(resource)) {
|
|
134
|
+
throw new TypeError(`${context} requires resource object.`);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const operations = resource.operations;
|
|
138
|
+
if (!operations || typeof operations !== "object" || Array.isArray(operations)) {
|
|
139
|
+
throw new TypeError(`${context} requires resource.operations.`);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const outputSchema = operations?.view?.outputValidator?.schema;
|
|
143
|
+
const writeSchema = operations?.create?.bodyValidator?.schema;
|
|
144
|
+
const outputProperties = requireObjectSchemaProperties(outputSchema, {
|
|
145
|
+
context,
|
|
146
|
+
schemaLabel: "operations.view.outputValidator.schema"
|
|
147
|
+
});
|
|
148
|
+
const writeProperties = requireObjectSchemaProperties(writeSchema, {
|
|
149
|
+
context,
|
|
150
|
+
schemaLabel: "operations.create.bodyValidator.schema"
|
|
151
|
+
});
|
|
152
|
+
const lookupContainerKey = resolveCrudLookupContainerKey(resource, {
|
|
153
|
+
context: `${context} resource.contract.lookup.containerKey`
|
|
154
|
+
});
|
|
155
|
+
const outputKeys = Object.freeze(
|
|
156
|
+
Object.keys(outputProperties).filter(
|
|
157
|
+
(key) => !isCrudRuntimeOutputOnlyFieldKey(key, { lookupContainerKey })
|
|
158
|
+
)
|
|
159
|
+
);
|
|
160
|
+
const writeKeys = Object.freeze(Object.keys(writeProperties));
|
|
161
|
+
|
|
162
|
+
const columnOverrides = {};
|
|
163
|
+
for (const entry of normalizeResourceFieldMetaEntries(resource.fieldMeta)) {
|
|
164
|
+
const key = normalizeText(entry.key);
|
|
165
|
+
const dbColumn = normalizeText(entry.dbColumn);
|
|
166
|
+
if (!key || !dbColumn) {
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
columnOverrides[key] = dbColumn;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const listSearchColumns = [];
|
|
173
|
+
for (const [key, schema] of Object.entries(outputProperties)) {
|
|
174
|
+
if (!schemaIncludesStringType(schema)) {
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const columnName = resolveColumnName(key, columnOverrides);
|
|
179
|
+
if (!columnName || listSearchColumns.includes(columnName)) {
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
listSearchColumns.push(columnName);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const parentFilterColumns = {};
|
|
186
|
+
for (const key of resolveCrudLookupFieldKeys(resource, { allowKeys: writeKeys })) {
|
|
187
|
+
const columnName = resolveColumnName(key, columnOverrides);
|
|
188
|
+
if (!columnName) {
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
parentFilterColumns[key] = columnName;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
return Object.freeze({
|
|
195
|
+
outputKeys,
|
|
196
|
+
writeKeys,
|
|
197
|
+
columnOverrides: Object.freeze(columnOverrides),
|
|
198
|
+
listSearchColumns: Object.freeze(listSearchColumns),
|
|
199
|
+
parentFilterColumns: Object.freeze(parentFilterColumns)
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
|
|
73
203
|
function mapRecordRow(row, fieldKeys = [], overrides = {}) {
|
|
74
204
|
if (!row) {
|
|
75
205
|
return null;
|
|
@@ -87,6 +217,79 @@ function mapRecordRow(row, fieldKeys = [], overrides = {}) {
|
|
|
87
217
|
return mapped;
|
|
88
218
|
}
|
|
89
219
|
|
|
220
|
+
function applyCrudListQueryFilters(
|
|
221
|
+
query,
|
|
222
|
+
{
|
|
223
|
+
idColumn = "id",
|
|
224
|
+
cursor = 0,
|
|
225
|
+
q = "",
|
|
226
|
+
searchColumns = [],
|
|
227
|
+
parentFilters = {},
|
|
228
|
+
parentFilterColumns = {}
|
|
229
|
+
} = {}
|
|
230
|
+
) {
|
|
231
|
+
if (!query || typeof query.modify !== "function" || typeof query.where !== "function") {
|
|
232
|
+
throw new TypeError("applyCrudListQueryFilters requires query builder.");
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const normalizedSearch = String(q || "").trim();
|
|
236
|
+
const normalizedSearchColumns = (Array.isArray(searchColumns) ? searchColumns : [])
|
|
237
|
+
.map((columnName) => String(columnName || "").trim())
|
|
238
|
+
.filter(Boolean);
|
|
239
|
+
|
|
240
|
+
let nextQuery = query;
|
|
241
|
+
|
|
242
|
+
const sourceParentFilters = normalizeObjectInput(parentFilters);
|
|
243
|
+
const normalizedParentFilterColumns = parentFilterColumns && typeof parentFilterColumns === "object" && !Array.isArray(parentFilterColumns)
|
|
244
|
+
? parentFilterColumns
|
|
245
|
+
: {};
|
|
246
|
+
|
|
247
|
+
for (const [fieldKey, columnNameRaw] of Object.entries(normalizedParentFilterColumns)) {
|
|
248
|
+
if (!Object.hasOwn(sourceParentFilters, fieldKey)) {
|
|
249
|
+
continue;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const columnName = String(columnNameRaw || "").trim();
|
|
253
|
+
if (!columnName) {
|
|
254
|
+
continue;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const sourceValue = sourceParentFilters[fieldKey];
|
|
258
|
+
const normalizedValue = typeof sourceValue === "string"
|
|
259
|
+
? sourceValue.trim()
|
|
260
|
+
: sourceValue;
|
|
261
|
+
if (normalizedValue === "" || normalizedValue === undefined || normalizedValue === null) {
|
|
262
|
+
continue;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
nextQuery = nextQuery.where(columnName, normalizedValue);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
if (normalizedSearch && normalizedSearchColumns.length > 0) {
|
|
269
|
+
const searchPattern = `%${normalizedSearch}%`;
|
|
270
|
+
nextQuery = nextQuery.modify((searchQuery) => {
|
|
271
|
+
searchQuery.where((whereQuery) => {
|
|
272
|
+
for (const [index, columnName] of normalizedSearchColumns.entries()) {
|
|
273
|
+
if (index === 0) {
|
|
274
|
+
whereQuery.where(columnName, "like", searchPattern);
|
|
275
|
+
continue;
|
|
276
|
+
}
|
|
277
|
+
whereQuery.orWhere(columnName, "like", searchPattern);
|
|
278
|
+
}
|
|
279
|
+
});
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const numericCursor = Number(cursor);
|
|
284
|
+
const normalizedCursor = Number.isInteger(numericCursor) && numericCursor > 0 ? numericCursor : 0;
|
|
285
|
+
const normalizedIdColumn = String(idColumn || "").trim() || "id";
|
|
286
|
+
if (normalizedCursor > 0) {
|
|
287
|
+
nextQuery = nextQuery.where(normalizedIdColumn, ">", normalizedCursor);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
return nextQuery;
|
|
291
|
+
}
|
|
292
|
+
|
|
90
293
|
function buildWritePayload(sourcePayload = {}, fieldKeys = [], overrides = {}) {
|
|
91
294
|
const source = normalizeObjectInput(sourcePayload);
|
|
92
295
|
const payload = {};
|
|
@@ -117,6 +320,8 @@ export {
|
|
|
117
320
|
MAX_LIST_LIMIT,
|
|
118
321
|
normalizeCrudListLimit,
|
|
119
322
|
requireCrudTableName,
|
|
323
|
+
deriveRepositoryMappingFromResource,
|
|
324
|
+
applyCrudListQueryFilters,
|
|
120
325
|
mapRecordRow,
|
|
121
326
|
buildWritePayload,
|
|
122
327
|
resolveColumnName,
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import {
|
|
2
|
+
requireCrudNamespace,
|
|
3
|
+
resolveCrudRecordChangedEvent
|
|
4
|
+
} from "../shared/crudNamespaceSupport.js";
|
|
5
|
+
|
|
6
|
+
function createCrudServiceEvents(resource = {}, { context = "crudService" } = {}) {
|
|
7
|
+
const namespace = requireCrudNamespace(resource?.resource, { context: `${context} resource.resource` });
|
|
8
|
+
const recordChangedEventName = resolveCrudRecordChangedEvent(namespace);
|
|
9
|
+
|
|
10
|
+
return Object.freeze({
|
|
11
|
+
createRecord: Object.freeze([
|
|
12
|
+
Object.freeze({
|
|
13
|
+
type: "entity.changed",
|
|
14
|
+
source: "crud",
|
|
15
|
+
entity: "record",
|
|
16
|
+
operation: "created",
|
|
17
|
+
entityId: ({ result }) => result?.id,
|
|
18
|
+
realtime: Object.freeze({
|
|
19
|
+
event: recordChangedEventName,
|
|
20
|
+
audience: "event_scope"
|
|
21
|
+
})
|
|
22
|
+
})
|
|
23
|
+
]),
|
|
24
|
+
updateRecord: Object.freeze([
|
|
25
|
+
Object.freeze({
|
|
26
|
+
type: "entity.changed",
|
|
27
|
+
source: "crud",
|
|
28
|
+
entity: "record",
|
|
29
|
+
operation: "updated",
|
|
30
|
+
entityId: ({ result }) => result?.id,
|
|
31
|
+
realtime: Object.freeze({
|
|
32
|
+
event: recordChangedEventName,
|
|
33
|
+
audience: "event_scope"
|
|
34
|
+
})
|
|
35
|
+
})
|
|
36
|
+
]),
|
|
37
|
+
deleteRecord: Object.freeze([
|
|
38
|
+
Object.freeze({
|
|
39
|
+
type: "entity.changed",
|
|
40
|
+
source: "crud",
|
|
41
|
+
entity: "record",
|
|
42
|
+
operation: "deleted",
|
|
43
|
+
entityId: ({ result }) => result?.id,
|
|
44
|
+
realtime: Object.freeze({
|
|
45
|
+
event: recordChangedEventName,
|
|
46
|
+
audience: "event_scope"
|
|
47
|
+
})
|
|
48
|
+
})
|
|
49
|
+
])
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export { createCrudServiceEvents };
|