@bedrockio/model 0.6.0 → 0.7.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
@@ -19,6 +19,7 @@ Bedrock utilities for model creation.
19
19
  - [Delete Hooks](#delete-hooks)
20
20
  - [Access Control](#access-control)
21
21
  - [Assign](#assign)
22
+ - [Upsert](#upsert)
22
23
  - [Slugs](#slugs)
23
24
  - [Testing](#testing)
24
25
  - [Troubleshooting](#troubleshooting)
@@ -568,11 +569,55 @@ will be flattened to:
568
569
  }
569
570
  ```
570
571
 
572
+ #### Searching Arrays
573
+
574
+ Passing an array to `search` will perform a query using `$in`, which matches on
575
+ the intersection of any elements. For example,
576
+
577
+ ```json
578
+ tags: ["one", "two"]
579
+ ```
580
+
581
+ is effectively saying "match any documents whose `tags` array contains any of
582
+ `one` or `two`".
583
+
584
+ For this reason, passing an empty array here is ambiguous as it could be asking:
585
+
586
+ - Match any documents whose `tags` array is empty.
587
+ - Match any documents whose `tags` array contains any of `[]` (ie. no elements
588
+ passed so no match).
589
+
590
+ The difference here is subtle, but for example in a UI field where tags can be
591
+ chosen but none happen to be selected, it would be unexpected to return
592
+ documents simply because their `tags` array happens to be empty.
593
+
594
+ The `search` method takes the simpler approach here where an empty array will
595
+ simply be passed along to `$in`, which will never result in a match.
596
+
597
+ However, as a special case, `null` may instead be passed to `search` for array
598
+ fields to explicitly search for empty arrays:
599
+
600
+ ```js
601
+ await User.search({
602
+ tags: null,
603
+ });
604
+ // Equivalent to:
605
+ await User.find({
606
+ tags: [],
607
+ });
608
+ ```
609
+
610
+ This works because Mongoose will always initialize an array field in its
611
+ documents (ie. the field is always guarnateed to exist and be an array), so an
612
+ empty array can be thought of as equivalent to `{ $exists: false }` for array
613
+ fields. Thinking of it this way makes it more intuitive that `null` should
614
+ match.
615
+
571
616
  #### Range Based Search
572
617
 
573
618
  Additionally, date and number fields allow range queries in the form:
574
619
 
575
- ```
620
+ ```json
576
621
  age: {
577
622
  gt: 1
578
623
  lt: 2
@@ -653,7 +698,57 @@ schema.pre('save', function () {
653
698
  });
654
699
  ```
655
700
 
656
- ##### Syncing Cache Fields
701
+ #### Syncing Cached Fields
702
+
703
+ When a foreign document is updated the cached fields will be out of sync, for
704
+ example:
705
+
706
+ ```js
707
+ await shop.save();
708
+ console.log(shop.userName);
709
+ // The current user name
710
+
711
+ user.name = 'New Name';
712
+ await user.save();
713
+
714
+ shop = await Shop.findById(shop.id);
715
+ console.log(shop.userName);
716
+ // Cached userName is out of sync as the user has been updated
717
+ ```
718
+
719
+ A simple mechanism is provided via the `sync` key to keep the documents in sync:
720
+
721
+ ```jsonc
722
+ // In user.json
723
+ {
724
+ "attributes": {
725
+ "name": "String"
726
+ },
727
+ "search": {
728
+ "sync": [
729
+ {
730
+ "ref": "Shop",
731
+ "path": "user"
732
+ }
733
+ ]
734
+ }
735
+ }
736
+ ```
737
+
738
+ This is the equivalent of running the following in a post save hook:
739
+
740
+ ```js
741
+ const shops = await Shop.find({
742
+ user: user.id,
743
+ });
744
+ for (let shop of shops) {
745
+ await shop.save();
746
+ }
747
+ ```
748
+
749
+ This will run the hooks on each shop, synchronizing the cached fields.
750
+
751
+ ##### Initial Sync
657
752
 
658
753
  When first applying or making changes to defined cached search fields, existing
659
754
  documents will be out of sync. The static method `syncCacheFields` is provided
@@ -1490,6 +1585,12 @@ provided as `undefined` cannot be represented in JSON which requires using
1490
1585
  either a `null` or empty string, both of which would be stored in the database
1491
1586
  if naively assigned with `Object.assign`.
1492
1587
 
1588
+ ### Upsert
1589
+
1590
+ This module adds a single `findOrCreate` convenience method that is easy to
1591
+ understand and avoids some of the gotchas that come with upserting documents in
1592
+ Mongoose.
1593
+
1493
1594
  ### Slugs
1494
1595
 
1495
1596
  A common requirement is to allow slugs on documents to serve as ids for human
@@ -237,6 +237,8 @@ function normalizeQuery(query, schema, root = {}, rootPath = []) {
237
237
  root[path.join('.')] = {
238
238
  $in: value
239
239
  };
240
+ } else if (isEmptyArrayQuery(schema, key, value)) {
241
+ root[path.join('.')] = [];
240
242
  } else {
241
243
  root[path.join('.')] = value;
242
244
  }
@@ -256,12 +258,15 @@ function isNestedQuery(key, value) {
256
258
  function isArrayQuery(key, value) {
257
259
  return !isMongoOperator(key) && !isInclude(key) && Array.isArray(value);
258
260
  }
261
+ function isEmptyArrayQuery(schema, key, value) {
262
+ return !isMongoOperator(key) && (0, _utils.isArrayField)(schema, key) && value === null;
263
+ }
259
264
  function isRangeQuery(schema, key, value) {
260
265
  // Range queries only allowed on Date and Number fields.
261
266
  if (!(0, _utils.isDateField)(schema, key) && !(0, _utils.isNumberField)(schema, key)) {
262
267
  return false;
263
268
  }
264
- return typeof value === 'object';
269
+ return typeof value === 'object' && !!value;
265
270
  }
266
271
  function mapOperatorQuery(obj) {
267
272
  const query = {};
@@ -323,9 +328,7 @@ function applySearchCache(schema, definition) {
323
328
  if (!force) {
324
329
  const $or = Object.keys(cache).map(cachedField => {
325
330
  return {
326
- [cachedField]: {
327
- $exists: false
328
- }
331
+ [cachedField]: null
329
332
  };
330
333
  });
331
334
  query.$or = $or;
package/dist/cjs/utils.js CHANGED
@@ -5,6 +5,7 @@ Object.defineProperty(exports, "__esModule", {
5
5
  });
6
6
  exports.getField = getField;
7
7
  exports.getInnerField = getInnerField;
8
+ exports.isArrayField = isArrayField;
8
9
  exports.isDateField = isDateField;
9
10
  exports.isEqual = isEqual;
10
11
  exports.isMongooseSchema = isMongooseSchema;
@@ -44,6 +45,10 @@ function isDateField(obj, path) {
44
45
  function isNumberField(obj, path) {
45
46
  return isType(obj, path, 'Number');
46
47
  }
48
+ function isArrayField(obj, path) {
49
+ const field = getField(obj, path);
50
+ return Array.isArray(field?.type);
51
+ }
47
52
  function isType(obj, path, test) {
48
53
  const {
49
54
  type
@@ -137,6 +137,7 @@ function applyValidation(schema, definition) {
137
137
  } = options;
138
138
  return getSchemaFromMongoose(schema, {
139
139
  model: this,
140
+ allowNull: true,
140
141
  allowSearch: true,
141
142
  skipRequired: true,
142
143
  allowInclude: true,
@@ -284,9 +285,11 @@ function getSchemaForTypedef(typedef, options = {}) {
284
285
  } else {
285
286
  schema = getSchemaForType(type, options);
286
287
 
287
- // Unsetting only allowed for primitive types.
288
+ // Only allowed for primitive types.
288
289
  if (allowUnset(typedef, options)) {
289
- schema = _yada.default.allow(null, '', schema);
290
+ schema = _yada.default.allow(schema, '').nullable();
291
+ } else if (allowNull(typedef, options)) {
292
+ schema = schema.nullable();
290
293
  } else if (allowEmpty(typedef, options)) {
291
294
  schema = schema.options({
292
295
  allowEmpty: true
@@ -402,6 +405,9 @@ function getSearchSchema(schema, type) {
402
405
  function isRequired(typedef, options) {
403
406
  return typedef.required && !typedef.default && !options.skipRequired;
404
407
  }
408
+ function allowNull(typedef, options) {
409
+ return options.allowNull && !typedef.required;
410
+ }
405
411
  function allowUnset(typedef, options) {
406
412
  return options.allowUnset && !typedef.required;
407
413
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bedrockio/model",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "Bedrock utilities for model creation.",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -30,7 +30,7 @@
30
30
  "lodash": "^4.17.21"
31
31
  },
32
32
  "peerDependencies": {
33
- "@bedrockio/yada": "^1.1.2",
33
+ "@bedrockio/yada": "^1.1.3",
34
34
  "mongoose": "^8.5.1"
35
35
  },
36
36
  "devDependencies": {
@@ -38,7 +38,7 @@
38
38
  "@babel/core": "^7.20.12",
39
39
  "@babel/preset-env": "^7.20.2",
40
40
  "@bedrockio/prettier-config": "^1.0.2",
41
- "@bedrockio/yada": "^1.1.2",
41
+ "@bedrockio/yada": "^1.1.3",
42
42
  "@shelf/jest-mongodb": "^4.3.2",
43
43
  "eslint": "^8.33.0",
44
44
  "eslint-plugin-bedrock": "^1.0.26",
package/src/search.js CHANGED
@@ -3,7 +3,7 @@ import logger from '@bedrockio/logger';
3
3
  import mongoose from 'mongoose';
4
4
  import { get, pick, isEmpty, escapeRegExp, isPlainObject } from 'lodash';
5
5
 
6
- import { isDateField, isNumberField, getField } from './utils';
6
+ import { isArrayField, isDateField, isNumberField, getField } from './utils';
7
7
  import { SEARCH_DEFAULTS } from './const';
8
8
  import { OBJECT_ID_SCHEMA } from './validation';
9
9
  import { debug } from './env';
@@ -241,6 +241,8 @@ function normalizeQuery(query, schema, root = {}, rootPath = []) {
241
241
  root[path.join('.')] = parseRegexQuery(value);
242
242
  } else if (isArrayQuery(key, value)) {
243
243
  root[path.join('.')] = { $in: value };
244
+ } else if (isEmptyArrayQuery(schema, key, value)) {
245
+ root[path.join('.')] = [];
244
246
  } else {
245
247
  root[path.join('.')] = value;
246
248
  }
@@ -262,12 +264,16 @@ function isArrayQuery(key, value) {
262
264
  return !isMongoOperator(key) && !isInclude(key) && Array.isArray(value);
263
265
  }
264
266
 
267
+ function isEmptyArrayQuery(schema, key, value) {
268
+ return !isMongoOperator(key) && isArrayField(schema, key) && value === null;
269
+ }
270
+
265
271
  function isRangeQuery(schema, key, value) {
266
272
  // Range queries only allowed on Date and Number fields.
267
273
  if (!isDateField(schema, key) && !isNumberField(schema, key)) {
268
274
  return false;
269
275
  }
270
- return typeof value === 'object';
276
+ return typeof value === 'object' && !!value;
271
277
  }
272
278
 
273
279
  function mapOperatorQuery(obj) {
@@ -340,9 +346,7 @@ function applySearchCache(schema, definition) {
340
346
  if (!force) {
341
347
  const $or = Object.keys(cache).map((cachedField) => {
342
348
  return {
343
- [cachedField]: {
344
- $exists: false,
345
- },
349
+ [cachedField]: null,
346
350
  };
347
351
  });
348
352
  query.$or = $or;
package/src/utils.js CHANGED
@@ -36,6 +36,11 @@ export function isNumberField(obj, path) {
36
36
  return isType(obj, path, 'Number');
37
37
  }
38
38
 
39
+ export function isArrayField(obj, path) {
40
+ const field = getField(obj, path);
41
+ return Array.isArray(field?.type);
42
+ }
43
+
39
44
  function isType(obj, path, test) {
40
45
  const { type } = getInnerField(obj, path);
41
46
  return type === test || type === mongoose.Schema.Types[test];
package/src/validation.js CHANGED
@@ -152,6 +152,7 @@ export function applyValidation(schema, definition) {
152
152
 
153
153
  return getSchemaFromMongoose(schema, {
154
154
  model: this,
155
+ allowNull: true,
155
156
  allowSearch: true,
156
157
  skipRequired: true,
157
158
  allowInclude: true,
@@ -299,9 +300,11 @@ function getSchemaForTypedef(typedef, options = {}) {
299
300
  } else {
300
301
  schema = getSchemaForType(type, options);
301
302
 
302
- // Unsetting only allowed for primitive types.
303
+ // Only allowed for primitive types.
303
304
  if (allowUnset(typedef, options)) {
304
- schema = yd.allow(null, '', schema);
305
+ schema = yd.allow(schema, '').nullable();
306
+ } else if (allowNull(typedef, options)) {
307
+ schema = schema.nullable();
305
308
  } else if (allowEmpty(typedef, options)) {
306
309
  schema = schema.options({
307
310
  allowEmpty: true,
@@ -352,6 +355,7 @@ function getSchemaForTypedef(typedef, options = {}) {
352
355
  if (typedef.writeAccess && options.requireWriteAccess) {
353
356
  schema = validateAccess('write', schema, typedef.writeAccess, options);
354
357
  }
358
+
355
359
  return schema;
356
360
  }
357
361
 
@@ -460,6 +464,10 @@ function isRequired(typedef, options) {
460
464
  return typedef.required && !typedef.default && !options.skipRequired;
461
465
  }
462
466
 
467
+ function allowNull(typedef, options) {
468
+ return options.allowNull && !typedef.required;
469
+ }
470
+
463
471
  function allowUnset(typedef, options) {
464
472
  return options.allowUnset && !typedef.required;
465
473
  }
@@ -18,6 +18,7 @@ export const INCLUDE_FIELD_SCHEMA: {
18
18
  strip(strip: any): any;
19
19
  allow(...set: any[]): any;
20
20
  reject(...set: any[]): any;
21
+ nullable(): any;
21
22
  message(message: any): any;
22
23
  tag(tags: any): any;
23
24
  description(description: any): any;
@@ -33,8 +34,11 @@ export const INCLUDE_FIELD_SCHEMA: {
33
34
  } | {
34
35
  default: any;
35
36
  };
36
- inspect(): string;
37
+ getNullable(): {
38
+ nullable: boolean;
39
+ };
37
40
  expandExtra(extra?: {}): {};
41
+ inspect(): string;
38
42
  assertEnum(set: any, allow: any): any;
39
43
  assert(type: any, fn: any): any;
40
44
  pushAssertion(assertion: any): void;
@@ -1 +1 @@
1
- {"version":3,"file":"include.d.ts","sourceRoot":"","sources":["../src/include.js"],"names":[],"mappings":"AA2BA,gDAuEC;AAMD,uDA4BC;AAGD,yDAIC;AAGD,yEAUC;AApID;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAKG"}
1
+ {"version":3,"file":"include.d.ts","sourceRoot":"","sources":["../src/include.js"],"names":[],"mappings":"AA2BA,gDAuEC;AAMD,uDA4BC;AAGD,yDAIC;AAGD,yEAUC;AApID;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAKG"}
package/types/search.d.ts CHANGED
@@ -15,6 +15,7 @@ export function searchValidation(options?: {}): {
15
15
  strip(strip: any): any;
16
16
  allow(...set: any[]): any;
17
17
  reject(...set: any[]): any;
18
+ nullable(): any;
18
19
  message(message: any): any;
19
20
  tag(tags: any): any;
20
21
  description(description: any): any;
@@ -30,8 +31,11 @@ export function searchValidation(options?: {}): {
30
31
  } | {
31
32
  default: any;
32
33
  };
33
- inspect(): string;
34
+ getNullable(): {
35
+ nullable: boolean;
36
+ };
34
37
  expandExtra(extra?: {}): {};
38
+ inspect(): string;
35
39
  assertEnum(set: any, allow: any): any;
36
40
  assert(type: any, fn: any): any;
37
41
  pushAssertion(assertion: any): void;
@@ -1 +1 @@
1
- {"version":3,"file":"search.d.ts","sourceRoot":"","sources":["../src/search.js"],"names":[],"mappings":"AAgBA,gEA0DC;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAyBC"}
1
+ {"version":3,"file":"search.d.ts","sourceRoot":"","sources":["../src/search.js"],"names":[],"mappings":"AAgBA,gEA0DC;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAyBC"}
package/types/utils.d.ts CHANGED
@@ -3,6 +3,7 @@ export function isMongooseSchema(obj: any): boolean;
3
3
  export function isReferenceField(obj: any, path: any): boolean;
4
4
  export function isDateField(obj: any, path: any): boolean;
5
5
  export function isNumberField(obj: any, path: any): boolean;
6
+ export function isArrayField(obj: any, path: any): boolean;
6
7
  export function isSchemaTypedef(arg: any): boolean;
7
8
  export function getField(obj: any, path: any): any;
8
9
  export function getInnerField(obj: any, path: any): any;
@@ -1 +1 @@
1
- {"version":3,"file":"utils.d.ts","sourceRoot":"","sources":["../src/utils.js"],"names":[],"mappings":"AAMA,iDAcC;AAED,oDAEC;AAED,+DAEC;AAED,0DAEC;AAED,4DAEC;AAOD,mDAGC;AAuBD,mDAYC;AAKD,wDAEC"}
1
+ {"version":3,"file":"utils.d.ts","sourceRoot":"","sources":["../src/utils.js"],"names":[],"mappings":"AAMA,iDAcC;AAED,oDAEC;AAED,+DAEC;AAED,0DAEC;AAED,4DAEC;AAED,2DAGC;AAOD,mDAGC;AAuBD,mDAYC;AAKD,wDAEC"}
@@ -77,6 +77,7 @@ export const OBJECT_ID_SCHEMA: {
77
77
  strip(strip: any): any;
78
78
  allow(...set: any[]): any;
79
79
  reject(...set: any[]): any;
80
+ nullable(): any;
80
81
  message(message: any): any;
81
82
  tag(tags: any): any;
82
83
  description(description: any): any;
@@ -93,8 +94,11 @@ export const OBJECT_ID_SCHEMA: {
93
94
  } | {
94
95
  default: any;
95
96
  };
96
- inspect(): string;
97
+ getNullable(): {
98
+ nullable: boolean;
99
+ };
97
100
  expandExtra(extra?: {}): {};
101
+ inspect(): string;
98
102
  assertEnum(set: any, allow: any): any;
99
103
  assert(type: any, fn: any): any;
100
104
  pushAssertion(assertion: any): void;
@@ -1 +1 @@
1
- {"version":3,"file":"validation.d.ts","sourceRoot":"","sources":["../src/validation.js"],"names":[],"mappings":"AAkFA,kDAEC;AAED,oEA+FC;AAsBD,wEA2BC;AA2SD;;;EAEC;AAED;;;EAOC;AA1gBD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAQK"}
1
+ {"version":3,"file":"validation.d.ts","sourceRoot":"","sources":["../src/validation.js"],"names":[],"mappings":"AAkFA,kDAEC;AAED,oEAgGC;AAsBD,wEA2BC;AAkTD;;;EAEC;AAED;;;EAOC;AAlhBD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAQK"}