@lobb-js/core 0.23.0 → 0.24.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/Lobb.ts +1 -0
- package/src/api/collections/CollectionControllers.ts +17 -0
- package/src/api/collections/collectionStore.ts +682 -24
- package/src/api/meta/service.ts +1 -0
- package/src/config/ConfigManager.ts +130 -7
- package/src/config/validations.ts +13 -5
- package/src/database/drivers/pgDriver/QueryBuilder.ts +3 -141
- package/src/index.ts +1 -1
- package/src/types/collectionServiceSchema.ts +61 -14
- package/src/types/config/collectionFields.ts +18 -0
- package/src/types/config/collectionsConfig.ts +5 -0
- package/src/types/config/relations.ts +22 -3
- package/src/types/index.ts +7 -3
- package/src/workflows/coreWorkflows/processors/hooksWorkflows.ts +29 -5
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { FindAllParams, FindAllParamsOutput, FindOneParams, ChildParams, CreateChildren, UpdateChildren } from "../../types/index.ts";
|
|
2
2
|
import type { DatabaseDriver } from "../../types/index.ts";
|
|
3
3
|
import type { MassReturn } from "../../types/index.ts";
|
|
4
4
|
import type { PoolClient } from "pg";
|
|
5
5
|
import type { Context as HonoContext } from "hono";
|
|
6
6
|
|
|
7
|
-
import { findAllParamsSchema } from "../../types/index.ts";
|
|
7
|
+
import { findAllParamsSchema, findOneParamsSchema } from "../../types/index.ts";
|
|
8
8
|
import { ZodError } from "zod";
|
|
9
9
|
import { LobbError } from "../../LobbError.ts";
|
|
10
10
|
import { Lobb } from "../../Lobb.ts";
|
|
@@ -36,7 +36,7 @@ export class CollectionStore {
|
|
|
36
36
|
client,
|
|
37
37
|
}: {
|
|
38
38
|
collectionName: string;
|
|
39
|
-
params?:
|
|
39
|
+
params?: FindAllParams;
|
|
40
40
|
triggeredBy?: TriggeredBy;
|
|
41
41
|
context?: HonoContext;
|
|
42
42
|
client?: PoolClient;
|
|
@@ -87,12 +87,19 @@ export class CollectionStore {
|
|
|
87
87
|
);
|
|
88
88
|
}
|
|
89
89
|
|
|
90
|
-
const parsedParams = findAllParamsSchema.parse(params);
|
|
90
|
+
const parsedParams = findAllParamsSchema.parse(params ?? {});
|
|
91
|
+
|
|
92
|
+
const resolvedParams: FindAllParamsOutput = {
|
|
93
|
+
...parsedParams,
|
|
94
|
+
limit: parsedParams.limit ?? 100,
|
|
95
|
+
offset: parsedParams.offset ?? 0,
|
|
96
|
+
fields: buildDriverFields(collectionName, parsedParams.fields),
|
|
97
|
+
};
|
|
91
98
|
|
|
92
99
|
try {
|
|
93
100
|
const result = await this.dbDriver.findAll(
|
|
94
101
|
collectionName,
|
|
95
|
-
|
|
102
|
+
resolvedParams,
|
|
96
103
|
client,
|
|
97
104
|
);
|
|
98
105
|
|
|
@@ -108,6 +115,13 @@ export class CollectionStore {
|
|
|
108
115
|
},
|
|
109
116
|
)).data;
|
|
110
117
|
|
|
118
|
+
result.data = await this.resolveFields(collectionName, result.data, parsedParams.fields, client) as any;
|
|
119
|
+
result.data = stripInternalFields(collectionName, parsedParams.fields, result.data) as any;
|
|
120
|
+
|
|
121
|
+
if (parsedParams.children && Object.keys(parsedParams.children).length > 0) {
|
|
122
|
+
result.data = await this.resolveChildren(collectionName, result.data, parsedParams.children, client) as any;
|
|
123
|
+
}
|
|
124
|
+
|
|
111
125
|
return result;
|
|
112
126
|
} catch (error: any) {
|
|
113
127
|
if (error instanceof ZodError) {
|
|
@@ -128,18 +142,22 @@ export class CollectionStore {
|
|
|
128
142
|
public async findOne({
|
|
129
143
|
collectionName,
|
|
130
144
|
id,
|
|
145
|
+
params,
|
|
131
146
|
triggeredBy = "INTERNAL",
|
|
132
147
|
context,
|
|
133
148
|
client,
|
|
134
149
|
}: {
|
|
135
150
|
collectionName: string;
|
|
136
151
|
id: string;
|
|
152
|
+
params?: FindOneParams;
|
|
137
153
|
triggeredBy?: TriggeredBy;
|
|
138
154
|
context?: HonoContext;
|
|
139
155
|
client?: PoolClient;
|
|
140
156
|
}): Promise<ExposedServiceOutput> {
|
|
141
157
|
return await beginTransaction(
|
|
142
158
|
async (client) => {
|
|
159
|
+
const { fields } = findOneParamsSchema.parse(params ?? {});
|
|
160
|
+
|
|
143
161
|
const eventResult: any = await Lobb.instance.eventSystem.emit(
|
|
144
162
|
`core.store.preFindOne`,
|
|
145
163
|
{
|
|
@@ -151,32 +169,27 @@ export class CollectionStore {
|
|
|
151
169
|
},
|
|
152
170
|
);
|
|
153
171
|
|
|
154
|
-
const
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
172
|
+
const queryParams: FindAllParams = {
|
|
173
|
+
fields,
|
|
174
|
+
children: params?.children,
|
|
175
|
+
limit: 1,
|
|
176
|
+
offset: 0,
|
|
177
|
+
filter: { id: eventResult.id },
|
|
158
178
|
};
|
|
159
179
|
|
|
160
180
|
if (eventResult.filter) {
|
|
161
|
-
|
|
162
|
-
params.filter = {
|
|
163
|
-
$and: [
|
|
164
|
-
params.filter,
|
|
165
|
-
eventResult.filter,
|
|
166
|
-
],
|
|
167
|
-
};
|
|
168
|
-
} else {
|
|
169
|
-
params.filter = eventResult.filter;
|
|
170
|
-
}
|
|
181
|
+
queryParams.filter = { $and: [queryParams.filter, eventResult.filter] };
|
|
171
182
|
}
|
|
172
183
|
|
|
173
|
-
const
|
|
174
|
-
|
|
175
|
-
let data = (await this.dbDriver.findAll(
|
|
184
|
+
const findAllResult = await this.findAll({
|
|
176
185
|
collectionName,
|
|
177
|
-
|
|
186
|
+
params: queryParams,
|
|
187
|
+
triggeredBy,
|
|
188
|
+
context,
|
|
178
189
|
client,
|
|
179
|
-
)
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
let data = findAllResult.data[0];
|
|
180
193
|
|
|
181
194
|
if (!data) {
|
|
182
195
|
throw new LobbError({
|
|
@@ -207,12 +220,14 @@ export class CollectionStore {
|
|
|
207
220
|
public async createOne({
|
|
208
221
|
collectionName,
|
|
209
222
|
data,
|
|
223
|
+
children,
|
|
210
224
|
triggeredBy = "INTERNAL",
|
|
211
225
|
context,
|
|
212
226
|
client,
|
|
213
227
|
}: {
|
|
214
228
|
collectionName: string;
|
|
215
229
|
data: Record<string, any>;
|
|
230
|
+
children?: CreateChildren;
|
|
216
231
|
triggeredBy?: TriggeredBy;
|
|
217
232
|
context?: HonoContext;
|
|
218
233
|
client?: PoolClient;
|
|
@@ -221,6 +236,10 @@ export class CollectionStore {
|
|
|
221
236
|
async (client) => {
|
|
222
237
|
await this.handleSingletonCollection(collectionName, context, client);
|
|
223
238
|
|
|
239
|
+
if (!Lobb.instance.configManager.isCollectionVirtual(collectionName)) {
|
|
240
|
+
data = await this.processInlineFKCreates(collectionName, data, triggeredBy, context, client);
|
|
241
|
+
}
|
|
242
|
+
|
|
224
243
|
const result = await Lobb.instance.eventSystem.emit(
|
|
225
244
|
`core.store.preCreateOne`,
|
|
226
245
|
{
|
|
@@ -240,6 +259,7 @@ export class CollectionStore {
|
|
|
240
259
|
);
|
|
241
260
|
}
|
|
242
261
|
|
|
262
|
+
|
|
243
263
|
data = (await Lobb.instance.eventSystem.emit(
|
|
244
264
|
`core.store.createOne`,
|
|
245
265
|
{
|
|
@@ -251,6 +271,10 @@ export class CollectionStore {
|
|
|
251
271
|
},
|
|
252
272
|
)).data;
|
|
253
273
|
|
|
274
|
+
if (children) {
|
|
275
|
+
await this.processChildrenOps(collectionName, data.id, children, triggeredBy, context, client);
|
|
276
|
+
}
|
|
277
|
+
|
|
254
278
|
return {
|
|
255
279
|
data,
|
|
256
280
|
};
|
|
@@ -263,6 +287,7 @@ export class CollectionStore {
|
|
|
263
287
|
collectionName,
|
|
264
288
|
id,
|
|
265
289
|
data,
|
|
290
|
+
children,
|
|
266
291
|
triggeredBy = "INTERNAL",
|
|
267
292
|
context,
|
|
268
293
|
client,
|
|
@@ -270,6 +295,7 @@ export class CollectionStore {
|
|
|
270
295
|
collectionName: string;
|
|
271
296
|
id: string;
|
|
272
297
|
data: Record<string, any> | null;
|
|
298
|
+
children?: UpdateChildren;
|
|
273
299
|
triggeredBy?: TriggeredBy;
|
|
274
300
|
context?: HonoContext;
|
|
275
301
|
client?: PoolClient;
|
|
@@ -305,6 +331,10 @@ export class CollectionStore {
|
|
|
305
331
|
},
|
|
306
332
|
)).data;
|
|
307
333
|
|
|
334
|
+
if (children) {
|
|
335
|
+
await this.processChildrenOps(collectionName, data.id, children, triggeredBy, context, client);
|
|
336
|
+
}
|
|
337
|
+
|
|
308
338
|
return {
|
|
309
339
|
data,
|
|
310
340
|
};
|
|
@@ -636,6 +666,556 @@ export class CollectionStore {
|
|
|
636
666
|
return filteredPayload;
|
|
637
667
|
}
|
|
638
668
|
|
|
669
|
+
private async resolveFields(
|
|
670
|
+
collectionName: string,
|
|
671
|
+
records: Record<string, any>[],
|
|
672
|
+
fields: string | string[] | undefined,
|
|
673
|
+
client: PoolClient,
|
|
674
|
+
): Promise<Record<string, any>[]> {
|
|
675
|
+
const { relations } = parseFieldsToTree(normalizeFields(fields));
|
|
676
|
+
if (Object.keys(relations).length === 0) return records;
|
|
677
|
+
return this.resolveRelations(collectionName, records, relations, client);
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
private async resolveRelations(
|
|
681
|
+
collectionName: string,
|
|
682
|
+
records: Record<string, any>[],
|
|
683
|
+
relations: Record<string, FieldsTree>,
|
|
684
|
+
client: PoolClient,
|
|
685
|
+
): Promise<Record<string, any>[]> {
|
|
686
|
+
const results = records.map((r) => ({ ...r }));
|
|
687
|
+
|
|
688
|
+
for (const [fieldName, subTree] of Object.entries(relations)) {
|
|
689
|
+
const relation = Lobb.instance.configManager.getParentRelation(collectionName, fieldName);
|
|
690
|
+
|
|
691
|
+
if (!relation) {
|
|
692
|
+
// Check for polymorphic virtual field
|
|
693
|
+
const poly = Lobb.instance.configManager.getPolymorphicRelationByVirtualField(collectionName, fieldName);
|
|
694
|
+
if (!poly) {
|
|
695
|
+
throw new LobbError({
|
|
696
|
+
code: "BAD_REQUEST",
|
|
697
|
+
message: `Cannot resolve field (${fieldName}) on collection (${collectionName}): no relation is defined for this field`,
|
|
698
|
+
});
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
// Group records by target collection (each record can point to a different one)
|
|
702
|
+
const groups: Record<string, any[]> = {};
|
|
703
|
+
for (const result of results) {
|
|
704
|
+
const targetCollection = result[poly.collectionField];
|
|
705
|
+
const targetId = result[poly.idField];
|
|
706
|
+
if (targetCollection && targetId != null) {
|
|
707
|
+
if (!groups[targetCollection]) groups[targetCollection] = [];
|
|
708
|
+
groups[targetCollection].push(result);
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
const subFields = subTree.rootFields.includes("*")
|
|
713
|
+
? "*"
|
|
714
|
+
: [...new Set(["id", ...Object.keys(subTree.relations), ...subTree.rootFields])].join(",") || "id";
|
|
715
|
+
|
|
716
|
+
// Batch-fetch per target collection
|
|
717
|
+
const refMap: Record<string, any> = {};
|
|
718
|
+
for (const [targetCollection, groupRecords] of Object.entries(groups)) {
|
|
719
|
+
const ids = [...new Set(groupRecords.map((r) => String(r[poly.idField])))];
|
|
720
|
+
const fetched = await this.findAll({
|
|
721
|
+
collectionName: targetCollection,
|
|
722
|
+
params: { fields: subFields, filter: { id: { $in: ids } } },
|
|
723
|
+
triggeredBy: "INTERNAL",
|
|
724
|
+
client,
|
|
725
|
+
});
|
|
726
|
+
let fetchedRecords = fetched.data;
|
|
727
|
+
if (Object.keys(subTree.relations).length > 0 && fetchedRecords.length > 0) {
|
|
728
|
+
fetchedRecords = await this.resolveRelations(targetCollection, fetchedRecords, subTree.relations, client);
|
|
729
|
+
}
|
|
730
|
+
for (const refRecord of fetchedRecords) {
|
|
731
|
+
refMap[`${targetCollection}:${refRecord.id}`] = refRecord;
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
for (const result of results) {
|
|
736
|
+
const key = `${result[poly.collectionField]}:${result[poly.idField]}`;
|
|
737
|
+
result[fieldName] = refMap[key] ?? null;
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
continue;
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
const uniqueIds = [
|
|
744
|
+
...new Set(
|
|
745
|
+
results
|
|
746
|
+
.map((r) => r[fieldName])
|
|
747
|
+
.filter((id) => id != null)
|
|
748
|
+
.map(String),
|
|
749
|
+
),
|
|
750
|
+
];
|
|
751
|
+
|
|
752
|
+
const refMap: Record<string, any> = {};
|
|
753
|
+
|
|
754
|
+
if (uniqueIds.length) {
|
|
755
|
+
const subFields = subTree.rootFields.includes("*")
|
|
756
|
+
? "*"
|
|
757
|
+
: [...new Set(["id", ...Object.keys(subTree.relations), ...subTree.rootFields])].join(",") || "id";
|
|
758
|
+
|
|
759
|
+
const refRecords = await this.findAll({
|
|
760
|
+
collectionName: relation.to.collection,
|
|
761
|
+
params: {
|
|
762
|
+
fields: subFields,
|
|
763
|
+
filter: { [relation.to.field]: { $in: uniqueIds } },
|
|
764
|
+
},
|
|
765
|
+
triggeredBy: "INTERNAL",
|
|
766
|
+
client,
|
|
767
|
+
});
|
|
768
|
+
|
|
769
|
+
let fetchedRecords = refRecords.data;
|
|
770
|
+
|
|
771
|
+
if (Object.keys(subTree.relations).length > 0 && fetchedRecords.length > 0) {
|
|
772
|
+
fetchedRecords = await this.resolveRelations(
|
|
773
|
+
relation.to.collection,
|
|
774
|
+
fetchedRecords,
|
|
775
|
+
subTree.relations,
|
|
776
|
+
client,
|
|
777
|
+
);
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
for (const refRecord of fetchedRecords) {
|
|
781
|
+
refMap[String(refRecord[relation.to.field])] = refRecord;
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
for (const result of results) {
|
|
786
|
+
const refId = result[fieldName];
|
|
787
|
+
result[fieldName] = refId != null ? (refMap[String(refId)] ?? null) : null;
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
return results;
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
private async resolveChildren(
|
|
795
|
+
collectionName: string,
|
|
796
|
+
records: Record<string, any>[],
|
|
797
|
+
children: Record<string, ChildParams>,
|
|
798
|
+
client: PoolClient,
|
|
799
|
+
): Promise<Record<string, any>[]> {
|
|
800
|
+
const results = records.map((r) => ({ ...r }));
|
|
801
|
+
|
|
802
|
+
for (const [childCollectionName, childParams] of Object.entries(children)) {
|
|
803
|
+
const resolved = this.resolveChildRelationType(collectionName, childCollectionName);
|
|
804
|
+
|
|
805
|
+
if (!resolved) {
|
|
806
|
+
throw new LobbError({
|
|
807
|
+
code: "BAD_REQUEST",
|
|
808
|
+
message: `Cannot fetch children (${childCollectionName}) for collection (${collectionName}): no relation is defined`,
|
|
809
|
+
});
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
const userChildFields = childParams.fields;
|
|
813
|
+
const hasWildcard = !userChildFields ||
|
|
814
|
+
(Array.isArray(userChildFields) ? userChildFields.includes("*") : userChildFields.includes("*"));
|
|
815
|
+
|
|
816
|
+
if (resolved.type === "fk") {
|
|
817
|
+
const fkField = resolved.relation.from.field;
|
|
818
|
+
const parentIds = [...new Set(results.map((r) => r[resolved.relation.to.field]).filter((id) => id != null).map(String))];
|
|
819
|
+
|
|
820
|
+
if (parentIds.length === 0) {
|
|
821
|
+
for (const result of results) result[childCollectionName] = [];
|
|
822
|
+
continue;
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
const childFieldsWithFK = hasWildcard
|
|
826
|
+
? userChildFields
|
|
827
|
+
: Array.isArray(userChildFields)
|
|
828
|
+
? [...new Set([...userChildFields, "id", fkField])]
|
|
829
|
+
: `${userChildFields},id,${fkField}`;
|
|
830
|
+
|
|
831
|
+
const fkFilter = { [fkField]: { $in: parentIds } };
|
|
832
|
+
const filter = childParams.filter ? { $and: [fkFilter, childParams.filter] } : fkFilter;
|
|
833
|
+
|
|
834
|
+
const childRecords = await this.findAll({
|
|
835
|
+
collectionName: childCollectionName,
|
|
836
|
+
params: { ...childParams, fields: childFieldsWithFK, filter },
|
|
837
|
+
triggeredBy: "INTERNAL",
|
|
838
|
+
client,
|
|
839
|
+
});
|
|
840
|
+
|
|
841
|
+
const childMap: Record<string, any[]> = {};
|
|
842
|
+
for (const parentId of parentIds) childMap[parentId] = [];
|
|
843
|
+
for (const childRecord of childRecords.data) {
|
|
844
|
+
const parentId = String(childRecord[fkField]);
|
|
845
|
+
if (childMap[parentId]) childMap[parentId].push(childRecord);
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
if (!hasWildcard) {
|
|
849
|
+
const requestedFields = Array.isArray(userChildFields)
|
|
850
|
+
? userChildFields
|
|
851
|
+
: userChildFields?.split(",").map((f) => f.trim()) ?? [];
|
|
852
|
+
if (!requestedFields.includes(fkField)) {
|
|
853
|
+
for (const group of Object.values(childMap)) {
|
|
854
|
+
for (const record of group) delete record[fkField];
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
for (const result of results) {
|
|
860
|
+
result[childCollectionName] = childMap[String(result[resolved.relation.to.field])] ?? [];
|
|
861
|
+
}
|
|
862
|
+
} else if (resolved.type === "m2m") {
|
|
863
|
+
const { junctionCollection, parentFKField, targetFKField } = resolved.junction;
|
|
864
|
+
const parentIds = [...new Set(results.map((r) => r["id"]).filter((id) => id != null).map(String))];
|
|
865
|
+
|
|
866
|
+
if (parentIds.length === 0) {
|
|
867
|
+
for (const result of results) result[childCollectionName] = [];
|
|
868
|
+
continue;
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
// Fetch junction rows to get target IDs per parent
|
|
872
|
+
const junctionRecords = await this.findAll({
|
|
873
|
+
collectionName: junctionCollection,
|
|
874
|
+
params: { fields: `${parentFKField},${targetFKField}`, filter: { [parentFKField]: { $in: parentIds } } },
|
|
875
|
+
triggeredBy: "INTERNAL",
|
|
876
|
+
client,
|
|
877
|
+
});
|
|
878
|
+
|
|
879
|
+
const targetIdsByParent: Record<string, string[]> = {};
|
|
880
|
+
for (const parentId of parentIds) targetIdsByParent[parentId] = [];
|
|
881
|
+
for (const jr of junctionRecords.data) {
|
|
882
|
+
const parentId = String(jr[parentFKField]);
|
|
883
|
+
const targetId = String(jr[targetFKField]);
|
|
884
|
+
if (targetIdsByParent[parentId]) targetIdsByParent[parentId].push(targetId);
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
const allTargetIds = [...new Set(Object.values(targetIdsByParent).flat())];
|
|
888
|
+
const targetMap: Record<string, any> = {};
|
|
889
|
+
|
|
890
|
+
if (allTargetIds.length > 0) {
|
|
891
|
+
const childFieldsWithId = hasWildcard ? userChildFields : Array.isArray(userChildFields)
|
|
892
|
+
? [...new Set([...userChildFields, "id"])]
|
|
893
|
+
: `${userChildFields},id`;
|
|
894
|
+
|
|
895
|
+
const targetRecords = await this.findAll({
|
|
896
|
+
collectionName: childCollectionName,
|
|
897
|
+
params: { ...childParams, fields: childFieldsWithId, filter: { id: { $in: allTargetIds } } },
|
|
898
|
+
triggeredBy: "INTERNAL",
|
|
899
|
+
client,
|
|
900
|
+
});
|
|
901
|
+
|
|
902
|
+
for (const tr of targetRecords.data) targetMap[String(tr.id)] = tr;
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
for (const result of results) {
|
|
906
|
+
const parentId = String(result["id"]);
|
|
907
|
+
result[childCollectionName] = (targetIdsByParent[parentId] ?? []).map((id) => targetMap[id]).filter(Boolean);
|
|
908
|
+
}
|
|
909
|
+
} else {
|
|
910
|
+
// Polymorphic — child records have polymorphic FK pointing to parent
|
|
911
|
+
const { collectionField, idField } = resolved.poly;
|
|
912
|
+
const parentIds = [...new Set(results.map((r) => r["id"]).filter((id) => id != null).map(String))];
|
|
913
|
+
|
|
914
|
+
if (parentIds.length === 0) {
|
|
915
|
+
for (const result of results) result[childCollectionName] = [];
|
|
916
|
+
continue;
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
const childFieldsWithFK = hasWildcard
|
|
920
|
+
? userChildFields
|
|
921
|
+
: Array.isArray(userChildFields)
|
|
922
|
+
? [...new Set([...userChildFields, "id", idField])]
|
|
923
|
+
: `${userChildFields},id,${idField}`;
|
|
924
|
+
|
|
925
|
+
const polyFilter = { [collectionField]: collectionName, [idField]: { $in: parentIds } };
|
|
926
|
+
const filter = childParams.filter ? { $and: [polyFilter, childParams.filter] } : polyFilter;
|
|
927
|
+
|
|
928
|
+
const childRecords = await this.findAll({
|
|
929
|
+
collectionName: childCollectionName,
|
|
930
|
+
params: { ...childParams, fields: childFieldsWithFK, filter },
|
|
931
|
+
triggeredBy: "INTERNAL",
|
|
932
|
+
client,
|
|
933
|
+
});
|
|
934
|
+
|
|
935
|
+
const childMap: Record<string, any[]> = {};
|
|
936
|
+
for (const parentId of parentIds) childMap[parentId] = [];
|
|
937
|
+
for (const childRecord of childRecords.data) {
|
|
938
|
+
const parentId = String(childRecord[idField]);
|
|
939
|
+
if (childMap[parentId]) childMap[parentId].push(childRecord);
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
if (!hasWildcard) {
|
|
943
|
+
const requestedFields = Array.isArray(userChildFields)
|
|
944
|
+
? userChildFields
|
|
945
|
+
: userChildFields?.split(",").map((f) => f.trim()) ?? [];
|
|
946
|
+
if (!requestedFields.includes(idField)) {
|
|
947
|
+
for (const group of Object.values(childMap)) {
|
|
948
|
+
for (const record of group) delete record[idField];
|
|
949
|
+
}
|
|
950
|
+
}
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
for (const result of results) {
|
|
954
|
+
result[childCollectionName] = childMap[String(result["id"])] ?? [];
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
return results;
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
private async processInlineFKCreates(
|
|
963
|
+
collectionName: string,
|
|
964
|
+
data: Record<string, any>,
|
|
965
|
+
triggeredBy: TriggeredBy,
|
|
966
|
+
context: HonoContext | undefined,
|
|
967
|
+
client: PoolClient,
|
|
968
|
+
): Promise<Record<string, any>> {
|
|
969
|
+
const result = { ...data };
|
|
970
|
+
|
|
971
|
+
for (const [fieldName, value] of Object.entries(result)) {
|
|
972
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) continue;
|
|
973
|
+
|
|
974
|
+
// Direct FK inline create: { data: {...} }
|
|
975
|
+
const fkRelation = Lobb.instance.configManager.getParentRelation(collectionName, fieldName);
|
|
976
|
+
if (fkRelation && value.data && typeof value.data === "object") {
|
|
977
|
+
const created = await this.createOne({
|
|
978
|
+
collectionName: fkRelation.to.collection,
|
|
979
|
+
data: value.data,
|
|
980
|
+
triggeredBy,
|
|
981
|
+
context,
|
|
982
|
+
client,
|
|
983
|
+
});
|
|
984
|
+
result[fieldName] = created.data.id;
|
|
985
|
+
continue;
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
// Polymorphic inline create: { collection: "...", data: {...} }
|
|
989
|
+
const polyRelation = Lobb.instance.configManager.getPolymorphicRelationByVirtualField(collectionName, fieldName);
|
|
990
|
+
if (polyRelation && value.collection && value.data && typeof value.data === "object") {
|
|
991
|
+
if (!polyRelation.allowedCollections.includes(value.collection)) {
|
|
992
|
+
throw new LobbError({
|
|
993
|
+
code: "BAD_REQUEST",
|
|
994
|
+
message: `Collection (${value.collection}) is not allowed for polymorphic field (${fieldName})`,
|
|
995
|
+
});
|
|
996
|
+
}
|
|
997
|
+
const created = await this.createOne({
|
|
998
|
+
collectionName: value.collection,
|
|
999
|
+
data: value.data,
|
|
1000
|
+
triggeredBy,
|
|
1001
|
+
context,
|
|
1002
|
+
client,
|
|
1003
|
+
});
|
|
1004
|
+
result[polyRelation.collectionField] = value.collection;
|
|
1005
|
+
result[polyRelation.idField] = created.data.id;
|
|
1006
|
+
delete result[fieldName];
|
|
1007
|
+
}
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
return result;
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
private resolveChildRelationType(
|
|
1014
|
+
collectionName: string,
|
|
1015
|
+
childCollectionName: string,
|
|
1016
|
+
): { type: "fk"; relation: any } | { type: "m2m"; junction: any } | { type: "polymorphic"; poly: any } | null {
|
|
1017
|
+
const directRelation = Lobb.instance.configManager.getChildRelations(collectionName)
|
|
1018
|
+
.find((r) => r.from.collection === childCollectionName);
|
|
1019
|
+
if (directRelation) return { type: "fk", relation: directRelation };
|
|
1020
|
+
|
|
1021
|
+
const junction = Lobb.instance.configManager.getM2MJunction(collectionName, childCollectionName);
|
|
1022
|
+
if (junction) return { type: "m2m", junction };
|
|
1023
|
+
|
|
1024
|
+
const poly = Lobb.instance.configManager.getPolymorphicChildRelation(collectionName, childCollectionName);
|
|
1025
|
+
if (poly) return { type: "polymorphic", poly };
|
|
1026
|
+
|
|
1027
|
+
return null;
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
private async processChildrenOps(
|
|
1031
|
+
collectionName: string,
|
|
1032
|
+
parentId: string | number,
|
|
1033
|
+
children: Record<string, { create?: Array<Record<string, any> & { children?: any }>; link?: (string | number)[]; unlink?: (string | number)[]; delete?: (string | number)[]; set?: (string | number)[]; update?: Array<{ id: string | number } & Record<string, any>> }>,
|
|
1034
|
+
triggeredBy: TriggeredBy,
|
|
1035
|
+
context: HonoContext | undefined,
|
|
1036
|
+
client: PoolClient,
|
|
1037
|
+
): Promise<void> {
|
|
1038
|
+
for (const [childCollectionName, ops] of Object.entries(children)) {
|
|
1039
|
+
if (!ops.create?.length && !ops.link?.length && !ops.unlink?.length && !ops.delete?.length && !ops.set && !ops.update?.length) continue;
|
|
1040
|
+
|
|
1041
|
+
const resolved = this.resolveChildRelationType(collectionName, childCollectionName);
|
|
1042
|
+
if (!resolved) {
|
|
1043
|
+
throw new LobbError({
|
|
1044
|
+
code: "BAD_REQUEST",
|
|
1045
|
+
message: `Cannot process children: no relation found from (${childCollectionName}) to (${collectionName})`,
|
|
1046
|
+
});
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
for (const childData of ops.create ?? []) {
|
|
1050
|
+
const { children: nestedChildren, ...data } = childData;
|
|
1051
|
+
|
|
1052
|
+
if (resolved.type === "fk") {
|
|
1053
|
+
await this.createOne({
|
|
1054
|
+
collectionName: childCollectionName,
|
|
1055
|
+
data: { ...data, [resolved.relation.from.field]: parentId },
|
|
1056
|
+
children: nestedChildren, triggeredBy, context, client,
|
|
1057
|
+
});
|
|
1058
|
+
} else if (resolved.type === "m2m") {
|
|
1059
|
+
const created = await this.createOne({
|
|
1060
|
+
collectionName: childCollectionName,
|
|
1061
|
+
data, children: nestedChildren, triggeredBy, context, client,
|
|
1062
|
+
});
|
|
1063
|
+
await this.createOne({
|
|
1064
|
+
collectionName: resolved.junction.junctionCollection,
|
|
1065
|
+
data: { [resolved.junction.parentFKField]: parentId, [resolved.junction.targetFKField]: created.data.id },
|
|
1066
|
+
triggeredBy, context, client,
|
|
1067
|
+
});
|
|
1068
|
+
} else {
|
|
1069
|
+
await this.createOne({
|
|
1070
|
+
collectionName: childCollectionName,
|
|
1071
|
+
data: { ...data, [resolved.poly.collectionField]: collectionName, [resolved.poly.idField]: parentId },
|
|
1072
|
+
children: nestedChildren, triggeredBy, context, client,
|
|
1073
|
+
});
|
|
1074
|
+
}
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
for (const id of ops.link ?? []) {
|
|
1078
|
+
if (resolved.type === "fk") {
|
|
1079
|
+
await this.updateOne({
|
|
1080
|
+
collectionName: childCollectionName,
|
|
1081
|
+
id: String(id),
|
|
1082
|
+
data: { [resolved.relation.from.field]: parentId },
|
|
1083
|
+
triggeredBy, context, client,
|
|
1084
|
+
});
|
|
1085
|
+
} else if (resolved.type === "m2m") {
|
|
1086
|
+
await this.createOne({
|
|
1087
|
+
collectionName: resolved.junction.junctionCollection,
|
|
1088
|
+
data: { [resolved.junction.parentFKField]: parentId, [resolved.junction.targetFKField]: id },
|
|
1089
|
+
triggeredBy, context, client,
|
|
1090
|
+
});
|
|
1091
|
+
} else {
|
|
1092
|
+
await this.updateOne({
|
|
1093
|
+
collectionName: childCollectionName,
|
|
1094
|
+
id: String(id),
|
|
1095
|
+
data: { [resolved.poly.collectionField]: collectionName, [resolved.poly.idField]: parentId },
|
|
1096
|
+
triggeredBy, context, client,
|
|
1097
|
+
});
|
|
1098
|
+
}
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
for (const id of ops.unlink ?? []) {
|
|
1102
|
+
if (resolved.type === "fk") {
|
|
1103
|
+
await this.updateOne({
|
|
1104
|
+
collectionName: childCollectionName,
|
|
1105
|
+
id: String(id),
|
|
1106
|
+
data: { [resolved.relation.from.field]: null },
|
|
1107
|
+
triggeredBy, context, client,
|
|
1108
|
+
});
|
|
1109
|
+
} else if (resolved.type === "m2m") {
|
|
1110
|
+
await this.deleteMany({
|
|
1111
|
+
collectionName: resolved.junction.junctionCollection,
|
|
1112
|
+
filter: { [resolved.junction.parentFKField]: parentId, [resolved.junction.targetFKField]: id },
|
|
1113
|
+
triggeredBy, context, client,
|
|
1114
|
+
});
|
|
1115
|
+
} else {
|
|
1116
|
+
await this.updateOne({
|
|
1117
|
+
collectionName: childCollectionName,
|
|
1118
|
+
id: String(id),
|
|
1119
|
+
data: { [resolved.poly.collectionField]: null, [resolved.poly.idField]: null },
|
|
1120
|
+
triggeredBy, context, client,
|
|
1121
|
+
});
|
|
1122
|
+
}
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
for (const id of ops.delete ?? []) {
|
|
1126
|
+
if (resolved.type === "m2m") {
|
|
1127
|
+
await this.deleteMany({
|
|
1128
|
+
collectionName: resolved.junction.junctionCollection,
|
|
1129
|
+
filter: { [resolved.junction.parentFKField]: parentId, [resolved.junction.targetFKField]: id },
|
|
1130
|
+
triggeredBy, context, client,
|
|
1131
|
+
});
|
|
1132
|
+
}
|
|
1133
|
+
await this.deleteOne({
|
|
1134
|
+
collectionName: childCollectionName,
|
|
1135
|
+
id: String(id),
|
|
1136
|
+
triggeredBy, context, client,
|
|
1137
|
+
});
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
if (ops.set) {
|
|
1141
|
+
const newIds = ops.set.map(String);
|
|
1142
|
+
|
|
1143
|
+
if (resolved.type === "fk") {
|
|
1144
|
+
const currentIds = await this.dbDriver.getIdsByFilter(
|
|
1145
|
+
childCollectionName,
|
|
1146
|
+
{ [resolved.relation.from.field]: parentId },
|
|
1147
|
+
client,
|
|
1148
|
+
);
|
|
1149
|
+
for (const currentId of currentIds) {
|
|
1150
|
+
if (!newIds.includes(String(currentId))) {
|
|
1151
|
+
await this.updateOne({
|
|
1152
|
+
collectionName: childCollectionName,
|
|
1153
|
+
id: String(currentId),
|
|
1154
|
+
data: { [resolved.relation.from.field]: null },
|
|
1155
|
+
triggeredBy, context, client,
|
|
1156
|
+
});
|
|
1157
|
+
}
|
|
1158
|
+
}
|
|
1159
|
+
for (const id of newIds) {
|
|
1160
|
+
await this.updateOne({
|
|
1161
|
+
collectionName: childCollectionName,
|
|
1162
|
+
id,
|
|
1163
|
+
data: { [resolved.relation.from.field]: parentId },
|
|
1164
|
+
triggeredBy, context, client,
|
|
1165
|
+
});
|
|
1166
|
+
}
|
|
1167
|
+
} else if (resolved.type === "m2m") {
|
|
1168
|
+
await this.deleteMany({
|
|
1169
|
+
collectionName: resolved.junction.junctionCollection,
|
|
1170
|
+
filter: { [resolved.junction.parentFKField]: parentId },
|
|
1171
|
+
triggeredBy, context, client,
|
|
1172
|
+
});
|
|
1173
|
+
for (const id of newIds) {
|
|
1174
|
+
await this.createOne({
|
|
1175
|
+
collectionName: resolved.junction.junctionCollection,
|
|
1176
|
+
data: { [resolved.junction.parentFKField]: parentId, [resolved.junction.targetFKField]: id },
|
|
1177
|
+
triggeredBy, context, client,
|
|
1178
|
+
});
|
|
1179
|
+
}
|
|
1180
|
+
} else {
|
|
1181
|
+
const currentIds = await this.dbDriver.getIdsByFilter(
|
|
1182
|
+
childCollectionName,
|
|
1183
|
+
{ [resolved.poly.collectionField]: collectionName, [resolved.poly.idField]: parentId },
|
|
1184
|
+
client,
|
|
1185
|
+
);
|
|
1186
|
+
for (const currentId of currentIds) {
|
|
1187
|
+
if (!newIds.includes(String(currentId))) {
|
|
1188
|
+
await this.updateOne({
|
|
1189
|
+
collectionName: childCollectionName,
|
|
1190
|
+
id: String(currentId),
|
|
1191
|
+
data: { [resolved.poly.collectionField]: null, [resolved.poly.idField]: null },
|
|
1192
|
+
triggeredBy, context, client,
|
|
1193
|
+
});
|
|
1194
|
+
}
|
|
1195
|
+
}
|
|
1196
|
+
for (const id of newIds) {
|
|
1197
|
+
await this.updateOne({
|
|
1198
|
+
collectionName: childCollectionName,
|
|
1199
|
+
id,
|
|
1200
|
+
data: { [resolved.poly.collectionField]: collectionName, [resolved.poly.idField]: parentId },
|
|
1201
|
+
triggeredBy, context, client,
|
|
1202
|
+
});
|
|
1203
|
+
}
|
|
1204
|
+
}
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
for (const item of ops.update ?? []) {
|
|
1208
|
+
const { id, ...data } = item;
|
|
1209
|
+
await this.updateOne({
|
|
1210
|
+
collectionName: childCollectionName,
|
|
1211
|
+
id: String(id),
|
|
1212
|
+
data,
|
|
1213
|
+
triggeredBy, context, client,
|
|
1214
|
+
});
|
|
1215
|
+
}
|
|
1216
|
+
}
|
|
1217
|
+
}
|
|
1218
|
+
|
|
639
1219
|
private async handleSingletonCollection(
|
|
640
1220
|
collectionName: string,
|
|
641
1221
|
context?: HonoContext,
|
|
@@ -654,3 +1234,81 @@ export class CollectionStore {
|
|
|
654
1234
|
}
|
|
655
1235
|
}
|
|
656
1236
|
}
|
|
1237
|
+
|
|
1238
|
+
interface FieldsTree {
|
|
1239
|
+
rootFields: string[];
|
|
1240
|
+
relations: Record<string, FieldsTree>;
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1243
|
+
function normalizeFields(fields: string | string[] | undefined): string[] {
|
|
1244
|
+
if (!fields) return ["*"];
|
|
1245
|
+
if (Array.isArray(fields)) return fields.map((f) => f.trim()).filter(Boolean);
|
|
1246
|
+
return fields.split(",").map((f) => f.trim()).filter(Boolean);
|
|
1247
|
+
}
|
|
1248
|
+
|
|
1249
|
+
function parseFieldsToTree(fields: string[]): FieldsTree {
|
|
1250
|
+
const tree: FieldsTree = { rootFields: [], relations: {} };
|
|
1251
|
+
for (const field of fields) {
|
|
1252
|
+
addToTree(tree, field);
|
|
1253
|
+
}
|
|
1254
|
+
return tree;
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1257
|
+
function addToTree(tree: FieldsTree, path: string): void {
|
|
1258
|
+
const dot = path.indexOf(".");
|
|
1259
|
+
if (dot === -1) {
|
|
1260
|
+
tree.rootFields.push(path);
|
|
1261
|
+
} else {
|
|
1262
|
+
const relationName = path.slice(0, dot);
|
|
1263
|
+
const rest = path.slice(dot + 1);
|
|
1264
|
+
if (!tree.relations[relationName]) {
|
|
1265
|
+
tree.relations[relationName] = { rootFields: [], relations: {} };
|
|
1266
|
+
}
|
|
1267
|
+
addToTree(tree.relations[relationName], rest);
|
|
1268
|
+
}
|
|
1269
|
+
}
|
|
1270
|
+
|
|
1271
|
+
function buildDriverFields(
|
|
1272
|
+
collectionName: string,
|
|
1273
|
+
fields: string | string[] | undefined,
|
|
1274
|
+
): string {
|
|
1275
|
+
const normalized = normalizeFields(fields).filter((f) => !f.includes("."));
|
|
1276
|
+
const allCollectionFields = Object.entries(
|
|
1277
|
+
Lobb.instance.configManager.getCollection(collectionName).fields,
|
|
1278
|
+
)
|
|
1279
|
+
.filter(([, cfg]) => !cfg.virtual)
|
|
1280
|
+
.map(([name]) => name);
|
|
1281
|
+
|
|
1282
|
+
const requestedFields = normalized.includes("*") ? allCollectionFields : normalized;
|
|
1283
|
+
|
|
1284
|
+
return [
|
|
1285
|
+
...new Set([
|
|
1286
|
+
"id",
|
|
1287
|
+
...Lobb.instance.configManager.getCollectionFKFields(collectionName),
|
|
1288
|
+
...requestedFields,
|
|
1289
|
+
]),
|
|
1290
|
+
].join(",");
|
|
1291
|
+
}
|
|
1292
|
+
|
|
1293
|
+
function stripInternalFields(
|
|
1294
|
+
collectionName: string,
|
|
1295
|
+
fields: string | string[] | undefined,
|
|
1296
|
+
records: Record<string, any>[],
|
|
1297
|
+
): Record<string, any>[] {
|
|
1298
|
+
const normalized = normalizeFields(fields);
|
|
1299
|
+
if (normalized.includes("*")) return records;
|
|
1300
|
+
|
|
1301
|
+
const internalFields = ["id", ...Lobb.instance.configManager.getCollectionFKFields(collectionName)];
|
|
1302
|
+
const tree = parseFieldsToTree(normalized);
|
|
1303
|
+
const userRequested = new Set([...tree.rootFields, ...Object.keys(tree.relations)]);
|
|
1304
|
+
|
|
1305
|
+
return records.map((record) => {
|
|
1306
|
+
const stripped: Record<string, any> = {};
|
|
1307
|
+
for (const key of Object.keys(record)) {
|
|
1308
|
+
if (!internalFields.includes(key) || userRequested.has(key)) {
|
|
1309
|
+
stripped[key] = record[key];
|
|
1310
|
+
}
|
|
1311
|
+
}
|
|
1312
|
+
return stripped;
|
|
1313
|
+
});
|
|
1314
|
+
}
|