@jskit-ai/crud-core 0.1.30 → 0.1.31
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/createCrudRepositoryFromResource.js +18 -12
- package/src/server/createHooksToCollectChildren.js +247 -0
- package/src/server/lookupHydration.js +172 -11
- package/src/server/lookupProviders.js +33 -1
- package/src/server/repositoryMethods.js +808 -58
- package/test/createCrudRepositoryFromResource.test.js +1091 -71
- package/test/createHooksToCollectChildren.test.js +251 -0
- package/test/lookupProviders.test.js +32 -0
|
@@ -2,70 +2,120 @@ import test from "node:test";
|
|
|
2
2
|
import assert from "node:assert/strict";
|
|
3
3
|
import { createCrudRepositoryFromResource } from "../src/server/createCrudRepositoryFromResource.js";
|
|
4
4
|
|
|
5
|
-
function createListKnexDouble(
|
|
5
|
+
function createListKnexDouble(
|
|
6
|
+
rows = [],
|
|
7
|
+
{
|
|
8
|
+
insertResult = [1],
|
|
9
|
+
updateResult = 1,
|
|
10
|
+
deleteResult = 1
|
|
11
|
+
} = {}
|
|
12
|
+
) {
|
|
6
13
|
const calls = [];
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
},
|
|
13
|
-
orWhere(...args) {
|
|
14
|
-
calls.push(["orWhere", ...args]);
|
|
15
|
-
return whereGroup;
|
|
16
|
-
}
|
|
14
|
+
const state = {
|
|
15
|
+
rows: Array.isArray(rows) ? rows : [],
|
|
16
|
+
insertPayloads: [],
|
|
17
|
+
updatePayloads: [],
|
|
18
|
+
deleteCount: 0
|
|
17
19
|
};
|
|
18
20
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
args
|
|
21
|
+
function createQueryBuilder() {
|
|
22
|
+
let firstMode = false;
|
|
23
|
+
const whereGroup = {
|
|
24
|
+
where(...args) {
|
|
25
|
+
calls.push(["where", ...args]);
|
|
26
|
+
return whereGroup;
|
|
27
|
+
},
|
|
28
|
+
orWhere(...args) {
|
|
29
|
+
calls.push(["orWhere", ...args]);
|
|
30
|
+
return whereGroup;
|
|
31
|
+
},
|
|
32
|
+
whereRaw(...args) {
|
|
33
|
+
calls.push(["whereRaw", ...args]);
|
|
34
|
+
return whereGroup;
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const query = {
|
|
39
|
+
select(...args) {
|
|
40
|
+
calls.push(["select", ...args]);
|
|
41
|
+
return query;
|
|
42
|
+
},
|
|
43
|
+
where(...args) {
|
|
44
|
+
if (args.length === 1 && typeof args[0] === "function") {
|
|
45
|
+
calls.push(["whereCallback"]);
|
|
46
|
+
args[0](whereGroup);
|
|
47
|
+
return query;
|
|
48
|
+
}
|
|
49
|
+
calls.push(["where", ...args]);
|
|
50
|
+
return query;
|
|
51
|
+
},
|
|
52
|
+
whereRaw(...args) {
|
|
53
|
+
calls.push(["whereRaw", ...args]);
|
|
54
|
+
return query;
|
|
55
|
+
},
|
|
56
|
+
orderBy(...args) {
|
|
57
|
+
calls.push(["orderBy", ...args]);
|
|
58
|
+
return query;
|
|
59
|
+
},
|
|
60
|
+
clearOrder() {
|
|
61
|
+
calls.push(["clearOrder"]);
|
|
28
62
|
return query;
|
|
63
|
+
},
|
|
64
|
+
clear(...args) {
|
|
65
|
+
calls.push(["clear", ...args]);
|
|
66
|
+
return query;
|
|
67
|
+
},
|
|
68
|
+
limit(value) {
|
|
69
|
+
calls.push(["limit", value]);
|
|
70
|
+
return query;
|
|
71
|
+
},
|
|
72
|
+
modify(callback) {
|
|
73
|
+
calls.push(["modify"]);
|
|
74
|
+
callback(query);
|
|
75
|
+
return query;
|
|
76
|
+
},
|
|
77
|
+
whereIn(...args) {
|
|
78
|
+
calls.push(["whereIn", ...args]);
|
|
79
|
+
return query;
|
|
80
|
+
},
|
|
81
|
+
first() {
|
|
82
|
+
calls.push(["first"]);
|
|
83
|
+
firstMode = true;
|
|
84
|
+
return query;
|
|
85
|
+
},
|
|
86
|
+
insert(payload) {
|
|
87
|
+
calls.push(["insert", payload]);
|
|
88
|
+
state.insertPayloads.push(payload);
|
|
89
|
+
return Promise.resolve(insertResult);
|
|
90
|
+
},
|
|
91
|
+
update(payload) {
|
|
92
|
+
calls.push(["update", payload]);
|
|
93
|
+
state.updatePayloads.push(payload);
|
|
94
|
+
return Promise.resolve(updateResult);
|
|
95
|
+
},
|
|
96
|
+
delete() {
|
|
97
|
+
calls.push(["delete"]);
|
|
98
|
+
state.deleteCount += 1;
|
|
99
|
+
return Promise.resolve(deleteResult);
|
|
100
|
+
},
|
|
101
|
+
then(resolve, reject) {
|
|
102
|
+
const payload = firstMode ? state.rows[0] || null : state.rows;
|
|
103
|
+
return Promise.resolve(payload).then(resolve, reject);
|
|
29
104
|
}
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
calls.push(["orderBy", ...args]);
|
|
35
|
-
return query;
|
|
36
|
-
},
|
|
37
|
-
limit(value) {
|
|
38
|
-
calls.push(["limit", value]);
|
|
39
|
-
return query;
|
|
40
|
-
},
|
|
41
|
-
modify(callback) {
|
|
42
|
-
calls.push(["modify"]);
|
|
43
|
-
callback(query);
|
|
44
|
-
return query;
|
|
45
|
-
},
|
|
46
|
-
whereIn(...args) {
|
|
47
|
-
calls.push(["whereIn", ...args]);
|
|
48
|
-
return query;
|
|
49
|
-
},
|
|
50
|
-
first() {
|
|
51
|
-
calls.push(["first"]);
|
|
52
|
-
firstMode = true;
|
|
53
|
-
return query;
|
|
54
|
-
},
|
|
55
|
-
then(resolve, reject) {
|
|
56
|
-
const payload = firstMode ? rows[0] || null : rows;
|
|
57
|
-
return Promise.resolve(payload).then(resolve, reject);
|
|
58
|
-
}
|
|
59
|
-
};
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
return query;
|
|
108
|
+
}
|
|
60
109
|
|
|
61
110
|
const knex = (tableName) => {
|
|
62
111
|
calls.push(["table", tableName]);
|
|
63
|
-
return
|
|
112
|
+
return createQueryBuilder();
|
|
64
113
|
};
|
|
65
114
|
|
|
66
115
|
return {
|
|
67
116
|
knex,
|
|
68
|
-
calls
|
|
117
|
+
calls,
|
|
118
|
+
state
|
|
69
119
|
};
|
|
70
120
|
}
|
|
71
121
|
|
|
@@ -235,6 +285,105 @@ function createLookupResourceWithCustomContainerKeyFixture() {
|
|
|
235
285
|
};
|
|
236
286
|
}
|
|
237
287
|
|
|
288
|
+
function createCollectionLookupResourceFixture() {
|
|
289
|
+
return {
|
|
290
|
+
resource: "contacts",
|
|
291
|
+
tableName: "contacts_table",
|
|
292
|
+
idColumn: "contact_id",
|
|
293
|
+
operations: {
|
|
294
|
+
view: {
|
|
295
|
+
outputValidator: {
|
|
296
|
+
schema: {
|
|
297
|
+
type: "object",
|
|
298
|
+
properties: {
|
|
299
|
+
id: { type: "integer" },
|
|
300
|
+
firstName: { type: "string" },
|
|
301
|
+
lookups: {
|
|
302
|
+
type: "object"
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
},
|
|
308
|
+
create: {
|
|
309
|
+
bodyValidator: {
|
|
310
|
+
schema: {
|
|
311
|
+
type: "object",
|
|
312
|
+
properties: {
|
|
313
|
+
firstName: { type: "string" }
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
},
|
|
319
|
+
fieldMeta: [
|
|
320
|
+
{
|
|
321
|
+
key: "id",
|
|
322
|
+
dbColumn: "contact_id"
|
|
323
|
+
},
|
|
324
|
+
{
|
|
325
|
+
key: "pets",
|
|
326
|
+
relation: {
|
|
327
|
+
kind: "collection",
|
|
328
|
+
namespace: "pets",
|
|
329
|
+
foreignKey: "customerId",
|
|
330
|
+
parentValueKey: "id"
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
]
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function createPetsLookupBackToContactsResourceFixture() {
|
|
338
|
+
return {
|
|
339
|
+
resource: "pets",
|
|
340
|
+
tableName: "pets_table",
|
|
341
|
+
idColumn: "pet_id",
|
|
342
|
+
operations: {
|
|
343
|
+
view: {
|
|
344
|
+
outputValidator: {
|
|
345
|
+
schema: {
|
|
346
|
+
type: "object",
|
|
347
|
+
properties: {
|
|
348
|
+
id: { type: "integer" },
|
|
349
|
+
name: { type: "string" },
|
|
350
|
+
customerId: { type: "integer" },
|
|
351
|
+
lookups: {
|
|
352
|
+
type: "object"
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
},
|
|
358
|
+
create: {
|
|
359
|
+
bodyValidator: {
|
|
360
|
+
schema: {
|
|
361
|
+
type: "object",
|
|
362
|
+
properties: {
|
|
363
|
+
name: { type: "string" }
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
},
|
|
369
|
+
fieldMeta: [
|
|
370
|
+
{
|
|
371
|
+
key: "id",
|
|
372
|
+
dbColumn: "pet_id"
|
|
373
|
+
},
|
|
374
|
+
{
|
|
375
|
+
key: "customerId",
|
|
376
|
+
dbColumn: "customer_id",
|
|
377
|
+
relation: {
|
|
378
|
+
kind: "lookup",
|
|
379
|
+
namespace: "contacts",
|
|
380
|
+
valueKey: "id"
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
]
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
|
|
238
387
|
function createNormalizedResourceFixture() {
|
|
239
388
|
return {
|
|
240
389
|
resource: "contacts",
|
|
@@ -279,6 +428,58 @@ function createNormalizedResourceFixture() {
|
|
|
279
428
|
};
|
|
280
429
|
}
|
|
281
430
|
|
|
431
|
+
function createWritableHookResourceFixture() {
|
|
432
|
+
return {
|
|
433
|
+
resource: "contacts",
|
|
434
|
+
tableName: "contacts_table",
|
|
435
|
+
idColumn: "contact_id",
|
|
436
|
+
operations: {
|
|
437
|
+
view: {
|
|
438
|
+
outputValidator: {
|
|
439
|
+
schema: {
|
|
440
|
+
type: "object",
|
|
441
|
+
properties: {
|
|
442
|
+
id: { type: "integer" },
|
|
443
|
+
firstName: { type: "string" },
|
|
444
|
+
createdAt: { type: "string" },
|
|
445
|
+
updatedAt: { type: "string" }
|
|
446
|
+
},
|
|
447
|
+
required: ["id", "firstName", "createdAt", "updatedAt"]
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
},
|
|
451
|
+
create: {
|
|
452
|
+
bodyValidator: {
|
|
453
|
+
schema: {
|
|
454
|
+
type: "object",
|
|
455
|
+
properties: {
|
|
456
|
+
firstName: { type: "string" }
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
},
|
|
462
|
+
fieldMeta: [
|
|
463
|
+
{
|
|
464
|
+
key: "id",
|
|
465
|
+
dbColumn: "contact_id"
|
|
466
|
+
},
|
|
467
|
+
{
|
|
468
|
+
key: "firstName",
|
|
469
|
+
dbColumn: "first_name"
|
|
470
|
+
},
|
|
471
|
+
{
|
|
472
|
+
key: "createdAt",
|
|
473
|
+
dbColumn: "created_at"
|
|
474
|
+
},
|
|
475
|
+
{
|
|
476
|
+
key: "updatedAt",
|
|
477
|
+
dbColumn: "updated_at"
|
|
478
|
+
}
|
|
479
|
+
]
|
|
480
|
+
};
|
|
481
|
+
}
|
|
482
|
+
|
|
282
483
|
test("createCrudRepositoryFromResource requires table metadata from resource", () => {
|
|
283
484
|
assert.throws(
|
|
284
485
|
() =>
|
|
@@ -389,6 +590,42 @@ test("createCrudRepositoryFromResource exposes listByIds for lookup providers",
|
|
|
389
590
|
assert.ok(calls.some((call) => call[0] === "whereIn" && call[1] === "contact_id"));
|
|
390
591
|
});
|
|
391
592
|
|
|
593
|
+
test("createCrudRepositoryFromResource exposes listByForeignIds for arbitrary output keys", async () => {
|
|
594
|
+
const createRepository = createCrudRepositoryFromResource(createResourceFixture());
|
|
595
|
+
const { knex, calls } = createListKnexDouble([
|
|
596
|
+
{
|
|
597
|
+
contact_id: 3,
|
|
598
|
+
first_name: "Tony"
|
|
599
|
+
}
|
|
600
|
+
]);
|
|
601
|
+
const repository = createRepository(knex);
|
|
602
|
+
|
|
603
|
+
const records = await repository.listByForeignIds(["Tony"], "firstName");
|
|
604
|
+
|
|
605
|
+
assert.equal(records.length, 1);
|
|
606
|
+
assert.deepEqual(records[0], {
|
|
607
|
+
id: 3,
|
|
608
|
+
firstName: "Tony"
|
|
609
|
+
});
|
|
610
|
+
assert.ok(calls.some((call) => call[0] === "whereIn" && call[1] === "first_name"));
|
|
611
|
+
});
|
|
612
|
+
|
|
613
|
+
test("createCrudRepositoryFromResource listByForeignIds requires a foreignKey", async () => {
|
|
614
|
+
const createRepository = createCrudRepositoryFromResource(createResourceFixture());
|
|
615
|
+
const { knex } = createListKnexDouble([
|
|
616
|
+
{
|
|
617
|
+
contact_id: 3,
|
|
618
|
+
first_name: "Tony"
|
|
619
|
+
}
|
|
620
|
+
]);
|
|
621
|
+
const repository = createRepository(knex);
|
|
622
|
+
|
|
623
|
+
await assert.rejects(
|
|
624
|
+
() => repository.listByForeignIds([3], ""),
|
|
625
|
+
/listByForeignIds requires foreignKey/
|
|
626
|
+
);
|
|
627
|
+
});
|
|
628
|
+
|
|
392
629
|
test("createCrudRepositoryFromResource listByIds fails fast when valueKey is not in output schema", async () => {
|
|
393
630
|
const createRepository = createCrudRepositoryFromResource(createResourceFixture());
|
|
394
631
|
const { knex } = createListKnexDouble([
|
|
@@ -649,14 +886,8 @@ test("createCrudRepositoryFromResource forwards wildcard nested include paths to
|
|
|
649
886
|
assert.equal(lookupCalls[0].options.include, "*");
|
|
650
887
|
});
|
|
651
888
|
|
|
652
|
-
test("createCrudRepositoryFromResource
|
|
653
|
-
const
|
|
654
|
-
resource.contract = {
|
|
655
|
-
lookup: {
|
|
656
|
-
maxDepth: 5
|
|
657
|
-
}
|
|
658
|
-
};
|
|
659
|
-
const createRepository = createCrudRepositoryFromResource(resource);
|
|
889
|
+
test("createCrudRepositoryFromResource remaps child lookup visibility for public child providers", async () => {
|
|
890
|
+
const createRepository = createCrudRepositoryFromResource(createLookupResourceFixture());
|
|
660
891
|
const { knex } = createListKnexDouble([
|
|
661
892
|
{
|
|
662
893
|
contact_id: 3,
|
|
@@ -670,6 +901,7 @@ test("createCrudRepositoryFromResource forwards configured lookup maxDepth to ch
|
|
|
670
901
|
const repository = createRepository(knex, {
|
|
671
902
|
resolveLookupProvider() {
|
|
672
903
|
return {
|
|
904
|
+
ownershipFilter: "public",
|
|
673
905
|
async listByIds(ids = [], options = {}) {
|
|
674
906
|
lookupCalls.push({
|
|
675
907
|
ids,
|
|
@@ -681,12 +913,20 @@ test("createCrudRepositoryFromResource forwards configured lookup maxDepth to ch
|
|
|
681
913
|
}
|
|
682
914
|
});
|
|
683
915
|
|
|
684
|
-
await repository.list({}
|
|
916
|
+
await repository.list({}, {
|
|
917
|
+
visibilityContext: {
|
|
918
|
+
visibility: "workspace",
|
|
919
|
+
scopeOwnerId: "workspace-1"
|
|
920
|
+
}
|
|
921
|
+
});
|
|
922
|
+
|
|
685
923
|
assert.equal(lookupCalls.length, 1);
|
|
686
|
-
assert.
|
|
924
|
+
assert.deepEqual(lookupCalls[0].options.visibilityContext, {
|
|
925
|
+
visibility: "public"
|
|
926
|
+
});
|
|
687
927
|
});
|
|
688
928
|
|
|
689
|
-
test("createCrudRepositoryFromResource
|
|
929
|
+
test("createCrudRepositoryFromResource remaps child lookup visibility for workspace child providers", async () => {
|
|
690
930
|
const createRepository = createCrudRepositoryFromResource(createLookupResourceFixture());
|
|
691
931
|
const { knex } = createListKnexDouble([
|
|
692
932
|
{
|
|
@@ -696,15 +936,39 @@ test("createCrudRepositoryFromResource throws when include requires lookups and
|
|
|
696
936
|
secondary_vet_id: 12
|
|
697
937
|
}
|
|
698
938
|
]);
|
|
699
|
-
const repository = createRepository(knex);
|
|
700
939
|
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
940
|
+
const lookupCalls = [];
|
|
941
|
+
const repository = createRepository(knex, {
|
|
942
|
+
resolveLookupProvider() {
|
|
943
|
+
return {
|
|
944
|
+
ownershipFilter: "workspace",
|
|
945
|
+
async listByIds(ids = [], options = {}) {
|
|
946
|
+
lookupCalls.push({
|
|
947
|
+
ids,
|
|
948
|
+
options
|
|
949
|
+
});
|
|
950
|
+
return [{ id: 10, name: "Vet A" }, { id: 12, name: "Vet B" }];
|
|
951
|
+
}
|
|
952
|
+
};
|
|
953
|
+
}
|
|
954
|
+
});
|
|
955
|
+
|
|
956
|
+
await repository.list({}, {
|
|
957
|
+
visibilityContext: {
|
|
958
|
+
visibility: "workspace_user",
|
|
959
|
+
scopeOwnerId: "workspace-1",
|
|
960
|
+
userOwnerId: "user-1"
|
|
961
|
+
}
|
|
962
|
+
});
|
|
963
|
+
|
|
964
|
+
assert.equal(lookupCalls.length, 1);
|
|
965
|
+
assert.deepEqual(lookupCalls[0].options.visibilityContext, {
|
|
966
|
+
visibility: "workspace",
|
|
967
|
+
scopeOwnerId: "workspace-1"
|
|
968
|
+
});
|
|
705
969
|
});
|
|
706
970
|
|
|
707
|
-
test("createCrudRepositoryFromResource
|
|
971
|
+
test("createCrudRepositoryFromResource requires child lookup ownershipFilter under non-public visibility", async () => {
|
|
708
972
|
const createRepository = createCrudRepositoryFromResource(createLookupResourceFixture());
|
|
709
973
|
const { knex } = createListKnexDouble([
|
|
710
974
|
{
|
|
@@ -725,7 +989,763 @@ test("createCrudRepositoryFromResource throws when include references unknown lo
|
|
|
725
989
|
});
|
|
726
990
|
|
|
727
991
|
await assert.rejects(
|
|
728
|
-
() =>
|
|
729
|
-
|
|
992
|
+
() =>
|
|
993
|
+
repository.list({}, {
|
|
994
|
+
visibilityContext: {
|
|
995
|
+
visibility: "workspace",
|
|
996
|
+
scopeOwnerId: "workspace-1"
|
|
997
|
+
}
|
|
998
|
+
}),
|
|
999
|
+
/must declare ownershipFilter/
|
|
730
1000
|
);
|
|
731
1001
|
});
|
|
1002
|
+
|
|
1003
|
+
test("createCrudRepositoryFromResource hydrates collection relations through listByIds valueKey", async () => {
|
|
1004
|
+
const createRepository = createCrudRepositoryFromResource(createCollectionLookupResourceFixture());
|
|
1005
|
+
const { knex } = createListKnexDouble([
|
|
1006
|
+
{
|
|
1007
|
+
contact_id: 3,
|
|
1008
|
+
first_name: "Tony"
|
|
1009
|
+
},
|
|
1010
|
+
{
|
|
1011
|
+
contact_id: 4,
|
|
1012
|
+
first_name: "Sara"
|
|
1013
|
+
}
|
|
1014
|
+
]);
|
|
1015
|
+
|
|
1016
|
+
const lookupCalls = [];
|
|
1017
|
+
const repository = createRepository(knex, {
|
|
1018
|
+
resolveLookupProvider(relation = {}) {
|
|
1019
|
+
assert.equal(relation.namespace, "pets");
|
|
1020
|
+
return {
|
|
1021
|
+
async listByIds(ids = [], options = {}) {
|
|
1022
|
+
lookupCalls.push({
|
|
1023
|
+
ids,
|
|
1024
|
+
options
|
|
1025
|
+
});
|
|
1026
|
+
return [
|
|
1027
|
+
{ id: 11, name: "Milo", customerId: 3 },
|
|
1028
|
+
{ id: 12, name: "Luna", customerId: 3 },
|
|
1029
|
+
{ id: 20, name: "Ruby", customerId: 4 }
|
|
1030
|
+
];
|
|
1031
|
+
}
|
|
1032
|
+
};
|
|
1033
|
+
}
|
|
1034
|
+
});
|
|
1035
|
+
|
|
1036
|
+
const result = await repository.list({
|
|
1037
|
+
include: "pets"
|
|
1038
|
+
});
|
|
1039
|
+
|
|
1040
|
+
assert.equal(lookupCalls.length, 1);
|
|
1041
|
+
assert.deepEqual(lookupCalls[0].ids, [3, 4]);
|
|
1042
|
+
assert.equal(lookupCalls[0].options.include, "none");
|
|
1043
|
+
assert.equal(lookupCalls[0].options.valueKey, "customerId");
|
|
1044
|
+
assert.deepEqual(result.items[0].lookups?.pets?.map((item) => item.name), ["Milo", "Luna"]);
|
|
1045
|
+
assert.deepEqual(result.items[1].lookups?.pets?.map((item) => item.name), ["Ruby"]);
|
|
1046
|
+
});
|
|
1047
|
+
|
|
1048
|
+
test("createCrudRepositoryFromResource blocks recursive lookup hydration across collection back-references", async () => {
|
|
1049
|
+
const createContactsRepository = createCrudRepositoryFromResource(createCollectionLookupResourceFixture());
|
|
1050
|
+
const createPetsRepository = createCrudRepositoryFromResource(createPetsLookupBackToContactsResourceFixture());
|
|
1051
|
+
const { knex: contactsKnex } = createListKnexDouble([
|
|
1052
|
+
{
|
|
1053
|
+
contact_id: 3,
|
|
1054
|
+
first_name: "Tony"
|
|
1055
|
+
}
|
|
1056
|
+
]);
|
|
1057
|
+
const { knex: petsKnex } = createListKnexDouble([
|
|
1058
|
+
{
|
|
1059
|
+
pet_id: 11,
|
|
1060
|
+
name: "Milo",
|
|
1061
|
+
customer_id: 3
|
|
1062
|
+
}
|
|
1063
|
+
]);
|
|
1064
|
+
|
|
1065
|
+
let contactsRepository = null;
|
|
1066
|
+
let petsRepository = null;
|
|
1067
|
+
let contactsLookupCallCount = 0;
|
|
1068
|
+
let receivedPetsLookupOptions = null;
|
|
1069
|
+
|
|
1070
|
+
petsRepository = createPetsRepository(petsKnex, {
|
|
1071
|
+
resolveLookupProvider(relation = {}) {
|
|
1072
|
+
if (relation.namespace !== "contacts") {
|
|
1073
|
+
throw new Error(`unexpected relation namespace: ${relation.namespace}`);
|
|
1074
|
+
}
|
|
1075
|
+
contactsLookupCallCount += 1;
|
|
1076
|
+
return contactsRepository;
|
|
1077
|
+
}
|
|
1078
|
+
});
|
|
1079
|
+
|
|
1080
|
+
contactsRepository = createContactsRepository(contactsKnex, {
|
|
1081
|
+
resolveLookupProvider(relation = {}) {
|
|
1082
|
+
if (relation.namespace !== "pets") {
|
|
1083
|
+
throw new Error(`unexpected relation namespace: ${relation.namespace}`);
|
|
1084
|
+
}
|
|
1085
|
+
return {
|
|
1086
|
+
async listByIds(ids = [], options = {}) {
|
|
1087
|
+
receivedPetsLookupOptions = options;
|
|
1088
|
+
return petsRepository.listByIds(ids, options);
|
|
1089
|
+
}
|
|
1090
|
+
};
|
|
1091
|
+
}
|
|
1092
|
+
});
|
|
1093
|
+
|
|
1094
|
+
const result = await contactsRepository.list({
|
|
1095
|
+
include: "pets.*"
|
|
1096
|
+
});
|
|
1097
|
+
|
|
1098
|
+
assert.equal(contactsLookupCallCount, 0);
|
|
1099
|
+
assert.deepEqual(result.items[0].lookups?.pets?.map((item) => item.name), ["Milo"]);
|
|
1100
|
+
assert.ok(Array.isArray(receivedPetsLookupOptions?.lookupVisitedNamespaces));
|
|
1101
|
+
assert.ok(receivedPetsLookupOptions.lookupVisitedNamespaces.includes("contacts"));
|
|
1102
|
+
});
|
|
1103
|
+
|
|
1104
|
+
test("createCrudRepositoryFromResource forwards configured lookup maxDepth to child repositories", async () => {
|
|
1105
|
+
const resource = createLookupResourceFixture();
|
|
1106
|
+
resource.contract = {
|
|
1107
|
+
lookup: {
|
|
1108
|
+
maxDepth: 5
|
|
1109
|
+
}
|
|
1110
|
+
};
|
|
1111
|
+
const createRepository = createCrudRepositoryFromResource(resource);
|
|
1112
|
+
const { knex } = createListKnexDouble([
|
|
1113
|
+
{
|
|
1114
|
+
contact_id: 3,
|
|
1115
|
+
first_name: "Tony",
|
|
1116
|
+
primary_vet_id: 10,
|
|
1117
|
+
secondary_vet_id: 12
|
|
1118
|
+
}
|
|
1119
|
+
]);
|
|
1120
|
+
|
|
1121
|
+
const lookupCalls = [];
|
|
1122
|
+
const repository = createRepository(knex, {
|
|
1123
|
+
resolveLookupProvider() {
|
|
1124
|
+
return {
|
|
1125
|
+
async listByIds(ids = [], options = {}) {
|
|
1126
|
+
lookupCalls.push({
|
|
1127
|
+
ids,
|
|
1128
|
+
options
|
|
1129
|
+
});
|
|
1130
|
+
return [{ id: 10, name: "Vet A" }, { id: 12, name: "Vet B" }];
|
|
1131
|
+
}
|
|
1132
|
+
};
|
|
1133
|
+
}
|
|
1134
|
+
});
|
|
1135
|
+
|
|
1136
|
+
await repository.list({});
|
|
1137
|
+
assert.equal(lookupCalls.length, 1);
|
|
1138
|
+
assert.equal(lookupCalls[0].options.lookupMaxDepth, 5);
|
|
1139
|
+
});
|
|
1140
|
+
|
|
1141
|
+
test("createCrudRepositoryFromResource throws when include requires lookups and resolver is missing", async () => {
|
|
1142
|
+
const createRepository = createCrudRepositoryFromResource(createLookupResourceFixture());
|
|
1143
|
+
const { knex } = createListKnexDouble([
|
|
1144
|
+
{
|
|
1145
|
+
contact_id: 3,
|
|
1146
|
+
first_name: "Tony",
|
|
1147
|
+
primary_vet_id: 10,
|
|
1148
|
+
secondary_vet_id: 12
|
|
1149
|
+
}
|
|
1150
|
+
]);
|
|
1151
|
+
const repository = createRepository(knex);
|
|
1152
|
+
|
|
1153
|
+
await assert.rejects(
|
|
1154
|
+
() => repository.list({}),
|
|
1155
|
+
/requires resolveLookupProvider/
|
|
1156
|
+
);
|
|
1157
|
+
});
|
|
1158
|
+
|
|
1159
|
+
test("createCrudRepositoryFromResource throws when include references unknown lookup field", async () => {
|
|
1160
|
+
const createRepository = createCrudRepositoryFromResource(createLookupResourceFixture());
|
|
1161
|
+
const { knex } = createListKnexDouble([
|
|
1162
|
+
{
|
|
1163
|
+
contact_id: 3,
|
|
1164
|
+
first_name: "Tony",
|
|
1165
|
+
primary_vet_id: 10,
|
|
1166
|
+
secondary_vet_id: 12
|
|
1167
|
+
}
|
|
1168
|
+
]);
|
|
1169
|
+
const repository = createRepository(knex, {
|
|
1170
|
+
resolveLookupProvider() {
|
|
1171
|
+
return {
|
|
1172
|
+
async listByIds() {
|
|
1173
|
+
return [];
|
|
1174
|
+
}
|
|
1175
|
+
};
|
|
1176
|
+
}
|
|
1177
|
+
});
|
|
1178
|
+
|
|
1179
|
+
await assert.rejects(
|
|
1180
|
+
() => repository.list({ include: "unknownLookupKey" }),
|
|
1181
|
+
/unknown lookup key/
|
|
1182
|
+
);
|
|
1183
|
+
});
|
|
1184
|
+
|
|
1185
|
+
test("createCrudRepositoryFromResource list hooks reject invalid types and unsupported keys", async () => {
|
|
1186
|
+
const createRepository = createCrudRepositoryFromResource(createResourceFixture());
|
|
1187
|
+
const { knex } = createListKnexDouble([
|
|
1188
|
+
{
|
|
1189
|
+
contact_id: 3,
|
|
1190
|
+
first_name: "Tony"
|
|
1191
|
+
}
|
|
1192
|
+
]);
|
|
1193
|
+
const repository = createRepository(knex);
|
|
1194
|
+
|
|
1195
|
+
await assert.rejects(
|
|
1196
|
+
() => repository.list({}, {}, {
|
|
1197
|
+
modifyQuery: "not-a-function"
|
|
1198
|
+
}),
|
|
1199
|
+
/hooks\.modifyQuery must be a function/
|
|
1200
|
+
);
|
|
1201
|
+
|
|
1202
|
+
await assert.rejects(
|
|
1203
|
+
() => repository.list({}, {}, {
|
|
1204
|
+
unsupported() {}
|
|
1205
|
+
}),
|
|
1206
|
+
/does not support hooks\.unsupported/
|
|
1207
|
+
);
|
|
1208
|
+
});
|
|
1209
|
+
|
|
1210
|
+
test("createCrudRepositoryFromResource list hooks cannot replace query builders", async () => {
|
|
1211
|
+
const createRepository = createCrudRepositoryFromResource(createResourceFixture());
|
|
1212
|
+
const { knex } = createListKnexDouble([
|
|
1213
|
+
{
|
|
1214
|
+
contact_id: 3,
|
|
1215
|
+
first_name: "Tony"
|
|
1216
|
+
}
|
|
1217
|
+
]);
|
|
1218
|
+
const repository = createRepository(knex);
|
|
1219
|
+
|
|
1220
|
+
await assert.rejects(
|
|
1221
|
+
() => repository.list({}, {}, {
|
|
1222
|
+
modifyQuery() {
|
|
1223
|
+
return {
|
|
1224
|
+
where() {}
|
|
1225
|
+
};
|
|
1226
|
+
}
|
|
1227
|
+
}),
|
|
1228
|
+
/must mutate the provided query builder/
|
|
1229
|
+
);
|
|
1230
|
+
});
|
|
1231
|
+
|
|
1232
|
+
test("createCrudRepositoryFromResource list hooks share state across afterQuery/transformReturnedRecord/finalizeOutput", async () => {
|
|
1233
|
+
const createRepository = createCrudRepositoryFromResource(createResourceFixture());
|
|
1234
|
+
const { knex } = createListKnexDouble([
|
|
1235
|
+
{
|
|
1236
|
+
contact_id: 3,
|
|
1237
|
+
first_name: "Tony"
|
|
1238
|
+
},
|
|
1239
|
+
{
|
|
1240
|
+
contact_id: 4,
|
|
1241
|
+
first_name: "Sam"
|
|
1242
|
+
}
|
|
1243
|
+
]);
|
|
1244
|
+
const repository = createRepository(knex);
|
|
1245
|
+
|
|
1246
|
+
const result = await repository.list({}, {}, {
|
|
1247
|
+
afterQuery(items = [], context = {}) {
|
|
1248
|
+
const labels = {};
|
|
1249
|
+
for (const item of items) {
|
|
1250
|
+
labels[item.id] = `contact-${item.id}`;
|
|
1251
|
+
}
|
|
1252
|
+
context.state.labels = labels;
|
|
1253
|
+
},
|
|
1254
|
+
transformReturnedRecord(record = {}, context = {}) {
|
|
1255
|
+
return {
|
|
1256
|
+
...record,
|
|
1257
|
+
label: context.state.labels?.[record.id] || null
|
|
1258
|
+
};
|
|
1259
|
+
},
|
|
1260
|
+
finalizeOutput(resultPayload = {}) {
|
|
1261
|
+
return {
|
|
1262
|
+
...resultPayload,
|
|
1263
|
+
itemCount: Array.isArray(resultPayload.items) ? resultPayload.items.length : 0
|
|
1264
|
+
};
|
|
1265
|
+
}
|
|
1266
|
+
});
|
|
1267
|
+
|
|
1268
|
+
assert.equal(result.itemCount, 2);
|
|
1269
|
+
assert.deepEqual(result.items.map((item) => item.label), ["contact-3", "contact-4"]);
|
|
1270
|
+
});
|
|
1271
|
+
|
|
1272
|
+
test("createCrudRepositoryFromResource list finalizeOutput requires canonical result keys", async () => {
|
|
1273
|
+
const createRepository = createCrudRepositoryFromResource(createResourceFixture());
|
|
1274
|
+
const { knex } = createListKnexDouble([
|
|
1275
|
+
{
|
|
1276
|
+
contact_id: 3,
|
|
1277
|
+
first_name: "Tony"
|
|
1278
|
+
}
|
|
1279
|
+
]);
|
|
1280
|
+
const repository = createRepository(knex);
|
|
1281
|
+
|
|
1282
|
+
await assert.rejects(
|
|
1283
|
+
() => repository.list({}, {}, {
|
|
1284
|
+
finalizeOutput() {
|
|
1285
|
+
return {
|
|
1286
|
+
items: []
|
|
1287
|
+
};
|
|
1288
|
+
}
|
|
1289
|
+
}),
|
|
1290
|
+
/required key "nextCursor"/
|
|
1291
|
+
);
|
|
1292
|
+
});
|
|
1293
|
+
|
|
1294
|
+
test("createCrudRepositoryFromResource list hooks reject async return values that replace builder identity", async () => {
|
|
1295
|
+
const createRepository = createCrudRepositoryFromResource(createResourceFixture());
|
|
1296
|
+
const { knex } = createListKnexDouble([
|
|
1297
|
+
{
|
|
1298
|
+
contact_id: 3,
|
|
1299
|
+
first_name: "Tony"
|
|
1300
|
+
}
|
|
1301
|
+
]);
|
|
1302
|
+
const repository = createRepository(knex);
|
|
1303
|
+
|
|
1304
|
+
await assert.rejects(
|
|
1305
|
+
() => repository.list({}, {}, {
|
|
1306
|
+
async modifyQuery(dbQuery) {
|
|
1307
|
+
dbQuery.where("vip", 1);
|
|
1308
|
+
return dbQuery;
|
|
1309
|
+
}
|
|
1310
|
+
}),
|
|
1311
|
+
/must mutate the provided query builder/
|
|
1312
|
+
);
|
|
1313
|
+
});
|
|
1314
|
+
|
|
1315
|
+
test("createCrudRepositoryFromResource list hooks keep visibility and canonical list controls", async () => {
|
|
1316
|
+
const createRepository = createCrudRepositoryFromResource(createResourceFixture());
|
|
1317
|
+
const { knex, calls } = createListKnexDouble([
|
|
1318
|
+
{
|
|
1319
|
+
contact_id: 3,
|
|
1320
|
+
first_name: "Tony"
|
|
1321
|
+
}
|
|
1322
|
+
]);
|
|
1323
|
+
const repository = createRepository(knex);
|
|
1324
|
+
|
|
1325
|
+
await repository.list({
|
|
1326
|
+
cursor: 2,
|
|
1327
|
+
limit: 5
|
|
1328
|
+
}, {
|
|
1329
|
+
visibilityContext: {
|
|
1330
|
+
visibility: "workspace",
|
|
1331
|
+
scopeOwnerId: "workspace-1"
|
|
1332
|
+
}
|
|
1333
|
+
}, {
|
|
1334
|
+
modifyQuery(dbQuery) {
|
|
1335
|
+
dbQuery.orderBy("first_name", "desc").limit(99);
|
|
1336
|
+
return dbQuery;
|
|
1337
|
+
}
|
|
1338
|
+
});
|
|
1339
|
+
|
|
1340
|
+
assert.ok(calls.some((call) => call[0] === "where" && call[1] === "contact_id" && call[2] === ">" && call[3] === 2));
|
|
1341
|
+
assert.ok(calls.some((call) => call[0] === "where" && call[1] === "workspace_owner_id" && call[2] === "workspace-1"));
|
|
1342
|
+
assert.ok(calls.some((call) => call[0] === "clearOrder"));
|
|
1343
|
+
assert.ok(calls.some((call) => call[0] === "clear" && call[1] === "limit"));
|
|
1344
|
+
assert.ok(calls.some((call) => call[0] === "orderBy" && call[1] === "contact_id" && call[2] === "asc"));
|
|
1345
|
+
assert.ok(calls.some((call) => call[0] === "limit" && call[1] === 6));
|
|
1346
|
+
});
|
|
1347
|
+
|
|
1348
|
+
test("createCrudRepositoryFromResource findById hooks keep visibility and id predicates", async () => {
|
|
1349
|
+
const createRepository = createCrudRepositoryFromResource(createResourceFixture());
|
|
1350
|
+
const { knex, calls } = createListKnexDouble([
|
|
1351
|
+
{
|
|
1352
|
+
contact_id: 7,
|
|
1353
|
+
first_name: "Tony"
|
|
1354
|
+
}
|
|
1355
|
+
]);
|
|
1356
|
+
const repository = createRepository(knex);
|
|
1357
|
+
|
|
1358
|
+
await repository.findById(7, {
|
|
1359
|
+
visibilityContext: {
|
|
1360
|
+
visibility: "workspace",
|
|
1361
|
+
scopeOwnerId: "workspace-1"
|
|
1362
|
+
}
|
|
1363
|
+
}, {
|
|
1364
|
+
modifyQuery(dbQuery) {
|
|
1365
|
+
dbQuery.where("contact_id", 999);
|
|
1366
|
+
return dbQuery;
|
|
1367
|
+
}
|
|
1368
|
+
});
|
|
1369
|
+
|
|
1370
|
+
assert.ok(calls.some((call) => call[0] === "where" && call[1] === "contact_id" && call[2] === 999));
|
|
1371
|
+
assert.ok(calls.some((call) => call[0] === "where" && call[1] === "workspace_owner_id" && call[2] === "workspace-1"));
|
|
1372
|
+
assert.ok(calls.some((call) => call[0] === "where" && call[1]?.contact_id === 7));
|
|
1373
|
+
});
|
|
1374
|
+
|
|
1375
|
+
test("createCrudRepositoryFromResource findById hooks share state between query and transformReturnedRecord", async () => {
|
|
1376
|
+
const createRepository = createCrudRepositoryFromResource(createResourceFixture());
|
|
1377
|
+
const { knex } = createListKnexDouble([
|
|
1378
|
+
{
|
|
1379
|
+
contact_id: 7,
|
|
1380
|
+
first_name: "Tony"
|
|
1381
|
+
}
|
|
1382
|
+
]);
|
|
1383
|
+
const repository = createRepository(knex);
|
|
1384
|
+
|
|
1385
|
+
const record = await repository.findById(7, {}, {
|
|
1386
|
+
modifyQuery(_dbQuery, context = {}) {
|
|
1387
|
+
context.state.recordTag = "from-state";
|
|
1388
|
+
},
|
|
1389
|
+
afterQuery(records = [], context = {}) {
|
|
1390
|
+
if (records.length > 0) {
|
|
1391
|
+
context.state.afterQuerySeen = true;
|
|
1392
|
+
}
|
|
1393
|
+
},
|
|
1394
|
+
transformReturnedRecord(resultRecord = {}, context = {}) {
|
|
1395
|
+
return {
|
|
1396
|
+
...resultRecord,
|
|
1397
|
+
recordTag: context.state.recordTag,
|
|
1398
|
+
afterQuerySeen: context.state.afterQuerySeen || false
|
|
1399
|
+
};
|
|
1400
|
+
}
|
|
1401
|
+
});
|
|
1402
|
+
|
|
1403
|
+
assert.equal(record.recordTag, "from-state");
|
|
1404
|
+
assert.equal(record.afterQuerySeen, true);
|
|
1405
|
+
});
|
|
1406
|
+
|
|
1407
|
+
test("createCrudRepositoryFromResource listByIds hooks support afterQuery/transformReturnedRecord/finalizeOutput", async () => {
|
|
1408
|
+
const createRepository = createCrudRepositoryFromResource(createResourceFixture());
|
|
1409
|
+
const { knex } = createListKnexDouble([
|
|
1410
|
+
{
|
|
1411
|
+
contact_id: 3,
|
|
1412
|
+
first_name: "Tony"
|
|
1413
|
+
},
|
|
1414
|
+
{
|
|
1415
|
+
contact_id: 4,
|
|
1416
|
+
first_name: "Sam"
|
|
1417
|
+
}
|
|
1418
|
+
]);
|
|
1419
|
+
const repository = createRepository(knex);
|
|
1420
|
+
|
|
1421
|
+
const items = await repository.listByIds([3, 4], {}, {
|
|
1422
|
+
afterQuery(records = [], context = {}) {
|
|
1423
|
+
const namesById = {};
|
|
1424
|
+
for (const record of records) {
|
|
1425
|
+
namesById[record.id] = record.firstName.toUpperCase();
|
|
1426
|
+
}
|
|
1427
|
+
context.state.namesById = namesById;
|
|
1428
|
+
},
|
|
1429
|
+
transformReturnedRecord(record = {}, context = {}) {
|
|
1430
|
+
return {
|
|
1431
|
+
...record,
|
|
1432
|
+
nameTag: context.state.namesById?.[record.id] || null
|
|
1433
|
+
};
|
|
1434
|
+
},
|
|
1435
|
+
finalizeOutput(records = []) {
|
|
1436
|
+
return [...records].reverse();
|
|
1437
|
+
}
|
|
1438
|
+
});
|
|
1439
|
+
|
|
1440
|
+
assert.deepEqual(items.map((item) => item.id), [4, 3]);
|
|
1441
|
+
assert.deepEqual(items.map((item) => item.nameTag), ["SAM", "TONY"]);
|
|
1442
|
+
});
|
|
1443
|
+
|
|
1444
|
+
test("createCrudRepositoryFromResource create hooks keep write-key filtering and ownership stamping", async () => {
|
|
1445
|
+
const createRepository = createCrudRepositoryFromResource(createWritableHookResourceFixture());
|
|
1446
|
+
const { knex, state } = createListKnexDouble([
|
|
1447
|
+
{
|
|
1448
|
+
contact_id: 11,
|
|
1449
|
+
first_name: "Tony",
|
|
1450
|
+
created_at: "2026-01-01 00:00:00",
|
|
1451
|
+
updated_at: "2026-01-01 00:00:00"
|
|
1452
|
+
}
|
|
1453
|
+
], {
|
|
1454
|
+
insertResult: [11]
|
|
1455
|
+
});
|
|
1456
|
+
const repository = createRepository(knex);
|
|
1457
|
+
|
|
1458
|
+
await repository.create({
|
|
1459
|
+
firstName: "Tony"
|
|
1460
|
+
}, {
|
|
1461
|
+
visibilityContext: {
|
|
1462
|
+
visibility: "workspace",
|
|
1463
|
+
scopeOwnerId: "workspace-1"
|
|
1464
|
+
}
|
|
1465
|
+
}, {
|
|
1466
|
+
modifyPayload(payload = {}) {
|
|
1467
|
+
return {
|
|
1468
|
+
...payload,
|
|
1469
|
+
unexpectedField: "blocked",
|
|
1470
|
+
workspaceOwnerId: "blocked"
|
|
1471
|
+
};
|
|
1472
|
+
},
|
|
1473
|
+
modifyQuery(_dbQuery, context = {}) {
|
|
1474
|
+
if (context.payload && typeof context.payload === "object") {
|
|
1475
|
+
context.payload.unexpectedFieldFromQuery = "blocked";
|
|
1476
|
+
}
|
|
1477
|
+
}
|
|
1478
|
+
});
|
|
1479
|
+
|
|
1480
|
+
assert.equal(state.insertPayloads.length, 1);
|
|
1481
|
+
assert.deepEqual(state.insertPayloads[0].first_name, "Tony");
|
|
1482
|
+
assert.equal(Object.hasOwn(state.insertPayloads[0], "unexpectedField"), false);
|
|
1483
|
+
assert.equal(Object.hasOwn(state.insertPayloads[0], "unexpectedFieldFromQuery"), false);
|
|
1484
|
+
assert.equal(Object.hasOwn(state.insertPayloads[0], "workspaceOwnerId"), false);
|
|
1485
|
+
assert.equal(state.insertPayloads[0].workspace_owner_id, "workspace-1");
|
|
1486
|
+
assert.ok(state.insertPayloads[0].created_at);
|
|
1487
|
+
assert.ok(state.insertPayloads[0].updated_at);
|
|
1488
|
+
});
|
|
1489
|
+
|
|
1490
|
+
test("createCrudRepositoryFromResource create hooks reject read-phase hook keys", async () => {
|
|
1491
|
+
const createRepository = createCrudRepositoryFromResource(createWritableHookResourceFixture());
|
|
1492
|
+
const { knex } = createListKnexDouble([
|
|
1493
|
+
{
|
|
1494
|
+
contact_id: 11,
|
|
1495
|
+
first_name: "Tony",
|
|
1496
|
+
created_at: "2026-01-01 00:00:00",
|
|
1497
|
+
updated_at: "2026-01-01 00:00:00"
|
|
1498
|
+
}
|
|
1499
|
+
], {
|
|
1500
|
+
insertResult: [11]
|
|
1501
|
+
});
|
|
1502
|
+
const repository = createRepository(knex);
|
|
1503
|
+
|
|
1504
|
+
await assert.rejects(
|
|
1505
|
+
() => repository.create({
|
|
1506
|
+
firstName: "Tony"
|
|
1507
|
+
}, {}, {
|
|
1508
|
+
transformReturnedRecord(record = {}) {
|
|
1509
|
+
return record;
|
|
1510
|
+
}
|
|
1511
|
+
}),
|
|
1512
|
+
/does not support hooks\./
|
|
1513
|
+
);
|
|
1514
|
+
});
|
|
1515
|
+
|
|
1516
|
+
test("createCrudRepositoryFromResource create hooks support afterWrite and canonical findById output pipeline", async () => {
|
|
1517
|
+
const createRepository = createCrudRepositoryFromResource(createWritableHookResourceFixture());
|
|
1518
|
+
const { knex } = createListKnexDouble([
|
|
1519
|
+
{
|
|
1520
|
+
contact_id: 11,
|
|
1521
|
+
first_name: "Tony",
|
|
1522
|
+
created_at: "2026-01-01 00:00:00",
|
|
1523
|
+
updated_at: "2026-01-01 00:00:00"
|
|
1524
|
+
}
|
|
1525
|
+
], {
|
|
1526
|
+
insertResult: [11]
|
|
1527
|
+
});
|
|
1528
|
+
const repository = createRepository(knex);
|
|
1529
|
+
const afterWriteCalls = [];
|
|
1530
|
+
|
|
1531
|
+
const record = await repository.create({
|
|
1532
|
+
firstName: "Tony"
|
|
1533
|
+
}, {}, {
|
|
1534
|
+
modifyPayload(payload = {}, context = {}) {
|
|
1535
|
+
context.state.createdName = payload.firstName;
|
|
1536
|
+
return payload;
|
|
1537
|
+
},
|
|
1538
|
+
afterWrite(meta = {}, context = {}) {
|
|
1539
|
+
afterWriteCalls.push({
|
|
1540
|
+
operation: meta.operation,
|
|
1541
|
+
createdName: context.state.createdName,
|
|
1542
|
+
recordId: meta.recordId
|
|
1543
|
+
});
|
|
1544
|
+
}
|
|
1545
|
+
});
|
|
1546
|
+
|
|
1547
|
+
assert.equal(record.firstName, "Tony");
|
|
1548
|
+
assert.equal(Object.hasOwn(record, "createdName"), false);
|
|
1549
|
+
assert.equal(afterWriteCalls.length, 1);
|
|
1550
|
+
assert.equal(afterWriteCalls[0].operation, "create");
|
|
1551
|
+
assert.equal(afterWriteCalls[0].createdName, "Tony");
|
|
1552
|
+
assert.equal(afterWriteCalls[0].recordId, 11);
|
|
1553
|
+
});
|
|
1554
|
+
|
|
1555
|
+
test("createCrudRepositoryFromResource update hooks keep write-key filtering and by-id visibility constraints", async () => {
|
|
1556
|
+
const createRepository = createCrudRepositoryFromResource(createWritableHookResourceFixture());
|
|
1557
|
+
const { knex, calls, state } = createListKnexDouble([
|
|
1558
|
+
{
|
|
1559
|
+
contact_id: 11,
|
|
1560
|
+
first_name: "Tony",
|
|
1561
|
+
created_at: "2026-01-01 00:00:00",
|
|
1562
|
+
updated_at: "2026-01-01 00:00:00"
|
|
1563
|
+
}
|
|
1564
|
+
]);
|
|
1565
|
+
const repository = createRepository(knex);
|
|
1566
|
+
|
|
1567
|
+
await repository.updateById(11, {
|
|
1568
|
+
firstName: "Tony"
|
|
1569
|
+
}, {
|
|
1570
|
+
visibilityContext: {
|
|
1571
|
+
visibility: "workspace",
|
|
1572
|
+
scopeOwnerId: "workspace-1"
|
|
1573
|
+
}
|
|
1574
|
+
}, {
|
|
1575
|
+
modifyPatch(patch = {}) {
|
|
1576
|
+
return {
|
|
1577
|
+
...patch,
|
|
1578
|
+
unexpectedField: "blocked"
|
|
1579
|
+
};
|
|
1580
|
+
},
|
|
1581
|
+
modifyQuery(dbQuery, context = {}) {
|
|
1582
|
+
if (context.patch && typeof context.patch === "object") {
|
|
1583
|
+
context.patch.unexpectedFieldFromQuery = "blocked";
|
|
1584
|
+
}
|
|
1585
|
+
dbQuery.where("vip", 1);
|
|
1586
|
+
return dbQuery;
|
|
1587
|
+
}
|
|
1588
|
+
});
|
|
1589
|
+
|
|
1590
|
+
assert.equal(state.updatePayloads.length, 1);
|
|
1591
|
+
assert.deepEqual(state.updatePayloads[0].first_name, "Tony");
|
|
1592
|
+
assert.equal(Object.hasOwn(state.updatePayloads[0], "unexpectedField"), false);
|
|
1593
|
+
assert.equal(Object.hasOwn(state.updatePayloads[0], "unexpectedFieldFromQuery"), false);
|
|
1594
|
+
assert.ok(state.updatePayloads[0].updated_at);
|
|
1595
|
+
assert.ok(calls.some((call) => call[0] === "where" && call[1] === "vip" && call[2] === 1));
|
|
1596
|
+
assert.ok(calls.some((call) => call[0] === "where" && call[1] === "workspace_owner_id" && call[2] === "workspace-1"));
|
|
1597
|
+
assert.ok(calls.some((call) => call[0] === "where" && call[1]?.contact_id === 11));
|
|
1598
|
+
});
|
|
1599
|
+
|
|
1600
|
+
test("createCrudRepositoryFromResource update hooks reject read-phase hook keys", async () => {
|
|
1601
|
+
const createRepository = createCrudRepositoryFromResource(createWritableHookResourceFixture());
|
|
1602
|
+
const { knex } = createListKnexDouble([
|
|
1603
|
+
{
|
|
1604
|
+
contact_id: 11,
|
|
1605
|
+
first_name: "Tony",
|
|
1606
|
+
created_at: "2026-01-01 00:00:00",
|
|
1607
|
+
updated_at: "2026-01-01 00:00:00"
|
|
1608
|
+
}
|
|
1609
|
+
]);
|
|
1610
|
+
const repository = createRepository(knex);
|
|
1611
|
+
|
|
1612
|
+
await assert.rejects(
|
|
1613
|
+
() => repository.updateById(11, {
|
|
1614
|
+
firstName: "Tony"
|
|
1615
|
+
}, {}, {
|
|
1616
|
+
transformReturnedRecord(record = {}) {
|
|
1617
|
+
return record;
|
|
1618
|
+
}
|
|
1619
|
+
}),
|
|
1620
|
+
/does not support hooks\./
|
|
1621
|
+
);
|
|
1622
|
+
});
|
|
1623
|
+
|
|
1624
|
+
test("createCrudRepositoryFromResource update hooks support afterWrite and canonical findById output pipeline", async () => {
|
|
1625
|
+
const createRepository = createCrudRepositoryFromResource(createWritableHookResourceFixture());
|
|
1626
|
+
const { knex } = createListKnexDouble([
|
|
1627
|
+
{
|
|
1628
|
+
contact_id: 11,
|
|
1629
|
+
first_name: "Tony",
|
|
1630
|
+
created_at: "2026-01-01 00:00:00",
|
|
1631
|
+
updated_at: "2026-01-01 00:00:00"
|
|
1632
|
+
}
|
|
1633
|
+
]);
|
|
1634
|
+
const repository = createRepository(knex);
|
|
1635
|
+
const afterWriteCalls = [];
|
|
1636
|
+
|
|
1637
|
+
const record = await repository.updateById(11, {
|
|
1638
|
+
firstName: "Tony"
|
|
1639
|
+
}, {}, {
|
|
1640
|
+
modifyPatch(patch = {}, context = {}) {
|
|
1641
|
+
context.state.patchKeys = Object.keys(patch);
|
|
1642
|
+
return patch;
|
|
1643
|
+
},
|
|
1644
|
+
afterWrite(meta = {}, context = {}) {
|
|
1645
|
+
afterWriteCalls.push({
|
|
1646
|
+
operation: meta.operation,
|
|
1647
|
+
patchKeys: context.state.patchKeys || [],
|
|
1648
|
+
recordId: meta.recordId
|
|
1649
|
+
});
|
|
1650
|
+
}
|
|
1651
|
+
});
|
|
1652
|
+
|
|
1653
|
+
assert.equal(record.firstName, "Tony");
|
|
1654
|
+
assert.equal(Object.hasOwn(record, "patchKeys"), false);
|
|
1655
|
+
assert.equal(afterWriteCalls.length, 1);
|
|
1656
|
+
assert.equal(afterWriteCalls[0].operation, "update");
|
|
1657
|
+
assert.deepEqual(afterWriteCalls[0].patchKeys, ["firstName"]);
|
|
1658
|
+
assert.equal(afterWriteCalls[0].recordId, 11);
|
|
1659
|
+
});
|
|
1660
|
+
|
|
1661
|
+
test("createCrudRepositoryFromResource delete hooks run through callOptions.trx client", async () => {
|
|
1662
|
+
const createRepository = createCrudRepositoryFromResource(createResourceFixture());
|
|
1663
|
+
const baseKnex = createListKnexDouble([
|
|
1664
|
+
{
|
|
1665
|
+
contact_id: 3,
|
|
1666
|
+
first_name: "Tony"
|
|
1667
|
+
}
|
|
1668
|
+
]);
|
|
1669
|
+
const trxKnex = createListKnexDouble([
|
|
1670
|
+
{
|
|
1671
|
+
contact_id: 3,
|
|
1672
|
+
first_name: "Tony"
|
|
1673
|
+
}
|
|
1674
|
+
]);
|
|
1675
|
+
const repository = createRepository(baseKnex.knex);
|
|
1676
|
+
|
|
1677
|
+
await repository.deleteById(3, {
|
|
1678
|
+
trx: trxKnex.knex,
|
|
1679
|
+
visibilityContext: {
|
|
1680
|
+
visibility: "workspace",
|
|
1681
|
+
scopeOwnerId: "workspace-1"
|
|
1682
|
+
}
|
|
1683
|
+
}, {
|
|
1684
|
+
modifyQuery(dbQuery) {
|
|
1685
|
+
dbQuery.where("vip", 1);
|
|
1686
|
+
return dbQuery;
|
|
1687
|
+
}
|
|
1688
|
+
});
|
|
1689
|
+
|
|
1690
|
+
assert.equal(baseKnex.calls.some((call) => call[0] === "table"), false);
|
|
1691
|
+
assert.ok(trxKnex.calls.some((call) => call[0] === "table" && call[1] === "contacts_table"));
|
|
1692
|
+
assert.ok(trxKnex.calls.some((call) => call[0] === "where" && call[1] === "vip" && call[2] === 1));
|
|
1693
|
+
assert.ok(trxKnex.calls.some((call) => call[0] === "delete"));
|
|
1694
|
+
});
|
|
1695
|
+
|
|
1696
|
+
test("createCrudRepositoryFromResource delete hooks support afterWrite", async () => {
|
|
1697
|
+
const createRepository = createCrudRepositoryFromResource(createResourceFixture());
|
|
1698
|
+
const { knex } = createListKnexDouble([
|
|
1699
|
+
{
|
|
1700
|
+
contact_id: 3,
|
|
1701
|
+
first_name: "Tony"
|
|
1702
|
+
}
|
|
1703
|
+
]);
|
|
1704
|
+
const repository = createRepository(knex);
|
|
1705
|
+
const afterWriteCalls = [];
|
|
1706
|
+
|
|
1707
|
+
const result = await repository.deleteById(3, {}, {
|
|
1708
|
+
afterWrite(meta = {}, context = {}) {
|
|
1709
|
+
context.state.deletedId = meta?.output?.id || null;
|
|
1710
|
+
afterWriteCalls.push({
|
|
1711
|
+
operation: meta.operation,
|
|
1712
|
+
deletedId: context.state.deletedId
|
|
1713
|
+
});
|
|
1714
|
+
}
|
|
1715
|
+
});
|
|
1716
|
+
|
|
1717
|
+
assert.equal(result.deleted, true);
|
|
1718
|
+
assert.equal(afterWriteCalls.length, 1);
|
|
1719
|
+
assert.equal(afterWriteCalls[0].operation, "delete");
|
|
1720
|
+
assert.equal(afterWriteCalls[0].deletedId, 3);
|
|
1721
|
+
});
|
|
1722
|
+
|
|
1723
|
+
test("createCrudRepositoryFromResource delete hooks support finalizeOutput for record and null flows", async () => {
|
|
1724
|
+
const createRepository = createCrudRepositoryFromResource(createResourceFixture());
|
|
1725
|
+
const presentKnex = createListKnexDouble([
|
|
1726
|
+
{
|
|
1727
|
+
contact_id: 3,
|
|
1728
|
+
first_name: "Tony"
|
|
1729
|
+
}
|
|
1730
|
+
]);
|
|
1731
|
+
const missingKnex = createListKnexDouble([]);
|
|
1732
|
+
const presentRepository = createRepository(presentKnex.knex);
|
|
1733
|
+
const missingRepository = createRepository(missingKnex.knex);
|
|
1734
|
+
|
|
1735
|
+
const deletedOutput = await presentRepository.deleteById(3, {}, {
|
|
1736
|
+
finalizeOutput(output) {
|
|
1737
|
+
return output
|
|
1738
|
+
? { ...output, status: "deleted" }
|
|
1739
|
+
: output;
|
|
1740
|
+
}
|
|
1741
|
+
});
|
|
1742
|
+
|
|
1743
|
+
const missingOutput = await missingRepository.deleteById(3, {}, {
|
|
1744
|
+
finalizeOutput(output) {
|
|
1745
|
+
return output === null ? { deleted: false } : output;
|
|
1746
|
+
}
|
|
1747
|
+
});
|
|
1748
|
+
|
|
1749
|
+
assert.equal(deletedOutput.status, "deleted");
|
|
1750
|
+
assert.deepEqual(missingOutput, { deleted: false });
|
|
1751
|
+
});
|