@bedrockio/model 0.7.6 → 0.8.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/CHANGELOG.md CHANGED
@@ -1,3 +1,18 @@
1
+ ## 0.8.0
2
+
3
+ - Moved "cache" field in "search" to definition root.
4
+ - Changed "lazy" on cached fields to instead use "sync".
5
+ - Removed "sync" field in "search". Syncing now happens automatically on synced
6
+ cache fields.
7
+ - Removed "force" option from "syncCacheFields".
8
+ - Better handling of deep nested and array cache fields.
9
+ - Small fix for field messages on errors.
10
+
11
+ ## 0.7.4
12
+
13
+ - NANP phone number validation support
14
+ - Better handling of empty string fields for unsetting fields
15
+
1
16
  ## 0.7.0
2
17
 
3
18
  - Handling null fields in search queries.
package/README.md CHANGED
@@ -15,6 +15,7 @@ Bedrock utilities for model creation.
15
15
  - [Soft Delete](#soft-delete)
16
16
  - [Validation](#validation)
17
17
  - [Search](#search)
18
+ - [Cache](#cache)
18
19
  - [Includes](#includes)
19
20
  - [Delete Hooks](#delete-hooks)
20
21
  - [Access Control](#access-control)
@@ -124,7 +125,7 @@ Links:
124
125
 
125
126
  ### Schema Extensions
126
127
 
127
- This package provides a number of extensions to assist schema creation outside
128
+ This module provides a number of extensions to assist schema creation outside
128
129
  the scope of Mongoose.
129
130
 
130
131
  #### Attributes
@@ -302,7 +303,7 @@ with `minLength` and `maxLength` on strings.
302
303
 
303
304
  ### Gotchas
304
305
 
305
- #### The `type` field is a special:
306
+ #### The `type` field is special:
306
307
 
307
308
  ```js
308
309
  {
@@ -421,7 +422,7 @@ Note that although monogoose allows a `unique` option on fields, this will add a
421
422
  unique index to the mongo collection itself which is incompatible with soft
422
423
  deletion.
423
424
 
424
- This package will intercept `unique: true` to create a soft delete compatible
425
+ This module will intercept `unique: true` to create a soft delete compatible
425
426
  validation which will:
426
427
 
427
428
  - Throw an error if other non-deleted documents with the same fields exist when
@@ -479,7 +480,7 @@ There are 4 main methods to generate schemas:
479
480
  - `getCreateValidation`: Validates all fields while disallowing reserved fields
480
481
  like `id`, `createdAt`, and `updatedAt`.
481
482
  - `getUpdateValidation`: Validates all fields as optional (ie. they will not be
482
- validated if they don't exist on the object). Additionally will strip out
483
+ validated if they don't exist on the input). Additionally will strip out
483
484
  reserved fields to allow created objects to be passed in. Unknown fields will
484
485
  also be stripped out rather than error to allow virtuals to be passed in.
485
486
  - `getSearchValidation`: Validates fields for use with [search](#search). The
@@ -659,11 +660,16 @@ a text index applied, then a Mongo text query will be attempted:
659
660
  }
660
661
  ```
661
662
 
662
- #### Keyword Field Caching
663
+ #### Search Validation
664
+
665
+ The [validation](#validation) generated for search using `getSearchValidation`
666
+ is inherently looser and allows more fields to be passed to allow complex
667
+ searches compatible with the above.
668
+
669
+ ### Cache
663
670
 
664
- A common problem with search is filtering on fields belonging to foreign models.
665
- The search module helps to alleviate this issue by allowing a simple way to
666
- cache foreign fields on the model to allow filtering on them.
671
+ The cache module allows a simple way to cache foreign fields on a document and
672
+ optionally keep them in sync.
667
673
 
668
674
  ```json
669
675
  {
@@ -673,20 +679,17 @@ cache foreign fields on the model to allow filtering on them.
673
679
  "ref": "User"
674
680
  }
675
681
  },
676
- "search": {
677
- "cache": {
678
- "userName": {
679
- "type": "String",
680
- "path": "user.name"
681
- }
682
- },
683
- "fields": ["userName"]
682
+ "cache": {
683
+ "userName": {
684
+ "type": "String",
685
+ "path": "user.name"
686
+ }
684
687
  }
685
688
  }
686
689
  ```
687
690
 
688
691
  The above example is equivalent to creating a field called `userName` and
689
- updating it when a document is saved:
692
+ setting it when a document is saved:
690
693
 
691
694
  ```js
692
695
  schema.add({
@@ -700,54 +703,44 @@ schema.pre('save', function () {
700
703
 
701
704
  #### Syncing Cached Fields
702
705
 
703
- When a foreign document is updated the cached fields will be out of sync, for
704
- example:
706
+ By default cached fields are only updated when the reference changes. This is
707
+ fine when the field on the foreign document will not change or to keep a
708
+ snapshot of the value. However in some cases the local cached field should be
709
+ kept in sync when the foreign field changes:
705
710
 
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
711
+ ```json
723
712
  {
724
713
  "attributes": {
725
- "name": "String"
714
+ "user": {
715
+ "type": "ObjectId",
716
+ "ref": "User"
717
+ }
726
718
  },
727
- "search": {
728
- "sync": [
729
- {
730
- "ref": "Shop",
731
- "path": "user"
732
- }
733
- ]
719
+ "cache": {
720
+ "userName": {
721
+ "type": "String",
722
+ "path": "user.name",
723
+ "sync": true
724
+ }
734
725
  }
735
726
  }
736
727
  ```
737
728
 
738
- This is the equivalent of running the following in a post save hook:
729
+ The "sync" field is the equivelent of running a post save hook on the foreign
730
+ model to keep the cached field in sync:
739
731
 
740
732
  ```js
741
- const shops = await Shop.find({
742
- user: user.id,
733
+ userSchema.post('save', function () {
734
+ await Shop.updateMany({
735
+ user: this.id,
736
+ }, {
737
+ $set: {
738
+ userName: this.name
739
+ }
740
+ })
743
741
  });
744
- for (let shop of shops) {
745
- await shop.save();
746
- }
747
742
  ```
748
743
 
749
- This will run the hooks on each shop, synchronizing the cached fields.
750
-
751
744
  ##### Initial Sync
752
745
 
753
746
  When first applying or making changes to defined cached search fields, existing
@@ -756,61 +749,11 @@ to synchronize them:
756
749
 
757
750
  ```js
758
751
  // Find and update any documents that do not have
759
- // existing cached fields. Generally called when
760
- // adding a cached field.
752
+ // existing cached fields. Generally called after
753
+ // adding or modifying a cached field.
761
754
  await Model.syncCacheFields();
762
-
763
- // Force an update on ALL documents to resync their
764
- // cached fields. Generally called to force a cache
765
- // refresh.
766
- await Model.syncCacheFields({
767
- force: true,
768
- });
769
755
  ```
770
756
 
771
- ##### Lazy Cached Fields
772
-
773
- Cached fields can be made lazy:
774
-
775
- ```json
776
- {
777
- "attributes": {
778
- "user": {
779
- "type": "ObjectId",
780
- "ref": "User"
781
- }
782
- },
783
- "search": {
784
- "cache": {
785
- "userName": {
786
- "type": "String",
787
- "path": "user.name",
788
- "lazy": true
789
- }
790
- },
791
- "fields": ["userName"]
792
- }
793
- }
794
- ```
795
-
796
- Lazy cached fields will not update themselves once set. They can only be updated
797
- by forcing a sync:
798
-
799
- ```js
800
- await Model.syncCacheFields({
801
- force: true,
802
- });
803
- ```
804
-
805
- Making fields lazy alleviates performance impact on writes and allows caches to
806
- be updated at another time (such as a background job).
807
-
808
- #### Search Validation
809
-
810
- The [validation](#validation) generated for search using `getSearchValidation`
811
- is inherently looser and allows more fields to be passed to allow complex
812
- searches compatible with the above.
813
-
814
757
  ### Includes
815
758
 
816
759
  Populating foreign documents with
@@ -1135,7 +1078,7 @@ await shop.include('owner', {
1135
1078
 
1136
1079
  ### Access Control
1137
1080
 
1138
- This package applies two forms of access control:
1081
+ This module applies two forms of access control:
1139
1082
 
1140
1083
  - [Field Access](#field-access)
1141
1084
  - [Document Access](#document-access)
@@ -1589,16 +1532,58 @@ string, both of which would be stored in the database if naively assigned with
1589
1532
 
1590
1533
  This module adds a single `findOrCreate` convenience method that is easy to
1591
1534
  understand and avoids some of the gotchas that come with upserting documents in
1592
- Mongoose.
1535
+ Mongoose:
1536
+
1537
+ ```js
1538
+ const shop = await Shop.findOrCreate({
1539
+ name: 'My Shop',
1540
+ });
1541
+
1542
+ // This is equivalent to running:
1543
+ let shop = await Shop.findOne({
1544
+ name: 'My Shop',
1545
+ });
1546
+
1547
+ if (!shop) {
1548
+ shop = await Shop.create({
1549
+ name: 'My Shop',
1550
+ });
1551
+ }
1552
+ ```
1553
+
1554
+ In most cases not all of the fields should be queried on to determine if an
1555
+ existing document exists. In this case two arguments should be passed the first
1556
+ of which is the query:
1557
+
1558
+ ```js
1559
+ const shop = await Shop.findOrCreate(
1560
+ {
1561
+ slug: 'my-shop',
1562
+ },
1563
+ {
1564
+ name: 'My Shop',
1565
+ slug: 'my-shop',
1566
+ }
1567
+ );
1568
+
1569
+ // This is equivalent to running:
1570
+ let shop = await Shop.findOne({
1571
+ slug: 'my-shop',
1572
+ });
1573
+
1574
+ if (!shop) {
1575
+ shop = await Shop.create({
1576
+ name: 'My Shop',
1577
+ slug: 'my-shop',
1578
+ });
1579
+ }
1580
+ ```
1593
1581
 
1594
1582
  ### Slugs
1595
1583
 
1596
1584
  A common requirement is to allow slugs on documents to serve as ids for human
1597
- readable URLs. To load a single document this way the naive approach would be to
1598
- run a search on all documents matching the `slug` then pull the first one off.
1599
-
1600
- This module simplifies this by assuming a `slug` field on a model and adding a
1601
- `findByIdOrSlug` method that allows searching on both:
1585
+ readable URLs. This module simplifies this by assuming a `slug` field on a model
1586
+ and adding a `findByIdOrSlug` method that allows searching on either:
1602
1587
 
1603
1588
  ```js
1604
1589
  const post = await Post.findByIdOrSlug(str);
@@ -7,7 +7,7 @@ exports.hasAccess = hasAccess;
7
7
  var _errors = require("./errors");
8
8
  var _utils = require("./utils");
9
9
  var _warn = _interopRequireDefault(require("./warn"));
10
- function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }
10
+ function _interopRequireDefault(e) { return e && e.__esModule ? e : { default: e }; }
11
11
  /**
12
12
  * @param {string|string[]} allowed
13
13
  */
@@ -7,7 +7,7 @@ exports.applyAssign = applyAssign;
7
7
  var _lodash = require("lodash");
8
8
  var _mongoose = _interopRequireDefault(require("mongoose"));
9
9
  var _utils = require("./utils");
10
- function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }
10
+ function _interopRequireDefault(e) { return e && e.__esModule ? e : { default: e }; }
11
11
  function applyAssign(schema) {
12
12
  schema.method('assign', function assign(fields) {
13
13
  unsetReferenceFields(fields, schema.obj);
@@ -0,0 +1,240 @@
1
+ "use strict";
2
+
3
+ Object.defineProperty(exports, "__esModule", {
4
+ value: true
5
+ });
6
+ exports.applyCache = applyCache;
7
+ var _mongoose = _interopRequireDefault(require("mongoose"));
8
+ var _lodash = require("lodash");
9
+ var _utils = require("./utils");
10
+ function _interopRequireDefault(e) { return e && e.__esModule ? e : { default: e }; }
11
+ const definitionMap = new Map();
12
+ _mongoose.default.plugin(cacheSyncPlugin);
13
+ function applyCache(schema, definition) {
14
+ definitionMap.set(schema, definition);
15
+ if (!definition.cache) {
16
+ return;
17
+ }
18
+ createCacheFields(schema, definition);
19
+ applyStaticMethods(schema, definition);
20
+ applyCacheHook(schema, definition);
21
+ }
22
+ function createCacheFields(schema, definition) {
23
+ for (let [cachedField, def] of Object.entries(definition.cache)) {
24
+ const {
25
+ type,
26
+ path,
27
+ ...rest
28
+ } = def;
29
+ schema.add({
30
+ [cachedField]: type
31
+ });
32
+ schema.obj[cachedField] = {
33
+ ...rest,
34
+ type,
35
+ writeAccess: 'none'
36
+ };
37
+ }
38
+ }
39
+ function applyStaticMethods(schema, definition) {
40
+ schema.static('syncCacheFields', async function syncCacheFields() {
41
+ assertIncludeModule(this);
42
+ const fields = resolveCachedFields(schema, definition);
43
+ const hasSynced = fields.some(entry => {
44
+ return entry.sync;
45
+ });
46
+ const query = {};
47
+ if (!hasSynced) {
48
+ const $or = fields.map(field => {
49
+ return {
50
+ [field.name]: null
51
+ };
52
+ });
53
+ query.$or = $or;
54
+ }
55
+ const includes = getIncludes(fields);
56
+ const docs = await this.find(query).include(includes);
57
+ const ops = docs.flatMap(doc => {
58
+ return fields.map(field => {
59
+ const {
60
+ name,
61
+ sync
62
+ } = field;
63
+ const updates = getUpdates(doc, [field]);
64
+ const filter = {
65
+ _id: doc._id
66
+ };
67
+ if (!sync) {
68
+ filter[name] = null;
69
+ }
70
+ return {
71
+ updateOne: {
72
+ filter,
73
+ update: {
74
+ $set: updates
75
+ }
76
+ }
77
+ };
78
+ });
79
+ });
80
+ return await this.bulkWrite(ops);
81
+ });
82
+ }
83
+ function applyCacheHook(schema, definition) {
84
+ const fields = resolveCachedFields(schema, definition);
85
+ schema.pre('save', async function () {
86
+ assertIncludeModule(this.constructor);
87
+ assertAssignModule(this.constructor);
88
+ const doc = this;
89
+ const changes = fields.filter(field => {
90
+ const {
91
+ sync,
92
+ local,
93
+ name
94
+ } = field;
95
+ if (sync || doc.isModified(local)) {
96
+ // Always update if we are actively syncing
97
+ // or if the field has been changed.
98
+ return true;
99
+ } else {
100
+ // Otherwise only update if the value does
101
+ // not exist yet.
102
+ const value = (0, _lodash.get)(doc, name);
103
+ return Array.isArray(value) ? !value.length : !value;
104
+ }
105
+ });
106
+ await this.include(getIncludes(changes));
107
+ this.assign(getUpdates(doc, changes));
108
+ });
109
+ }
110
+
111
+ // Syncing
112
+
113
+ const syncOperations = {};
114
+ const compiledModels = new Set();
115
+ function cacheSyncPlugin(schema) {
116
+ // Compile sync fields each time a new schema
117
+ // is registered but only do it one time for.
118
+ const initialize = (0, _lodash.once)(compileSyncOperations);
119
+ schema.pre('save', async function () {
120
+ this.$locals.modifiedPaths = this.modifiedPaths();
121
+ });
122
+ schema.post('save', async function () {
123
+ initialize();
124
+
125
+ // @ts-ignore
126
+ const {
127
+ modelName
128
+ } = this.constructor;
129
+ const ops = syncOperations[modelName] || [];
130
+ for (let op of ops) {
131
+ await op(this);
132
+ }
133
+ });
134
+ }
135
+ function compileSyncOperations() {
136
+ for (let Model of Object.values(_mongoose.default.models)) {
137
+ const {
138
+ schema
139
+ } = Model;
140
+ if (compiledModels.has(Model)) {
141
+ // Model has already been compiled so skip.
142
+ continue;
143
+ }
144
+ const definition = definitionMap.get(schema);
145
+ const fields = resolveCachedFields(schema, definition);
146
+ for (let [ref, group] of Object.entries((0, _lodash.groupBy)(fields, 'ref'))) {
147
+ const hasSynced = group.some(entry => {
148
+ return entry.sync;
149
+ });
150
+ if (!hasSynced) {
151
+ continue;
152
+ }
153
+ const fn = async doc => {
154
+ const {
155
+ modifiedPaths
156
+ } = doc.$locals;
157
+ const changes = group.filter(entry => {
158
+ return entry.sync && modifiedPaths.includes(entry.foreign);
159
+ });
160
+ if (changes.length) {
161
+ const $or = changes.map(change => {
162
+ const {
163
+ local
164
+ } = change;
165
+ return {
166
+ [local]: doc.id
167
+ };
168
+ });
169
+ const docs = await Model.find({
170
+ $or
171
+ });
172
+ await Promise.all(docs.map(doc => doc.save()));
173
+ }
174
+ };
175
+ syncOperations[ref] ||= [];
176
+ syncOperations[ref].push(fn);
177
+ }
178
+ compiledModels.add(Model);
179
+ }
180
+ }
181
+
182
+ // Utils
183
+
184
+ function resolveCachedFields(schema, definition) {
185
+ const {
186
+ cache = {}
187
+ } = definition;
188
+ return Object.entries(cache).map(([name, def]) => {
189
+ const {
190
+ path,
191
+ sync = false
192
+ } = def;
193
+ const resolved = (0, _utils.resolveRefPath)(schema, path);
194
+ if (!resolved) {
195
+ throw new Error(`Could not resolve path ${path}.`);
196
+ }
197
+ return {
198
+ ...resolved,
199
+ name,
200
+ path,
201
+ sync
202
+ };
203
+ });
204
+ }
205
+ function getUpdates(doc, fields) {
206
+ const updates = {};
207
+ for (let field of fields) {
208
+ const {
209
+ name,
210
+ path
211
+ } = field;
212
+
213
+ // doc.get will not return virtuals (even with specified options),
214
+ // so fall back to lodash to ensure they are included here.
215
+ // https://mongoosejs.com/docs/api/document.html#Document.prototype.get()
216
+ const value = doc.get(path) ?? (0, _lodash.get)(doc, path);
217
+ updates[name] = value;
218
+ }
219
+ return updates;
220
+ }
221
+ function getIncludes(fields) {
222
+ const includes = new Set();
223
+ for (let field of fields) {
224
+ includes.add(field.local);
225
+ }
226
+ return includes;
227
+ }
228
+
229
+ // Assertions
230
+
231
+ function assertIncludeModule(Model) {
232
+ if (!Model.schema.methods.include) {
233
+ throw new Error('Include module is required for cached fields.');
234
+ }
235
+ }
236
+ function assertAssignModule(Model) {
237
+ if (!Model.schema.methods.assign) {
238
+ throw new Error('Assign module is required for cached fields.');
239
+ }
240
+ }
@@ -8,7 +8,7 @@ var _mongoose = _interopRequireDefault(require("mongoose"));
8
8
  var _lodash = require("lodash");
9
9
  var _errors = require("./errors");
10
10
  var _utils = require("./utils");
11
- function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }
11
+ function _interopRequireDefault(e) { return e && e.__esModule ? e : { default: e }; }
12
12
  const {
13
13
  ObjectId: SchemaObjectId
14
14
  } = _mongoose.default.Schema.Types;
@@ -5,7 +5,7 @@ Object.defineProperty(exports, "__esModule", {
5
5
  });
6
6
  exports.applyDisallowed = applyDisallowed;
7
7
  var _warn = _interopRequireDefault(require("./warn"));
8
- function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }
8
+ function _interopRequireDefault(e) { return e && e.__esModule ? e : { default: e }; }
9
9
  function applyDisallowed(schema) {
10
10
  schema.method('deleteOne', function () {
11
11
  (0, _warn.default)('The "deleteOne" method on documents is disallowed due to ambiguity', 'Use either "delete" or "deleteOne" on the model.');
@@ -13,7 +13,7 @@ var _lodash = require("lodash");
13
13
  var _yada = _interopRequireDefault(require("@bedrockio/yada"));
14
14
  var _utils = require("./utils");
15
15
  var _const = require("./const");
16
- function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }
16
+ function _interopRequireDefault(e) { return e && e.__esModule ? e : { default: e }; }
17
17
  // @ts-ignore
18
18
  // Overloading mongoose Query prototype to
19
19
  // allow an "include" method for queries.
@@ -149,10 +149,19 @@ function checkSelects(doc, ret) {
149
149
 
150
150
  // Exported for testing.
151
151
  function getParams(modelName, arg) {
152
- const paths = Array.isArray(arg) ? arg : [arg];
152
+ const paths = resolvePathsArg(arg);
153
153
  const node = pathsToNode(paths, modelName);
154
154
  return nodeToPopulates(node);
155
155
  }
156
+ function resolvePathsArg(arg) {
157
+ if (Array.isArray(arg)) {
158
+ return arg;
159
+ } else if (arg instanceof Set) {
160
+ return Array.from(arg);
161
+ } else {
162
+ return [arg];
163
+ }
164
+ }
156
165
 
157
166
  // Exported for testing.
158
167
  function getDocumentParams(doc, arg, options = {}) {
package/dist/cjs/load.js CHANGED
@@ -10,7 +10,7 @@ var _path = _interopRequireDefault(require("path"));
10
10
  var _mongoose = _interopRequireDefault(require("mongoose"));
11
11
  var _lodash = require("lodash");
12
12
  var _schema = require("./schema");
13
- function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }
13
+ function _interopRequireDefault(e) { return e && e.__esModule ? e : { default: e }; }
14
14
  /**
15
15
  * Loads a single model by definition and name.
16
16
  * @param {object} definition
@@ -10,6 +10,7 @@ var _lodash = require("lodash");
10
10
  var _utils = require("./utils");
11
11
  var _serialization = require("./serialization");
12
12
  var _slug = require("./slug");
13
+ var _cache = require("./cache");
13
14
  var _search = require("./search");
14
15
  var _assign = require("./assign");
15
16
  var _upsert = require("./upsert");
@@ -19,7 +20,7 @@ var _softDelete = require("./soft-delete");
19
20
  var _deleteHooks = require("./delete-hooks");
20
21
  var _disallowed = require("./disallowed");
21
22
  var _validation = require("./validation");
22
- function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }
23
+ function _interopRequireDefault(e) { return e && e.__esModule ? e : { default: e }; }
23
24
  /**
24
25
  * Creates a new Mongoose schema with Bedrock extensions
25
26
  * applied. For more about syntax and functionality see
@@ -54,6 +55,7 @@ function createSchema(definition, options = {}) {
54
55
  (0, _validation.applyValidation)(schema, definition);
55
56
  (0, _deleteHooks.applyDeleteHooks)(schema, definition);
56
57
  (0, _search.applySearch)(schema, definition);
58
+ (0, _cache.applyCache)(schema, definition);
57
59
  (0, _disallowed.applyDisallowed)(schema);
58
60
  (0, _include.applyInclude)(schema);
59
61
  (0, _hydrate.applyHydrate)(schema);