diff --git a/packages/_example/scripts/db-seed.ts b/packages/_example/scripts/db-seed.ts index 1b7a392fd3..7530cb0b5d 100644 --- a/packages/_example/scripts/db-seed.ts +++ b/packages/_example/scripts/db-seed.ts @@ -39,6 +39,7 @@ async function createReviewRecords(connection: Connection, storeRecords: any[]): for (let i = 0; i < 30; i += 1) { reviewsRecords.push({ title: faker.word.adjective(), + rating: faker.datatype.number({ min: 1, max: 5 }), message: faker.lorem.paragraphs(1), storeId: faker.helpers.randomize(storeRecords.map(({ id }) => id)), }); diff --git a/packages/_example/src/datasources/mongoose/mongodb.ts b/packages/_example/src/datasources/mongoose/mongodb.ts index ad13b775b6..bc2eec86c5 100644 --- a/packages/_example/src/datasources/mongoose/mongodb.ts +++ b/packages/_example/src/datasources/mongoose/mongodb.ts @@ -1,4 +1,4 @@ -import mongoose from 'mongoose'; +import mongoose, { Schema } from 'mongoose'; const connectionString = 'mongodb://root:password@localhost:27017'; const connection = mongoose.createConnection(connectionString); @@ -13,10 +13,33 @@ connection.model( message: { type: String, }, + rating: { + type: Number, + }, storeId: { type: Number, required: true, }, + testArrayIds: { + type: [Number], + }, + ownerIds: { + type: [mongoose.Schema.Types.ObjectId], + ref: 'ownerMongo', + }, + oldOwnerIds: { + type: [mongoose.Schema.Types.ObjectId], + ref: 'ownerMongo', + }, + }), +); + +connection.model( + 'ownerMongo', + new Schema({ + name: { + type: String, + }, }), ); diff --git a/packages/_example/src/typings.ts b/packages/_example/src/typings.ts index 68c9f2ca81..72fa1f2b92 100644 --- a/packages/_example/src/typings.ts +++ b/packages/_example/src/typings.ts @@ -198,6 +198,7 @@ export type Schema = { plain: { title: string; message: string; + rating: number; storeId: number; _id: string; }; diff --git a/packages/datasource-mongoose/package.json b/packages/datasource-mongoose/package.json index 8286725c31..1d3fb619f3 100644 --- a/packages/datasource-mongoose/package.json +++ b/packages/datasource-mongoose/package.json @@ -9,7 +9,8 @@ ], "dependencies": { "@forestadmin/datasource-toolkit": "1.0.0-beta.16", - "mongoose": "^6.3.2" + "mongoose": "^6.3.2", + "luxon": "^2.3.0" }, "peerDependencies": {}, "scripts": { diff --git a/packages/datasource-mongoose/src/collection.ts b/packages/datasource-mongoose/src/collection.ts index a01845084a..86c60e0a84 100644 --- a/packages/datasource-mongoose/src/collection.ts +++ b/packages/datasource-mongoose/src/collection.ts @@ -1,15 +1,24 @@ +/* eslint-disable max-classes-per-file, no-underscore-dangle */ import { AggregateResult, + Aggregation, BaseCollection, + Caller, + ConditionTreeLeaf, DataSource, + Filter, + ManyToManySchema, + PaginatedFilter, + Projection, RecordData, } from '@forestadmin/datasource-toolkit'; -import { Model } from 'mongoose'; +import { Model, PipelineStage, Schema, model as mongooseModel } from 'mongoose'; +import PipelineGenerator from './utils/pipeline-generator'; import SchemaFieldsGenerator from './utils/schema-fields-generator'; export default class MongooseCollection extends BaseCollection { - private readonly model: Model; + public readonly model: Model; constructor(dataSource: DataSource, model: Model) { super(model.modelName, dataSource); @@ -17,23 +26,220 @@ export default class MongooseCollection extends BaseCollection { this.addFields(SchemaFieldsGenerator.buildFieldsSchema(model)); } - async create(): Promise { - throw new Error('not implemented'); + async create(caller: Caller, data: RecordData[]): Promise { + this.parseJSONToNestedFieldsInPlace(data); + const records = await this.model.insertMany(data); + + const ids = records.map(record => record._id); + const conditionTree = new ConditionTreeLeaf('_id', 'In', ids); + + return this.list(caller, new Filter({ conditionTree }), new Projection()); + } + + async list( + caller: Caller, + filter: PaginatedFilter, + projection: Projection, + ): Promise { + return this.model.aggregate(PipelineGenerator.find(this, this.model, filter, projection)); } - async list(): Promise { - throw new Error('not implemented'); + async update(caller: Caller, filter: Filter, patch: RecordData): Promise { + const ids = await this.list(caller, filter, new Projection('_id')); + await this.model.updateMany({ _id: ids.map(record => record._id) }, patch); } - async update(): Promise { - throw new Error('not implemented'); + async delete(caller: Caller, filter: Filter): Promise { + const ids = await this.list(caller, filter, new Projection('_id')); + await this.model.deleteMany({ _id: ids.map(record => record._id) }); } - async delete(): Promise { - throw new Error('not implemented'); + async aggregate( + caller: Caller, + filter: Filter, + aggregation: Aggregation, + limit?: number, + ): Promise { + let pipeline = PipelineGenerator.find(this, this.model, filter, aggregation.projection); + pipeline = PipelineGenerator.group(aggregation, pipeline); + if (limit) pipeline.push({ $limit: limit }); + + return MongooseCollection.formatRecords(await this.model.aggregate(pipeline)); + } + + protected static formatRecords(records: RecordData[]): AggregateResult[] { + const results: AggregateResult[] = []; + + records.forEach(record => { + const group = Object.entries(record?._id || {}).reduce((computed, [field, value]) => { + computed[field] = value; + + return computed; + }, {}); + + results.push({ value: record.value, group }); + }); + + return results; + } + + private parseJSONToNestedFieldsInPlace(data: RecordData[]) { + data.forEach(currentData => { + Object.entries(this.schema.fields).forEach(([fieldName, schema]) => { + if (schema.type === 'Column' && typeof schema.columnType === 'object') { + if (typeof currentData[fieldName] === 'string') { + currentData[fieldName] = JSON.parse(currentData[fieldName]); + } + } + }); + }); + } +} + +export class ManyToManyMongooseCollection extends MongooseCollection { + private readonly originCollection: MongooseCollection; + private readonly foreignCollection: MongooseCollection; + private readonly originFieldNameOfIds: string; + + constructor( + originCollection: MongooseCollection, + foreignCollection: MongooseCollection, + originFieldNameOfIds: string, + manyToManyRelation: string, + ) { + const model = ManyToManyMongooseCollection.buildModel( + originCollection, + foreignCollection, + manyToManyRelation, + ); + + super(originCollection.dataSource, model); + this.originCollection = originCollection; + this.foreignCollection = foreignCollection; + this.originFieldNameOfIds = originFieldNameOfIds; + } + + override async create(caller: Caller, data: RecordData[]): Promise { + const records = await this.createManyToMany(data); + const ids = records.map(record => record._id); + const conditionTree = new ConditionTreeLeaf('_id', 'In', ids); + + return this.list(caller, new Filter({ conditionTree }), new Projection()); } - async aggregate(): Promise { - throw new Error('not implemented'); + override async list( + caller: Caller, + filter: PaginatedFilter, + projection: Projection, + ): Promise { + const { model } = this.originCollection; + + return model.aggregate(this.buildListPipeline(model, filter, projection)); + } + + override async update(caller: Caller, filter: Filter, patch: RecordData): Promise { + return this.updateManyToMany(caller, filter, patch); + } + + override async delete(caller: Caller, filter: Filter): Promise { + return this.updateManyToMany(caller, filter); + } + + override async aggregate( + caller: Caller, + filter: Filter, + aggregation: Aggregation, + limit?: number, + ): Promise { + const { model } = this.originCollection; + let pipeline = this.buildListPipeline(model, filter, aggregation.projection); + pipeline = PipelineGenerator.group(aggregation, pipeline); + if (limit) pipeline.push({ $limit: limit }); + + return MongooseCollection.formatRecords(await model.aggregate(pipeline)); + } + + private async createManyToMany(data: RecordData[]): Promise { + return Promise.all( + data.map(item => { + return this.originCollection.model.updateOne( + { _id: item[`${this.originCollection.name}_id`] }, + { + $addToSet: { [this.originFieldNameOfIds]: item[`${this.foreignCollection.name}_id`] }, + }, + ); + }), + ); + } + + private async updateManyToMany( + caller: Caller, + filter: Filter, + patch?: RecordData, + ): Promise { + const records = await this.list( + caller, + filter, + new Projection(`${this.originCollection.name}_id`, `${this.foreignCollection.name}_id`), + ); + + for (const record of records) { + // improve by grouping by origin id + // eslint-disable-next-line no-await-in-loop + await this.originCollection.model.updateOne( + { _id: record[`${this.originCollection.name}_id`] }, + { + $pull: { [this.originFieldNameOfIds]: record[`${this.foreignCollection.name}_id`] }, + }, + ); + + if (patch) { + // eslint-disable-next-line no-await-in-loop + await this.originCollection.model.updateOne( + { _id: patch[`${this.originCollection.name}_id`] }, + { + $addToSet: { [this.originFieldNameOfIds]: patch[`${this.foreignCollection.name}_id`] }, + }, + ); + } + } + } + + private buildListPipeline( + model: Model, + filter: Filter, + projection: Projection, + ): PipelineStage[] { + const allFilter = new PaginatedFilter({}); + const allProjection = new Projection(); + let pipeline = PipelineGenerator.find(this.originCollection, model, allFilter, allProjection); + pipeline = PipelineGenerator.emulateManyToManyCollection( + model, + this.originFieldNameOfIds, + this.originCollection.name, + this.foreignCollection.name, + pipeline, + ); + + return PipelineGenerator.find(this, model, filter, projection, pipeline); + } + + private static buildModel( + originCollection: MongooseCollection, + foreignCollection: MongooseCollection, + manyToManyRelation: string, + ): Model { + const { foreignKey, originKey, throughCollection } = foreignCollection.schema.fields[ + manyToManyRelation + ] as ManyToManySchema; + const schema = new Schema( + { + [foreignKey]: { type: Schema.Types.ObjectId, ref: originCollection.name }, + [originKey]: { type: Schema.Types.ObjectId, ref: foreignCollection.name }, + }, + { _id: false }, + ); + + return mongooseModel(throughCollection, schema, null, { overwriteModels: true }); } } diff --git a/packages/datasource-mongoose/src/utils/field-name-generator.ts b/packages/datasource-mongoose/src/utils/field-name-generator.ts new file mode 100644 index 0000000000..a8cbfe8c27 --- /dev/null +++ b/packages/datasource-mongoose/src/utils/field-name-generator.ts @@ -0,0 +1,35 @@ +export default class FieldNameGenerator { + static generateManyToOne(fieldName: string, modelName: string): string { + return `${fieldName}__${modelName.split('_').pop()}__manyToOne`; + } + + static generateOneToMany(foreignCollectionName: string, foreignKey: string): string { + return `${foreignCollectionName}__${foreignKey}__oneToMany`; + } + + static generateThroughFieldName( + modelName: string, + foreignCollectionName: string, + fieldName: string, + ): string { + return `${modelName}__${foreignCollectionName}__${fieldName}`; + } + + static getOriginFieldNameOfIds(name: string) { + return name.split('_').pop(); + } + + static generateManyToManyName( + foreignCollectionName: string, + originKey: string, + throughCollection: string, + ): string { + return `${foreignCollectionName}__${originKey}__${this.getOriginFieldNameOfIds( + throughCollection, + )}`; + } + + static generateKey(name: string): string { + return `${name}_id`; + } +} diff --git a/packages/datasource-mongoose/src/utils/pipeline-generator.ts b/packages/datasource-mongoose/src/utils/pipeline-generator.ts new file mode 100644 index 0000000000..0998e8c7cd --- /dev/null +++ b/packages/datasource-mongoose/src/utils/pipeline-generator.ts @@ -0,0 +1,479 @@ +import { + Aggregation, + AggregationOperation, + Collection, + CollectionSchema, + ConditionTree, + ConditionTreeBranch, + ConditionTreeLeaf, + DateOperation, + ManyToOneSchema, + Operator, + PaginatedFilter, + Projection, +} from '@forestadmin/datasource-toolkit'; +import { DateTime } from 'luxon'; +import { Model, PipelineStage, Schema, SchemaType, Types, isValidObjectId } from 'mongoose'; + +const STRING_OPERATORS = [ + 'Like', + 'Contains', + 'NotContains', + 'EndsWith', + 'LongerThan', + 'ShorterThan', +]; + +const AGGREGATION_OPERATION: Record = { + Sum: '$sum', + Avg: '$avg', + Count: '$sum', + Max: '$max', + Min: '$min', +}; +const GROUP_OPERATION: Record = { + Year: '%Y-01-01', + Month: '%Y-%m-01', + Day: '%Y-%m-%d', + Week: '%Y-%m-%d', +}; + +export default class PipelineGenerator { + static emulateManyToManyCollection( + model: Model, + manyToManyField: string, + originName: string, + foreignName: string, + pipeline: PipelineStage[], + ): PipelineStage[] { + const originId = `${originName}_id`; + const foreignId = `${foreignName}_id`; + // fake also the schema to provide the type of the computed fields + model.schema.add(new Schema({ [originId]: { type: Schema.Types.ObjectId, ref: originName } })); + model.schema.add( + new Schema({ [foreignId]: { type: Schema.Types.ObjectId, ref: foreignName } }), + ); + + pipeline.push({ $unwind: `$${manyToManyField}` }); + pipeline.push({ $addFields: { [originId]: '$_id', [foreignId]: `$${manyToManyField}` } }); + pipeline.push({ $project: { _id: false, [originId]: true, [foreignId]: true } }); + + return pipeline; + } + + static group(aggregation: Aggregation, pipeline: PipelineStage[]): PipelineStage[] { + const aggregationOperation = AGGREGATION_OPERATION[aggregation.operation]; + let value: unknown = this.formatNestedFieldPath(`$${aggregation.field}`); + if (aggregation.operation === 'Count') value = { $cond: [{ $ne: [value, null] }, 1, 0] }; + const condition = { [aggregationOperation]: value }; + + if (!aggregation.groups) { + pipeline.push({ $group: { value: { [aggregationOperation]: condition }, _id: null } }); + + return pipeline; + } + + // eslint-disable-next-line no-underscore-dangle,@typescript-eslint/naming-convention + const _id = aggregation.groups.reduce((ids, group) => { + let field: unknown = this.formatNestedFieldPath(`$${group.field}`); + + if (group.operation) { + if (group.operation === 'Week') { + const date = { $dateTrunc: { date: field, startOfWeek: 'Monday', unit: 'week' } }; + field = { $dateToString: { format: GROUP_OPERATION[group.operation], date } }; + } else { + field = { $dateToString: { format: GROUP_OPERATION[group.operation], date: field } }; + } + } + + ids[group.field] = field; + + return ids; + }, {}); + + pipeline.push({ $group: { value: condition, _id } }); + + return pipeline; + } + + static find( + collection: Collection, + model: Model, + filter: PaginatedFilter, + projection: Projection, + pipeline: PipelineStage[] = [], + ): PipelineStage[] { + const { schema } = collection; + const joints = new Set(); + const fields = new Set(); + + const tree = filter?.conditionTree; + const match = PipelineGenerator.computeMatch(schema, model, tree, joints, fields); + const sort = PipelineGenerator.computeSort(schema, filter?.sort, joints); + const project = PipelineGenerator.computeProject(schema, model, projection, joints, fields); + + pipeline.push(...PipelineGenerator.computeLookups(collection, model, joints)); + if (fields.size) pipeline.push(PipelineGenerator.computeFields(fields)); + if (match) pipeline.push({ $match: match }); + if (sort) pipeline.push({ $sort: sort }); + if (filter?.page?.skip !== undefined) pipeline.push({ $skip: filter.page.skip }); + if (filter?.page?.limit !== undefined) pipeline.push({ $limit: filter.page.limit }); + pipeline.push({ $project: project }); + + return pipeline; + } + + private static computeMatch( + schema: CollectionSchema, + model: Model, + conditionTree: ConditionTree, + joints: Set, + fields: Set, + ): PipelineStage.Match['$match'] { + if (!conditionTree) { + return null; + } + + if ((conditionTree as ConditionTreeBranch).aggregator) { + const tree = conditionTree as ConditionTreeBranch; + + return PipelineGenerator.computeMatchBranch(schema, model, tree, joints, fields); + } + + const tree = conditionTree as ConditionTreeLeaf; + + return PipelineGenerator.computeMatchLeaf(schema, model, tree, joints, fields); + } + + private static computeMatchBranch( + schema: CollectionSchema, + model: Model, + branch: ConditionTreeBranch, + joints: Set, + fields: Set, + ): PipelineStage.Match['$match'] { + const subMatch = branch.conditions.map(condition => + PipelineGenerator.computeMatch(schema, model, condition, joints, fields), + ); + + if (branch.aggregator === 'And') { + return { $and: subMatch }; + } + + if (branch.aggregator === 'Or') { + return { $or: subMatch }; + } + + throw new Error(`Invalid '${branch.aggregator}' aggregator`); + } + + private static computeMatchLeaf( + schema: CollectionSchema, + model: Model, + leaf: ConditionTreeLeaf, + joints: Set, + fields: Set, + ): PipelineStage.Match['$match'] { + const value = this.formatAndCastLeafValue(leaf, model, fields, schema, joints); + const condition = this.buildMatchCondition(leaf.operator, value); + + return { [this.formatNestedFieldPath(leaf.field)]: condition }; + } + + private static formatAndCastLeafValue( + leaf: ConditionTreeLeaf, + model: Model, + fields: Set, + schema: CollectionSchema, + joints: Set, + ) { + let { value } = leaf; + leaf.field = this.formatNestedFieldPath(leaf.field); + + if (this.isRelationField(leaf.field, schema)) { + this.addJoints(joints, leaf.field); + } + + const schemaType = this.getSchemaType(model, leaf, schema); + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignorex + const subType = schemaType?.caster?.instance; + const instanceType = schemaType?.instance; + + if (instanceType === 'ObjectID') { + if (STRING_OPERATORS.includes(leaf.operator)) { + fields.add(leaf.field); + leaf.field = this.formatStringFieldName(leaf.field); + } else if (Array.isArray(value) && value.every(v => isValidObjectId(v))) { + value = (value as Array).map(id => new Types.ObjectId(id)); + } else if (isValidObjectId(value)) { + value = new Types.ObjectId(value as string); + } + } else if (instanceType === 'Date') { + value = new Date(value as string); + } else if (instanceType === 'Array') { + if ( + subType === 'Date' && + Array.isArray(value) && + value.every(v => DateTime.fromISO(v).isValid) + ) { + value = (value as Array).map(v => new Date(v)); + } else if ( + subType === 'ObjectID' && + Array.isArray(value) && + value.every(v => isValidObjectId(v)) + ) { + value = (value as Array).map(id => new Types.ObjectId(id)); + } + } + + return value; + } + + private static getSchemaType( + model: Model, + leaf: ConditionTreeLeaf, + collectionSchema: CollectionSchema, + ): SchemaType { + let { schema } = model; + let { field } = leaf; + + if (this.isRelationField(field, collectionSchema)) { + field = this.getFieldName(leaf.field); + const refField = this.getParentPath(leaf.field).split('__').slice(0, -2).join(':'); + const referenceName = model.schema.paths[refField].options.ref; + schema = this.getMongooseModel(model, referenceName).schema; + } + + return schema.paths[field]; + } + + private static buildMatchCondition( + operator: Operator, + formattedLeafValue: unknown, + ): PipelineStage.Match['$match'] { + switch (operator) { + case 'GreaterThan': + return { $gt: formattedLeafValue }; + case 'LessThan': + return { $lt: formattedLeafValue }; + case 'Equal': + return { $eq: formattedLeafValue }; + case 'NotEqual': + return { $ne: formattedLeafValue }; + case 'In': + return { $in: formattedLeafValue }; + case 'IncludesAll': + return { $all: formattedLeafValue }; + case 'Contains': + return new RegExp(`.*${formattedLeafValue}.*`); + case 'NotContains': + return { $not: new RegExp(`.*${formattedLeafValue}.*`) }; + case 'Like': + return this.like(formattedLeafValue as string); + case 'Present': + return { $exists: true, $ne: null }; + default: + throw new Error(`Unsupported '${operator}' operator`); + } + } + + /** @see https://stackoverflow.com/a/18418386/1897495 */ + private static like(pattern: string): RegExp { + let regexp = pattern; + + // eslint-disable-next-line no-useless-escape + regexp = regexp.replace(/([\.\\\+\*\?\[\^\]\$\(\)\{\}\=\!\<\>\|\:\-])/g, '\\$1'); + regexp = regexp.replace(/%/g, '.*').replace(/_/g, '.'); + + return RegExp(`^${regexp}$`, 'gi'); + } + + private static computeDefaultProject( + model: Model, + schema: CollectionSchema, + joints: Set, + fields: Set, + ): Record { + const project = { __v: false }; + + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + Object.keys(model.schema.singleNestedPaths).forEach(field => { + if (field.includes('_id')) { + project[field] = false; + } + }); + + // adds jointures + Object.keys(schema.fields).forEach(fieldName => { + if (this.isRelationField(fieldName, schema)) { + this.addJoints(joints, fieldName); + } + }); + + // removes the "computed fields" + Array.from(fields).forEach(field => { + project[PipelineGenerator.formatStringFieldName(field)] = false; + }); + + return project; + } + + private static computeProject( + schema: CollectionSchema, + model: Model, + projection: Projection, + joints: Set, + fields: Set, + ): PipelineStage.Project['$project'] { + if (projection && projection.length === 0) { + return PipelineGenerator.computeDefaultProject(model, schema, joints, fields); + } + + const project: PipelineStage.Project['$project'] = { _id: false }; + + for (const field of projection) { + const formattedField = this.formatNestedFieldPath(field); + + if (this.isRelationField(formattedField, schema)) { + this.addJoints(joints, formattedField); + } + + project[formattedField] = true; + } + + return project; + } + + private static computeSort( + schema: CollectionSchema, + sorts: PaginatedFilter['sort'], + joints: Set, + ): PipelineStage.Sort['$sort'] { + if (!sorts || sorts.length === 0) return null; + + const result = {}; + + for (const { field, ascending } of sorts) { + const formattedField = this.formatNestedFieldPath(field); + + if (this.isRelationField(formattedField, schema)) { + this.addJoints(joints, formattedField); + } + + result[formattedField] = ascending ? 1 : -1; + } + + return result; + } + + private static computeLookups( + collection: Collection, + model: Model, + joints: Set, + ): (PipelineStage.Lookup | PipelineStage.Unwind)[] { + const lookups = []; + + Array.from(joints).forEach(path => { + let currentCollection = collection; + let currentPath; + + path.split('.').forEach(relationName => { + if (currentPath) { + currentPath = `${currentPath}.${relationName}`; + } else { + currentPath = relationName; + } + + const relation = currentCollection.schema.fields[relationName] as ManyToOneSchema; + currentCollection = currentCollection.dataSource.getCollection(relation.foreignCollection); + + const from = this.getMongooseModel(model, relation.foreignCollection).collection + .collectionName; + const parentPath = this.getParentPath(currentPath); + let localField = relation.foreignKey; + if (parentPath !== currentPath) localField = `${parentPath}.${relation.foreignKey}`; + + lookups.push({ + $lookup: { + from, + localField, + foreignField: relation.foreignKeyTarget, + as: currentPath, + }, + }); + lookups.push({ $unwind: { path: `$${currentPath}`, preserveNullAndEmptyArrays: true } }); + lookups.push({ $project: { [`${currentPath}.__v`]: false } }); + }); + }); + + return lookups; + } + + private static getMongooseModel(model: Model, modelName: string): Model { + return model.db.models[modelName]; + } + + private static computeFields(fields: Set): PipelineStage.AddFields { + return Array.from(fields).reduce( + (computed, field) => { + const stringField = PipelineGenerator.formatStringFieldName(field); + computed.$addFields[stringField] = { $toString: `$${field}` }; + + return computed; + }, + { $addFields: {} }, + ); + } + + private static addJoints(joints: Set, field): void { + const paths = this.getParentPath(this.formatNestedFieldPath(field)); + let isJointAlreadyExists = false; + Array.from(joints).forEach(joint => { + if (joint.startsWith(paths)) { + isJointAlreadyExists = true; + } else if (paths.startsWith(joint)) { + joints.delete(joint); + } + }); + if (!isJointAlreadyExists) joints.add(paths); + } + + private static formatNestedFieldPath(field: string): string { + return field.replace(':', '.'); + } + + private static isRelationField(field: string, schema: CollectionSchema): boolean { + const relation = this.getParentPath(field).split('.').shift(); + + return schema.fields[relation]?.type === 'ManyToOne'; + } + + private static formatStringFieldName(field: string): string { + const parentPath = this.getParentPath(field); + const fieldName = this.getFieldName(field); + + if (parentPath === field) { + return `string_${fieldName}`; + } + + return `${parentPath}.string_${fieldName}`; + } + + private static getParentPath(path: string): string { + if (!path.includes('.')) { + return path; + } + + return path.split('.').slice(0, -1).join('.'); + } + + private static getFieldName(path: string): string { + if (!path.includes('.')) { + return path; + } + + return path.split('.').slice(-1).join('.'); + } +} diff --git a/packages/datasource-mongoose/src/utils/schema-fields-generator.ts b/packages/datasource-mongoose/src/utils/schema-fields-generator.ts index 8dde4f3c63..fd7cb23b93 100644 --- a/packages/datasource-mongoose/src/utils/schema-fields-generator.ts +++ b/packages/datasource-mongoose/src/utils/schema-fields-generator.ts @@ -1,67 +1,165 @@ +/* eslint-disable no-underscore-dangle */ import { CollectionSchema, ColumnSchema, ColumnType, + ManyToManySchema, ManyToOneSchema, + OneToManySchema, PrimitiveTypes, RecordData, } from '@forestadmin/datasource-toolkit'; import { Model, Schema, SchemaType } from 'mongoose'; +import FieldNameGenerator from './field-name-generator'; import FilterOperatorBuilder from './filter-operator-builder'; -import MongooseCollection from '../collection'; +import MongooseCollection, { ManyToManyMongooseCollection } from '../collection'; export default class SchemaFieldsGenerator { static addInverseRelationships(collections: MongooseCollection[]): void { + // avoid to create two times the many to many collection + // when iterating on the many to many type field schema + const createdManyToMany: string[] = []; + const isAlreadyCreated = name => createdManyToMany.includes(name); collections.forEach(collection => { - Object.values(collection.schema.fields).forEach(fieldSchema => { + Object.entries(collection.schema.fields).forEach(([fieldName, fieldSchema]) => { if (fieldSchema.type === 'ManyToOne') { - const foreignCollection = collections.find(c => c.name === fieldSchema.foreignCollection); - - if (!foreignCollection) { - throw new Error(`The collection '${fieldSchema.foreignCollection}' does not exist`); - } - - const field = `${foreignCollection.name}__${fieldSchema.foreignKey}__oneToMany`; - foreignCollection.schema.fields[field] = { - foreignCollection: collection.name, - originKey: fieldSchema.foreignKey, - originKeyTarget: '_id', - type: 'OneToMany', - }; + this.addOneToManyRelation(fieldSchema, collection); + } else if (fieldSchema.type === 'ManyToMany' && !isAlreadyCreated(fieldName)) { + this.addManyToManyRelationAndCollection(fieldSchema, collection, createdManyToMany); } }); }); } static buildFieldsSchema(model: Model): CollectionSchema['fields'] { - const schemaFields = {}; - - Object.entries(model.schema.paths).forEach(([fieldName, field]) => { + return Object.entries(model.schema.paths).reduce((schemaFields, [fieldName, schemaType]) => { const mixedFieldPattern = '$*'; const privateFieldPattern = '__'; if (fieldName.startsWith(privateFieldPattern) || fieldName.includes(mixedFieldPattern)) { - return; + return schemaFields; } - if (field.options.ref) { - schemaFields[`${fieldName}_manyToOne`] = { - type: 'ManyToOne', - foreignCollection: field.options.ref, - foreignKey: field.path, - foreignKeyTarget: '_id', - } as ManyToOneSchema; - schemaFields[fieldName] = SchemaFieldsGenerator.buildColumnSchema(field, 'String'); + const isRefField = schemaType.options?.ref; + const isArrayOfRefField = + schemaType.options?.type[0]?.schemaName === 'ObjectId' && isRefField; + + if (isArrayOfRefField) { + this.addManyToManyRelation(schemaType, model.modelName, fieldName, schemaFields); + } else if (isRefField) { + this.addManyToOneRelation(schemaType, model.modelName, fieldName, schemaFields); + } else { + schemaFields[schemaType.path.split('.').shift()] = SchemaFieldsGenerator.buildColumnSchema( + schemaType, + SchemaFieldsGenerator.getColumnType(schemaType.instance, schemaType), + ); } - schemaFields[field.path.split('.').shift()] = SchemaFieldsGenerator.buildColumnSchema( - field, - SchemaFieldsGenerator.getColumnType(field.instance, field), - ); - }); + return schemaFields; + }, {}); + } - return schemaFields; + private static addManyToOneRelation( + schema: SchemaType, + modelName: string, + fieldName: string, + schemaFields: CollectionSchema['fields'], + ): void { + schemaFields[FieldNameGenerator.generateManyToOne(fieldName, modelName)] = { + type: 'ManyToOne', + foreignCollection: schema.options.ref, + foreignKey: schema.path, + foreignKeyTarget: '_id', + } as ManyToOneSchema; + // when there is no _id all ref are an id, this case is for the generated many to many + const isPrimaryKey = !schemaFields._id; + schemaFields[fieldName] = SchemaFieldsGenerator.buildColumnSchema( + schema, + 'String', + isPrimaryKey, + ); + } + + private static addManyToManyRelation( + schema: SchemaType, + modelName: string, + fieldName: string, + schemaFields: CollectionSchema['fields'], + ): void { + const foreignCollectionName = schema.options?.ref; + + const throughCollection = FieldNameGenerator.generateThroughFieldName( + modelName, + foreignCollectionName, + fieldName, + ); + + schemaFields[FieldNameGenerator.generateManyToOne(fieldName, modelName)] = { + type: 'ManyToMany', + foreignCollection: foreignCollectionName, + throughCollection, + foreignKey: FieldNameGenerator.generateKey(foreignCollectionName), + originKey: FieldNameGenerator.generateKey(modelName), + foreignKeyTarget: '_id', + originKeyTarget: '_id', + } as ManyToManySchema; + schemaFields[fieldName] = SchemaFieldsGenerator.buildColumnSchema(schema, 'String'); + } + + private static addManyToManyRelationAndCollection( + fieldSchema: ManyToManySchema, + collection: MongooseCollection, + createdManyToMany: string[], + ): void { + const foreignCollection = collection.dataSource.getCollection( + fieldSchema.foreignCollection, + ) as MongooseCollection; + const foreignKey = FieldNameGenerator.generateKey(collection.name); + const originKey = fieldSchema.foreignKey; + const { throughCollection } = fieldSchema; + + const manyToMany = FieldNameGenerator.generateManyToManyName( + foreignCollection.name, + fieldSchema.originKey, + throughCollection, + ); + createdManyToMany.push(manyToMany); + + foreignCollection.schema.fields[manyToMany] = { + throughCollection, + foreignKey, + originKey, + foreignCollection: collection.name, + type: 'ManyToMany', + foreignKeyTarget: '_id', + originKeyTarget: '_id', + } as ManyToManySchema; + + collection.dataSource.addCollection( + new ManyToManyMongooseCollection( + collection, + foreignCollection, + FieldNameGenerator.getOriginFieldNameOfIds(throughCollection), + manyToMany, + ), + ); + } + + private static addOneToManyRelation( + schema: ManyToOneSchema, + collection: MongooseCollection, + ): void { + const foreignCollection = collection.dataSource.getCollection( + schema.foreignCollection, + ) as MongooseCollection; + const field = FieldNameGenerator.generateOneToMany(foreignCollection.name, schema.foreignKey); + foreignCollection.schema.fields[field] = { + foreignCollection: collection.name, + originKey: schema.foreignKey, + originKeyTarget: '_id', + type: 'OneToMany', + } as OneToManySchema; } private static getColumnType(instance: string, field: SchemaType): ColumnType { @@ -111,13 +209,17 @@ export default class SchemaFieldsGenerator { } } - private static buildColumnSchema(field: SchemaType, columnType: ColumnType): ColumnSchema { + private static buildColumnSchema( + field: SchemaType, + columnType: ColumnType, + isPrimaryKey = false, + ): ColumnSchema { return { columnType, filterOperators: FilterOperatorBuilder.getSupportedOperators(columnType as PrimitiveTypes), defaultValue: field.options?.default, enumValues: this.getEnumValues(field), - isPrimaryKey: field.path === '_id', + isPrimaryKey: field.path === '_id' || isPrimaryKey, isReadOnly: !!field.options?.immutable, isSortable: !(columnType instanceof Object || columnType === 'Json'), type: 'Column', @@ -125,7 +227,7 @@ export default class SchemaFieldsGenerator { }; } - private static getEnumValues(field: SchemaType) { + private static getEnumValues(field: SchemaType): string[] { return field.options?.enum instanceof Array ? field.options.enum : field.options?.enum?.values; } diff --git a/packages/datasource-mongoose/test/collection.test.ts b/packages/datasource-mongoose/test/collection.test.ts new file mode 100644 index 0000000000..2b16949856 --- /dev/null +++ b/packages/datasource-mongoose/test/collection.test.ts @@ -0,0 +1,1756 @@ +/* eslint-disable no-underscore-dangle */ +import * as factories from '@forestadmin/datasource-toolkit/dist/test/__factories__'; +import { + Aggregation, + Aggregator, + ConditionTree, + Filter, + Operator, + Page, + Projection, + Sort, +} from '@forestadmin/datasource-toolkit'; +import { Connection, Schema, Types, createConnection, model } from 'mongoose'; + +import MongooseCollection from '../src/collection'; +import MongooseDatasource from '../src/datasource'; + +describe('MongooseCollection', () => { + let connection: Connection; + + const setupReview = async () => { + const connectionString = 'mongodb://root:password@localhost:27019'; + connection = createConnection(connectionString); + + connection.model( + 'review', + new Schema({ + title: { + type: String, + }, + message: { + type: String, + }, + rating: { + type: Number, + }, + tags: { + type: [String], + }, + createdDate: { + type: Date, + }, + modificationDates: { + type: [Date], + }, + editorIds: { + type: [Types.ObjectId], + }, + authorId: { + type: Types.ObjectId, + }, + nestedField: new Schema({ nested: [new Schema({ level: Number })] }), + }), + ); + await connection.dropDatabase(); + }; + + const setupWithManyToOneRelation = async () => { + const connectionString = 'mongodb://root:password@localhost:27019'; + connection = createConnection(connectionString); + + connection.model( + 'owner', + new Schema({ + storeId: { + type: Types.ObjectId, + ref: 'store', + }, + name: { + type: String, + }, + }), + ); + + connection.model( + 'store', + new Schema({ + name: { + type: String, + }, + addressId: { + type: Types.ObjectId, + ref: 'address', + }, + }), + ); + + connection.model('address', new Schema({ name: { type: String } })); + + await connection.dropDatabase(); + }; + + const setupWith2ManyToManyRelations = async () => { + const connectionString = 'mongodb://root:password@localhost:27019'; + connection = createConnection(connectionString); + + connection.model( + 'owner', + new Schema({ + stores: { type: [Schema.Types.ObjectId], ref: 'store' }, + oldStores: { type: [Schema.Types.ObjectId], ref: 'store' }, + name: { + type: String, + }, + }), + ); + + connection.model('store', new Schema({ name: { type: String } })); + + await connection.dropDatabase(); + }; + + afterEach(async () => { + await connection?.close(); + }); + + it('should build a collection with the right datasource and schema', () => { + const mockedConnection = { models: {} } as Connection; + const dataSource = new MongooseDatasource(mockedConnection); + + const carsModel = model('aModel', new Schema({ aField: { type: Number } })); + + const mongooseCollection = new MongooseCollection(dataSource, carsModel); + + expect(mongooseCollection.dataSource).toEqual(dataSource); + expect(mongooseCollection.name).toEqual('aModel'); + expect(mongooseCollection.schema).toEqual({ + actions: {}, + fields: { + aField: expect.any(Object), + _id: expect.any(Object), + }, + searchable: false, + segments: [], + }); + }); + + describe('update', () => { + it('should update the created record', async () => { + // given + await setupReview(); + const dataSource = new MongooseDatasource(connection); + const review = dataSource.getCollection('review'); + const record = { _id: new Types.ObjectId(), message: 'old message' }; + await review.create(factories.caller.build(), [record]); + + const filter = factories.filter.build({ + conditionTree: factories.conditionTreeLeaf.build({ + field: '_id', + value: record._id, + operator: 'Equal', + }), + }); + + // when + await review.update(factories.caller.build(), filter, { + message: 'new message', + }); + + // then + const updatedRecord = await review.list( + factories.caller.build(), + filter, + new Projection('message'), + ); + expect(updatedRecord).toEqual([{ message: 'new message' }]); + }); + + describe('with a many to many relation', () => { + describe('when there is the foreign relation to update', () => { + it('updates the right id in the array of objectId', async () => { + // given + await setupWith2ManyToManyRelations(); + const dataSource = new MongooseDatasource(connection); + const store = dataSource.getCollection('store'); + const owner = dataSource.getCollection('owner'); + const ownerStore = dataSource.getCollection('owner__store__stores'); + + const storeRecordA = { _id: new Types.ObjectId(), name: 'A' }; + const storeRecordB = { _id: new Types.ObjectId(), name: 'B' }; + await store.create(factories.caller.build(), [storeRecordA, storeRecordB]); + const ownerRecordA = { _id: new Types.ObjectId(), stores: [storeRecordA._id] }; + const ownerRecordB = { _id: new Types.ObjectId() }; + await owner.create(factories.caller.build(), [ownerRecordA, ownerRecordB]); + + // when + await ownerStore.update( + factories.caller.build(), + new Filter({ + conditionTree: factories.conditionTreeBranch.build({ + aggregator: 'And', + conditions: [ + factories.conditionTreeLeaf.build({ + value: ownerRecordA._id, + operator: 'Equal', + field: 'owner_id', + }), + factories.conditionTreeLeaf.build({ + value: storeRecordA._id, + operator: 'Equal', + field: 'store_id', + }), + ], + }), + }), + + { owner_id: ownerRecordA._id, store_id: storeRecordB._id }, + ); + + // then + + const expectedOwnerStore = await ownerStore.list( + factories.caller.build(), + factories.filter.build(), + new Projection('store_id', 'owner_id'), + ); + expect(expectedOwnerStore).toEqual([ + { owner_id: ownerRecordA._id, store_id: storeRecordB._id }, + ]); + }); + }); + + describe('when there is the origin and the foreign relation to update', () => { + it('moves the right id to the right record', async () => { + // given + await setupWith2ManyToManyRelations(); + const dataSource = new MongooseDatasource(connection); + const store = dataSource.getCollection('store'); + const owner = dataSource.getCollection('owner'); + const ownerStore = dataSource.getCollection('owner__store__stores'); + + const storeRecordA = { _id: new Types.ObjectId(), name: 'A' }; + const storeRecordB = { _id: new Types.ObjectId(), name: 'B' }; + await store.create(factories.caller.build(), [storeRecordA, storeRecordB]); + + const ownerRecordA = { _id: new Types.ObjectId(), stores: [storeRecordA._id] }; + const ownerRecordB = { _id: new Types.ObjectId() }; + await owner.create(factories.caller.build(), [ownerRecordA, ownerRecordB]); + + // when + await ownerStore.update( + factories.caller.build(), + new Filter({ + conditionTree: factories.conditionTreeBranch.build({ + aggregator: 'And', + conditions: [ + factories.conditionTreeLeaf.build({ + value: ownerRecordA._id, + operator: 'Equal', + field: 'owner_id', + }), + factories.conditionTreeLeaf.build({ + value: storeRecordA._id, + operator: 'Equal', + field: 'store_id', + }), + ], + }), + }), + + { owner_id: ownerRecordB._id, store_id: storeRecordB._id }, + ); + + // then + + const expectedOwnerStore = await ownerStore.list( + factories.caller.build(), + factories.filter.build(), + new Projection('store_id', 'owner_id'), + ); + expect(expectedOwnerStore).toEqual([ + { owner_id: ownerRecordB._id, store_id: storeRecordB._id }, + ]); + }); + }); + }); + }); + + describe('delete', () => { + it('should delete the created record', async () => { + // given + await setupReview(); + const dataSource = new MongooseDatasource(connection); + const review = dataSource.getCollection('review'); + const record = { _id: new Types.ObjectId(), message: 'old message' }; + await review.create(factories.caller.build(), [record]); + + const filter = factories.filter.build({ + conditionTree: factories.conditionTreeLeaf.build({ + field: '_id', + value: record._id, + operator: 'Equal', + }), + }); + + // when + await review.delete(factories.caller.build(), filter); + + // then + const updatedRecord = await review.list(factories.caller.build(), filter, new Projection()); + expect(updatedRecord).toEqual([]); + }); + + describe('with a many to many collection', () => { + it('deletes the right id to the right record', async () => { + // given + await setupWith2ManyToManyRelations(); + const dataSource = new MongooseDatasource(connection); + const store = dataSource.getCollection('store'); + const owner = dataSource.getCollection('owner'); + const ownerStore = dataSource.getCollection('owner__store__stores'); + + const storeRecordA = { _id: new Types.ObjectId(), name: 'A' }; + const storeRecordB = { _id: new Types.ObjectId(), name: 'B' }; + await store.create(factories.caller.build(), [storeRecordA, storeRecordB]); + + const ownerRecordA = { _id: new Types.ObjectId(), stores: [storeRecordA._id] }; + const ownerRecordB = { _id: new Types.ObjectId() }; + await owner.create(factories.caller.build(), [ownerRecordA, ownerRecordB]); + + // when + await ownerStore.delete( + factories.caller.build(), + new Filter({ + conditionTree: factories.conditionTreeBranch.build({ + aggregator: 'And', + conditions: [ + factories.conditionTreeLeaf.build({ + value: ownerRecordA._id, + operator: 'Equal', + field: 'owner_id', + }), + factories.conditionTreeLeaf.build({ + value: storeRecordA._id, + operator: 'Equal', + field: 'store_id', + }), + ], + }), + }), + ); + + // then + const expectedOwnerStore = await ownerStore.list( + factories.caller.build(), + factories.filter.build(), + new Projection(), + ); + expect(expectedOwnerStore).toEqual([]); + }); + }); + }); + + describe('list', () => { + describe('page', () => { + it('should returns the expected list by given a skip and limit', async () => { + await setupReview(); + const dataSource = new MongooseDatasource(connection); + const review = dataSource.getCollection('review'); + const expectedRecordC = { message: 'C' }; + const expectedRecordB = { message: 'B' }; + const expectedRecordA = { message: 'A' }; + await review.create(factories.caller.build(), [ + expectedRecordC, + expectedRecordB, + expectedRecordA, + ]); + + const records = await review.list( + factories.caller.build(), + factories.filter.build({ + page: new Page(1, 1), + }), + new Projection('message'), + ); + + expect(records).toEqual([{ message: 'B' }]); + }); + }); + + describe('sort', () => { + it('should sort the records by ascending', async () => { + await setupReview(); + const dataSource = new MongooseDatasource(connection); + const review = dataSource.getCollection('review'); + const expectedRecordC = { message: 'C' }; + const expectedRecordB = { message: 'B' }; + const expectedRecordA = { message: 'A' }; + await review.create(factories.caller.build(), [ + expectedRecordC, + expectedRecordB, + expectedRecordA, + ]); + + const records = await review.list( + factories.caller.build(), + factories.filter.build({ + sort: new Sort({ field: 'message', ascending: true }), + }), + new Projection('message'), + ); + + expect(records).toEqual([{ message: 'A' }, { message: 'B' }, { message: 'C' }]); + }); + + it('should sort the records by descending', async () => { + await setupReview(); + const dataSource = new MongooseDatasource(connection); + const review = dataSource.getCollection('review'); + const expectedRecordC = { message: 'C' }; + const expectedRecordB = { message: 'B' }; + const expectedRecordA = { message: 'A' }; + await review.create(factories.caller.build(), [ + expectedRecordC, + expectedRecordB, + expectedRecordA, + ]); + + const records = await review.list( + factories.caller.build(), + factories.filter.build({ + sort: new Sort({ field: 'message', ascending: false }), + }), + new Projection('message'), + ); + + expect(records).toEqual([{ message: 'C' }, { message: 'B' }, { message: 'A' }]); + }); + + describe('with a many to one relation', () => { + it('applies correctly the sort when it sorts on a relation field', async () => { + await setupWithManyToOneRelation(); + const dataSource = new MongooseDatasource(connection); + const store = dataSource.getCollection('store'); + const owner = dataSource.getCollection('owner'); + + const storeRecordA = { _id: new Types.ObjectId(), name: 'A' }; + const storeRecordB = { _id: new Types.ObjectId(), name: 'B' }; + await store.create(factories.caller.build(), [storeRecordA, storeRecordB]); + const ownerRecordA = { + _id: new Types.ObjectId(), + + storeId: storeRecordA._id, + name: 'the second', + }; + const ownerRecordB = { + _id: new Types.ObjectId(), + + storeId: storeRecordB._id, + name: 'the first', + }; + await owner.create(factories.caller.build(), [ownerRecordA, ownerRecordB]); + + const expectedOwner = await owner.list( + factories.caller.build(), + factories.filter.build({ + sort: new Sort({ field: 'storeId__owner__manyToOne:name', ascending: false }), + }), + new Projection('name'), + ); + + expect(expectedOwner).toEqual([{ name: 'the first' }, { name: 'the second' }]); + }); + }); + }); + + describe('projection', () => { + it('should return the given record with the given projection', async () => { + await setupReview(); + const dataSource = new MongooseDatasource(connection); + const review = dataSource.getCollection('review'); + const expectedRecord = { message: 'a message', title: 'a title' }; + await review.create(factories.caller.build(), [expectedRecord]); + + const records = await review.list( + factories.caller.build(), + factories.filter.build(), + new Projection('message'), + ); + + expect(records).toEqual([{ message: 'a message' }]); + }); + + describe('with a many to one relation', () => { + describe('when projection is empty', () => { + it('should return all the records with the relation', async () => { + await setupWithManyToOneRelation(); + const dataSource = new MongooseDatasource(connection); + const store = dataSource.getCollection('store'); + const owner = dataSource.getCollection('owner'); + + const storeRecord = { _id: new Types.ObjectId(), name: 'aStore' }; + await store.create(factories.caller.build(), [storeRecord]); + const ownerRecord = { + _id: new Types.ObjectId(), + storeId: storeRecord._id, + name: 'aOwner', + }; + await owner.create(factories.caller.build(), [ownerRecord]); + + const expectedOwner = await owner.list( + factories.caller.build(), + factories.filter.build(), + new Projection(), + ); + + expect(expectedOwner).toEqual([ + { ...ownerRecord, storeId__owner__manyToOne: storeRecord }, + ]); + }); + }); + + describe('when projection provide the relation', () => { + it('should return the relation and the provided field', async () => { + await setupWithManyToOneRelation(); + const dataSource = new MongooseDatasource(connection); + const store = dataSource.getCollection('store'); + const owner = dataSource.getCollection('owner'); + + const storeRecord = { _id: new Types.ObjectId(), name: 'aStore' }; + await store.create(factories.caller.build(), [storeRecord]); + const ownerRecord = { + _id: new Types.ObjectId(), + storeId: storeRecord._id, + name: 'aOwner', + }; + await owner.create(factories.caller.build(), [ownerRecord]); + + const expectedOwner = await owner.list( + factories.caller.build(), + factories.filter.build(), + new Projection('name', 'storeId__owner__manyToOne:name'), + ); + + expect(expectedOwner).toEqual([ + { name: 'aOwner', storeId__owner__manyToOne: { name: 'aStore' } }, + ]); + }); + + describe('when some record does not have relation', () => { + it('should return the relation and the provided field', async () => { + await setupWithManyToOneRelation(); + const dataSource = new MongooseDatasource(connection); + const store = dataSource.getCollection('store'); + const owner = dataSource.getCollection('owner'); + + const storeRecord = { _id: new Types.ObjectId(), name: 'aStore' }; + await store.create(factories.caller.build(), [storeRecord]); + const ownerRecord = { + _id: new Types.ObjectId(), + storeId: null, + name: 'aOwner', + }; + await owner.create(factories.caller.build(), [ownerRecord]); + + const expectedOwner = await owner.list( + factories.caller.build(), + factories.filter.build(), + new Projection('name', 'storeId_manyToOne:name'), + ); + + expect(expectedOwner).toEqual([{ name: 'aOwner' }]); + }); + }); + }); + }); + }); + + describe('condition tree', () => { + describe('operators', () => { + const cases: [ConditionTree, Projection, unknown][] = [ + [ + factories.conditionTreeLeaf.build({ + value: ['00000003411bcba43b97a6fb'], + operator: 'In', + field: 'editorIds', + }), + new Projection('editorIds'), + [ + { + editorIds: [ + new Types.ObjectId('000000017578d48e343067c3'), + new Types.ObjectId('00000003411bcba43b97a6fb'), + ], + }, + ], + ], + [ + factories.conditionTreeLeaf.build({ + value: ['000000017578d48e343067c3', '00000003411bcba43b97a6fb'], + operator: 'Equal', + field: 'editorIds', + }), + new Projection('editorIds'), + [ + { + editorIds: [ + new Types.ObjectId('000000017578d48e343067c3'), + new Types.ObjectId('00000003411bcba43b97a6fb'), + ], + }, + ], + ], + [ + factories.conditionTreeLeaf.build({ + value: ['2016-02-29'], + operator: 'In', + field: 'modificationDates', + }), + new Projection('modificationDates'), + [{ modificationDates: [new Date('2016-02-31'), new Date('2016-02-29')] }], + ], + [ + factories.conditionTreeLeaf.build({ + value: '2016-02-31', + operator: 'LessThan', + field: 'createdDate', + }), + new Projection('createdDate'), + [{ createdDate: new Date('2015-02-31') }], + ], + [ + factories.conditionTreeLeaf.build({ + value: 9, + operator: 'GreaterThan', + field: 'rating', + }), + new Projection('rating'), + [{ rating: 10 }], + ], + [ + factories.conditionTreeLeaf.build({ + value: 9, + operator: 'LessThan', + field: 'rating', + }), + new Projection('rating'), + [{ rating: 5 }], + ], + [ + factories.conditionTreeLeaf.build({ + value: 10, + operator: 'Equal', + field: 'rating', + }), + new Projection('rating'), + [{ rating: 10 }], + ], + [ + factories.conditionTreeLeaf.build({ + value: 10, + operator: 'NotEqual', + field: 'rating', + }), + new Projection('rating'), + [{ rating: 5 }], + ], + [ + factories.conditionTreeLeaf.build({ + value: [10, 5], + operator: 'In', + field: 'rating', + }), + new Projection('rating'), + [{ rating: 10 }, { rating: 5 }], + ], + [ + factories.conditionTreeLeaf.build({ + value: ['A', 'B'], + operator: 'IncludesAll', + field: 'tags', + }), + new Projection('tags'), + [{ tags: ['A', 'B'] }], + ], + [ + factories.conditionTreeLeaf.build({ + value: ['A'], + operator: 'Contains', + field: 'tags', + }), + new Projection('tags'), + [{ tags: ['A', 'B'] }], + ], + [ + factories.conditionTreeLeaf.build({ + value: ['A'], + operator: 'NotContains', + field: 'tags', + }), + new Projection('tags'), + [{ tags: ['B', 'C'] }], + ], + [ + factories.conditionTreeLeaf.build({ + value: '%message%', + operator: 'Like', + field: 'message', + }), + new Projection('message'), + [{ message: 'a message' }, { message: 'message' }], + ], + [ + factories.conditionTreeLeaf.build({ + operator: 'Present', + field: 'title', + }), + new Projection('title'), + [{ title: 'a title' }], + ], + ]; + + it.each(cases)( + '[%p] should support the operator', + async (conditionTree, projection, expectedResult) => { + await setupReview(); + const dataSource = new MongooseDatasource(connection); + const review = dataSource.getCollection('review'); + const record1 = { + message: 'a message', + title: 'a title', + rating: 10, + nestedField: { nested: [{ level: 10 }] }, + createdDate: new Date('2016-02-31'), + tags: ['A', 'B'], + modificationDates: ['2016-02-31', '2016-02-30'], + editorIds: [ + new Types.ObjectId('000000017578d48e343067c3'), + new Types.ObjectId('000000017578d48e343067c9'), + ], + }; + const record2 = { + message: 'message', + title: null, + rating: 5, + nestedField: { nested: [{ level: 5 }, { level: 6 }] }, + createdDate: new Date('2015-02-31'), + tags: ['B', 'C'], + modificationDates: ['2016-02-31', '2016-02-29'], + editorIds: [ + new Types.ObjectId('000000017578d48e343067c3'), + new Types.ObjectId('00000003411bcba43b97a6fb'), + ], + }; + await review.create(factories.caller.build(), [record1, record2]); + + const records = await review.list( + factories.caller.build(), + factories.filter.build({ conditionTree }), + projection, + ); + + expect(records).toEqual(expectedResult); + }, + ); + + it('should throw an error when the operator is invalid', async () => { + await setupReview(); + const dataSource = new MongooseDatasource(connection); + const review = dataSource.getCollection('review'); + await review.create(factories.caller.build(), [{ message: 'a message' }]); + + await expect(() => + review.list( + factories.caller.build(), + factories.filter.build({ + conditionTree: factories.conditionTreeLeaf.build({ operator: 'BAD' as Operator }), + }), + new Projection(), + ), + ).rejects.toThrow("Unsupported 'BAD' operator"); + }); + + it('should throw an error when the aggregator is invalid', async () => { + await setupReview(); + const dataSource = new MongooseDatasource(connection); + const review = dataSource.getCollection('review'); + await review.create(factories.caller.build(), [{ message: 'a message' }]); + + await expect(() => + review.list( + factories.caller.build(), + factories.filter.build({ + conditionTree: factories.conditionTreeBranch.build({ + aggregator: 'BAD' as Aggregator, + }), + }), + new Projection(), + ), + ).rejects.toThrow("Invalid 'BAD' aggregator"); + }); + + it('supports default operators when the primary key objectId is given', async () => { + await setupReview(); + const dataSource = new MongooseDatasource(connection); + const review = dataSource.getCollection('review'); + const targetedId = new Types.ObjectId(); + const expectedRecord = { _id: targetedId }; + const unexpectedRecord = { _id: new Types.ObjectId() }; + await review.create(factories.caller.build(), [expectedRecord, unexpectedRecord]); + + const records = await review.list( + factories.caller.build(), + factories.filter.build({ + conditionTree: factories.conditionTreeLeaf.build({ + value: targetedId, + operator: 'Equal', + field: '_id', + }), + }), + new Projection('_id'), + ); + + expect(records).toEqual([{ _id: targetedId }]); + }); + + it('supports string operators when the _id as string is given', async () => { + await setupReview(); + const dataSource = new MongooseDatasource(connection); + const review = dataSource.getCollection('review'); + const targetedId = new Types.ObjectId(); + const expectedRecord = { _id: targetedId }; + const unexpectedRecord = { _id: new Types.ObjectId() }; + await review.create(factories.caller.build(), [expectedRecord, unexpectedRecord]); + + const records = await review.list( + factories.caller.build(), + factories.filter.build({ + conditionTree: factories.conditionTreeLeaf.build({ + value: targetedId.toString(), + operator: 'Contains', + field: '_id', + }), + }), + new Projection('_id'), + ); + + expect(records).toEqual([{ _id: targetedId }]); + }); + + it('supports string operators when an objectId is given', async () => { + await setupReview(); + const dataSource = new MongooseDatasource(connection); + const review = dataSource.getCollection('review'); + const targetedId = new Types.ObjectId(); + const expectedRecord = { authorId: targetedId }; + const unexpectedRecord = { authorId: new Types.ObjectId() }; + await review.create(factories.caller.build(), [expectedRecord, unexpectedRecord]); + + const records = await review.list( + factories.caller.build(), + factories.filter.build({ + conditionTree: factories.conditionTreeLeaf.build({ + value: targetedId.toString(), + operator: 'Contains', + field: 'authorId', + }), + }), + new Projection('authorId'), + ); + + expect(records).toEqual([{ authorId: targetedId }]); + }); + + it('supports operators when an [objectId] as string is given', async () => { + await setupReview(); + const dataSource = new MongooseDatasource(connection); + const review = dataSource.getCollection('review'); + const targetedId = new Types.ObjectId(); + const expectedRecord = { authorId: targetedId }; + const unexpectedRecord = { authorId: new Types.ObjectId() }; + await review.create(factories.caller.build(), [expectedRecord, unexpectedRecord]); + + const records = await review.list( + factories.caller.build(), + factories.filter.build({ + conditionTree: factories.conditionTreeLeaf.build({ + value: [targetedId.toString()], + operator: 'In', + field: 'authorId', + }), + }), + new Projection('authorId'), + ); + + expect(records).toEqual([{ authorId: targetedId }]); + }); + }); + + describe('when only a leaf condition is given', () => { + it('should return the expected list', async () => { + await setupReview(); + const dataSource = new MongooseDatasource(connection); + const review = dataSource.getCollection('review'); + const expectedRecord = { message: 'a message' }; + const unexpectedRecord = { message: 'message' }; + await review.create(factories.caller.build(), [expectedRecord, unexpectedRecord]); + + const records = await review.list( + factories.caller.build(), + factories.filter.build({ + conditionTree: factories.conditionTreeLeaf.build({ + value: 'a message', + operator: 'Equal', + field: 'message', + }), + }), + new Projection('message'), + ); + + expect(records).toEqual([{ message: 'a message' }]); + }); + + it('applies a condition tree on a nested field', async () => { + await setupReview(); + const dataSource = new MongooseDatasource(connection); + const review = dataSource.getCollection('review'); + const expectedRecord = { nestedField: { nested: [{ level: 10 }] } }; + const unexpectedRecord = { nestedField: { nested: [{ level: 5 }] } }; + await review.create(factories.caller.build(), [expectedRecord, unexpectedRecord]); + + const records = await review.list( + factories.caller.build(), + factories.filter.build({ + conditionTree: factories.conditionTreeLeaf.build({ + value: 9, + operator: 'GreaterThan', + field: 'nestedField.nested.level', + }), + }), + new Projection('nestedField.nested.level'), + ); + + expect(records).toEqual([{ nestedField: { nested: [{ level: 10 }] } }]); + }); + }); + + describe('with Or branches', () => { + it('should return the expected list', async () => { + await setupReview(); + const dataSource = new MongooseDatasource(connection); + const review = dataSource.getCollection('review'); + const expectedRecord1 = { message: 'match message 1' }; + const expectedRecord2 = { message: 'match message 2' }; + const unexpectedRecord = { message: 'message', title: 'a title' }; + await review.create(factories.caller.build(), [ + expectedRecord1, + expectedRecord2, + unexpectedRecord, + ]); + + const records = await review.list( + factories.caller.build(), + factories.filter.build({ + conditionTree: factories.conditionTreeBranch.build({ + aggregator: 'Or', + conditions: [ + factories.conditionTreeLeaf.build({ + value: 'match message 1', + operator: 'Equal', + field: 'message', + }), + factories.conditionTreeLeaf.build({ + value: 'match message 2', + operator: 'Equal', + field: 'message', + }), + ], + }), + }), + new Projection('message'), + ); + + expect(records).toEqual([{ message: 'match message 1' }, { message: 'match message 2' }]); + }); + }); + + describe('with And branches', () => { + it('should return the expected list', async () => { + await setupReview(); + const dataSource = new MongooseDatasource(connection); + const review = dataSource.getCollection('review'); + const expectedRecord = { message: 'match message', title: 'a title 1' }; + const unexpectedRecord1 = { message: 'match message', title: 'a title 2' }; + const unexpectedRecord2 = { message: 'message', title: 'a title' }; + await review.create(factories.caller.build(), [ + expectedRecord, + unexpectedRecord2, + unexpectedRecord1, + ]); + + const records = await review.list( + factories.caller.build(), + factories.filter.build({ + conditionTree: factories.conditionTreeBranch.build({ + aggregator: 'And', + conditions: [ + factories.conditionTreeLeaf.build({ + value: 'match message', + operator: 'Equal', + field: 'message', + }), + factories.conditionTreeLeaf.build({ + value: 'a title 1', + operator: 'Equal', + field: 'title', + }), + ], + }), + }), + new Projection('message', 'title'), + ); + + expect(records).toEqual([{ message: 'match message', title: 'a title 1' }]); + }); + }); + + describe('with a many to one relation', () => { + it('applies correctly the condition tree', async () => { + await setupWithManyToOneRelation(); + const dataSource = new MongooseDatasource(connection); + const store = dataSource.getCollection('store'); + const owner = dataSource.getCollection('owner'); + + const storeRecordA = { _id: new Types.ObjectId(), name: 'A' }; + const storeRecordB = { _id: new Types.ObjectId(), name: 'B' }; + await store.create(factories.caller.build(), [storeRecordA, storeRecordB]); + const ownerRecordA = { + _id: new Types.ObjectId(), + storeId: storeRecordA._id, + name: 'owner with the store A', + }; + const ownerRecordB = { + _id: new Types.ObjectId(), + storeId: storeRecordB._id, + name: 'owner with the store B', + }; + await owner.create(factories.caller.build(), [ownerRecordA, ownerRecordB]); + + const expectedOwner = await owner.list( + factories.caller.build(), + factories.filter.build({ + conditionTree: factories.conditionTreeLeaf.build({ + value: 'A', + operator: 'Equal', + field: 'storeId__owner__manyToOne:name', + }), + }), + new Projection('name'), + ); + + expect(expectedOwner).toEqual([{ name: 'owner with the store A' }]); + }); + }); + + describe('with a many to many relation', () => { + it('applies correctly the condition tree', async () => { + await setupWith2ManyToManyRelations(); + const dataSource = new MongooseDatasource(connection); + const store = dataSource.getCollection('store'); + const owner = dataSource.getCollection('owner'); + const ownerStore = dataSource.getCollection('owner__store__stores'); + + const storeRecordA = { _id: new Types.ObjectId(), name: 'A' }; + const storeRecordB = { _id: new Types.ObjectId(), name: 'B' }; + await store.create(factories.caller.build(), [storeRecordA, storeRecordB]); + const ownerRecordA = { + _id: new Types.ObjectId(), + stores: [storeRecordA._id, storeRecordB._id], + name: 'owner with the store A and B', + }; + const ownerRecordB = { + _id: new Types.ObjectId(), + stores: [storeRecordB._id], + name: 'owner with the store B', + }; + await owner.create(factories.caller.build(), [ownerRecordA, ownerRecordB]); + + const expectedOwner = await ownerStore.list( + factories.caller.build(), + factories.filter.build({ + conditionTree: factories.conditionTreeLeaf.build({ + value: storeRecordA._id, + operator: 'Equal', + field: 'store_id', + }), + }), + new Projection('store_id'), + ); + + expect(expectedOwner).toEqual([{ store_id: storeRecordA._id }]); + }); + }); + + describe('with a deep many to one relation', () => { + it('applies correctly the condition tree', async () => { + await setupWithManyToOneRelation(); + const dataSource = new MongooseDatasource(connection); + const store = dataSource.getCollection('store'); + const owner = dataSource.getCollection('owner'); + const address = dataSource.getCollection('address'); + + const addressRecordA = { _id: new Types.ObjectId(), name: 'A' }; + const addressRecordB = { _id: new Types.ObjectId(), name: 'B' }; + await address.create(factories.caller.build(), [addressRecordA, addressRecordB]); + + const storeRecordA = { + _id: new Types.ObjectId(), + name: 'A', + addressId: addressRecordA._id, + }; + const storeRecordB = { + _id: new Types.ObjectId(), + name: 'B', + addressId: addressRecordB._id, + }; + await store.create(factories.caller.build(), [storeRecordA, storeRecordB]); + + const ownerRecordA = { + _id: new Types.ObjectId(), + + storeId: storeRecordA._id, + name: 'owner with the store address A', + }; + const ownerRecordB = { + _id: new Types.ObjectId(), + storeId: storeRecordB._id, + name: 'owner with the store address B', + }; + await owner.create(factories.caller.build(), [ownerRecordA, ownerRecordB]); + + const expectedOwner = await owner.list( + factories.caller.build(), + factories.filter.build({ + conditionTree: factories.conditionTreeLeaf.build({ + value: 'A', + operator: 'Equal', + field: 'storeId__owner__manyToOne:addressId__store__manyToOne:name', + }), + }), + new Projection(), + ); + + expect(expectedOwner).toEqual([ + { + _id: expect.any(Object), + storeId: expect.any(Object), + name: 'owner with the store address A', + storeId__owner__manyToOne: { + _id: expect.any(Object), + name: 'A', + addressId: expect.any(Object), + addressId__store__manyToOne: { + _id: expect.any(Object), + name: 'A', + }, + }, + }, + ]); + }); + }); + }); + }); + + describe('create', () => { + it('should return the list of the created records', async () => { + await setupReview(); + const dataSource = new MongooseDatasource(connection); + const review = dataSource.getCollection('review'); + const expectedRecord = { + message: 'a message', + title: 'a title', + tags: ['A'], + modificationDates: [], + editorIds: [], + }; + + const records = await review.create(factories.caller.build(), [ + expectedRecord, + expectedRecord, + expectedRecord, + ]); + + expect(records).toEqual([ + { ...expectedRecord, _id: expect.any(Object) }, + { ...expectedRecord, _id: expect.any(Object) }, + { ...expectedRecord, _id: expect.any(Object) }, + ]); + const persistedRecord = await connection.models.review.find(); + expect(persistedRecord).toHaveLength(3); + }); + + describe('when there are nested records', () => { + it('returns the created nested records without the mongoose _id', async () => { + await setupReview(); + const dataSource = new MongooseDatasource(connection); + const review = dataSource.getCollection('review'); + const data = { + message: 'a message', + title: 'a title', + nestedField: { nested: [{ level: 10 }] }, + }; + + const records = await review.create(factories.caller.build(), [data, data, data]); + + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + expect(records[0].nestedField.nested).toEqual([{ level: 10 }]); + + const persistedRecord = await connection.models.review.find(); + expect(persistedRecord).toHaveLength(3); + }); + + describe('when the nested record is a JSON', () => { + it('returns the list of the created nested records', async () => { + await setupReview(); + const dataSource = new MongooseDatasource(connection); + const review = dataSource.getCollection('review'); + const data = { + message: 'a message', + title: 'a title', + nestedField: JSON.stringify({ nested: [{ level: 10 }] }), + }; + + const records = await review.create(factories.caller.build(), [data, data, data]); + + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + expect(records[0].nestedField.nested).toEqual([{ level: 10 }]); + + const persistedRecord = await connection.models.review.find(); + expect(persistedRecord).toHaveLength(3); + }); + }); + }); + + describe('with a many to many relation', () => { + describe('when there is the foreign relation to update', () => { + it('updates the right id in the array of objectId', async () => { + // given + await setupWith2ManyToManyRelations(); + const dataSource = new MongooseDatasource(connection); + const store = dataSource.getCollection('store'); + const owner = dataSource.getCollection('owner'); + const ownerStore = dataSource.getCollection('owner__store__stores'); + + const storeRecordA = { _id: new Types.ObjectId(), name: 'A' }; + const storeRecordB = { _id: new Types.ObjectId(), name: 'B' }; + await store.create(factories.caller.build(), [storeRecordA, storeRecordB]); + const ownerRecordA = { _id: new Types.ObjectId(), stores: [storeRecordA._id] }; + const ownerRecordB = { _id: new Types.ObjectId() }; + await owner.create(factories.caller.build(), [ownerRecordA, ownerRecordB]); + + // when + await ownerStore.update( + factories.caller.build(), + new Filter({ + conditionTree: factories.conditionTreeBranch.build({ + aggregator: 'And', + conditions: [ + factories.conditionTreeLeaf.build({ + value: ownerRecordA._id, + operator: 'Equal', + field: 'owner_id', + }), + factories.conditionTreeLeaf.build({ + value: storeRecordA._id, + operator: 'Equal', + field: 'store_id', + }), + ], + }), + }), + + { owner_id: ownerRecordA._id, store_id: storeRecordB._id }, + ); + + // then + + const expectedOwnerStore = await ownerStore.list( + factories.caller.build(), + factories.filter.build(), + new Projection('store_id', 'owner_id'), + ); + expect(expectedOwnerStore).toEqual([ + { owner_id: ownerRecordA._id, store_id: storeRecordB._id }, + ]); + }); + }); + + describe('with a many to many collection', () => { + it('adds the right id to the right record', async () => { + // given + await setupWith2ManyToManyRelations(); + const dataSource = new MongooseDatasource(connection); + const store = dataSource.getCollection('store'); + const owner = dataSource.getCollection('owner'); + const ownerStore = dataSource.getCollection('owner__store__stores'); + + const storeRecordA = { _id: new Types.ObjectId(), name: 'A' }; + const storeRecordB = { _id: new Types.ObjectId(), name: 'B' }; + await store.create(factories.caller.build(), [storeRecordA, storeRecordB]); + + const ownerRecordA = { _id: new Types.ObjectId(), stores: [storeRecordA._id] }; + const ownerRecordB = { _id: new Types.ObjectId() }; + await owner.create(factories.caller.build(), [ownerRecordA, ownerRecordB]); + + // when + await ownerStore.create(factories.caller.build(), [ + { + owner_id: ownerRecordB._id, + store_id: storeRecordB._id, + }, + ]); + + // then + const expectedOwnerStore = await ownerStore.list( + factories.caller.build(), + factories.filter.build(), + new Projection('store_id', 'owner_id'), + ); + expect(expectedOwnerStore).toEqual([ + { owner_id: ownerRecordA._id, store_id: storeRecordA._id }, + { owner_id: ownerRecordB._id, store_id: storeRecordB._id }, + ]); + }); + }); + }); + }); + + describe('aggregate', () => { + it('applies an aggregation operator on a relation field', async () => { + await setupWithManyToOneRelation(); + const dataSource = new MongooseDatasource(connection); + const store = dataSource.getCollection('store'); + const owner = dataSource.getCollection('owner'); + + const storeRecordA = { _id: new Types.ObjectId(), name: 'A' }; + const storeRecordB = { _id: new Types.ObjectId(), name: 'B' }; + await store.create(factories.caller.build(), [storeRecordA, storeRecordB]); + const ownerRecordA = { + storeId: storeRecordA._id, + }; + const ownerRecordB = { + storeId: storeRecordB._id, + }; + await owner.create(factories.caller.build(), [ownerRecordA, ownerRecordB]); + + const aggregation = new Aggregation({ + operation: 'Max', + field: 'storeId__owner__manyToOne:name', + }); + const records = await owner.aggregate(factories.caller.build(), new Filter({}), aggregation); + + expect(records).toIncludeSameMembers([{ value: 'B', group: {} }]); + }); + + it('applies a group on a relation field', async () => { + await setupWithManyToOneRelation(); + const dataSource = new MongooseDatasource(connection); + const store = dataSource.getCollection('store'); + const owner = dataSource.getCollection('owner'); + + const storeRecordA = { _id: new Types.ObjectId(), name: 'A' }; + const storeRecordB = { _id: new Types.ObjectId(), name: 'B' }; + await store.create(factories.caller.build(), [storeRecordA, storeRecordB]); + const ownerRecordA = { + storeId: storeRecordA._id, + }; + const ownerRecordB = { + storeId: storeRecordB._id, + }; + await owner.create(factories.caller.build(), [ownerRecordA, ownerRecordA, ownerRecordB]); + + const aggregation = new Aggregation({ + operation: 'Count', + field: 'storeId_manyToOne:name', + groups: [{ field: 'storeId__owner__manyToOne:name' }], + }); + const records = await owner.aggregate(factories.caller.build(), new Filter({}), aggregation); + + expect(records).toIncludeSameMembers([ + { group: { 'storeId__owner__manyToOne:name': 'A' }, value: 2 }, + { group: { 'storeId__owner__manyToOne:name': 'B' }, value: 1 }, + ]); + }); + + it('applies Limit on the results', async () => { + await setupReview(); + const dataSource = new MongooseDatasource(connection); + const review = dataSource.getCollection('review'); + const rating1 = { rating: 1, message: 'message 1' }; + const rating2 = { rating: 2, message: 'message 1' }; + const rating3 = { rating: 15, message: 'message 2' }; + await review.create(factories.caller.build(), [rating1, rating2, rating3]); + + const aggregation = new Aggregation({ + operation: 'Max', + field: 'rating', + groups: [{ field: 'message' }], + }); + const limit = 1; + const records = await review.aggregate( + factories.caller.build(), + new Filter({}), + aggregation, + limit, + ); + + expect(records).toHaveLength(limit); + }); + + it('applies Max on a field', async () => { + await setupReview(); + const dataSource = new MongooseDatasource(connection); + const review = dataSource.getCollection('review'); + const rating1 = { rating: 1 }; + const rating2 = { rating: 2 }; + const rating3 = { rating: 15 }; + await review.create(factories.caller.build(), [rating1, rating2, rating3]); + + const aggregation = new Aggregation({ + operation: 'Max', + field: 'rating', + }); + const records = await review.aggregate(factories.caller.build(), new Filter({}), aggregation); + + expect(records).toIncludeSameMembers([{ value: 15, group: {} }]); + }); + + it('applies Max on a field with grouping', async () => { + await setupReview(); + const dataSource = new MongooseDatasource(connection); + const review = dataSource.getCollection('review'); + const rating1 = { rating: 1, message: 'message 1' }; + const rating2 = { rating: 2, message: 'message 1' }; + const rating3 = { rating: 15, message: 'message 2' }; + await review.create(factories.caller.build(), [rating1, rating2, rating3]); + + const aggregation = new Aggregation({ + operation: 'Max', + field: 'rating', + groups: [{ field: 'message' }], + }); + const records = await review.aggregate(factories.caller.build(), new Filter({}), aggregation); + + expect(records).toIncludeSameMembers([ + { group: { message: 'message 2' }, value: 15 }, + { group: { message: 'message 1' }, value: 2 }, + ]); + }); + + it('applies Min on a field', async () => { + await setupReview(); + const dataSource = new MongooseDatasource(connection); + const review = dataSource.getCollection('review'); + const rating1 = { rating: 1 }; + const rating2 = { rating: 2 }; + const rating3 = { rating: 15 }; + await review.create(factories.caller.build(), [rating1, rating2, rating3]); + + const aggregation = new Aggregation({ + operation: 'Min', + field: 'rating', + }); + const records = await review.aggregate(factories.caller.build(), new Filter({}), aggregation); + + expect(records).toIncludeSameMembers([{ value: 1, group: {} }]); + }); + + it('applies Min on a field with grouping', async () => { + await setupReview(); + const dataSource = new MongooseDatasource(connection); + const review = dataSource.getCollection('review'); + const rating1 = { rating: 1, message: 'message 1' }; + const rating2 = { rating: 2, message: 'message 1' }; + const rating3 = { rating: 15, message: 'message 2' }; + await review.create(factories.caller.build(), [rating1, rating2, rating3]); + + const aggregation = new Aggregation({ + operation: 'Min', + field: 'rating', + groups: [{ field: 'message' }], + }); + const records = await review.aggregate(factories.caller.build(), new Filter({}), aggregation); + + expect(records).toIncludeSameMembers([ + { group: { message: 'message 2' }, value: 15 }, + { group: { message: 'message 1' }, value: 1 }, + ]); + }); + + it('applies Count', async () => { + await setupReview(); + const dataSource = new MongooseDatasource(connection); + const review = dataSource.getCollection('review'); + const message1 = { title: 'present', message: 'message 1' }; + const message2 = { title: 'present', message: 'message 2' }; + const message3 = { title: null, message: 'message 3' }; + await review.create(factories.caller.build(), [message1, message1, message2, message3]); + + const aggregation = new Aggregation({ + operation: 'Count', + field: 'title', + }); + const records = await review.aggregate(factories.caller.build(), new Filter({}), aggregation); + + expect(records).toIncludeSameMembers([{ value: 3, group: {} }]); + }); + + it('applies Count on a field with grouping', async () => { + await setupReview(); + const dataSource = new MongooseDatasource(connection); + const review = dataSource.getCollection('review'); + const message1 = { title: 'present', message: 'message 1' }; + const message2 = { title: 'present', message: 'message 2' }; + const message3 = { title: null, message: 'message 3' }; + await review.create(factories.caller.build(), [message1, message1, message2, message3]); + + const aggregation = new Aggregation({ + operation: 'Count', + field: 'title', + groups: [{ field: 'message' }], + }); + const records = await review.aggregate(factories.caller.build(), new Filter({}), aggregation); + + expect(records).toIncludeSameMembers([ + { value: 2, group: { message: 'message 1' } }, + { value: 1, group: { message: 'message 2' } }, + { value: 0, group: { message: 'message 3' } }, + ]); + }); + + it('applies Count on the record with grouping', async () => { + await setupReview(); + const dataSource = new MongooseDatasource(connection); + const review = dataSource.getCollection('review'); + const message1 = { message: 'message 1' }; + const message2 = { message: 'message 2' }; + await review.create(factories.caller.build(), [message1, message1, message2]); + + const aggregation = new Aggregation({ + operation: 'Count', + groups: [{ field: 'message' }], + }); + const records = await review.aggregate(factories.caller.build(), new Filter({}), aggregation); + + expect(records).toIncludeSameMembers([ + { value: 2, group: { message: 'message 1' } }, + { value: 1, group: { message: 'message 2' } }, + ]); + }); + + it('applies Count without providing a field', async () => { + await setupReview(); + const dataSource = new MongooseDatasource(connection); + const review = dataSource.getCollection('review'); + const rating1 = { rating: 1 }; + const rating2 = { rating: 2 }; + const rating3 = { rating: 15 }; + await review.create(factories.caller.build(), [rating1, rating2, rating3]); + + const aggregation = new Aggregation({ + operation: 'Count', + }); + const records = await review.aggregate(factories.caller.build(), new Filter({}), aggregation); + + expect(records).toIncludeSameMembers([{ value: 3, group: {} }]); + }); + + it('applies Sum on a field', async () => { + await setupReview(); + const dataSource = new MongooseDatasource(connection); + const review = dataSource.getCollection('review'); + const rating1 = { createdDate: new Date('2022-01-13'), rating: 5 }; + const rating2 = { createdDate: new Date('2022-02-13'), rating: 1 }; + const rating3 = { createdDate: new Date('2023-03-14'), rating: 3 }; + await review.create(factories.caller.build(), [rating1, rating2, rating3]); + + const aggregation = new Aggregation({ + operation: 'Sum', + field: 'rating', + groups: [{ field: 'createdDate', operation: 'Year' }], + }); + const records = await review.aggregate(factories.caller.build(), new Filter({}), aggregation); + + expect(records).toIncludeSameMembers([ + { group: { createdDate: '2023-01-01' }, value: 3 }, + { group: { createdDate: '2022-01-01' }, value: 6 }, + ]); + }); + + it('applies Sum on a field with grouping and Month operator', async () => { + await setupReview(); + const dataSource = new MongooseDatasource(connection); + const review = dataSource.getCollection('review'); + const rating1 = { createdDate: new Date('2022-01-13'), rating: 5 }; + const rating2 = { createdDate: new Date('2022-02-13'), rating: 1 }; + const rating3 = { createdDate: new Date('2023-03-14'), rating: 3 }; + await review.create(factories.caller.build(), [rating1, rating2, rating3]); + + const aggregation = new Aggregation({ + operation: 'Sum', + field: 'rating', + groups: [{ field: 'createdDate', operation: 'Month' }], + }); + const records = await review.aggregate(factories.caller.build(), new Filter({}), aggregation); + + expect(records).toIncludeSameMembers([ + { group: { createdDate: '2022-02-01' }, value: 1 }, + { group: { createdDate: '2023-03-01' }, value: 3 }, + { group: { createdDate: '2022-01-01' }, value: 5 }, + ]); + }); + + it('applies Sum on a field with grouping and Day operator', async () => { + await setupReview(); + const dataSource = new MongooseDatasource(connection); + const review = dataSource.getCollection('review'); + const rating1 = { createdDate: new Date('2022-01-13'), rating: 5 }; + const rating2 = { createdDate: new Date('2022-02-13'), rating: 1 }; + const rating3 = { createdDate: new Date('2023-03-14'), rating: 3 }; + await review.create(factories.caller.build(), [rating1, rating2, rating3]); + + const aggregation = new Aggregation({ + operation: 'Sum', + field: 'rating', + groups: [{ field: 'createdDate', operation: 'Day' }], + }); + const records = await review.aggregate(factories.caller.build(), new Filter({}), aggregation); + + expect(records).toIncludeSameMembers([ + { group: { createdDate: '2022-02-13' }, value: 1 }, + { group: { createdDate: '2022-01-13' }, value: 5 }, + { group: { createdDate: '2023-03-14' }, value: 3 }, + ]); + }); + + it('applies Sum on a field with grouping and Week operator', async () => { + await setupReview(); + const dataSource = new MongooseDatasource(connection); + const review = dataSource.getCollection('review'); + const rating1 = { createdDate: new Date('2022-01-13'), rating: 5 }; + const rating2 = { createdDate: new Date('2022-03-15'), rating: 1 }; + const rating3 = { createdDate: new Date('2022-03-14'), rating: 3 }; + await review.create(factories.caller.build(), [rating1, rating2, rating3]); + + const aggregation = new Aggregation({ + operation: 'Sum', + field: 'rating', + groups: [{ field: 'createdDate', operation: 'Week' }], + }); + const records = await review.aggregate(factories.caller.build(), new Filter({}), aggregation); + + expect(records).toIncludeSameMembers([ + { group: { createdDate: '2022-01-10' }, value: 5 }, + { group: { createdDate: '2022-03-14' }, value: 4 }, + ]); + }); + + it('applies Avg on a field with grouping and Week operator', async () => { + await setupReview(); + const dataSource = new MongooseDatasource(connection); + const review = dataSource.getCollection('review'); + const rating1 = { createdDate: new Date('2022-01-13'), rating: 5 }; + const rating2 = { createdDate: new Date('2022-03-15'), rating: 1 }; + const rating3 = { createdDate: new Date('2022-03-14'), rating: 3 }; + await review.create(factories.caller.build(), [rating1, rating2, rating3]); + + const aggregation = new Aggregation({ + operation: 'Avg', + field: 'rating', + groups: [{ field: 'createdDate', operation: 'Week' }], + }); + const records = await review.aggregate(factories.caller.build(), new Filter({}), aggregation); + + expect(records).toIncludeSameMembers([ + { group: { createdDate: '2022-01-10' }, value: 5 }, + { group: { createdDate: '2022-03-14' }, value: (3 + 1) / 2 }, + ]); + }); + + describe('when there are multiple groups', () => { + it('should apply all the given group', async () => { + await setupReview(); + const dataSource = new MongooseDatasource(connection); + const review = dataSource.getCollection('review'); + const message1 = { title: 'title 1', message: 'message 1' }; + const message2 = { title: 'title 1', message: 'message 1' }; + const message3 = { title: 'title 2', message: 'message 1' }; + const message4 = { title: 'title 3', message: 'message 2' }; + await review.create(factories.caller.build(), [message1, message2, message3, message4]); + + const aggregation = new Aggregation({ + operation: 'Count', + field: 'title', + groups: [{ field: 'message' }, { field: 'title' }], + }); + const records = await review.aggregate( + factories.caller.build(), + new Filter({}), + aggregation, + ); + + expect(records).toIncludeSameMembers([ + { value: 2, group: { message: 'message 1', title: 'title 1' } }, + { value: 1, group: { message: 'message 1', title: 'title 2' } }, + { value: 1, group: { message: 'message 2', title: 'title 3' } }, + ]); + }); + }); + + describe('with a many to many', () => { + it('applies correctly the aggregation', async () => { + await setupWith2ManyToManyRelations(); + const dataSource = new MongooseDatasource(connection); + const store = dataSource.getCollection('store'); + const owner = dataSource.getCollection('owner'); + const ownerStore = dataSource.getCollection('owner__store__stores'); + + const storeRecordA = { _id: new Types.ObjectId(), name: 'A' }; + const storeRecordB = { _id: new Types.ObjectId(), name: 'B' }; + await store.create(factories.caller.build(), [storeRecordA, storeRecordB]); + const ownerRecordA = { + _id: new Types.ObjectId(), + stores: [storeRecordA._id, storeRecordB._id], + name: 'owner with the store A and B', + }; + const ownerRecordB = { + _id: new Types.ObjectId(), + stores: [storeRecordB._id], + name: 'owner with the store B', + }; + await owner.create(factories.caller.build(), [ownerRecordA, ownerRecordB]); + + const aggregation = new Aggregation({ + operation: 'Count', + groups: [], + }); + const expectedCount = await ownerStore.aggregate( + factories.caller.build(), + factories.filter.build({ + conditionTree: factories.conditionTreeLeaf.build({ + value: storeRecordA._id, + operator: 'Equal', + field: 'store_id', + }), + }), + aggregation, + ); + + expect(expectedCount).toEqual([{ value: 1, group: {} }]); + }); + }); + }); +}); diff --git a/packages/datasource-mongoose/test/utils/schema-fields-generator.test.ts b/packages/datasource-mongoose/test/utils/schema-fields-generator.test.ts index 6ace8a994f..114434f462 100644 --- a/packages/datasource-mongoose/test/utils/schema-fields-generator.test.ts +++ b/packages/datasource-mongoose/test/utils/schema-fields-generator.test.ts @@ -4,10 +4,11 @@ import { PrimitiveTypes, RecordData, } from '@forestadmin/datasource-toolkit'; -import { Model, Schema, deleteModel, model } from 'mongoose'; +import { Connection, Model, Schema, deleteModel, model } from 'mongoose'; import FilterOperatorBuilder from '../../src/utils/filter-operator-builder'; import MongooseCollection from '../../src/collection'; +import MongooseDatasource from '../../src/datasource'; import SchemaFieldsGenerator from '../../src/utils/schema-fields-generator'; const buildModel = (schema: Schema, modelName = 'aModel'): Model => { @@ -331,16 +332,16 @@ describe('SchemaFieldsGenerator', () => { }); }); - describe('with many to one relationship fields', () => { + describe('with an objectId and a ref', () => { it('should add a relation _manyToOne in the fields schema', () => { const schema = new Schema({ aField: { type: Schema.Types.ObjectId, ref: 'companies' }, }); - const fieldsSchema = SchemaFieldsGenerator.buildFieldsSchema(buildModel(schema)); + const fieldsSchema = SchemaFieldsGenerator.buildFieldsSchema(buildModel(schema, 'aModel')); expect(fieldsSchema).toMatchObject({ - aField_manyToOne: { + aField__aModel__manyToOne: { foreignCollection: 'companies', foreignKey: 'aField', type: 'ManyToOne', @@ -355,23 +356,25 @@ describe('SchemaFieldsGenerator', () => { }); }); - describe('with an array of object ids and ref', () => { + describe('with an array of objectId and a ref', () => { it('should returns a array of string in the schema', () => { const schema = new Schema({ - oneToManyField: [{ type: Schema.Types.ObjectId, ref: 'companies1' }], - anotherOneToManyField: { type: [Schema.Types.ObjectId], ref: 'companies2' }, + manyToManyField: { type: [Schema.Types.ObjectId], ref: 'companies' }, }); - const fieldsSchema = SchemaFieldsGenerator.buildFieldsSchema(buildModel(schema)); + const fieldsSchema = SchemaFieldsGenerator.buildFieldsSchema( + buildModel(schema, 'aModelName'), + ); expect(fieldsSchema).toMatchObject({ - oneToManyField: { - columnType: ['String'], - type: 'Column', - }, - anotherOneToManyField: { - columnType: ['String'], - type: 'Column', + manyToManyField__aModelName__manyToOne: { + type: 'ManyToMany', + foreignCollection: 'companies', + throughCollection: 'aModelName__companies__manyToManyField', + foreignKey: 'companies_id', + foreignKeyTarget: '_id', + originKey: 'aModelName_id', + originKeyTarget: '_id', }, }); }); @@ -379,32 +382,95 @@ describe('SchemaFieldsGenerator', () => { }); describe('addInverseRelationships', () => { - it('should add a relation _oneToMany in the foreign fields schema', () => { - const schemaWithManyToOne = new Schema({ - aFieldTarget: { type: Schema.Types.ObjectId, ref: 'modelB' }, + describe('when there is a many to one relation', () => { + it('should add a relation _oneToMany in the referenced model', () => { + const schemaWithManyToOne = new Schema({ + aFieldRelation: { type: Schema.Types.ObjectId, ref: 'modelB' }, + }); + const schemaWithOneToMany = new Schema({ aField: { type: 'String' } }); + + const dataSource = new MongooseDatasource({ models: {} } as Connection); + const modelA = buildModel(schemaWithManyToOne, 'modelA'); + const modelB = buildModel(schemaWithOneToMany, 'modelB'); + const collectionA = new MongooseCollection(dataSource, modelA); + const collectionB = new MongooseCollection(dataSource, modelB); + dataSource.addCollection(collectionA); + dataSource.addCollection(collectionB); + + SchemaFieldsGenerator.addInverseRelationships([collectionA, collectionB]); + + expect(collectionB.schema.fields).toMatchObject({ + modelB__aFieldRelation__oneToMany: { + foreignCollection: 'modelA', + originKeyTarget: '_id', + originKey: 'aFieldRelation', + type: 'OneToMany', + }, + aField: { + type: 'Column', + columnType: 'String', + filterOperators: FilterOperatorBuilder.getSupportedOperators('String'), + }, + }); }); + }); - const schemaWithOneToMany = new Schema({ aField: { type: 'String' } }); + describe('when there is a many to many relation', () => { + it('adds a _ManyToMany relation to the references model and create a new collection', () => { + // given + const schemaWithManyToMany = new Schema({ + aFieldRelation: { type: [Schema.Types.ObjectId], ref: 'modelB' }, + }); + const referencedSchema = new Schema(); - const modelA = buildModel(schemaWithManyToOne, 'modelA'); - const modelB = buildModel(schemaWithOneToMany, 'modelB'); - const collectionA = new MongooseCollection(null, modelA); - const collectionB = new MongooseCollection(null, modelB); + const modelA = buildModel(schemaWithManyToMany, 'modelA'); + const modelB = buildModel(referencedSchema, 'modelB'); + const dataSource = new MongooseDatasource({ models: {} } as Connection); + + const collectionA = new MongooseCollection(dataSource, modelA); + const collectionB = new MongooseCollection(dataSource, modelB); + dataSource.addCollection(collectionA); + dataSource.addCollection(collectionB); - SchemaFieldsGenerator.addInverseRelationships([collectionA, collectionB]); + // when + SchemaFieldsGenerator.addInverseRelationships(dataSource.collections); - expect(collectionB.schema.fields).toMatchObject({ - modelB__aFieldTarget__oneToMany: { + // then + expect(collectionB.schema.fields.modelB__modelA_id__aFieldRelation).toEqual({ + type: 'ManyToMany', foreignCollection: 'modelA', + throughCollection: 'modelA__modelB__aFieldRelation', + foreignKey: 'modelA_id', + foreignKeyTarget: '_id', + originKey: 'modelB_id', originKeyTarget: '_id', - originKey: 'aFieldTarget', - type: 'OneToMany', - }, - aField: { - type: 'Column', - columnType: 'String', - filterOperators: FilterOperatorBuilder.getSupportedOperators('String'), - }, + }); + + // be aware to not create two times the collection + expect(() => dataSource.getCollection('modelB__modelA__aFieldRelation')).toThrow(); + const manyToManyCollection = dataSource.getCollection('modelA__modelB__aFieldRelation'); + + expect(manyToManyCollection.schema.fields.modelA_id).toHaveProperty('isPrimaryKey', true); + expect(manyToManyCollection.schema.fields.modelB_id).toHaveProperty('isPrimaryKey', true); + // eslint-disable-next-line no-underscore-dangle + expect(manyToManyCollection.schema.fields._id).toEqual(undefined); + + expect(manyToManyCollection.schema.fields).toEqual({ + modelA_id__aFieldRelation__manyToOne: { + type: 'ManyToOne', + foreignCollection: 'modelA', + foreignKey: 'modelA_id', + foreignKeyTarget: '_id', + }, + modelA_id: expect.any(Object), + modelB_id__aFieldRelation__manyToOne: { + type: 'ManyToOne', + foreignCollection: 'modelB', + foreignKey: 'modelB_id', + foreignKeyTarget: '_id', + }, + modelB_id: expect.any(Object), + }); }); }); @@ -413,11 +479,12 @@ describe('SchemaFieldsGenerator', () => { aFieldTarget: { type: Schema.Types.ObjectId, ref: 'modelDoesNotExist' }, }); + const dataSource = new MongooseDatasource({ models: {} } as Connection); const modelA = buildModel(schemaWithManyToOne, 'modelA'); - const collectionA = new MongooseCollection(null, modelA); + const collectionA = new MongooseCollection(dataSource, modelA); expect(() => SchemaFieldsGenerator.addInverseRelationships([collectionA])).toThrow( - "The collection 'modelDoesNotExist' does not exist", + "Collection 'modelDoesNotExist' not found.", ); }); });