@bedrockio/model 0.4.2 → 0.5.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/README.md CHANGED
@@ -756,13 +756,7 @@ complex situations this can easily be a lot of overhead. The include module
756
756
  attempts to streamline this process by adding an `include` method to queries:
757
757
 
758
758
  ```js
759
- const product = await Product.findById(id).include([
760
- 'name',
761
- 'shop.email',
762
- 'shop.user.name',
763
- 'shop.user.address.line1',
764
- 'shop.customers.tags',
765
- ]);
759
+ const product = await Product.findById(id).include('shop.user.customers');
766
760
  ```
767
761
 
768
762
  This method accepts a string or array of strings that will map to a `populate`
@@ -771,20 +765,21 @@ call that can be far more complex:
771
765
  ```js
772
766
  const product = await Product.findById(id).populate([
773
767
  {
774
- select: ['name'],
768
+ select: [],
775
769
  populate: [
776
770
  {
777
771
  path: 'shop',
778
- select: ['email'],
772
+ select: [],
779
773
  populate: [
780
774
  {
781
775
  path: 'user',
782
- select: ['name', 'address.line1'],
776
+ select: [],
783
777
  populate: [],
784
778
  },
785
779
  {
786
780
  path: 'customers',
787
- select: ['tags'],
781
+ select: [],
782
+ populate: [],
788
783
  },
789
784
  ],
790
785
  },
@@ -794,11 +789,131 @@ const product = await Product.findById(id).populate([
794
789
  ```
795
790
 
796
791
  In addition to brevity, one major advantage of using `include` is that the
797
- caller does not need to know whether the documents are subdocuments or foreign
792
+ caller does not need to know whether the path contains subdocuments or foreign
798
793
  references. As Bedrock has knowledge of the schemas, it is able to build the
799
794
  appropriate call to `populate` for you.
800
795
 
801
- #### Excluding Fields
796
+ #### Exclusive Fields
797
+
798
+ By default, arguments to `include` are for population. However often field
799
+ projection (selection) is also desired to avoid excessive data transfer. The `^`
800
+ token can be used here to build the `select` option to populates:
801
+
802
+ ```js
803
+ const product = await Product.findById(id).include([
804
+ '^name',
805
+ '^shop.name',
806
+ '^shop.user.name',
807
+ ]);
808
+ ```
809
+
810
+ This will map to a selective inclusion of fields in the `populate` call:
811
+
812
+ ```js
813
+ const product = await Product.findById(id).populate([
814
+ {
815
+ select: ['name', 'shop'],
816
+ populate: [
817
+ {
818
+ path: 'shop',
819
+ select: ['name', 'user'],
820
+ populate: [
821
+ {
822
+ path: 'user',
823
+ select: ['name'],
824
+ populate: [],
825
+ },
826
+ ],
827
+ },
828
+ ],
829
+ },
830
+ ]);
831
+ ```
832
+
833
+ The resulting data will include only the specified fields:
834
+
835
+ ```json
836
+ {
837
+ "name": "Product Name",
838
+ "shop": {
839
+ "name": "Shop Name",
840
+ "user": {
841
+ "name": "User Name"
842
+ }
843
+ }
844
+ }
845
+ ```
846
+
847
+ Note that the exclusive operator can be used anywhere in the path. The exclusion
848
+ (select) will be applied at the depth in which it is specified:
849
+
850
+ Example 1:
851
+
852
+ - Top level exclusion
853
+ - Only exact field returned.
854
+
855
+ ```js
856
+ await Product.findById(id).include('^shop.user.name').
857
+ ```
858
+
859
+ ```json
860
+ {
861
+ "shop": {
862
+ "user": {
863
+ "name": "User Name"
864
+ }
865
+ }
866
+ }
867
+ ```
868
+
869
+ Example 2:
870
+
871
+ - Mid-level exclusion.
872
+ - Top level fields included, mid-level begins exclusion.
873
+
874
+ ```js
875
+ await Product.findById(id).include('shop.^user.name').
876
+ ```
877
+
878
+ ```json
879
+ {
880
+ "name": "Product Name",
881
+ "cost": 10,
882
+ // etc
883
+ "shop": {
884
+ "user": {
885
+ "name": "User Name"
886
+ }
887
+ }
888
+ }
889
+ ```
890
+
891
+ Example 3:
892
+
893
+ - Final level exclusion.
894
+ - All fields returned except in final `user` population.
895
+
896
+ ```js
897
+ await Product.findById(id).include('shop.user.^name').
898
+ ```
899
+
900
+ ```json
901
+ {
902
+ "name": "Product Name",
903
+ "cost": 10,
904
+ // etc
905
+ "shop": {
906
+ "name": "Shop Name",
907
+ "rating": 5,
908
+ // etc
909
+ "user": {
910
+ "name": "User Name"
911
+ }
912
+ }
913
+ }
914
+ ```
915
+
916
+ #### Excluded Fields
802
917
 
