Skip to content

Commit c5c1fba

Browse files
authored
feat(mongodb): Add ObjectId resolvers and MongoDB option in the guide (#2847)
1 parent 9bf5222 commit c5c1fba

14 files changed

Lines changed: 423 additions & 67 deletions

File tree

docs/api/databases/mongodb.md

Lines changed: 97 additions & 46 deletions
Large diffs are not rendered by default.
515 KB
Loading
189 KB
Loading

docs/guides/basics/generator.md

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,16 +28,36 @@ Since the generated application is using modern features like ES modules, the Fe
2828

2929
First, choose if you want to use JavaScript or TypeScript. When presented with the project name, just hit enter, or enter a name (no spaces). Next, write a short description for your application. Confirm the next questions with the default selection by pressing Enter. When asked about authentication methods, let's include GitHub as well so we can look at adding a "Log In with Github" button.
3030

31+
<DatabaseBlock global-id="sql">
32+
33+
<BlockQuote type="tip">
34+
35+
If you want to use **MongoDB** instead of SQLite (or another SQL database) for this quide, select it in the **Database** dropdown in the main menu.
36+
37+
</BlockQuote>
38+
39+
</DatabaseBlock>
40+
3141
Once you confirm the last prompt, the final selection should look similar to this:
3242

43+
<DatabaseBlock global-id="sql">
44+
3345
![feathers generate app prompts](./assets/generate-app.png)
3446

35-
<BlockQuote type="warning" label="Note">
47+
<BlockQuote type="info" label="Note">
3648

3749
`SQLite` creates an SQL database in a file so we don't need to have a database server running. For any other selection, the database you choose has to be available at the connection string.
3850

3951
</BlockQuote>
4052

53+
</DatabaseBlock>
54+
55+
<DatabaseBlock global-id="mongodb">
56+
57+
![feathers generate app prompts](./assets/generate-app-mongodb.png)
58+
59+
</DatabaseBlock>
60+
4161
Sweet! We generated our first Feathers application in a new folder called `feathers-chat` so we need to go there.
4262

4363
```sh

docs/guides/basics/schemas.md

Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,8 @@ First we need to update the `src/services/users/users.schema.js` file with the s
4545

4646
</LanguageBlock>
4747

48+
<DatabaseBlock global-id="sql">
49+
4850
```ts{1,16-17,36,47-57,70-74}
4951
import crypto from 'crypto'
5052
import { resolve } from '@feathersjs/schema'
@@ -127,6 +129,90 @@ export const userQueryResolver = resolve<UserQuery, HookContext>({
127129
})
128130
```
129131

132+
</DatabaseBlock>
133+
134+
<DatabaseBlock global-id="mongodb">
135+
136+
```ts{1,16-17,36,47-57,70-74}
137+
import crypto from 'crypto'
138+
import { resolve } from '@feathersjs/schema'
139+
import { Type, getDataValidator, getValidator, querySyntax } from '@feathersjs/typebox'
140+
import type { Static } from '@feathersjs/typebox'
141+
import { passwordHash } from '@feathersjs/authentication-local'
142+
143+
import type { HookContext } from '../../declarations'
144+
import { dataValidator, queryValidator } from '../../schemas/validators'
145+
146+
// Main data model schema
147+
export const userSchema = Type.Object(
148+
{
149+
_id: Type.String(),
150+
email: Type.String(),
151+
password: Type.Optional(Type.String()),
152+
githubId: Type.Optional(Type.Number()),
153+
avatar: Type.Optional(Type.String())
154+
},
155+
{ $id: 'User', additionalProperties: false }
156+
)
157+
export type User = Static<typeof userSchema>
158+
export const userResolver = resolve<User, HookContext>({
159+
properties: {}
160+
})
161+
162+
export const userExternalResolver = resolve<User, HookContext>({
163+
properties: {
164+
// The password should never be visible externally
165+
password: async () => undefined
166+
}
167+
})
168+
169+
// Schema for the basic data model (e.g. creating new entries)
170+
export const userDataSchema = Type.Pick(userSchema, ['email', 'password', 'githubId', 'avatar'], {
171+
$id: 'UserData',
172+
additionalProperties: false
173+
})
174+
export type UserData = Static<typeof userDataSchema>
175+
export const userDataValidator = getDataValidator(userDataSchema, dataValidator)
176+
export const userDataResolver = resolve<User, HookContext>({
177+
properties: {
178+
password: passwordHash({ strategy: 'local' }),
179+
avatar: async (value, user) => {
180+
// If the user passed an avatar image, use it
181+
if (value !== undefined) {
182+
return value
183+
}
184+
185+
// Gravatar uses MD5 hashes from an email address to get the image
186+
const hash = crypto.createHash('md5').update(user.email.toLowerCase()).digest('hex')
187+
// Return the full avatar URL
188+
return `https://s.gravatar.com/avatar/${hash}?s=60`
189+
}
190+
}
191+
})
192+
193+
// Schema for allowed query properties
194+
export const userQueryProperties = Type.Pick(userSchema, ['_id', 'email', 'githubId'])
195+
export const userQuerySchema = querySyntax(userQueryProperties)
196+
export type UserQuery = Static<typeof userQuerySchema>
197+
export const userQueryValidator = getValidator(userQuerySchema, queryValidator)
198+
export const userQueryResolver = resolve<UserQuery, HookContext>({
199+
properties: {
200+
// If there is a user (e.g. with authentication), they are only allowed to see their own data
201+
_id: async (value, user, context) => {
202+
// We want to be able to get a list of all users but
203+
// only let a user modify their own data otherwise
204+
if (context.params.user && context.method !== 'find') {
205+
return context.params.user._id
206+
}
207+
208+
return value
209+
}
210+
}
211+
})
212+
```
213+
214+
</DatabaseBlock>
215+
130216
## Handling messages
131217

132218
Next we can look at the messages service schema. We want to include the date when the message was created as `createdAt` and the id of the user who sent it as `userId`. When we get a message back, we also want to populate the `user` with the user data from `userId` so that we can show e.g. the user image and email.
@@ -142,6 +228,8 @@ Update the `src/services/messages/messages.schema.js` file like this:
142228

143229
</LanguageBlock>
144230

231+
<DatabaseBlock global-id="sql">
232+
145233
```ts{7,14-16,23-26,43-49,56,66-74}
146234
import { resolve } from '@feathersjs/schema'
147235
import { Type, getDataValidator, getValidator, querySyntax } from '@feathersjs/typebox'
@@ -221,8 +309,91 @@ export const messageQueryResolver = resolve<MessageQuery, HookContext>({
221309
})
222310
```
223311

312+
</DatabaseBlock>
313+
314+
<DatabaseBlock global-id="mongodb">
315+
316+
```ts{7,14-16,23-26,43-49,56,66-74}
317+
import { resolve } from '@feathersjs/schema'
318+
import { Type, getDataValidator, getValidator, querySyntax } from '@feathersjs/typebox'
319+
import type { Static } from '@feathersjs/typebox'
320+
321+
import type { HookContext } from '../../declarations'
322+
import { dataValidator, queryValidator } from '../../schemas/validators'
323+
import { userSchema } from '../users/users.schema'
324+
325+
// Main data model schema
326+
export const messageSchema = Type.Object(
327+
{
328+
_id: Type.String(),
329+
text: Type.String(),
330+
createdAt: Type.Number(),
331+
userId: Type.String(),
332+
user: Type.Ref(userSchema)
333+
},
334+
{ $id: 'Message', additionalProperties: false }
335+
)
336+
export type Message = Static<typeof messageSchema>
337+
export const messageResolver = resolve<Message, HookContext>({
338+
properties: {
339+
user: async (_value, message, context) => {
340+
// Associate the user that sent the message
341+
return context.app.service('users').get(message.userId)
342+
}
343+
}
344+
})
345+
346+
export const messageExternalResolver = resolve<Message, HookContext>({
347+
properties: {}
348+
})
349+
350+
// Schema for creating new entries
351+
export const messageDataSchema = Type.Pick(messageSchema, ['text'], {
352+
$id: 'MessageData',
353+
additionalProperties: false
354+
})
355+
export type MessageData = Static<typeof messageDataSchema>
356+
export const messageDataValidator = getDataValidator(messageDataSchema, dataValidator)
357+
export const messageDataResolver = resolve<Message, HookContext>({
358+
properties: {
359+
userId: async (_value, _message, context) => {
360+
// Associate the record with the id of the authenticated user
361+
return context.params.user._id
362+
},
363+
createdAt: async () => {
364+
return Date.now()
365+
}
366+
}
367+
})
368+
369+
// Schema for allowed query properties
370+
export const messageQueryProperties = Type.Pick(messageSchema, ['_id', 'text', 'createdAt', 'userId'], {
371+
additionalProperties: false
372+
})
373+
export const messageQuerySchema = querySyntax(messageQueryProperties)
374+
export type MessageQuery = Static<typeof messageQuerySchema>
375+
export const messageQueryValidator = getValidator(messageQuerySchema, queryValidator)
376+
export const messageQueryResolver = resolve<MessageQuery, HookContext>({
377+
properties: {
378+
userId: async (value, user, context) => {
379+
// We want to be able to get a list of all messages but
380+
// only let a user access their own messages otherwise
381+
if (context.params.user && context.method !== 'find') {
382+
return context.params.user._id
383+
}
384+
385+
return value
386+
}
387+
}
388+
})
389+
```
390+
391+
</DatabaseBlock>
392+
224393
## Creating a migration
225394

395+
<DatabaseBlock global-id="sql">
396+
226397
Now that our schemas and resolvers have everything we need, we also have to update the database with those changes. For SQL databases this is done with migrations. Migrations are a best practise for SQL databases to roll out and undo changes to the data model. Every change we make in a schema will need its corresponding migration step.
227398

228399
<BlockQuote type="warning">
@@ -283,6 +454,18 @@ We can run the migrations on the current database with
283454
npm run migrate
284455
```
285456

457+
</DatabaseBlock>
458+
459+
<DatabaseBlock global-id="mongodb">
460+
461+
<BlockQuote type="tip">
462+
463+
For MongoDB no migrations are necessary.
464+
465+
</BlockQuote>
466+
467+
</DatabaseBlock>
468+
286469
## What's next?
287470

288471
In this chapter we learned about schemas and implemented all the things we need for our chat application. In the next chapter we will learn about [authentication](./authentication.md) and add a "Login with GitHub".

docs/guides/basics/services.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,8 +164,18 @@ npx feathers generate service
164164

165165
The name for our service is `message` (this is used for variable names etc.) and for the path we use `messages`. Anything else we can confirm with the default:
166166

167+
<DatabaseBlock global-id="sql">
168+
167169
![feathers generate service prompts](./assets/generate-service.png)
168170

171+
</DatabaseBlock>
172+
173+
<DatabaseBlock global-id="mongodb">
174+
175+
![feathers generate service prompts](./assets/generate-service-mongodb.png)
176+
177+
</DatabaseBlock>
178+
169179
This is it, we now have a database backed messages service with authentication enabled.
170180

171181
## What's next?

docs/guides/frontend/javascript.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -234,8 +234,8 @@ const showChat = async () => {
234234
}
235235
```
236236

237-
- `showLogin(error)` will either show the content of loginHTML or, if the login page is already showing, add an error message. This will happen when you try to log in with invalid credentials or sign up with a user that already exists.
238-
- `showChat()` does several things. First, we add the static chatHTML to the page. Then we get the latest 25 messages from the messages Feathers service (this is the same as the `/messages` endpoint of our chat API) using the Feathers query syntax. Since the list will come back with the newest message first, we need to reverse the data. Then we add each message by calling our `addMessage` function so that it looks like a chat app should — with old messages getting older as you scroll up. After that we get a list of all registered users to show them in the sidebar by calling addUser.
237+
- `showLogin(error)` will either show the content of loginTemplate or, if the login page is already showing, add an error message. This will happen when you try to log in with invalid credentials or sign up with a user that already exists.
238+
- `showChat()` does several things. First, we add the static chatTemplate to the page. Then we get the latest 25 messages from the messages Feathers service (this is the same as the `/messages` endpoint of our chat API) using the Feathers query syntax. Since the list will come back with the newest message first, we need to reverse the data. Then we add each message by calling our `addMessage` function so that it looks like a chat app should — with old messages getting older as you scroll up. After that we get a list of all registered users to show them in the sidebar by calling addUser.
239239

240240
## Login and signup
241241

@@ -313,7 +313,7 @@ addEventListener('#login', 'click', async () => {
313313
addEventListener('#logout', 'click', async () => {
314314
await client.logout()
315315

316-
document.getElementById('app').innerHTML = loginHTML
316+
document.getElementById('app').innerHTML = loginTemplate()
317317
})
318318

319319
// "Send" message form submission handler

packages/cli/src/connection/templates/mongodb.tpl.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ declare module './declarations' {
1414
1515
export const mongodb = (app: Application) => {
1616
const connection = app.get('mongodb') as string
17-
const database = new URL("mongodb://localhost").pathname.substring(1)
17+
const database = new URL(connection).pathname.substring(1)
1818
const mongoClient = MongoClient.connect(connection)
1919
.then(client => client.db(database))
2020

packages/cli/test/generators.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ describe('@feathersjs/cli', () => {
3838

3939
before(async () => {
4040
cwd = await mkdtemp(path.join(os.tmpdir(), name + '-'))
41-
console.log(cwd)
41+
console.log(`\nGenerating test application to\n${cwd}\n\n`)
4242
context = await generateApp(
4343
getContext<AppGeneratorContext>(
4444
{

packages/mongodb/src/adapter.ts

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ export class MongoDbAdapter<
6969
return id
7070
}
7171

72-
filterQuery(id: NullableId, params: P) {
72+
filterQuery(id: NullableId | ObjectId, params: P) {
7373
const { $select, $sort, $limit, $skip, ...query } = (params.query || {}) as AdapterQuery
7474

7575
if (id !== null) {
@@ -164,11 +164,11 @@ export class MongoDbAdapter<
164164
return select
165165
}
166166

167-
async $findOrGet(id: NullableId, params: P) {
167+
async $findOrGet(id: NullableId | ObjectId, params: P) {
168168
return id === null ? await this.$find(params) : await this.$get(id, params)
169169
}
170170

171-
normalizeId(id: NullableId, data: Partial<D>): Partial<D> {
171+
normalizeId(id: NullableId | ObjectId, data: Partial<D>): Partial<D> {
172172
if (this.id === '_id') {
173173
// Default Mongo IDs cannot be updated. The Mongo library handles
174174
// this automatically.
@@ -184,7 +184,7 @@ export class MongoDbAdapter<
184184
return data
185185
}
186186

187-
async $get(id: Id, params: P = {} as P): Promise<T> {
187+
async $get(id: Id | ObjectId, params: P = {} as P): Promise<T> {
188188
const {
189189
query,
190190
filters: { $select }
@@ -286,8 +286,9 @@ export class MongoDbAdapter<
286286

287287
async $patch(id: null, data: Partial<D>, params?: P): Promise<T[]>
288288
async $patch(id: Id, data: Partial<D>, params?: P): Promise<T>
289+
async $patch(id: ObjectId, data: Partial<D>, params?: P): Promise<T>
289290
async $patch(id: NullableId, data: Partial<D>, _params?: P): Promise<T | T[]>
290-
async $patch(id: NullableId, _data: Partial<D>, params: P = {} as P): Promise<T | T[]> {
291+
async $patch(id: NullableId | ObjectId, _data: Partial<D>, params: P = {} as P): Promise<T | T[]> {
291292
const data = this.normalizeId(id, _data)
292293
const model = await this.getModel(params)
293294
const {
@@ -333,7 +334,7 @@ export class MongoDbAdapter<
333334
return this.$findOrGet(id, findParams).catch(errorHandler)
334335
}
335336

336-
async $update(id: Id, data: D, params: P = {} as P): Promise<T> {
337+
async $update(id: Id | ObjectId, data: D, params: P = {} as P): Promise<T> {
337338
const model = await this.getModel(params)
338339
const { query } = this.filterQuery(id, params)
339340
const replaceOptions = { ...params.mongodb }
@@ -345,8 +346,9 @@ export class MongoDbAdapter<
345346

346347
async $remove(id: null, params?: P): Promise<T[]>
347348
async $remove(id: Id, params?: P): Promise<T>
349+
async $remove(id: ObjectId, params?: P): Promise<T>
348350
async $remove(id: NullableId, _params?: P): Promise<T | T[]>
349-
async $remove(id: NullableId, params: P = {} as P): Promise<T | T[]> {
351+
async $remove(id: NullableId | ObjectId, params: P = {} as P): Promise<T | T[]> {
350352
const model = await this.getModel(params)
351353
const {
352354
query,

0 commit comments

Comments
 (0)