coll-fns 1.0.0 â 1.2.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 +1024 -424
- package/dist/coll-fns.cjs +1 -1
- package/dist/coll-fns.cjs.map +1 -1
- package/dist/coll-fns.mjs +1 -1
- package/dist/coll-fns.mjs.map +1 -1
- package/package.json +3 -1
package/README.md
CHANGED
|
@@ -1,372 +1,517 @@
|
|
|
1
|
-
|
|
1
|
+
A functional interface to **join MongoDB collections** and add **hooks before and after** insertions, updates and removals.
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
# Overview
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
Skip the repetitive glue code for joining collections and wiring related data. Define joins between collections once, then **fetch documents in the exact shape you need in a nested tree**. The data fetching is optimized to minimize database queries.
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
Stop repeating business logic all over your code base. Define hooks on collections that will be triggered conditionally **before or after insertions, updates or removals**: data validation, change propagation, logging, etc.
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
# Table of contents
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
- [Overview](#overview)
|
|
12
|
+
- [Table of contents](#table-of-contents)
|
|
13
|
+
- [Rationale](#rationale)
|
|
14
|
+
- [Installation and configuration](#installation-and-configuration)
|
|
15
|
+
- [`setProtocol(protocol)`](#setprotocolprotocol)
|
|
16
|
+
- [Bypassing `coll-fns`](#bypassing-coll-fns)
|
|
17
|
+
- [Joins and fetch](#joins-and-fetch)
|
|
18
|
+
- [Quick start examples](#quick-start-examples)
|
|
19
|
+
- [`join(Coll, joinDefinitions)`](#joincoll-joindefinitions)
|
|
20
|
+
- [Simple array join](#simple-array-join)
|
|
21
|
+
- [Sub-array joins](#sub-array-joins)
|
|
22
|
+
- [Filtered array-joins](#filtered-array-joins)
|
|
23
|
+
- [Object joins](#object-joins)
|
|
24
|
+
- [Function joins](#function-joins)
|
|
25
|
+
- [Recursive joins](#recursive-joins)
|
|
26
|
+
- [Join additional options](#join-additional-options)
|
|
27
|
+
- [`postFetch`](#postfetch)
|
|
28
|
+
- [`getJoins`](#getjoins)
|
|
29
|
+
- [`fetchList(Coll, selector, options)`](#fetchlistcoll-selector-options)
|
|
30
|
+
- [`fields` option and joins](#fields-option-and-joins)
|
|
31
|
+
- [Examples](#examples)
|
|
32
|
+
- [`setJoinPrefix(prefix)`](#setjoinprefixprefix)
|
|
33
|
+
- [Nested Joins](#nested-joins)
|
|
34
|
+
- [Recursion levels](#recursion-levels)
|
|
35
|
+
- [Documents transformation](#documents-transformation)
|
|
36
|
+
- [`fetchOne(Coll, selector, options)`](#fetchonecoll-selector-options)
|
|
37
|
+
- [`fetchIds(Coll, selector, options)`](#fetchidscoll-selector-options)
|
|
38
|
+
- [`exists(Coll, selector)`](#existscoll-selector)
|
|
39
|
+
- [`count(Coll, selector)`](#countcoll-selector)
|
|
40
|
+
- [`flattenFields(fields)`](#flattenfieldsfields)
|
|
41
|
+
- [Hooks and write operations](#hooks-and-write-operations)
|
|
42
|
+
- [`hook(Coll, hooksObj)`](#hookcoll-hooksobj)
|
|
43
|
+
- [Before hooks](#before-hooks)
|
|
44
|
+
- [After hooks](#after-hooks)
|
|
45
|
+
- [Hook definition properties](#hook-definition-properties)
|
|
46
|
+
- [Examples](#examples-1)
|
|
47
|
+
- [`insert(Coll, doc)`](#insertcoll-doc)
|
|
48
|
+
- [`update(Coll, selector, modifier, options)`](#updatecoll-selector-modifier-options)
|
|
49
|
+
- [`remove(Coll, selector)`](#removecoll-selector)
|
|
50
|
+
- [Hook best practices](#hook-best-practices)
|
|
51
|
+
- [License](#license)
|
|
12
52
|
|
|
13
|
-
|
|
14
|
-
- ðŠ **Extensible Hooks**: React to CRUD operations with before/after hooks for validation, transformation, and side effects
|
|
15
|
-
- ð **Isomorphic by Design**: Write once, run anywhere - same API for client-side (sync) and server-side (async) code
|
|
16
|
-
- ð **Protocol-based Architecture**: Switch between different database backends seamlessly
|
|
17
|
-
- ð **Advanced Field Projections**: Support for nested fields, dot notation, and MongoDB-style projections
|
|
18
|
-
- ð **Promise/async Support**: Works with both synchronous and asynchronous protocols
|
|
19
|
-
- ð **TypeScript-ready**: Includes JSDoc types for better IDE support
|
|
53
|
+
# Rationale
|
|
20
54
|
|
|
21
|
-
|
|
55
|
+
Click on any element to unfold it and better understand the rationale behind this library!
|
|
56
|
+
|
|
57
|
+
<details>
|
|
58
|
+
<summary><strong>Data normalization</strong></summary>
|
|
59
|
+
|
|
60
|
+
While document databases with eventual consistency such as MongoDB are quite powerful, they often encourage denormalization patterns where information from one document must be copied on related documents. If everything goes well, one should be able to limit data fetching to self-contained documents only... (yeah, right).
|
|
61
|
+
|
|
62
|
+
This pattern adds much unwelcome complexity:
|
|
63
|
+
|
|
64
|
+
- the fields to denormalize must be determined in advance
|
|
65
|
+
- adding functionalities that would require new related fields means more denormalization and migration scripts
|
|
66
|
+
- the denormalized data must be kept in sync (somehow)
|
|
67
|
+
- denormalization can fail halfway through, leading to an incoherent state
|
|
68
|
+
|
|
69
|
+
I designed this library as a Meteor developer. I was hence _forced_ to use MongoDB documents database instead of a normalized database. Yet, I wanted to join collections so I could
|
|
70
|
+
|
|
71
|
+
- **keep data in only one place** (where it made most sense)
|
|
72
|
+
- **retrieve documents easily in an intuitive shape**.
|
|
73
|
+
|
|
74
|
+
Joins allow to do so. They are declared in advance between collections, defining how different types of documents relate to each other in a single place, instead of repeating this logic each time when querying the data. Data fetching knows about them and allows **arbitrary depth relationships** where documents are returned as **simple nested tree-shaped documents** without the need for helpers, methods or other complex structures... It's just data!
|
|
75
|
+
|
|
76
|
+
And joins can be defined in code shared by both server and client (I _hate_ redundant code ð).
|
|
77
|
+
|
|
78
|
+
</details>
|
|
79
|
+
|
|
80
|
+
<details>
|
|
81
|
+
<summary><strong>DRY business logic</strong></summary>
|
|
82
|
+
|
|
83
|
+
Although joins between collections reduce the need for denormalization, it is often essential to update related documents based on what happens to another one (cascading removals, for example).
|
|
84
|
+
|
|
85
|
+
Reacting to data changes to propagate the information on the wire through API calls is also quite frequent. As is the need for final validation before commiting a change.
|
|
86
|
+
|
|
87
|
+
So how should we go about it? Should this code be incorporated in each mutation path? Should we create custom functions to update a specific type of document so all these side-effects are executed? If so, how do we not forget to use these functions instead of the normal collection methods? And how do we reduce the amount of boilerplate code that will simply handle the mechanics of data manipulation?
|
|
88
|
+
|
|
89
|
+
**Hooks** were introduced to solve these issues. The are defined in advance on each collection to **intercept or react to insertions, updates and removals**.
|
|
90
|
+
|
|
91
|
+
Even better, they can be defined so they fire only if certain conditions are met! And they can be **defined in many places**, making it much easier to group hooks by area of concern, instead of spreading their logic all over the place.
|
|
92
|
+
|
|
93
|
+
Focussing on the business logic, describing it only once, wasting no time on boilerplate code, that's a time (and sanity) saver for sure! ðĪŠ
|
|
94
|
+
|
|
95
|
+
</details>
|
|
96
|
+
|
|
97
|
+
<details>
|
|
98
|
+
<summary><strong>Protocol implementations</strong></summary>
|
|
99
|
+
|
|
100
|
+
I also faced a challenge when **migrating from Meteor 2.0 to 3.0**, which rendered isomorphic code cumbersome (same code running on the server and the client).
|
|
101
|
+
|
|
102
|
+
On the server, the new Meteor architecture now required the use of promises and async/await constructs.
|
|
103
|
+
|
|
104
|
+
On the client, data fetching with Minimongo must be synchronous in most frameworks to avoid complicated front-end code to handle promises.
|
|
105
|
+
|
|
106
|
+
I wanted a tool that would help me **keep the isomorphic query capabilities** while eliminating redundant glue code.
|
|
107
|
+
|
|
108
|
+
By designing the library with a protocol architecture, the same code can be run with a different database implementation.
|
|
109
|
+
|
|
110
|
+
On the client, I use a synchronous Minimongo implementation.
|
|
111
|
+
|
|
112
|
+
On the server, while still on Meteor 2.0, I also used a synchronous implementation that worked fine with Fibers. When I got ready to move to async/await and Meteor 3.0, I simply switched to an async protocol implementation without so much refactoring!
|
|
113
|
+
|
|
114
|
+
(Of course, the code had to be refactored to actually use `coll-fns`, but it comes with so much powerful features that it would have been a go-to anyway!)
|
|
115
|
+
|
|
116
|
+
And since it uses a protocol, it can be used with the **native MongoDB driver** too (built-in) and could even be adapted to almost any type of database... ðĪŊ
|
|
117
|
+
|
|
118
|
+
</details>
|
|
119
|
+
|
|
120
|
+
<details style="margin-bottom: 1rem">
|
|
121
|
+
<summary><strong>Functional API</strong></summary>
|
|
122
|
+
|
|
123
|
+
A lot of libraries that add functionalities to the database layer mutate the collection instances themselves or, when more respectful, offer ways to extend the collection constructor somehow.
|
|
124
|
+
|
|
125
|
+
In either case, it can lead to potential issues where different enhancement libraries conflict with each other. Method names might change, data might be saved in added fields on the collection instances (is `_joins` _really_ safe to use?)...
|
|
126
|
+
|
|
127
|
+
`coll-fns`, as the name implies, offers a **functional API**. Instead of doing `Collection.insert(doc)`, you would `insert(Collection, doc)`. I know... Moving the left parenthesis and replacing the dot with a comma is a lot to ask ð, but it comes with benefits!
|
|
128
|
+
|
|
129
|
+
Instead of mutating the collections themselves, **joins and hooks definitions are saved in a global registry**. No collection instance is harmed (mutated) in the process. You could have a fancy custom collection wrapped and distorted by many different libraries; `coll-fns` won't add a thing to it.
|
|
130
|
+
|
|
131
|
+
Doing so makes it easy to offer a protocol interface: **the type of collection involved doesn't matter at all**. Heck, the collections could even be table names as strings and it would still work (if you implement a custom protocol)!
|
|
132
|
+
|
|
133
|
+
For Meteor developers, it also means being able to enhance the `Meteor.users` collection itself... event without access to instantiation code! ðĪ
|
|
134
|
+
|
|
135
|
+
</details>
|
|
136
|
+
|
|
137
|
+
# Installation and configuration
|
|
138
|
+
|
|
139
|
+
**IMPORTANT**: For concision, the **examples will use the synchronous Meteor protocol** to avoid `async/await` boilerplate. Of course, your code will have to be adapted when used with an asynchronous protocol.
|
|
22
140
|
|
|
23
141
|
```bash
|
|
24
142
|
npm install coll-fns
|
|
25
143
|
```
|
|
26
144
|
|
|
27
|
-
##
|
|
145
|
+
## `setProtocol(protocol)`
|
|
146
|
+
|
|
147
|
+
You will have to **define which protocol to use** before using any of the library's functionality.
|
|
28
148
|
|
|
29
149
|
```js
|
|
30
|
-
import {
|
|
31
|
-
setProtocol,
|
|
32
|
-
fetchList,
|
|
33
|
-
insert,
|
|
34
|
-
update,
|
|
35
|
-
remove,
|
|
36
|
-
count,
|
|
37
|
-
protocols,
|
|
38
|
-
} from "coll-fns";
|
|
150
|
+
import { setProtocol, protocols } from "coll-fns";
|
|
39
151
|
|
|
40
|
-
|
|
41
|
-
|
|
152
|
+
/* Built-in protocols include:
|
|
153
|
+
* - meteorAsync
|
|
154
|
+
* - meteorSync
|
|
155
|
+
* - node
|
|
156
|
+
*
|
|
157
|
+
* Can also define a custom protocol! */
|
|
158
|
+
setProtocol(protocols.meteorAsync);
|
|
159
|
+
```
|
|
42
160
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
161
|
+
In a Meteor project, you should probably define a **different protocol on client** (synchronous) **and server** (asynchronous).
|
|
162
|
+
|
|
163
|
+
```js
|
|
164
|
+
import { setProtocol, protocols } from "coll-fns";
|
|
165
|
+
|
|
166
|
+
const protocol = Meteor.isServer ? protocols.meteorAsync : protocols.meteorSync;
|
|
167
|
+
setProtocol(protocol);
|
|
49
168
|
```
|
|
50
169
|
|
|
51
|
-
|
|
170
|
+
There's also a **native NodeJS MongoDB driver** protocol built-in (`protocols.node`).
|
|
52
171
|
|
|
53
|
-
|
|
172
|
+
<details style="margin-bottom: 1rem">
|
|
173
|
+
<summary><strong>Custom protocol</strong></summary>
|
|
54
174
|
|
|
55
|
-
|
|
175
|
+
You could even define a **custom protocol** for the library to work with another interface to MongoDB or even to a completely different storage system! Joins and hooks should then work the same way (let me know if you do ðĪ!).
|
|
56
176
|
|
|
57
177
|
```js
|
|
58
|
-
|
|
59
|
-
PostsCollection,
|
|
60
|
-
{},
|
|
61
|
-
{
|
|
62
|
-
joins: {
|
|
63
|
-
author: {
|
|
64
|
-
coll: UsersCollection,
|
|
65
|
-
on: ["authorId", "_id"], // [local field, foreign field]
|
|
66
|
-
fields: { name: 1, avatar: 1, email: 1 },
|
|
67
|
-
},
|
|
68
|
-
},
|
|
69
|
-
fields: {
|
|
70
|
-
title: 1,
|
|
71
|
-
content: 1,
|
|
72
|
-
"+": { author: 1 }, // Use '+' prefix to include join fields
|
|
73
|
-
},
|
|
74
|
-
}
|
|
75
|
-
);
|
|
178
|
+
import { setProtocol } from "coll-fns";
|
|
76
179
|
|
|
77
|
-
|
|
78
|
-
|
|
180
|
+
const customProtocol = {
|
|
181
|
+
/* Return a documents count */
|
|
182
|
+
count(/* Coll, selector = {}, options = {} */) {},
|
|
79
183
|
|
|
80
|
-
|
|
184
|
+
/* Return a list of documents. */
|
|
185
|
+
findList(/* Coll, selector = {}, options = {} */) {},
|
|
81
186
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
comments: {
|
|
89
|
-
coll: CommentsCollection,
|
|
90
|
-
on: ["_id", "postId"],
|
|
91
|
-
many: true, // Returns an array of related documents
|
|
92
|
-
fields: { text: 1, createdAt: 1 },
|
|
93
|
-
},
|
|
94
|
-
},
|
|
95
|
-
fields: {
|
|
96
|
-
title: 1,
|
|
97
|
-
"+": { comments: 1 },
|
|
98
|
-
},
|
|
99
|
-
}
|
|
100
|
-
);
|
|
187
|
+
/* Return the name of the collection. */
|
|
188
|
+
getName(/* Coll */) {},
|
|
189
|
+
|
|
190
|
+
/* Optional. Return a function that will transform each document
|
|
191
|
+
* after being fetched with descendants. */
|
|
192
|
+
getTransform(/* Coll */) {},
|
|
101
193
|
|
|
102
|
-
|
|
194
|
+
/* Insert a document in a collection
|
|
195
|
+
* and return the inserted _id. */
|
|
196
|
+
insert(/* Coll, doc, options */) {},
|
|
197
|
+
|
|
198
|
+
/* Remove documents in a collection
|
|
199
|
+
* and return the number of removed documents. */
|
|
200
|
+
remove(/* Coll, selector, options */) {},
|
|
201
|
+
|
|
202
|
+
/* Update documents in a collection
|
|
203
|
+
* and return the number of modified documents. */
|
|
204
|
+
update(/* Coll, selector, modifier, options */) {},
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
setProtocol(customProtocol);
|
|
103
208
|
```
|
|
104
209
|
|
|
105
|
-
|
|
210
|
+
</details>
|
|
211
|
+
|
|
212
|
+
## Bypassing `coll-fns`
|
|
106
213
|
|
|
107
|
-
Joins
|
|
214
|
+
`coll-fns` intentionally keeps collection instances untouched. It doesn't add any methods nor change exsting ones' behaviour. To bypass any `coll-fns` functionality, simply **use the normal collection methods** (ex: `Coll.insert`, `Coll.find().fetchAsync()`, `Coll.removeOne()`). Joins and hooks will only get fired when using the library's functions... and if joins and hooks have been pre-defined, of course! ð
|
|
215
|
+
|
|
216
|
+
# Joins and fetch
|
|
217
|
+
|
|
218
|
+
## Quick start examples
|
|
108
219
|
|
|
109
220
|
```js
|
|
110
|
-
|
|
111
|
-
|
|
221
|
+
import { fetchList, join } from "coll-fns";
|
|
222
|
+
import { Comments, Posts, Users } from "/collections";
|
|
223
|
+
|
|
224
|
+
/* Define joins on Posts collection */
|
|
225
|
+
join(Posts, {
|
|
226
|
+
/* One-to-one join */
|
|
227
|
+
author: {
|
|
228
|
+
Coll: Users,
|
|
229
|
+
on: ["authorId", "_id"],
|
|
230
|
+
single: true,
|
|
231
|
+
},
|
|
232
|
+
|
|
233
|
+
/* One-to-many join */
|
|
234
|
+
comments: {
|
|
235
|
+
Coll: Comments,
|
|
236
|
+
on: ["_id", "postId"],
|
|
237
|
+
/* `single` defaults to false,
|
|
238
|
+
* so joined docs are returned as an array */
|
|
239
|
+
},
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
/* Fetch data with nested joined documents in the requested shape. */
|
|
243
|
+
fetchList(
|
|
244
|
+
Posts,
|
|
112
245
|
{},
|
|
113
246
|
{
|
|
114
|
-
joins: {
|
|
115
|
-
author: {
|
|
116
|
-
coll: UsersCollection,
|
|
117
|
-
on: ["authorId", "_id"],
|
|
118
|
-
fields: { name: 1, avatar: 1 },
|
|
119
|
-
},
|
|
120
|
-
comments: {
|
|
121
|
-
coll: CommentsCollection,
|
|
122
|
-
on: ["_id", "postId"],
|
|
123
|
-
many: true,
|
|
124
|
-
fields: { text: 1, "+": { user: 1 } },
|
|
125
|
-
joins: {
|
|
126
|
-
user: {
|
|
127
|
-
coll: UsersCollection,
|
|
128
|
-
on: ["userId", "_id"],
|
|
129
|
-
fields: { name: 1, avatar: 1 },
|
|
130
|
-
},
|
|
131
|
-
},
|
|
132
|
-
},
|
|
133
|
-
},
|
|
134
247
|
fields: {
|
|
135
|
-
title: 1,
|
|
136
|
-
|
|
137
|
-
|
|
248
|
+
title: 1, // <= Own
|
|
249
|
+
author: { birthdate: 0 }, // <= Falsy = anything but these fields
|
|
250
|
+
comments: { text: 1 },
|
|
138
251
|
},
|
|
139
252
|
}
|
|
140
253
|
);
|
|
254
|
+
```
|
|
141
255
|
|
|
142
|
-
|
|
256
|
+
```jsonc
|
|
257
|
+
[
|
|
258
|
+
{
|
|
259
|
+
"title": "Blabla",
|
|
260
|
+
"authorId": "foo", // <= Included by join definition
|
|
261
|
+
"author": {
|
|
262
|
+
"name": "Foo Bar",
|
|
263
|
+
"genre": "non-fiction",
|
|
264
|
+
},
|
|
265
|
+
/* Comments is a one-to-many join, so is returned as a list */
|
|
266
|
+
"comments": [{ "text": "Nice!" }, { "text": "Great!" }],
|
|
267
|
+
},
|
|
268
|
+
]
|
|
143
269
|
```
|
|
144
270
|
|
|
145
|
-
|
|
271
|
+
## `join(Coll, joinDefinitions)`
|
|
272
|
+
|
|
273
|
+
Collections can be joined together with **globally pre-registered joins** to greatly simplify optimized data fetching.
|
|
146
274
|
|
|
147
|
-
|
|
275
|
+
Joins are **not symmetrical by default**. Each collection should define its own relationships.
|
|
148
276
|
|
|
149
277
|
```js
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
278
|
+
import { join } from "coll-fns";
|
|
279
|
+
|
|
280
|
+
join(
|
|
281
|
+
/* Parent collection */
|
|
282
|
+
Coll,
|
|
283
|
+
|
|
284
|
+
/* Map of joins on children collections.
|
|
285
|
+
* Each key is the name of the field
|
|
286
|
+
* where joined docs will be placed. */
|
|
153
287
|
{
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
coll: UsersCollection,
|
|
157
|
-
on: ["friendIds", "_id"],
|
|
158
|
-
many: true,
|
|
159
|
-
fields: { name: 1, "+": { friends: 1 } },
|
|
160
|
-
joins: {
|
|
161
|
-
friends: {
|
|
162
|
-
coll: UsersCollection,
|
|
163
|
-
on: ["friendIds", "_id"],
|
|
164
|
-
many: true,
|
|
165
|
-
},
|
|
166
|
-
},
|
|
167
|
-
},
|
|
288
|
+
joinProp1: {
|
|
289
|
+
/* joinDefinition */
|
|
168
290
|
},
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
291
|
+
|
|
292
|
+
joinProp2: {
|
|
293
|
+
/* joinDefinition */
|
|
172
294
|
},
|
|
173
295
|
}
|
|
174
296
|
);
|
|
175
297
|
```
|
|
176
298
|
|
|
177
|
-
|
|
299
|
+
Collections can define **as many joins as needed** without impacting performance. They will be used only when explicitly fetched. They can be **declared in different places** (as long as join names don't collide).
|
|
300
|
+
|
|
301
|
+
In the context of Meteor, joins could (should?) be **defined in shared client and server code**, but some might only ever be used in one environement or the other. They could also define a set of common joins, but add others in environment specific code.
|
|
178
302
|
|
|
179
|
-
|
|
303
|
+
By default, joins link one document from the parent collection to multiple ones from the child collection. In the case of a **one-to-one relationship**, the `single` property should be set to true.
|
|
180
304
|
|
|
181
|
-
|
|
305
|
+
There are three main types of join definitions based on the argument to the `on` property: **array**, **object** and **function** joins.
|
|
306
|
+
|
|
307
|
+
### Simple array join
|
|
308
|
+
|
|
309
|
+
`on` can be defined as an array of `[parentProp, childProp]` equality.
|
|
182
310
|
|
|
183
311
|
```js
|
|
184
|
-
import {
|
|
312
|
+
import { join } from "coll-fns";
|
|
313
|
+
import { Comments, Posts, Users } from "/collections";
|
|
314
|
+
|
|
315
|
+
join(Posts, {
|
|
316
|
+
author: {
|
|
317
|
+
Coll: Users,
|
|
318
|
+
/* `post.authorId === user._id` */
|
|
319
|
+
on: ["authorId", "_id"],
|
|
320
|
+
/* Single doc instead of a list */
|
|
321
|
+
single: true,
|
|
322
|
+
},
|
|
185
323
|
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
}
|
|
191
|
-
// Transform data
|
|
192
|
-
doc.email = doc.email.toLowerCase();
|
|
193
|
-
doc.createdAt = new Date();
|
|
194
|
-
return doc;
|
|
324
|
+
comments: {
|
|
325
|
+
Coll: Comments,
|
|
326
|
+
/* `post._id === comment.postId` */
|
|
327
|
+
on: ["_id", "postId"],
|
|
328
|
+
},
|
|
195
329
|
});
|
|
196
330
|
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
331
|
+
/* Reversed join from user to posts */
|
|
332
|
+
join(Users, {
|
|
333
|
+
posts: {
|
|
334
|
+
Coll: Posts,
|
|
335
|
+
on: ["_id", "authorId"],
|
|
336
|
+
},
|
|
202
337
|
});
|
|
203
338
|
```
|
|
204
339
|
|
|
205
|
-
###
|
|
206
|
-
|
|
207
|
-
Hooks can be attached to any CRUD operation:
|
|
208
|
-
|
|
209
|
-
- **`insert`**: Before/after document insertion
|
|
210
|
-
- **`update`**: Before/after document updates
|
|
211
|
-
- **`remove`**: Before/after document removal
|
|
212
|
-
- **`fetch`**: Before/after fetching documents (useful for filtering)
|
|
213
|
-
|
|
214
|
-
### Hook Examples
|
|
340
|
+
### Sub-array joins
|
|
215
341
|
|
|
216
|
-
|
|
342
|
+
Sometimes, the property referencing linked documents is an array (of ids, usually). In that case, the name of the array property should be nested in an array.
|
|
217
343
|
|
|
218
344
|
```js
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
345
|
+
import { join } from "coll-fns";
|
|
346
|
+
import { Actions, Resources } from "/collections";
|
|
347
|
+
|
|
348
|
+
/* Each action can be associated with many resources and vice-versa.
|
|
349
|
+
* Resource's `actionIds` array is the link between them. */
|
|
350
|
+
join(Actions, {
|
|
351
|
+
resources: {
|
|
352
|
+
Coll: Resources,
|
|
353
|
+
on: ["_id", ["actionIds"]],
|
|
354
|
+
},
|
|
355
|
+
});
|
|
227
356
|
|
|
228
|
-
|
|
357
|
+
/* The reverse join will flip the property names. */
|
|
358
|
+
join(Resources, {
|
|
359
|
+
actions: {
|
|
360
|
+
Coll: Actions,
|
|
361
|
+
on: [["actionIds"], "_id"],
|
|
362
|
+
},
|
|
229
363
|
});
|
|
230
364
|
```
|
|
231
365
|
|
|
232
|
-
|
|
366
|
+
### Filtered array-joins
|
|
367
|
+
|
|
368
|
+
Some joins should target only specific documents in the foreign collection. A complementary selector can be passed to the third `on` array argument.
|
|
233
369
|
|
|
234
370
|
```js
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
371
|
+
import { join } from "coll-fns";
|
|
372
|
+
import { Actions, Resources } from "/collections";
|
|
373
|
+
|
|
374
|
+
join(Resources, {
|
|
375
|
+
/* Only active tasks (third array element is a selector) */
|
|
376
|
+
activeTasks: {
|
|
377
|
+
Coll: Tasks,
|
|
378
|
+
on: ["_id", "resourceId", { active: true }],
|
|
379
|
+
},
|
|
241
380
|
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
381
|
+
/* All tasks associated with a resource */
|
|
382
|
+
tasks: {
|
|
383
|
+
Coll: Tasks,
|
|
384
|
+
on: ["_id", "resourceId"],
|
|
385
|
+
},
|
|
246
386
|
});
|
|
247
387
|
```
|
|
248
388
|
|
|
249
|
-
|
|
389
|
+
### Object joins
|
|
390
|
+
|
|
391
|
+
The `on` join definition property can be an object representing a selector. It will always retrieve the same linked documents.
|
|
250
392
|
|
|
251
393
|
```js
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
}
|
|
394
|
+
import { join } from "coll-fns";
|
|
395
|
+
import { Factory, Workers } from "../collections";
|
|
396
|
+
|
|
397
|
+
join(Workers, {
|
|
398
|
+
/* All workers will have the same `factory` props. */
|
|
399
|
+
factory: {
|
|
400
|
+
Coll: Factory,
|
|
401
|
+
on: { name: "FACTORY ABC" },
|
|
402
|
+
single: true,
|
|
403
|
+
},
|
|
262
404
|
});
|
|
263
405
|
```
|
|
264
406
|
|
|
265
|
-
|
|
407
|
+
### Function joins
|
|
266
408
|
|
|
267
|
-
|
|
268
|
-
// Update denormalized data
|
|
269
|
-
hook(UsersCollection, "update", "after", (result, selector, modifier) => {
|
|
270
|
-
const userId = selector._id;
|
|
271
|
-
const userName = modifier.$set?.name;
|
|
409
|
+
When joins are too complex to be defined with an array or object (although rare), a function can be used as the `on` property. Each parent document will be passed to this function, which should return a selector to use on the child collection.
|
|
272
410
|
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
411
|
+
When using function-based joins, **a `fields` property should be added** to the join definition to declare which fields the parent document needs for the join to work:
|
|
412
|
+
|
|
413
|
+
```js
|
|
414
|
+
import { join } from "coll-fns";
|
|
415
|
+
import { Comments, Posts } from "/collections";
|
|
416
|
+
import { twoMonthsPrior } from "/lib/dates";
|
|
417
|
+
|
|
418
|
+
join(Posts, {
|
|
419
|
+
recentComments: {
|
|
420
|
+
Coll: Comments,
|
|
421
|
+
on: (post) => {
|
|
422
|
+
const { _id: postId, postedAt } = post;
|
|
423
|
+
|
|
424
|
+
/* This argument must be defined at runtime. */
|
|
425
|
+
const minDate = twoMonthsPrior(postedAt);
|
|
426
|
+
|
|
427
|
+
/* Return a selector for the Comments collection */
|
|
428
|
+
return {
|
|
429
|
+
createdAt: { $gte: minDate },
|
|
430
|
+
postId,
|
|
431
|
+
};
|
|
432
|
+
},
|
|
433
|
+
/* Parent fields needed in the join function */
|
|
434
|
+
fields: {
|
|
435
|
+
_id: 1, // Optional. _id is implicit in any fetch.
|
|
436
|
+
postedAt: 1,
|
|
437
|
+
},
|
|
438
|
+
},
|
|
282
439
|
});
|
|
283
440
|
```
|
|
284
441
|
|
|
285
|
-
|
|
442
|
+
### Recursive joins
|
|
286
443
|
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
### Server-Side (Async)
|
|
444
|
+
A collection can define joins on itself.
|
|
290
445
|
|
|
291
446
|
```js
|
|
292
|
-
|
|
293
|
-
import {
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
const user = await fetchOne(UsersCollection, { _id: this.userId });
|
|
301
|
-
return await insert(PostsCollection, {
|
|
302
|
-
title,
|
|
303
|
-
content,
|
|
304
|
-
authorId: this.userId,
|
|
305
|
-
});
|
|
447
|
+
import { join } from "coll-fns";
|
|
448
|
+
import { Users } from "/collections";
|
|
449
|
+
|
|
450
|
+
join(Users, {
|
|
451
|
+
friends: {
|
|
452
|
+
/* Use the same collection in the join definition */
|
|
453
|
+
Coll: Users,
|
|
454
|
+
on: [["friendIds"], "_id"],
|
|
306
455
|
},
|
|
307
456
|
});
|
|
308
457
|
```
|
|
309
458
|
|
|
310
|
-
###
|
|
459
|
+
### Join additional options
|
|
311
460
|
|
|
312
|
-
|
|
313
|
-
// client/main.js
|
|
314
|
-
import { setProtocol, protocols } from "coll-fns";
|
|
461
|
+
Any additional properties defined on the join (other than `Coll`, `on`, `single`, `postFetch`) will be treated as options to pass to the nested documents `fetchList`. It usually includes:
|
|
315
462
|
|
|
316
|
-
|
|
463
|
+
- `limit`: Maximum joined documents count
|
|
464
|
+
- `skip`: Documents to skip in the fetch
|
|
465
|
+
- `sort`: Sort order of joined documents
|
|
317
466
|
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
467
|
+
### `postFetch`
|
|
468
|
+
|
|
469
|
+
Children documents might need to be modified (transformed, ordered, filtered...) after being fetched. The `postFetch: (childrenDocs, parentDoc) => childrenDocs` join definition property can be used to do so.
|
|
470
|
+
|
|
471
|
+
The second argument of the function is the parent document. If some of its properties are needed, they should be declared in the `fields` property to ensure they are not missing from the requested fetched fields.
|
|
472
|
+
|
|
473
|
+
```js
|
|
474
|
+
import { join } from "coll-fns";
|
|
475
|
+
import { Actions, Resources } from "/collections";
|
|
476
|
+
import { sortTasks } from "/lib/tasks";
|
|
477
|
+
|
|
478
|
+
join(Resources, {
|
|
479
|
+
tasks: {
|
|
480
|
+
Coll: Tasks,
|
|
481
|
+
on: ["_id", "resourceId"],
|
|
482
|
+
|
|
483
|
+
/* Ensure `tasksOrder` will be fetched */
|
|
484
|
+
fields: { tasksOrder: 1 },
|
|
485
|
+
|
|
486
|
+
/* Transform the joined tasks documents based on parent resource. */
|
|
487
|
+
postFetch(tasks, resource) {
|
|
488
|
+
const { tasksOrder = [] } = resource;
|
|
489
|
+
return sortTasks(tasks, tasksOrder);
|
|
328
490
|
},
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
);
|
|
491
|
+
},
|
|
492
|
+
});
|
|
332
493
|
```
|
|
333
494
|
|
|
334
|
-
###
|
|
495
|
+
### `getJoins`
|
|
335
496
|
|
|
336
|
-
|
|
337
|
-
// imports/api/posts.js
|
|
338
|
-
import { fetchList } from "coll-fns";
|
|
497
|
+
Use `getJoins(Coll)` to retrieve the complete dictionary of the collection's joins.
|
|
339
498
|
|
|
340
|
-
|
|
341
|
-
export function getPostsWithAuthors() {
|
|
342
|
-
return fetchList(
|
|
343
|
-
PostsCollection,
|
|
344
|
-
{},
|
|
345
|
-
{
|
|
346
|
-
joins: {
|
|
347
|
-
author: {
|
|
348
|
-
coll: UsersCollection,
|
|
349
|
-
on: ["authorId", "_id"],
|
|
350
|
-
fields: { name: 1, avatar: 1 },
|
|
351
|
-
},
|
|
352
|
-
},
|
|
353
|
-
fields: { title: 1, content: 1, "+": { author: 1 } },
|
|
354
|
-
}
|
|
355
|
-
);
|
|
356
|
-
}
|
|
357
|
-
```
|
|
499
|
+
## `fetchList(Coll, selector, options)`
|
|
358
500
|
|
|
359
|
-
|
|
501
|
+
Fetch documents with the ability to **use collection joins**.
|
|
360
502
|
|
|
361
|
-
|
|
503
|
+
**Options:**
|
|
362
504
|
|
|
363
|
-
|
|
505
|
+
- `fields`: Field projection object
|
|
506
|
+
- `limit`: Maximum number of documents
|
|
507
|
+
- `skip`: Number of documents to skip
|
|
508
|
+
- `sort`: Sort specification
|
|
364
509
|
|
|
365
|
-
|
|
510
|
+
In its simplest form, `fetchList` can be used in much the same way as Meteor's `Coll.find(...args).fetch()`.
|
|
366
511
|
|
|
367
512
|
```js
|
|
368
513
|
const users = await fetchList(
|
|
369
|
-
|
|
514
|
+
Users,
|
|
370
515
|
{ status: "active" },
|
|
371
516
|
{
|
|
372
517
|
fields: { name: 1, email: 1 },
|
|
@@ -377,173 +522,366 @@ const users = await fetchList(
|
|
|
377
522
|
);
|
|
378
523
|
```
|
|
379
524
|
|
|
380
|
-
|
|
525
|
+
### `fields` option and joins
|
|
381
526
|
|
|
382
|
-
- `
|
|
383
|
-
- `sort`: Sort specification
|
|
384
|
-
- `limit`: Maximum number of documents
|
|
385
|
-
- `skip`: Number of documents to skip
|
|
386
|
-
- `joins`: Join definitions for related collections
|
|
527
|
+
Contrary to regular projection objects, they can use nested properties `{ car: { make: 1 } }` instead of dot-string ones `{ car: 1, "car.make": 1 }`.
|
|
387
528
|
|
|
388
|
-
|
|
529
|
+
The joins defined on the collections must be **explicitly specified in the `fields`** object for the children documents to be fetched. The combined presence of join or own fields determines the shape of the fetched documents.
|
|
389
530
|
|
|
390
|
-
|
|
531
|
+
#### Examples
|
|
532
|
+
|
|
533
|
+
<details>
|
|
534
|
+
<summary>Join definitions for examples</summary>
|
|
391
535
|
|
|
392
536
|
```js
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
537
|
+
import { fetchList, join } from "coll-fns";
|
|
538
|
+
import { Comments, Posts, Users } from "/collections";
|
|
539
|
+
|
|
540
|
+
/* Define joins on Posts collection */
|
|
541
|
+
join(Posts, {
|
|
542
|
+
/* One-to-one join */
|
|
543
|
+
author: {
|
|
544
|
+
Coll: Users,
|
|
545
|
+
on: ["authorId", "_id"],
|
|
546
|
+
single: true,
|
|
547
|
+
},
|
|
548
|
+
|
|
549
|
+
/* One-to-many join */
|
|
550
|
+
comments: {
|
|
551
|
+
Coll: Comments,
|
|
552
|
+
on: ["_id", "postId"],
|
|
553
|
+
/* `single` defaults to false,
|
|
554
|
+
* so joined docs are returned as an array */
|
|
555
|
+
},
|
|
556
|
+
});
|
|
400
557
|
```
|
|
401
558
|
|
|
402
|
-
|
|
559
|
+
</details>
|
|
403
560
|
|
|
404
|
-
|
|
561
|
+
<details>
|
|
562
|
+
<summary>Undefined (all) own fields</summary>
|
|
405
563
|
|
|
406
564
|
```js
|
|
407
|
-
|
|
408
|
-
// Returns: ['id1', 'id2', 'id3']
|
|
565
|
+
fetchList(Posts, {});
|
|
409
566
|
```
|
|
410
567
|
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
Check if any documents match the selector.
|
|
414
|
-
|
|
415
|
-
```js
|
|
416
|
-
const hasActiveUsers = await exists(UsersCollection, { status: "active" });
|
|
417
|
-
// Returns: true or false
|
|
568
|
+
```json
|
|
569
|
+
[{ "title": "Blabla", "authorId": "foo", "likes": 7 }]
|
|
418
570
|
```
|
|
419
571
|
|
|
420
|
-
|
|
572
|
+
</details>
|
|
421
573
|
|
|
422
|
-
|
|
574
|
+
<details>
|
|
575
|
+
<summary>Some own fields</summary>
|
|
423
576
|
|
|
424
577
|
```js
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
578
|
+
fetchList(
|
|
579
|
+
Posts,
|
|
580
|
+
{},
|
|
581
|
+
{
|
|
582
|
+
fields: {
|
|
583
|
+
title: true, // <= Own. Any truthy value works
|
|
584
|
+
},
|
|
585
|
+
}
|
|
586
|
+
);
|
|
429
587
|
```
|
|
430
588
|
|
|
431
|
-
|
|
589
|
+
```json
|
|
590
|
+
[{ "title": "Blabla" }]
|
|
591
|
+
```
|
|
432
592
|
|
|
433
|
-
|
|
593
|
+
</details>
|
|
594
|
+
|
|
595
|
+
<details>
|
|
596
|
+
<summary>Undefined (all) own fields, truthy (all) join fields</summary>
|
|
434
597
|
|
|
435
598
|
```js
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
{
|
|
439
|
-
{
|
|
440
|
-
|
|
599
|
+
fetchList(
|
|
600
|
+
Posts,
|
|
601
|
+
{},
|
|
602
|
+
{
|
|
603
|
+
fields: {
|
|
604
|
+
author: 1, // <= Join
|
|
605
|
+
},
|
|
606
|
+
}
|
|
441
607
|
);
|
|
442
608
|
```
|
|
443
609
|
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
610
|
+
```jsonc
|
|
611
|
+
[
|
|
612
|
+
{
|
|
613
|
+
"title": "Blabla",
|
|
614
|
+
"authorId": "foo",
|
|
615
|
+
"likes": 7,
|
|
616
|
+
"author": {
|
|
617
|
+
"name": "Foo Bar",
|
|
618
|
+
"birthdate": "Some Date",
|
|
619
|
+
"genre": "non-fiction",
|
|
620
|
+
},
|
|
621
|
+
},
|
|
622
|
+
]
|
|
450
623
|
```
|
|
451
624
|
|
|
452
|
-
|
|
625
|
+
</details>
|
|
453
626
|
|
|
454
|
-
|
|
627
|
+
<details>
|
|
628
|
+
<summary>Some own fields, truthy (all) join fields</summary>
|
|
455
629
|
|
|
456
630
|
```js
|
|
457
|
-
|
|
631
|
+
fetchList(
|
|
632
|
+
Posts,
|
|
633
|
+
{},
|
|
634
|
+
{
|
|
635
|
+
fields: {
|
|
636
|
+
title: 1, // <= Own
|
|
637
|
+
author: 1, // <= Join
|
|
638
|
+
},
|
|
639
|
+
}
|
|
640
|
+
);
|
|
458
641
|
```
|
|
459
642
|
|
|
460
|
-
|
|
643
|
+
```jsonc
|
|
644
|
+
[
|
|
645
|
+
{
|
|
646
|
+
"title": "Blabla",
|
|
647
|
+
"authorId": "foo", // <= Included by join definition
|
|
648
|
+
"author": {
|
|
649
|
+
"name": "Foo Bar",
|
|
650
|
+
"birthdate": "Some Date",
|
|
651
|
+
"genre": "non-fiction",
|
|
652
|
+
},
|
|
653
|
+
},
|
|
654
|
+
]
|
|
655
|
+
```
|
|
461
656
|
|
|
462
|
-
|
|
657
|
+
</details>
|
|
463
658
|
|
|
464
|
-
|
|
659
|
+
<details>
|
|
660
|
+
<summary>Some own fields, some join fields</summary>
|
|
465
661
|
|
|
466
662
|
```js
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
663
|
+
fetchList(
|
|
664
|
+
Posts,
|
|
665
|
+
{},
|
|
666
|
+
{
|
|
667
|
+
fields: {
|
|
668
|
+
title: 1, // <= Own
|
|
669
|
+
author: { birthdate: 0 }, // <= Falsy = anything but these fields
|
|
670
|
+
comments: { text: 1 },
|
|
671
|
+
},
|
|
672
|
+
}
|
|
673
|
+
);
|
|
471
674
|
```
|
|
472
675
|
|
|
473
|
-
|
|
676
|
+
```jsonc
|
|
677
|
+
[
|
|
678
|
+
{
|
|
679
|
+
"title": "Blabla",
|
|
680
|
+
"authorId": "foo", // <= Included by join definition
|
|
681
|
+
"author": {
|
|
682
|
+
"name": "Foo Bar",
|
|
683
|
+
"genre": "non-fiction",
|
|
684
|
+
},
|
|
685
|
+
/* Comments is a one-to-many join, so is returned as a list */
|
|
686
|
+
"comments": [{ "text": "Nice!" }, { "text": "Great!" }],
|
|
687
|
+
},
|
|
688
|
+
]
|
|
689
|
+
```
|
|
474
690
|
|
|
475
|
-
|
|
476
|
-
- `operation`: `'insert'`, `'update'`, `'remove'`, or `'fetch'`
|
|
477
|
-
- `timing`: `'before'` or `'after'`
|
|
478
|
-
- `fn`: Hook function (return value depends on operation and timing)
|
|
691
|
+
</details>
|
|
479
692
|
|
|
480
|
-
###
|
|
693
|
+
### `setJoinPrefix(prefix)`
|
|
481
694
|
|
|
482
|
-
|
|
695
|
+
If this combination approach seems confusing, it is possible to define a prefix that must be explicitly used when joined documents should be used. **The prefix will be removed** from the returned documents.
|
|
483
696
|
|
|
484
|
-
|
|
697
|
+
Setting the prefix to null or undefined allows using join fields at the document root like any normal field.
|
|
485
698
|
|
|
486
699
|
```js
|
|
487
|
-
import {
|
|
700
|
+
import { setJoinPrefix } from "coll-fns";
|
|
488
701
|
|
|
489
|
-
|
|
702
|
+
/* All join fields will have to be prefixed with "+" */
|
|
703
|
+
setJoinPrefix("+");
|
|
704
|
+
|
|
705
|
+
/* Some own fields, some join fields */
|
|
706
|
+
fetchList(
|
|
707
|
+
Posts,
|
|
708
|
+
{},
|
|
709
|
+
{
|
|
710
|
+
fields: {
|
|
711
|
+
title: 1, // <= Own
|
|
712
|
+
|
|
713
|
+
/* Join fields must be nested under the prefix key */
|
|
714
|
+
"+": {
|
|
715
|
+
author: { name: 1, birthdate: 1 }, // <= Join sub fields
|
|
716
|
+
},
|
|
717
|
+
},
|
|
718
|
+
}
|
|
719
|
+
);
|
|
490
720
|
```
|
|
491
721
|
|
|
492
|
-
|
|
722
|
+
This option could also be useful if a document can have some denormalized data with the same property name as the join. The denormalized values or the joined document would then be returned based on the use of the prefix.
|
|
723
|
+
|
|
724
|
+
If, for some reason, you need to retrieve the prefix, you can do so with `getJoinPrefix(Coll)`.
|
|
725
|
+
|
|
726
|
+
### Nested Joins
|
|
493
727
|
|
|
494
|
-
|
|
728
|
+
Joins can be nested to fetch deeply related data. See [Hooks best practices](#hook-best-practices) for how hooks can be used with nested joins.
|
|
495
729
|
|
|
496
730
|
```js
|
|
497
|
-
|
|
498
|
-
```
|
|
731
|
+
import { fetchList } from "coll-fns";
|
|
499
732
|
|
|
500
|
-
|
|
733
|
+
const posts = fetchList(
|
|
734
|
+
Posts,
|
|
735
|
+
{},
|
|
736
|
+
{
|
|
737
|
+
fields: {
|
|
738
|
+
title: 1,
|
|
501
739
|
|
|
502
|
-
|
|
740
|
+
/* Level 1 : One-to-many join */
|
|
741
|
+
comments: {
|
|
742
|
+
text: 1,
|
|
503
743
|
|
|
504
|
-
|
|
505
|
-
|
|
744
|
+
/* Level 2 : One-to-one join */
|
|
745
|
+
user: {
|
|
746
|
+
username: 1,
|
|
747
|
+
},
|
|
748
|
+
},
|
|
749
|
+
},
|
|
750
|
+
}
|
|
751
|
+
);
|
|
752
|
+
```
|
|
506
753
|
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
754
|
+
```json
|
|
755
|
+
{
|
|
756
|
+
"title": "Blabla",
|
|
757
|
+
"comments": [
|
|
758
|
+
{ "text": "Nice!", "user": { "username": "foo"} },
|
|
759
|
+
{ "text": "Great!", "user": { "username": "bar" } }
|
|
760
|
+
]
|
|
761
|
+
},
|
|
510
762
|
```
|
|
511
763
|
|
|
512
|
-
###
|
|
764
|
+
### Recursion levels
|
|
513
765
|
|
|
514
|
-
|
|
766
|
+
When a field is declared using a positive number, its value is treated as a recursion limit. This could help preventing infinite loops. The value `Infinity` can even be used to go as deep as possible (to exhaustion), although it involves a greater risk of infinite loops.
|
|
515
767
|
|
|
516
768
|
```js
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
769
|
+
import { join } from "coll-fns";
|
|
770
|
+
import { Users } from "/collections";
|
|
771
|
+
|
|
772
|
+
/* Pre-register recursive join */
|
|
773
|
+
join(Users, {
|
|
774
|
+
friends: {
|
|
775
|
+
Coll: Users,
|
|
776
|
+
on: [["friendIds"], "_id"],
|
|
777
|
+
},
|
|
778
|
+
});
|
|
779
|
+
|
|
780
|
+
fetchList(
|
|
781
|
+
Users,
|
|
520
782
|
{},
|
|
521
783
|
{
|
|
522
784
|
fields: {
|
|
523
785
|
name: 1,
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
city: 1,
|
|
527
|
-
},
|
|
786
|
+
/* Join field. Limit to 2 levels deep, reusing parent fields */
|
|
787
|
+
friends: 2,
|
|
528
788
|
},
|
|
529
789
|
}
|
|
530
790
|
);
|
|
791
|
+
```
|
|
792
|
+
|
|
793
|
+
### Documents transformation
|
|
531
794
|
|
|
532
|
-
|
|
795
|
+
Documents can be transformed after fetching. Collection-level transforms are automatically applied if the protocol allows it:
|
|
796
|
+
|
|
797
|
+
**Meteor:**
|
|
798
|
+
|
|
799
|
+
```js
|
|
800
|
+
import { Mongo } from "meteor/mongo";
|
|
801
|
+
|
|
802
|
+
const Users = new Mongo.Collection("users", {
|
|
803
|
+
transform: (doc) => ({
|
|
804
|
+
...doc,
|
|
805
|
+
fullName: `${doc.firstName} ${doc.lastName}`,
|
|
806
|
+
}),
|
|
807
|
+
});
|
|
808
|
+
```
|
|
809
|
+
|
|
810
|
+
**For a specific fetch**, pass a `transform` option:
|
|
811
|
+
|
|
812
|
+
```js
|
|
533
813
|
const users = await fetchList(
|
|
534
|
-
|
|
535
|
-
{},
|
|
814
|
+
Users,
|
|
815
|
+
{ status: "active" },
|
|
816
|
+
{
|
|
817
|
+
transform: (doc) => ({
|
|
818
|
+
...doc,
|
|
819
|
+
fullName: `${doc.firstName} ${doc.lastName}`,
|
|
820
|
+
}),
|
|
821
|
+
}
|
|
822
|
+
);
|
|
823
|
+
```
|
|
824
|
+
|
|
825
|
+
To skip a collection's transform, pass `transform: null`. Transforms are applied **after joins resolve**, so they have access to joined data. See [Nested Joins](#nested-joins) for examples of using transforms with complex data structures.
|
|
826
|
+
|
|
827
|
+
## `fetchOne(Coll, selector, options)`
|
|
828
|
+
|
|
829
|
+
Fetch a single document from a collection. Same behaviour as `fetchList`.
|
|
830
|
+
|
|
831
|
+
```js
|
|
832
|
+
import { fetchOne } from "coll-fns";
|
|
833
|
+
import { Users } from "/collections";
|
|
834
|
+
|
|
835
|
+
const user = fetchOne(
|
|
836
|
+
Users,
|
|
837
|
+
{ _id: userId },
|
|
536
838
|
{
|
|
537
839
|
fields: {
|
|
538
840
|
name: 1,
|
|
539
|
-
|
|
540
|
-
"address.city": 1,
|
|
841
|
+
friends: 1, // <= Join
|
|
541
842
|
},
|
|
542
843
|
}
|
|
543
844
|
);
|
|
544
845
|
```
|
|
545
846
|
|
|
546
|
-
|
|
847
|
+
## `fetchIds(Coll, selector, options)`
|
|
848
|
+
|
|
849
|
+
Fetch only the `_id` field of matching documents. `fields` option will be ignored.
|
|
850
|
+
|
|
851
|
+
```js
|
|
852
|
+
import { fetchOne } from "coll-fns";
|
|
853
|
+
import { Users } from "/collections";
|
|
854
|
+
|
|
855
|
+
const userIds = fetchIds(Users, { status: "active" });
|
|
856
|
+
```
|
|
857
|
+
|
|
858
|
+
## `exists(Coll, selector)`
|
|
859
|
+
|
|
860
|
+
Check if any document matches the selector.
|
|
861
|
+
|
|
862
|
+
```js
|
|
863
|
+
import { fetchOne } from "coll-fns";
|
|
864
|
+
import { Users } from "/collections";
|
|
865
|
+
|
|
866
|
+
const hasActiveUsers = exists(Users, { status: "active" });
|
|
867
|
+
// Returns: true or false
|
|
868
|
+
```
|
|
869
|
+
|
|
870
|
+
## `count(Coll, selector)`
|
|
871
|
+
|
|
872
|
+
Count documents matching the selector.
|
|
873
|
+
|
|
874
|
+
```js
|
|
875
|
+
import { fetchOne } from "coll-fns";
|
|
876
|
+
import { Users } from "/collections";
|
|
877
|
+
|
|
878
|
+
const activeUsersCount = count(UsersCollection, { status: "active" });
|
|
879
|
+
// Returns an integer
|
|
880
|
+
```
|
|
881
|
+
|
|
882
|
+
## `flattenFields(fields)`
|
|
883
|
+
|
|
884
|
+
Flatten a general field specifiers object (which could include nested objects) into a MongoDB-compatible one that uses dot-notation.
|
|
547
885
|
|
|
548
886
|
```js
|
|
549
887
|
import { flattenFields } from "coll-fns";
|
|
@@ -558,120 +896,382 @@ const flattened = flattenFields({
|
|
|
558
896
|
// Result: { name: 1, 'address.street': 1, 'address.city': 1 }
|
|
559
897
|
```
|
|
560
898
|
|
|
561
|
-
|
|
899
|
+
# Hooks and write operations
|
|
900
|
+
|
|
901
|
+
Hooks allow you to **intercept and react to data mutations** (insertions, updates, removals) on collections. They are triggered conditionally **before or after** write operations, making them ideal for validation, cascading updates, logging, and other side effects.
|
|
562
902
|
|
|
563
|
-
|
|
903
|
+
## `hook(Coll, hooksObj)`
|
|
904
|
+
|
|
905
|
+
Register hooks on a collection to run before or after specific write operations (insert, update, remove).
|
|
906
|
+
|
|
907
|
+
The same object argument can define multiple hook types. Each hook type is defined using an array of hook definitions, making it possible to **define multiple hooks at once**.
|
|
908
|
+
|
|
909
|
+
Hooks can be **defined in multiple places** in your codebase. This allows grouping functionally related hooks together.
|
|
564
910
|
|
|
565
911
|
```js
|
|
566
|
-
import {
|
|
912
|
+
import { hook } from "coll-fns";
|
|
913
|
+
import { Users, Posts } from "/collections";
|
|
914
|
+
|
|
915
|
+
hook(Users, {
|
|
916
|
+
beforeInsert: [
|
|
917
|
+
{
|
|
918
|
+
fn(doc) {
|
|
919
|
+
if (!doc.email) throw new Error("Email is required");
|
|
920
|
+
|
|
921
|
+
doc.createdAt = new Date();
|
|
922
|
+
},
|
|
923
|
+
},
|
|
924
|
+
],
|
|
925
|
+
|
|
926
|
+
onInserted: [
|
|
927
|
+
{
|
|
928
|
+
fn(doc) {
|
|
929
|
+
console.log(`New user created: ${doc._id}`);
|
|
930
|
+
},
|
|
931
|
+
},
|
|
932
|
+
],
|
|
933
|
+
});
|
|
567
934
|
```
|
|
568
935
|
|
|
569
|
-
###
|
|
936
|
+
### Before hooks
|
|
570
937
|
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
938
|
+
These hooks run **before** the write operation and can **prevent the operation** by throwing an error.
|
|
939
|
+
|
|
940
|
+
- **`beforeInsert`**: Runs before inserting a document. Receives `(doc)`.
|
|
941
|
+
- **`beforeUpdate`**: Runs before updating documents. Receives `([...docsToUpdate], modifier)`.
|
|
942
|
+
- **`beforeRemove`**: Runs before removing documents. Receives `([...docsToRemove])`.
|
|
943
|
+
|
|
944
|
+
Although arguments can be mutated, it is not the main purpose of these hooks. Mutations are brittle and hard to debug.
|
|
945
|
+
|
|
946
|
+
`beforeUpdate` and `beforeRemove` receive an **array of targeted documents**, whereas `beforeInsert` receives a **single document**.
|
|
947
|
+
|
|
948
|
+
### After hooks
|
|
574
949
|
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
950
|
+
These hooks run **after** the write operation completes and are **fire-and-forget** (not awaited). They should not throw errors that should get back to the caller.
|
|
951
|
+
|
|
952
|
+
- **`onInserted`**: Runs after a document is inserted. Receives `(doc)`.
|
|
953
|
+
- **`onUpdated`**: Runs after a document is updated. Receives `(afterDoc, beforeDoc)`.
|
|
954
|
+
- **`onRemoved`**: Runs after a document is removed. Receives `(doc)`.
|
|
955
|
+
|
|
956
|
+
### Hook definition properties
|
|
957
|
+
|
|
958
|
+
Each hook definition is an object with the following properties:
|
|
959
|
+
|
|
960
|
+
```js
|
|
961
|
+
{
|
|
962
|
+
/* Required. The function to execute.
|
|
963
|
+
* Arguments depend on the hook type (see above). */
|
|
964
|
+
fn(...args) { /* ... */ },
|
|
965
|
+
|
|
966
|
+
/* Optional. Fields to fetch for the documents passed to the hook.
|
|
967
|
+
* Fields for multiple hooks of the same type are automatically combined.
|
|
968
|
+
* If any hook of a type requests all fields with `undefined` or `true`,
|
|
969
|
+
* all other similar hooks will also get the entire documents.
|
|
970
|
+
* Has no effect on `beforeInsert`: the doc to be inserted is the argument. */
|
|
971
|
+
fields: { name: 1, email: 1 },
|
|
972
|
+
|
|
973
|
+
/* Optional (`onUpdated` only). If true, fetch the document state
|
|
974
|
+
* before the update with the same `fields` value.
|
|
975
|
+
* Otherwise, only _id is fetched initially (they would have been
|
|
976
|
+
* needed anyway to fetch their "after" versions). */
|
|
977
|
+
before: true,
|
|
978
|
+
|
|
979
|
+
/* Optional. Predicate that prevents the hook from running if it
|
|
980
|
+
* returns a truthy value. Receives the same arguments as fn. */
|
|
981
|
+
unless(doc) { return doc.isBot; },
|
|
982
|
+
|
|
983
|
+
/* Optional. Predicate that allows the hook to run only if it
|
|
984
|
+
* returns a truthy value. Receives the same arguments as fn. */
|
|
985
|
+
when(doc) { return doc.status === "pending"; },
|
|
986
|
+
|
|
987
|
+
/* Optional handler called if the hook function throws an error.
|
|
988
|
+
* A default handler that logs to console.error is defined
|
|
989
|
+
* for after-hooks (onInserted, onUpdated, onRemoved)
|
|
990
|
+
* to prevent an error from crashing the server. */
|
|
991
|
+
onError(err, hookDef) { /* ... */ },
|
|
992
|
+
}
|
|
578
993
|
```
|
|
579
994
|
|
|
580
|
-
###
|
|
995
|
+
### Examples
|
|
996
|
+
|
|
997
|
+
<details>
|
|
998
|
+
<summary>Data validation</summary>
|
|
581
999
|
|
|
582
1000
|
```js
|
|
583
|
-
|
|
1001
|
+
hook(Users, {
|
|
1002
|
+
beforeInsert: [
|
|
1003
|
+
{
|
|
1004
|
+
fn(doc) {
|
|
1005
|
+
if (!doc.email || !doc.email.includes("@")) {
|
|
1006
|
+
throw new Error("Invalid email");
|
|
1007
|
+
}
|
|
1008
|
+
},
|
|
1009
|
+
},
|
|
1010
|
+
],
|
|
1011
|
+
});
|
|
1012
|
+
```
|
|
1013
|
+
|
|
1014
|
+
</details>
|
|
584
1015
|
|
|
585
|
-
|
|
1016
|
+
<details>
|
|
1017
|
+
<summary>Cascading updates</summary>
|
|
1018
|
+
|
|
1019
|
+
```js
|
|
1020
|
+
/* If user's name changed, update their posts' denormalized data */
|
|
1021
|
+
hook(Users, {
|
|
1022
|
+
onUpdated: [
|
|
1023
|
+
{
|
|
1024
|
+
fields: { name: 1 },
|
|
1025
|
+
/* Use `when` predicate to run the hook only on this condition.
|
|
1026
|
+
* Could also have used `unless` or checked condition inside `fn`. */
|
|
1027
|
+
when: (after, before) => after.name !== before.name,
|
|
1028
|
+
|
|
1029
|
+
/* Effect to run - uses update() which also supports hooks */
|
|
1030
|
+
fn(after) {
|
|
1031
|
+
const { _id, name } = after;
|
|
1032
|
+
update(Posts, { authorId: _id }, { $set: { authorName: name } });
|
|
1033
|
+
},
|
|
1034
|
+
},
|
|
1035
|
+
],
|
|
1036
|
+
});
|
|
586
1037
|
```
|
|
587
1038
|
|
|
588
|
-
|
|
1039
|
+
</details>
|
|
1040
|
+
|
|
1041
|
+
<details>
|
|
1042
|
+
<summary>Conditional hooks with when/unless</summary>
|
|
589
1043
|
|
|
590
1044
|
```js
|
|
591
|
-
|
|
1045
|
+
hook(Posts, {
|
|
1046
|
+
beforeRemove: [
|
|
1047
|
+
{
|
|
1048
|
+
/* Limit fetched fields of docs to be removed */
|
|
1049
|
+
fields: { _id: 1 },
|
|
1050
|
+
/* Only run for non-admin users */
|
|
1051
|
+
unless() {
|
|
1052
|
+
return Meteor.user()?.isAdmin;
|
|
1053
|
+
},
|
|
1054
|
+
fn() {
|
|
1055
|
+
throw new Error("Only admins can delete posts");
|
|
1056
|
+
},
|
|
1057
|
+
},
|
|
1058
|
+
],
|
|
592
1059
|
|
|
593
|
-
|
|
1060
|
+
onRemoved: [
|
|
1061
|
+
{
|
|
1062
|
+
/* Only log removal of published posts */
|
|
1063
|
+
when(doc) {
|
|
1064
|
+
return doc.status === "published";
|
|
1065
|
+
},
|
|
1066
|
+
fn(doc) {
|
|
1067
|
+
logEvent("post_deleted", { postId: doc._id });
|
|
1068
|
+
},
|
|
1069
|
+
},
|
|
1070
|
+
],
|
|
1071
|
+
});
|
|
594
1072
|
```
|
|
595
1073
|
|
|
596
|
-
|
|
1074
|
+
</details>
|
|
597
1075
|
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
Create your own protocol by implementing the required methods:
|
|
1076
|
+
<details>
|
|
1077
|
+
<summary>Cascading removals</summary>
|
|
601
1078
|
|
|
602
1079
|
```js
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
}
|
|
1080
|
+
hook(Users, {
|
|
1081
|
+
beforeRemove: [
|
|
1082
|
+
{
|
|
1083
|
+
fn(usersToRemove) {
|
|
1084
|
+
const userIds = usersToRemove.map((u) => u._id);
|
|
1085
|
+
|
|
1086
|
+
/* Prevent removal if user has published posts */
|
|
1087
|
+
const hasPublished = exists(Posts, {
|
|
1088
|
+
authorId: { $in: userIds },
|
|
1089
|
+
status: "published",
|
|
1090
|
+
});
|
|
1091
|
+
|
|
1092
|
+
if (hasPublished) {
|
|
1093
|
+
throw new Error("Cannot delete users with published posts");
|
|
1094
|
+
}
|
|
1095
|
+
},
|
|
1096
|
+
},
|
|
1097
|
+
],
|
|
620
1098
|
|
|
621
|
-
|
|
1099
|
+
onRemoved: [
|
|
1100
|
+
{
|
|
1101
|
+
fn(user) {
|
|
1102
|
+
/* Clean up related data after user is removed.
|
|
1103
|
+
* See remove() for more details on how this integrates with hooks. */
|
|
1104
|
+
remove(Comments, { authorId: user._id });
|
|
1105
|
+
},
|
|
1106
|
+
},
|
|
1107
|
+
],
|
|
1108
|
+
});
|
|
622
1109
|
```
|
|
623
1110
|
|
|
624
|
-
|
|
1111
|
+
</details>
|
|
1112
|
+
|
|
1113
|
+
The data mutation methods below use basically the same arguments as [Meteor collection methods](https://docs.meteor.com/api/collections.html#Mongo-Collection-updateAsync).
|
|
625
1114
|
|
|
626
|
-
|
|
1115
|
+
## `insert(Coll, doc)`
|
|
627
1116
|
|
|
628
|
-
|
|
1117
|
+
Insert a document into a collection. Returns the document \_id. Runs `beforeInsert` and `onInserted` hooks if defined.
|
|
629
1118
|
|
|
630
1119
|
```js
|
|
631
|
-
|
|
1120
|
+
const newUser = insert(Users, {
|
|
1121
|
+
name: "Bob",
|
|
1122
|
+
email: "bob@example.com",
|
|
1123
|
+
});
|
|
1124
|
+
```
|
|
1125
|
+
|
|
1126
|
+
**Execution flow:**
|
|
632
1127
|
|
|
633
|
-
|
|
1128
|
+
1. Run `beforeInsert` hooks (can throw to prevent insertion)
|
|
1129
|
+
2. Insert the document
|
|
1130
|
+
3. Fire `onInserted` hooks asynchronously (without awaiting)
|
|
1131
|
+
|
|
1132
|
+
## `update(Coll, selector, modifier, options)`
|
|
1133
|
+
|
|
1134
|
+
Update documents matching the selector. Returns the number of documents modified. Runs `beforeUpdate` and `onUpdated` hooks if defined. Updates **multiple documents by default** (unlike Meteor's behavior).
|
|
1135
|
+
|
|
1136
|
+
```js
|
|
1137
|
+
update(Users, { status: "pending" }, { $set: { status: "active" } });
|
|
634
1138
|
```
|
|
635
1139
|
|
|
636
|
-
|
|
1140
|
+
**Execution flow:**
|
|
1141
|
+
|
|
1142
|
+
1. Fetch target documents with `beforeUpdate` and `onUpdated.before` fields
|
|
1143
|
+
2. Run `beforeUpdate` hooks with `(docsToUpdate, modifier)` (can throw to prevent update)
|
|
1144
|
+
3. Execute the update
|
|
1145
|
+
4. Fetch updated documents again with `onUpdated` fields
|
|
1146
|
+
5. Fire `onUpdated` hooks asynchronously with `(afterDoc, beforeDoc)` pairs
|
|
1147
|
+
|
|
1148
|
+
**Options:**
|
|
1149
|
+
|
|
1150
|
+
- `multi` (default: `true`): Update multiple documents or just the first match;
|
|
1151
|
+
- `arrayFilters`: Optional. Used in combination with [MongoDB filtered positional operator](https://www.mongodb.com/docs/manual/reference/operator/update/positional-filtered/) to specify which elements to modify in an array field.
|
|
1152
|
+
|
|
1153
|
+
## `remove(Coll, selector)`
|
|
1154
|
+
|
|
1155
|
+
Remove documents matching the selector. Runs `beforeRemove` and `onRemoved` hooks if defined.
|
|
637
1156
|
|
|
638
1157
|
```js
|
|
639
|
-
|
|
1158
|
+
remove(Users, { inactive: true });
|
|
1159
|
+
```
|
|
1160
|
+
|
|
1161
|
+
**Execution flow:**
|
|
1162
|
+
|
|
1163
|
+
1. Fetch documents matching the selector with `beforeRemove` and `onRemoved` fields
|
|
1164
|
+
2. Run `beforeRemove` hooks with matched documents (can throw to prevent removal)
|
|
1165
|
+
3. Remove the documents
|
|
1166
|
+
4. Fire `onRemoved` hooks asynchronously with each removed document
|
|
640
1167
|
|
|
641
|
-
|
|
1168
|
+
## Hook best practices
|
|
1169
|
+
|
|
1170
|
+
<details>
|
|
1171
|
+
<summary><strong>Error handling</strong></summary>
|
|
1172
|
+
|
|
1173
|
+
**Before hooks** should throw errors to prevent operations:
|
|
1174
|
+
|
|
1175
|
+
```js
|
|
1176
|
+
hook(Users, {
|
|
1177
|
+
beforeInsert: [
|
|
1178
|
+
{
|
|
1179
|
+
fn(doc) {
|
|
1180
|
+
if (!isValidEmail(doc.email)) {
|
|
1181
|
+
throw new Error("Invalid email");
|
|
1182
|
+
}
|
|
1183
|
+
},
|
|
1184
|
+
},
|
|
1185
|
+
],
|
|
1186
|
+
});
|
|
642
1187
|
```
|
|
643
1188
|
|
|
644
|
-
|
|
1189
|
+
**After hooks** have a default error handler that logs to `console.error`. Define a custom `onError` handler if you need different behavior. Receives (err, hookDef) where hookDef is the hook definition enhanced with metadata, including `Coll`, `collName` and `hookType`.
|
|
645
1190
|
|
|
646
1191
|
```js
|
|
647
|
-
|
|
1192
|
+
hook(Users, {
|
|
1193
|
+
onInserted: [
|
|
1194
|
+
{
|
|
1195
|
+
fn(doc) {
|
|
1196
|
+
/* ... */
|
|
1197
|
+
},
|
|
1198
|
+
onError(err, hookDef) {
|
|
1199
|
+
logToService(err, hookDef.collName);
|
|
1200
|
+
},
|
|
1201
|
+
},
|
|
1202
|
+
],
|
|
1203
|
+
});
|
|
1204
|
+
```
|
|
1205
|
+
|
|
1206
|
+
</details>
|
|
1207
|
+
|
|
1208
|
+
<details>
|
|
1209
|
+
<summary><strong>Field optimization</strong></summary>
|
|
1210
|
+
|
|
1211
|
+
Always declare which fields your hook needs with the `fields` property. This reduces database queries and improves performance:
|
|
648
1212
|
|
|
649
|
-
|
|
650
|
-
|
|
1213
|
+
```js
|
|
1214
|
+
hook(Posts, {
|
|
1215
|
+
onUpdated: [
|
|
1216
|
+
{
|
|
1217
|
+
/* Only fetch these fields */
|
|
1218
|
+
fields: { authorId: 1, title: 1 },
|
|
1219
|
+
fn(afterPost, beforePost) {
|
|
1220
|
+
if (afterPost.title !== beforePost.title) {
|
|
1221
|
+
notifySubscribers(afterPost);
|
|
1222
|
+
}
|
|
1223
|
+
},
|
|
1224
|
+
},
|
|
1225
|
+
],
|
|
1226
|
+
});
|
|
651
1227
|
```
|
|
652
1228
|
|
|
653
|
-
|
|
1229
|
+
</details>
|
|
1230
|
+
|
|
1231
|
+
<details>
|
|
1232
|
+
<summary><strong>Conditional execution</strong></summary>
|
|
1233
|
+
|
|
1234
|
+
Use `when` and `unless` to avoid unnecessary side effects while keeping code clean and predictable:
|
|
654
1235
|
|
|
1236
|
+
```js
|
|
1237
|
+
hook(Users, {
|
|
1238
|
+
onUpdated: [
|
|
1239
|
+
{
|
|
1240
|
+
fields: { status: 1 },
|
|
1241
|
+
/* Only run if status actually changed */
|
|
1242
|
+
unless(after, before) {
|
|
1243
|
+
return after.status === before.status;
|
|
1244
|
+
},
|
|
1245
|
+
fn(after, before) {
|
|
1246
|
+
sendStatusChangeEmail(after);
|
|
1247
|
+
},
|
|
1248
|
+
},
|
|
1249
|
+
],
|
|
1250
|
+
});
|
|
655
1251
|
```
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
1252
|
+
|
|
1253
|
+
</details>
|
|
1254
|
+
|
|
1255
|
+
<details style="margin-bottom: 1rem">
|
|
1256
|
+
<summary><strong>Async operations</strong></summary>
|
|
1257
|
+
|
|
1258
|
+
Hooks support both synchronous and asynchronous code. Returning a promise from a before-hook will delay the write operation:
|
|
1259
|
+
|
|
1260
|
+
```js
|
|
1261
|
+
hook(Users, {
|
|
1262
|
+
beforeInsert: [
|
|
1263
|
+
{
|
|
1264
|
+
async fn(doc) {
|
|
1265
|
+
/* Wait for external service */
|
|
1266
|
+
doc.externalId = await createExternalUser(doc);
|
|
1267
|
+
},
|
|
1268
|
+
},
|
|
1269
|
+
],
|
|
1270
|
+
});
|
|
673
1271
|
```
|
|
674
1272
|
|
|
675
|
-
|
|
1273
|
+
</details>
|
|
1274
|
+
|
|
1275
|
+
# License
|
|
676
1276
|
|
|
677
1277
|
MIT
|