803
918
  Fields can be excluded rather than included using `-`:
804
919
 
@@ -813,6 +928,7 @@ The above will return all fields except `profile`. Note that:
813
928
  - An excluded field on a foreign reference will implicitly be populated. This
814
929
  means that passing `-profile.name` where `profile` is a foreign field will
815
930
  populate `profile` but exclude `name`.
931
+ - Note that `-` can only be used at the beginning of the path.
816
932
 
817
933
  #### Wildcards
818
934
 
@@ -820,35 +936,43 @@ Multiple fields can be selected using wildcards:
820
936
 
821
937
  - `*` - Matches anything except `.`.
822
938
  - `**` - Matches anything including `.`.
939
+ - Note that the use of wildcards implies that other fields are excluded.
940
+
941
+ Example 1: Single wildcard
823
942
 
824
943
  ```js
825
- // Assuming a schema of:
826
- // {
827
- // "firstName": "String"
828
- // "lastName": "String"
829
- // }
830
944
  const user = await User.findById(id).include('*Name');
831
945
  ```
832
946
 
833
- The example above will select both `firstName` and `lastName`.
947
+ ```json
948
+ {
949
+ "firstName": "Frank",
950
+ "lastName": "Reynolds"
951
+ // Other fields excluded.
952
+ }
953
+ ```
954
+
955
+ Example 2: Double wildcard
834
956
 
835
957
  ```js
836
- // Assuming a schema of:
837
- // {
838
- // "profile1": {
839
- // "address": {
840
- // "phone": "String"
841
- // }
842
- // },
843
- // "profile2": {
844
- // "address": {
845
- // "phone": "String"
846
- // }
847
- // }
848
- // }
849
958
  const user = await User.findById(id).include('**.phone');
850
959
  ```
851
960
 
961
+ ```json
962
+ {
963
+ "profile1": {
964
+ "address": {
965
+ "phone": "String"
966
+ }
967
+ },
968
+ "profile2": {
969
+ "address": {
970
+ "phone": "String"
971
+ }
972
+ }
973
+ }
974
+ ```
975
+
852
976
  This example above will select both `profile1.address.phone` and
853
977
  `profile2.address.phone`. Compare this to `*` which will not match here.
854
978
 
