mongo-ts-struct
v1.2.0
Published
Mongoose wrapper for Typescript supports
Downloads
4
Maintainers
Readme
Mongo-TS Struct
About :
Mongoose Documents, by default, are not covered by there schema type. 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.
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.
References :
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.
Table Of Content :
Installation
npm i mongo-ts-struct -S
Quick Setup and Usage
Assuming you got an existing typescript node app, install the module with
npm i mongo-ts-struct -S
.Make sure the setting on your
tsconfig.json
file allowing decorators :{ "compilerOptions": { "experimentalDecorators": true, "emitDecoratorMetadata": true, ... } ... }
Moving on to the code, The common mongoose schema setup with typescript on the background is looking something like :
// 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);
The down falls on this approach :
- You got three files for a single schema, you can place it all in one file, but that might be consider a bad practice.
- The redundancy and duplicate definition of your schema are very match noticeable. Maintaining two definitions located in separate files in sync can cause some hide-of-sight bugs.
- A method defined with
schema.methods
will not be type covered, meaning, the calling of the method will not be supported by typescript compiler.
This approach can be reduce now to the following, while avoiding all the mentioned fall becks.
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.
// 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');
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;
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 }; }
Api Reference
Schema Class Decorator
Overview:
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.
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.
@TypedSchema(config?: TypedSchemaConfig)
Description:
Example:
@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[];
}
/* 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']
}
}
interface TypedSchemaConfig
Description: ::TODO:: Definition:
::TODO::
toModel<M, T extends Ctor<M>>(TypedSchemeClass: T, modelName: string,preModelCreation: PreModelCreationFunc<T>)
Description: ::TODO:: Definition:
::TODO::
Schema Creation Hooks
Overview: 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.
OnConstructDefinitions
Description:
An interface the schema-class can implement and apply the hook onConstructDefinitions
.
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.
Definition:
interface OnConstructDefinitions {
onConstructDefinitions(schemaDefinitions: object, functions?: object): void
}
OnSchemaCreated
Description:
An interface the schema-class can implement and apply the hook onSchemaCreated
.
onSchemaCreated
function executed after the 'native' schema as created, the new schema object is provided as an argument.
Definition:
interface OnSchemaCreated {
onSchemaCreated(schema: Schema): void
}
OnSchemaBound
Description:
An interface the schema-class can implement and apply the hook onSchemaBound
.
onSchemaBound
function executed after static / class method, had been bound to the 'native' schema, the bound schema object is provided as an argument.
Definition:
interface OnSchemaBound {
onSchemaBound(schema: Schema): void
}
OnSchemaCached
Description:
An interface the schema-class can implement and apply the hook onSchemaCached
.
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.
The cached schema object is provided as an argument.
Definition:
interface OnSchemaCached {
onSchemaCached(schema: Schema): void
}
Class Members Decorators
Overview: 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.
@Prop(definition?: Partial<PropertyDefinition>)
Description:
Decorator that infer the type's constructor of the decorated property using reflection, mapped it as the type
value of the property schema definition.
Example:
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]+/
}
...
}
@Ref(modelName: string, definition?: Partial<PropertyDefinition>)
Description:
Decorator that define a ref type
property by a provided modal name.
Example:
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,
}
...
}
@ArrayRef(modelName: string, definition?: Partial<PropertyDefinition>)
Description:
Decorator that define an array ref type
property by a provided modal name.
Example:
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: []
}
...
}
@ArrayOf(type: SupportedTypes | Function, definition: Partial<PropertyDefinition>)
Description:
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.
An array type field can be inferred using reflection but currently the type of that array can't be detect.
Example:
class User ... {
@ArrayOf('string', { default: [] });
tokens: string[],
...
}
/* Will be mapped to : */
User = {
tokens: {
type: [Schema.Types.String],
default: []
}
...
}
@Enum(enumKeys: Array<string>, definition?: Partial<PropertyDefinition>)
Description:
Decorator that define an Enum type property by a provided enum keys array (an array of string).
The property type can be the enum type or an array of that enum, the @Enum
will infer and map the property type accordingly.
Example:
// 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
}
...
}
@Property(type: any, definition?: Partial<PropertyDefinition>)
Description:
Decorator that allows a free / custom definition of of the decorated property.
Useful in any case that not supported by an out-of-the-box decorator.
Note:
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:
@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
}
}
...
}
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. An option for setting a class member's definition can be by composing specific-attribute decorators.
@Default(value: boolean = true)
Description:
Decorator that set the default
definition attribute to the provided value.
Example:
@TypedSchema()
class User {
@Prop()
@Default(true)
subscribed: boolean;
}
@Required(value: (boolean | string) = true)
Description:
Decorator that set the required
definition attribute to the provided value.
Example:
@TypedSchema()
class User {
@Prop()
@Required()
email: string;
}
@Unique(value: boolean = true)
Description:
Decorator that set the unique
definition attribute to the provided value.
Example:
@TypedSchema()
class User {
@Prop()
@Unique()
email: string;
}
@Match(value: RegExp | string)
Description:
Decorator that set the match
definition attribute to the provided value.
Example:
@TypedSchema()
class User {
@Prop()
@Match(/^[\w\.-]+@[\w-]+\.[\w\.-]+$/)
email: string;
}
Static & Class Method Decorators
Overview Mongo-TS uses method decorators to reference and apply the decorated method (class method or static method) to the document and Model.
@Method()
Description: Decorator that define a class method as a schema method for any document to use.
Example:
@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
});
@Static()
Description: Decorator that define a static method as a schema method for any model to use.
Example:
@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);
}
});
Important ! 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.
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"
}
});
Note:
If .lean()
was not chained before the .then()
than the method user.getEmailAccountProvider()
would have been called as expected.
Custom Default Schema Definition
Overview ::TODO::
Fallbacks
This module works best with flat schemas (zero redundancies). 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.
E.g, you could write your class like :
@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.
So by separating this class in to two layers, you can elegantly write it like :
@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;
}
}