Skip to content
This repository has been archived by the owner on Jun 29, 2021. It is now read-only.

Commit

Permalink
Add @mapProp for Maps with tests (#342)
Browse files Browse the repository at this point in the history
- add @mapProp
- add tests for @mapProp
- add @mapProp README docs
- polish & rebase of #284
  • Loading branch information
hasezoey authored and Ben305 committed Jul 23, 2019
1 parent b386675 commit 95c3fa6
Show file tree
Hide file tree
Showing 4 changed files with 152 additions and 36 deletions.
52 changes: 24 additions & 28 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -356,39 +356,35 @@ Note that unfortunately the [reflect-metadata](https://github.com/rbuckton/refle
previousCars?: Ref<Car>[];
```
### Dynamic References
Mongoose supports the concept of [dynamic references](https://mongoosejs.com/docs/populate.html#dynamic-ref): which is, an item-specific model reference instead of a model-specific reference.
```js
const Comment = new Schema({
body: { type: String, required: true },
on: { type: Schema.Types.ObjectId, required: true, refPath: 'onModel' },
onModel: { type: String, required: true, enum: ['BlogPost', 'Product']}
})
#### mapProp(options)
// later
const post = new BlogPost()
const comment = new Comment({
body: 'First!'
on: post,
onModel: 'BlogPost'
})
```
The `mapProp` is a `prop` decorator which makes it possible to create map schema properties.
This is now supported with `@prop({refPath: 'string' })`. You will need to expose the field containing the referenced model name as a `@prop()` as well.
```ts
class Schema extends Typegoose {
@prop()
body: string
The options object accepts `enum` and `default`, just like `prop` decorator. In addition to these the following properties are accepted:
@prop({ required: true, refPath: 'onModel' })
on: Ref<BlogPost | Product>
- `of` : This will tell Typegoose that the Map value consists of primitives (if `String`, `Number`, or other primitive type is given) or this is an array which consists of subdocuments (if it's extending the `Typegoose` class).
@prop()
onModel: string
}
```
```ts
class Car extends Typegoose {
@mapProp({ of: Car })
public keys: Map<string, Car>;
}
```
For arrays, use `@prop({ itemsRefPath: 'name'})`
- `mapDefault` : This will set the default value for the map.
```ts
enum ProjectState {
WORKING = 'working',
BROKEN = 'broken',
MAINTAINANCE = 'maintainance',
}

class Car extends Typegoose {
@mapProp({ of: String, enum: ProjectState,mapDefault: { 'MainProject' : ProjectState.WORKING }})
public projects: Map<string, ProjectState>;
}
```
### Method decorators
Expand Down
58 changes: 51 additions & 7 deletions src/prop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,13 @@ export type PropOptionsWithNumberValidate = PropOptions & ValidateNumberOptions;
export type PropOptionsWithStringValidate = PropOptions & TransformStringOptions & ValidateStringOptions;
export type PropOptionsWithValidate = PropOptionsWithNumberValidate | PropOptionsWithStringValidate | VirtualOptions;

/** This Enum is meant for baseProp to decide for diffrent props (like if it is an arrayProp or prop or mapProp) */
enum WhatIsIt {
ARRAY = 'Array',
MAP = 'Map',
NONE = ''
}

const isWithStringValidate = (options: PropOptionsWithStringValidate) =>
(
options.lowercase
Expand All @@ -96,7 +103,7 @@ const isWithStringTransform = (options: PropOptionsWithStringValidate) =>

const isWithNumberValidate = (options: PropOptionsWithNumberValidate) => options.min || options.max;

const baseProp = (rawOptions: any, Type: any, target: any, key: any, isArray = false) => {
const baseProp = (rawOptions: any, Type: any, target: any, key: any, whatis: WhatIsIt = WhatIsIt.NONE) => {
const name: string = target.constructor.name;
const isGetterSetter = Object.getOwnPropertyDescriptor(target, key);
if (isGetterSetter) {
Expand Down Expand Up @@ -130,7 +137,7 @@ const baseProp = (rawOptions: any, Type: any, target: any, key: any, isArray = f
return;
}

if (isArray) {
if (whatis === WhatIsIt.ARRAY) {
initAsArray(name, key);
} else {
initAsObject(name, key);
Expand Down Expand Up @@ -218,16 +225,27 @@ const baseProp = (rawOptions: any, Type: any, target: any, key: any, isArray = f
throw new InvalidPropError(Type.name, key);
}

const { ['ref']: r, ['items']: i, ...options } = rawOptions;
const { ['ref']: r, ['items']: i, ['of']: o, ...options } = rawOptions;
if (isPrimitive(Type)) {
if (isArray) {
if (whatis === WhatIsIt.ARRAY) {
schema[name][key] = {
...schema[name][key][0],
...options,
type: [Type],
};
return;
}
if (whatis === WhatIsIt.MAP) {
const { mapDefault } = options;
delete options.mapDefault;
schema[name][key] = {
...schema[name][key],
type: Map,
default: mapDefault,
of: { type: Type, ...options },
};
return;
}
schema[name][key] = {
...schema[name][key],
...options,
Expand All @@ -247,7 +265,7 @@ const baseProp = (rawOptions: any, Type: any, target: any, key: any, isArray = f
return;
}

if (isArray) {
if (whatis === WhatIsIt.ARRAY) {
schema[name][key] = {
...schema[name][key][0],
...options,
Expand All @@ -259,6 +277,18 @@ const baseProp = (rawOptions: any, Type: any, target: any, key: any, isArray = f
return;
}

if (whatis === WhatIsIt.MAP) {
schema[name][key] = {
...schema[name][key],
type: Map,
...options
};
schema[name][key].of = {
...schema[name][key].of,
...subSchema,
};
return;
}
const Schema = mongoose.Schema;

const supressSubschemaId = rawOptions._id === false;
Expand All @@ -284,18 +314,32 @@ export const prop = (options: PropOptionsWithValidate = {}) => (target: any, key
throw new NoMetadataError(key);
}

baseProp(options, Type, target, key);
baseProp(options, Type, target, key, WhatIsIt.NONE);
};

export interface ArrayPropOptions extends BasePropOptions {
items?: any;
itemsRef?: any;
itemsRefPath?: any;
}
export interface MapPropOptions extends BasePropOptions {
of?: any;
mapDefault?: any;
}

export const arrayProp = (options: ArrayPropOptions) => (target: any, key: string) => {
const Type = options.items;
baseProp(options, Type, target, key, true);
baseProp(options, Type, target, key, WhatIsIt.ARRAY);
};

/**
* Set Options for the Map (options -> mongoose)
* @param options Options for the Map
* @public
*/
export const mapProp = (options: MapPropOptions) => (target: any, key: string) => {
const Type = options.of;
baseProp(options, Type, target, key, WhatIsIt.MAP);
};

export type Ref<T> = T | mongoose.Schema.Types.ObjectId;
50 changes: 49 additions & 1 deletion test/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { getClassForDocument } from '../src/utils';
import { Genders } from './enums/genders';
import { Role } from './enums/role';
import { Car as CarType, model as Car } from './models/car';
import { model as InternetUser } from './models/internet-user';
import { BeverageModel as Beverage, InventoryModel as Inventory, ScooterModel as Scooter } from './models/inventory';
import { AddressNested, PersonNested, PersonNestedModel } from './models/nested-object';
import { model as Person } from './models/person';
Expand Down Expand Up @@ -321,6 +322,34 @@ describe('Typegoose', () => {
expect(fSExtra).to.have.property('test3', SelectStrings.test3);
});
});

it(`should add dynamic fields using map`, async () => {
const user = await InternetUser.create({
socialNetworks: {
'twitter': 'twitter account',
'facebook': 'facebook account',
},
sideNotes: {
'day1': {
content: 'day1',
link: 'url'
},
'day2': {
content: 'day2',
link: 'url//2'
},
},
projects: {},
});
expect(user).to.not.be.an('undefined');
expect(user).to.have.property('socialNetworks').to.be.instanceOf(Map);
expect(user.socialNetworks.get('twitter')).to.be.eq('twitter account');
expect(user.socialNetworks.get('facebook')).to.be.eq('facebook account');
expect(user).to.have.property('sideNotes').to.be.instanceOf(Map);
expect(user.sideNotes.get('day1')).to.have.property('content', 'day1');
expect(user.sideNotes.get('day1')).to.have.property('link', 'url');
expect(user.sideNotes.has('day2')).to.be.equal(true);
});
});

describe('getClassForDocument()', () => {
Expand Down Expand Up @@ -430,7 +459,26 @@ describe('getClassForDocument()', () => {
});
fail('Validation must fail.');
} catch (e) {
expect(e).to.be.a.instanceof((mongoose.Error as any).ValidationError);
expect(e).to.be.a.instanceof(mongoose.Error.ValidationError);
expect(e.message).to.be.equal( // test it specificly, to know that it is not another error
'Person validation failed: email: Validator failed for path `email` with value `email`'
);
}
});

it(`Should Validate Map`, async () => {
try {
await InternetUser.create({
projects: {
p1: 'project'
}
});
fail('Validation Should Fail');
} catch (e) {
expect(e).to.be.a.instanceof(mongoose.Error.ValidationError);
expect(e.message).to.be.equal( // test it specificly, to know that it is not another error
'InternetUser validation failed: projects.p1: `project` is not a valid enum value for path `projects.p1`.'
);
}
});
});
28 changes: 28 additions & 0 deletions test/models/internet-user.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { mapProp, prop, Typegoose } from '../../src/typegoose';

export class SideNote {
@prop()
public content: string;

@prop()
public link?: string;
}

enum ProjectValue {
WORKING = 'working',
UNDERDEVELOPMENT = 'underdevelopment',
BROKEN = 'broken',
}

class InternetUser extends Typegoose {
@mapProp({ of: String, mapDefault: {} })
public socialNetworks?: Map<string, string>;

@mapProp({ of: SideNote })
public sideNotes?: Map<string, SideNote>;

@mapProp({ of: String, enum: ProjectValue })
public projects: Map<string, ProjectValue>;
}

export const model = new InternetUser().getModelForClass(InternetUser);

0 comments on commit 95c3fa6

Please sign in to comment.