@@ -186,13 +186,19 @@ function nodeToPopulates(node) {
186
186
  const select = [];
187
187
  const populate = [];
188
188
  for (let [key, value] of Object.entries(node)) {
189
+ if (key.startsWith('-')) {
190
+ select.push(key);
191
+ continue;
192
+ }
193
+ if (key.startsWith('^')) {
194
+ key = key.slice(1);
195
+ select.push(key);
196
+ }
189
197
  if (value) {
190
198
  populate.push({
191
199
  path: key,
192
200
  ...nodeToPopulates(value)
193
201
  });
194
- } else {
195
- select.push(key);
196
202
  }
197
203
  }
198
204
  return {
@@ -212,24 +218,17 @@ function pathsToNode(paths, modelName) {
212
218
  if (typeof str !== 'string') {
213
219
  throw new Error('Provided include path was not as string.');
214
220
  }
215
- let exclude = false;
216
- if (str.startsWith('-')) {
217
- exclude = true;
218
- str = str.slice(1);
219
- }
220
221
  setNodePath(node, {
221
222
  path: str.split('.'),
222
- modelName,
223
- exclude
223
+ modelName
224
224
  });
225
225
  }
226
226
  return node;
227
227
  }
228
228
  function setNodePath(node, options) {
229
229
  const {
230
- path,
231
230
  modelName,
232
- exclude,
231
+ path: fullPath,
233
232
  depth = 0
234
233
  } = options;
235
234
  if (depth > _const.POPULATE_MAX_DEPTH) {
@@ -239,50 +238,104 @@ function setNodePath(node, options) {
239
238
  if (!schema) {
240
239
  throw new Error(`Could not derive schema for ${modelName}.`);
241
240
  }
241
+ let {
242
+ excluded = false,
243
+ exclusive = false
244
+ } = options;
242
245
  const parts = [];
243
- for (let part of path) {
246
+ for (let part of fullPath) {
247
+ if (part.startsWith('-')) {
248
+ // Field is excluded. Note that this occurs only at
249
+ // top level and should take precedence:
250
+ // -name -> "name" is excluded
251
+ // -user.name -> "user" is populated but "name" is
252
+ // excluded
253
+ excluded = true;
254
+ part = part.slice(1);
255
+ } else if (!excluded && part.startsWith('^')) {
256
+ // Field is exclusive. Note that this can happen at
257
+ // any part of the path:
258
+ // ^name -> "name" is exclusively selected
259
+ // user.^name -> "user" is populated with "name"
260
+ // exclusively selected within
261
+ // ^user.name -> "user" is exclusively selected
262
+ // ("name" is redundant)
263
+ exclusive = true;
264
+ part = part.slice(1);
265
+ } else if (!excluded && part.includes('*')) {
266
+ // Wildcards in field implies exclusion, but only
267
+ // if path is not already excluded:
268
+ // *name -> "firstName" and "lastName" are exclusively
269
+ // selected
270
+ // user.*name -> "user" is populated, "user.firstName"
271
+ // and "user.lastName" are exclusively selected
272
+ // -*name -> "firstName" and "lastName" are excluded
273
+ // -user.*name -> "user" is populated but "user.firstName"
274
+ // and "user.lastName" are excluded
275
+ exclusive = true;
276
+ }
244
277
  parts.push(part);
245
- const str = parts.join('.');
246
- const isExact = parts.length === path.length;
278
+ const isExact = parts.length === fullPath.length;
247
279
  let halt = false;
248
- for (let [key, type] of resolvePaths(schema, str)) {
280
+ for (let [path, type] of resolvePaths(schema, parts.join('.'))) {
281
+ // The exclusive key.
282
+ const eKey = '^' + path;
283
+ // The negative (excluded) key.
284
+ const nKey = '-' + path;
285
+ let key = path;
286
+ if (exclusive && !node[key]) {
287
+ // Add the exclusive flag if the node
288
+ // has not already been included.
289
+ key = eKey;
290
+ } else if (!exclusive && node[eKey]) {
291
+ // If the node has already been marked exclusive
292
+ // and another include overrides it, then we need
293
+ // to move that node over to be inclusive. This
294
+ // step is needed as includes should always take
295
+ // priority over exclusion regardless of the order.
296
+ node[key] = node[eKey];
297
+ delete node[eKey];
298
+ } else if (excluded && isExact) {
299
+ // Only flag the node as excluded if the path is an
300
+ // exact match:
301
+ // -name -> Exclude "name" field.
302
+ // -user.name -> Exclude "user.name" field, however this
303
+ // implies that we must populate "user" so
304
+ // continue traversing and flag for include.
305
+ key = nKey;
306
+ }
249
307
  if (type === 'real') {
250
- const field = (0, _utils.getInnerField)(schema.obj, key);
251
- // Only exclude the field if the match is exact, ie:
252
- // -name - Exclude "name"
253
- // -user.name - Implies population of "user" but exclude "user.name",
254
- // so continue traversing into object when part is "user".
255
- if (isExact && exclude) {
256
- node['-' + key] = LEAF_NODE;
257
- } else if (field.ref) {
308
+ const field = (0, _utils.getInnerField)(schema.obj, path);
309
+ if (field.ref) {
258
310
  node[key] ||= {};
259
311
  setNodePath(node[key], {
260
312
  modelName: field.ref,
261
- path: path.slice(parts.length),
313
+ path: fullPath.slice(parts.length),
262
314
  depth: depth + 1,
263
- exclude
315
+ excluded,
316
+ exclusive
264
317
  });
265
318
  halt = true;
266
319
  } else if ((0, _utils.isSchemaTypedef)(field)) {
267
320
  node[key] = LEAF_NODE;
268
321
  }
269
322
  } else if (type === 'virtual') {
270
- const virtual = schema.virtual(key);
323
+ const virtual = schema.virtual(path);
271
324
  // @ts-ignore
272
325
  const ref = virtual.options.ref;
273
326
  if (ref) {
274
327
  node[key] ||= {};
275
328
  setNodePath(node[key], {
276
- // @ts-ignore
277
329
  modelName: ref,
278
- path: path.slice(parts.length),
330
+ path: fullPath.slice(parts.length),
279
331
  depth: depth + 1,
280
- exclude
332
+ excluded,
333
+ exclusive
281
334
  });
282
335
  }
283
336
  halt = true;
284
337
  } else if (type !== 'nested') {
285
- throw new Error(`Unknown path on ${modelName}: ${key}.`);
338
+ throw new Error(`Unknown path on ${modelName}: ${path}.`);
286
339
  }
287
340
  }
288
341
  if (halt) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bedrockio/model",
3
- "version": "0.4.2",
3
+ "version": "0.5.0",
4
4
  "description": "Bedrock utilities for model creation.",
5
5
  "type": "module",
6
6
  "scripts": {
package/src/include.js CHANGED
@@ -175,13 +175,19 @@ function nodeToPopulates(node) {
175
175
  const select = [];
176
176
  const populate = [];
177
177
  for (let [key, value] of Object.entries(node)) {
178
+ if (key.startsWith('-')) {
179
+ select.push(key);
180
+ continue;
181
+ }
182
+ if (key.startsWith('^')) {
183
+ key = key.slice(1);
184
+ select.push(key);
185
+ }
178
186
  if (value) {
179
187
  populate.push({
180
188
  path: key,
181
189
  ...nodeToPopulates(value),
182
190
  });
183
- } else {
184
- select.push(key);
185
191
  }
186
192
  }
187
193
  return {
@@ -202,75 +208,127 @@ function pathsToNode(paths, modelName) {
202
208
  if (typeof str !== 'string') {
203
209
  throw new Error('Provided include path was not as string.');
204
210
  }
205
- let exclude = false;
206
- if (str.startsWith('-')) {
207
- exclude = true;
208
- str = str.slice(1);
209
- }
210
211
  setNodePath(node, {
211
212
  path: str.split('.'),
212
213
  modelName,
213
- exclude,
214
214
  });
215
215
  }
216
216
  return node;
217
217
  }
218
218
 
219
219
  function setNodePath(node, options) {
220
- const { path, modelName, exclude, depth = 0 } = options;
220
+ const { modelName, path: fullPath, depth = 0 } = options;
221
221
  if (depth > POPULATE_MAX_DEPTH) {
222
222
  throw new Error(`Cannot populate more than ${POPULATE_MAX_DEPTH} levels.`);
223
223
  }
224
+
224
225
  const schema = mongoose.models[modelName]?.schema;
225
226
  if (!schema) {
226
227
  throw new Error(`Could not derive schema for ${modelName}.`);
227
228
  }
229
+
230
+ let { excluded = false, exclusive = false } = options;
231
+
228
232
  const parts = [];
229
- for (let part of path) {
233
+ for (let part of fullPath) {
234
+ if (part.startsWith('-')) {
235
+ // Field is excluded. Note that this occurs only at
236
+ // top level and should take precedence:
237
+ // -name -> "name" is excluded
238
+ // -user.name -> "user" is populated but "name" is
239
+ // excluded
240
+ excluded = true;
241
+ part = part.slice(1);
242
+ } else if (!excluded && part.startsWith('^')) {
243
+ // Field is exclusive. Note that this can happen at
244
+ // any part of the path:
245
+ // ^name -> "name" is exclusively selected
246
+ // user.^name -> "user" is populated with "name"
247
+ // exclusively selected within
248
+ // ^user.name -> "user" is exclusively selected
249
+ // ("name" is redundant)
250
+ exclusive = true;
251
+ part = part.slice(1);
252
+ } else if (!excluded && part.includes('*')) {
253
+ // Wildcards in field implies exclusion, but only
254
+ // if path is not already excluded:
255
+ // *name -> "firstName" and "lastName" are exclusively
256
+ // selected
257
+ // user.*name -> "user" is populated, "user.firstName"
258
+ // and "user.lastName" are exclusively selected
259
+ // -*name -> "firstName" and "lastName" are excluded
260
+ // -user.*name -> "user" is populated but "user.firstName"
261
+ // and "user.lastName" are excluded
262
+ exclusive = true;
263
+ }
264
+
230
265
  parts.push(part);
231
- const str = parts.join('.');
232
- const isExact = parts.length === path.length;
266
+ const isExact = parts.length === fullPath.length;
267
+
233
268
  let halt = false;
234
269
 
235
- for (let [key, type] of resolvePaths(schema, str)) {
270
+ for (let [path, type] of resolvePaths(schema, parts.join('.'))) {
271
+ // The exclusive key.
272
+ const eKey = '^' + path;
273
+ // The negative (excluded) key.
274
+ const nKey = '-' + path;
275
+
276
+ let key = path;
277
+ if (exclusive && !node[key]) {
278
+ // Add the exclusive flag if the node
279
+ // has not already been included.
280
+ key = eKey;
281
+ } else if (!exclusive && node[eKey]) {
282
+ // If the node has already been marked exclusive
283
+ // and another include overrides it, then we need
284
+ // to move that node over to be inclusive. This
285
+ // step is needed as includes should always take
286
+ // priority over exclusion regardless of the order.
287
+ node[key] = node[eKey];
288
+ delete node[eKey];
289
+ } else if (excluded && isExact) {
290
+ // Only flag the node as excluded if the path is an
291
+ // exact match:
292
+ // -name -> Exclude "name" field.
293
+ // -user.name -> Exclude "user.name" field, however this
294
+ // implies that we must populate "user" so
295
+ // continue traversing and flag for include.
296
+ key = nKey;
297
+ }
298
+
236
299
  if (type === 'real') {
237
- const field = getInnerField(schema.obj, key);
238
- // Only exclude the field if the match is exact, ie:
239
- // -name - Exclude "name"
240
- // -user.name - Implies population of "user" but exclude "user.name",
241
- // so continue traversing into object when part is "user".
242
- if (isExact && exclude) {
243
- node['-' + key] = LEAF_NODE;
244
- } else if (field.ref) {
300
+ const field = getInnerField(schema.obj, path);
301
+ if (field.ref) {
245
302
  node[key] ||= {};
246
303
  setNodePath(node[key], {
247
304
  modelName: field.ref,
248
- path: path.slice(parts.length),
305
+ path: fullPath.slice(parts.length),
249
306
  depth: depth + 1,
250
- exclude,
307
+ excluded,
308
+ exclusive,
251
309
  });
252
310
  halt = true;
253
311
  } else if (isSchemaTypedef(field)) {
254
312
  node[key] = LEAF_NODE;
255
313
  }
256
314
  } else if (type === 'virtual') {
257
- const virtual = schema.virtual(key);
315
+ const virtual = schema.virtual(path);
258
316
  // @ts-ignore
259
317
  const ref = virtual.options.ref;
260
318
 
261
319
  if (ref) {
262
320
  node[key] ||= {};
263
321
  setNodePath(node[key], {
264
- // @ts-ignore
265
322
  modelName: ref,
266
- path: path.slice(parts.length),
323
+ path: fullPath.slice(parts.length),
267
324
  depth: depth + 1,
268
- exclude,
325
+ excluded,
326
+ exclusive,
269
327
  });
270
328
  }
271
329
  halt = true;
272
330
  } else if (type !== 'nested') {
273
- throw new Error(`Unknown path on ${modelName}: ${key}.`);
331
+ throw new Error(`Unknown path on ${modelName}: ${path}.`);
274
332
  }
275
333
  }
276
334