coll-fns 1.1.0 → 1.3.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 +1058 -630
- 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 +17 -4
package/README.md
CHANGED
|
@@ -1,958 +1,1386 @@
|
|
|
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
|
+
- [`configurePool(options)`](#configurepooloptions)
|
|
51
|
+
- [Default behavior](#default-behavior)
|
|
52
|
+
- [Options](#options)
|
|
53
|
+
- [Hook best practices](#hook-best-practices)
|
|
54
|
+
- [License](#license)
|
|
12
55
|
|
|
13
|
-
|
|
56
|
+
# Rationale
|
|
14
57
|
|
|
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
|
|
58
|
+
Click on any element to unfold it and better understand the rationale behind this library!
|
|
22
59
|
|
|
23
|
-
|
|
60
|
+
<details>
|
|
61
|
+
<summary><strong>Data normalization</strong></summary>
|
|
62
|
+
|
|
63
|
+
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).
|
|
64
|
+
|
|
65
|
+
This pattern adds much unwelcome complexity:
|
|
66
|
+
|
|
67
|
+
- the fields to denormalize must be determined in advance
|
|
68
|
+
- adding functionalities that would require new related fields means more denormalization and migration scripts
|
|
69
|
+
- the denormalized data must be kept in sync (somehow)
|
|
70
|
+
- denormalization can fail halfway through, leading to an incoherent state
|
|
71
|
+
|
|
72
|
+
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
|
|
73
|
+
|
|
74
|
+
- **keep data in only one place** (where it made most sense)
|
|
75
|
+
- **retrieve documents easily in an intuitive shape**.
|
|
76
|
+
|
|
77
|
+
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!
|
|
78
|
+
|
|
79
|
+
And joins can be defined in code shared by both server and client (I _hate_ redundant code 😒).
|
|
80
|
+
|
|
81
|
+
</details>
|
|
82
|
+
|
|
83
|
+
<details>
|
|
84
|
+
<summary><strong>DRY business logic</strong></summary>
|
|
85
|
+
|
|
86
|
+
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).
|
|
87
|
+
|
|
88
|
+
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.
|
|
89
|
+
|
|
90
|
+
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?
|
|
91
|
+
|
|
92
|
+
**Hooks** were introduced to solve these issues. The are defined in advance on each collection to **intercept or react to insertions, updates and removals**.
|
|
93
|
+
|
|
94
|
+
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.
|
|
95
|
+
|
|
96
|
+
Focussing on the business logic, describing it only once, wasting no time on boilerplate code, that's a time (and sanity) saver for sure! 🤪
|
|
97
|
+
|
|
98
|
+
</details>
|
|
99
|
+
|
|
100
|
+
<details>
|
|
101
|
+
<summary><strong>Protocol implementations</strong></summary>
|
|
102
|
+
|
|
103
|
+
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).
|
|
104
|
+
|
|
105
|
+
On the server, the new Meteor architecture now required the use of promises and async/await constructs.
|
|
106
|
+
|
|
107
|
+
On the client, data fetching with Minimongo must be synchronous in most frameworks to avoid complicated front-end code to handle promises.
|
|
108
|
+
|
|
109
|
+
I wanted a tool that would help me **keep the isomorphic query capabilities** while eliminating redundant glue code.
|
|
110
|
+
|
|
111
|
+
By designing the library with a protocol architecture, the same code can be run with a different database implementation.
|
|
112
|
+
|
|
113
|
+
On the client, I use a synchronous Minimongo implementation.
|
|
114
|
+
|
|
115
|
+
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!
|
|
116
|
+
|
|
117
|
+
(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!)
|
|
118
|
+
|
|
119
|
+
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... 🤯
|
|
120
|
+
|
|
121
|
+
</details>
|
|
122
|
+
|
|
123
|
+
<details style="margin-bottom: 1rem">
|
|
124
|
+
<summary><strong>Functional API</strong></summary>
|
|
125
|
+
|
|
126
|
+
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.
|
|
127
|
+
|
|
128
|
+
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?)...
|
|
129
|
+
|
|
130
|
+
`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!
|
|
131
|
+
|
|
132
|
+
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.
|
|
133
|
+
|
|
134
|
+
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)!
|
|
135
|
+
|
|
136
|
+
For Meteor developers, it also means being able to enhance the `Meteor.users` collection itself... event without access to instantiation code! 🤓
|
|
137
|
+
|
|
138
|
+
</details>
|
|
139
|
+
|
|
140
|
+
# Installation and configuration
|
|
141
|
+
|
|
142
|
+
**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
143
|
|
|
25
144
|
```bash
|
|
26
145
|
npm install coll-fns
|
|
27
146
|
```
|
|
28
147
|
|
|
29
|
-
##
|
|
148
|
+
## `setProtocol(protocol)`
|
|
149
|
+
|
|
150
|
+
You will have to **define which protocol to use** before using any of the library's functionality.
|
|
30
151
|
|
|
31
152
|
```js
|
|
32
|
-
import {
|
|
33
|
-
setProtocol,
|
|
34
|
-
fetchList,
|
|
35
|
-
insert,
|
|
36
|
-
update,
|
|
37
|
-
remove,
|
|
38
|
-
count,
|
|
39
|
-
join,
|
|
40
|
-
protocols,
|
|
41
|
-
} from "coll-fns";
|
|
153
|
+
import { setProtocol, protocols } from "coll-fns";
|
|
42
154
|
|
|
43
|
-
|
|
44
|
-
|
|
155
|
+
/* Built-in protocols include:
|
|
156
|
+
* - meteorAsync
|
|
157
|
+
* - meteorSync
|
|
158
|
+
* - node
|
|
159
|
+
*
|
|
160
|
+
* Can also define a custom protocol! */
|
|
161
|
+
setProtocol(protocols.meteorAsync);
|
|
162
|
+
```
|
|
45
163
|
|
|
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" });
|
|
164
|
+
In a Meteor project, you should probably define a **different protocol on client** (synchronous) **and server** (asynchronous).
|
|
52
165
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
author: {
|
|
56
|
-
Coll: UsersCollection,
|
|
57
|
-
on: ["authorId", "_id"],
|
|
58
|
-
single: true,
|
|
59
|
-
},
|
|
60
|
-
});
|
|
166
|
+
```js
|
|
167
|
+
import { setProtocol, protocols } from "coll-fns";
|
|
61
168
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
169
|
+
const protocol = Meteor.isServer ? protocols.meteorAsync : protocols.meteorSync;
|
|
170
|
+
setProtocol(protocol);
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
There's also a **native NodeJS MongoDB driver** protocol built-in (`protocols.node`).
|
|
174
|
+
|
|
175
|
+
<details style="margin-bottom: 1rem">
|
|
176
|
+
<summary><strong>Custom protocol</strong></summary>
|
|
177
|
+
|
|
178
|
+
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 🤓!).
|
|
179
|
+
|
|
180
|
+
```js
|
|
181
|
+
import { setProtocol } from "coll-fns";
|
|
182
|
+
|
|
183
|
+
const customProtocol = {
|
|
184
|
+
/* Return a documents count */
|
|
185
|
+
count(/* Coll, selector = {}, options = {} */) {},
|
|
186
|
+
|
|
187
|
+
/* Return a list of documents. */
|
|
188
|
+
findList(/* Coll, selector = {}, options = {} */) {},
|
|
189
|
+
|
|
190
|
+
/* Return the name of the collection. */
|
|
191
|
+
getName(/* Coll */) {},
|
|
192
|
+
|
|
193
|
+
/* Optional. Return a function that will transform each document
|
|
194
|
+
* after being fetched with descendants. */
|
|
195
|
+
getTransform(/* Coll */) {},
|
|
196
|
+
|
|
197
|
+
/* Insert a document in a collection
|
|
198
|
+
* and return the inserted _id. */
|
|
199
|
+
insert(/* Coll, doc, options */) {},
|
|
200
|
+
|
|
201
|
+
/* Remove documents in a collection
|
|
202
|
+
* and return the number of removed documents. */
|
|
203
|
+
remove(/* Coll, selector, options */) {},
|
|
204
|
+
|
|
205
|
+
/* Update documents in a collection
|
|
206
|
+
* and return the number of modified documents. */
|
|
207
|
+
update(/* Coll, selector, modifier, options */) {},
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
setProtocol(customProtocol);
|
|
75
211
|
```
|
|
76
212
|
|
|
77
|
-
|
|
213
|
+
</details>
|
|
78
214
|
|
|
79
|
-
|
|
215
|
+
## Bypassing `coll-fns`
|
|
80
216
|
|
|
81
|
-
|
|
217
|
+
`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
218
|
|
|
83
|
-
|
|
219
|
+
# Joins and fetch
|
|
84
220
|
|
|
85
|
-
|
|
221
|
+
## Quick start examples
|
|
86
222
|
|
|
87
223
|
```js
|
|
88
|
-
import {
|
|
224
|
+
import { fetchList, join } from "coll-fns";
|
|
225
|
+
import { Comments, Posts, Users } from "/collections";
|
|
89
226
|
|
|
90
|
-
|
|
91
|
-
join(
|
|
227
|
+
/* Define joins on Posts collection */
|
|
228
|
+
join(Posts, {
|
|
229
|
+
/* One-to-one join */
|
|
92
230
|
author: {
|
|
93
|
-
Coll:
|
|
231
|
+
Coll: Users,
|
|
94
232
|
on: ["authorId", "_id"],
|
|
95
233
|
single: true,
|
|
96
234
|
},
|
|
235
|
+
|
|
236
|
+
/* One-to-many join */
|
|
97
237
|
comments: {
|
|
98
|
-
Coll:
|
|
238
|
+
Coll: Comments,
|
|
99
239
|
on: ["_id", "postId"],
|
|
240
|
+
/* `single` defaults to false,
|
|
241
|
+
* so joined docs are returned as an array */
|
|
100
242
|
},
|
|
101
243
|
});
|
|
102
244
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
{
|
|
245
|
+
/* Fetch data with nested joined documents in the requested shape. */
|
|
246
|
+
fetchList(
|
|
247
|
+
Posts,
|
|
248
|
+
{},
|
|
107
249
|
{
|
|
108
250
|
fields: {
|
|
109
|
-
title: 1,
|
|
110
|
-
|
|
111
|
-
|
|
251
|
+
title: 1, // <= Own
|
|
252
|
+
author: { birthdate: 0 }, // <= Falsy = anything but these fields
|
|
253
|
+
comments: { text: 1 },
|
|
112
254
|
},
|
|
113
255
|
}
|
|
114
256
|
);
|
|
115
|
-
|
|
116
|
-
// Result: Each post includes author and comments as defined
|
|
117
257
|
```
|
|
118
258
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
### Basic Join Example
|
|
122
|
-
|
|
123
|
-
```js
|
|
124
|
-
const posts = await fetchList(
|
|
125
|
-
PostsCollection,
|
|
126
|
-
{},
|
|
259
|
+
```jsonc
|
|
260
|
+
[
|
|
127
261
|
{
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
"
|
|
262
|
+
"title": "Blabla",
|
|
263
|
+
"authorId": "foo", // <= Included by join definition
|
|
264
|
+
"author": {
|
|
265
|
+
"name": "Foo Bar",
|
|
266
|
+
"genre": "non-fiction",
|
|
132
267
|
},
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
// (assuming 'author' was pre-registered via join(PostsCollection, { author: { ... } }))
|
|
268
|
+
/* Comments is a one-to-many join, so is returned as a list */
|
|
269
|
+
"comments": [{ "text": "Nice!" }, { "text": "Great!" }],
|
|
270
|
+
},
|
|
271
|
+
]
|
|
138
272
|
```
|
|
139
273
|
|
|
140
|
-
|
|
274
|
+
## `join(Coll, joinDefinitions)`
|
|
275
|
+
|
|
276
|
+
Collections can be joined together with **globally pre-registered joins** to greatly simplify optimized data fetching.
|
|
277
|
+
|
|
278
|
+
Joins are **not symmetrical by default**. Each collection should define its own relationships.
|
|
141
279
|
|
|
142
280
|
```js
|
|
143
|
-
import { join
|
|
281
|
+
import { join } from "coll-fns";
|
|
144
282
|
|
|
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
|
-
});
|
|
283
|
+
join(
|
|
284
|
+
/* Parent collection */
|
|
285
|
+
Coll,
|
|
154
286
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
{},
|
|
287
|
+
/* Map of joins on children collections.
|
|
288
|
+
* Each key is the name of the field
|
|
289
|
+
* where joined docs will be placed. */
|
|
159
290
|
{
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
291
|
+
joinProp1: {
|
|
292
|
+
/* joinDefinition */
|
|
293
|
+
},
|
|
294
|
+
|
|
295
|
+
joinProp2: {
|
|
296
|
+
/* joinDefinition */
|
|
163
297
|
},
|
|
164
298
|
}
|
|
165
299
|
);
|
|
166
|
-
|
|
167
|
-
// Result: Each post includes a 'comments' array
|
|
168
300
|
```
|
|
169
301
|
|
|
170
|
-
|
|
302
|
+
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).
|
|
303
|
+
|
|
304
|
+
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.
|
|
305
|
+
|
|
306
|
+
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.
|
|
307
|
+
|
|
308
|
+
There are three main types of join definitions based on the argument to the `on` property: **array**, **object** and **function** joins.
|
|
309
|
+
|
|
310
|
+
### Simple array join
|
|
171
311
|
|
|
172
|
-
|
|
312
|
+
`on` can be defined as an array of `[parentProp, childProp]` equality.
|
|
173
313
|
|
|
174
314
|
```js
|
|
175
|
-
import { join
|
|
315
|
+
import { join } from "coll-fns";
|
|
316
|
+
import { Comments, Posts, Users } from "/collections";
|
|
176
317
|
|
|
177
|
-
|
|
178
|
-
join(PostsCollection, {
|
|
318
|
+
join(Posts, {
|
|
179
319
|
author: {
|
|
180
|
-
Coll:
|
|
320
|
+
Coll: Users,
|
|
321
|
+
/* `post.authorId === user._id` */
|
|
181
322
|
on: ["authorId", "_id"],
|
|
323
|
+
/* Single doc instead of a list */
|
|
182
324
|
single: true,
|
|
183
|
-
fields: { name: 1, avatar: 1 },
|
|
184
325
|
},
|
|
326
|
+
|
|
185
327
|
comments: {
|
|
186
|
-
Coll:
|
|
328
|
+
Coll: Comments,
|
|
329
|
+
/* `post._id === comment.postId` */
|
|
187
330
|
on: ["_id", "postId"],
|
|
188
|
-
fields: { text: 1, "+": { user: 1 } },
|
|
189
331
|
},
|
|
190
332
|
});
|
|
191
333
|
|
|
192
|
-
|
|
193
|
-
join(
|
|
194
|
-
|
|
195
|
-
Coll:
|
|
196
|
-
on: ["
|
|
197
|
-
single: true,
|
|
198
|
-
fields: { name: 1, avatar: 1 },
|
|
334
|
+
/* Reversed join from user to posts */
|
|
335
|
+
join(Users, {
|
|
336
|
+
posts: {
|
|
337
|
+
Coll: Posts,
|
|
338
|
+
on: ["_id", "authorId"],
|
|
199
339
|
},
|
|
200
340
|
});
|
|
341
|
+
```
|
|
201
342
|
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
343
|
+
### Sub-array joins
|
|
344
|
+
|
|
345
|
+
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.
|
|
346
|
+
|
|
347
|
+
```js
|
|
348
|
+
import { join } from "coll-fns";
|
|
349
|
+
import { Actions, Resources } from "/collections";
|
|
350
|
+
|
|
351
|
+
/* Each action can be associated with many resources and vice-versa.
|
|
352
|
+
* Resource's `actionIds` array is the link between them. */
|
|
353
|
+
join(Actions, {
|
|
354
|
+
resources: {
|
|
355
|
+
Coll: Resources,
|
|
356
|
+
on: ["_id", ["actionIds"]],
|
|
357
|
+
},
|
|
358
|
+
});
|
|
214
359
|
|
|
215
|
-
|
|
360
|
+
/* The reverse join will flip the property names. */
|
|
361
|
+
join(Resources, {
|
|
362
|
+
actions: {
|
|
363
|
+
Coll: Actions,
|
|
364
|
+
on: [["actionIds"], "_id"],
|
|
365
|
+
},
|
|
366
|
+
});
|
|
216
367
|
```
|
|
217
368
|
|
|
218
|
-
###
|
|
369
|
+
### Filtered array-joins
|
|
219
370
|
|
|
220
|
-
|
|
371
|
+
Some joins should target only specific documents in the foreign collection. A complementary selector can be passed to the third `on` array argument.
|
|
221
372
|
|
|
222
373
|
```js
|
|
223
|
-
import { join
|
|
374
|
+
import { join } from "coll-fns";
|
|
375
|
+
import { Actions, Resources } from "/collections";
|
|
224
376
|
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
Coll:
|
|
229
|
-
on: ["
|
|
377
|
+
join(Resources, {
|
|
378
|
+
/* Only active tasks (third array element is a selector) */
|
|
379
|
+
activeTasks: {
|
|
380
|
+
Coll: Tasks,
|
|
381
|
+
on: ["_id", "resourceId", { active: true }],
|
|
230
382
|
},
|
|
231
|
-
});
|
|
232
383
|
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
name: 1,
|
|
240
|
-
"+": { friends: 2 }, // Limit to 2 levels deep
|
|
241
|
-
},
|
|
242
|
-
}
|
|
243
|
-
);
|
|
384
|
+
/* All tasks associated with a resource */
|
|
385
|
+
tasks: {
|
|
386
|
+
Coll: Tasks,
|
|
387
|
+
on: ["_id", "resourceId"],
|
|
388
|
+
},
|
|
389
|
+
});
|
|
244
390
|
```
|
|
245
391
|
|
|
246
|
-
###
|
|
392
|
+
### Object joins
|
|
247
393
|
|
|
248
|
-
|
|
394
|
+
The `on` join definition property can be an object representing a selector. It will always retrieve the same linked documents.
|
|
249
395
|
|
|
250
396
|
```js
|
|
251
|
-
import { join
|
|
397
|
+
import { join } from "coll-fns";
|
|
398
|
+
import { Factory, Workers } from "../collections";
|
|
252
399
|
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
Coll:
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
// Declare which parent fields are required for the join function
|
|
260
|
-
fields: { userId: 1 },
|
|
400
|
+
join(Workers, {
|
|
401
|
+
/* All workers will have the same `factory` props. */
|
|
402
|
+
factory: {
|
|
403
|
+
Coll: Factory,
|
|
404
|
+
on: { name: "FACTORY ABC" },
|
|
405
|
+
single: true,
|
|
261
406
|
},
|
|
262
407
|
});
|
|
408
|
+
```
|
|
263
409
|
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
410
|
+
### Function joins
|
|
411
|
+
|
|
412
|
+
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.
|
|
413
|
+
|
|
414
|
+
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:
|
|
415
|
+
|
|
416
|
+
```js
|
|
417
|
+
import { join } from "coll-fns";
|
|
418
|
+
import { Comments, Posts } from "/collections";
|
|
419
|
+
import { twoMonthsPrior } from "/lib/dates";
|
|
420
|
+
|
|
421
|
+
join(Posts, {
|
|
422
|
+
recentComments: {
|
|
423
|
+
Coll: Comments,
|
|
424
|
+
on: (post) => {
|
|
425
|
+
const { _id: postId, postedAt } = post;
|
|
426
|
+
|
|
427
|
+
/* This argument must be defined at runtime. */
|
|
428
|
+
const minDate = twoMonthsPrior(postedAt);
|
|
429
|
+
|
|
430
|
+
/* Return a selector for the Comments collection */
|
|
431
|
+
return {
|
|
432
|
+
createdAt: { $gte: minDate },
|
|
433
|
+
postId,
|
|
434
|
+
};
|
|
435
|
+
},
|
|
436
|
+
/* Parent fields needed in the join function */
|
|
269
437
|
fields: {
|
|
270
|
-
|
|
271
|
-
|
|
438
|
+
_id: 1, // Optional. _id is implicit in any fetch.
|
|
439
|
+
postedAt: 1,
|
|
272
440
|
},
|
|
273
|
-
}
|
|
274
|
-
);
|
|
441
|
+
},
|
|
442
|
+
});
|
|
275
443
|
```
|
|
276
444
|
|
|
277
|
-
|
|
445
|
+
### Recursive joins
|
|
278
446
|
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
### Setting Up Hooks
|
|
447
|
+
A collection can define joins on itself.
|
|
282
448
|
|
|
283
449
|
```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
|
-
});
|
|
450
|
+
import { join } from "coll-fns";
|
|
451
|
+
import { Users } from "/collections";
|
|
296
452
|
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
453
|
+
join(Users, {
|
|
454
|
+
friends: {
|
|
455
|
+
/* Use the same collection in the join definition */
|
|
456
|
+
Coll: Users,
|
|
457
|
+
on: [["friendIds"], "_id"],
|
|
458
|
+
},
|
|
302
459
|
});
|
|
303
460
|
```
|
|
304
461
|
|
|
305
|
-
###
|
|
462
|
+
### Join additional options
|
|
306
463
|
|
|
307
|
-
|
|
464
|
+
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
465
|
|
|
309
|
-
-
|
|
310
|
-
-
|
|
311
|
-
-
|
|
312
|
-
- **`fetch`**: Before/after fetching documents (useful for filtering)
|
|
466
|
+
- `limit`: Maximum joined documents count
|
|
467
|
+
- `skip`: Documents to skip in the fetch
|
|
468
|
+
- `sort`: Sort order of joined documents
|
|
313
469
|
|
|
314
|
-
###
|
|
470
|
+
### `postFetch`
|
|
315
471
|
|
|
316
|
-
|
|
472
|
+
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.
|
|
473
|
+
|
|
474
|
+
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
475
|
|
|
318
476
|
```js
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
const post = fetchList(PostsCollection, selector)[0];
|
|
477
|
+
import { join } from "coll-fns";
|
|
478
|
+
import { Actions, Resources } from "/collections";
|
|
479
|
+
import { sortTasks } from "/lib/tasks";
|
|
323
480
|
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
481
|
+
join(Resources, {
|
|
482
|
+
tasks: {
|
|
483
|
+
Coll: Tasks,
|
|
484
|
+
on: ["_id", "resourceId"],
|
|
485
|
+
|
|
486
|
+
/* Ensure `tasksOrder` will be fetched */
|
|
487
|
+
fields: { tasksOrder: 1 },
|
|
327
488
|
|
|
328
|
-
|
|
489
|
+
/* Transform the joined tasks documents based on parent resource. */
|
|
490
|
+
postFetch(tasks, resource) {
|
|
491
|
+
const { tasksOrder = [] } = resource;
|
|
492
|
+
return sortTasks(tasks, tasksOrder);
|
|
493
|
+
},
|
|
494
|
+
},
|
|
329
495
|
});
|
|
330
496
|
```
|
|
331
497
|
|
|
332
|
-
|
|
498
|
+
### `getJoins`
|
|
333
499
|
|
|
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
|
-
});
|
|
500
|
+
Use `getJoins(Coll)` to retrieve the complete dictionary of the collection's joins.
|
|
341
501
|
|
|
342
|
-
|
|
343
|
-
if (!modifier.$set) modifier.$set = {};
|
|
344
|
-
modifier.$set.updatedAt = new Date();
|
|
345
|
-
return [selector, modifier, options];
|
|
346
|
-
});
|
|
347
|
-
```
|
|
502
|
+
## `fetchList(Coll, selector, options)`
|
|
348
503
|
|
|
349
|
-
|
|
504
|
+
Fetch documents with the ability to **use collection joins**.
|
|
350
505
|
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
selector,
|
|
358
|
-
modifier,
|
|
359
|
-
timestamp: new Date(),
|
|
360
|
-
userId: getCurrentUserId(),
|
|
361
|
-
});
|
|
362
|
-
});
|
|
363
|
-
```
|
|
506
|
+
**Options:**
|
|
507
|
+
|
|
508
|
+
- `fields`: Field projection object
|
|
509
|
+
- `limit`: Maximum number of documents
|
|
510
|
+
- `skip`: Number of documents to skip
|
|
511
|
+
- `sort`: Sort specification
|
|
364
512
|
|
|
365
|
-
|
|
513
|
+
In its simplest form, `fetchList` can be used in much the same way as Meteor's `Coll.find(...args).fetch()`.
|
|
366
514
|
|
|
367
515
|
```js
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
PostsCollection,
|
|
377
|
-
{ authorId: userId },
|
|
378
|
-
{ $set: { authorName: userName } },
|
|
379
|
-
{ multi: true }
|
|
380
|
-
);
|
|
516
|
+
const users = await fetchList(
|
|
517
|
+
Users,
|
|
518
|
+
{ status: "active" },
|
|
519
|
+
{
|
|
520
|
+
fields: { name: 1, email: 1 },
|
|
521
|
+
sort: { createdAt: -1 },
|
|
522
|
+
limit: 10,
|
|
523
|
+
skip: 0,
|
|
381
524
|
}
|
|
382
|
-
|
|
525
|
+
);
|
|
383
526
|
```
|
|
384
527
|
|
|
385
|
-
|
|
528
|
+
### `fields` option and joins
|
|
529
|
+
|
|
530
|
+
Contrary to regular projection objects, they can use nested properties `{ car: { make: 1 } }` instead of dot-string ones `{ car: 1, "car.make": 1 }`.
|
|
386
531
|
|
|
387
|
-
|
|
532
|
+
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
533
|
|
|
389
|
-
|
|
534
|
+
#### Examples
|
|
390
535
|
|
|
391
|
-
|
|
536
|
+
<details>
|
|
537
|
+
<summary>Join definitions for examples</summary>
|
|
392
538
|
|
|
393
539
|
```js
|
|
394
|
-
import {
|
|
540
|
+
import { fetchList, join } from "coll-fns";
|
|
541
|
+
import { Comments, Posts, Users } from "/collections";
|
|
395
542
|
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
543
|
+
/* Define joins on Posts collection */
|
|
544
|
+
join(Posts, {
|
|
545
|
+
/* One-to-one join */
|
|
546
|
+
author: {
|
|
547
|
+
Coll: Users,
|
|
548
|
+
on: ["authorId", "_id"],
|
|
549
|
+
single: true,
|
|
550
|
+
},
|
|
403
551
|
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
}
|
|
552
|
+
/* One-to-many join */
|
|
553
|
+
comments: {
|
|
554
|
+
Coll: Comments,
|
|
555
|
+
on: ["_id", "postId"],
|
|
556
|
+
/* `single` defaults to false,
|
|
557
|
+
* so joined docs are returned as an array */
|
|
558
|
+
},
|
|
559
|
+
});
|
|
411
560
|
```
|
|
412
561
|
|
|
413
|
-
|
|
562
|
+
</details>
|
|
414
563
|
|
|
415
|
-
|
|
564
|
+
<details>
|
|
565
|
+
<summary>Undefined (all) own fields</summary>
|
|
416
566
|
|
|
417
567
|
```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`);
|
|
568
|
+
fetchList(Posts, {});
|
|
569
|
+
```
|
|
424
570
|
|
|
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
|
-
}
|
|
571
|
+
```json
|
|
572
|
+
[{ "title": "Blabla", "authorId": "foo", "likes": 7 }]
|
|
437
573
|
```
|
|
438
574
|
|
|
439
|
-
|
|
575
|
+
</details>
|
|
440
576
|
|
|
441
|
-
|
|
577
|
+
<details>
|
|
578
|
+
<summary>Some own fields</summary>
|
|
442
579
|
|
|
443
580
|
```js
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
on: ["authorId", "_id"], // Error: Missing Coll
|
|
581
|
+
fetchList(
|
|
582
|
+
Posts,
|
|
583
|
+
{},
|
|
584
|
+
{
|
|
585
|
+
fields: {
|
|
586
|
+
title: true, // <= Own. Any truthy value works
|
|
451
587
|
},
|
|
452
|
-
}
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
// Output: Collection 'Coll' for 'author' join is required.
|
|
456
|
-
}
|
|
588
|
+
}
|
|
589
|
+
);
|
|
590
|
+
```
|
|
457
591
|
|
|
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
|
-
}
|
|
592
|
+
```json
|
|
593
|
+
[{ "title": "Blabla" }]
|
|
470
594
|
```
|
|
471
595
|
|
|
472
|
-
|
|
596
|
+
</details>
|
|
473
597
|
|
|
474
|
-
|
|
598
|
+
<details>
|
|
599
|
+
<summary>Undefined (all) own fields, truthy (all) join fields</summary>
|
|
475
600
|
|
|
476
601
|
```js
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
602
|
+
fetchList(
|
|
603
|
+
Posts,
|
|
604
|
+
{},
|
|
605
|
+
{
|
|
606
|
+
fields: {
|
|
607
|
+
author: 1, // <= Join
|
|
608
|
+
},
|
|
483
609
|
}
|
|
610
|
+
);
|
|
611
|
+
```
|
|
484
612
|
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
613
|
+
```jsonc
|
|
614
|
+
[
|
|
615
|
+
{
|
|
616
|
+
"title": "Blabla",
|
|
617
|
+
"authorId": "foo",
|
|
618
|
+
"likes": 7,
|
|
619
|
+
"author": {
|
|
620
|
+
"name": "Foo Bar",
|
|
621
|
+
"birthdate": "Some Date",
|
|
622
|
+
"genre": "non-fiction",
|
|
623
|
+
},
|
|
624
|
+
},
|
|
625
|
+
]
|
|
626
|
+
```
|
|
488
627
|
|
|
489
|
-
|
|
490
|
-
});
|
|
628
|
+
</details>
|
|
491
629
|
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
630
|
+
<details>
|
|
631
|
+
<summary>Some own fields, truthy (all) join fields</summary>
|
|
632
|
+
|
|
633
|
+
```js
|
|
634
|
+
fetchList(
|
|
635
|
+
Posts,
|
|
636
|
+
{},
|
|
637
|
+
{
|
|
638
|
+
fields: {
|
|
639
|
+
title: 1, // <= Own
|
|
640
|
+
author: 1, // <= Join
|
|
641
|
+
},
|
|
504
642
|
}
|
|
505
|
-
|
|
643
|
+
);
|
|
644
|
+
```
|
|
645
|
+
|
|
646
|
+
```jsonc
|
|
647
|
+
[
|
|
648
|
+
{
|
|
649
|
+
"title": "Blabla",
|
|
650
|
+
"authorId": "foo", // <= Included by join definition
|
|
651
|
+
"author": {
|
|
652
|
+
"name": "Foo Bar",
|
|
653
|
+
"birthdate": "Some Date",
|
|
654
|
+
"genre": "non-fiction",
|
|
655
|
+
},
|
|
656
|
+
},
|
|
657
|
+
]
|
|
506
658
|
```
|
|
507
659
|
|
|
508
|
-
|
|
660
|
+
</details>
|
|
509
661
|
|
|
510
|
-
|
|
662
|
+
<details>
|
|
663
|
+
<summary>Some own fields, some join fields</summary>
|
|
511
664
|
|
|
512
665
|
```js
|
|
513
|
-
|
|
666
|
+
fetchList(
|
|
667
|
+
Posts,
|
|
668
|
+
{},
|
|
669
|
+
{
|
|
670
|
+
fields: {
|
|
671
|
+
title: 1, // <= Own
|
|
672
|
+
author: { birthdate: 0 }, // <= Falsy = anything but these fields
|
|
673
|
+
comments: { text: 1 },
|
|
674
|
+
},
|
|
675
|
+
}
|
|
676
|
+
);
|
|
677
|
+
```
|
|
514
678
|
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
679
|
+
```jsonc
|
|
680
|
+
[
|
|
681
|
+
{
|
|
682
|
+
"title": "Blabla",
|
|
683
|
+
"authorId": "foo", // <= Included by join definition
|
|
684
|
+
"author": {
|
|
685
|
+
"name": "Foo Bar",
|
|
686
|
+
"genre": "non-fiction",
|
|
687
|
+
},
|
|
688
|
+
/* Comments is a one-to-many join, so is returned as a list */
|
|
689
|
+
"comments": [{ "text": "Nice!" }, { "text": "Great!" }],
|
|
521
690
|
},
|
|
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
|
-
}
|
|
691
|
+
]
|
|
537
692
|
```
|
|
538
693
|
|
|
539
|
-
|
|
694
|
+
</details>
|
|
695
|
+
|
|
696
|
+
### `setJoinPrefix(prefix)`
|
|
540
697
|
|
|
541
|
-
|
|
698
|
+
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.
|
|
699
|
+
|
|
700
|
+
Setting the prefix to null or undefined allows using join fields at the document root like any normal field.
|
|
542
701
|
|
|
543
702
|
```js
|
|
544
|
-
import {
|
|
703
|
+
import { setJoinPrefix } from "coll-fns";
|
|
545
704
|
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
// Missing required methods
|
|
549
|
-
});
|
|
705
|
+
/* All join fields will have to be prefixed with "+" */
|
|
706
|
+
setJoinPrefix("+");
|
|
550
707
|
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
708
|
+
/* Some own fields, some join fields */
|
|
709
|
+
fetchList(
|
|
710
|
+
Posts,
|
|
711
|
+
{},
|
|
712
|
+
{
|
|
713
|
+
fields: {
|
|
714
|
+
title: 1, // <= Own
|
|
715
|
+
|
|
716
|
+
/* Join fields must be nested under the prefix key */
|
|
717
|
+
"+": {
|
|
718
|
+
author: { name: 1, birthdate: 1 }, // <= Join sub fields
|
|
719
|
+
},
|
|
720
|
+
},
|
|
721
|
+
}
|
|
722
|
+
);
|
|
557
723
|
```
|
|
558
724
|
|
|
559
|
-
|
|
725
|
+
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.
|
|
560
726
|
|
|
561
|
-
|
|
727
|
+
If, for some reason, you need to retrieve the prefix, you can do so with `getJoinPrefix(Coll)`.
|
|
562
728
|
|
|
563
|
-
###
|
|
729
|
+
### Nested Joins
|
|
730
|
+
|
|
731
|
+
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
732
|
|
|
565
733
|
```js
|
|
566
|
-
|
|
567
|
-
import { setProtocol, protocols } from "coll-fns";
|
|
734
|
+
import { fetchList } from "coll-fns";
|
|
568
735
|
|
|
569
|
-
|
|
736
|
+
const posts = fetchList(
|
|
737
|
+
Posts,
|
|
738
|
+
{},
|
|
739
|
+
{
|
|
740
|
+
fields: {
|
|
741
|
+
title: 1,
|
|
742
|
+
|
|
743
|
+
/* Level 1 : One-to-many join */
|
|
744
|
+
comments: {
|
|
745
|
+
text: 1,
|
|
746
|
+
|
|
747
|
+
/* Level 2 : One-to-one join */
|
|
748
|
+
user: {
|
|
749
|
+
username: 1,
|
|
750
|
+
},
|
|
751
|
+
},
|
|
752
|
+
},
|
|
753
|
+
}
|
|
754
|
+
);
|
|
755
|
+
```
|
|
570
756
|
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
authorId: this.userId,
|
|
579
|
-
});
|
|
757
|
+
```json
|
|
758
|
+
{
|
|
759
|
+
"title": "Blabla",
|
|
760
|
+
"comments": [
|
|
761
|
+
{ "text": "Nice!", "user": { "username": "foo"} },
|
|
762
|
+
{ "text": "Great!", "user": { "username": "bar" } }
|
|
763
|
+
]
|
|
580
764
|
},
|
|
581
|
-
});
|
|
582
765
|
```
|
|
583
766
|
|
|
584
|
-
###
|
|
767
|
+
### Recursion levels
|
|
585
768
|
|
|
586
|
-
|
|
587
|
-
// client/main.js
|
|
588
|
-
import { setProtocol, protocols, join } from "coll-fns";
|
|
769
|
+
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
770
|
|
|
590
|
-
|
|
771
|
+
```js
|
|
772
|
+
import { join } from "coll-fns";
|
|
773
|
+
import { Users } from "/collections";
|
|
591
774
|
|
|
592
|
-
|
|
593
|
-
join(
|
|
594
|
-
|
|
595
|
-
Coll:
|
|
596
|
-
on: ["
|
|
597
|
-
single: true,
|
|
775
|
+
/* Pre-register recursive join */
|
|
776
|
+
join(Users, {
|
|
777
|
+
friends: {
|
|
778
|
+
Coll: Users,
|
|
779
|
+
on: [["friendIds"], "_id"],
|
|
598
780
|
},
|
|
599
781
|
});
|
|
600
782
|
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
PostsCollection,
|
|
783
|
+
fetchList(
|
|
784
|
+
Users,
|
|
604
785
|
{},
|
|
605
|
-
{
|
|
786
|
+
{
|
|
787
|
+
fields: {
|
|
788
|
+
name: 1,
|
|
789
|
+
/* Join field. Limit to 2 levels deep, reusing parent fields */
|
|
790
|
+
friends: 2,
|
|
791
|
+
},
|
|
792
|
+
}
|
|
606
793
|
);
|
|
607
794
|
```
|
|
608
795
|
|
|
609
|
-
###
|
|
796
|
+
### Documents transformation
|
|
797
|
+
|
|
798
|
+
Documents can be transformed after fetching. Collection-level transforms are automatically applied if the protocol allows it:
|
|
799
|
+
|
|
800
|
+
**Meteor:**
|
|
610
801
|
|
|
611
802
|
```js
|
|
612
|
-
|
|
613
|
-
import { join, fetchList } from "coll-fns";
|
|
803
|
+
import { Mongo } from "meteor/mongo";
|
|
614
804
|
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
single: true,
|
|
621
|
-
fields: { name: 1, avatar: 1 },
|
|
622
|
-
},
|
|
805
|
+
const Users = new Mongo.Collection("users", {
|
|
806
|
+
transform: (doc) => ({
|
|
807
|
+
...doc,
|
|
808
|
+
fullName: `${doc.firstName} ${doc.lastName}`,
|
|
809
|
+
}),
|
|
623
810
|
});
|
|
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
811
|
```
|
|
640
812
|
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
### Core Functions
|
|
644
|
-
|
|
645
|
-
#### `fetchList(collection, selector, options)`
|
|
646
|
-
|
|
647
|
-
Fetch an array of documents from a collection.
|
|
813
|
+
**For a specific fetch**, pass a `transform` option:
|
|
648
814
|
|
|
649
815
|
```js
|
|
650
816
|
const users = await fetchList(
|
|
651
|
-
|
|
817
|
+
Users,
|
|
652
818
|
{ status: "active" },
|
|
653
819
|
{
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
820
|
+
transform: (doc) => ({
|
|
821
|
+
...doc,
|
|
822
|
+
fullName: `${doc.firstName} ${doc.lastName}`,
|
|
823
|
+
}),
|
|
658
824
|
}
|
|
659
825
|
);
|
|
660
826
|
```
|
|
661
827
|
|
|
662
|
-
**
|
|
663
|
-
|
|
664
|
-
- `fields`: Field projection object
|
|
665
|
-
- `sort`: Sort specification
|
|
666
|
-
- `limit`: Maximum number of documents
|
|
667
|
-
- `skip`: Number of documents to skip
|
|
828
|
+
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.
|
|
668
829
|
|
|
669
|
-
|
|
830
|
+
## `fetchOne(Coll, selector, options)`
|
|
670
831
|
|
|
671
|
-
Fetch a single document from a collection.
|
|
832
|
+
Fetch a single document from a collection. Same behaviour as `fetchList`.
|
|
672
833
|
|
|
673
834
|
```js
|
|
674
|
-
|
|
675
|
-
|
|
835
|
+
import { fetchOne } from "coll-fns";
|
|
836
|
+
import { Users } from "/collections";
|
|
837
|
+
|
|
838
|
+
const user = fetchOne(
|
|
839
|
+
Users,
|
|
676
840
|
{ _id: userId },
|
|
677
841
|
{
|
|
678
|
-
fields: {
|
|
842
|
+
fields: {
|
|
843
|
+
name: 1,
|
|
844
|
+
friends: 1, // <= Join
|
|
845
|
+
},
|
|
679
846
|
}
|
|
680
847
|
);
|
|
681
848
|
```
|
|
682
849
|
|
|
683
|
-
|
|
850
|
+
## `fetchIds(Coll, selector, options)`
|
|
684
851
|
|
|
685
|
-
Fetch only the `_id` field of matching documents.
|
|
852
|
+
Fetch only the `_id` field of matching documents. `fields` option will be ignored.
|
|
686
853
|
|
|
687
854
|
```js
|
|
688
|
-
|
|
689
|
-
|
|
855
|
+
import { fetchOne } from "coll-fns";
|
|
856
|
+
import { Users } from "/collections";
|
|
857
|
+
|
|
858
|
+
const userIds = fetchIds(Users, { status: "active" });
|
|
690
859
|
```
|
|
691
860
|
|
|
692
|
-
|
|
861
|
+
## `exists(Coll, selector)`
|
|
693
862
|
|
|
694
|
-
Check if any
|
|
863
|
+
Check if any document matches the selector.
|
|
695
864
|
|
|
696
865
|
```js
|
|
697
|
-
|
|
866
|
+
import { fetchOne } from "coll-fns";
|
|
867
|
+
import { Users } from "/collections";
|
|
868
|
+
|
|
869
|
+
const hasActiveUsers = exists(Users, { status: "active" });
|
|
698
870
|
// Returns: true or false
|
|
699
871
|
```
|
|
700
872
|
|
|
701
|
-
|
|
873
|
+
## `count(Coll, selector)`
|
|
702
874
|
|
|
703
|
-
|
|
875
|
+
Count documents matching the selector.
|
|
704
876
|
|
|
705
877
|
```js
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
});
|
|
878
|
+
import { fetchOne } from "coll-fns";
|
|
879
|
+
import { Users } from "/collections";
|
|
880
|
+
|
|
881
|
+
const activeUsersCount = count(UsersCollection, { status: "active" });
|
|
882
|
+
// Returns an integer
|
|
710
883
|
```
|
|
711
884
|
|
|
712
|
-
|
|
885
|
+
## `flattenFields(fields)`
|
|
713
886
|
|
|
714
|
-
|
|
887
|
+
Flatten a general field specifiers object (which could include nested objects) into a MongoDB-compatible one that uses dot-notation.
|
|
715
888
|
|
|
716
889
|
```js
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
890
|
+
import { flattenFields } from "coll-fns";
|
|
891
|
+
|
|
892
|
+
const flattened = flattenFields({
|
|
893
|
+
name: 1,
|
|
894
|
+
address: {
|
|
895
|
+
street: 1,
|
|
896
|
+
city: 1,
|
|
897
|
+
},
|
|
898
|
+
});
|
|
899
|
+
// Result: { name: 1, 'address.street': 1, 'address.city': 1 }
|
|
723
900
|
```
|
|
724
901
|
|
|
725
|
-
|
|
902
|
+
# Hooks and write operations
|
|
726
903
|
|
|
727
|
-
|
|
904
|
+
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
905
|
|
|
729
|
-
|
|
730
|
-
await remove(UsersCollection, { inactive: true });
|
|
731
|
-
```
|
|
906
|
+
## `hook(Coll, hooksObj)`
|
|
732
907
|
|
|
733
|
-
|
|
908
|
+
Register hooks on a collection to run before or after specific write operations (insert, update, remove).
|
|
734
909
|
|
|
735
|
-
|
|
910
|
+
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**.
|
|
911
|
+
|
|
912
|
+
Hooks can be **defined in multiple places** in your codebase. This allows grouping functionally related hooks together.
|
|
736
913
|
|
|
737
914
|
```js
|
|
738
|
-
|
|
915
|
+
import { hook } from "coll-fns";
|
|
916
|
+
import { Users, Posts } from "/collections";
|
|
917
|
+
|
|
918
|
+
hook(Users, {
|
|
919
|
+
beforeInsert: [
|
|
920
|
+
{
|
|
921
|
+
fn(doc) {
|
|
922
|
+
if (!doc.email) throw new Error("Email is required");
|
|
923
|
+
|
|
924
|
+
doc.createdAt = new Date();
|
|
925
|
+
},
|
|
926
|
+
},
|
|
927
|
+
],
|
|
928
|
+
|
|
929
|
+
onInserted: [
|
|
930
|
+
{
|
|
931
|
+
fn(doc) {
|
|
932
|
+
console.log(`New user created: ${doc._id}`);
|
|
933
|
+
},
|
|
934
|
+
},
|
|
935
|
+
],
|
|
936
|
+
});
|
|
739
937
|
```
|
|
740
938
|
|
|
741
|
-
###
|
|
939
|
+
### Before hooks
|
|
940
|
+
|
|
941
|
+
These hooks run **before** the write operation and can **prevent the operation** by throwing an error.
|
|
942
|
+
|
|
943
|
+
- **`beforeInsert`**: Runs before inserting a document. Receives `(doc)`.
|
|
944
|
+
- **`beforeUpdate`**: Runs before updating documents. Receives `([...docsToUpdate], modifier)`.
|
|
945
|
+
- **`beforeRemove`**: Runs before removing documents. Receives `([...docsToRemove])`.
|
|
946
|
+
|
|
947
|
+
Although arguments can be mutated, it is not the main purpose of these hooks. Mutations are brittle and hard to debug.
|
|
948
|
+
|
|
949
|
+
`beforeUpdate` and `beforeRemove` receive an **array of targeted documents**, whereas `beforeInsert` receives a **single document**.
|
|
950
|
+
|
|
951
|
+
### After hooks
|
|
742
952
|
|
|
743
|
-
|
|
953
|
+
These hooks run **after** the write operation completes and are **fire-and-forget** (not awaited by the caller of the collection function). They are usually used to trigger side-effects. They should not throw errors that should get back to the caller.
|
|
744
954
|
|
|
745
|
-
|
|
955
|
+
- **`onInserted`**: Runs after a document is inserted. Receives `(doc)`.
|
|
956
|
+
- **`onUpdated`**: Runs after a document is updated. Receives `(afterDoc, beforeDoc)`.
|
|
957
|
+
- **`onRemoved`**: Runs after a document is removed. Receives `(doc)`.
|
|
958
|
+
|
|
959
|
+
**IMPORTANT!**
|
|
960
|
+
|
|
961
|
+
1. These hooks **might create incoherent state** when used as a denormalization technique (a common and helpful use case) if a downstream update fails. It is **NOT inherent to `coll-fns`**, but rather to eventual consistent database designs. Even if the after hooks were awaited, errors would not rollback prior successful updates.
|
|
962
|
+
|
|
963
|
+
2. Although hooks can define `onError` callbacks, if `fn` executes async code, **it MUST await it or return it as a promise**. Otherwise, `onError` callback will never get fired because the function will be running in a separate promise context. If `fn` starts async work and doesn’t return/await it, any error will become an **unhandled rejection (and may crash the process)**.
|
|
964
|
+
|
|
965
|
+
❌ **Wrong**
|
|
746
966
|
|
|
747
967
|
```js
|
|
748
|
-
hook(
|
|
749
|
-
|
|
750
|
-
|
|
968
|
+
hook(Coll, {
|
|
969
|
+
fn(doc) {
|
|
970
|
+
update(/* Some other collection */); // not awaited / not returned
|
|
971
|
+
},
|
|
751
972
|
});
|
|
752
973
|
```
|
|
753
974
|
|
|
754
|
-
|
|
975
|
+
Might crash the process!
|
|
755
976
|
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
977
|
+
```
|
|
978
|
+
UnhandledPromiseRejection: Error: Validation failed
|
|
979
|
+
at beforeUpdate (.../src/hooks.js:42:11)
|
|
980
|
+
at update (.../src/update.js:128:7)
|
|
981
|
+
...
|
|
982
|
+
```
|
|
760
983
|
|
|
761
|
-
|
|
984
|
+
✅ **Right**
|
|
762
985
|
|
|
763
|
-
|
|
986
|
+
```js
|
|
987
|
+
hook(Coll, {
|
|
988
|
+
async fn(doc) {
|
|
989
|
+
await update(/* Some other collection */); // Awaited
|
|
990
|
+
},
|
|
991
|
+
});
|
|
992
|
+
```
|
|
764
993
|
|
|
765
|
-
|
|
994
|
+
or
|
|
766
995
|
|
|
767
996
|
```js
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
997
|
+
hook(Coll, {
|
|
998
|
+
fn(doc) {
|
|
999
|
+
return update(/* Some other collection */); // Returned promise
|
|
1000
|
+
},
|
|
1001
|
+
});
|
|
771
1002
|
```
|
|
772
1003
|
|
|
773
|
-
|
|
1004
|
+
### Hook definition properties
|
|
774
1005
|
|
|
775
|
-
|
|
1006
|
+
Each hook definition is an object with the following properties:
|
|
776
1007
|
|
|
777
1008
|
```js
|
|
778
|
-
|
|
1009
|
+
{
|
|
1010
|
+
/* Required. The function to execute.
|
|
1011
|
+
* Arguments depend on the hook type (see above).
|
|
1012
|
+
* Can be either synchronous or asynchronous. */
|
|
1013
|
+
fn(...args) { /* ... */ },
|
|
1014
|
+
|
|
1015
|
+
/* Optional. Fields to fetch for the documents passed to the hook.
|
|
1016
|
+
* Fields for multiple hooks of the same type are automatically combined.
|
|
1017
|
+
* If any hook of a type requests all fields with `undefined` or `true`,
|
|
1018
|
+
* all other similar hooks will also get the entire documents.
|
|
1019
|
+
* Has no effect on `beforeInsert`: the doc to be inserted is the argument. */
|
|
1020
|
+
fields: { name: 1, email: 1 },
|
|
1021
|
+
|
|
1022
|
+
/* Optional (`onUpdated` only). If true, fetch the document state
|
|
1023
|
+
* before the update with the same `fields` value.
|
|
1024
|
+
* Otherwise, only _id is fetched initially (they would have been
|
|
1025
|
+
* needed anyway to fetch their "after" versions). */
|
|
1026
|
+
before: true,
|
|
1027
|
+
|
|
1028
|
+
/* Optional. Synchronous predicate that prevents the hook from running if it
|
|
1029
|
+
* returns a truthy value. Receives the same arguments as fn. */
|
|
1030
|
+
unless(doc) { return doc.isBot; },
|
|
1031
|
+
|
|
1032
|
+
/* Optional. Synchronous predicate that allows the hook to run only if it
|
|
1033
|
+
* returns a truthy value. Receives the same arguments as fn. */
|
|
1034
|
+
when(doc) { return doc.status === "pending"; },
|
|
1035
|
+
|
|
1036
|
+
/* Optional handler called if the hook function throws an error.
|
|
1037
|
+
* A default handler that logs to console.error is defined
|
|
1038
|
+
* for after-hooks (onInserted, onUpdated, onRemoved)
|
|
1039
|
+
* to prevent an error from crashing the server. */
|
|
1040
|
+
onError(err, hookDef) { /* ... */ },
|
|
1041
|
+
}
|
|
779
1042
|
```
|
|
780
1043
|
|
|
781
|
-
|
|
1044
|
+
### Examples
|
|
782
1045
|
|
|
783
|
-
|
|
1046
|
+
<details>
|
|
1047
|
+
<summary>Data validation</summary>
|
|
784
1048
|
|
|
785
1049
|
```js
|
|
786
|
-
|
|
1050
|
+
hook(Users, {
|
|
1051
|
+
beforeInsert: [
|
|
1052
|
+
{
|
|
1053
|
+
fn(doc) {
|
|
1054
|
+
if (!doc.email || !doc.email.includes("@")) {
|
|
1055
|
+
throw new Error("Invalid email");
|
|
1056
|
+
}
|
|
1057
|
+
},
|
|
1058
|
+
},
|
|
1059
|
+
],
|
|
1060
|
+
});
|
|
1061
|
+
```
|
|
1062
|
+
|
|
1063
|
+
</details>
|
|
1064
|
+
|
|
1065
|
+
<details>
|
|
1066
|
+
<summary>Cascading updates</summary>
|
|
787
1067
|
|
|
788
|
-
|
|
789
|
-
|
|
1068
|
+
```js
|
|
1069
|
+
/* If user's name changed, update their posts' denormalized data */
|
|
1070
|
+
hook(Users, {
|
|
1071
|
+
onUpdated: [
|
|
1072
|
+
{
|
|
1073
|
+
fields: { name: 1 },
|
|
1074
|
+
/* Use `when` predicate to run the hook only on this condition.
|
|
1075
|
+
* Could also have used `unless` or checked condition inside `fn`. */
|
|
1076
|
+
when: (after, before) => after.name !== before.name,
|
|
1077
|
+
|
|
1078
|
+
/* Effect to run - uses update() which also supports hooks */
|
|
1079
|
+
fn(after) {
|
|
1080
|
+
const { _id, name } = after;
|
|
1081
|
+
update(Posts, { authorId: _id }, { $set: { authorName: name } });
|
|
1082
|
+
},
|
|
1083
|
+
},
|
|
1084
|
+
],
|
|
790
1085
|
});
|
|
791
1086
|
```
|
|
792
1087
|
|
|
793
|
-
|
|
1088
|
+
</details>
|
|
794
1089
|
|
|
795
|
-
|
|
1090
|
+
<details>
|
|
1091
|
+
<summary>Conditional hooks with when/unless</summary>
|
|
796
1092
|
|
|
797
1093
|
```js
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
1094
|
+
hook(Posts, {
|
|
1095
|
+
beforeRemove: [
|
|
1096
|
+
{
|
|
1097
|
+
/* Limit fetched fields of docs to be removed */
|
|
1098
|
+
fields: { _id: 1 },
|
|
1099
|
+
/* Only run for non-admin users */
|
|
1100
|
+
unless() {
|
|
1101
|
+
return Meteor.user()?.isAdmin;
|
|
1102
|
+
},
|
|
1103
|
+
fn() {
|
|
1104
|
+
throw new Error("Only admins can delete posts");
|
|
808
1105
|
},
|
|
809
1106
|
},
|
|
810
|
-
|
|
811
|
-
);
|
|
1107
|
+
],
|
|
812
1108
|
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
1109
|
+
onRemoved: [
|
|
1110
|
+
{
|
|
1111
|
+
/* Only log removal of published posts */
|
|
1112
|
+
when(doc) {
|
|
1113
|
+
return doc.status === "published";
|
|
1114
|
+
},
|
|
1115
|
+
fn(doc) {
|
|
1116
|
+
logEvent("post_deleted", { postId: doc._id });
|
|
1117
|
+
},
|
|
822
1118
|
},
|
|
823
|
-
|
|
824
|
-
);
|
|
1119
|
+
],
|
|
1120
|
+
});
|
|
825
1121
|
```
|
|
826
1122
|
|
|
827
|
-
|
|
1123
|
+
</details>
|
|
1124
|
+
|
|
1125
|
+
<details>
|
|
1126
|
+
<summary>Cascading removals</summary>
|
|
828
1127
|
|
|
829
1128
|
```js
|
|
830
|
-
|
|
1129
|
+
hook(Users, {
|
|
1130
|
+
beforeRemove: [
|
|
1131
|
+
{
|
|
1132
|
+
fn(usersToRemove) {
|
|
1133
|
+
const userIds = usersToRemove.map((u) => u._id);
|
|
1134
|
+
|
|
1135
|
+
/* Prevent removal if user has published posts */
|
|
1136
|
+
const hasPublished = exists(Posts, {
|
|
1137
|
+
authorId: { $in: userIds },
|
|
1138
|
+
status: "published",
|
|
1139
|
+
});
|
|
1140
|
+
|
|
1141
|
+
if (hasPublished) {
|
|
1142
|
+
throw new Error("Cannot delete users with published posts");
|
|
1143
|
+
}
|
|
1144
|
+
},
|
|
1145
|
+
},
|
|
1146
|
+
],
|
|
831
1147
|
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
1148
|
+
onRemoved: [
|
|
1149
|
+
{
|
|
1150
|
+
fn(user) {
|
|
1151
|
+
/* Clean up related data after user is removed.
|
|
1152
|
+
* See remove() for more details on how this integrates with hooks. */
|
|
1153
|
+
remove(Comments, { authorId: user._id });
|
|
1154
|
+
},
|
|
1155
|
+
},
|
|
1156
|
+
],
|
|
838
1157
|
});
|
|
839
|
-
// Result: { name: 1, 'address.street': 1, 'address.city': 1 }
|
|
840
1158
|
```
|
|
841
1159
|
|
|
842
|
-
|
|
1160
|
+
</details>
|
|
1161
|
+
|
|
1162
|
+
The data mutation methods below use basically the same arguments as [Meteor collection methods](https://docs.meteor.com/api/collections.html#Mongo-Collection-updateAsync).
|
|
843
1163
|
|
|
844
|
-
|
|
1164
|
+
## `insert(Coll, doc)`
|
|
1165
|
+
|
|
1166
|
+
Insert a document into a collection. Returns the document \_id. Runs `beforeInsert` and `onInserted` hooks if defined.
|
|
845
1167
|
|
|
846
1168
|
```js
|
|
847
|
-
|
|
1169
|
+
const newUser = insert(Users, {
|
|
1170
|
+
name: "Bob",
|
|
1171
|
+
email: "bob@example.com",
|
|
1172
|
+
});
|
|
848
1173
|
```
|
|
849
1174
|
|
|
850
|
-
|
|
1175
|
+
**Execution flow:**
|
|
851
1176
|
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
1177
|
+
1. Run `beforeInsert` hooks (can throw to prevent insertion)
|
|
1178
|
+
2. Insert the document
|
|
1179
|
+
3. Fire `onInserted` hooks asynchronously (without awaiting)
|
|
855
1180
|
|
|
856
|
-
|
|
857
|
-
await client.connect();
|
|
858
|
-
setProtocol(protocols.node(client));
|
|
859
|
-
```
|
|
1181
|
+
## `update(Coll, selector, modifier, options)`
|
|
860
1182
|
|
|
861
|
-
|
|
1183
|
+
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
1184
|
|
|
863
1185
|
```js
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
setProtocol(protocols.meteorSync);
|
|
1186
|
+
update(Users, { status: "pending" }, { $set: { status: "active" } });
|
|
867
1187
|
```
|
|
868
1188
|
|
|
869
|
-
|
|
1189
|
+
**Execution flow:**
|
|
870
1190
|
|
|
871
|
-
|
|
872
|
-
|
|
1191
|
+
1. Fetch target documents with `beforeUpdate` and `onUpdated.before` fields
|
|
1192
|
+
2. Run `beforeUpdate` hooks with `(docsToUpdate, modifier)` (can throw to prevent update)
|
|
1193
|
+
3. Execute the update
|
|
1194
|
+
4. Fetch updated documents again with `onUpdated` fields
|
|
1195
|
+
5. Fire `onUpdated` hooks asynchronously with `(afterDoc, beforeDoc)` pairs
|
|
873
1196
|
|
|
874
|
-
|
|
875
|
-
```
|
|
1197
|
+
**Options:**
|
|
876
1198
|
|
|
877
|
-
|
|
1199
|
+
- `multi` (default: `true`): Update multiple documents or just the first match;
|
|
1200
|
+
- `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
1201
|
|
|
879
|
-
|
|
1202
|
+
## `remove(Coll, selector)`
|
|
880
1203
|
|
|
881
|
-
|
|
1204
|
+
Remove documents matching the selector. Runs `beforeRemove` and `onRemoved` hooks if defined.
|
|
882
1205
|
|
|
883
1206
|
```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);
|
|
1207
|
+
remove(Users, { inactive: true });
|
|
903
1208
|
```
|
|
904
1209
|
|
|
905
|
-
|
|
1210
|
+
**Execution flow:**
|
|
906
1211
|
|
|
907
|
-
|
|
1212
|
+
1. Fetch documents matching the selector with `beforeRemove` and `onRemoved` fields
|
|
1213
|
+
2. Run `beforeRemove` hooks with matched documents (can throw to prevent removal)
|
|
1214
|
+
3. Remove the documents
|
|
1215
|
+
4. Fire `onRemoved` hooks asynchronously with each removed document
|
|
908
1216
|
|
|
909
|
-
|
|
1217
|
+
## `configurePool(options)`
|
|
910
1218
|
|
|
911
|
-
|
|
912
|
-
|
|
1219
|
+
After hooks can generate significant background work, especially when they trigger cascading writes and more after hooks.
|
|
1220
|
+
|
|
1221
|
+
`coll-fns` uses an internal execution pool for fire-and-forget after hooks.
|
|
1222
|
+
You can configure this pool at startup.
|
|
1223
|
+
|
|
1224
|
+
`configurePool` must be called **before any after hook is processed**.
|
|
1225
|
+
|
|
1226
|
+
### Default behavior
|
|
1227
|
+
|
|
1228
|
+
By default, the pool uses:
|
|
1229
|
+
|
|
1230
|
+
- `maxConcurrent: 10`
|
|
1231
|
+
- `maxPending: 250`
|
|
1232
|
+
- `onOverflow`: drop new call and warn in console
|
|
1233
|
+
|
|
1234
|
+
This prevents unbounded growth while allowing parallel processing.
|
|
1235
|
+
|
|
1236
|
+
### Options
|
|
913
1237
|
|
|
914
|
-
|
|
1238
|
+
```ts
|
|
1239
|
+
configurePool({
|
|
1240
|
+
maxConcurrent?: number; // >= 1
|
|
1241
|
+
maxPending?: number | Infinity; // >= 0 or Infinity
|
|
1242
|
+
onOverflow?: "drop" | "shift" | (pendings, call) => reorderedPendings | void;
|
|
1243
|
+
onError?: (error, call) => void;
|
|
1244
|
+
});
|
|
915
1245
|
```
|
|
916
1246
|
|
|
917
|
-
|
|
1247
|
+
- `maxConcurrent`: maximum number of after hooks executed in parallel.
|
|
1248
|
+
- `maxPending`: maximum number of queued hooks waiting for execution.
|
|
1249
|
+
- `onOverflow`: policy to apply when pendings overflow
|
|
1250
|
+
- "drop": ignore the new call.
|
|
1251
|
+
- "shift": remove oldest pending call, enqueue the new one.
|
|
1252
|
+
- function: return a reordered/filtered pending list.
|
|
1253
|
+
- `onError`: called when a pooled call fails. If omitted, errors are logged.
|
|
1254
|
+
|
|
1255
|
+
**Example**
|
|
918
1256
|
|
|
919
1257
|
```js
|
|
920
|
-
import {
|
|
1258
|
+
import { configurePool } from "coll-fns";
|
|
1259
|
+
|
|
1260
|
+
/* Must be called BEFORE any after hook is processed. */
|
|
1261
|
+
configurePool({
|
|
1262
|
+
maxConcurrent: 20,
|
|
1263
|
+
maxPending: 1000,
|
|
1264
|
+
onOverflow: "shift",
|
|
1265
|
+
onError(error, call) {
|
|
1266
|
+
console.error("After-hook pool error:", error, call);
|
|
1267
|
+
},
|
|
1268
|
+
});
|
|
1269
|
+
```
|
|
1270
|
+
|
|
1271
|
+
**Notes**
|
|
1272
|
+
|
|
1273
|
+
- This configuration is **startup-only**. Calling configurePool after processing starts throws.
|
|
1274
|
+
- If your workload is sensitive to ordering, keep maxConcurrent low or implement ordering constraints in your hook logic.
|
|
1275
|
+
- Tune maxConcurrent/maxPending based on your app’s throughput and memory profile.
|
|
921
1276
|
|
|
922
|
-
|
|
1277
|
+
## Hook best practices
|
|
1278
|
+
|
|
1279
|
+
<details>
|
|
1280
|
+
<summary><strong>Error handling</strong></summary>
|
|
1281
|
+
|
|
1282
|
+
**Before hooks** should throw errors to prevent operations:
|
|
1283
|
+
|
|
1284
|
+
```js
|
|
1285
|
+
hook(Users, {
|
|
1286
|
+
beforeInsert: [
|
|
1287
|
+
{
|
|
1288
|
+
fn(doc) {
|
|
1289
|
+
if (!isValidEmail(doc.email)) {
|
|
1290
|
+
throw new Error("Invalid email");
|
|
1291
|
+
}
|
|
1292
|
+
},
|
|
1293
|
+
},
|
|
1294
|
+
],
|
|
1295
|
+
});
|
|
923
1296
|
```
|
|
924
1297
|
|
|
925
|
-
|
|
1298
|
+
**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`.
|
|
926
1299
|
|
|
927
1300
|
```js
|
|
928
|
-
|
|
1301
|
+
hook(Users, {
|
|
1302
|
+
onInserted: [
|
|
1303
|
+
{
|
|
1304
|
+
fn(doc) {
|
|
1305
|
+
/* ... */
|
|
1306
|
+
},
|
|
1307
|
+
onError(err, hookDef) {
|
|
1308
|
+
logToService(err, hookDef.collName);
|
|
1309
|
+
},
|
|
1310
|
+
},
|
|
1311
|
+
],
|
|
1312
|
+
});
|
|
1313
|
+
```
|
|
1314
|
+
|
|
1315
|
+
</details>
|
|
1316
|
+
|
|
1317
|
+
<details>
|
|
1318
|
+
<summary><strong>Field optimization</strong></summary>
|
|
929
1319
|
|
|
930
|
-
|
|
931
|
-
|
|
1320
|
+
Always declare which fields your hook needs with the `fields` property. This reduces database queries and improves performance:
|
|
1321
|
+
|
|
1322
|
+
```js
|
|
1323
|
+
hook(Posts, {
|
|
1324
|
+
onUpdated: [
|
|
1325
|
+
{
|
|
1326
|
+
/* Only fetch these fields */
|
|
1327
|
+
fields: { authorId: 1, title: 1 },
|
|
1328
|
+
fn(afterPost, beforePost) {
|
|
1329
|
+
if (afterPost.title !== beforePost.title) {
|
|
1330
|
+
notifySubscribers(afterPost);
|
|
1331
|
+
}
|
|
1332
|
+
},
|
|
1333
|
+
},
|
|
1334
|
+
],
|
|
1335
|
+
});
|
|
932
1336
|
```
|
|
933
1337
|
|
|
934
|
-
|
|
1338
|
+
</details>
|
|
1339
|
+
|
|
1340
|
+
<details>
|
|
1341
|
+
<summary><strong>Conditional execution</strong></summary>
|
|
1342
|
+
|
|
1343
|
+
Use `when` and `unless` to avoid unnecessary side effects while keeping code clean and predictable:
|
|
935
1344
|
|
|
1345
|
+
```js
|
|
1346
|
+
hook(Users, {
|
|
1347
|
+
onUpdated: [
|
|
1348
|
+
{
|
|
1349
|
+
fields: { status: 1 },
|
|
1350
|
+
/* Only run if status actually changed */
|
|
1351
|
+
unless(after, before) {
|
|
1352
|
+
return after.status === before.status;
|
|
1353
|
+
},
|
|
1354
|
+
fn(after, before) {
|
|
1355
|
+
sendStatusChangeEmail(after);
|
|
1356
|
+
},
|
|
1357
|
+
},
|
|
1358
|
+
],
|
|
1359
|
+
});
|
|
936
1360
|
```
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
1361
|
+
|
|
1362
|
+
</details>
|
|
1363
|
+
|
|
1364
|
+
<details style="margin-bottom: 1rem">
|
|
1365
|
+
<summary><strong>Async operations</strong></summary>
|
|
1366
|
+
|
|
1367
|
+
Hooks support both synchronous and asynchronous code. Returning a promise from a before-hook will delay the write operation:
|
|
1368
|
+
|
|
1369
|
+
```js
|
|
1370
|
+
hook(Users, {
|
|
1371
|
+
beforeInsert: [
|
|
1372
|
+
{
|
|
1373
|
+
async fn(doc) {
|
|
1374
|
+
/* Wait for external service */
|
|
1375
|
+
doc.externalId = await createExternalUser(doc);
|
|
1376
|
+
},
|
|
1377
|
+
},
|
|
1378
|
+
],
|
|
1379
|
+
});
|
|
954
1380
|
```
|
|
955
1381
|
|
|
956
|
-
|
|
1382
|
+
</details>
|
|
1383
|
+
|
|
1384
|
+
# License
|
|
957
1385
|
|
|
958
1386
|
MIT
|