Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(datasource-mongoose): fix errors when creating or retrieving records from collections created with asModel on an object field #821

Merged
merged 11 commits into from
Sep 13, 2023
61 changes: 54 additions & 7 deletions packages/datasource-mongoose/src/collection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,16 +113,31 @@ export default class MongooseCollection extends BaseCollection {

// Only array fields can create subdocuments (the others should use update)
const schema = MongooseSchema.fromModel(this.model).applyStack(this.stack);
if (!schema.isArray)
throw new ValidationError('Trying to create subrecords on a non-array field');

// Transform list of subrecords to a list of modifications that we'll apply to the root record.
if (schema.isArray) {
return this._createForArraySubfield(data, flatData, schema);
Scra3 marked this conversation as resolved.
Show resolved Hide resolved
}

return this._createForObjectSubfield(data, flatData);
}

private computeSubFieldName() {
const lastStackStep = this.stack[this.stack.length - 1];
const fieldName =
this.stack.length > 2
? lastStackStep.prefix.substring(this.stack[this.stack.length - 2].prefix.length + 1)
: lastStackStep.prefix;

return fieldName;
ghusse marked this conversation as resolved.
Show resolved Hide resolved
}

private async _createForArraySubfield(
data: RecordData[],
flatData: RecordData[],
schema: MongooseSchema,
) {
const fieldName = this.computeSubFieldName();

const updates: Record<string, { rootId: unknown; path: string; records: unknown[] }> = {};
const results = [];

Expand All @@ -146,17 +161,49 @@ export default class MongooseCollection extends BaseCollection {

// Apply the modifications to the root document.
const promises = Object.values(updates).map(({ rootId, path, records }) =>
this.model.updateOne(
{ _id: rootId },
{ $push: { [path]: { $position: 0, $each: records } } },
),
schema.isArray
? this.model.updateOne(
{ _id: rootId },
{ $push: { [path]: { $position: 0, $each: records } } },
)
: this.model.updateOne({ _id: rootId }, { $set: { [path]: records[0] } }),
);

await Promise.all(promises);

return results;
}

private async _createForObjectSubfield(data: RecordData[], flatData: RecordData[]) {
if (data.length > 1) throw new ValidationError('Trying to create multiple subrecords at once');
Scra3 marked this conversation as resolved.
Show resolved Hide resolved

const fieldName = this.computeSubFieldName();

const entry = data[0];
const flatEntry = flatData[0];
const { parentId, ...rest } = entry;
ghusse marked this conversation as resolved.
Show resolved Hide resolved

if (!parentId) throw new ValidationError('Trying to create a subrecord with no parent');

const [rootId, path] = splitId(`${parentId}.${fieldName}`);

await this.model.updateOne(
{ _id: rootId },
{
$set: {
[path]: rest,
},
},
);

return [
{
_id: `${rootId}.${path}`,
...flatEntry,
ghusse marked this conversation as resolved.
Show resolved Hide resolved
},
];
}

private async _update(caller: Caller, filter: Filter, flatPatch: RecordData): Promise<void> {
const { asFields } = this.stack[this.stack.length - 1];
const patch = unflattenRecord(flatPatch, asFields, true);
Expand Down
41 changes: 31 additions & 10 deletions packages/datasource-mongoose/src/utils/pipeline/lookup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,38 +10,54 @@ import { Stack } from '../../types';
*/
export default class LookupGenerator {
static lookup(model: Model<unknown>, stack: Stack, projection: Projection): PipelineStage[] {
const childSchema = MongooseSchema.fromModel(model).applyStack(stack, true).fields;
const schemaStack = stack.reduce(
(acc, _, index) => {
return [
...acc,
MongooseSchema.fromModel(model).applyStack(stack.slice(0, index + 1), true),
];
},
[MongooseSchema.fromModel(model)],
);

return this.lookupProjection(model.db.models, null, childSchema, projection);
return this.lookupProjection(
model.db.models,
null,
schemaStack.map(s => s.fields),
projection,
);
}

private static lookupProjection(
models: Record<string, Model<unknown>>,
currentPath: string,
schema: SchemaNode,
schemaStack: SchemaNode[],
projection: Projection,
): PipelineStage[] {
const pipeline = [];

for (const [name, subProjection] of Object.entries(projection.relations))
pipeline.push(...this.lookupRelation(models, currentPath, schema, name, subProjection));
pipeline.push(...this.lookupRelation(models, currentPath, schemaStack, name, subProjection));

return pipeline;
}

private static lookupRelation(
models: Record<string, Model<unknown>>,
currentPath: string,
schema: SchemaNode,
schemaStack: SchemaNode[],
name: string,
subProjection: Projection,
): PipelineStage[] {
const as = currentPath ? `${currentPath}.${name}` : name;

const lastSchema = schemaStack[schemaStack.length - 1];
const previousSchemas = schemaStack.slice(0, schemaStack.length - 1);

// Native many to one relation
if (name.endsWith('__manyToOne')) {
const foreignKeyName = name.substring(0, name.length - '__manyToOne'.length);
const model = models[schema[foreignKeyName].options.ref];
const model = models[lastSchema[foreignKeyName].options.ref];

const from = model.collection.collectionName;
const localField = currentPath ? `${currentPath}.${foreignKeyName}` : foreignKeyName;
Expand All @@ -55,13 +71,18 @@ export default class LookupGenerator {
{ $unwind: { path: `$${as}`, preserveNullAndEmptyArrays: true } },

// Recurse to get relations of relations
...this.lookupProjection(models, as, subSchema, subProjection),
...this.lookupProjection(models, as, [...schemaStack, subSchema], subProjection),
];
}

// Fake relation or inverse of fake relation
if (name === 'parent' || schema[name]) {
return this.lookupProjection(models, as, schema[name], subProjection);
// Inverse of fake relation
if (name === 'parent' && previousSchemas.length) {
return this.lookupProjection(models, as, previousSchemas, subProjection);
}

// Fake relation
if (lastSchema[name]) {
return this.lookupProjection(models, as, [...schemaStack, lastSchema[name]], subProjection);
}

// We should have handled all possible cases.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,7 @@ describe('Complex flattening', () => {
);
});

it('creating a subModel should fail', async () => {
it('creating a subModel should work', async () => {
connection = await setupFlattener('collection_flattener_create');

const dataSource = new MongooseDatasource(connection, {
Expand All @@ -180,10 +180,28 @@ describe('Complex flattening', () => {
.getCollection('cars')
.create(caller, [{ name: 'my fiesta', wheelSize: 12 }]);

await expect(
dataSource
.getCollection('cars_engine')
.create(caller, [{ parentId: car._id, horsePower: '12' }]),
).rejects.toThrow('Trying to create subrecords on a non-array field');
const result = await dataSource
.getCollection('cars_engine')
.create(caller, [{ parentId: car._id, horsePower: '12' }]);

const doc = await connection.model('cars').findOne({ _id: car._id });

expect(result).toEqual(
expect.arrayContaining([
expect.objectContaining({
horsePower: '12',
}),
]),
);

expect(doc).toEqual(
expect.objectContaining({
name: 'my fiesta',
wheelSize: 12,
engine: expect.objectContaining({
horsePower: '12',
}),
}),
);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,40 @@ describe('Complex flattening', () => {
}),
]);
});

it('should correctly retrieve one nested record', async () => {
connection = await setupFlattener('collection_flattener_list');
const dataSource = new MongooseDatasource(connection, {
flattenMode: 'manual',
flattenOptions: {
cars: { asModels: ['engine'] },
},
});

const [car] = await dataSource
.getCollection('cars')
.create(caller, [{ name: 'my fiesta', wheelSize: 12, engine: { horsePower: 98 } }]);

const records = await dataSource.getCollection('cars_engine').list(
caller,
new Filter({
conditionTree: new ConditionTreeLeaf('_id', 'Equal', `${car._id}.engine`),
}),
new Projection('_id', 'horsePower', 'parentId', 'parent:_id', 'parent:engine:horsePower'),
);

expect(records).toEqual([
expect.objectContaining({
horsePower: '98',
parent: {
_id: car._id,
engine: {
horsePower: '98',
},
},
}),
]);
});
});
});
});