@cepharum/concrete-db 0.1.1 → 0.2.1
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/LICENSE +1 -1
- package/concrete-db.d.ts +72 -2
- package/cure.mjs +39 -33
- package/lib/collector.mjs +32 -36
- package/lib/cure.mjs +1 -29
- package/lib/{default.shape.yml → default.shape.yaml} +2 -0
- package/lib/defaults.mjs +2 -30
- package/lib/generator.mjs +22 -31
- package/lib/helper.mjs +8 -31
- package/lib/index.mjs +0 -28
- package/lib/meta.cjs +0 -28
- package/lib/options.mjs +3 -0
- package/lib/shaper.mjs +740 -132
- package/lib/term-functions.mjs +86 -28
- package/package.json +16 -12
package/lib/shaper.mjs
CHANGED
|
@@ -1,38 +1,12 @@
|
|
|
1
|
-
|
|
2
|
-
* (c) 2021 cepharum GmbH, Berlin, http://cepharum.de
|
|
3
|
-
*
|
|
4
|
-
* The MIT License (MIT)
|
|
5
|
-
*
|
|
6
|
-
* Copyright (c) 2021 cepharum GmbH
|
|
7
|
-
*
|
|
8
|
-
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
9
|
-
* of this software and associated documentation files (the "Software"), to deal
|
|
10
|
-
* in the Software without restriction, including without limitation the rights
|
|
11
|
-
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
12
|
-
* copies of the Software, and to permit persons to whom the Software is
|
|
13
|
-
* furnished to do so, subject to the following conditions:
|
|
14
|
-
*
|
|
15
|
-
* The above copyright notice and this permission notice shall be included in all
|
|
16
|
-
* copies or substantial portions of the Software.
|
|
17
|
-
*
|
|
18
|
-
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
19
|
-
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
20
|
-
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
21
|
-
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
22
|
-
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
23
|
-
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
24
|
-
* SOFTWARE.
|
|
25
|
-
*
|
|
26
|
-
* @author: cepharum
|
|
27
|
-
*/
|
|
1
|
+
// noinspection ExceptionCaughtLocallyJS
|
|
28
2
|
|
|
29
3
|
import EventEmitter from "events";
|
|
30
4
|
|
|
31
5
|
import { Compiler, Functions } from "simple-terms";
|
|
32
6
|
import merge from "lodash.merge";
|
|
33
7
|
|
|
34
|
-
import { DefaultShaperOptions } from "./defaults.mjs";
|
|
35
|
-
import {
|
|
8
|
+
import { DefaultShape, DefaultShaperOptions } from "./defaults.mjs";
|
|
9
|
+
import { augmentDataSpace } from "./helper.mjs";
|
|
36
10
|
import * as TermFunctions from "./term-functions.mjs";
|
|
37
11
|
|
|
38
12
|
|
|
@@ -55,16 +29,17 @@ export class Shaper extends EventEmitter {
|
|
|
55
29
|
constructor( options ) {
|
|
56
30
|
super();
|
|
57
31
|
|
|
32
|
+
const compiledOptions = { ...DefaultShaperOptions, ...options };
|
|
33
|
+
|
|
58
34
|
Object.defineProperties( this, {
|
|
59
|
-
options: { value:
|
|
35
|
+
options: { value: compiledOptions, enumerable: true },
|
|
60
36
|
|
|
61
37
|
termsCache: { value: new Map() },
|
|
62
38
|
|
|
63
|
-
|
|
64
|
-
|
|
39
|
+
// SECURITY: prevent custom library functions from replacing regular ones by intention
|
|
40
|
+
termFunctions: { value: { ...compiledOptions.library, ...Functions, ...TermFunctions } },
|
|
65
41
|
|
|
66
|
-
|
|
67
|
-
prefix: { value: normalizePathname( this.options.prefix, true ) },
|
|
42
|
+
prefix: { value: normalizePathname( compiledOptions.prefix, true ) },
|
|
68
43
|
} );
|
|
69
44
|
}
|
|
70
45
|
|
|
@@ -72,13 +47,19 @@ export class Shaper extends EventEmitter {
|
|
|
72
47
|
* Creates database from provided collection of records.
|
|
73
48
|
*
|
|
74
49
|
* @param {Map<string,CollectedRecord[]>} recordsCollection collection of records to transform
|
|
50
|
+
* @param {Shape[]} localShapes local shapes of folders records have been collected from
|
|
75
51
|
* @returns {Database} resulting database records
|
|
76
52
|
*/
|
|
77
|
-
transform( recordsCollection ) {
|
|
78
|
-
|
|
53
|
+
transform( recordsCollection, localShapes ) {
|
|
54
|
+
if ( recordsCollection.size > 0 && !localShapes?.length ) {
|
|
55
|
+
throw new Error( "invalid use of Shaper: missing list of local shapes per folder records have been collected from" );
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const mergedLocalShapes = merge( {}, DefaultShape, ...localShapes );
|
|
59
|
+
const models = this.handleContributions( this.groupByModel( recordsCollection ), mergedLocalShapes );
|
|
79
60
|
const endpoints = {};
|
|
80
61
|
|
|
81
|
-
this.populateModels( endpoints, models );
|
|
62
|
+
this.populateModels( endpoints, models, mergedLocalShapes );
|
|
82
63
|
|
|
83
64
|
return { prefix: this.prefix, endpoints };
|
|
84
65
|
}
|
|
@@ -86,62 +67,587 @@ export class Shaper extends EventEmitter {
|
|
|
86
67
|
/**
|
|
87
68
|
* Reorganizes records to by grouped by model.
|
|
88
69
|
*
|
|
89
|
-
* @param {Map<string,CollectedRecord[]>}
|
|
90
|
-
* @returns {
|
|
70
|
+
* @param {Map<string,CollectedRecord[]>} collectedRecordsByName collection of records to transform
|
|
71
|
+
* @returns {Object<string,Object<string, ModelItem>>} provided records grouped by model and unique item ID
|
|
91
72
|
*/
|
|
92
|
-
groupByModel(
|
|
93
|
-
const
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
73
|
+
groupByModel( collectedRecordsByName ) {
|
|
74
|
+
const globalItemsPerModel = {};
|
|
75
|
+
let index = 0;
|
|
76
|
+
|
|
77
|
+
if ( this.options.verbose ) {
|
|
78
|
+
console.error( `shaping records ...` );
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
for ( const records of collectedRecordsByName.values() ) {
|
|
82
|
+
if ( this.options.verbose ) {
|
|
83
|
+
process.stderr.write( `\r[${++index}/${collectedRecordsByName.size}]` );
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
this.compileRecords( globalItemsPerModel, records );
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return globalItemsPerModel;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Implements separate stage for computing contributions per model item
|
|
94
|
+
* based on either model's compiled shape.
|
|
95
|
+
*
|
|
96
|
+
* @param {Object<string,Object<string, CollectedRecord[]>>} globalItemsPerModel collections of items per model
|
|
97
|
+
* @param {Shape} mergedLocalShapes local shapes of source folders merged in order of collecting records
|
|
98
|
+
* @returns {Object<string,Object<string, CollectedRecord[]>>} collections of items per model as provided
|
|
99
|
+
*/
|
|
100
|
+
handleContributions( globalItemsPerModel, mergedLocalShapes ) {
|
|
101
|
+
const postProcessingQueue = new Set();
|
|
102
|
+
|
|
103
|
+
if ( this.options.verbose ) {
|
|
104
|
+
console.error( `\nprocessing contributions ...` );
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
this.processContributions( globalItemsPerModel, postProcessingQueue, mergedLocalShapes );
|
|
108
|
+
|
|
109
|
+
if ( this.options.verbose ) {
|
|
110
|
+
console.error( `post-processing ${postProcessingQueue.size} contributions` );
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
this.recompileContributionTargets( globalItemsPerModel, postProcessingQueue );
|
|
114
|
+
|
|
115
|
+
return globalItemsPerModel;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Compiles all items according to collected records as provided and collect
|
|
120
|
+
* them grouped by described items' models.
|
|
121
|
+
*
|
|
122
|
+
* @param {Object<string,Object<string, ModelItem>>} itemsPerModel collections of items per model to be populated
|
|
123
|
+
* @param {CollectedRecord[]} records list of records collected from a source
|
|
124
|
+
* @returns {Object<string,Object<string,ModelItem>>} compiled items grouped by model, addressable by either item's ID
|
|
125
|
+
*/
|
|
126
|
+
compileRecords( itemsPerModel, records ) {
|
|
127
|
+
const shapesPerModelCache = {};
|
|
128
|
+
|
|
129
|
+
for ( let read = 0, numRecords = records.length; read < numRecords; read++ ) {
|
|
130
|
+
const item = this.compileModelItemFromRecord( shapesPerModelCache, itemsPerModel, records[read] );
|
|
131
|
+
const modelPool = itemsPerModel[item.$model];
|
|
132
|
+
|
|
133
|
+
if ( item.$variantId == null ) {
|
|
134
|
+
modelPool[item.$id] = this.mergeModelItem( modelPool[item.$id], item );
|
|
135
|
+
} else {
|
|
136
|
+
const target = modelPool[item.$id];
|
|
137
|
+
|
|
138
|
+
if ( !target.$variants ) {
|
|
139
|
+
target.$variants = {};
|
|
109
140
|
}
|
|
110
141
|
|
|
111
|
-
|
|
112
|
-
|
|
142
|
+
target.$variants[item.$variantId] = this.mergeModelItem( target.$variants[item.$variantId], item );
|
|
143
|
+
}
|
|
144
|
+
}
|
|
113
145
|
|
|
114
|
-
|
|
115
|
-
|
|
146
|
+
return itemsPerModel;
|
|
147
|
+
}
|
|
116
148
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
149
|
+
/**
|
|
150
|
+
* Compiles item of model from a provided source record. This includes
|
|
151
|
+
* detecting model and selected ID of resulting item from provided record
|
|
152
|
+
* based on record's shape.
|
|
153
|
+
*
|
|
154
|
+
* @param {Object<string,ShapeModel>} shapesPerModelCache caches per-model shapes compiled before
|
|
155
|
+
* @param {Object<string,Object<string,Object>>} itemsPerModel collection of previously compiled items per model
|
|
156
|
+
* @param {CollectedRecord} record augmented data collected from a source to be compiled
|
|
157
|
+
* @returns {object} instance of model with hidden meta information injected
|
|
158
|
+
*/
|
|
159
|
+
compileModelItemFromRecord( shapesPerModelCache, itemsPerModel, record ) {
|
|
160
|
+
const { segments, shape, data } = record;
|
|
120
161
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
162
|
+
const metaProperties = {
|
|
163
|
+
$segments: segments
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
const augmentedData = augmentDataSpace( data, metaProperties );
|
|
167
|
+
|
|
168
|
+
metaProperties.$model = this.detectModel( augmentedData, shape );
|
|
169
|
+
metaProperties.$shape = this.getShapeOfModel( {}, shape, metaProperties.$model );
|
|
170
|
+
metaProperties.$id = this.identifyRecord( augmentedData, metaProperties.$shape );
|
|
171
|
+
|
|
172
|
+
if ( !itemsPerModel.hasOwnProperty( metaProperties.$model ) ) {
|
|
173
|
+
// eslint-disable-next-line no-param-reassign
|
|
174
|
+
itemsPerModel[metaProperties.$model] = {};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const model = itemsPerModel[metaProperties.$model];
|
|
178
|
+
|
|
179
|
+
const { properties, defaults } = metaProperties.$shape;
|
|
180
|
+
const item = this.compileItem( augmentedData, properties, model[metaProperties.$id] ? {} : defaults );
|
|
181
|
+
|
|
182
|
+
Object.defineProperties( item, {
|
|
183
|
+
$segments: { value: segments },
|
|
184
|
+
$original: { value: data, configurable: true },
|
|
185
|
+
$shape: { value: metaProperties.$shape, configurable: true },
|
|
186
|
+
$model: { value: metaProperties.$model },
|
|
187
|
+
$id: { value: metaProperties.$id },
|
|
188
|
+
$variantId: { value: this.computeVariantIdOfItem( metaProperties.$shape.properties.$variant, augmentedData ) },
|
|
189
|
+
} );
|
|
190
|
+
|
|
191
|
+
return item;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Re-compiles provided item this time based on item's properties'
|
|
196
|
+
* themselves instead of some record originally collected from a source.
|
|
197
|
+
*
|
|
198
|
+
* Re-compiling is used to compute data contributed by other models.
|
|
199
|
+
*
|
|
200
|
+
* @param {ModelItem} item previously compiled item to re-compile
|
|
201
|
+
* @returns {ModelItem} re-compiled item
|
|
202
|
+
*/
|
|
203
|
+
recompileItem( item ) {
|
|
204
|
+
const data = augmentDataSpace( merge( {}, item.$original, item ), { ...item, $post: true } );
|
|
205
|
+
const updatedItem = this.compileItem( data, item.$shape.properties, {} );
|
|
206
|
+
|
|
207
|
+
Object.defineProperties( updatedItem, {
|
|
208
|
+
$segments: { value: item.$segments },
|
|
209
|
+
$original: { value: item.$original, configurable: true },
|
|
210
|
+
$shape: { value: item.$shape, configurable: true },
|
|
211
|
+
$model: { value: item.$model },
|
|
212
|
+
$id: { value: item.$id },
|
|
213
|
+
} );
|
|
214
|
+
|
|
215
|
+
return updatedItem;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Computes ID of variant provided item is describing. If nullish, the item
|
|
220
|
+
* isn't describing any variant but the base version of the item.
|
|
221
|
+
*
|
|
222
|
+
* @param {string?} term term expressing computation of variant ID
|
|
223
|
+
* @param {ModelItem} item an item to get variant ID for
|
|
224
|
+
* @returns {null|any} computed variant ID of provided item, nullish if item is not a variant
|
|
225
|
+
*/
|
|
226
|
+
computeVariantIdOfItem( term, item ) {
|
|
227
|
+
if ( term == null ) {
|
|
228
|
+
return null;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// compile data space based on item's properties overlaying the item's originating record
|
|
232
|
+
const data = augmentDataSpace( merge( {}, item.$original, item ), item );
|
|
233
|
+
|
|
234
|
+
try {
|
|
235
|
+
const value = Compiler.compile( String( term ), this.termFunctions, this.termsCache )( data );
|
|
236
|
+
|
|
237
|
+
// support processed term requesting to fail computation of variant ID
|
|
238
|
+
if ( value instanceof Error ) {
|
|
239
|
+
throw value;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
return value;
|
|
243
|
+
} catch ( cause ) {
|
|
244
|
+
throw new Error( `computing variant ID of ${item.$model}#${item.$id} failed: ${cause.message} in ${term}` );
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Fetches compiled shape of named model using provided cache.
|
|
250
|
+
*
|
|
251
|
+
* @param {Object<string,ShapeModel>} shapesPerModelCache cache to use on fetching shape for same model multiple times
|
|
252
|
+
* @param {Shape} localShape shape including desired definition of selected model's shape
|
|
253
|
+
* @param {string} modelName name of model
|
|
254
|
+
* @returns {ShapeModel} compiled shape of model incorporating common definitions for all models
|
|
255
|
+
*/
|
|
256
|
+
getShapeOfModel( shapesPerModelCache, localShape, modelName ) {
|
|
257
|
+
if ( !shapesPerModelCache.hasOwnProperty( modelName ) ) {
|
|
258
|
+
// eslint-disable-next-line no-param-reassign
|
|
259
|
+
shapesPerModelCache[modelName] = merge( { defaults: DefaultShape.common.defaults }, localShape?.common, localShape?.models?.[modelName] );
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
return shapesPerModelCache[modelName];
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Computes dependencies of models on contributions by provided items
|
|
267
|
+
* grouped by model.
|
|
268
|
+
*
|
|
269
|
+
* @param {Object<string,Object<string,Object>>} itemsPerModel compiled items grouped by model
|
|
270
|
+
* @returns {string[]} sorted list of names of models in provided pool starting with model depending on other models' contributions the least
|
|
271
|
+
*/
|
|
272
|
+
listContributingModels( itemsPerModel ) {
|
|
273
|
+
const contributorsPerModel = {};
|
|
274
|
+
|
|
275
|
+
// create map of immediate relations between every two models involved
|
|
276
|
+
// in a contribution
|
|
277
|
+
for ( const model of Object.keys( itemsPerModel ) ) {
|
|
278
|
+
for ( const item of Object.values( itemsPerModel[model] ) ) {
|
|
279
|
+
for ( const contributedModel of Object.keys( item.$shape.contributions || {} ) ) {
|
|
280
|
+
if ( !contributorsPerModel.hasOwnProperty( contributedModel ) ) {
|
|
281
|
+
contributorsPerModel[contributedModel] = {};
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
contributorsPerModel[contributedModel][item.$model] = 1;
|
|
125
285
|
}
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// surface mediate dependencies and collect all contributing models' names
|
|
290
|
+
const contributingModelNames = {};
|
|
291
|
+
|
|
292
|
+
for ( const surfaceModel of Object.keys( contributorsPerModel ) ) {
|
|
293
|
+
descend( surfaceModel, surfaceModel, [] );
|
|
294
|
+
}
|
|
126
295
|
|
|
127
|
-
|
|
128
|
-
|
|
296
|
+
// sort names of contributing models from models depending on other
|
|
297
|
+
// contributing models the least to those doing it the most
|
|
298
|
+
const names = Object.keys( contributingModelNames );
|
|
129
299
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
300
|
+
names.sort( ( left, right ) => {
|
|
301
|
+
if ( contributorsPerModel.hasOwnProperty( left ) && contributorsPerModel[left].hasOwnProperty( right ) ) {
|
|
302
|
+
// left model depends on contributions by right model
|
|
303
|
+
return 1;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
if ( contributorsPerModel.hasOwnProperty( right ) && contributorsPerModel[right].hasOwnProperty( left ) ) {
|
|
307
|
+
// right model depends on contributions by left model
|
|
308
|
+
return -1;
|
|
309
|
+
}
|
|
135
310
|
|
|
136
|
-
|
|
137
|
-
|
|
311
|
+
return 0;
|
|
312
|
+
} );
|
|
313
|
+
|
|
314
|
+
return names;
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Surfaces dependencies of a named model.
|
|
318
|
+
*
|
|
319
|
+
* @param {string} surfaceModel name of model triggering current processing
|
|
320
|
+
* @param {string} currentModel name of currently processed model
|
|
321
|
+
* @param {string[]} breadcrumb names of models processed while descending into dependencies
|
|
322
|
+
* @returns {void}
|
|
323
|
+
*/
|
|
324
|
+
function descend( surfaceModel, currentModel, breadcrumb ) {
|
|
325
|
+
if ( breadcrumb.indexOf( currentModel ) > -1 ) {
|
|
326
|
+
throw new Error( `invalid circular dependency on contributing to model ${surfaceModel}: [${breadcrumb}]` );
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
if ( contributorsPerModel.hasOwnProperty( currentModel ) ) {
|
|
330
|
+
const current = contributorsPerModel[currentModel];
|
|
331
|
+
const surface = contributorsPerModel[surfaceModel];
|
|
332
|
+
|
|
333
|
+
for ( const contributingModel of Object.keys( current ) ) {
|
|
334
|
+
contributingModelNames[contributingModel] = true;
|
|
335
|
+
|
|
336
|
+
if ( !surface.hasOwnProperty( contributingModel ) ) {
|
|
337
|
+
surface[contributingModel] = -1;
|
|
338
|
+
} else if ( current[contributingModel] > 0 ) {
|
|
339
|
+
breadcrumb.push( currentModel );
|
|
340
|
+
descend( surfaceModel, contributingModel, breadcrumb );
|
|
341
|
+
breadcrumb.pop();
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Processes contributions of models in provided collection of items
|
|
350
|
+
* augmenting that collection.
|
|
351
|
+
*
|
|
352
|
+
* @param {Object<string,Object<string,Object>>} itemsPerModel map of model names into either model's map of item IDs into related item
|
|
353
|
+
* @param {Set<string>} contributedItems tracks IDs of items or their variants that have been contributed to
|
|
354
|
+
* @param {Shape} mergedLocalShapes local shapes of source folders merged in order of collecting records
|
|
355
|
+
* @returns {void}
|
|
356
|
+
*/
|
|
357
|
+
processContributions( itemsPerModel, contributedItems, mergedLocalShapes ) {
|
|
358
|
+
const contributingModels = this.listContributingModels( itemsPerModel );
|
|
359
|
+
const shapePerModelCache = {};
|
|
360
|
+
|
|
361
|
+
for ( const modelName of contributingModels ) {
|
|
362
|
+
const items = itemsPerModel[modelName];
|
|
363
|
+
|
|
364
|
+
for ( const item of Object.values( items ) ) {
|
|
365
|
+
const { contributions } = item.$shape;
|
|
366
|
+
if ( !contributions ) {
|
|
367
|
+
continue;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
for ( const contributedModelName of Object.keys( contributions ) ) {
|
|
371
|
+
const isOptionalContribution = contributedModelName.trim().endsWith( "?" );
|
|
372
|
+
const targetModelName = contributedModelName.replace( /\s*\?\s*$/, "" );
|
|
373
|
+
|
|
374
|
+
const targetModelShape = this.getShapeOfModel( shapePerModelCache, mergedLocalShapes, targetModelName );
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
// extract contributions to actually declared properties of target model
|
|
378
|
+
const contributionTerms = {};
|
|
379
|
+
const contributionModes = {};
|
|
380
|
+
let warn = false;
|
|
381
|
+
let numActualProperties = 0;
|
|
382
|
+
|
|
383
|
+
for ( const termTargetName of Object.keys( contributions[contributedModelName] ) ) {
|
|
384
|
+
const match = /^\s*(\S.+?)\s*(\[\])?\s*(\.\.\.|\u2026)?\s*$/u.exec( termTargetName );
|
|
385
|
+
const propertyName = match ? match[1] : termTargetName;
|
|
386
|
+
|
|
387
|
+
if ( match && ( match[2] || match[3] ) && propertyName.startsWith( "$" ) ) {
|
|
388
|
+
throw new Error( `invalid collecting or distributed contribution of record #${item.$id} in model ${item.$model} to property ${propertyName} of model ${targetModelName}` ); // eslint-disable-line max-len
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
if ( propertyName === "$variant" || propertyName === "$id" || Object.prototype.hasOwnProperty.call( targetModelShape.properties, propertyName ) ) {
|
|
392
|
+
if ( contributionTerms.hasOwnProperty( propertyName ) ) {
|
|
393
|
+
throw new Error( `double contribution of record #${item.$id} in model ${item.$model} to property ${propertyName} of model ${targetModelName}` ); // eslint-disable-line max-len
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
if ( !propertyName.startsWith( "$" ) ) {
|
|
397
|
+
numActualProperties++;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
contributionTerms[propertyName] = contributions[contributedModelName][termTargetName];
|
|
401
|
+
contributionModes[propertyName] = {
|
|
402
|
+
$isCollecting: Boolean( match && match[2] ),
|
|
403
|
+
$isDistributed: Boolean( match && match[3] ),
|
|
404
|
+
};
|
|
405
|
+
} else {
|
|
406
|
+
warn = true;
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
if ( numActualProperties < 1 && !isOptionalContribution ) {
|
|
411
|
+
console.warn( `ignoring eventually empty contribution of record #${item.$id} in model ${item.$model} to model ${targetModelName}` );
|
|
412
|
+
continue;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
if ( warn ) {
|
|
416
|
+
console.warn( `ignoring undeclared properties in contribution of record #${item.$id} in model ${item.$model} to model ${targetModelName}` ); // eslint-disable-line max-len
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
// pre-compute ID(s) of item(s) to contribute to
|
|
421
|
+
const contributionMeta = {
|
|
422
|
+
$segments: item.$segments,
|
|
423
|
+
$original: item.$original,
|
|
424
|
+
$shape: item.$shape,
|
|
425
|
+
$model: item.$model,
|
|
426
|
+
$id: item.$id,
|
|
427
|
+
};
|
|
428
|
+
|
|
429
|
+
const augmentedData = augmentDataSpace( item, contributionMeta );
|
|
430
|
+
const targetId = this.computeId( contributionTerms.$id, augmentedData );
|
|
431
|
+
|
|
432
|
+
if ( !isOptionalContribution && ( !targetId || ( Array.isArray( targetId ) && !targetId.length ) ) ) {
|
|
433
|
+
console.warn( `ignoring contribution of record #${item.$id} in model ${item.$model} to model ${targetModelName} due to lack of target ID` ); // eslint-disable-line max-len
|
|
434
|
+
continue;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
const targetIds = Array.isArray( targetId ) ? targetId : [targetId];
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
for ( let index = 0, count = targetIds.length; index < count; index++ ) {
|
|
441
|
+
const id = targetIds[index];
|
|
442
|
+
|
|
443
|
+
// compile contribution per target item
|
|
444
|
+
contributionMeta.$targetid = id;
|
|
445
|
+
contributionMeta.$targetindex = index;
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
// try computing variant ID based on contributing item
|
|
449
|
+
const variantId = this.computeVariantIdOfItem( contributionTerms.$variant, {
|
|
450
|
+
...item,
|
|
451
|
+
$segments: item.$segments,
|
|
452
|
+
$original: item.$original,
|
|
453
|
+
$shape: targetModelShape,
|
|
454
|
+
$model: targetModelName,
|
|
455
|
+
$id: targetId,
|
|
456
|
+
} );
|
|
457
|
+
|
|
458
|
+
// track contributions to selected target item or its variant
|
|
459
|
+
contributedItems.add( JSON.stringify( [ targetModelName, id, variantId ] ) );
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
// gain access on selected target item, create its containers as necessary
|
|
463
|
+
let target;
|
|
464
|
+
|
|
465
|
+
if ( !itemsPerModel.hasOwnProperty( targetModelName ) ) {
|
|
466
|
+
// eslint-disable-next-line no-param-reassign
|
|
467
|
+
itemsPerModel[targetModelName] = {};
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
const model = itemsPerModel[targetModelName];
|
|
471
|
+
const targetItemExists = model.hasOwnProperty( id );
|
|
472
|
+
|
|
473
|
+
if ( !targetItemExists ) {
|
|
474
|
+
model[id] = Object.defineProperties( {}, {
|
|
475
|
+
$segments: { value: item.$segments },
|
|
476
|
+
$original: { value: {}, configurable: true },
|
|
477
|
+
$shape: { value: targetModelShape, configurable: true },
|
|
478
|
+
$model: { value: targetModelName },
|
|
479
|
+
$id: { value: id },
|
|
480
|
+
} );
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
const targetItem = model[id];
|
|
484
|
+
|
|
485
|
+
if ( variantId == null ) {
|
|
486
|
+
target = targetItem;
|
|
487
|
+
} else {
|
|
488
|
+
if ( !targetItem.$variants ) {
|
|
489
|
+
targetItem.$variants = {};
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
if ( !targetItem.$variants.hasOwnProperty( variantId ) ) {
|
|
493
|
+
targetItem.$variants[variantId] = {};
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
target = targetItem.$variants[variantId];
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
// compile actual properties of contribution
|
|
501
|
+
const contributionDefaults = targetItemExists || variantId != null ? {} : targetModelShape.defaults;
|
|
502
|
+
const contribution = this.compileItem( augmentedData, contributionTerms, contributionDefaults );
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
// merge this item's contribution with existing
|
|
506
|
+
// properties of selected target using reduced version
|
|
507
|
+
// of merge strategy found in mergeModelItem()
|
|
508
|
+
for ( const name of Object.keys( contribution ) ) {
|
|
509
|
+
if ( String( name ).startsWith( "$" ) ) {
|
|
510
|
+
continue;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
const { $isCollecting, $isDistributed } = contributionModes[name] || {};
|
|
514
|
+
let value = contribution[name];
|
|
515
|
+
|
|
516
|
+
if ( $isDistributed ) {
|
|
517
|
+
// contribution is providing another value for every target item
|
|
518
|
+
if ( Array.isArray( value ) && value.length === targetIds.length ) { // eslint-disable-line max-depth
|
|
519
|
+
value = value[index];
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
if ( $isCollecting ) {
|
|
524
|
+
// contribution has been declared with one or more suffices, e.g. []
|
|
525
|
+
// -> collect value in target
|
|
526
|
+
if ( Array.isArray( target[name] ) ) { // eslint-disable-line no-lonely-if,max-depth
|
|
527
|
+
target[name].push( value );
|
|
528
|
+
} else if ( target[name] == null ) {
|
|
529
|
+
target[name] = [value];
|
|
530
|
+
} else {
|
|
531
|
+
target[name] = [ target[name], value ];
|
|
532
|
+
}
|
|
533
|
+
} else {
|
|
534
|
+
// contribution has been declared without any suffix
|
|
535
|
+
// -> do not collect, but replace value in target
|
|
536
|
+
target[name] = value;
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
/**
|
|
546
|
+
* Deeply merges provided source item into given target item including their
|
|
547
|
+
* attached model shape and original data.
|
|
548
|
+
*
|
|
549
|
+
* @param {ModelItem} target model item to adjust by merging
|
|
550
|
+
* @param {ModelItem} source model item to merge into provided target
|
|
551
|
+
* @returns {ModelItem} provided target
|
|
552
|
+
*/
|
|
553
|
+
mergeModelItem( target, source ) {
|
|
554
|
+
const _to = target || {};
|
|
555
|
+
const oldNames = target ? Object.keys( _to.$shape.properties ) : [];
|
|
556
|
+
const newNames = Object.keys( source.$shape.properties );
|
|
557
|
+
|
|
558
|
+
// merge properties of items
|
|
559
|
+
for ( const name of Object.keys( source ) ) {
|
|
560
|
+
if ( !String( name ).startsWith( "$" ) ) {
|
|
561
|
+
const wasCollecting = oldNames.some( n => n.replace( /\s*\[]$/, "" ).trim() === name );
|
|
562
|
+
const isCollecting = newNames.some( n => n.replace( /\s*\[]$/, "" ).trim() === name );
|
|
563
|
+
|
|
564
|
+
if ( _to.hasOwnProperty( name ) ) {
|
|
565
|
+
if ( isCollecting ) {
|
|
566
|
+
if ( wasCollecting && Array.isArray( _to[name] ) ) {
|
|
567
|
+
_to[name].splice( _to[name].length, 0, ...source[name] );
|
|
568
|
+
} else if ( _to[name] == null ) {
|
|
569
|
+
_to[name] = source[name];
|
|
570
|
+
} else {
|
|
571
|
+
_to[name] = [ _to[name], ...source[name] ];
|
|
572
|
+
}
|
|
573
|
+
} else {
|
|
574
|
+
_to[name] = source[name];
|
|
575
|
+
}
|
|
138
576
|
} else {
|
|
139
|
-
|
|
577
|
+
_to[name] = source[name];
|
|
140
578
|
}
|
|
141
579
|
}
|
|
142
580
|
}
|
|
143
581
|
|
|
144
|
-
|
|
582
|
+
// assure some freshly created target has all common meta properties
|
|
583
|
+
if ( !target ) {
|
|
584
|
+
Object.defineProperties( _to, {
|
|
585
|
+
$segments: { value: source.$segments },
|
|
586
|
+
$model: { value: source.$model },
|
|
587
|
+
$id: { value: source.$id },
|
|
588
|
+
} );
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
// merge some complex meta properties
|
|
592
|
+
Object.defineProperties( _to, {
|
|
593
|
+
$shape: {
|
|
594
|
+
value: merge( _to.$shape || {}, source.$shape ),
|
|
595
|
+
configurable: true,
|
|
596
|
+
},
|
|
597
|
+
$original: {
|
|
598
|
+
value: merge( _to.$original || {}, source.$original ),
|
|
599
|
+
configurable: true,
|
|
600
|
+
},
|
|
601
|
+
} );
|
|
602
|
+
|
|
603
|
+
return _to;
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
/**
|
|
607
|
+
* Re-compiles all items in provided pool that have been contributed to.
|
|
608
|
+
*
|
|
609
|
+
* @param {Object<string,Object<string,ModelItem>>} itemsPerModel pool of items per model to update
|
|
610
|
+
* @param {Set<string>} contributions lists IDs of items or their variants that have been contributed to
|
|
611
|
+
* @returns {Object<string,Object<string,ModelItem>>} pool of items per model as provided
|
|
612
|
+
*/
|
|
613
|
+
recompileContributionTargets( itemsPerModel, contributions ) {
|
|
614
|
+
for ( const track of contributions.values() ) {
|
|
615
|
+
const [ model, id, variantId ] = JSON.parse( track );
|
|
616
|
+
|
|
617
|
+
if ( !itemsPerModel.hasOwnProperty( model ) ) {
|
|
618
|
+
console.warn( `inconsistency: missing items pool of model ${model} though contributions have been tracked` );
|
|
619
|
+
continue;
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
const modelPool = itemsPerModel[model];
|
|
623
|
+
|
|
624
|
+
if ( !modelPool.hasOwnProperty( id ) ) {
|
|
625
|
+
console.warn( `inconsistency: missing item ${id} of model ${model} though contributions have been tracked` );
|
|
626
|
+
continue;
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
const item = modelPool[id];
|
|
630
|
+
|
|
631
|
+
if ( variantId == null ) {
|
|
632
|
+
modelPool[id] = this.recompileItem( item );
|
|
633
|
+
} else {
|
|
634
|
+
if ( !item.$variants ) {
|
|
635
|
+
console.warn( `inconsistency: missing variants of item ${id} of model ${model} though contributions have been tracked` );
|
|
636
|
+
continue;
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
const variants = item.$variants;
|
|
640
|
+
|
|
641
|
+
if ( !variants.hasOwnProperty( variantId ) ) {
|
|
642
|
+
console.warn( `inconsistency: missing variant #${variantId} of item #${id} of model ${model} though contributions have been tracked` );
|
|
643
|
+
continue;
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
variants[variantId] = this.recompileItem( variants[variantId] );
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
return itemsPerModel;
|
|
145
651
|
}
|
|
146
652
|
|
|
147
653
|
/**
|
|
@@ -160,6 +666,11 @@ export class Shaper extends EventEmitter {
|
|
|
160
666
|
|
|
161
667
|
const name = Compiler.compile( selector, this.termFunctions, this.termsCache )( data );
|
|
162
668
|
|
|
669
|
+
// support processed term requesting to fail model detection
|
|
670
|
+
if ( name instanceof Error ) {
|
|
671
|
+
throw new Error( `detecting model of record failed: ${name.message} in context of ${JSON.stringify( data )}` );
|
|
672
|
+
}
|
|
673
|
+
|
|
163
674
|
if ( !name ) {
|
|
164
675
|
throw new Error( `detecting model of record failed: ${JSON.stringify( data )}` );
|
|
165
676
|
}
|
|
@@ -175,13 +686,7 @@ export class Shaper extends EventEmitter {
|
|
|
175
686
|
* @returns {string} ID of record
|
|
176
687
|
*/
|
|
177
688
|
identifyRecord( data, modelShape ) {
|
|
178
|
-
const
|
|
179
|
-
|
|
180
|
-
if ( !selector || typeof selector !== "string" ) {
|
|
181
|
-
throw new Error( `missing or invalid definition of $id in shape of ${data.$segments.join( "/" )}` );
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
const id = Compiler.compile( selector )( data );
|
|
689
|
+
const id = this.computeId( modelShape?.properties?.$id, data );
|
|
185
690
|
|
|
186
691
|
if ( !id ) {
|
|
187
692
|
throw new Error( `detecting ID of item related to record ${data.$segments.join( "/" )} failed` );
|
|
@@ -193,66 +698,133 @@ export class Shaper extends EventEmitter {
|
|
|
193
698
|
/**
|
|
194
699
|
* Compiles resulting item by evaluating properties declared in model shape.
|
|
195
700
|
*
|
|
196
|
-
* @param {object} data source of record
|
|
197
|
-
* @param {
|
|
198
|
-
* @param {
|
|
701
|
+
* @param {object} data (augmented) source of record
|
|
702
|
+
* @param {ShapeProperties} properties definition of resulting item's properties
|
|
703
|
+
* @param {ShapePropertyDefaults} defaults definition of default values for resulting item's properties
|
|
704
|
+
* @param {string[]} accept list names of properties to accept even when starting with a dollar sign
|
|
199
705
|
* @returns {object} compiled item
|
|
200
706
|
*/
|
|
201
|
-
compileItem( data,
|
|
202
|
-
const properties = modelShape?.properties || {};
|
|
203
|
-
const defaults = modelShape?.defaults || {};
|
|
707
|
+
compileItem( data, properties = {}, defaults = {}, accept = [] ) {
|
|
204
708
|
const item = {};
|
|
205
709
|
|
|
206
710
|
for ( const property of Object.keys( properties ) ) {
|
|
207
|
-
if ( !String( property ).startsWith( "$" ) ) {
|
|
208
|
-
const
|
|
209
|
-
|
|
210
|
-
|
|
711
|
+
if ( accept.indexOf( property ) > -1 || !String( property ).startsWith( "$" ) ) {
|
|
712
|
+
const propertyName = property.replace( /\s*\[]$/, "" );
|
|
713
|
+
const isCollecting = property !== propertyName;
|
|
714
|
+
|
|
715
|
+
const term = properties[property];
|
|
716
|
+
if ( typeof term !== "string" || term.trim().length === 0 ) {
|
|
717
|
+
throw new Error( `invalid or missing term for computing value of property ${propertyName} of ${data.$model}` );
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
if ( isCollecting && properties.hasOwnProperty( propertyName ) ) {
|
|
721
|
+
throw new Error( `double definition: property ${propertyName} of ${data.$model} must be either regular or collecting property` );
|
|
211
722
|
}
|
|
212
723
|
|
|
213
724
|
let value;
|
|
214
725
|
|
|
215
726
|
try {
|
|
216
|
-
value = Compiler.compile(
|
|
727
|
+
value = Compiler.compile( term, this.termFunctions, this.termsCache )( data );
|
|
728
|
+
|
|
729
|
+
// support processed term requesting to fail computation of property's value
|
|
730
|
+
if ( value instanceof Error ) {
|
|
731
|
+
throw value;
|
|
732
|
+
}
|
|
217
733
|
} catch ( cause ) {
|
|
218
|
-
throw new Error( `fetching value of property ${data.$model}
|
|
734
|
+
throw new Error( `fetching value of property ${propertyName} of ${data.$model} failed: ${cause.message} in ${term}` );
|
|
219
735
|
}
|
|
220
736
|
|
|
221
737
|
if ( value == null ) {
|
|
222
|
-
if (
|
|
223
|
-
const defaultValue = defaults[property];
|
|
738
|
+
if ( defaults.hasOwnProperty( propertyName ) || defaults.hasOwnProperty( property ) ) {
|
|
739
|
+
const defaultValue = defaults.hasOwnProperty( propertyName ) ? defaults[propertyName] : defaults[property];
|
|
224
740
|
const trimmed = typeof defaultValue === "string" ? defaultValue.trim() : "";
|
|
225
741
|
|
|
226
742
|
try {
|
|
227
743
|
const code = trimmed.startsWith( "=" ) ? trimmed.slice( 1 ) : undefined;
|
|
228
744
|
value = code ? Compiler.compile( code, this.termFunctions, this.termsCache )( data ) : defaultValue;
|
|
745
|
+
|
|
746
|
+
// support processed term requesting to fail computation of default value
|
|
747
|
+
if ( value instanceof Error ) {
|
|
748
|
+
throw value;
|
|
749
|
+
}
|
|
229
750
|
} catch ( cause ) {
|
|
230
|
-
throw new Error( `fetching default of property ${data.$model}
|
|
751
|
+
throw new Error( `fetching default of property ${propertyName} of ${data.$model} failed: "${cause.message}" in "${defaultValue}"` );
|
|
231
752
|
}
|
|
232
753
|
}
|
|
233
754
|
}
|
|
234
755
|
|
|
235
756
|
if ( value != null ) {
|
|
236
|
-
|
|
757
|
+
if ( isCollecting ) {
|
|
758
|
+
if ( Array.isArray( item[propertyName] ) ) { // eslint-disable-line no-lonely-if
|
|
759
|
+
item[propertyName].push( value );
|
|
760
|
+
} else if ( item[propertyName] == null ) {
|
|
761
|
+
item[propertyName] = [value];
|
|
762
|
+
} else {
|
|
763
|
+
item[propertyName] = [ item[propertyName], value ];
|
|
764
|
+
}
|
|
765
|
+
} else {
|
|
766
|
+
item[propertyName] = value;
|
|
767
|
+
}
|
|
237
768
|
}
|
|
238
769
|
}
|
|
239
770
|
}
|
|
240
771
|
|
|
241
|
-
|
|
772
|
+
// deeply merge fresh object with compiled item to get rid of deep data
|
|
773
|
+
// exposed to terms as Proxy
|
|
774
|
+
return merge( {}, item );
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
/**
|
|
778
|
+
* Computes ID using provided term in context of given data space.
|
|
779
|
+
*
|
|
780
|
+
* @param {string} term term to process
|
|
781
|
+
* @param {object} data data space available to term processing
|
|
782
|
+
* @returns {any} processed term's result
|
|
783
|
+
*/
|
|
784
|
+
computeId( term, data ) {
|
|
785
|
+
if ( !term || typeof term !== "string" ) {
|
|
786
|
+
throw new Error( `missing or invalid term for computing ID of ${data.$segments.join( "/" )}` );
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
try {
|
|
790
|
+
const value = Compiler.compile( term, this.termFunctions, this.termsCache )( data );
|
|
791
|
+
|
|
792
|
+
// support processed term requesting to fail computation of ID
|
|
793
|
+
if ( value instanceof Error ) {
|
|
794
|
+
throw value;
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
return value;
|
|
798
|
+
} catch ( cause ) {
|
|
799
|
+
throw new Error( `computing ID failed: ${cause.message} in ${term}` );
|
|
800
|
+
}
|
|
242
801
|
}
|
|
243
802
|
|
|
244
803
|
/**
|
|
245
804
|
* Populates pool of endpoints with collected records of discovered models.
|
|
246
805
|
*
|
|
247
806
|
* @param {DatabaseEndpoints} endpoints map of routes into data sets to expose eventually
|
|
248
|
-
* @param {
|
|
807
|
+
* @param {Object<string,Object<string,ModelItem>>} models pool of items per discovered model
|
|
808
|
+
* @param {Shape} mergedLocalShapes local shapes of source folders merged in order of collecting records
|
|
249
809
|
* @returns {DatabaseEndpoints} map of routes into data sets to expose eventually
|
|
250
810
|
*/
|
|
251
|
-
populateModels( endpoints, models ) {
|
|
252
|
-
|
|
811
|
+
populateModels( endpoints, models, mergedLocalShapes ) {
|
|
812
|
+
const modelNames = Object.keys( models );
|
|
813
|
+
let index = 0;
|
|
814
|
+
|
|
815
|
+
for ( const modelName of modelNames ) {
|
|
253
816
|
const model = models[modelName];
|
|
817
|
+
const itemIds = Object.keys( model );
|
|
254
818
|
|
|
255
|
-
|
|
819
|
+
if ( this.options.verbose ) {
|
|
820
|
+
console.error( `creating endpoints for model ${modelName} [${++index}/${modelNames.length}] ...` );
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
if ( this.options.verbose ) {
|
|
824
|
+
console.error( ` - ${itemIds.length} items ...` );
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
for ( const itemId of itemIds ) {
|
|
256
828
|
const normalized = normalizePathname( `${this.prefix}${modelName}/item/${itemId}` );
|
|
257
829
|
if ( normalized.endsWith( "/" ) ) {
|
|
258
830
|
throw new Error( `item ID "${itemId}" of model "${modelName}" results in invalid route for endpoint exposing it` );
|
|
@@ -261,7 +833,7 @@ export class Shaper extends EventEmitter {
|
|
|
261
833
|
endpoints[normalized] = model[itemId]; // eslint-disable-line no-param-reassign
|
|
262
834
|
}
|
|
263
835
|
|
|
264
|
-
this.populateModelCollections( endpoints, models, modelName );
|
|
836
|
+
this.populateModelCollections( endpoints, models, modelName, mergedLocalShapes );
|
|
265
837
|
}
|
|
266
838
|
|
|
267
839
|
return endpoints;
|
|
@@ -272,13 +844,14 @@ export class Shaper extends EventEmitter {
|
|
|
272
844
|
* a model.
|
|
273
845
|
*
|
|
274
846
|
* @param {DatabaseEndpoints} endpoints map of routes into data sets to expose eventually
|
|
275
|
-
* @param {
|
|
847
|
+
* @param {Object<string,Object<string,ModelItem>>} models pool of items per discovered model
|
|
276
848
|
* @param {string} modelName name of model to process
|
|
849
|
+
* @param {Shape} mergedLocalShapes local shapes of source folders merged in order of collecting records
|
|
277
850
|
* @returns {DatabaseEndpoints} map of routes into data sets to expose eventually
|
|
278
851
|
*/
|
|
279
|
-
populateModelCollections( endpoints, models, modelName ) {
|
|
280
|
-
const
|
|
281
|
-
const collections =
|
|
852
|
+
populateModelCollections( endpoints, models, modelName, mergedLocalShapes ) {
|
|
853
|
+
const finalModelShape = merge( {}, mergedLocalShapes.common, mergedLocalShapes.models[modelName] );
|
|
854
|
+
const collections = finalModelShape.collections || {};
|
|
282
855
|
const cache = {};
|
|
283
856
|
|
|
284
857
|
// prepare to process collection instances prior to collection references
|
|
@@ -297,9 +870,15 @@ export class Shaper extends EventEmitter {
|
|
|
297
870
|
} );
|
|
298
871
|
|
|
299
872
|
// process collection by collection
|
|
873
|
+
let index = 0;
|
|
874
|
+
|
|
300
875
|
for ( const route of keys ) {
|
|
301
876
|
const normalized = normalizePathname( route );
|
|
302
877
|
|
|
878
|
+
if ( this.options.verbose ) {
|
|
879
|
+
console.error( ` - collection /${normalized} [${++index}/${keys.length}] ...` );
|
|
880
|
+
}
|
|
881
|
+
|
|
303
882
|
if ( String( normalized ).startsWith( "item/" ) || /(^|\/)\.\.(\/|$)|[\\%]/.test( normalized ) ) {
|
|
304
883
|
throw new Error( `invalid custom route "${normalized}" for collection of model "${modelName}"` );
|
|
305
884
|
}
|
|
@@ -339,14 +918,14 @@ export class Shaper extends EventEmitter {
|
|
|
339
918
|
|
|
340
919
|
for ( const key in collection ) {
|
|
341
920
|
if ( collection.hasOwnProperty( key ) ) {
|
|
342
|
-
this.populateSlicedEndpoint( endpoints, normalized.replace( ptnKey, key ), collection[key], false, modelName );
|
|
921
|
+
this.populateSlicedEndpoint( endpoints, normalized.replace( ptnKey, key ), collection[key], false, modelName, definition );
|
|
343
922
|
}
|
|
344
923
|
}
|
|
345
924
|
} else {
|
|
346
925
|
throw new Error( `invalid use of {key} in route "${normalized}" of regular collection of model "${modelName}"` );
|
|
347
926
|
}
|
|
348
927
|
} else {
|
|
349
|
-
this.populateSlicedEndpoint( endpoints, normalized, collection, isKeyed, modelName );
|
|
928
|
+
this.populateSlicedEndpoint( endpoints, normalized, collection, isKeyed, modelName, definition );
|
|
350
929
|
}
|
|
351
930
|
}
|
|
352
931
|
}
|
|
@@ -361,12 +940,13 @@ export class Shaper extends EventEmitter {
|
|
|
361
940
|
*
|
|
362
941
|
* @param {DatabaseEndpoints} endpoints map of routes into data sets to expose eventually
|
|
363
942
|
* @param {string} route route of current collection
|
|
364
|
-
* @param {{items: Array<object>}|
|
|
943
|
+
* @param {{items: Array<object>}|Object<string, Array<object>>} collection collection of items to expose
|
|
365
944
|
* @param {boolean} isKeyed indicates if collection lists item per key or all items in a single list
|
|
366
945
|
* @param {string} modelName name of model this collection is defined for
|
|
946
|
+
* @param {object} definition provided collection's definition in shape
|
|
367
947
|
* @returns {void}
|
|
368
948
|
*/
|
|
369
|
-
populateSlicedEndpoint( endpoints, route, collection, isKeyed, modelName ) {
|
|
949
|
+
populateSlicedEndpoint( endpoints, route, collection, isKeyed, modelName, definition ) {
|
|
370
950
|
ptnOffset.lastIndex = 0;
|
|
371
951
|
const match = ptnOffset.exec( route );
|
|
372
952
|
|
|
@@ -387,22 +967,30 @@ export class Shaper extends EventEmitter {
|
|
|
387
967
|
const normalized = normalizePathname( prefix + route.replace( ptnOffset, offset ) );
|
|
388
968
|
endpoints[normalized] = { count: items.length, items: items.slice( offset, offset + limit ) }; // eslint-disable-line no-param-reassign
|
|
389
969
|
}
|
|
390
|
-
} else {
|
|
970
|
+
} else if ( isKeyed ) {
|
|
391
971
|
const normalized = normalizePathname( this.prefix + modelName + "/" + route );
|
|
972
|
+
const base = endpoints[normalized] = {}; // eslint-disable-line no-param-reassign
|
|
392
973
|
|
|
393
|
-
|
|
394
|
-
const
|
|
974
|
+
for ( const key of Object.keys( collection ) ) {
|
|
975
|
+
const items = collection[key].items;
|
|
395
976
|
|
|
396
|
-
|
|
397
|
-
const items = collection[key].items;
|
|
977
|
+
base[key] = { count: items.length };
|
|
398
978
|
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
979
|
+
if ( definition.reduce ) {
|
|
980
|
+
// generate single endpoint listing all items of collection grouped by key
|
|
981
|
+
base[key].items = items;
|
|
982
|
+
} else {
|
|
983
|
+
// expose separate endpoint per group of items
|
|
984
|
+
const subNormalized = normalizePathname( this.prefix + modelName + "/" + route + "/" + key );
|
|
403
985
|
|
|
404
|
-
|
|
986
|
+
endpoints[subNormalized] = { count: items.length, items }; // eslint-disable-line no-param-reassign
|
|
987
|
+
}
|
|
405
988
|
}
|
|
989
|
+
} else {
|
|
990
|
+
const normalized = normalizePathname( this.prefix + modelName + "/" + route );
|
|
991
|
+
const items = collection.items;
|
|
992
|
+
|
|
993
|
+
endpoints[normalized] = { count: items.length, items }; // eslint-disable-line no-param-reassign
|
|
406
994
|
}
|
|
407
995
|
}
|
|
408
996
|
|
|
@@ -410,15 +998,15 @@ export class Shaper extends EventEmitter {
|
|
|
410
998
|
* Creates managed data space for evaluating terms in context of a set of
|
|
411
999
|
* items.
|
|
412
1000
|
*
|
|
413
|
-
* @param {
|
|
1001
|
+
* @param {Object<string,Object<string,ModelItem>>} models pool of items per discovered model
|
|
414
1002
|
* @param {string} modelName name of model to process
|
|
415
1003
|
* @param {ShapeCollection} collectionDefinition rules controlling compilation of collection
|
|
416
|
-
* @returns {{handler: {get: ((function(*, *=): (*))|*)}, context: {$
|
|
1004
|
+
* @returns {{handler: {get: ((function(*, *=): (*))|*)}, context: {$database, $model, $collection}}} handler and extensible context for term evaluation
|
|
417
1005
|
*/
|
|
418
1006
|
createTermContextCollection( models, modelName, collectionDefinition ) {
|
|
419
1007
|
const context = {
|
|
420
1008
|
$collection: collectionDefinition,
|
|
421
|
-
$
|
|
1009
|
+
$database: models,
|
|
422
1010
|
$model: modelName,
|
|
423
1011
|
};
|
|
424
1012
|
|
|
@@ -438,7 +1026,7 @@ export class Shaper extends EventEmitter {
|
|
|
438
1026
|
/**
|
|
439
1027
|
* Compiles collection according to its definition in provided shape.
|
|
440
1028
|
*
|
|
441
|
-
* @param {
|
|
1029
|
+
* @param {Object<string,Object<string,ModelItem>>} models pool of items per discovered model
|
|
442
1030
|
* @param {string} modelName name of model to process
|
|
443
1031
|
* @param {ShapeCollection} definition rules controlling compilation of collection
|
|
444
1032
|
* @param {string} route route for resulting collection
|
|
@@ -451,7 +1039,7 @@ export class Shaper extends EventEmitter {
|
|
|
451
1039
|
const collection = isKeyed ? {} : { items: [] };
|
|
452
1040
|
|
|
453
1041
|
|
|
454
|
-
const { handler, context } = this.createTermContextCollection();
|
|
1042
|
+
const { handler, context } = this.createTermContextCollection( models, modelName, definition );
|
|
455
1043
|
|
|
456
1044
|
|
|
457
1045
|
// prepare terms used in compiling collection
|
|
@@ -461,12 +1049,22 @@ export class Shaper extends EventEmitter {
|
|
|
461
1049
|
|
|
462
1050
|
try {
|
|
463
1051
|
keyFn = isKeyed ? Compiler.compile( $key, this.termFunctions, this.termsCache ) : undefined;
|
|
1052
|
+
|
|
1053
|
+
// support processed term requesting to fail
|
|
1054
|
+
if ( keyFn instanceof Error ) {
|
|
1055
|
+
throw keyFn;
|
|
1056
|
+
}
|
|
464
1057
|
} catch ( cause ) {
|
|
465
1058
|
throw new Error( `invalid rule for defining key of collection at "${route}" in ${modelName}: ${cause.message}` );
|
|
466
1059
|
}
|
|
467
1060
|
|
|
468
1061
|
try {
|
|
469
1062
|
filterFn = $filter && typeof $filter === "string" ? Compiler.compile( $filter, this.termFunctions, this.termsCache ) : undefined;
|
|
1063
|
+
|
|
1064
|
+
// support processed term requesting to fail
|
|
1065
|
+
if ( filterFn instanceof Error ) {
|
|
1066
|
+
throw filterFn;
|
|
1067
|
+
}
|
|
470
1068
|
} catch ( cause ) {
|
|
471
1069
|
throw new Error( `invalid rule for defining filter of collection at "${route}" in ${modelName}: ${cause.message}` );
|
|
472
1070
|
}
|
|
@@ -478,6 +1076,11 @@ export class Shaper extends EventEmitter {
|
|
|
478
1076
|
if ( code && typeof code === "string" ) {
|
|
479
1077
|
const fn = Compiler.compile( code, this.termFunctions, this.termsCache );
|
|
480
1078
|
|
|
1079
|
+
// support processed term requesting to fail computed definition of collection
|
|
1080
|
+
if ( fn instanceof Error ) {
|
|
1081
|
+
throw fn;
|
|
1082
|
+
}
|
|
1083
|
+
|
|
481
1084
|
fn.target = target;
|
|
482
1085
|
|
|
483
1086
|
return fn;
|
|
@@ -492,7 +1095,7 @@ export class Shaper extends EventEmitter {
|
|
|
492
1095
|
|
|
493
1096
|
// process all items of model
|
|
494
1097
|
for ( const id in model ) {
|
|
495
|
-
if ( model
|
|
1098
|
+
if ( model[id] && typeof id === "string" && id[0] !== "$" ) {
|
|
496
1099
|
const item = model[id];
|
|
497
1100
|
const data = new Proxy( item, handler );
|
|
498
1101
|
let extracted;
|
|
@@ -548,7 +1151,7 @@ export class Shaper extends EventEmitter {
|
|
|
548
1151
|
/**
|
|
549
1152
|
* Sorts provided collection either in-place or as a copy.
|
|
550
1153
|
*
|
|
551
|
-
* @param {
|
|
1154
|
+
* @param {Object<string,Object<string,ModelItem>>} models pool of items per discovered model
|
|
552
1155
|
* @param {string} modelName name of model to process
|
|
553
1156
|
* @param {ShapeCollection} collectionDefinition rules controlling compilation of collection
|
|
554
1157
|
* @param {string} route route for resulting collection
|
|
@@ -562,6 +1165,11 @@ export class Shaper extends EventEmitter {
|
|
|
562
1165
|
|
|
563
1166
|
try {
|
|
564
1167
|
$sort = $sort && typeof $sort === "string" ? Compiler.compile( $sort, this.termFunctions, this.termsCache ) : $sort?.property ? $sort : undefined;
|
|
1168
|
+
|
|
1169
|
+
// support processed term requesting to fail
|
|
1170
|
+
if ( $sort instanceof Error ) {
|
|
1171
|
+
throw $sort;
|
|
1172
|
+
}
|
|
565
1173
|
} catch ( cause ) {
|
|
566
1174
|
throw new Error( `invalid rule for defining sorting of collection at "${route}" in ${modelName}: ${cause.message}` );
|
|
567
1175
|
}
|