mongo-ts-struct
Version:
Mongoose wrapper for Typescript supports
857 lines (636 loc) • 22.1 kB
Markdown
# Mongo-TS Struct
### About :
Mongoose Documents, by default, are not covered by there schema type. <br>
The common solution For type-cover Document object, is an interface for each schema, which lead to redundancy (define the schema once as a schema definition object and once as an interface) and may take more code maintenance. <br>
By defining a class that represent your Mongoose schema, and using Typescript decorators around the class's members, your schema definitions are being stored behind the scene and a type-cover 'native' Mongoose schema is created.
**Using mongo-ts your schema need to be written once as a class - and a typed cover schema will be created for you.**
<br>
### References :
* [MongoDB](https://docs.mongodb.com/manual/)
* [Mongoose](https://mongoosejs.com/docs/guide.html)
* [Typescript](https://www.typescriptlang.org/docs/handbook/typescript-in-5-minutes.html)
<br>
### Features :
* Generate class definition to a native mongoose schema.
* Support OOP writing style by enabling schema class extending.
* Support class method & static implementation and invoking them through a document and Model.
* Reduce redundancies by inferring the property type (using reflection).
* Cover created and fetched documents with the schema class type definition.
<br>
### Table Of Content :
+ [Installation](#installation)
+ [Quick Setup and Usage](#quick-setup-and-usage)
+ [API Reference](#api-reference)
+ [Schema Class Decorator](#schema-class-decorator)
+ [Schema Creation Hooks](#schema-creation-hooks)
+ [Class Members Decorators](#class-members-decorators)
+ [Class Method and Static Decorators](#class-static-and-class-method-decorators)
+ [Custom Default Schema Definition](#custom-default-schema-definition)
+ [Fallbacks](#fallbacks)
<br>
<hr>
<br>
# Installation
```sh
npm i mongo-ts-struct -S
```
<br>
# Quick Setup and Usage
1. Assuming you got an existing typescript node app, install the module with `npm i mongo-ts-struct -S`. <br>
Make sure the setting on your `tsconfig.json` file allowing decorators : <br>
```js
{
"compilerOptions": {
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
...
}
...
}
```
2. Moving on to the code, <br>
The common mongoose schema setup with typescript on the background is looking something like : <br>
```ts
// on file - blog.interface.ts
export interface IBlog {
title: string;
author: string,
body: string,
comments: { body: string, date: Date }[],
date: Date
hidden: boolean,
meta: {
votes: number,
favs: number
}
}
// on file - blog.schema.ts
import * as mongoose from 'mongoose';
import { IBlog } from './blog.interface';
export const blogSchema = new mongoose.Schema<IBlog>({
title: {
type: String,
required: true
},
author: String,
body: String,
comments: {
type: [{ body: String, date: Date }],
default: []
},
date: { type: Date, default: Date.now },
hidden: Boolean,
meta: {
votes: Number,
favs: Number
}
});
// on file - blog.model.ts
import * as mongoose from 'mongoose';
import { blogSchema } from './blog.schema';
blogSchema.methods.recentComments = function(amount: number = 5) {
const blog = this;
return blog.comments.sort((a, b) => a - b > 0 ? 1 : -1 ).slice(0, amount);
}
export const Blog = mongoose.Model('blogs', blogSchema);
```
<br>
**The down falls on this approach :** <br>
* You got three files for a single schema, you can place it all in one file, but that might be consider a bad practice. <br>
* The redundancy and duplicate definition of your schema are very match noticeable.<br>
Maintaining two definitions located in separate files in sync can cause some hide-of-sight bugs. <br>
* A method defined with `schema.methods` will not be type covered, meaning, the calling of the method will not be supported by typescript compiler. <br>
This approach can be reduce now to the following, while avoiding all the mentioned fall becks. <br>
The interface definition, mongoose-schema and schema-functions are all located under the same roof of a single class, just like the familiar OOP coding style.<br>
```ts
// on file - blog.ts / a single file will suffice
import { TypedSchema, Prop, Property, ArrayOf, Method ,toModel } from 'mongo-ts-struct';
@TypedSchema() class BlogComments {
@Prop() body: string;
@Prop() date: Date;
}
@TypedSchema() class Blog {
@Prop({ required: true }) title: string;
@Prop() author: string;
@Prop() body: string;
// can use @Property instead of creating comments schema.
@ArrayOf(BlogComments, { default: [] }) comments: BlogComments[];
@Prop({ default: Date.now }) date: Date;
@Prop() hidden: boolean;
@Property(votes: Number, favs: Number})
meta: {
votes: number,
favs: number
}
@Method() recentComments(amount: number = 5) {
return this.comments.sort((a, b) => a - b > 0 ? 1 : -1 ).slice(0, amount);
}
}
export const BlogModel = toModel<Blog, typeof Blog>(Blog, 'blogs');
```
3. The data queries are all remain the same, the returned documents are cover with the schema-class type, and the schema-class method can be invoked by the returned documents as well;
```ts
async function getBlog(id: string): { blog: Blog, recentComments: Array<BlogComments> } {
const blogDoc = await BlogModel.findById(id); // blogDoc of Blog type
const recentComments = blog.recentComments(); // recentComments of BlogComments[] type
return { blog: blogDoc, recentComments };
}
```
<br>
<br>
<br>
# Api Reference
## **Schema Class Decorator**
**Overview:** <br>
Every schema class must be decorate with `@TypedSchema`.
With that, the schema definitions can be collected (using field decorators) from the class, and the schema-class extending can be supported and work properly.<br>
Class decorated with `@TypedSchema` supports multiple function-hooks (stages) in the schema creation, using those hooks you can make your custom changes the schema creation process as you please.<br>
<br>
#### `@TypedSchema(config?: TypedSchemaConfig)`
***Description:*** <br>
***Example:*** <br>
```ts
@TypedSchema()
class Profile {
@Prop() firstName: string;
@Prop() lastName: string;
@Prop() address: string;
@Prop() age: number;
@Prop() img: string;
}
@TypedSchema({ options: { timestamps: true } })
class User {
@Prop({ required: true }) username: string;
@Prop({ unique: true, required: true }) email: string;
@Prop() profile: Profile;
@Prop() hash: string;
@Method() getEmail() {
return this.email;
}
}
const enumKeys = (eType => (Object.values(eType).filter(e => typeof e == 'string')));
enum Permission { 'delete' ,'update', 'insert' }
@TypedSchema()
class Admin extends User {
@Prop({ default: 2, max: 4 }) role: number;
@Enum(enumKeys(Permission), {default: ['update', 'insert']})
permissions: string[];
}
```
```ts
/* Will be mapped to : */
Admin = {
username : {
type: Schema.Types.String,
required: true,
},
email: {
type: Schema.Types.String,
unique: true,
required: true,
},
profile: {
type: {
firstName: { type: Schema.Types.String },
lastName: { type: Schema.Types.String },
address: { type: Schema.Types.String },
age: { type: Schema.Types.Number },
img: { type: Schema.Types.String }
}
},
hash: { type: Schema.Types.String },
role: {
type: Schema.Types.Number,
default: 2,
max: 4,
},
permissions: {
type: [Schema.Types.String],
enum: ['delete' ,'update', 'insert'],
default: ['insert', 'update']
}
}
```
<br>
#### `interface TypedSchemaConfig`
***Description:*** <br>
::TODO:: <br>
***Definition:*** <br>
```ts
::TODO::
```
<br>
#### `toModel<M, T extends Ctor<M>>(TypedSchemeClass: T, modelName: string,preModelCreation: PreModelCreationFunc<T>)`
***Description:*** <br>
::TODO:: <br>
***Definition:*** <br>
```ts
::TODO::
```
<br>
<br>
## **Schema Creation Hooks**
**Overview:** <br>
The process of generating a 'native' schema from schema-class, is divided to stages :
* first there is a checkup, if a 'native' schema has been generated from that class, if so, its cached and the hook `onSchemaCreated` is called, else,
* the schema definition is being constructed from the metadata of the class and the hook `onConstructDefinitions` is called.
* after the schema definitions are determent, the 'native' schema is created and the hook `onSchemaCreated` is called,
* then the 'native' schema object is bound to any static / class method, that was decorated in the schema-class, and the hook `onSchemaBound` is called.
<br>
#### `OnConstructDefinitions`
***Description:*** <br>
An interface the schema-class can implement and apply the hook `onConstructDefinitions`. <br>
`onConstructDefinitions` function executed after the schema definition has been constructed from the schema-class metadata, the schema definition object and decorated static / class methods object is provided as an argument.<br>
***Definition:*** <br>
```ts
interface OnConstructDefinitions {
onConstructDefinitions(schemaDefinitions: object, functions?: object): void
}
```
<br>
#### `OnSchemaCreated`
***Description:*** <br>
An interface the schema-class can implement and apply the hook `onSchemaCreated`. <br>
`onSchemaCreated` function executed after the 'native' schema as created, the new schema object is provided as an argument.<br>
***Definition:*** <br>
```ts
interface OnSchemaCreated {
onSchemaCreated(schema: Schema): void
}
```
<br>
#### `OnSchemaBound`
***Description:*** <br>
An interface the schema-class can implement and apply the hook `onSchemaBound`. <br>
`onSchemaBound` function executed after static / class method, had been bound to the 'native' schema, the bound schema object is provided as an argument.<br>
***Definition:*** <br>
```ts
interface OnSchemaBound {
onSchemaBound(schema: Schema): void
}
```
<br>
#### `OnSchemaCached`
***Description:*** <br>
An interface the schema-class can implement and apply the hook `onSchemaCached`. <br>
`onSchemaCached` function executed only if a 'native' schema has been created from the schema-class (and can be cached), in case the schema can be cached non of the other hooks will ran. <br>
The cached schema object is provided as an argument.<br>
***Definition:*** <br>
```ts
interface OnSchemaCached {
onSchemaCached(schema: Schema): void
}
```
<br>
<br>
<br>
## **Class Members Decorators**
**Overview:** <br>
Mongo-TS uses field decorators to collect data regard the decorated class members, with that data the member's schema definition is created and mapped to relevant property on the generated schema. <br>
<br>
#### `@Prop(definition?: Partial<PropertyDefinition>)`
***Description:*** <br>
Decorator that infer the type's constructor of the decorated property using reflection, mapped it as the `type` value of the property schema definition. <br>
***Example:*** <br>
```ts
class User ... {
@Prop({ required: true, unique: true, match: /[a-z0-9]+@[a-z]+\.[a-z]+/ })
email: string;
...
}
/* Will be mapped to : */
User = {
email: {
type: Schema.Types.String,
required: true,
unique: true,
match: /[a-z0-9]+@[a-z]+\.[a-z]+/
}
...
}
```
<br>
#### `@Ref(modelName: string, definition?: Partial<PropertyDefinition>)`
***Description:*** <br>
Decorator that define a ref `type` property by a provided modal name. <br>
***Example:*** <br>
```ts
class User ... {
@Ref('territory');
territory: Territory | ObjectId; // assume 'Territory' in a defined type / class
...
}
/* Will be mapped to : */
User = {
territory: {
ref: 'territory'
type: Schema.Types.ObjectId,
}
...
}
```
<br>
#### `@ArrayRef(modelName: string, definition?: Partial<PropertyDefinition>)`
***Description:*** <br>
Decorator that define an array ref `type` property by a provided modal name. <br>
***Example:*** <br>
```ts
class User ... {
@ArrayRef('post', { default: [] });
posts: Post[] | ObjectId[]; // assume 'Post' in a defined type / class
...
}
/* Will be mapped to : */
User = {
posts: {
type: [{ ref: 'post' type: Schema.Types.ObjectId }],
default: []
}
...
}
```
<br>
#### `@ArrayOf(type: SupportedTypes | Function, definition: Partial<PropertyDefinition>)`
***Description:*** <br>
Decorator that get `SupportedTypes` (one of the string values `string` | `number` | `boolean` | `any`) as a type indicator, or a constructor type function of a schema-class (decorated with `@TypedSchema`), and define an array of that type.<br>
An array type field can be inferred using reflection but currently the type of that array can't be detect.<br>
***Example:*** <br>
```ts
class User ... {
@ArrayOf('string', { default: [] });
tokens: string[],
...
}
/* Will be mapped to : */
User = {
tokens: {
type: [Schema.Types.String],
default: []
}
...
}
```
<br>
#### `@Enum(enumKeys: Array<string>, definition?: Partial<PropertyDefinition>)`
***Description:*** <br>
Decorator that define an Enum type property by a provided enum keys array (an array of string). <br>
The property type can be the enum type or an array of that enum, the `@Enum` will infer and map the property type accordingly.<br>
***Example:*** <br>
```ts
// helper, take enum type and return his keys as an array.
const enumKeys = (eType => (Object.values(eType).filter(e => typeof e == 'string')));
enum Permission { 'delete' ,'update', 'insert' }
class User ... {
@Enum(enumKeys(Permission), { default: ['insert'] });
permissions: Permission[];
...
}
/* Will be mapped to : */
User = {
permissions: {
type: [Schema.Types.String],
enum: ['delete' ,'update', 'insert'],
default: ['insert']
}
...
}
enum Gender { 'female' ,'male', 'other' }
class Profile ... {
@Enum(enumKeys(Gender), { required: true });
gender: Gender;
...
}
/* Will be mapped to : */
Profile = {
gender: {
type: Schema.Types.String,
enum: ['female' ,'male', 'other'],
required: true
}
...
}
```
<br>
#### `@Property(type: any, definition?: Partial<PropertyDefinition>)`
***Description:*** <br>
Decorator that allows a free / custom definition of of the decorated property.<br>
Useful in any case that not supported by an out-of-the-box decorator. <br>
Note: <br>
In must cases the `@Property` decorator will be used, a duplication of the field type definition will be made, there for a more elegant approach will be to create a separate schema-class of for that field and decorate it with `@Prop`.
***Example:*** <br>
```ts
@TypedSchema()
class User {
@Prop({ required: true }) username: string;
@Property({ firstName: String; lastName: String; address: String; age: Number; img: String; })
profile: { firstName: string; lastName: string; address: string; age: number; img: string; }
}
/* Will be mapped to : */
User = {
username: {
type: String,
},
profile:
type: {
firstName: String,
lastName: String,
address: String,
age: Number,
img: String
}
}
...
}
```
<br>
### **Compositions**
Decorators by nature can be compose on top of each other, with decorator that set a specific definition attribute it can make more sense to utilize that behaviors.<br>
An option for setting a class member's definition can be by composing specific-attribute decorators.<br>
<br>
#### `@Default(value: boolean = true)`
***Description:*** <br>
Decorator that set the `default` definition attribute to the provided value. <br>
***Example:*** <br>
```ts
@TypedSchema()
class User {
@Prop()
@Default(true)
subscribed: boolean;
}
```
<br>
#### `@Required(value: (boolean | string) = true)`
***Description:*** <br>
Decorator that set the `required` definition attribute to the provided value. <br>
***Example:*** <br>
```ts
@TypedSchema()
class User {
@Prop()
@Required()
email: string;
}
```
<br>
#### `@Unique(value: boolean = true)`
***Description:*** <br>
Decorator that set the `unique` definition attribute to the provided value. <br>
***Example:*** <br>
```ts
@TypedSchema()
class User {
@Prop()
@Unique()
email: string;
}
```
<br>
#### `@Match(value: RegExp | string)`
***Description:*** <br>
Decorator that set the `match` definition attribute to the provided value. <br>
***Example:*** <br>
```ts
@TypedSchema()
class User {
@Prop()
@Match(/^[\w\.-]+@[\w-]+\.[\w\.-]+$/)
email: string;
}
```
<br>
<br>
<br>
## **Static & Class Method Decorators**
**Overview** <br>
Mongo-TS uses method decorators to reference and apply the decorated method (class method or static method) to the document and Model. <br>
<br>
#### `@Method()`
***Description:*** <br>
Decorator that define a class method as a schema method for any document to use. <br>
***Example:*** <br>
```ts
@TypedSchema({ options: { timestamps: true } })
class User {
@Prop({ required: true }) first: string;
@Prop({ required: true }) last: string;
@Method() getFullName() {
const caps = (s) => s.charAt(0).toUpperCase() + s.slice(1);
return `${caps(this.first)} ${caps(this.last)}`;
}
}
const UserModel = toModel<User, typeof User>(User, 'users');
UserModel.findById(id).then((user) => {
console.log(user.getFullName()); // will print the user full name
});
```
<br>
#### `@Static()`
***Description:*** <br>
Decorator that define a static method as a schema method for any model to use. <br>
***Example:*** <br>
```ts
@TypedSchema({ options: { timestamps: true } })
class User {
@Prop({ required: true }) first: string;
@Prop({ required: true }) last: string;
@Static() static async searchByName(searchValue: string) {
const _this = UserModel;
const fieldsForSearch = ['first', 'last'];
const toPipeLine = (
(s: string) =>
fieldsForSearch.map(f => ({ [f]: { $regex: searchValue, $options: 'i' } }))
);
const searchResult = await _this.aggregate([ {
$or: [
{ $text: { $search: new RegExp(searchValue) } },
... toPipeLine(searchValue)
]
} ]).exec();
return searchResult;
}
}
const UserModel = toModel<User, typeof User>(User, 'users');
// calling this method in a static like syntax - will be supported in compile time as well as run time!
UserModel.searchByName('bob').then((users) => {
try{
console.log(users);
} catch(e) {
console.log(e);
}
});
```
<br>
***Important !*** <br>
Document object that been 'leaned' will not be able to invoke any of his bounded class methods, altho, in compile time, the method under the document's type can be access. <br>
```ts
import { TypedSchema, Prop, Method, toModel } from 'mongo-ts';
@TypedSchema({ options: { timestamps: true } })
class User {
@Prop({ required: true }) username: string;
@Prop({ unique: true, required: true }) email: string;
@Method() getEmailAccountProvider() {
const email = this.email;
return email.replace(/.+\@(.+)\.[a-z]+$/, '$1');
}
}
const UserModel = toModel<User, typeof User>(User, 'users');
UserModel.findById(id).lean().then((user) => {
try{
console.log(user.getEmailAccountProvider()); // will throw as error.
} catch(e) {
console.log(e); // log : "user.getEmailAccountProvider is not a function"
}
});
```
<br>
*Note:* <br>
If `.lean()` was not chained before the `.then()` than the method `user.getEmailAccountProvider()` would have been called as expected. <br>
<br>
<br>
<br>
## **Custom Default Schema Definition**
**Overview** <br>
::TODO:: <br>
<br>
<br>
<br>
# Fallbacks
This module works best with flat schemas (zero redundancies). <br>
The solution for multilayered schema, is to cover each complex (let say, more then three members) layer with a class and use it in the parent layer.<br>
E.g, you could write your class like :
```ts
@TypedSchema()
class BaseUser {
@Prop({ required: true }) name: string;
@Prop({ unique: true, required: true }) email: string;
@Prop() hash: string;
@Property({ firstName: String; lastName: String; address: String; age: Number; img: String; })
profile: { firstName: string; lastName: string; address: string; age: number; img: string; }
@Method() getEmail() {
return this.email;
}
}
```
You can see that there is a repeating definition of the member `profile` - that is the redundancy we aim to loos.<br>
So by separating this class in to two layers, you can elegantly write it like :
```ts
@TypedSchema()
class Profile {
@Prop() firstName: string;
@Prop() lastName: string;
@Prop() address: string;
@Prop() age: number;
@Prop() img: string;
}
@TypedSchema()
class BaseUser {
@Prop({ required: true }) name: string;
@Prop({ unique: true, required: true }) email: string;
@Prop() profile: Profile;
@Prop() hash: string;
@Method() getEmail() {
return this.email;
}
}
```