├── .gitignore ├── LICENSE ├── README.md ├── jest.config.js ├── package-lock.json ├── package.json ├── src ├── index.ts └── repository.ts ├── test ├── _mongo.ts ├── basic.spec.ts ├── ignore.spec.ts ├── nested.spec.ts ├── referenced.spec.ts └── tsconfig.json └── tsconfig.json /.gitignore: -------------------------------------------------------------------------------- 1 | lib/ 2 | node_modules/ 3 | .vscode/ 4 | .idea/ 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Aljaz Mur Erzen 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # mongodb-typescript 2 | 3 | > Hydrate MongoDB documents into TypeScript-defined objects 4 | 5 | - [Motivation](#motivation) 6 | - [Install](#install) 7 | - [Quick start](#quick-start) 8 | - [Reference](#reference) 9 | 10 | ## Motivation 11 | 12 | When using MongoDB with TypeScript we usually want to save our "strongly-typed" entities into database collection and then 13 | retrieve them back at some later time. During this we face three major difficulties: 14 | 1. **objects returned by `mongodb` driver are plain objects**. This means that if we have saved an object with some functions, these functions will not be saved and will not be present on the retrieved document. If we were to assign all properties of received object to a properly TypeScript-typed object, we would have to do this recursively, since some properties can also be typed objects and have own functions. 15 | 2. **there is not easy way to reference other collections**. In a noSQL database relations should be avoided, but we all know this is not always a viable option. In such case we define a field with id referencing some other collection and then make separate request to retrieve referenced entity and append it to referencing entity. This is tedious and not easy to explain well to TypeScript's static typing. 16 | 3. **class definitions should reflect database schema**. In particular: we want to use a property decorator to define database indexes 17 | 18 | This package strives to facilitate at these points by wrapping official `mongodb` package. It utilizes `class-transformer` package to hydrate and de-hydrate plain object into classed objects and vice-versa. 19 | 20 | It may seem that it is a TypeScript equivalent to `mongoose` package, but this is not the case, since it does not provide any validation, stripping of non-defined properties or middleware. 21 | 22 | This package is trying to be as non-restrictive as possible and to let the developer access underlying `mongodb` functions and mechanism (such as cursors) while still providing hydration, population and schema reflection. 23 | 24 | ## Install 25 | 26 | ``` 27 | $ npm install mongodb-typescript 28 | ``` 29 | 30 | Make sure to enable `emitDecoratorMetadata` and `experimentalDecorators` in [tsconfig.json](https://www.typescriptlang.org/docs/handbook/tsconfig-json.html) 31 | 32 | ## Quick start 33 | 34 | ```typescript 35 | import { id, Repository } from 'mongodb-typescript'; 36 | 37 | // define your entity 38 | class User { 39 | @id id: ObjectId; 40 | 41 | name: string; 42 | 43 | age: number = 15; 44 | 45 | hello() { 46 | return `Hello, my name is ${this.name} and I am ${this.age} years old`; 47 | } 48 | } 49 | 50 | 51 | const repository = new Repository(User, mongodbClient); 52 | 53 | // create new user entity (MongoDB document) 54 | const user = new User(); 55 | user.name = 'tom'; 56 | 57 | await userRepo.insert(user); 58 | 59 | // prints "User { id: 5ba2648a6f74af5def444491, name: 'tom', age: 15 }" 60 | console.log(user); 61 | 62 | // now let's retrieve entity from database 63 | const saved = await userRepo.findById(user.id); 64 | 65 | // prints `Hello, my name is tom and I am 15 years old` 66 | console.log(saved.hello()); 67 | ``` 68 | 69 | ## Reference 70 | 71 | - [Entity definition](#entity-definition) 72 | - [@id](#id) 73 | - [@objectId](#objectId) 74 | - [@nested](#nested) 75 | - [@ignore](#ignore) 76 | - [@ref](#ref) 77 | - [@index](#index) 78 | - [@indexes](#indexes) 79 | - [Repository](#Repository) 80 | - [constructor](#constructor) 81 | - [c](#c) 82 | - [count](#count) 83 | - [createIndexes](#createIndexes) 84 | - [insert](#insert) 85 | - [update](#update) 86 | - [save](#save) 87 | - [findOne](#findOne) 88 | - [findById](#findById) 89 | - [findManyById](#findManyById) 90 | - [find](#find) 91 | - [populate](#populate) 92 | - [populateMany](#populateMany) 93 | - [hydrate](#hydrate) 94 | - [dehydrate](#dehydrate) 95 | 96 | ### Entity definition 97 | 98 | #### @id 99 | 100 | Required. Defines primary id that will be used as `_id` of the mongo collection. 101 | 102 | ```ts 103 | class Post { 104 | @id myId: ObjectId; 105 | } 106 | ``` 107 | 108 | #### @objectId 109 | 110 | All properties except ones decorated with `@id` that are of type ObjectId (from `bson` package) must have `@objectId` because underlying package `class-transformer` does not handle it correctly. 111 | 112 | ```ts 113 | class Post { 114 | ... 115 | 116 | @objectId authorId: ObjectId; 117 | } 118 | ``` 119 | 120 | #### @nested 121 | 122 | Used to mark nested entity or array of entities. 123 | 124 | | Parameter | | 125 | | ------------ | -------------------------------------------- | 126 | | typeFunction | Function that returns type of nested entity | 127 | 128 | 129 | Example usage: 130 | 131 | ```ts 132 | class Texts { 133 | main: string; 134 | doc: string; 135 | } 136 | 137 | class Comment { 138 | text: string; 139 | } 140 | 141 | class Post { 142 | @id id: ObjectId; 143 | title: string; 144 | 145 | @nested(() => Texts) text: Texts; 146 | @nested(() => Comment) comments: Comment[] 147 | } 148 | ``` 149 | 150 | This would represent following mongo document: 151 | ```ts 152 | { 153 | "_id": ObjectId("5b27c8da65ec1b5c0c0e8ed4"), 154 | "title": "My new post", 155 | "timestamps": { 156 | "postedAt": ISODate("2018-09-15T10:50:38.718Z"), 157 | "lastUpdateAt": ISODate("2018-09-15T10:50:38.718Z"), 158 | }, 159 | "comments": [ 160 | { "text": "This is good." }, 161 | { "text": "This is bad." } 162 | ] 163 | } 164 | ``` 165 | 166 | #### @ignore 167 | 168 | Used to mark a property as ignored so it will not ba saved in the database. 169 | 170 | Example usage: 171 | ```ts 172 | class User { 173 | @id id: ObjectId; 174 | name: string; 175 | @ignore onlyImportantAtRuntime: number; 176 | } 177 | ``` 178 | 179 | This would represent following mongo document: 180 | ```ts 181 | // user 182 | { 183 | "_id": ObjectId("5b27d15bfab97f681aac2862"), 184 | "name": "gregory" 185 | } 186 | ``` 187 | 188 | #### @ref 189 | 190 | Used to define an entity or array of entities that will not be saved into another collection and only have a key or array of keys saved on referencing entity's collection. 191 | 192 | This key will be saved in a field named `{@ref field name}Id` or `{@ref field name}Ids`. 193 | 194 | To access this key directly or apply a custom name you can pass a parameter with name of your key field. See example below. 195 | 196 | | Parameter | | 197 | | ------------ | ----------------------------------------------------- | 198 | | refId | Optional. Name of field should hold referencing key | 199 | 200 | 201 | Example usage: 202 | 203 | ```ts 204 | class User { 205 | @id id: ObjectId; 206 | name: string; 207 | } 208 | 209 | class Post { 210 | @id id: ObjectId; 211 | title: string; 212 | 213 | @ref() author: User; 214 | } 215 | ``` 216 | 217 | This would represent following mongo documents: 218 | ```ts 219 | // post 220 | { 221 | "_id": ObjectId("5b27c8da65ec1b5c0c0e8ed4"), 222 | "title": "My new post", 223 | "authorId": ObjectId("5b27d15bfab97f681aac2862") 224 | } 225 | 226 | // user 227 | { 228 | "_id": ObjectId("5b27d15bfab97f681aac2862"), 229 | "name": "gregory" 230 | } 231 | ``` 232 | 233 | Custom referencing key: 234 | ```ts 235 | class Post { 236 | ... 237 | 238 | @objectId author_key: ObjectId; 239 | @ref('author_key') author: User; 240 | } 241 | ``` 242 | 243 | #### @index 244 | 245 | Used to define an index on a field. 246 | 247 | *does not actually create the index. Use Repository.createIndexes to do so.* 248 | 249 | Parameters: 250 | 251 | | parameter | | 252 | | ------------ | ---------------------------------------------------------------- | 253 | | type | Type of index. Use 1 or -1 for ascending or descending order, respectively. Use string value for other index types (eg. '2dsphere' for geo spacial index). Defaults to 1 | 254 | | options | Optional. SimpleIndexOptions. See table below, SimpleIndexOptions interface or [mongodb docs](http://docs.mongodb.org/manual/reference/command/createIndexes/) | 255 | 256 | 257 | SimpleIndexOptions: 258 | 259 | | field | type | | 260 | | ----------------------- | -------- | --------------------------------------------------------------- | 261 | | name | string | Name of the index. Defaults to field name. | 262 | | background | boolean | | 263 | | unique | boolean | | 264 | | partialFilterExpression | document | | 265 | | sparse | boolean | | 266 | | expireAfterSeconds | number | | 267 | | storageEngine | document | | 268 | | weights | document | | 269 | | default_language | string | | 270 | | language_override | string | | 271 | | textIndexVersion | number | | 272 | | 2dsphereIndexVersion | number | | 273 | | bits | number | | 274 | | min | number | | 275 | | max | number | | 276 | | bucketSize | number | | 277 | | collation | Object | | 278 | 279 | 280 | Example usage: 281 | 282 | ```ts 283 | class User { 284 | ... 285 | @index(1, { unique: true, sparse: true, name: 'email_unique_index' }) email: string; 286 | 287 | @index() someId: number; 288 | } 289 | ``` 290 | 291 | #### @indexes 292 | 293 | Used to define an indexes on a entity (most likely compound). 294 | 295 | *does not actually create the index. Use Repository.createIndexes to do so.* 296 | 297 | Parameters: 298 | 299 | | parameter | type | 300 | | --- | --- | 301 | | indexes | IndexOptions[] | 302 | 303 | 304 | IndexOptions: 305 | 306 | | field | type | | 307 | | --- | --- | --- | 308 | | name | string | Required. Name of the index | 309 | | key | document | A document that contains the field and value pairs where the field is the index key and the value describes the type of index for that field. For an ascending index on a field, specify a value of 1; for descending index, specify a value of -1. See [mongodb documentation](https://docs.mongodb.com/manual/indexes/#index-types) | 310 | | ... | | All properties of `SimpleIndexOptions` | 311 | 312 | 313 | 314 | ### Repository 315 | 316 | Reference to `mongodb` collection that handles hydration and de-hydration of documents into entities and vice-versa. 317 | 318 | Repository is a generic class that requires type parameter T should be type of entity that is stored in referenced collection. 319 | 320 | Different repositories may reference collections in different databases at different hosts. 321 | 322 | #### constructor 323 | 324 | | parameter | type | | 325 | | --- | --- | --- | 326 | | entity | type | type of stored entities. Must equal T | 327 | | mongoClient | MongoClient | mongo client to use for all requests | 328 | | collection | string | name of collection to reference | 329 | 330 | #### c 331 | 332 | `mongodb` collection used to make all the requests to the database. 333 | Can be used to access all features of mongodb, but returns non-hydrated (plain) objects. 334 | 335 | #### count 336 | 337 | *TODO* 338 | 339 | #### createIndexes 340 | 341 | *TODO* 342 | 343 | #### insert 344 | 345 | *TODO* 346 | 347 | #### update 348 | 349 | | parameter | type | | 350 | | --- | --- | --- | 351 | | entity | Object | Entity to update, must be of type T | 352 | | options | ReplaceOneOptions | Options to pass to the underlying replaceOne call | 353 | 354 | ```ts 355 | await userRepo.update(post); 356 | await userRepo.update(post, { upsert: true }); 357 | ``` 358 | 359 | #### save 360 | 361 | *TODO* 362 | 363 | #### findOne 364 | 365 | *TODO* 366 | 367 | #### findById 368 | 369 | *TODO* 370 | 371 | #### findManyById 372 | 373 | *TODO* 374 | 375 | #### find 376 | 377 | *TODO* 378 | 379 | #### populate 380 | 381 | *TODO* 382 | 383 | ```ts 384 | await userRepo.populate(post, 'author'); 385 | ``` 386 | 387 | #### populateMany 388 | 389 | *TODO* 390 | 391 | #### hydrate 392 | 393 | Converts a plain object from database into typed entity with functions, typed nested entities and correctly named _id field. 394 | 395 | Use this function when fetching documents via vanilla `mongodb` collection. 396 | 397 | #### dehydrate 398 | 399 | Returns plain object that can be saved to database. 400 | It handles custom _id names and dereferences objects (removes referenced objects and sets referencing keys). 401 | 402 | > This is a standalone function and does not require associated repository. 403 | 404 | 405 | *Inspired by [Typegoose](https://www.npmjs.com/package/typegoose) and [TypeORM](http://typeorm.io)* 406 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | // For a detailed explanation regarding each configuration property, visit: 2 | // https://jestjs.io/docs/en/configuration.html 3 | 4 | module.exports = { 5 | clearMocks: true, 6 | coverageDirectory: 'coverage', 7 | globals: { 8 | 'ts-jest': { 9 | tsconfig: 'test/tsconfig.json', 10 | }, 11 | }, 12 | moduleFileExtensions: [ 13 | 'js', 14 | 'ts', 15 | 'tsx', 16 | ], 17 | testEnvironment: 'node', 18 | testMatch: [ 19 | '**/test/*.spec.+(ts|tsx|js)', 20 | ], 21 | preset: 'ts-jest', 22 | } 23 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "mongodb-typescript", 3 | "version": "3.0.0", 4 | "description": "Hydrate MongoDB documents into TypeScript-defined objects", 5 | "keywords": [ 6 | "mongodb", 7 | "typescript", 8 | "nosql" 9 | ], 10 | "main": "lib/index.js", 11 | "files": [ 12 | "lib" 13 | ], 14 | "scripts": { 15 | "build": "rm -rf lib && tsc", 16 | "test": "jest --runInBand", 17 | "ts-jest": "ts-jest" 18 | }, 19 | "author": "aljazerzen", 20 | "license": "ISC", 21 | "repository": "https://github.com/aljazerzen/mongodb-typescript.git", 22 | "dependencies": { 23 | "class-transformer": "^0.5.1" 24 | }, 25 | "peerDependencies": { 26 | "mongodb": "^4.8.0", 27 | "reflect-metadata": "^0.1.13" 28 | }, 29 | "devDependencies": { 30 | "@types/jest": "^28.1.6", 31 | "@types/mongodb": "^4.0.7", 32 | "@types/node": "^18.0.6", 33 | "jest": "^28.1.3", 34 | "ts-jest": "^28.0.7", 35 | "typescript": "^4.7.4" 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import 'reflect-metadata'; 2 | 3 | import { Expose, Transform, Type, TypeHelpOptions } from 'class-transformer'; 4 | import { CollationOptions, Document, IndexDescription, ObjectId} from 'mongodb'; 5 | 6 | import { ClassType } from './repository'; 7 | 8 | export * from './repository'; 9 | 10 | export type TypeFunction = (type?: TypeHelpOptions) => ClassType; 11 | 12 | /** 13 | * Options passed to mongodb.createIndexes 14 | * http://mongodb.github.io/node-mongodb-native/3.1/api/Collection.html#createIndexes and http://docs.mongodb.org/manual/reference/command/createIndexes/ 15 | */ 16 | export interface IndexOptions extends SimpleIndexOptions { 17 | key: { [key in keyof T]?: number | string }; 18 | name: string; 19 | } 20 | 21 | /** 22 | * This must be identical (with a few stricter fields) to IndexSpecification from mongodb, but without 'key' field. 23 | * It would be great it we could just extend that interface but without that field. 24 | * 25 | * Options passed to mongodb.createIndexes 26 | * http://mongodb.github.io/node-mongodb-native/3.1/api/Collection.html#createIndexes and http://docs.mongodb.org/manual/reference/command/createIndexes/ 27 | */ 28 | export interface SimpleIndexOptions { 29 | name?: string; 30 | background?: boolean; 31 | unique?: boolean; 32 | 33 | // stricter 34 | partialFilterExpression?: Document; 35 | 36 | sparse?: boolean; 37 | expireAfterSeconds?: number; 38 | storageEngine?: object; 39 | 40 | // stricter 41 | weights?: { [key in keyof T]?: number }; 42 | default_language?: string; 43 | language_override?: string; 44 | textIndexVersion?: number; 45 | '2dsphereIndexVersion'?: number; 46 | bits?: number; 47 | min?: number; 48 | max?: number; 49 | bucketSize?: number; 50 | collation?: CollationOptions; 51 | } 52 | 53 | function isNotPrimitive(targetType: ClassType, propertyKey: string) { 54 | if (targetType === ObjectId || targetType === String || targetType === Number || targetType === Boolean) { 55 | throw new Error(`property '${propertyKey}' cannot have nested type '${targetType}'`); 56 | } 57 | } 58 | 59 | function addRef(name: string, ref: Ref, target: any) { 60 | const refs = Reflect.getMetadata('mongo:refs', target) || {}; 61 | refs[name] = ref; 62 | Reflect.defineMetadata('mongo:refs', refs, target); 63 | } 64 | 65 | function pushToMetadata(metadataKey: string, values: any[], target: any) { 66 | const data: any[] = Reflect.getMetadata(metadataKey, target) || []; 67 | Reflect.defineMetadata(metadataKey, data.concat(values), target); 68 | } 69 | 70 | export function objectId(target: any, propertyKey: string) { 71 | const targetType = Reflect.getMetadata('design:type', target, propertyKey); 72 | 73 | if (targetType === ObjectId) { 74 | Type(() => String)(target, propertyKey); 75 | Transform(val => new ObjectId(val.value))(target, propertyKey); 76 | } else if (targetType === Array) { 77 | Type(() => String)(target, propertyKey); 78 | Transform(val => val.value.map((v: any) => new ObjectId(v)))(target, propertyKey); 79 | } else { 80 | throw Error('@objectId can only be used on properties of type ObjectId or ObjectId[]'); 81 | } 82 | } 83 | 84 | export function id(target: any, propertyKey: string) { 85 | const targetType = Reflect.getMetadata('design:type', target, propertyKey); 86 | Reflect.defineMetadata('mongo:id', propertyKey, target); 87 | 88 | Expose({ name: '_id' })(target, propertyKey); 89 | 90 | if (targetType === ObjectId) { 91 | Type(() => String)(target, propertyKey); 92 | objectId(target, propertyKey); 93 | } 94 | } 95 | 96 | export function nested(typeFunction: TypeFunction) { 97 | return function (target: any, propertyKey: string) { 98 | const targetType = Reflect.getMetadata('design:type', target, propertyKey); 99 | isNotPrimitive(targetType, propertyKey); 100 | 101 | Type(typeFunction)(target, propertyKey); 102 | 103 | pushToMetadata('mongo:nested', [{ name: propertyKey, typeFunction, array: targetType === Array } as Nested], target); 104 | } 105 | } 106 | 107 | export function ignore(target: any, propertyKey: any) { 108 | const ignores = Reflect.getMetadata('mongo:ignore', target) || {}; 109 | ignores[propertyKey] = true; 110 | Reflect.defineMetadata('mongo:ignore', ignores, target); 111 | } 112 | 113 | export function ref(refId?: string) { 114 | return function (target: any, propertyKey: string) { 115 | const targetType = Reflect.getMetadata('design:type', target, propertyKey); 116 | isNotPrimitive(targetType, propertyKey); 117 | 118 | const array = targetType === Array; 119 | 120 | if (!refId) { 121 | refId = propertyKey + (array ? 'Ids' : 'Id'); 122 | Reflect.defineMetadata('design:type', (array ? Array : ObjectId), target, refId); 123 | objectId(target, refId); 124 | } 125 | 126 | addRef(propertyKey, { id: refId, array }, target); 127 | }; 128 | } 129 | 130 | export function index(type: number | string = 1, options: SimpleIndexOptions = {}) { 131 | return function (target: any, propertyKey: string) { 132 | if (!propertyKey) { 133 | throw new Error('@index decorator can only be applied to class properties'); 134 | } 135 | 136 | const indexOptions: IndexDescription = { 137 | name: propertyKey, 138 | ...options, 139 | key: { [propertyKey]: type } as any, 140 | }; 141 | pushToMetadata('mongo:indexes', [indexOptions], target); 142 | } 143 | } 144 | 145 | export function indexes(options: IndexOptions[]) { 146 | return function (target: any) { 147 | pushToMetadata('mongo:indexes', options, target.prototype); 148 | } 149 | } 150 | 151 | export interface Nested { 152 | name: string; 153 | array: boolean; 154 | } 155 | 156 | export interface Ref { 157 | id: string; 158 | array: boolean; 159 | } 160 | -------------------------------------------------------------------------------- /src/repository.ts: -------------------------------------------------------------------------------- 1 | import { plainToClass } from 'class-transformer'; 2 | import { 3 | Collection as MongoCollection, 4 | Filter, 5 | FindOneAndUpdateOptions, 6 | FindOneAndDeleteOptions, 7 | UpdateFilter, 8 | IndexDescription, 9 | FindCursor, 10 | MongoClient, 11 | ReplaceOptions, 12 | ObjectId, 13 | WithId, 14 | } from 'mongodb'; 15 | 16 | import { Ref } from '.'; 17 | 18 | export declare type ClassType = { 19 | new(...args: any[]): T; 20 | }; 21 | 22 | 23 | export function dehydrate(entity: T, idField?: string): Object { 24 | // const plain = classToPlain(entity) as any; 25 | if (!entity) 26 | return entity; 27 | 28 | const refs = Reflect.getMetadata('mongo:refs', entity) || {}; 29 | 30 | for (let name in refs) { 31 | const ref: Ref = refs[name]; 32 | const reffedEntity = (entity as any)[name]; 33 | if (reffedEntity) { 34 | if (!ref.array) { 35 | const idField = Reflect.getMetadata('mongo:id', reffedEntity); 36 | (entity as any)[ref.id] = reffedEntity[idField]; 37 | } else { 38 | (entity as any)[ref.id] = reffedEntity.map((e: any) => e[Reflect.getMetadata('mongo:id', e)]); 39 | } 40 | } 41 | } 42 | const plain: any = Object.assign({}, entity); 43 | 44 | if (idField && idField !== '_id') { 45 | plain._id = plain[idField]; 46 | delete plain[idField]; 47 | } 48 | 49 | for (let name in refs) { 50 | delete plain[name]; 51 | } 52 | 53 | const nested = Reflect.getMetadata('mongo:nested', entity) || []; 54 | for (let { name, array } of nested) { 55 | if (plain[name]) { 56 | if (!array) { 57 | plain[name] = dehydrate(plain[name]); 58 | } else { 59 | plain[name] = plain[name].map((e: any) => dehydrate(e)); 60 | } 61 | } 62 | } 63 | 64 | const ignores = Reflect.getMetadata('mongo:ignore', entity) || {}; 65 | for (const name in ignores) { 66 | delete plain[name]; 67 | } 68 | 69 | return plain; 70 | } 71 | 72 | export interface RepositoryOptions { 73 | /** 74 | * create indexes when creating repository. Will force `background` flag and not block other database operations. 75 | */ 76 | autoIndex?: boolean; 77 | 78 | /** 79 | * database name passed to MongoClient.db 80 | * 81 | * overrides database name in connection string 82 | */ 83 | databaseName?: string; 84 | } 85 | 86 | export class Repository { 87 | 88 | protected readonly collection: MongoCollection; 89 | 90 | /** 91 | * Underlying mongodb collection (use with caution) 92 | * any of methods from this will not return hydrated objects 93 | */ 94 | get c(): MongoCollection { 95 | return this.collection; 96 | } 97 | 98 | private readonly idField: string; 99 | 100 | constructor(protected Type: ClassType, mongo: MongoClient, collection: string, options: RepositoryOptions = {}) { 101 | this.collection = mongo.db(options.databaseName).collection(collection); 102 | this.idField = Reflect.getMetadata('mongo:id', this.Type.prototype); 103 | if (!this.idField) 104 | throw new Error(`repository cannot be created for entity '${Type.name}' because none of its properties has @id decorator'`); 105 | 106 | if (options.autoIndex) 107 | this.createIndexes(true); 108 | } 109 | 110 | async createIndexes(forceBackground: boolean = false) { 111 | const indexes: IndexDescription[] = Reflect.getMetadata('mongo:indexes', this.Type.prototype) || []; 112 | 113 | if (indexes.length == 0) 114 | return null; 115 | 116 | if (forceBackground) { 117 | for (let index of indexes) { 118 | index.background = true; 119 | } 120 | } 121 | 122 | return this.collection.createIndexes(indexes); 123 | } 124 | 125 | async insert(entity: T) { 126 | const plain = dehydrate(entity, this.idField); 127 | const res = await this.collection.insertOne(plain); 128 | (entity as any)[this.idField] = res.insertedId; 129 | } 130 | 131 | async update(entity: T, options: ReplaceOptions = {}) { 132 | const plain = dehydrate(entity, this.idField); 133 | await this.collection.replaceOne({ _id: (entity as any)[this.idField] }, plain, options); 134 | } 135 | 136 | async save(entity: T) { 137 | if (!(entity as any)[this.idField]) 138 | await this.insert(entity); 139 | else 140 | await this.update(entity); 141 | } 142 | 143 | async findOne(filter: Filter = {}): Promise { 144 | const result = await this.collection.findOne(filter); 145 | return this.hydrate(result); 146 | } 147 | 148 | async findById(id: ObjectId): Promise { 149 | return this.findOne({ _id: id }); 150 | } 151 | 152 | async findManyById(ids: ObjectId[]): Promise { 153 | return this.find({ _id: { $in: ids } }).toArray(); 154 | } 155 | 156 | async findOneAndUpdate(filter: Filter = {}, update: UpdateFilter, options: FindOneAndUpdateOptions = {}): Promise { 157 | const result = await this.collection.findOneAndUpdate(filter,update, options ); 158 | return this.hydrate(result.value) 159 | } 160 | 161 | async findOneAndDelete(filter: Filter = {}, options: FindOneAndDeleteOptions = {}): Promise { 162 | const result = await this.collection.findOneAndDelete(filter, options ); 163 | return this.hydrate(result.value) 164 | } 165 | 166 | async remove(entity: T): Promise { 167 | await this.c.deleteOne({ _id: (entity as any)[this.idField] }); 168 | } 169 | 170 | /** 171 | * calls mongodb.find function and returns its cursor with attached map function that hydrates results 172 | * mongodb.find: http://mongodb.github.io/node-mongodb-native/3.1/api/Collection.html#find 173 | */ 174 | find(filter: Filter): FindCursor { 175 | return this.collection.find(filter).map(doc => this.hydrate(doc) as T); 176 | } 177 | 178 | async populate(entity: S, refName: string) { 179 | const refs = Reflect.getMetadata('mongo:refs', entity) || {}; 180 | const ref: Ref = refs[refName]; 181 | 182 | if (!ref) 183 | throw new Error(`cannot find ref '${refName}' on '${entity.constructor.name}'`); 184 | // if (ref.typeFunction().prototype !== this.Type.prototype) 185 | // throw new Error(`incompatible repository: expected ${ref.typeFunction().name}, got ${this.Type.name}`); 186 | 187 | if (!ref.array) { 188 | (entity as any)[refName] = await this.findById((entity as any)[ref.id] as ObjectId); 189 | } else { 190 | (entity as any)[refName] = await this.findManyById((entity as any)[ref.id] as ObjectId[]); 191 | } 192 | } 193 | 194 | async populateMany(entities: S[], refName: string) { 195 | if (entities.length === 0) 196 | return; 197 | const refs = Reflect.getMetadata('mongo:refs', entities[0]) || {}; 198 | const ref: Ref = refs[refName]; 199 | 200 | // if (ref.typeFunction().prototype !== this.Type.prototype) 201 | // throw new Error(`incompatible repository: expected ${ref.typeFunction().name}, got ${this.Type.name}`); 202 | 203 | const referenced = await this.findManyById(entities.map((entity: any) => entity[ref.id] as ObjectId)); 204 | for (let entity of entities) { 205 | (entity as any)[refName] = referenced.find(r => (r as any)[this.idField].equals((entity as any)[ref.id])); 206 | } 207 | } 208 | 209 | /** 210 | * Gets the number of documents matching the filter. 211 | * http://mongodb.github.io/node-mongodb-native/3.1/api/Collection.html#estimatedDocumentCount 212 | * http://mongodb.github.io/node-mongodb-native/3.1/api/Collection.html#countDocuments 213 | * @param filter whether estimatedDocumentCount or countDocuments will be called. 214 | * @returns integer 215 | */ 216 | async count(filter?: Filter>) { 217 | if(filter){ 218 | return this.collection.countDocuments(filter); 219 | } 220 | 221 | return this.collection.countDocuments(); 222 | } 223 | 224 | hydrate(plain: Object | null) { 225 | return plain ? plainToClass(this.Type, plain) : null; 226 | } 227 | } 228 | -------------------------------------------------------------------------------- /test/_mongo.ts: -------------------------------------------------------------------------------- 1 | import * as mongodb from 'mongodb'; 2 | 3 | export async function connect() { 4 | const url = process.env.MONGO_URL || 'mongodb://localhost:27017/mongodb-typescript'; 5 | return new mongodb.MongoClient(url).connect(); 6 | } 7 | 8 | export async function clean(mongo: mongodb.MongoClient, databaseName?: string) { 9 | await mongo.db(databaseName).dropDatabase(); 10 | } 11 | 12 | export async function close(mongo: mongodb.MongoClient) { 13 | await mongo.close(); 14 | } 15 | -------------------------------------------------------------------------------- /test/basic.spec.ts: -------------------------------------------------------------------------------- 1 | import { MongoClient, ObjectId } from 'mongodb'; 2 | 3 | import { id, index, indexes, objectId, Repository } from '../src'; 4 | import { clean, close, connect } from './_mongo'; 5 | 6 | let client: MongoClient; 7 | 8 | beforeAll(async () => { 9 | client = await connect(); 10 | }); 11 | 12 | describe('basic', () => { 13 | 14 | class User { 15 | @id id: ObjectId; 16 | name: string; 17 | age: number; 18 | @objectId someIds: ObjectId[]; 19 | 20 | hello() { 21 | return `Hello, my name is ${this.name} and I am ${this.age} years old`; 22 | } 23 | } 24 | 25 | class UserRepo extends Repository { 26 | findAllByName(name: string) { 27 | return this.find({ name }).toArray(); 28 | } 29 | } 30 | 31 | let userRepo: UserRepo; 32 | 33 | beforeAll(async () => { 34 | await clean(client); 35 | userRepo = new UserRepo(User, client, 'users'); 36 | }); 37 | 38 | test('insert and findOne', async () => { 39 | const user = new User(); 40 | user.name = 'tom'; 41 | user.age = 15; 42 | await userRepo.insert(user); 43 | 44 | const saved = await userRepo.findById(user.id); 45 | 46 | expect(saved).toHaveProperty('name', 'tom'); 47 | expect(saved).toHaveProperty('id'); 48 | }); 49 | 50 | test('update', async () => { 51 | const user = await userRepo.findOne(); 52 | 53 | expect(user).not.toBeNull(); 54 | user.age = Math.floor(Math.random() * 30); 55 | 56 | await userRepo.update(user); 57 | 58 | const saved = await userRepo.findById(user.id); 59 | expect(saved).toHaveProperty('age', user.age); 60 | }); 61 | 62 | test('save', async () => { 63 | const user = new User(); 64 | user.name = 'ben'; 65 | user.age = 15; 66 | await userRepo.save(user); 67 | 68 | expect(user.id).not.toBeUndefined(); 69 | const initialUserId = user.id; 70 | 71 | user.age = Math.floor(Math.random() * 30); 72 | await userRepo.save(user); 73 | 74 | const saved = await userRepo.findById(user.id); 75 | expect(saved).toHaveProperty('id', initialUserId); 76 | }); 77 | 78 | test('proper hydration', async () => { 79 | 80 | const saved = await userRepo.findOne(); 81 | 82 | expect(saved).toHaveProperty('hello'); 83 | expect(saved.hello()).toContain('Hello, my name is '); 84 | expect(saved.id).toBeInstanceOf(ObjectId); 85 | }); 86 | 87 | test('custom repository function', async () => { 88 | const user = new User(); 89 | user.name = 'tom'; 90 | user.age = 22; 91 | await userRepo.c.insertOne(user); 92 | 93 | const users = await userRepo.findAllByName('tom'); 94 | 95 | expect(users).toHaveLength(2); 96 | users.forEach(user => expect(user).toHaveProperty('name', 'tom')); 97 | }); 98 | 99 | test('count', async () => { 100 | const count = await userRepo.count(); 101 | 102 | const user = new User(); 103 | user.name = 'tina'; 104 | user.age = 21; 105 | await userRepo.save(user); 106 | 107 | const newCount = await userRepo.count(); 108 | expect(newCount).toBe(count + 1); 109 | }); 110 | 111 | test('array of ObjectIds', async () => { 112 | const user = new User(); 113 | user.name = 'perry'; 114 | user.age = 21; 115 | user.someIds = [new ObjectId(), new ObjectId()]; 116 | 117 | await userRepo.save(user); 118 | 119 | const saved = await userRepo.findById(user.id); 120 | expect(saved.someIds).toHaveLength(2); 121 | expect(saved.someIds).toContainEqual(user.someIds[0]); 122 | expect(saved.someIds).toContainEqual(user.someIds[1]); 123 | }); 124 | 125 | test('remove', async () => { 126 | const count = await userRepo.count(); 127 | 128 | const saved = await userRepo.findOne(); 129 | await userRepo.remove(saved); 130 | 131 | const newCount = await userRepo.count(); 132 | expect(newCount).toBe(count - 1); 133 | }); 134 | 135 | test('Find and Update', async () => { 136 | const user = new User(); 137 | user.name = 'Katy'; 138 | user.age = 21; 139 | user.someIds = [new ObjectId(), new ObjectId()]; 140 | 141 | await userRepo.save(user); 142 | const res = await userRepo.findOneAndUpdate({_id: user.id}, {$inc: {age: 1}}, {returnDocument: 'after'}) 143 | 144 | expect(res.age).toBe(22); 145 | }); 146 | 147 | test('Find and Update', async () => { 148 | const user = new User(); 149 | user.name = 'Katy2'; 150 | user.age = 21; 151 | user.someIds = [new ObjectId(), new ObjectId()]; 152 | 153 | await userRepo.save(user); 154 | const res = await userRepo.findOneAndUpdate({_id: user.id}, {$set: {age: 33}}, {returnDocument: 'after'}) 155 | 156 | expect(res.age).toBe(33); 157 | }); 158 | 159 | test('Find and Delete', async () => { 160 | const user = new User(); 161 | user.name = 'Katy3'; 162 | user.age = 15; 163 | user.someIds = [new ObjectId(), new ObjectId()]; 164 | 165 | await userRepo.save(user); 166 | await userRepo.findOneAndDelete({_id: user.id}) 167 | const res = await userRepo.findById(user.id); 168 | 169 | expect(res).toBe(null); 170 | }); 171 | }); 172 | 173 | describe('default values', () => { 174 | class Star { 175 | @id _id: ObjectId; 176 | age: number = 1215432154; 177 | } 178 | 179 | let starRepo: Repository; 180 | 181 | beforeAll(async () => { 182 | await clean(client); 183 | starRepo = new Repository(Star, client, 'stars'); 184 | }); 185 | 186 | test('default value when creating new entity', async () => { 187 | const star = new Star(); 188 | expect(star).toHaveProperty('age', 1215432154); 189 | await starRepo.insert(star); 190 | 191 | const saved = await starRepo.findById(star._id); 192 | 193 | expect(saved).toHaveProperty('age', 1215432154); 194 | }); 195 | 196 | test('default value when fetching an entity', async () => { 197 | let res = await starRepo.c.insertOne({}); 198 | 199 | const saved = await starRepo.findById(res.insertedId); 200 | 201 | expect(saved).toHaveProperty('_id'); 202 | expect(saved).toHaveProperty('age', 1215432154); 203 | }); 204 | }); 205 | 206 | describe('indexes', () => { 207 | class Cat { 208 | @id id: ObjectId 209 | @index() name: string; 210 | } 211 | 212 | class House { 213 | @id id: ObjectId; 214 | 215 | @index('2dsphere', { name: 'location_1' }) 216 | location: number[]; 217 | } 218 | 219 | @indexes([ 220 | { key: { houseId: 1, catId: 1 }, name: 'house_cat', unique: true } 221 | ]) 222 | class HouseCat { 223 | @id _id: ObjectId; 224 | 225 | @objectId houseId: ObjectId; 226 | @objectId catId: ObjectId; 227 | } 228 | 229 | let houseRepo: Repository; 230 | let catRepo: Repository; 231 | let houseCatRepo: Repository; 232 | 233 | beforeAll(async () => { 234 | houseRepo = new Repository(House, client, 'houses'); 235 | catRepo = new Repository(Cat, client, 'cats'); 236 | houseCatRepo = new Repository(HouseCat, client, 'house_cats'); 237 | await clean(client); 238 | }); 239 | 240 | test('add simple string index', async () => { 241 | const cat = new Cat(); 242 | cat.name = 'kimmy'; 243 | await catRepo.save(cat); 244 | 245 | const res = await catRepo.createIndexes(); 246 | expect(res.length).toEqual(1); 247 | }); 248 | 249 | test('do not re-add existing index', async () => { 250 | const res = await catRepo.createIndexes(); 251 | expect(res.length).toEqual(1); 252 | }); 253 | 254 | test('location index', async () => { 255 | 256 | const house = new House(); 257 | house.location = [45.15453, 12.354654]; 258 | 259 | await houseRepo.save(house); 260 | 261 | await houseRepo.createIndexes(); 262 | 263 | }); 264 | 265 | test('location index', async () => { 266 | 267 | const house = new House(); 268 | house.location = [45.15453, 12.354654]; 269 | 270 | await houseRepo.save(house); 271 | 272 | await houseRepo.createIndexes(); 273 | }); 274 | 275 | test('compound index', async () => { 276 | const cat = await catRepo.findOne(); 277 | const house = await houseRepo.findOne(); 278 | 279 | const houseCat = new HouseCat(); 280 | houseCat.catId = cat.id; 281 | houseCat.houseId = house.id; 282 | await houseCatRepo.save(houseCat); 283 | 284 | // create unique compound index 285 | const res = await houseCatRepo.createIndexes(); 286 | expect(res.length).toEqual(1); 287 | 288 | await expect((async () => { 289 | const anotherHouseCat = new HouseCat(); 290 | anotherHouseCat.catId = cat.id; 291 | anotherHouseCat.houseId = house.id; 292 | await houseCatRepo.save(anotherHouseCat); 293 | 294 | })()).rejects.toThrow(/E11000 duplicate key error/); 295 | }); 296 | 297 | }); 298 | 299 | describe('multiple databases', () => { 300 | class Entity1 { 301 | @id id: ObjectId 302 | } 303 | 304 | class Entity2 { 305 | @id id: ObjectId; 306 | } 307 | 308 | let repo1: Repository; 309 | let repo2: Repository; 310 | 311 | const CUSTOM_DATABASE_NAME = 'mongodb-typescript-custom-database'; 312 | 313 | beforeAll(async () => { 314 | repo1 = new Repository(Entity1, client, 'entity1'); // database from URL 315 | repo2 = new Repository(Entity2, client, 'entity2', { databaseName: CUSTOM_DATABASE_NAME }); 316 | await clean(client); 317 | await clean(client, CUSTOM_DATABASE_NAME); 318 | }); 319 | 320 | test('create entity in default database', async () => { 321 | const entity1 = new Entity1(); 322 | await repo1.save(entity1); 323 | 324 | const count1 = await client.db().collection('entity1').countDocuments(); 325 | expect(count1).toBe(1); 326 | }); 327 | 328 | test('create entity in custom database', async () => { 329 | const entity2 = new Entity2(); 330 | await repo2.save(entity2); 331 | 332 | const count2 = await client.db(CUSTOM_DATABASE_NAME).collection('entity2').countDocuments(); 333 | expect(count2).toBe(1); 334 | }); 335 | }); 336 | 337 | afterAll(() => close(client)); 338 | -------------------------------------------------------------------------------- /test/ignore.spec.ts: -------------------------------------------------------------------------------- 1 | import { MongoClient, ObjectId } from "mongodb"; 2 | 3 | import { id, Repository, ignore } from "../src"; 4 | import { clean, close, connect } from "./_mongo"; 5 | 6 | class OnlyImportantAtRuntime { 7 | @id id: ObjectId; 8 | superSecretNumber: number; 9 | nuclearLaunchCodes: number[]; 10 | currentThreadId: number; 11 | someText: string; 12 | maybeABoolean: boolean; 13 | } 14 | 15 | class UserWithIgnoredPrimitive { 16 | @id id: ObjectId; 17 | 18 | name: string; 19 | 20 | @ignore 21 | sensitiveInformation: string; 22 | } 23 | 24 | class UserWithIgnoredObject { 25 | @id id: ObjectId; 26 | 27 | name: string; 28 | 29 | @ignore 30 | sensitiveInformation: OnlyImportantAtRuntime; 31 | } 32 | 33 | let client: MongoClient; 34 | let userRepo: Repository; 35 | let userWithIgnoredObjectRepo: Repository; 36 | 37 | beforeAll(async () => { 38 | client = await connect(); 39 | userRepo = new Repository( 40 | UserWithIgnoredPrimitive, 41 | client, 42 | "users" 43 | ); 44 | userWithIgnoredObjectRepo = new Repository( 45 | UserWithIgnoredObject, 46 | client, 47 | "userwithignoredobjects" 48 | ); 49 | }); 50 | 51 | describe("ignored objects", () => { 52 | beforeAll(() => clean(client)); 53 | 54 | test("insert entity with ignored primitive", async () => { 55 | const user = new UserWithIgnoredPrimitive(); 56 | user.name = "hal"; 57 | user.sensitiveInformation = "Don't save me"; 58 | await userRepo.insert(user); 59 | 60 | const saved = await userRepo.findById(user.id); 61 | 62 | expect(saved).toHaveProperty("name", "hal"); 63 | expect(saved).toHaveProperty("id"); 64 | expect(saved).not.toHaveProperty("sensitiveInformation"); 65 | }); 66 | 67 | test("insert entity with ignored object", async () => { 68 | const user = new UserWithIgnoredObject(); 69 | user.name = "hal"; 70 | user.sensitiveInformation = new OnlyImportantAtRuntime(); 71 | user.sensitiveInformation.currentThreadId = 1; 72 | user.sensitiveInformation.maybeABoolean = false; 73 | user.sensitiveInformation.nuclearLaunchCodes = [1, 2, 3]; 74 | user.sensitiveInformation.someText = "If you save me, the world will end!"; 75 | user.sensitiveInformation.superSecretNumber = 2; 76 | await userWithIgnoredObjectRepo.insert(user); 77 | 78 | const saved = await userWithIgnoredObjectRepo.findById(user.id); 79 | 80 | expect(saved).toHaveProperty("name", "hal"); 81 | expect(saved).toHaveProperty("id"); 82 | expect(saved).not.toHaveProperty("sensitiveInformation"); 83 | }); 84 | }); 85 | 86 | afterAll(() => close(client)); 87 | -------------------------------------------------------------------------------- /test/nested.spec.ts: -------------------------------------------------------------------------------- 1 | import { MongoClient, ObjectId } from 'mongodb'; 2 | 3 | import { id, ignore, nested, ref, Repository } from '../src'; 4 | import { clean, close, connect } from './_mongo'; 5 | 6 | class Settings { 7 | colorScheme: string; 8 | articlesPerPage: number; 9 | } 10 | 11 | class Category { 12 | @id id: ObjectId; 13 | name: string; 14 | } 15 | 16 | class Article { 17 | title: string; 18 | 19 | @ignore ignoredField: string; 20 | 21 | @ref() category: Category; 22 | } 23 | 24 | class User { 25 | @id id: ObjectId; 26 | 27 | name: string; 28 | 29 | @nested(() => Article) 30 | articles: Article[]; 31 | 32 | @nested(() => Settings) 33 | settings: Settings; 34 | } 35 | 36 | let client: MongoClient; 37 | let userRepo: Repository; 38 | let categoryRepo: Repository; 39 | 40 | beforeAll(async () => { 41 | client = await connect(); 42 | userRepo = new Repository(User, client, 'users'); 43 | categoryRepo = new Repository(Category, client, 'categories'); 44 | }); 45 | 46 | describe('nested objects', () => { 47 | beforeAll(() => clean(client)); 48 | 49 | test('insert entity with nested object', async () => { 50 | const settings = new Settings(); 51 | settings.articlesPerPage = 10; 52 | settings.colorScheme = 'BLACK_AND_YELLOW'; 53 | 54 | const user = new User(); 55 | user.name = 'hal'; 56 | user.settings = settings; 57 | await userRepo.insert(user); 58 | 59 | const saved = await userRepo.findById(user.id); 60 | 61 | expect(saved).toHaveProperty('name', 'hal'); 62 | expect(saved).toHaveProperty('id'); 63 | expect(saved).toHaveProperty('settings'); 64 | expect(saved.settings).toHaveProperty('articlesPerPage', 10); 65 | expect(saved.settings).toHaveProperty('colorScheme', 'BLACK_AND_YELLOW'); 66 | 67 | expect(saved).not.toHaveProperty('articles'); 68 | }); 69 | 70 | test('insert nested array of objects', async () => { 71 | const article1 = new Article(); 72 | article1.title = 'How to be a better JavaScript programmer'; 73 | 74 | const article2 = new Article(); 75 | article2.title = 'JavaScript and other bad choices'; 76 | 77 | const user = new User(); 78 | user.name = 'bay'; 79 | user.articles = [article1, article2]; 80 | await userRepo.insert(user); 81 | 82 | const saved = await userRepo.findById(user.id); 83 | 84 | expect(saved).toHaveProperty('name', 'bay'); 85 | expect(saved).toHaveProperty('id'); 86 | expect(saved).toHaveProperty('articles'); 87 | expect(saved.articles).toHaveLength(2); 88 | 89 | expect(saved).not.toHaveProperty('settings'); 90 | }); 91 | 92 | test('falsy values', async () => { 93 | const user = new User(); 94 | user.name = 'james'; 95 | user.articles = [null, undefined]; 96 | await userRepo.insert(user); 97 | 98 | const saved = await userRepo.findById(user.id); 99 | 100 | expect(saved).toHaveProperty('articles'); 101 | expect(saved.articles).toHaveLength(2); 102 | expect(saved.articles[0]).toBeNull(); 103 | expect(saved.articles[1]).toBeNull(); 104 | }); 105 | 106 | test('nested ignored properties', async () => { 107 | const article = new Article(); 108 | article.title = 'How to sleep at night'; 109 | article.ignoredField = 'blah'; 110 | 111 | const user = new User(); 112 | user.name = 'james'; 113 | user.articles = [article]; 114 | await userRepo.insert(user); 115 | 116 | const saved = await userRepo.findById(user.id); 117 | 118 | expect(saved).toHaveProperty('articles'); 119 | expect(saved.articles).toHaveLength(1); 120 | expect(saved.articles[0].title).toEqual(article.title); 121 | expect(saved.articles[0]).not.toHaveProperty('ignoredField'); 122 | }); 123 | 124 | test('nested referenced entities', async () => { 125 | const category = new Category(); 126 | category.name = 'Lifestyle'; 127 | 128 | await categoryRepo.save(category); 129 | 130 | 131 | const article = new Article(); 132 | article.title = 'How to sleep at night'; 133 | article.category = category; 134 | 135 | const user = new User(); 136 | user.name = 'tommy'; 137 | user.articles = [article]; 138 | await userRepo.insert(user); 139 | 140 | expect(user.articles).toHaveLength(1); 141 | expect(user.articles[0]).toHaveProperty('categoryId'); 142 | expect(user.articles[0].category).toHaveProperty('id', category.id); 143 | 144 | const saved = await userRepo.findById(user.id); 145 | 146 | expect(saved).toHaveProperty('articles'); 147 | expect(saved.articles).toHaveLength(1); 148 | expect(saved.articles[0]).toHaveProperty('categoryId'); 149 | expect(saved.articles[0]).not.toHaveProperty('category'); 150 | }); 151 | }); 152 | 153 | afterAll(() => close(client)); 154 | -------------------------------------------------------------------------------- /test/referenced.spec.ts: -------------------------------------------------------------------------------- 1 | import { MongoClient, ObjectId } from 'mongodb'; 2 | 3 | import { id, nested, objectId, ref, Repository } from '../src'; 4 | import { clean, close, connect } from './_mongo'; 5 | 6 | class User { 7 | @id id: ObjectId; 8 | name: string; 9 | } 10 | 11 | class Comment { 12 | @objectId customIdField: ObjectId; 13 | text: string; 14 | 15 | @ref() commentator: User; 16 | } 17 | 18 | class Page { 19 | @id _id: ObjectId; 20 | text: string; 21 | 22 | @ref() author: User; 23 | @objectId authorId: ObjectId; 24 | 25 | @nested(() => Comment) comments: Comment[]; 26 | 27 | @ref() pinnedBy: User[]; 28 | } 29 | 30 | let client: MongoClient; 31 | let userRepo: Repository, pageRepo: Repository; 32 | 33 | beforeAll(async () => { 34 | client = await connect(); 35 | userRepo = new Repository(User, client, 'users'); 36 | pageRepo = new Repository(Page, client, 'pages'); 37 | }); 38 | 39 | describe('referenced objects', () => { 40 | beforeAll(() => clean(client)); 41 | 42 | let user1: User, user2: User, user3: User; 43 | 44 | beforeAll(async () => { 45 | user1 = new User(); 46 | user1.name = 'Elijah'; 47 | await userRepo.insert(user1); 48 | 49 | user2 = new User(); 50 | user2.name = 'Lisa'; 51 | await userRepo.insert(user2); 52 | 53 | user3 = new User(); 54 | user3.name = 'Mike'; 55 | await userRepo.insert(user3); 56 | }); 57 | 58 | test('insert entity referencing another entity', async () => { 59 | let page = new Page(); 60 | page.text = 'this is my home page!'; 61 | page.author = user1; 62 | 63 | await pageRepo.insert(page); 64 | 65 | expect(page).toHaveProperty('_id'); 66 | expect(page).toHaveProperty('author'); 67 | expect(page.author).toHaveProperty('name', user1.name); 68 | expect(page).toHaveProperty('authorId', user1.id); 69 | 70 | const raw = await pageRepo.c.findOne({ _id: page._id }); 71 | expect(raw).not.toHaveProperty('author'); 72 | expect(raw).toHaveProperty('authorId'); 73 | 74 | const saved = await pageRepo.findOne({}); 75 | expect(saved).toHaveProperty('authorId', user1.id); 76 | expect(saved).not.toHaveProperty('author'); 77 | }); 78 | 79 | test('populate', async () => { 80 | const page = await pageRepo.findOne({}); 81 | expect(page).not.toHaveProperty('author'); 82 | await userRepo.populate(page, 'author'); 83 | expect(page).toHaveProperty('author'); 84 | }); 85 | 86 | test('populate many', async () => { 87 | let page = new Page(); 88 | page.text = 'this is a sub page!'; 89 | page.author = user1; 90 | 91 | let comment1 = new Comment(); 92 | comment1.text = 'This is great!'; 93 | comment1.commentator = user1; 94 | 95 | let comment2 = new Comment(); 96 | comment2.text = 'This sucks'; 97 | comment2.commentator = user1; 98 | 99 | page.comments = [comment1, comment2]; 100 | 101 | await pageRepo.save(page); 102 | 103 | let saved = await pageRepo.findById(page._id); 104 | expect(saved).not.toBeNull(); 105 | 106 | await userRepo.populateMany(page.comments, 'commentator'); 107 | for (let comment of page.comments) { 108 | expect(comment).toHaveProperty('commentator'); 109 | } 110 | }); 111 | 112 | test('populate many with array with zero length', async () => { 113 | await userRepo.populateMany([], 'commentator'); 114 | }); 115 | 116 | test('reference many', async () => { 117 | let page = new Page(); 118 | page.text = 'this is a another sub page!'; 119 | page.author = user1; 120 | 121 | page.pinnedBy = [user1, user2]; 122 | await pageRepo.save(page); 123 | 124 | let saved = await pageRepo.findById(page._id); 125 | expect(saved).not.toBeNull(); 126 | 127 | expect(saved).not.toHaveProperty('pinnedBy'); 128 | expect(saved).toHaveProperty('pinnedByIds'); 129 | expect((saved as any).pinnedByIds).toHaveLength(2); 130 | 131 | await userRepo.populate(saved, 'pinnedBy'); 132 | expect(saved).toHaveProperty('pinnedBy'); 133 | expect(saved.pinnedBy).toHaveLength(2); 134 | 135 | for (let pinnedBy of saved.pinnedBy) { 136 | expect(pinnedBy).not.toBeNull(); 137 | } 138 | }); 139 | }); 140 | 141 | afterAll(() => close(client)); 142 | -------------------------------------------------------------------------------- /test/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es6", 4 | "module": "commonjs", 5 | "experimentalDecorators": true, 6 | "emitDecoratorMetadata": true, 7 | "noEmit": true 8 | }, 9 | "include": [ 10 | ".", 11 | "../src/**/*.ts" 12 | ] 13 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es6", 4 | "module": "commonjs", 5 | "declaration": true, 6 | "sourceMap": true, 7 | "outDir": "./lib", 8 | "strict": true, 9 | "baseUrl": "./src", 10 | "esModuleInterop": true, 11 | "experimentalDecorators": true, 12 | "emitDecoratorMetadata": true, 13 | }, 14 | "include": [ 15 | "./src/**/*.ts" 16 | ] 17 | } --------------------------------------------------------------------------------