Skip to content

Commit

Permalink
Merge pull request #371 from Freezystem/fix.virtuals-with-match-clause
Browse files Browse the repository at this point in the history
Create an adapter method to handle virtual population
  • Loading branch information
icebob authored Nov 12, 2023
2 parents 2702ad1 + d3ce10a commit a424fe7
Show file tree
Hide file tree
Showing 5 changed files with 483 additions and 73 deletions.
2 changes: 1 addition & 1 deletion packages/moleculer-db-adapter-mongoose/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ new MongooseAdapter("mongodb://127.0.0.1/moleculer-db")
```js
new MongooseAdapter("mongodb://db-server-hostname/my-db", {
user: process.env.MONGO_USERNAME,
pass: process.env.MONGO_PASSWORD
pass: process.env.MONGO_PASSWORD,
keepAlive: true
})
```
Expand Down
70 changes: 63 additions & 7 deletions packages/moleculer-db-adapter-mongoose/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ class MongooseDbAdapter {
init(broker, service) {
this.broker = broker;
this.service = service;
this.useNativeMongooseVirtuals = !!service.settings?.useNativeMongooseVirtuals

if (this.service.schema.model) {
this.model = this.service.schema.model;
Expand Down Expand Up @@ -302,6 +303,63 @@ class MongooseDbAdapter {
return this.model.deleteMany({}).then(res => res.deletedCount);
}

/**
* Return proper query to populate virtuals depending on service populate params
*
* @param {Context} ctx - moleculer context
* @returns {Object[]}
* @memberof MongooseDbAdapter
*/
getNativeVirtualPopulateQuery(ctx) {
const fieldsToPopulate = ctx.params?.populate || [];

if (fieldsToPopulate.length === 0) return [];

const virtualFields = Object.entries( this.model?.schema?.virtuals || {})
.reduce((acc, [path, virtual]) => {
const hasRef = !!(virtual.options?.ref || virtual.options?.refPath);
const hasMatch = !! virtual.options?.match;
if (hasRef) acc[path] = hasMatch;
return acc;
}, {});
const virtualsToPopulate = _.intersection(fieldsToPopulate, Object.keys(virtualFields));

if (virtualsToPopulate.length === 0) return [];

const getPathOptions = (path) =>
_.get(ctx, `service.settings.virtuals.${path}.options`, {skipInvalidIds: true, lean: true});

const getPathTransform = (path) =>
_.get(ctx, `service.settings.virtuals.${path}.transform`, (doc) => doc._id);

const getPathSelect = (path) =>
_.get(ctx, `service.settings.virtuals.${path}.select`, _.get(virtualFields, path) ? undefined : "_id");

return virtualsToPopulate.map((path) => ({
path,
select: getPathSelect(path),
options : getPathOptions(path),
transform: getPathTransform(path)
}));
}

/**
* Replace virtuals that would trigger subqueries by the localField
* they target to be used later in action propagation
*
* @param {Context} ctx - moleculer context
* @param {Object} json - the JSONified entity
* @returns {Object}
* @memberof MongooseDbAdapter
*/
mapVirtualsToLocalFields(ctx, json) {
Object.entries(this.model?.schema?.virtuals || {})
.forEach(([path, virtual]) => {
const localField = virtual.options?.localField;
if (localField) json[path] = json[localField];
});
}

/**
* Convert DB entity to JSON object
*
Expand All @@ -311,13 +369,7 @@ class MongooseDbAdapter {
* @memberof MongooseDbAdapter
*/
entityToObject(entity, ctx) {
const fieldsToPopulate = _.get(ctx, "params.populate", []);
const virtualFields = Object.values(_.get(this, "model.schema.virtuals", {}))
.reduce((acc, virtual) => _.get(virtual, "options.ref") ? [...acc, virtual.path] : acc, []);
const virtualsToPopulate = _.intersection(fieldsToPopulate, virtualFields);
const options = {skipInvalidIds: true, lean: true};
const transform = (doc) => doc._id;
const populate = virtualsToPopulate.map(path => ({path, select: "_id", options, transform}));
const populate = this.useNativeMongooseVirtuals ? this.getNativeVirtualPopulateQuery(ctx) : [];

return Promise.resolve(populate.length > 0 ? entity.populate(populate) : entity)
.then(entity => {
Expand All @@ -329,6 +381,10 @@ class MongooseDbAdapter {
json._id = entity._id.toString();
}

if (!this.useNativeMongooseVirtuals) {
this.mapVirtualsToLocalFields(ctx, json);
}

return json;
});
}
Expand Down
265 changes: 201 additions & 64 deletions packages/moleculer-db-adapter-mongoose/test/integration/virtuals.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,88 +10,225 @@ if (process.versions.node.split(".")[0] < 14) {
const User = require("../models/users");
const Post = require("../models/posts");

describe("Test virtuals population feature", () => {
// Create broker
const broker = new ServiceBroker({
logger: console,
logLevel: "error",
describe("Test virtual population feature", () => {

beforeEach(async () => {
// clean collection for replayability
await Post.Model.deleteMany({});
await User.Model.deleteMany({});
});

beforeAll(async () => {
// Load posts service
broker.createService(DbService, {
name: "posts",
adapter: new MongooseStoreAdapter("mongodb://127.0.0.1:27017"),
model: Post.Model,
settings: {
populates: {
author: "users.get",
},
},
describe("Test mongoose native virtual population", () => {

// Create broker
const broker = new ServiceBroker({
logger: console,
logLevel: "error",
});

// Load users service
broker.createService(DbService, {
name: "users",
adapter: new MongooseStoreAdapter("mongodb://127.0.0.1:27017"),
model: User.Model,
settings: {
populates: {
posts: "posts.get",
lastPost: "posts.get",
beforeAll(async () => {
// Load posts service
broker.createService(DbService, {
name: "posts",
adapter: new MongooseStoreAdapter("mongodb://127.0.0.1:27017"),
model: Post.Model,
settings: {
useNativeMongooseVirtuals: true,
populates: {
author: "users.get",
},
},
});

// Load users service
broker.createService(DbService, {
name: "users",
adapter: new MongooseStoreAdapter("mongodb://127.0.0.1:27017"),
model: User.Model,
settings: {
useNativeMongooseVirtuals: true,
populates: {
posts: "posts.get",
lastPost: "posts.get",
lastPostWithVotes: "posts.get",
},
},
},
});

await broker.start();
});

await broker.start();
});
afterAll(async () => {
await broker.stop();
});

afterAll(async () => {
await broker.stop();
});
it("Should populate virtuals", async () => {
const _user = await User.Model.create({
firstName: "John",
lastName: "Doe",
});

beforeEach(async () => {
// clean collection for replayability
await Post.Model.deleteMany({});
await User.Model.deleteMany({});
});
const _post1 = await Post.Model.create({
title: "post_1",
content: "content 1",
author: _user._id,
});

const _post2 = await Post.Model.create({
title: "post_2",
content: "content 2",
author: _user._id,
votes: 2,
});

it("Should populate virtuals", async () => {
const _user = await User.Model.create({
firstName: "John",
lastName: "Doe",
const user = await broker.call("users.get", {
id: _user.id,
populate: ["posts", "postCount", "lastPost", "lastPostWithVotes"],
});

expect(user).toHaveProperty("firstName", "John");
expect(user).toHaveProperty("lastName", "Doe");
// virtual function without populate
expect(user).toHaveProperty("fullName", "John Doe");
// virtual populate with refPath and count option
expect(user).toHaveProperty("postCount", 2);
// virtual populate with ref
expect(user).toHaveProperty("posts");
expect(user.posts).toHaveLength(2);
expect(user.posts.map((p) => p._id)).toEqual([_post2.id, _post1.id]);
// virtual populate with justOne option set to "true"
expect(user).toHaveProperty("lastPost");
expect(user.lastPost).toHaveProperty("_id", _post2.id);
// virtual populate with match clause
expect(user).toHaveProperty("lastPostWithVotes");
expect(user.lastPostWithVotes).toHaveProperty("_id", _post2.id);
});
});

const _post1 = await Post.Model.create({
title: "post_1",
content: "content 1",
author: _user._id,
describe("Test moleculer basic virtual population", () => {

// Create broker
const broker = new ServiceBroker({
logger: console,
logLevel: "error",
});

const _post2 = await Post.Model.create({
title: "post_2",
content: "content 2",
author: _user._id,
beforeAll(async () => {
// Load posts service
broker.createService(DbService, {
name: "posts",
adapter: new MongooseStoreAdapter("mongodb://127.0.0.1:27017"),
model: Post.Model,
settings: {
populates: {
author: "users.get",
},
},
actions: {
countFromUser:{
params: {id: {type: "array", items: "string"}},
async handler(ctx) {
const author = ctx.params.id[0];
ctx.params = { query: { author } };
const res = await this._count(ctx, ctx.params);
return {[author]: res};
}
},
allFromUser:{
params: {id: {type: "array", items: "string"}},
async handler(ctx) {
const author = ctx.params.id[0];
ctx.params = { query: { author }, sort: "-createdAt" };
const res = await this._find(ctx, ctx.params);
return {[author]: res};
}
},
lastFromUser:{
params: {
id: {type: "array", items: "string"},
minVoteCount: {type: "number", min: 0, optional: true}
},
async handler(ctx) {
const {id, minVoteCount} = ctx.params;
const query = { author: id };
if (minVoteCount) query.votes = {$gte: minVoteCount};
ctx.params = { query, sort: "-createdAt", limit: 1 };
const res = await this._find(ctx, ctx.params);
return {[id]: res[0]};
}
},
}
});

// Load users service
broker.createService(DbService, {
name: "users",
adapter: new MongooseStoreAdapter("mongodb://127.0.0.1:27017"),
model: User.Model,
settings: {
populates: {
posts: "posts.allFromUser",
postCount: "posts.countFromUser",
lastPost: "posts.lastFromUser",
lastPostWithVotes: {
action: "posts.lastFromUser",
params: {
minVoteCount: 1
}
},
},
virtuals: false,
},
});

await broker.start();
});

const user = await broker.call("users.get", {
id: _user.id,
populate: ["posts", "lastPost", "postCount"],
afterAll(async () => {
await broker.stop();
});

expect(user).toHaveProperty("firstName", "John");
expect(user).toHaveProperty("lastName", "Doe");
// virtual function without populate
expect(user).toHaveProperty("fullName", "John Doe");
// virtual populate with ref and count option
expect(user).toHaveProperty("postCount", 2);
// virtual populate with ref
expect(user).toHaveProperty("posts");
expect(user.posts).toHaveLength(2);
expect(user.posts.map((p) => p._id)).toEqual([_post2.id, _post1.id]);
// virtual populate with justOne option set to "true"
expect(user).toHaveProperty("lastPost");
expect(user.lastPost).toHaveProperty("_id", _post2.id);
it("Should populate virtuals", async () => {
const _user = await User.Model.create({
firstName: "John",
lastName: "Doe",
});

const _post1 = await Post.Model.create({
title: "post_1",
content: "content 1",
author: _user._id,
});

const _post2 = await Post.Model.create({
title: "post_2",
content: "content 2",
author: _user._id,
votes: 2,
});

const user = await broker.call("users.get", {
id: _user.id,
populate: ["posts", "postCount", "lastPost", "lastPostWithVotes"],
});

expect(user).toHaveProperty("firstName", "John");
expect(user).toHaveProperty("lastName", "Doe");
// virtual function without populate
expect(user).toHaveProperty("fullName", "John Doe");
// virtual populate with refPath and count option
expect(user).toHaveProperty("postCount", 2);
// virtual populate with ref
expect(user).toHaveProperty("posts");
expect(user.posts).toHaveLength(2);
expect(user.posts.map((p) => p._id)).toEqual([_post2.id, _post1.id]);
// virtual populate with justOne option set to "true"
expect(user).toHaveProperty("lastPost");
expect(user.lastPost).toHaveProperty("_id", _post2.id);
// virtual populate with match clause
expect(user).toHaveProperty("lastPostWithVotes");
expect(user.lastPostWithVotes).toHaveProperty("_id", _post2.id);
});
});
});
}
Loading

0 comments on commit a424fe7

Please sign in to comment.