@cepharum/concrete-db 0.1.0 → 0.1.2-alpha.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/lib/shaper.mjs CHANGED
@@ -1,38 +1,10 @@
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
- */
28
-
29
1
  import EventEmitter from "events";
30
2
 
31
3
  import { Compiler, Functions } from "simple-terms";
32
4
  import merge from "lodash.merge";
33
5
 
34
6
  import { DefaultShaperOptions } from "./defaults.mjs";
35
- import { augmentedDataSpace, deepFreeze } from "./helper.mjs";
7
+ import { augmentDataSpace } from "./helper.mjs";
36
8
  import * as TermFunctions from "./term-functions.mjs";
37
9
 
38
10
 
@@ -87,61 +59,561 @@ export class Shaper extends EventEmitter {
87
59
  * Reorganizes records to by grouped by model.
88
60
  *
89
61
  * @param {Map<string,CollectedRecord[]>} collection collection of records to transform
90
- * @returns {object<string,object<string, CollectedRecord[]>>} provided records grouped by model and unique item ID
62
+ * @returns {Object<string,Object<string, CollectedRecord[]>>} provided records grouped by model and unique item ID
91
63
  */
92
64
  groupByModel( collection ) {
93
- const models = {};
94
- const modelShapes = {};
95
-
96
- for ( const records of collection.values() ) {
97
- for ( const record of records ) {
98
- const { segments, shape, data } = record;
99
- const metaProperties = {
100
- $segments: segments,
101
- };
102
- const wrapped = augmentedDataSpace( data, metaProperties );
103
-
104
- const modelName = this.detectModel( wrapped, shape );
105
- metaProperties.$model = modelName;
106
-
107
- if ( !modelShapes.hasOwnProperty( modelName ) ) {
108
- modelShapes[modelName] = deepFreeze( merge( {}, shape?.common, shape?.models?.[modelName] ) );
65
+ const globalItemsPerModel = {};
66
+ const postProcessingQueue = new Set();
67
+
68
+ for ( const source of collection.values() ) {
69
+ const localItemsPerModel = this.compileModelsOfSource( source );
70
+
71
+ this.processContributions( localItemsPerModel, postProcessingQueue );
72
+
73
+ this.mergeItemsPerModel( globalItemsPerModel, localItemsPerModel );
74
+ }
75
+
76
+ this.postProcessContributions( globalItemsPerModel, postProcessingQueue );
77
+
78
+ return globalItemsPerModel;
79
+ }
80
+
81
+ /**
82
+ * Compiles all items according to collected records of provided source and
83
+ * collect them grouped by items' model.
84
+ *
85
+ * @param {CollectedRecord[]} source list of records collected from a source
86
+ * @returns {Object<string,Object<string,Object>>} compiled items grouped by model, addressable by either item's ID
87
+ */
88
+ compileModelsOfSource( source ) {
89
+ const shapesPerModelCache = {};
90
+ const itemsPerModel = {};
91
+
92
+ for ( let read = 0, numRecords = source.length; read < numRecords; read++ ) {
93
+ const item = this.compileModelItemFromRecord( shapesPerModelCache, itemsPerModel, source[read] );
94
+ const modelPool = itemsPerModel[item.$model];
95
+
96
+ const variantId = this.computeVariantIdOfItem( item );
97
+ if ( variantId == null ) {
98
+ modelPool[item.$id] = modelPool[item.$id] ? merge( modelPool[item.$id], item ) : item;
99
+ } else {
100
+ const target = modelPool[item.$id];
101
+
102
+ if ( !target.$variants ) {
103
+ target.$variants = {};
109
104
  }
110
105
 
111
- const modelShape = modelShapes[modelName];
112
- metaProperties.$shape = modelShape;
106
+ target.$variants[variantId] = target.$variants[variantId] ? merge( target.$variants[variantId], item ) : item;
107
+ }
108
+ }
109
+
110
+ return itemsPerModel;
111
+ }
112
+
113
+ /**
114
+ * Compiles item of model from a provided source record. This includes
115
+ * detecting model and selected ID of resulting item from provided record
116
+ * based on record's shape.
117
+ *
118
+ * @param {Object<string,ShapeModel>} shapesPerModelCache caches per-model shapes compiled before
119
+ * @param {Object<string,Object<string,Object>>} itemsPerModel collection of previously compiled items per model
120
+ * @param {CollectedRecord} record augmented data collected from a source to be compiled
121
+ * @returns {object} instance of model with hidden meta information injected
122
+ */
123
+ compileModelItemFromRecord( shapesPerModelCache, itemsPerModel, record ) {
124
+ const { segments, shape, data } = record;
113
125
 
114
- const id = this.identifyRecord( wrapped, modelShape );
115
- metaProperties.$id = id;
126
+ const metaProperties = {
127
+ $segments: segments
128
+ };
116
129
 
117
- let model = models[modelName];
118
- if ( !model ) {
119
- model = models[modelName] = {};
130
+ const augmentedData = augmentDataSpace( data, metaProperties );
120
131
 
121
- Object.defineProperties( model, {
122
- $shape: { value: modelShape },
123
- $model: { value: modelName },
124
- } );
132
+ metaProperties.$model = this.detectModel( augmentedData, shape );
133
+ metaProperties.$shape = this.getShapeOfModel( shapesPerModelCache, shape, metaProperties.$model );
134
+ metaProperties.$id = this.identifyRecord( augmentedData, metaProperties.$shape );
135
+
136
+ if ( !itemsPerModel.hasOwnProperty( metaProperties.$model ) ) {
137
+ // eslint-disable-next-line no-param-reassign
138
+ itemsPerModel[metaProperties.$model] = Object.defineProperties( {}, {
139
+ $model: { value: metaProperties.$model },
140
+ $shape: { value: metaProperties.$shape },
141
+ } );
142
+ }
143
+
144
+ const model = itemsPerModel[metaProperties.$model];
145
+
146
+ const { properties, defaults } = metaProperties.$shape;
147
+ const item = this.compileItem( augmentedData, properties, model[metaProperties.$id] ? {} : defaults );
148
+
149
+ Object.defineProperties( item, {
150
+ $original: { value: data },
151
+ $shape: { value: metaProperties.$shape },
152
+ $model: { value: metaProperties.$model },
153
+ $id: { value: metaProperties.$id },
154
+ $segments: { value: segments },
155
+ $localShape: { value: shape },
156
+ } );
157
+
158
+ return item;
159
+ }
160
+
161
+ /**
162
+ * Re-compiles provided item this time based item's properties' themselves
163
+ * instead of some record originally collected from a source.
164
+ *
165
+ * Re-compiling is used to compute data contributed by other models.
166
+ *
167
+ * @param {CollectedItem} item previously compiled item to re-compile
168
+ * @returns {CollectedItem} re-compiled item
169
+ */
170
+ recompileItem( item ) {
171
+ const data = augmentDataSpace( merge( {}, item.$original, item ), { ...item, $postcontribution: true } );
172
+ const updatedItem = this.compileItem( data, item.$shape.properties, {} );
173
+
174
+ Object.defineProperties( updatedItem, {
175
+ $original: { value: item.$original },
176
+ $shape: { value: item.$shape },
177
+ $model: { value: item.$model },
178
+ $id: { value: item.$id },
179
+ $segments: { value: item.$segments },
180
+ $localShape: { value: item.$localShape },
181
+ } );
182
+
183
+ return updatedItem;
184
+ }
185
+
186
+ /**
187
+ * Computes ID of variant provided item is describing. If nullish, the item
188
+ * isn't describing any variant but the base version of the item.
189
+ *
190
+ * @param {CollectedItem} item an item to get variant ID for
191
+ * @returns {null|any} computed variant ID of provided item, nullish if item is not a variant
192
+ */
193
+ computeVariantIdOfItem( item ) {
194
+ const term = item.$shape.variant;
195
+
196
+ if ( term == null ) {
197
+ return null;
198
+ }
199
+
200
+ // compile data space based on item's properties overlaying the item's originating record
201
+ const data = augmentDataSpace( merge( {}, item.$original, item ), item );
202
+
203
+ try {
204
+ return Compiler.compile( String( term ), this.termFunctions, this.termsCache )( data );
205
+ } catch ( cause ) {
206
+ throw new Error( `computing variant ID of ${item.$model}#${item.$id} failed: ${cause.message} in ${term}` );
207
+ }
208
+ }
209
+
210
+ /**
211
+ * Fetches compiled shape of named model using provided cache.
212
+ *
213
+ * @param {Object<string,ShapeModel>} shapesPerModelCache cache to use on fetching shape for same model multiple times
214
+ * @param {Shape} localShape shape including desired definition of selected model's shape
215
+ * @param {string} modelName name of model
216
+ * @returns {ShapeModel} compiled shape of model incorporating common definitions for all models
217
+ */
218
+ getShapeOfModel( shapesPerModelCache, localShape, modelName ) {
219
+ if ( !shapesPerModelCache.hasOwnProperty( modelName ) ) {
220
+ // eslint-disable-next-line no-param-reassign
221
+ shapesPerModelCache[modelName] = merge( {}, localShape?.common, localShape?.models?.[modelName] );
222
+ }
223
+
224
+ return shapesPerModelCache[modelName];
225
+ }
226
+
227
+ /**
228
+ * Computes dependencies of models on contributions by provided items
229
+ * grouped by model.
230
+ *
231
+ * @param {Object<string,Object<string,Object>>} itemsPerModel compiled items grouped by model
232
+ * @returns {string[]} sorted list of names of models in provided pool starting with model depending on other models' contributions the least
233
+ */
234
+ listContributingModels( itemsPerModel ) {
235
+ const contributorsPerModel = {};
236
+
237
+ // create map of immediate relations between every two models involved
238
+ // in a contribution
239
+ for ( const model of Object.keys( itemsPerModel ) ) {
240
+ for ( const item of Object.values( itemsPerModel[model] ) ) {
241
+ for ( const contributedModel of Object.keys( item.$shape.contributions || {} ) ) {
242
+ if ( !contributorsPerModel.hasOwnProperty( contributedModel ) ) {
243
+ contributorsPerModel[contributedModel] = {};
244
+ }
245
+
246
+ contributorsPerModel[contributedModel][item.$model] = 1;
125
247
  }
248
+ }
249
+ }
250
+
251
+ // surface mediate dependencies and collect all contributing models' names
252
+ const contributingModelNames = {};
253
+
254
+ for ( const surfaceModel of Object.keys( contributorsPerModel ) ) {
255
+ descend( surfaceModel, surfaceModel, [] );
256
+ }
257
+
258
+ // sort names of contributing models from models depending on other
259
+ // contributing models the least to those doing it the most
260
+ const names = Object.keys( contributingModelNames );
261
+
262
+ names.sort( ( left, right ) => {
263
+ if ( contributorsPerModel.hasOwnProperty( left ) && contributorsPerModel[left].hasOwnProperty( right ) ) {
264
+ // left model depends on contributions by right model
265
+ return 1;
266
+ }
126
267
 
127
- const isVariant = model.hasOwnProperty( id );
128
- const item = this.compileItem( wrapped, modelShape, !isVariant );
268
+ if ( contributorsPerModel.hasOwnProperty( right ) && contributorsPerModel[right].hasOwnProperty( left ) ) {
269
+ // right model depends on contributions by left model
270
+ return -1;
271
+ }
272
+
273
+ return 0;
274
+ } );
275
+
276
+ return names;
277
+
278
+ /**
279
+ * Surfaces dependencies of a named model.
280
+ *
281
+ * @param {string} surfaceModel name of model triggering current processing
282
+ * @param {string} currentModel name of currently processed model
283
+ * @param {string[]} breadcrumb names of models processed while descending into dependencies
284
+ * @returns {void}
285
+ */
286
+ function descend( surfaceModel, currentModel, breadcrumb ) {
287
+ if ( breadcrumb.indexOf( currentModel ) > -1 ) {
288
+ throw new Error( `invalid circular dependency on contributing to model ${surfaceModel}: [${breadcrumb}]` );
289
+ }
290
+
291
+ if ( contributorsPerModel.hasOwnProperty( currentModel ) ) {
292
+ const current = contributorsPerModel[currentModel];
293
+ const surface = contributorsPerModel[surfaceModel];
294
+
295
+ for ( const contributingModel of Object.keys( current ) ) {
296
+ contributingModelNames[contributingModel] = true;
297
+
298
+ if ( !surface.hasOwnProperty( contributingModel ) ) {
299
+ surface[contributingModel] = -1;
300
+ } else if ( current[contributingModel] > 0 ) {
301
+ breadcrumb.push( currentModel );
302
+ descend( surfaceModel, contributingModel, breadcrumb );
303
+ breadcrumb.pop();
304
+ }
305
+ }
306
+ }
307
+ }
308
+ }
309
+
310
+ /**
311
+ * Processes contributions of models in provided collection of items
312
+ * augmenting that collection.
313
+ *
314
+ * @param {Object<string,Object<string,Object>>} itemsPerModel map of model names into either model's map of item IDs into related item
315
+ * @param {Set<string>} contributedItems tracks IDs of items or their variants that have been contributed to
316
+ * @returns {void}
317
+ */
318
+ processContributions( itemsPerModel, contributedItems ) {
319
+ const contributingModels = this.listContributingModels( itemsPerModel );
320
+ const shapePerModelCache = {};
321
+
322
+ for ( const modelName of contributingModels ) {
323
+ const items = itemsPerModel[modelName];
324
+
325
+ for ( const item of Object.values( items ) ) {
326
+ const { contributions } = item.$shape;
327
+ if ( !contributions ) {
328
+ continue;
329
+ }
330
+
331
+ for ( const targetModelName of Object.keys( contributions ) ) {
332
+ const targetModelShape = this.getShapeOfModel( shapePerModelCache, item.$localShape, targetModelName );
333
+
334
+
335
+ // extract contributions to actually declared properties of target model
336
+ const contributionTerms = {};
337
+ let warn = false;
338
+
339
+ for ( const termTargetName of Object.keys( contributions[targetModelName] ) ) {
340
+ const propertyName = termTargetName.replace( /\s*\[]$/, "" );
341
+
342
+ if ( propertyName !== termTargetName && propertyName.startsWith( "$" ) ) {
343
+ throw new Error( `invalid collecting contribution of record #${item.$id} in model ${item.$model} to property ${propertyName} of model ${targetModelName}` ); // eslint-disable-line max-len
344
+ }
129
345
 
130
- Object.defineProperties( item, {
131
- $shape: { value: modelShape },
132
- $model: { value: modelName },
133
- $id: { value: id },
346
+ if ( Object.prototype.hasOwnProperty.call( targetModelShape.properties, propertyName ) ) {
347
+ if ( contributionTerms.hasOwnProperty( propertyName ) ) {
348
+ throw new Error( `double contribution of record #${item.$id} in model ${item.$model} to property ${propertyName} of model ${targetModelName}` ); // eslint-disable-line max-len
349
+ }
350
+
351
+ contributionTerms[propertyName] = contributions[targetModelName][termTargetName];
352
+ } else {
353
+ warn = true;
354
+ }
355
+ }
356
+
357
+ if ( Object.keys( contributionTerms ).length < 1 ) {
358
+ console.warn( `ignoring eventually empty contribution of record #${item.$id} in model ${item.$model} to model ${targetModelName}` );
359
+ continue;
360
+ }
361
+
362
+ if ( warn ) {
363
+ console.warn( `ignoring undeclared properties in contribution of record #${item.$id} in model ${item.$model} to model ${targetModelName}` ); // eslint-disable-line max-len
364
+ }
365
+
366
+
367
+ // compile values contributed to target model in context of current item
368
+ const augmentedData = augmentDataSpace( item, item );
369
+ const contribution = this.compileItem( augmentedData, contributionTerms, {}, ["$id"] );
370
+
371
+ if ( Object.keys( contribution ).length < 1 ) {
372
+ console.warn( `ignoring empty contribution of record #${item.$id} in model ${item.$model} to model ${targetModelName}` );
373
+ continue;
374
+ }
375
+
376
+
377
+ // use contribution data's $id property to pick item of target model to contribute to
378
+ const targetId = contribution.$id;
379
+ if ( !targetId ) {
380
+ throw new Error( `contribution of record #${item.$id} in model ${item.$model} to model ${targetModelName} failed due to lack of ID` );
381
+ }
382
+
383
+ const targetIds = Array.isArray( targetId ) ? targetId : [targetId];
384
+
385
+ // check if this contribution is regarding a variant of selected target item
386
+ let variantId;
387
+
388
+ if ( targetModelShape.variant ) {
389
+ // try computing variant ID based on this item's contribution to target item
390
+ variantId = this.computeVariantIdOfItem( {
391
+ ...contribution,
392
+ $shape: targetModelShape,
393
+ $id: targetId,
394
+ $model: targetModelName,
395
+ $original: { ...item.$original, data: {} },
396
+ $segments: item.$segments,
397
+ $localShape: item.$localShape,
398
+ } );
399
+ }
400
+
401
+ if ( variantId == null ) {
402
+ variantId = null;
403
+ }
404
+
405
+
406
+ for ( const id of targetIds ) {
407
+ // track contributions to selected target item or its variant
408
+ contributedItems.add( JSON.stringify( [ targetModelName, id, variantId ] ) );
409
+
410
+
411
+ // gain access on selected target item, create its containers as necessary
412
+ let target;
413
+
414
+ if ( !itemsPerModel.hasOwnProperty( targetModelName ) ) {
415
+ // eslint-disable-next-line no-param-reassign
416
+ itemsPerModel[targetModelName] = Object.defineProperties( {}, {
417
+ $model: { value: targetModelName },
418
+ $shape: { value: targetModelShape },
419
+ } );
420
+ }
421
+
422
+ const model = itemsPerModel[targetModelName];
423
+
424
+ if ( !model.hasOwnProperty( id ) ) {
425
+ model[id] = Object.defineProperties( {}, {
426
+ $original: { value: {} },
427
+ $shape: { value: targetModelShape },
428
+ $model: { value: targetModelName },
429
+ $id: { value: id },
430
+ $segments: { value: item.$segments },
431
+ $localShape: { value: item.$localShape, configurable: true },
432
+ } );
433
+ }
434
+
435
+ const targetItem = model[id];
436
+
437
+ if ( variantId == null ) {
438
+ target = targetItem;
439
+ } else {
440
+ if ( !targetItem.$variants ) {
441
+ targetItem.$variants = {};
442
+ }
443
+
444
+ if ( !targetItem.$variants.hasOwnProperty( variantId ) ) {
445
+ targetItem.$variants[variantId] = {};
446
+ }
447
+
448
+ target = targetItem.$variants[variantId];
449
+ }
450
+
451
+
452
+ // merge this item's contribution with any previous contribution to the selected target
453
+ for ( const name of Object.keys( contribution ) ) {
454
+ if ( String( name ).startsWith( "$" ) ) {
455
+ continue;
456
+ }
457
+
458
+ const value = contribution[name];
459
+
460
+ if ( contributions[targetModelName][name] ) {
461
+ // contribution has been declared without suffix "[]"
462
+ // -> do not collect, but replace value in target
463
+ target[name] = value;
464
+ } else {
465
+ // contribution has been declared with suffix "[]"
466
+ // -> collect value in target
467
+ if ( Array.isArray( target[name] ) ) { // eslint-disable-line no-lonely-if,max-depth
468
+ target[name].push( value );
469
+ } else if ( target[name] == null ) {
470
+ target[name] = [value];
471
+ } else {
472
+ target[name] = [ target[name], value ];
473
+ }
474
+ }
475
+ }
476
+ }
477
+ }
478
+ }
479
+ }
480
+ }
481
+
482
+ /**
483
+ * Re-compiles all items in provided pool that have been contributed to.
484
+ *
485
+ * @param {Object<string,Object<string,CollectedItem>>} itemsPerModel pool of items per model to update
486
+ * @param {Set<string>} contributions lists IDs of items or their variants that have been contributed to
487
+ * @returns {Object<string,Object<string,CollectedItem>>} pool of items per model as provided
488
+ */
489
+ postProcessContributions( itemsPerModel, contributions ) {
490
+ for ( const track of contributions.values() ) {
491
+ const [ model, id, variantId ] = JSON.parse( track );
492
+
493
+ if ( !itemsPerModel.hasOwnProperty( model ) ) {
494
+ console.warn( `inconsistency: missing items pool of model ${model} though contributions have been tracked` );
495
+ continue;
496
+ }
497
+
498
+ const modelPool = itemsPerModel[model];
499
+
500
+ if ( !modelPool.hasOwnProperty( id ) ) {
501
+ console.warn( `inconsistency: missing item ${id} of model ${model} though contributions have been tracked` );
502
+ continue;
503
+ }
504
+
505
+ const item = modelPool[id];
506
+
507
+ if ( variantId == null ) {
508
+ modelPool[id] = this.recompileItem( item );
509
+ } else {
510
+ if ( !item.$variants ) {
511
+ console.warn( `inconsistency: missing variants of item ${id} of model ${model} though contributions have been tracked` );
512
+ continue;
513
+ }
514
+
515
+ const variants = item.$variants;
516
+
517
+ if ( !variants.hasOwnProperty( variantId ) ) {
518
+ console.warn( `inconsistency: missing variant ${variantId} of item ${id} of model ${model} though contributions have been tracked` );
519
+ continue;
520
+ }
521
+
522
+ variants[variantId] = this.recompileItem( variants[variantId] );
523
+ }
524
+ }
525
+
526
+ return itemsPerModel;
527
+ }
528
+
529
+ /**
530
+ * Merges items collected per model from a single source into provided
531
+ * target collection which is covering multiple possible sources.
532
+ *
533
+ * @param {Object<string,Object<string,Object>>} target collection of items per model to be augmented by provided source collection
534
+ * @param {Object<string,Object<string,Object>>} source collection items per model to merge into target
535
+ * @returns {void}
536
+ */
537
+ mergeItemsPerModel( target, source ) {
538
+ for ( const modelName of Object.keys( source ) ) {
539
+ const sourcePool = source[modelName];
540
+
541
+ if ( target.hasOwnProperty( modelName ) ) {
542
+ // blend shapes of pools claiming to refer to same model
543
+ const mergedShape = merge( {}, target[modelName].$shape, sourcePool.$shape );
544
+
545
+ Object.defineProperties( target[modelName], {
546
+ $shape: { value: mergedShape, configurable: true },
547
+ } );
548
+ } else {
549
+ // create pool of items for target model as it hasn't been populated before
550
+ // eslint-disable-next-line no-param-reassign
551
+ target[modelName] = Object.defineProperties( {}, {
552
+ $model: { value: sourcePool.$model },
553
+ $shape: { value: sourcePool.$shape, configurable: true },
134
554
  } );
555
+ }
556
+
557
+ const targetPool = target[modelName];
558
+
559
+ for ( const itemId of Object.keys( sourcePool ) ) {
560
+ if ( targetPool.hasOwnProperty( itemId ) ) {
561
+ const from = sourcePool[itemId];
562
+ const to = targetPool[itemId];
563
+
564
+ targetPool[itemId] = this.mergeItem( to, from );
565
+
566
+ if ( from.$variants ) {
567
+ if ( !to.$variants ) {
568
+ to.$variants = {};
569
+ }
135
570
 
136
- if ( isVariant ) {
137
- // FIXME implement variants or overlays or whatever there is to do with multiple records for the same item
571
+ for ( const variantId of Object.keys( from.$variants ) ) {
572
+ to.$variants[variantId] = this.mergeItem( to.$variants[variantId] || {}, from.$variants[variantId] );
573
+ }
574
+ }
575
+ } else {
576
+ targetPool[itemId] = sourcePool[itemId];
577
+ }
578
+ }
579
+ }
580
+ }
581
+
582
+ /**
583
+ * Updates a given set of properties blending it with another set of item
584
+ * properties.
585
+ *
586
+ * @param {object} to target object to merge properties of source into
587
+ * @param {object} from properties to merge into target
588
+ * @returns {object} provided target object, or new object if provided target was falsy
589
+ */
590
+ mergeItem( to, from ) {
591
+ const _to = to || {};
592
+
593
+ for ( const name of Object.keys( from ) ) {
594
+ if ( !String( name ).startsWith( "$" ) ) {
595
+ // TODO reconsider this merging strategy for being beneficial
596
+ // they way it is by now (e.g. for it keeps nullish data on
597
+ // either end)
598
+ if ( Array.isArray( from[name] ) ) {
599
+ if ( Array.isArray( _to[name] ) ) {
600
+ _to[name].splice( _to[name].length, 0, ...from[name] );
601
+ } else if ( _to.hasOwnProperty( name ) ) {
602
+ _to[name] = [ _to[name], ...from[name] ];
603
+ } else {
604
+ _to[name] = [...from[name]];
605
+ }
606
+ } else if ( Array.isArray( _to[name] ) ) {
607
+ if ( from.hasOwnProperty( name ) ) {
608
+ _to[name].push( from[name] );
609
+ }
138
610
  } else {
139
- model[id] = item;
611
+ _to[name] = from[name];
140
612
  }
141
613
  }
142
614
  }
143
615
 
144
- return models;
616
+ return _to;
145
617
  }
146
618
 
147
619
  /**
@@ -193,33 +665,32 @@ export class Shaper extends EventEmitter {
193
665
  /**
194
666
  * Compiles resulting item by evaluating properties declared in model shape.
195
667
  *
196
- * @param {object} data source of record
197
- * @param {ShapeModel} modelShape shape of particular model
198
- * @param {boolean} useDefaults set true to include defaults configured in shape
668
+ * @param {object} data (augmented) source of record
669
+ * @param {ShapeProperties} properties definition of resulting item's properties
670
+ * @param {ShapePropertyDefaults} defaults definition of default values for resulting item's properties
671
+ * @param {string[]} accept list names of properties to accept even when starting with a dollar sign
199
672
  * @returns {object} compiled item
200
673
  */
201
- compileItem( data, modelShape, useDefaults = true ) {
202
- const properties = modelShape?.properties || {};
203
- const defaults = modelShape?.defaults || {};
674
+ compileItem( data, properties = {}, defaults = {}, accept = [] ) {
204
675
  const item = {};
205
676
 
206
677
  for ( const property of Object.keys( properties ) ) {
207
- if ( !String( property ).startsWith( "$" ) ) {
208
- const selector = properties[property];
209
- if ( typeof selector !== "string" || selector.trim().length === 0 ) {
678
+ if ( accept.indexOf( property ) > -1 || !String( property ).startsWith( "$" ) ) {
679
+ const term = properties[property];
680
+ if ( typeof term !== "string" || term.trim().length === 0 ) {
210
681
  throw new Error( `invalid or missing term for computing value of property ${data.$model}#${property}` );
211
682
  }
212
683
 
213
684
  let value;
214
685
 
215
686
  try {
216
- value = Compiler.compile( selector, this.termFunctions, this.termsCache )( data );
687
+ value = Compiler.compile( term, this.termFunctions, this.termsCache )( data );
217
688
  } catch ( cause ) {
218
- throw new Error( `fetching value of property ${data.$model}#${property} failed: ${cause.message} in ${selector}` );
689
+ throw new Error( `fetching value of property ${data.$model}#${property} failed: ${cause.message} in ${term}` );
219
690
  }
220
691
 
221
692
  if ( value == null ) {
222
- if ( useDefaults && defaults.hasOwnProperty( property ) ) {
693
+ if ( defaults.hasOwnProperty( property ) ) {
223
694
  const defaultValue = defaults[property];
224
695
  const trimmed = typeof defaultValue === "string" ? defaultValue.trim() : "";
225
696
 
@@ -238,14 +709,16 @@ export class Shaper extends EventEmitter {
238
709
  }
239
710
  }
240
711
 
241
- return item;
712
+ // deeply merge fresh object with compiled item to get rid of deep data
713
+ // exposed to terms as Proxy
714
+ return merge( {}, item );
242
715
  }
243
716
 
244
717
  /**
245
718
  * Populates pool of endpoints with collected records of discovered models.
246
719
  *
247
720
  * @param {DatabaseEndpoints} endpoints map of routes into data sets to expose eventually
248
- * @param {object<string,DatabaseKeyedCollection>} models pool of items per discovered model
721
+ * @param {Object<string,Object<string,CollectedItem>>} models pool of items per discovered model
249
722
  * @returns {DatabaseEndpoints} map of routes into data sets to expose eventually
250
723
  */
251
724
  populateModels( endpoints, models ) {
@@ -272,13 +745,13 @@ export class Shaper extends EventEmitter {
272
745
  * a model.
273
746
  *
274
747
  * @param {DatabaseEndpoints} endpoints map of routes into data sets to expose eventually
275
- * @param {object<string,DatabaseKeyedCollection>} models pool of items per discovered model
748
+ * @param {Object<string,Object<string,CollectedItem>>} models pool of items per discovered model
276
749
  * @param {string} modelName name of model to process
277
750
  * @returns {DatabaseEndpoints} map of routes into data sets to expose eventually
278
751
  */
279
752
  populateModelCollections( endpoints, models, modelName ) {
280
753
  const model = models[modelName];
281
- const collections = model.$shape?.collections || {};
754
+ const collections = model.$shape.collections || {};
282
755
  const cache = {};
283
756
 
284
757
  // prepare to process collection instances prior to collection references
@@ -339,14 +812,14 @@ export class Shaper extends EventEmitter {
339
812
 
340
813
  for ( const key in collection ) {
341
814
  if ( collection.hasOwnProperty( key ) ) {
342
- this.populateSlicedEndpoint( endpoints, normalized.replace( ptnKey, key ), collection[key], false, modelName );
815
+ this.populateSlicedEndpoint( endpoints, normalized.replace( ptnKey, key ), collection[key], false, modelName, definition );
343
816
  }
344
817
  }
345
818
  } else {
346
819
  throw new Error( `invalid use of {key} in route "${normalized}" of regular collection of model "${modelName}"` );
347
820
  }
348
821
  } else {
349
- this.populateSlicedEndpoint( endpoints, normalized, collection, isKeyed, modelName );
822
+ this.populateSlicedEndpoint( endpoints, normalized, collection, isKeyed, modelName, definition );
350
823
  }
351
824
  }
352
825
  }
@@ -361,12 +834,13 @@ export class Shaper extends EventEmitter {
361
834
  *
362
835
  * @param {DatabaseEndpoints} endpoints map of routes into data sets to expose eventually
363
836
  * @param {string} route route of current collection
364
- * @param {{items: Array<object>}|object<string, Array<object>>} collection collection of items to expose
837
+ * @param {{items: Array<object>}|Object<string, Array<object>>} collection collection of items to expose
365
838
  * @param {boolean} isKeyed indicates if collection lists item per key or all items in a single list
366
839
  * @param {string} modelName name of model this collection is defined for
840
+ * @param {object} definition provided collection's definition in shape
367
841
  * @returns {void}
368
842
  */
369
- populateSlicedEndpoint( endpoints, route, collection, isKeyed, modelName ) {
843
+ populateSlicedEndpoint( endpoints, route, collection, isKeyed, modelName, definition ) {
370
844
  ptnOffset.lastIndex = 0;
371
845
  const match = ptnOffset.exec( route );
372
846
 
@@ -387,22 +861,30 @@ export class Shaper extends EventEmitter {
387
861
  const normalized = normalizePathname( prefix + route.replace( ptnOffset, offset ) );
388
862
  endpoints[normalized] = { count: items.length, items: items.slice( offset, offset + limit ) }; // eslint-disable-line no-param-reassign
389
863
  }
390
- } else {
864
+ } else if ( isKeyed ) {
391
865
  const normalized = normalizePathname( this.prefix + modelName + "/" + route );
866
+ const base = endpoints[normalized] = {}; // eslint-disable-line no-param-reassign
392
867
 
393
- if ( isKeyed ) {
394
- const qualified = endpoints[normalized] = {}; // eslint-disable-line no-param-reassign
868
+ for ( const key of Object.keys( collection ) ) {
869
+ const items = collection[key].items;
395
870
 
396
- for ( const key of Object.keys( collection ) ) {
397
- const items = collection[key].items;
871
+ base[key] = { count: items.length };
398
872
 
399
- qualified[key] = { count: items.length, items };
400
- }
401
- } else {
402
- const items = collection.items;
873
+ if ( definition.reduce ) {
874
+ // generate single endpoint listing all items of collection grouped by key
875
+ base[key].items = items;
876
+ } else {
877
+ // expose separate endpoint per group of items
878
+ const subNormalized = normalizePathname( this.prefix + modelName + "/" + route + "/" + key );
403
879
 
404
- endpoints[normalized] = { count: items.length, items }; // eslint-disable-line no-param-reassign
880
+ endpoints[subNormalized] = { count: items.length, items }; // eslint-disable-line no-param-reassign
881
+ }
405
882
  }
883
+ } else {
884
+ const normalized = normalizePathname( this.prefix + modelName + "/" + route );
885
+ const items = collection.items;
886
+
887
+ endpoints[normalized] = { count: items.length, items }; // eslint-disable-line no-param-reassign
406
888
  }
407
889
  }
408
890
 
@@ -410,7 +892,7 @@ export class Shaper extends EventEmitter {
410
892
  * Creates managed data space for evaluating terms in context of a set of
411
893
  * items.
412
894
  *
413
- * @param {object<string,DatabaseKeyedCollection>} models pool of items per discovered model
895
+ * @param {Object<string,Object<string,CollectedItem>>} models pool of items per discovered model
414
896
  * @param {string} modelName name of model to process
415
897
  * @param {ShapeCollection} collectionDefinition rules controlling compilation of collection
416
898
  * @returns {{handler: {get: ((function(*, *=): (*))|*)}, context: {$models, $model, $collection}}} handler and extensible context for term evaluation
@@ -438,7 +920,7 @@ export class Shaper extends EventEmitter {
438
920
  /**
439
921
  * Compiles collection according to its definition in provided shape.
440
922
  *
441
- * @param {object<string,DatabaseKeyedCollection>} models pool of items per discovered model
923
+ * @param {Object<string,Object<string,CollectedItem>>} models pool of items per discovered model
442
924
  * @param {string} modelName name of model to process
443
925
  * @param {ShapeCollection} definition rules controlling compilation of collection
444
926
  * @param {string} route route for resulting collection
@@ -451,7 +933,7 @@ export class Shaper extends EventEmitter {
451
933
  const collection = isKeyed ? {} : { items: [] };
452
934
 
453
935
 
454
- const { handler, context } = this.createTermContextCollection();
936
+ const { handler, context } = this.createTermContextCollection( models, modelName, definition );
455
937
 
456
938
 
457
939
  // prepare terms used in compiling collection
@@ -492,7 +974,7 @@ export class Shaper extends EventEmitter {
492
974
 
493
975
  // process all items of model
494
976
  for ( const id in model ) {
495
- if ( model.hasOwnProperty( id ) && typeof id === "string" && id[0] !== "$" ) {
977
+ if ( model[id] && typeof id === "string" && id[0] !== "$" ) {
496
978
  const item = model[id];
497
979
  const data = new Proxy( item, handler );
498
980
  let extracted;
@@ -548,7 +1030,7 @@ export class Shaper extends EventEmitter {
548
1030
  /**
549
1031
  * Sorts provided collection either in-place or as a copy.
550
1032
  *
551
- * @param {object<string,DatabaseKeyedCollection>} models pool of items per discovered model
1033
+ * @param {Object<string,Object<string,CollectedItem>>} models pool of items per discovered model
552
1034
  * @param {string} modelName name of model to process
553
1035
  * @param {ShapeCollection} collectionDefinition rules controlling compilation of collection
554
1036
  * @param {string} route route for resulting collection