Skip to content
This repository has been archived by the owner on Dec 6, 2024. It is now read-only.

feat(mongoose): support for map types #2

Merged
merged 2 commits into from
Mar 3, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ jobs:
run: npm install
- name: Format
run: |
UNFORMATTED_FILES=$(npm run format:check)
UNFORMATTED_FILES=$(npx nx format:check)
if [ -n "$UNFORMATTED_FILES" ]; then
echo "Unformatted files found:"
echo "$UNFORMATTED_FILES"
Expand Down
12 changes: 9 additions & 3 deletions packages/core/src/faker/faker-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,16 @@ import { Rule } from '../types';
* Note: the methods and their return types are specific to faker.js library.
*/
export class FakerHelpers {
static getMinMaxRule(rule: Rule | undefined) {
static getMinMaxRule(
rule: Rule | undefined,
defaultValues?: {
min: number;
max: number;
},
) {
return {
min: rule?.min,
max: rule?.max,
min: Number.isInteger(rule?.min) ? rule?.min : defaultValues?.min,
max: Number.isInteger(rule?.max) ? rule?.max : defaultValues?.max,
};
}

Expand Down
18 changes: 17 additions & 1 deletion packages/core/src/faker/modules/person.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { BaseFakerModule } from './base-faker-module';
import { FakerCandidate, FieldType } from '../../types';
import { FakerCandidate, FieldType, Rule } from '../../types';
import { faker } from '@faker-js/faker';
import { FakerHelpers } from '../faker-helpers';

export class PersonModule extends BaseFakerModule {
private firstName(): FakerCandidate {
Expand Down Expand Up @@ -115,6 +116,20 @@ export class PersonModule extends BaseFakerModule {
};
}

private age(): FakerCandidate {
return {
type: FieldType.NUMBER,
method: 'age',
callback: (rule: Rule | undefined) =>
faker.number.int(
FakerHelpers.getMinMaxRule(rule, {
min: 18,
max: 99,
}),
),
};
}

override toFakerCandidates(): FakerCandidate[] {
return [
this.firstName(),
Expand All @@ -131,6 +146,7 @@ export class PersonModule extends BaseFakerModule {
this.jobArea(),
this.jobType(),
this.zodiacSign(),
this.age(),
];
}
}
167 changes: 166 additions & 1 deletion packages/mongoose/src/mongoose-fixture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
FieldType,
FixtureOptions,
GlobPathFinder,
NonArrayFieldType,
Rule,
Value,
} from '@mocking-bird/core';
Expand Down Expand Up @@ -34,8 +35,11 @@ import { MongooseValidator } from './mongoose-validator';
export class MongooseFixture<T> extends CoreFixture<T> {
private static readonly globalOptions: FixtureOptions = {};

private static readonly MONGOOSE_SPECIAL_CHARS_REGEX = /[.*$]/g;

private static readonly NESTED_SCHEMA_INSTANCE = 'Embedded';
private static readonly ARRAY_SCHEMA_INSTANCE = 'Array';
private static readonly MAP_SCHEMA_INSTANCE = 'Map';
private static readonly VERSION_KEY = '__v';

private readonly schema: Schema<T>;
Expand Down Expand Up @@ -199,6 +203,7 @@ export class MongooseFixture<T> extends CoreFixture<T> {
* If a field has basic types such as String, Number, etc., it will not have a schema property.
* Whereas, if a field is an array or an embedded schema, it will have a schema property and will be recursively
* processed.
* Map types are also supported and processed in a special way, as they can be little more complex.
*
* @param path The path to the field.
* @param schemaType The schema type of the field.
Expand All @@ -216,6 +221,15 @@ export class MongooseFixture<T> extends CoreFixture<T> {
overrideValues: Record<FieldPath, Value> | undefined,
options: FixtureOptions | undefined,
): Value | undefined {
if (schemaType.instance === MongooseFixture.MAP_SCHEMA_INSTANCE) {
return this.generateValueForMapType(
path,
schemaType,
overrideValues,
options,
);
}

if (schemaType.schema) {
return this.generateValueForNestedSchema(
path,
Expand Down Expand Up @@ -314,6 +328,142 @@ export class MongooseFixture<T> extends CoreFixture<T> {
];
}

/**
* Generates a value for a map schema.
* The types used in the map is separated into three main categories: array, schema and basic types.
*
* There are four special cases for processing a map:
* 1. A map is of an array of schema -> { type: Map, of: [new Schema({ name: String, age: Number })] }
* 2. A map is of an array of basic types -> { type: Map, of: [String] }
* 3. A map is of a schema type -> { type: Map, of: new Schema({ name: String, age: Number }) }
* 4. A map is of a basic type -> { type: Map, of: String }
*
* @param path The path to the field.
* @param schemaType The mongoose schema type of the field.
* @param overrideValues Values to override in the schema.
* @param options Fixture generation options.
*
* @returns A key-value pair of mock values. Returns `undefined` if the map type is not defined.
*
* @private
*/
private generateValueForMapType(
path: FieldPath,
schemaType: SchemaType,
overrideValues: Record<FieldPath, Value> | undefined,
options: FixtureOptions | undefined,
): Value | undefined {
const { of: mapDefinition } = schemaType.options;

if (!mapDefinition) {
return undefined;
}

// Generate a mock vale for the map key
const mapKey = this.generateMapKey();

let value: Value | Value[] | undefined;

if (Array.isArray(mapDefinition)) {
// 1. processing of array types -> { type: Map, of: [String] } or { type: Map, of: [Schema] }
value = this.generateArrayMapValues(
mapDefinition[0],
path,
overrideValues,
options,
);
} else if (mapDefinition instanceof Schema) {
// 2. processing of schema type, which is just a nested schema generation
value = this.recursivelyGenerateValue(
mapDefinition,
path,
overrideValues,
options,
) as Value;
} else {
// 3. processing of basic map types -> { type: Map, of: String }
value = this.generateBasicMapValue(mapDefinition.name);
}

return { [mapKey]: value };
}

/**
* Generates a value for an array map type.
*
* @example
* {
* basicArray: [1, 2, 3],
* schemaArray: [{ name: 'John', age: 30 }, { name: 'Jane', age: 25 }],
* }
*
* @param mapDefinition Either based on schema or basic type. If based on basic type, we access the `name` property.
* @param path The path to the field.
* @param overrideValues Values to override in the schema.
* @param options Fixture generation options.
*
* @returns An array of mock values, either an object of values or primitive values.
*
* @private
*/
private generateArrayMapValues(
mapDefinition: Schema | { name: string },
path: FieldPath,
overrideValues: Record<FieldPath, Value> | undefined,
options: FixtureOptions | undefined,
): Value[] {
const rule = this.pathfinder.findRule(path, options?.rules);
const size = rule?.size ?? 1; // Default to 1 if size is not specified
const isSchema = mapDefinition instanceof Schema;

return Array.from({ length: size }, () => {
if (isSchema) {
return this.recursivelyGenerateValue(
mapDefinition as Schema,
path,
overrideValues,
options,
) as Value;
}

return this.generateBasicMapValue(mapDefinition.name);
});
}

/**
* Generates a map value based on the basic type -> { type: Map, of: String }
*
* @param basicFieldType The basic type of the map value. String, Number, etc.
*
* @returns A mock value for the map value.
*
* @private
*/
private generateBasicMapValue(basicFieldType: string): Value {
const fieldType = this.typeMapper.getType(
basicFieldType,
) as NonArrayFieldType;

// The field name is irrelevant here, as it will return a random value regardless.
return this.generateSingleValue('value', fieldType, undefined, false);
}

/**
* Generates a random map value which will be used for the map key.
*
* @returns A random map key.
*
* @private
*/
private generateMapKey(): string {
return this.generateSingleValue(
'mapKey', // The field name is irrelevant here, as it will return a random value regardless.
FieldType.STRING,
undefined,
false,
) as string;
}

/**
* Generates a value for a field based on the schema type.
* If a value is provided in the `overrideValues`, it will be used instead of generating a new value.
Expand Down Expand Up @@ -383,6 +533,7 @@ export class MongooseFixture<T> extends CoreFixture<T> {
if (type === FieldType.ARRAY) {
// caster is the schema type of the array elements, from which we can get the array type, e.g., String, Number, etc.
const { caster } = schemaType as Schema.Types.Array;

return caster?.instance
? this.generateArrayValue(
fieldName,
Expand Down Expand Up @@ -486,9 +637,10 @@ export class MongooseFixture<T> extends CoreFixture<T> {
options: FixtureOptions | undefined,
): boolean {
// Immediately return true if only required fields are needed and the current field is not required.
// Also, return true if the current field is the version key (__v).
// Also, return true if the current field is the version key (__v) or contains special characters (.$*).
if (
path === MongooseFixture.VERSION_KEY ||
this.containsSpecialChars(path) ||
(options?.requiredOnly && !schemaType.isRequired)
) {
return true;
Expand Down Expand Up @@ -556,6 +708,19 @@ export class MongooseFixture<T> extends CoreFixture<T> {
return [...overridePaths, ...excludePaths, ...rulePaths];
}

/**
* Checks if the path contains special characters.
*
* @param path The path to the field.
*
* @returns `true` if the path contains special characters, `false` otherwise.
*
* @private
*/
private containsSpecialChars(path: FieldPath) {
return MongooseFixture.MONGOOSE_SPECIAL_CHARS_REGEX.test(path);
}

/**
* Checks if the target is a Mongoose model.
* Model instances have a `schema` property, by which we can distinguish between a model and a schema.
Expand Down
2 changes: 0 additions & 2 deletions packages/mongoose/src/mongoose-type-mapper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,8 +47,6 @@ export class MongooseTypeMapper extends CoreTypeMapper {
case 'bigint':
return FieldType.BIGINT;
case 'map':
// TODO: support map type
throw new Error('Map type is not supported yet.');
case 'mixed':
case 'schema':
return FieldType.OBJECT;
Expand Down
14 changes: 10 additions & 4 deletions packages/mongoose/test/mongodb-integration.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,17 +25,21 @@ describe('MongoDB Integration Test', () => {

const savedDocument = await BasicModel.findById(_id).lean().exec();
const expectedDocument = {
...document,
...savedDocument,
_id: savedDocument?._id?.toString(),
idField: savedDocument?.idField?.toString(),
bigInt: savedDocument?.bigInt?.toString(),
decimal128: savedDocument?.decimal128?.toString(),
binData: savedDocument?.binData?.toString(),
uuid: savedDocument?.uuid?.toString(),
};

expect(expectedDocument).toMatchObject({
...document,
bigInt: document.bigInt?.toString(),
decimal128: document.decimal128?.toString(),
binData: document.binData?.toString(),
uuid: document.uuid?.toString(),
});
});

Expand All @@ -51,11 +55,13 @@ describe('MongoDB Integration Test', () => {
expect(error).toBeUndefined();
});

// TODO - Fix this test
it.skip('should save a document with a complex map type', async () => {
it('should save a document with a complex map type', async () => {
const fixture = new MongooseFixture(MapModel);
const mockData = fixture.generate();

expect(await MapModel.create(mockData)).not.toThrow();
const { _id } = await MapModel.create(mockData);

const savedDocument = await MapModel.findById(_id).lean().exec();
expect(savedDocument).toBeDefined();
});
});
Loading