coll-fns 1.1.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 +955 -636
- 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 +1 -1
package/README.md
CHANGED
|
@@ -1,958 +1,1277 @@
|
|
|
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
|
-
|
|
53
|
+
# Rationale
|
|
14
54
|
|
|
15
|
-
|
|
16
|
-
- **Extensible Hooks**: React to CRUD operations with before/after hooks for validation, transformation, and side effects
|
|
17
|
-
- **Isomorphic by Design**: Write once, run anywhere - same API for client-side (sync) and server-side (async) code
|
|
18
|
-
- **Protocol-based Architecture**: Switch between different database backends seamlessly
|
|
19
|
-
- **Advanced Field Projections**: Support for nested fields, dot notation, and MongoDB-style projections
|
|
20
|
-
- **Promise/async Support**: Works with both synchronous and asynchronous protocols
|
|
21
|
-
- **TypeScript-ready**: Includes JSDoc types for better IDE support
|
|
55
|
+
Click on any element to unfold it and better understand the rationale behind this library!
|
|
22
56
|
|
|
23
|
-
|
|
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.
|
|
24
140
|
|
|
25
141
|
```bash
|
|
26
142
|
npm install coll-fns
|
|
27
143
|
```
|
|
28
144
|
|
|
29
|
-
##
|
|
145
|
+
## `setProtocol(protocol)`
|
|
146
|
+
|
|
147
|
+
You will have to **define which protocol to use** before using any of the library's functionality.
|
|
30
148
|
|
|
31
149
|
```js
|
|
32
|
-
import {
|
|
33
|
-
setProtocol,
|
|
34
|
-
fetchList,
|
|
35
|
-
insert,
|
|
36
|
-
update,
|
|
37
|
-
remove,
|
|
38
|
-
count,
|
|
39
|
-
join,
|
|
40
|
-
protocols,
|
|
41
|
-
} from "coll-fns";
|
|
150
|
+
import { setProtocol, protocols } from "coll-fns";
|
|
42
151
|
|
|
43
|
-
|
|
44
|
-
|
|
152
|
+
/* Built-in protocols include:
|
|
153
|
+
* - meteorAsync
|
|
154
|
+
* - meteorSync
|
|
155
|
+
* - node
|
|
156
|
+
*
|
|
157
|
+
* Can also define a custom protocol! */
|
|
158
|
+
setProtocol(protocols.meteorAsync);
|
|
159
|
+
```
|
|
45
160
|
|
|
46
|
-
|
|
47
|
-
const users = await fetchList(UsersCollection, { age: { $gte: 18 } });
|
|
48
|
-
await insert(UsersCollection, { name: "Alice", age: 25 });
|
|
49
|
-
await update(UsersCollection, { name: "Alice" }, { $set: { age: 26 } });
|
|
50
|
-
const total = await count(UsersCollection, {});
|
|
51
|
-
await remove(UsersCollection, { name: "Alice" });
|
|
161
|
+
In a Meteor project, you should probably define a **different protocol on client** (synchronous) **and server** (asynchronous).
|
|
52
162
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
author: {
|
|
56
|
-
Coll: UsersCollection,
|
|
57
|
-
on: ["authorId", "_id"],
|
|
58
|
-
single: true,
|
|
59
|
-
},
|
|
60
|
-
});
|
|
163
|
+
```js
|
|
164
|
+
import { setProtocol, protocols } from "coll-fns";
|
|
61
165
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
166
|
+
const protocol = Meteor.isServer ? protocols.meteorAsync : protocols.meteorSync;
|
|
167
|
+
setProtocol(protocol);
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
There's also a **native NodeJS MongoDB driver** protocol built-in (`protocols.node`).
|
|
171
|
+
|
|
172
|
+
<details style="margin-bottom: 1rem">
|
|
173
|
+
<summary><strong>Custom protocol</strong></summary>
|
|
174
|
+
|
|
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 🤓!).
|
|
176
|
+
|
|
177
|
+
```js
|
|
178
|
+
import { setProtocol } from "coll-fns";
|
|
179
|
+
|
|
180
|
+
const customProtocol = {
|
|
181
|
+
/* Return a documents count */
|
|
182
|
+
count(/* Coll, selector = {}, options = {} */) {},
|
|
183
|
+
|
|
184
|
+
/* Return a list of documents. */
|
|
185
|
+
findList(/* Coll, selector = {}, options = {} */) {},
|
|
186
|
+
|
|
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 */) {},
|
|
193
|
+
|
|
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);
|
|
75
208
|
```
|
|
76
209
|
|
|
77
|
-
|
|
210
|
+
</details>
|
|
78
211
|
|
|
79
|
-
|
|
212
|
+
## Bypassing `coll-fns`
|
|
80
213
|
|
|
81
|
-
|
|
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! 😉
|
|
82
215
|
|
|
83
|
-
|
|
216
|
+
# Joins and fetch
|
|
84
217
|
|
|
85
|
-
|
|
218
|
+
## Quick start examples
|
|
86
219
|
|
|
87
220
|
```js
|
|
88
|
-
import {
|
|
221
|
+
import { fetchList, join } from "coll-fns";
|
|
222
|
+
import { Comments, Posts, Users } from "/collections";
|
|
89
223
|
|
|
90
|
-
|
|
91
|
-
join(
|
|
224
|
+
/* Define joins on Posts collection */
|
|
225
|
+
join(Posts, {
|
|
226
|
+
/* One-to-one join */
|
|
92
227
|
author: {
|
|
93
|
-
Coll:
|
|
228
|
+
Coll: Users,
|
|
94
229
|
on: ["authorId", "_id"],
|
|
95
230
|
single: true,
|
|
96
231
|
},
|
|
232
|
+
|
|
233
|
+
/* One-to-many join */
|
|
97
234
|
comments: {
|
|
98
|
-
Coll:
|
|
235
|
+
Coll: Comments,
|
|
99
236
|
on: ["_id", "postId"],
|
|
237
|
+
/* `single` defaults to false,
|
|
238
|
+
* so joined docs are returned as an array */
|
|
100
239
|
},
|
|
101
240
|
});
|
|
102
241
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
{
|
|
242
|
+
/* Fetch data with nested joined documents in the requested shape. */
|
|
243
|
+
fetchList(
|
|
244
|
+
Posts,
|
|
245
|
+
{},
|
|
107
246
|
{
|
|
108
247
|
fields: {
|
|
109
|
-
title: 1,
|
|
110
|
-
|
|
111
|
-
|
|
248
|
+
title: 1, // <= Own
|
|
249
|
+
author: { birthdate: 0 }, // <= Falsy = anything but these fields
|
|
250
|
+
comments: { text: 1 },
|
|
112
251
|
},
|
|
113
252
|
}
|
|
114
253
|
);
|
|
115
|
-
|
|
116
|
-
// Result: Each post includes author and comments as defined
|
|
117
254
|
```
|
|
118
255
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
### Basic Join Example
|
|
122
|
-
|
|
123
|
-
```js
|
|
124
|
-
const posts = await fetchList(
|
|
125
|
-
PostsCollection,
|
|
126
|
-
{},
|
|
256
|
+
```jsonc
|
|
257
|
+
[
|
|
127
258
|
{
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
"
|
|
259
|
+
"title": "Blabla",
|
|
260
|
+
"authorId": "foo", // <= Included by join definition
|
|
261
|
+
"author": {
|
|
262
|
+
"name": "Foo Bar",
|
|
263
|
+
"genre": "non-fiction",
|
|
132
264
|
},
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
// (assuming 'author' was pre-registered via join(PostsCollection, { author: { ... } }))
|
|
265
|
+
/* Comments is a one-to-many join, so is returned as a list */
|
|
266
|
+
"comments": [{ "text": "Nice!" }, { "text": "Great!" }],
|
|
267
|
+
},
|
|
268
|
+
]
|
|
138
269
|
```
|
|
139
270
|
|
|
140
|
-
|
|
271
|
+
## `join(Coll, joinDefinitions)`
|
|
272
|
+
|
|
273
|
+
Collections can be joined together with **globally pre-registered joins** to greatly simplify optimized data fetching.
|
|
274
|
+
|
|
275
|
+
Joins are **not symmetrical by default**. Each collection should define its own relationships.
|
|
141
276
|
|
|
142
277
|
```js
|
|
143
|
-
import { join
|
|
278
|
+
import { join } from "coll-fns";
|
|
144
279
|
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
Coll: CommentsCollection,
|
|
149
|
-
on: ["_id", "postId"],
|
|
150
|
-
// Note: 'single' defaults to false, so joined docs are returned as an array
|
|
151
|
-
fields: { text: 1, createdAt: 1 },
|
|
152
|
-
},
|
|
153
|
-
});
|
|
280
|
+
join(
|
|
281
|
+
/* Parent collection */
|
|
282
|
+
Coll,
|
|
154
283
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
{},
|
|
284
|
+
/* Map of joins on children collections.
|
|
285
|
+
* Each key is the name of the field
|
|
286
|
+
* where joined docs will be placed. */
|
|
159
287
|
{
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
288
|
+
joinProp1: {
|
|
289
|
+
/* joinDefinition */
|
|
290
|
+
},
|
|
291
|
+
|
|
292
|
+
joinProp2: {
|
|
293
|
+
/* joinDefinition */
|
|
163
294
|
},
|
|
164
295
|
}
|
|
165
296
|
);
|
|
166
|
-
|
|
167
|
-
// Result: Each post includes a 'comments' array
|
|
168
297
|
```
|
|
169
298
|
|
|
170
|
-
|
|
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.
|
|
302
|
+
|
|
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.
|
|
171
304
|
|
|
172
|
-
|
|
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.
|
|
173
310
|
|
|
174
311
|
```js
|
|
175
|
-
import { join
|
|
312
|
+
import { join } from "coll-fns";
|
|
313
|
+
import { Comments, Posts, Users } from "/collections";
|
|
176
314
|
|
|
177
|
-
|
|
178
|
-
join(PostsCollection, {
|
|
315
|
+
join(Posts, {
|
|
179
316
|
author: {
|
|
180
|
-
Coll:
|
|
317
|
+
Coll: Users,
|
|
318
|
+
/* `post.authorId === user._id` */
|
|
181
319
|
on: ["authorId", "_id"],
|
|
320
|
+
/* Single doc instead of a list */
|
|
182
321
|
single: true,
|
|
183
|
-
fields: { name: 1, avatar: 1 },
|
|
184
322
|
},
|
|
323
|
+
|
|
185
324
|
comments: {
|
|
186
|
-
Coll:
|
|
325
|
+
Coll: Comments,
|
|
326
|
+
/* `post._id === comment.postId` */
|
|
187
327
|
on: ["_id", "postId"],
|
|
188
|
-
fields: { text: 1, "+": { user: 1 } },
|
|
189
328
|
},
|
|
190
329
|
});
|
|
191
330
|
|
|
192
|
-
|
|
193
|
-
join(
|
|
194
|
-
|
|
195
|
-
Coll:
|
|
196
|
-
on: ["
|
|
197
|
-
single: true,
|
|
198
|
-
fields: { name: 1, avatar: 1 },
|
|
331
|
+
/* Reversed join from user to posts */
|
|
332
|
+
join(Users, {
|
|
333
|
+
posts: {
|
|
334
|
+
Coll: Posts,
|
|
335
|
+
on: ["_id", "authorId"],
|
|
199
336
|
},
|
|
200
337
|
});
|
|
338
|
+
```
|
|
201
339
|
|
|
202
|
-
|
|
203
|
-
const posts = await fetchList(
|
|
204
|
-
PostsCollection,
|
|
205
|
-
{},
|
|
206
|
-
{
|
|
207
|
-
fields: {
|
|
208
|
-
title: 1,
|
|
209
|
-
content: 1,
|
|
210
|
-
"+": { author: 1, comments: 1 },
|
|
211
|
-
},
|
|
212
|
-
}
|
|
213
|
-
);
|
|
340
|
+
### Sub-array joins
|
|
214
341
|
|
|
215
|
-
|
|
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.
|
|
343
|
+
|
|
344
|
+
```js
|
|
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
|
+
});
|
|
356
|
+
|
|
357
|
+
/* The reverse join will flip the property names. */
|
|
358
|
+
join(Resources, {
|
|
359
|
+
actions: {
|
|
360
|
+
Coll: Actions,
|
|
361
|
+
on: [["actionIds"], "_id"],
|
|
362
|
+
},
|
|
363
|
+
});
|
|
216
364
|
```
|
|
217
365
|
|
|
218
|
-
###
|
|
366
|
+
### Filtered array-joins
|
|
219
367
|
|
|
220
|
-
|
|
368
|
+
Some joins should target only specific documents in the foreign collection. A complementary selector can be passed to the third `on` array argument.
|
|
221
369
|
|
|
222
370
|
```js
|
|
223
|
-
import { join
|
|
371
|
+
import { join } from "coll-fns";
|
|
372
|
+
import { Actions, Resources } from "/collections";
|
|
224
373
|
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
Coll:
|
|
229
|
-
on: ["
|
|
374
|
+
join(Resources, {
|
|
375
|
+
/* Only active tasks (third array element is a selector) */
|
|
376
|
+
activeTasks: {
|
|
377
|
+
Coll: Tasks,
|
|
378
|
+
on: ["_id", "resourceId", { active: true }],
|
|
230
379
|
},
|
|
231
|
-
});
|
|
232
380
|
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
name: 1,
|
|
240
|
-
"+": { friends: 2 }, // Limit to 2 levels deep
|
|
241
|
-
},
|
|
242
|
-
}
|
|
243
|
-
);
|
|
381
|
+
/* All tasks associated with a resource */
|
|
382
|
+
tasks: {
|
|
383
|
+
Coll: Tasks,
|
|
384
|
+
on: ["_id", "resourceId"],
|
|
385
|
+
},
|
|
386
|
+
});
|
|
244
387
|
```
|
|
245
388
|
|
|
246
|
-
###
|
|
389
|
+
### Object joins
|
|
247
390
|
|
|
248
|
-
|
|
391
|
+
The `on` join definition property can be an object representing a selector. It will always retrieve the same linked documents.
|
|
249
392
|
|
|
250
393
|
```js
|
|
251
|
-
import { join
|
|
394
|
+
import { join } from "coll-fns";
|
|
395
|
+
import { Factory, Workers } from "../collections";
|
|
252
396
|
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
Coll:
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
// Declare which parent fields are required for the join function
|
|
260
|
-
fields: { userId: 1 },
|
|
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,
|
|
261
403
|
},
|
|
262
404
|
});
|
|
405
|
+
```
|
|
263
406
|
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
407
|
+
### Function joins
|
|
408
|
+
|
|
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.
|
|
410
|
+
|
|
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 */
|
|
269
434
|
fields: {
|
|
270
|
-
|
|
271
|
-
|
|
435
|
+
_id: 1, // Optional. _id is implicit in any fetch.
|
|
436
|
+
postedAt: 1,
|
|
272
437
|
},
|
|
273
|
-
}
|
|
274
|
-
);
|
|
438
|
+
},
|
|
439
|
+
});
|
|
275
440
|
```
|
|
276
441
|
|
|
277
|
-
|
|
442
|
+
### Recursive joins
|
|
278
443
|
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
### Setting Up Hooks
|
|
444
|
+
A collection can define joins on itself.
|
|
282
445
|
|
|
283
446
|
```js
|
|
284
|
-
import {
|
|
285
|
-
|
|
286
|
-
// Before insert hook - validation and transformation
|
|
287
|
-
hook(UsersCollection, "insert", "before", (doc) => {
|
|
288
|
-
if (!doc.email) {
|
|
289
|
-
throw new Error("Email is required");
|
|
290
|
-
}
|
|
291
|
-
// Transform data
|
|
292
|
-
doc.email = doc.email.toLowerCase();
|
|
293
|
-
doc.createdAt = new Date();
|
|
294
|
-
return doc;
|
|
295
|
-
});
|
|
447
|
+
import { join } from "coll-fns";
|
|
448
|
+
import { Users } from "/collections";
|
|
296
449
|
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
450
|
+
join(Users, {
|
|
451
|
+
friends: {
|
|
452
|
+
/* Use the same collection in the join definition */
|
|
453
|
+
Coll: Users,
|
|
454
|
+
on: [["friendIds"], "_id"],
|
|
455
|
+
},
|
|
302
456
|
});
|
|
303
457
|
```
|
|
304
458
|
|
|
305
|
-
###
|
|
459
|
+
### Join additional options
|
|
306
460
|
|
|
307
|
-
|
|
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:
|
|
308
462
|
|
|
309
|
-
-
|
|
310
|
-
-
|
|
311
|
-
-
|
|
312
|
-
- **`fetch`**: Before/after fetching documents (useful for filtering)
|
|
463
|
+
- `limit`: Maximum joined documents count
|
|
464
|
+
- `skip`: Documents to skip in the fetch
|
|
465
|
+
- `sort`: Sort order of joined documents
|
|
313
466
|
|
|
314
|
-
###
|
|
467
|
+
### `postFetch`
|
|
315
468
|
|
|
316
|
-
|
|
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.
|
|
317
472
|
|
|
318
473
|
```js
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
const post = fetchList(PostsCollection, selector)[0];
|
|
474
|
+
import { join } from "coll-fns";
|
|
475
|
+
import { Actions, Resources } from "/collections";
|
|
476
|
+
import { sortTasks } from "/lib/tasks";
|
|
323
477
|
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
478
|
+
join(Resources, {
|
|
479
|
+
tasks: {
|
|
480
|
+
Coll: Tasks,
|
|
481
|
+
on: ["_id", "resourceId"],
|
|
482
|
+
|
|
483
|
+
/* Ensure `tasksOrder` will be fetched */
|
|
484
|
+
fields: { tasksOrder: 1 },
|
|
327
485
|
|
|
328
|
-
|
|
486
|
+
/* Transform the joined tasks documents based on parent resource. */
|
|
487
|
+
postFetch(tasks, resource) {
|
|
488
|
+
const { tasksOrder = [] } = resource;
|
|
489
|
+
return sortTasks(tasks, tasksOrder);
|
|
490
|
+
},
|
|
491
|
+
},
|
|
329
492
|
});
|
|
330
493
|
```
|
|
331
494
|
|
|
332
|
-
|
|
495
|
+
### `getJoins`
|
|
333
496
|
|
|
334
|
-
|
|
335
|
-
// Add timestamps automatically
|
|
336
|
-
hook(PostsCollection, "insert", "before", (doc) => {
|
|
337
|
-
doc.createdAt = new Date();
|
|
338
|
-
doc.updatedAt = new Date();
|
|
339
|
-
return doc;
|
|
340
|
-
});
|
|
497
|
+
Use `getJoins(Coll)` to retrieve the complete dictionary of the collection's joins.
|
|
341
498
|
|
|
342
|
-
|
|
343
|
-
if (!modifier.$set) modifier.$set = {};
|
|
344
|
-
modifier.$set.updatedAt = new Date();
|
|
345
|
-
return [selector, modifier, options];
|
|
346
|
-
});
|
|
347
|
-
```
|
|
499
|
+
## `fetchList(Coll, selector, options)`
|
|
348
500
|
|
|
349
|
-
|
|
501
|
+
Fetch documents with the ability to **use collection joins**.
|
|
350
502
|
|
|
351
|
-
|
|
352
|
-
// Log all changes
|
|
353
|
-
hook(PostsCollection, "update", "after", (result, selector, modifier) => {
|
|
354
|
-
logToAuditTrail({
|
|
355
|
-
collection: "posts",
|
|
356
|
-
action: "update",
|
|
357
|
-
selector,
|
|
358
|
-
modifier,
|
|
359
|
-
timestamp: new Date(),
|
|
360
|
-
userId: getCurrentUserId(),
|
|
361
|
-
});
|
|
362
|
-
});
|
|
363
|
-
```
|
|
503
|
+
**Options:**
|
|
364
504
|
|
|
365
|
-
|
|
505
|
+
- `fields`: Field projection object
|
|
506
|
+
- `limit`: Maximum number of documents
|
|
507
|
+
- `skip`: Number of documents to skip
|
|
508
|
+
- `sort`: Sort specification
|
|
366
509
|
|
|
367
|
-
|
|
368
|
-
// Update denormalized data
|
|
369
|
-
hook(UsersCollection, "update", "after", (result, selector, modifier) => {
|
|
370
|
-
const userId = selector._id;
|
|
371
|
-
const userName = modifier.$set?.name;
|
|
510
|
+
In its simplest form, `fetchList` can be used in much the same way as Meteor's `Coll.find(...args).fetch()`.
|
|
372
511
|
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
512
|
+
```js
|
|
513
|
+
const users = await fetchList(
|
|
514
|
+
Users,
|
|
515
|
+
{ status: "active" },
|
|
516
|
+
{
|
|
517
|
+
fields: { name: 1, email: 1 },
|
|
518
|
+
sort: { createdAt: -1 },
|
|
519
|
+
limit: 10,
|
|
520
|
+
skip: 0,
|
|
381
521
|
}
|
|
382
|
-
|
|
522
|
+
);
|
|
383
523
|
```
|
|
384
524
|
|
|
385
|
-
|
|
525
|
+
### `fields` option and joins
|
|
526
|
+
|
|
527
|
+
Contrary to regular projection objects, they can use nested properties `{ car: { make: 1 } }` instead of dot-string ones `{ car: 1, "car.make": 1 }`.
|
|
386
528
|
|
|
387
|
-
|
|
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.
|
|
388
530
|
|
|
389
|
-
|
|
531
|
+
#### Examples
|
|
390
532
|
|
|
391
|
-
|
|
533
|
+
<details>
|
|
534
|
+
<summary>Join definitions for examples</summary>
|
|
392
535
|
|
|
393
536
|
```js
|
|
394
|
-
import {
|
|
537
|
+
import { fetchList, join } from "coll-fns";
|
|
538
|
+
import { Comments, Posts, Users } from "/collections";
|
|
395
539
|
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
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
|
+
},
|
|
403
548
|
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
}
|
|
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
|
+
});
|
|
411
557
|
```
|
|
412
558
|
|
|
413
|
-
|
|
559
|
+
</details>
|
|
414
560
|
|
|
415
|
-
|
|
561
|
+
<details>
|
|
562
|
+
<summary>Undefined (all) own fields</summary>
|
|
416
563
|
|
|
417
564
|
```js
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
async function safeUpdatePosts() {
|
|
421
|
-
try {
|
|
422
|
-
const posts = await fetchList(PostsCollection, { status: "draft" });
|
|
423
|
-
console.log(`Found ${posts.length} draft posts`);
|
|
565
|
+
fetchList(Posts, {});
|
|
566
|
+
```
|
|
424
567
|
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
PostsCollection,
|
|
428
|
-
{ _id: post._id },
|
|
429
|
-
{ $set: { status: "published" } }
|
|
430
|
-
);
|
|
431
|
-
}
|
|
432
|
-
} catch (error) {
|
|
433
|
-
console.error("Update operation failed:", error);
|
|
434
|
-
// Handle database error, network issue, validation error, etc.
|
|
435
|
-
}
|
|
436
|
-
}
|
|
568
|
+
```json
|
|
569
|
+
[{ "title": "Blabla", "authorId": "foo", "likes": 7 }]
|
|
437
570
|
```
|
|
438
571
|
|
|
439
|
-
|
|
572
|
+
</details>
|
|
440
573
|
|
|
441
|
-
|
|
574
|
+
<details>
|
|
575
|
+
<summary>Some own fields</summary>
|
|
442
576
|
|
|
443
577
|
```js
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
on: ["authorId", "_id"], // Error: Missing Coll
|
|
578
|
+
fetchList(
|
|
579
|
+
Posts,
|
|
580
|
+
{},
|
|
581
|
+
{
|
|
582
|
+
fields: {
|
|
583
|
+
title: true, // <= Own. Any truthy value works
|
|
451
584
|
},
|
|
452
|
-
}
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
// Output: Collection 'Coll' for 'author' join is required.
|
|
456
|
-
}
|
|
585
|
+
}
|
|
586
|
+
);
|
|
587
|
+
```
|
|
457
588
|
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
join(PostsCollection, {
|
|
461
|
-
comments: {
|
|
462
|
-
Coll: CommentsCollection,
|
|
463
|
-
// Error: Missing on
|
|
464
|
-
},
|
|
465
|
-
});
|
|
466
|
-
} catch (error) {
|
|
467
|
-
console.error(error.message);
|
|
468
|
-
// Output: Join 'comments' has no 'on' condition specified.
|
|
469
|
-
}
|
|
589
|
+
```json
|
|
590
|
+
[{ "title": "Blabla" }]
|
|
470
591
|
```
|
|
471
592
|
|
|
472
|
-
|
|
593
|
+
</details>
|
|
473
594
|
|
|
474
|
-
|
|
595
|
+
<details>
|
|
596
|
+
<summary>Undefined (all) own fields, truthy (all) join fields</summary>
|
|
475
597
|
|
|
476
598
|
```js
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
599
|
+
fetchList(
|
|
600
|
+
Posts,
|
|
601
|
+
{},
|
|
602
|
+
{
|
|
603
|
+
fields: {
|
|
604
|
+
author: 1, // <= Join
|
|
605
|
+
},
|
|
483
606
|
}
|
|
607
|
+
);
|
|
608
|
+
```
|
|
484
609
|
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
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
|
+
]
|
|
623
|
+
```
|
|
488
624
|
|
|
489
|
-
|
|
490
|
-
|
|
625
|
+
</details>
|
|
626
|
+
|
|
627
|
+
<details>
|
|
628
|
+
<summary>Some own fields, truthy (all) join fields</summary>
|
|
491
629
|
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
// Handle authorization error
|
|
502
|
-
} else if (error.message === "Post not found") {
|
|
503
|
-
// Handle not found error
|
|
630
|
+
```js
|
|
631
|
+
fetchList(
|
|
632
|
+
Posts,
|
|
633
|
+
{},
|
|
634
|
+
{
|
|
635
|
+
fields: {
|
|
636
|
+
title: 1, // <= Own
|
|
637
|
+
author: 1, // <= Join
|
|
638
|
+
},
|
|
504
639
|
}
|
|
505
|
-
|
|
640
|
+
);
|
|
641
|
+
```
|
|
642
|
+
|
|
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
|
+
]
|
|
506
655
|
```
|
|
507
656
|
|
|
508
|
-
|
|
657
|
+
</details>
|
|
509
658
|
|
|
510
|
-
|
|
659
|
+
<details>
|
|
660
|
+
<summary>Some own fields, some join fields</summary>
|
|
511
661
|
|
|
512
662
|
```js
|
|
513
|
-
|
|
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
|
+
);
|
|
674
|
+
```
|
|
514
675
|
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
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!" }],
|
|
521
687
|
},
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
// Use in fetch
|
|
525
|
-
try {
|
|
526
|
-
const posts = await fetchList(
|
|
527
|
-
PostsCollection,
|
|
528
|
-
{},
|
|
529
|
-
{
|
|
530
|
-
fields: { title: 1, "+": { author: 1 } },
|
|
531
|
-
}
|
|
532
|
-
);
|
|
533
|
-
} catch (error) {
|
|
534
|
-
console.error("Failed to fetch posts with authors:", error);
|
|
535
|
-
// Errors from nested joins are propagated here
|
|
536
|
-
}
|
|
688
|
+
]
|
|
537
689
|
```
|
|
538
690
|
|
|
539
|
-
|
|
691
|
+
</details>
|
|
692
|
+
|
|
693
|
+
### `setJoinPrefix(prefix)`
|
|
540
694
|
|
|
541
|
-
|
|
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.
|
|
696
|
+
|
|
697
|
+
Setting the prefix to null or undefined allows using join fields at the document root like any normal field.
|
|
542
698
|
|
|
543
699
|
```js
|
|
544
|
-
import {
|
|
700
|
+
import { setJoinPrefix } from "coll-fns";
|
|
545
701
|
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
// Missing required methods
|
|
549
|
-
});
|
|
702
|
+
/* All join fields will have to be prefixed with "+" */
|
|
703
|
+
setJoinPrefix("+");
|
|
550
704
|
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
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
|
+
);
|
|
557
720
|
```
|
|
558
721
|
|
|
559
|
-
|
|
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)`.
|
|
560
725
|
|
|
561
|
-
|
|
726
|
+
### Nested Joins
|
|
562
727
|
|
|
563
|
-
|
|
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.
|
|
564
729
|
|
|
565
730
|
```js
|
|
566
|
-
|
|
567
|
-
import { setProtocol, protocols } from "coll-fns";
|
|
731
|
+
import { fetchList } from "coll-fns";
|
|
568
732
|
|
|
569
|
-
|
|
733
|
+
const posts = fetchList(
|
|
734
|
+
Posts,
|
|
735
|
+
{},
|
|
736
|
+
{
|
|
737
|
+
fields: {
|
|
738
|
+
title: 1,
|
|
739
|
+
|
|
740
|
+
/* Level 1 : One-to-many join */
|
|
741
|
+
comments: {
|
|
742
|
+
text: 1,
|
|
743
|
+
|
|
744
|
+
/* Level 2 : One-to-one join */
|
|
745
|
+
user: {
|
|
746
|
+
username: 1,
|
|
747
|
+
},
|
|
748
|
+
},
|
|
749
|
+
},
|
|
750
|
+
}
|
|
751
|
+
);
|
|
752
|
+
```
|
|
570
753
|
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
authorId: this.userId,
|
|
579
|
-
});
|
|
754
|
+
```json
|
|
755
|
+
{
|
|
756
|
+
"title": "Blabla",
|
|
757
|
+
"comments": [
|
|
758
|
+
{ "text": "Nice!", "user": { "username": "foo"} },
|
|
759
|
+
{ "text": "Great!", "user": { "username": "bar" } }
|
|
760
|
+
]
|
|
580
761
|
},
|
|
581
|
-
});
|
|
582
762
|
```
|
|
583
763
|
|
|
584
|
-
###
|
|
764
|
+
### Recursion levels
|
|
585
765
|
|
|
586
|
-
|
|
587
|
-
// client/main.js
|
|
588
|
-
import { setProtocol, protocols, join } from "coll-fns";
|
|
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.
|
|
589
767
|
|
|
590
|
-
|
|
768
|
+
```js
|
|
769
|
+
import { join } from "coll-fns";
|
|
770
|
+
import { Users } from "/collections";
|
|
591
771
|
|
|
592
|
-
|
|
593
|
-
join(
|
|
594
|
-
|
|
595
|
-
Coll:
|
|
596
|
-
on: ["
|
|
597
|
-
single: true,
|
|
772
|
+
/* Pre-register recursive join */
|
|
773
|
+
join(Users, {
|
|
774
|
+
friends: {
|
|
775
|
+
Coll: Users,
|
|
776
|
+
on: [["friendIds"], "_id"],
|
|
598
777
|
},
|
|
599
778
|
});
|
|
600
779
|
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
PostsCollection,
|
|
780
|
+
fetchList(
|
|
781
|
+
Users,
|
|
604
782
|
{},
|
|
605
|
-
{
|
|
783
|
+
{
|
|
784
|
+
fields: {
|
|
785
|
+
name: 1,
|
|
786
|
+
/* Join field. Limit to 2 levels deep, reusing parent fields */
|
|
787
|
+
friends: 2,
|
|
788
|
+
},
|
|
789
|
+
}
|
|
606
790
|
);
|
|
607
791
|
```
|
|
608
792
|
|
|
609
|
-
###
|
|
793
|
+
### Documents transformation
|
|
794
|
+
|
|
795
|
+
Documents can be transformed after fetching. Collection-level transforms are automatically applied if the protocol allows it:
|
|
796
|
+
|
|
797
|
+
**Meteor:**
|
|
610
798
|
|
|
611
799
|
```js
|
|
612
|
-
|
|
613
|
-
import { join, fetchList } from "coll-fns";
|
|
800
|
+
import { Mongo } from "meteor/mongo";
|
|
614
801
|
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
single: true,
|
|
621
|
-
fields: { name: 1, avatar: 1 },
|
|
622
|
-
},
|
|
802
|
+
const Users = new Mongo.Collection("users", {
|
|
803
|
+
transform: (doc) => ({
|
|
804
|
+
...doc,
|
|
805
|
+
fullName: `${doc.firstName} ${doc.lastName}`,
|
|
806
|
+
}),
|
|
623
807
|
});
|
|
624
|
-
|
|
625
|
-
// This function works on both client and server!
|
|
626
|
-
export function getPostsWithAuthors() {
|
|
627
|
-
return fetchList(
|
|
628
|
-
PostsCollection,
|
|
629
|
-
{},
|
|
630
|
-
{
|
|
631
|
-
fields: {
|
|
632
|
-
title: 1,
|
|
633
|
-
content: 1,
|
|
634
|
-
"+": { author: 1 },
|
|
635
|
-
},
|
|
636
|
-
}
|
|
637
|
-
);
|
|
638
|
-
}
|
|
639
808
|
```
|
|
640
809
|
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
### Core Functions
|
|
644
|
-
|
|
645
|
-
#### `fetchList(collection, selector, options)`
|
|
646
|
-
|
|
647
|
-
Fetch an array of documents from a collection.
|
|
810
|
+
**For a specific fetch**, pass a `transform` option:
|
|
648
811
|
|
|
649
812
|
```js
|
|
650
813
|
const users = await fetchList(
|
|
651
|
-
|
|
814
|
+
Users,
|
|
652
815
|
{ status: "active" },
|
|
653
816
|
{
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
817
|
+
transform: (doc) => ({
|
|
818
|
+
...doc,
|
|
819
|
+
fullName: `${doc.firstName} ${doc.lastName}`,
|
|
820
|
+
}),
|
|
658
821
|
}
|
|
659
822
|
);
|
|
660
823
|
```
|
|
661
824
|
|
|
662
|
-
**
|
|
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.
|
|
663
826
|
|
|
664
|
-
|
|
665
|
-
- `sort`: Sort specification
|
|
666
|
-
- `limit`: Maximum number of documents
|
|
667
|
-
- `skip`: Number of documents to skip
|
|
827
|
+
## `fetchOne(Coll, selector, options)`
|
|
668
828
|
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
Fetch a single document from a collection.
|
|
829
|
+
Fetch a single document from a collection. Same behaviour as `fetchList`.
|
|
672
830
|
|
|
673
831
|
```js
|
|
674
|
-
|
|
675
|
-
|
|
832
|
+
import { fetchOne } from "coll-fns";
|
|
833
|
+
import { Users } from "/collections";
|
|
834
|
+
|
|
835
|
+
const user = fetchOne(
|
|
836
|
+
Users,
|
|
676
837
|
{ _id: userId },
|
|
677
838
|
{
|
|
678
|
-
fields: {
|
|
839
|
+
fields: {
|
|
840
|
+
name: 1,
|
|
841
|
+
friends: 1, // <= Join
|
|
842
|
+
},
|
|
679
843
|
}
|
|
680
844
|
);
|
|
681
845
|
```
|
|
682
846
|
|
|
683
|
-
|
|
847
|
+
## `fetchIds(Coll, selector, options)`
|
|
684
848
|
|
|
685
|
-
Fetch only the `_id` field of matching documents.
|
|
849
|
+
Fetch only the `_id` field of matching documents. `fields` option will be ignored.
|
|
686
850
|
|
|
687
851
|
```js
|
|
688
|
-
|
|
689
|
-
|
|
852
|
+
import { fetchOne } from "coll-fns";
|
|
853
|
+
import { Users } from "/collections";
|
|
854
|
+
|
|
855
|
+
const userIds = fetchIds(Users, { status: "active" });
|
|
690
856
|
```
|
|
691
857
|
|
|
692
|
-
|
|
858
|
+
## `exists(Coll, selector)`
|
|
693
859
|
|
|
694
|
-
Check if any
|
|
860
|
+
Check if any document matches the selector.
|
|
695
861
|
|
|
696
862
|
```js
|
|
697
|
-
|
|
863
|
+
import { fetchOne } from "coll-fns";
|
|
864
|
+
import { Users } from "/collections";
|
|
865
|
+
|
|
866
|
+
const hasActiveUsers = exists(Users, { status: "active" });
|
|
698
867
|
// Returns: true or false
|
|
699
868
|
```
|
|
700
869
|
|
|
701
|
-
|
|
870
|
+
## `count(Coll, selector)`
|
|
702
871
|
|
|
703
|
-
|
|
872
|
+
Count documents matching the selector.
|
|
704
873
|
|
|
705
874
|
```js
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
});
|
|
875
|
+
import { fetchOne } from "coll-fns";
|
|
876
|
+
import { Users } from "/collections";
|
|
877
|
+
|
|
878
|
+
const activeUsersCount = count(UsersCollection, { status: "active" });
|
|
879
|
+
// Returns an integer
|
|
710
880
|
```
|
|
711
881
|
|
|
712
|
-
|
|
882
|
+
## `flattenFields(fields)`
|
|
713
883
|
|
|
714
|
-
|
|
884
|
+
Flatten a general field specifiers object (which could include nested objects) into a MongoDB-compatible one that uses dot-notation.
|
|
715
885
|
|
|
716
886
|
```js
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
887
|
+
import { flattenFields } from "coll-fns";
|
|
888
|
+
|
|
889
|
+
const flattened = flattenFields({
|
|
890
|
+
name: 1,
|
|
891
|
+
address: {
|
|
892
|
+
street: 1,
|
|
893
|
+
city: 1,
|
|
894
|
+
},
|
|
895
|
+
});
|
|
896
|
+
// Result: { name: 1, 'address.street': 1, 'address.city': 1 }
|
|
723
897
|
```
|
|
724
898
|
|
|
725
|
-
|
|
899
|
+
# Hooks and write operations
|
|
726
900
|
|
|
727
|
-
|
|
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.
|
|
728
902
|
|
|
729
|
-
|
|
730
|
-
await remove(UsersCollection, { inactive: true });
|
|
731
|
-
```
|
|
903
|
+
## `hook(Coll, hooksObj)`
|
|
732
904
|
|
|
733
|
-
|
|
905
|
+
Register hooks on a collection to run before or after specific write operations (insert, update, remove).
|
|
734
906
|
|
|
735
|
-
|
|
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**.
|
|
736
908
|
|
|
737
|
-
|
|
738
|
-
const activeUsers = await count(UsersCollection, { status: "active" });
|
|
739
|
-
```
|
|
909
|
+
Hooks can be **defined in multiple places** in your codebase. This allows grouping functionally related hooks together.
|
|
740
910
|
|
|
741
|
-
|
|
911
|
+
```js
|
|
912
|
+
import { hook } from "coll-fns";
|
|
913
|
+
import { Users, Posts } from "/collections";
|
|
742
914
|
|
|
743
|
-
|
|
915
|
+
hook(Users, {
|
|
916
|
+
beforeInsert: [
|
|
917
|
+
{
|
|
918
|
+
fn(doc) {
|
|
919
|
+
if (!doc.email) throw new Error("Email is required");
|
|
744
920
|
|
|
745
|
-
|
|
921
|
+
doc.createdAt = new Date();
|
|
922
|
+
},
|
|
923
|
+
},
|
|
924
|
+
],
|
|
746
925
|
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
926
|
+
onInserted: [
|
|
927
|
+
{
|
|
928
|
+
fn(doc) {
|
|
929
|
+
console.log(`New user created: ${doc._id}`);
|
|
930
|
+
},
|
|
931
|
+
},
|
|
932
|
+
],
|
|
751
933
|
});
|
|
752
934
|
```
|
|
753
935
|
|
|
754
|
-
|
|
936
|
+
### Before hooks
|
|
755
937
|
|
|
756
|
-
|
|
757
|
-
- `operation`: `'insert'`, `'update'`, `'remove'`, or `'fetch'`
|
|
758
|
-
- `timing`: `'before'` or `'after'`
|
|
759
|
-
- `fn`: Hook function (return value depends on operation and timing)
|
|
938
|
+
These hooks run **before** the write operation and can **prevent the operation** by throwing an error.
|
|
760
939
|
|
|
761
|
-
|
|
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])`.
|
|
762
943
|
|
|
763
|
-
|
|
944
|
+
Although arguments can be mutated, it is not the main purpose of these hooks. Mutations are brittle and hard to debug.
|
|
764
945
|
|
|
765
|
-
|
|
946
|
+
`beforeUpdate` and `beforeRemove` receive an **array of targeted documents**, whereas `beforeInsert` receives a **single document**.
|
|
766
947
|
|
|
767
|
-
|
|
768
|
-
import { setProtocol, protocols } from "coll-fns";
|
|
948
|
+
### After hooks
|
|
769
949
|
|
|
770
|
-
|
|
771
|
-
|
|
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)`.
|
|
772
955
|
|
|
773
|
-
|
|
956
|
+
### Hook definition properties
|
|
774
957
|
|
|
775
|
-
|
|
958
|
+
Each hook definition is an object with the following properties:
|
|
776
959
|
|
|
777
960
|
```js
|
|
778
|
-
|
|
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
|
+
}
|
|
779
993
|
```
|
|
780
994
|
|
|
781
|
-
|
|
995
|
+
### Examples
|
|
782
996
|
|
|
783
|
-
|
|
997
|
+
<details>
|
|
998
|
+
<summary>Data validation</summary>
|
|
784
999
|
|
|
785
1000
|
```js
|
|
786
|
-
|
|
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>
|
|
787
1015
|
|
|
788
|
-
|
|
789
|
-
|
|
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
|
+
],
|
|
790
1036
|
});
|
|
791
1037
|
```
|
|
792
1038
|
|
|
793
|
-
|
|
1039
|
+
</details>
|
|
794
1040
|
|
|
795
|
-
|
|
1041
|
+
<details>
|
|
1042
|
+
<summary>Conditional hooks with when/unless</summary>
|
|
796
1043
|
|
|
797
1044
|
```js
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
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");
|
|
808
1056
|
},
|
|
809
1057
|
},
|
|
810
|
-
|
|
811
|
-
);
|
|
1058
|
+
],
|
|
812
1059
|
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
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
|
+
},
|
|
822
1069
|
},
|
|
823
|
-
|
|
824
|
-
);
|
|
1070
|
+
],
|
|
1071
|
+
});
|
|
825
1072
|
```
|
|
826
1073
|
|
|
827
|
-
|
|
1074
|
+
</details>
|
|
1075
|
+
|
|
1076
|
+
<details>
|
|
1077
|
+
<summary>Cascading removals</summary>
|
|
828
1078
|
|
|
829
1079
|
```js
|
|
830
|
-
|
|
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
|
+
],
|
|
831
1098
|
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
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
|
+
],
|
|
838
1108
|
});
|
|
839
|
-
// Result: { name: 1, 'address.street': 1, 'address.city': 1 }
|
|
840
1109
|
```
|
|
841
1110
|
|
|
842
|
-
|
|
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).
|
|
1114
|
+
|
|
1115
|
+
## `insert(Coll, doc)`
|
|
843
1116
|
|
|
844
|
-
|
|
1117
|
+
Insert a document into a collection. Returns the document \_id. Runs `beforeInsert` and `onInserted` hooks if defined.
|
|
845
1118
|
|
|
846
1119
|
```js
|
|
847
|
-
|
|
1120
|
+
const newUser = insert(Users, {
|
|
1121
|
+
name: "Bob",
|
|
1122
|
+
email: "bob@example.com",
|
|
1123
|
+
});
|
|
848
1124
|
```
|
|
849
1125
|
|
|
850
|
-
|
|
1126
|
+
**Execution flow:**
|
|
851
1127
|
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
1128
|
+
1. Run `beforeInsert` hooks (can throw to prevent insertion)
|
|
1129
|
+
2. Insert the document
|
|
1130
|
+
3. Fire `onInserted` hooks asynchronously (without awaiting)
|
|
855
1131
|
|
|
856
|
-
|
|
857
|
-
await client.connect();
|
|
858
|
-
setProtocol(protocols.node(client));
|
|
859
|
-
```
|
|
1132
|
+
## `update(Coll, selector, modifier, options)`
|
|
860
1133
|
|
|
861
|
-
|
|
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).
|
|
862
1135
|
|
|
863
1136
|
```js
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
setProtocol(protocols.meteorSync);
|
|
1137
|
+
update(Users, { status: "pending" }, { $set: { status: "active" } });
|
|
867
1138
|
```
|
|
868
1139
|
|
|
869
|
-
|
|
1140
|
+
**Execution flow:**
|
|
870
1141
|
|
|
871
|
-
|
|
872
|
-
|
|
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
|
|
873
1147
|
|
|
874
|
-
|
|
875
|
-
```
|
|
1148
|
+
**Options:**
|
|
876
1149
|
|
|
877
|
-
|
|
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.
|
|
878
1152
|
|
|
879
|
-
|
|
1153
|
+
## `remove(Coll, selector)`
|
|
880
1154
|
|
|
881
|
-
|
|
1155
|
+
Remove documents matching the selector. Runs `beforeRemove` and `onRemoved` hooks if defined.
|
|
882
1156
|
|
|
883
1157
|
```js
|
|
884
|
-
|
|
885
|
-
fetch: (coll, selector, options) => {
|
|
886
|
-
/* ... */
|
|
887
|
-
},
|
|
888
|
-
insert: (coll, doc) => {
|
|
889
|
-
/* ... */
|
|
890
|
-
},
|
|
891
|
-
update: (coll, selector, modifier, options) => {
|
|
892
|
-
/* ... */
|
|
893
|
-
},
|
|
894
|
-
remove: (coll, selector) => {
|
|
895
|
-
/* ... */
|
|
896
|
-
},
|
|
897
|
-
count: (coll, selector) => {
|
|
898
|
-
/* ... */
|
|
899
|
-
},
|
|
900
|
-
};
|
|
901
|
-
|
|
902
|
-
setProtocol(customProtocol);
|
|
1158
|
+
remove(Users, { inactive: true });
|
|
903
1159
|
```
|
|
904
1160
|
|
|
905
|
-
|
|
1161
|
+
**Execution flow:**
|
|
906
1162
|
|
|
907
|
-
|
|
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
|
|
908
1167
|
|
|
909
|
-
|
|
1168
|
+
## Hook best practices
|
|
910
1169
|
|
|
911
|
-
|
|
912
|
-
|
|
1170
|
+
<details>
|
|
1171
|
+
<summary><strong>Error handling</strong></summary>
|
|
913
1172
|
|
|
914
|
-
|
|
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
|
+
});
|
|
915
1187
|
```
|
|
916
1188
|
|
|
917
|
-
|
|
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`.
|
|
918
1190
|
|
|
919
1191
|
```js
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
1192
|
+
hook(Users, {
|
|
1193
|
+
onInserted: [
|
|
1194
|
+
{
|
|
1195
|
+
fn(doc) {
|
|
1196
|
+
/* ... */
|
|
1197
|
+
},
|
|
1198
|
+
onError(err, hookDef) {
|
|
1199
|
+
logToService(err, hookDef.collName);
|
|
1200
|
+
},
|
|
1201
|
+
},
|
|
1202
|
+
],
|
|
1203
|
+
});
|
|
923
1204
|
```
|
|
924
1205
|
|
|
925
|
-
|
|
1206
|
+
</details>
|
|
926
1207
|
|
|
927
|
-
|
|
928
|
-
|
|
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:
|
|
929
1212
|
|
|
930
|
-
|
|
931
|
-
|
|
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
|
+
});
|
|
932
1227
|
```
|
|
933
1228
|
|
|
934
|
-
|
|
1229
|
+
</details>
|
|
1230
|
+
|
|
1231
|
+
<details>
|
|
1232
|
+
<summary><strong>Conditional execution</strong></summary>
|
|
935
1233
|
|
|
1234
|
+
Use `when` and `unless` to avoid unnecessary side effects while keeping code clean and predictable:
|
|
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
|
+
});
|
|
936
1251
|
```
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
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
|
+
});
|
|
954
1271
|
```
|
|
955
1272
|
|
|
956
|
-
|
|
1273
|
+
</details>
|
|
1274
|
+
|
|
1275
|
+
# License
|
|
957
1276
|
|
|
958
1277
|
MIT
|