diff --git a/docs/linking_collections.md b/docs/linking_collections.md index b5731b1b..0cb02fa2 100644 --- a/docs/linking_collections.md +++ b/docs/linking_collections.md @@ -80,6 +80,40 @@ Posts.addLinks({ You created the link, and now you can use the query illustrated above. We decided to choose `author` as a name for our link and `authorId` the field to store it in, but it's up to you to decide this. +## Nested links + +Nested links are also supported: + +```js +// file: /imports/db/posts/links.js +import Posts from '...'; + +Posts.addLinks({ + 'authorObject.authorId': { + type: 'one', + collection: Meteor.users, + field: 'authorObject.authorId', + }, +}) +``` + +In this example we're assuming that `authorObject` is a nested document inside `Posts` collection, and we want to link it to `Meteor.users`. + +Nested arrays are also supported, e.g.: + +```js +// file: /imports/db/posts/links.js +import Posts from '...'; + +Posts.addLinks({ + 'authorsArray.authorId': { + type: 'one', + collection: Meteor.users, + field: 'authorsArray.authorId', + }, +}) +``` + ## Inversed links Because we linked `Posts` with `Meteor.users` it means that we can also get all `posts` of an user. diff --git a/lib/links/lib/createSearchFilters.js b/lib/links/lib/createSearchFilters.js index 7a01e4e5..c556dff8 100755 --- a/lib/links/lib/createSearchFilters.js +++ b/lib/links/lib/createSearchFilters.js @@ -26,12 +26,29 @@ export default function createSearchFilters(object, linker, metaFilters) { } } +function getIdQueryFieldStorage(object, fieldStorage, isMany = false) { + const [root, ...rest] = fieldStorage.split('.'); + if (rest.length === 0) { + const ids = object[fieldStorage]; + return Array.isArray(ids) ? {$in: ids} : ids; + } + + const nestedPath = rest.join('.'); + const rootValue = object[root]; + if (Array.isArray(rootValue)) { + return {$in: _.uniq(_.union(...rootValue.map(item => dot.pick(nestedPath, item))))}; + } + else if (_.isObject(rootValue)) { + return isMany ? {$in: dot.pick(nestedPath, rootValue) || []} : dot.pick(nestedPath, rootValue); + } +} + export function createOne(object, linker) { return { // Using {$in: []} as a workaround because foreignIdentityField which is not _id is not required to be set // and {something: undefined} in query returns all the records. // $in: [] ensures that nothing will be returned for this query - [linker.foreignIdentityField]: dot.pick(linker.linkStorageField, object) || {$in: []}, + [linker.foreignIdentityField]: getIdQueryFieldStorage(object, linker.linkStorageField) || {$in: []}, }; } @@ -69,19 +86,8 @@ export function createOneMetaVirtual(object, fieldStorage, metaFilters) { } export function createMany(object, linker) { - const [root, ...nested] = linker.linkStorageField.split('.'); - if (nested.length > 0) { - const arr = object[root]; - const ids = arr ? _.uniq(_.union(arr.map(obj => _.isObject(obj) ? dot.pick(nested.join('.'), obj) : []))) : []; - return { - [linker.foreignIdentityField]: {$in: ids} - }; - } - const value = object[linker.linkStorageField]; return { - [linker.foreignIdentityField]: { - $in: _.isArray(value) ? value : (value ? [value] : []), - } + [linker.foreignIdentityField]: getIdQueryFieldStorage(object, linker.linkStorageField, true) || {$in: []}, }; } diff --git a/lib/query/hypernova/aggregateSearchFilters.js b/lib/query/hypernova/aggregateSearchFilters.js index 653dae36..a441c573 100644 --- a/lib/query/hypernova/aggregateSearchFilters.js +++ b/lib/query/hypernova/aggregateSearchFilters.js @@ -1,8 +1,24 @@ import sift from 'sift'; import dot from 'dot-object'; +function getIdsFromObject(object, field) { + const parts = field.split('.'); + if (parts.length === 1) { + return [dot.pick(field, object)]; + } + + const rootValue = object[parts[0]]; + if (Array.isArray(rootValue)) { + return rootValue.map(item => dot.pick(parts.slice(1).join('.'), item)); + } + else if (_.isObject(rootValue)) { + return [dot.pick(parts.slice(1).join('.'), rootValue)]; + } + return []; +} + function extractIdsFromArray(array, field) { - return (array || []).map(obj => _.isObject(obj) ? dot.pick(field, obj) : undefined).filter(v => !!v); + return _.flatten((array || []).map(obj => _.isObject(obj) ? getIdsFromObject(obj, field) : [])).filter(v => !!v); } /** @@ -44,7 +60,7 @@ export default class AggregateFilters { createOne() { if (!this.isVirtual) { return { - _id: { + [this.foreignIdentityField]: { $in: _.uniq(extractIdsFromArray(this.parentObjects, this.linkStorageField)) } }; diff --git a/lib/query/hypernova/assembler.js b/lib/query/hypernova/assembler.js index 7f805493..7da27f05 100644 --- a/lib/query/hypernova/assembler.js +++ b/lib/query/hypernova/assembler.js @@ -3,6 +3,201 @@ import cleanObjectForMetaFilters from './lib/cleanObjectForMetaFilters'; import sift from 'sift'; import dot from 'dot-object'; +/** + * + * getIdsForMany + * + * Possible options: + * + * A. array of ids directly inside the parentResult + * { + * projectIds: [...], + * } + * + * B. array of ids in nested document + * { + * nested: { + * projectIds: [...], + * } + * } + * + * Case for link with foreignIdentityField on projectId. This is still 'many' link because mapping could be one to many. + * { + * nested: { + * projectId: 1. + * }, + * } + * + * C. array of ids in nested array + * { + * nestedArray: [{ + * projectIds: [...], + * }, { + * projectIds: [...], + * }] + * } + * + * Case with foreign identity field. + * { + * nestedArray: [{ + * projectId: 1, + * }, { + * projectId: 2, + * }] + * } + */ +function getIdsForMany(parentResult, fieldStorage) { + // support dotted fields + const [root, ...nested] = fieldStorage.split('.'); + const value = dot.pick(root, parentResult); + + if (_.isUndefined(value) || _.isNull(value)) { + return []; + } + + // Option A. + if (nested.length === 0) { + return Array.isArray(value) ? value : [value]; + } + + // Option C. + if (Array.isArray(value)) { + return _.flatten(value.map(v => getIdsFromObject(v, nested.join('.')))); + } + + // Option B + if (_.isObject(value)) { + return getIdsFromObject(value, nested.join('.')); + } + + return []; +} + +function getIdsFromObject(object, path) { + const pickedValue = dot.pick(path, object); + return Array.isArray(pickedValue) ? pickedValue : ((_.isUndefined(pickedValue) || _.isNull(pickedValue)) ? [] : [pickedValue]); +} + +export function assembleMany(parentResult, { + childCollectionNode, + linker, + limit, + skip, + resultsByKeyId, +}) { + const fieldStorage = linker.linkStorageField; + + const [root, ...rest] = fieldStorage.split('.'); + const rootValue = parentResult[root]; + if (!rootValue) { + return; + } + + const [, ...nestedLinkPath] = childCollectionNode.linkName.split('.'); + if (nestedLinkPath.length > 0 && Array.isArray(rootValue)) { + rootValue.forEach(result => { + const results = _.flatten(_.union(...getIdsForMany(result, rest.join('.')).map(id => resultsByKeyId[id]))); + const data = filterAssembledData( + results, + { limit, skip } + ); + result[nestedLinkPath.join('.')] = data; + }); + return; + } + + const results = _.union(...getIdsForMany(parentResult, fieldStorage).map(id => resultsByKeyId[id])); + const data = filterAssembledData( + results, + { limit, skip } + ); + dot.str(childCollectionNode.linkName, data, parentResult); +} + +export function assembleManyMeta(parentResult, { + childCollectionNode, + linker, + skip, + limit, + resultsByKeyId, +}) { + const fieldStorage = linker.linkStorageField; + + const _ids = _.pluck(parentResult[fieldStorage], '_id'); + let data = []; + _ids.forEach(_id => { + data.push(_.first(resultsByKeyId[_id])); + }); + + parentResult[childCollectionNode.linkName] = filterAssembledData( + data, + { limit, skip } + ); +} + +export function assembleOneMeta(parentResult, { + childCollectionNode, + linker, + limit, + skip, + resultsByKeyId, +}) { + const fieldStorage = linker.linkStorageField; + + if (!parentResult[fieldStorage]) { + return; + } + + const _id = parentResult[fieldStorage]._id; + parentResult[childCollectionNode.linkName] = filterAssembledData( + resultsByKeyId[_id], + { limit, skip } + ); +} + +export function assembleOne(parentResult, { + childCollectionNode, + linker, + limit, + skip, + resultsByKeyId, +}) { + const fieldStorage = linker.linkStorageField; + + const [root, ...rest] = fieldStorage.split('.'); + const rootValue = parentResult[root]; + if (!rootValue) { + return; + } + + // todo: using linker.linkName should be correct here since it should be the same as childCollectionNode.linkName + const path = childCollectionNode.linkName.split('.'); + + if (Array.isArray(rootValue)) { + rootValue.forEach(result => { + const value = dot.pick(rest.join('.'), result); + const data = filterAssembledData( + resultsByKeyId[value], + { limit, skip } + ); + dot.set(path.slice(1).join('.'), data, result); + // result[path.slice(1).join('.')] = data; + }); + return; + } + + const value = dot.pick(fieldStorage, parentResult); + if (!value) { + return; + } + + const data = filterAssembledData( + resultsByKeyId[value], + { limit, skip } + ); + dot.str(childCollectionNode.linkName, data, parentResult); +} + export default (childCollectionNode, { limit, skip, metaFilters }) => { if (childCollectionNode.results.length === 0) { return; @@ -12,7 +207,6 @@ export default (childCollectionNode, { limit, skip, metaFilters }) => { const linker = childCollectionNode.linker; const strategy = linker.strategy; - const isSingle = linker.isSingle(); const isMeta = linker.isMeta(); const fieldStorage = linker.linkStorageField; @@ -31,68 +225,55 @@ export default (childCollectionNode, { limit, skip, metaFilters }) => { const resultsByKeyId = _.groupBy(childCollectionNode.results, linker.foreignIdentityField); + if (childCollectionNode.linkName !== linker.linkName) { + throw new Error(`error: ${childCollectionNode.linkName} ${linker.linkName}`); + } + if (strategy === 'one') { parent.results.forEach(parentResult => { - const value = dot.pick(fieldStorage, parentResult); - if (!value) { - return; - } - - parentResult[childCollectionNode.linkName] = filterAssembledData( - resultsByKeyId[value], - { limit, skip } - ); + return assembleOne(parentResult, { + childCollectionNode, + linker, + limit, + skip, + resultsByKeyId, + }); }); } if (strategy === 'many') { parent.results.forEach(parentResult => { - // support dotted fields - const [root, ...nested] = fieldStorage.split('.'); - const value = dot.pick(root, parentResult); - if (!value) { - return; - } - - const data = []; - (_.isArray(value) ? value : [value]).forEach(v => { - const _id = nested.length > 0 ? dot.pick(nested.join('.'), v) : v; - data.push(...(resultsByKeyId[_id] || [])); + return assembleMany(parentResult, { + childCollectionNode, + linker, + skip, + limit, + resultsByKeyId, }); - - parentResult[childCollectionNode.linkName] = filterAssembledData( - data, - { limit, skip } - ); }); } if (strategy === 'one-meta') { parent.results.forEach(parentResult => { - if (!parentResult[fieldStorage]) { - return; - } - - const _id = parentResult[fieldStorage]._id; - parentResult[childCollectionNode.linkName] = filterAssembledData( - resultsByKeyId[_id], - { limit, skip } - ); + return assembleOneMeta(parentResult, { + linker, + childCollectionNode, + limit, + skip, + resultsByKeyId, + }) }); } if (strategy === 'many-meta') { parent.results.forEach(parentResult => { - const _ids = _.pluck(parentResult[fieldStorage], '_id'); - let data = []; - _ids.forEach(_id => { - data.push(_.first(resultsByKeyId[_id])); + return assembleManyMeta(parentResult, { + childCollectionNode, + linker, + limit, + skip, + resultsByKeyId, }); - - parentResult[childCollectionNode.linkName] = filterAssembledData( - data, - { limit, skip } - ); }); } }; diff --git a/lib/query/hypernova/buildVirtualNodeProps.js b/lib/query/hypernova/buildVirtualNodeProps.js index c3b5847a..11d3229b 100644 --- a/lib/query/hypernova/buildVirtualNodeProps.js +++ b/lib/query/hypernova/buildVirtualNodeProps.js @@ -28,7 +28,7 @@ export default function (childCollectionNode, filters, options, userId) { delete a[key]; } - if (!_.isArray(value) && _.isObject(value) && !(value instanceof Date)) { + if (!Array.isArray(value) && _.isObject(value) && !(value instanceof Date)) { a[key] = cleanUndefinedLeafs(value); } }); diff --git a/lib/query/hypernova/processVirtualNode.js b/lib/query/hypernova/processVirtualNode.js index 2e9979e0..d9b1ff75 100644 --- a/lib/query/hypernova/processVirtualNode.js +++ b/lib/query/hypernova/processVirtualNode.js @@ -62,7 +62,7 @@ export default function(childCollectionNode, results, metaFilters, options = {}) if (isMany) { comparator = (result, parent) => { const [root, ...nestedFields] = linkField.split('.'); - const rootValue = _.isArray(result[root]) ? result[root] : [result[root]]; + const rootValue = Array.isArray(result[root]) ? result[root] : [result[root]]; if (nestedFields.length > 0) { return _.contains(rootValue.map(nestedObject => dot.pick(nestedFields.join('.'), nestedObject)), parent[linker.foreignIdentityField]); } diff --git a/lib/query/hypernova/storeHypernovaResults.js b/lib/query/hypernova/storeHypernovaResults.js index 88e298dd..a21cc8de 100644 --- a/lib/query/hypernova/storeHypernovaResults.js +++ b/lib/query/hypernova/storeHypernovaResults.js @@ -50,7 +50,7 @@ export default function storeHypernovaResults(childCollectionNode, userId) { const {filters: virtualFilters, options: {limit, skip, ...virtualOptions}} = virtualProps; // console.log(JSON.stringify(virtualProps, null, 4)); - + const results = collection.find(virtualFilters, virtualOptions).fetch(); // console.log(JSON.stringify(results, null, 4)); diff --git a/lib/query/hypernova/testing/assembler.test.js b/lib/query/hypernova/testing/assembler.test.js new file mode 100644 index 00000000..16609325 --- /dev/null +++ b/lib/query/hypernova/testing/assembler.test.js @@ -0,0 +1,486 @@ +import {expect} from 'chai'; +import Linker from "../../../links/linker"; +import {assembleMany, assembleManyMeta, assembleOne, assembleOneMeta} from "../assembler"; + +describe('Assembler test', function () { + const COMMENTS = [{ + _id: 1, + text: 'Text 1', + originalId: 1, + }, { + _id: 2, + text: 'Text 2', + originalId: 2, + }, { + _id: 3, + text: 'Text 3', + originalId: 1, + }, { + _id: 4, + text: 'Text 4', + originalId: 4, + }]; + + const GROUPED_COMMENTS_BY_ID = _.groupBy(COMMENTS, '_id'); + const GROUPED_COMMENTS_BY_ORIGINAL_ID = _.groupBy(COMMENTS, 'originalId'); + + describe('one', function () { + const ONE_LINK = new Linker(null, 'comment', { + type: 'one', + field: 'commentId', + collection: new Mongo.Collection(null), + }); + + it('works in trivial case', function () { + const childCollectionNode = { + linkName: 'comment', + }; + + const parentResult = { + _id: 1, + commentId: 1, + }; + + assembleOne(parentResult, { + childCollectionNode, + linker: ONE_LINK, + resultsByKeyId: GROUPED_COMMENTS_BY_ID, + }); + + expect(parentResult.comment).to.be.an('array').and.have.length(1); + }); + + describe('nested objects', function () { + const NESTED_OBJECT_ONE_LINK = new Linker(null, 'meta.comment', { + type: 'one', + field: 'meta.commentId', + collection: new Mongo.Collection(null), + }); + + const NESTED_OBJECT_ONE_LINK_FOREIGN_FIELD = new Linker(null, 'meta.comment', { + type: 'one', + field: 'meta.commentId', + foreignIdentityField: 'originalId', + collection: new Mongo.Collection(null), + }); + + it('works for nested objects', function () { + const childCollectionNode = { + linkName: 'meta.comment', + }; + + const parentResult = { + _id: 1, + meta: { + commentId: 1, + }, + }; + + assembleOne(parentResult, { + childCollectionNode, + linker: NESTED_OBJECT_ONE_LINK, + resultsByKeyId: GROUPED_COMMENTS_BY_ID, + }); + + expect(parentResult.meta.comment).to.be.an('array').and.have.length(1); + }); + + it('works for nested objects when nested object is empty', function () { + const childCollectionNode = { + linkName: 'meta.comment', + }; + + const parentResult = { + _id: 1, + }; + + assembleOne(parentResult, { + childCollectionNode, + linker: NESTED_OBJECT_ONE_LINK, + resultsByKeyId: GROUPED_COMMENTS_BY_ID, + }); + + expect(parentResult.meta).to.be.undefined; + }); + + it('works for nested objects with foreign field', function () { + const childCollectionNode = { + linkName: 'meta.comments', + }; + + const parentResult = { + _id: 1, + meta: { + commentId: 1, + }, + }; + + assembleOne(parentResult, { + childCollectionNode, + linker: NESTED_OBJECT_ONE_LINK_FOREIGN_FIELD, + resultsByKeyId: GROUPED_COMMENTS_BY_ORIGINAL_ID, + }); + + expect(parentResult.meta.comments).to.be.an('array').and.have.length(2); + }); + }); + + describe('nested array', function () { + const NESTED_ARRAY_ONE_LINK = new Linker(null, 'meta.comment', { + type: 'many', + field: 'meta.commentId', + collection: new Mongo.Collection(null), + }); + + const NESTED_ONE_ONE_LINK_FOREIGN_FIELD = new Linker(null, 'meta.comment', { + type: 'many', + field: 'meta.commentId', + foreignIdentityField: 'originalId', + collection: new Mongo.Collection(null), + }); + + it('works for nested arrays', function () { + const childCollectionNode = { + linkName: 'meta.comments', + }; + + const parentResult = { + _id: 1, + meta: [{ + commentId: 1, + }, { + commentId: 2, + }], + }; + + assembleOne(parentResult, { + childCollectionNode, + linker: NESTED_ARRAY_ONE_LINK, + resultsByKeyId: GROUPED_COMMENTS_BY_ID, + }); + + expect(parentResult.meta[0].comments).to.be.an('array').and.have.length(1); + expect(parentResult.meta[1].comments).to.be.an('array').and.have.length(1); + }); + + it('works for nested arrays with foreign field', function () { + const childCollectionNode = { + linkName: 'meta.comments', + }; + + const parentResult = { + _id: 1, + meta: [{ + commentId: 1, + }, { + + }], + }; + + assembleOne(parentResult, { + childCollectionNode, + linker: NESTED_ONE_ONE_LINK_FOREIGN_FIELD, + resultsByKeyId: GROUPED_COMMENTS_BY_ORIGINAL_ID, + }); + + expect(parentResult.meta[0].comments).to.be.an('array').and.have.length(2); + expect(parentResult.meta[1].comments).to.be.undefined; + }); + }); + }); + + describe('many', function () { + const MANY_LINK = new Linker(null, 'comments', { + type: 'many', + field: 'commentIds', + collection: new Mongo.Collection(null), + }); + + it('works in trivial case', function () { + const childCollectionNode = { + linkName: 'comments', + }; + + const parentResult = { + _id: 1, + commentIds: [1, 2], + }; + + assembleMany(parentResult, { + childCollectionNode, + linker: MANY_LINK, + resultsByKeyId: GROUPED_COMMENTS_BY_ID, + }); + + expect(parentResult.comments).to.be.an('array').and.have.length(2); + }); + + describe('nested objects', function () { + const NESTED_OBJECT_MANY_LINK = new Linker(null, 'meta.comments', { + type: 'many', + field: 'meta.commentIds', + collection: new Mongo.Collection(null), + }); + + const NESTED_OBJECT_MANY_LINK_FOREIGN_FIELD = new Linker(null, 'meta.comments', { + type: 'many', + field: 'meta.commentIds', + foreignIdentityField: 'originalId', + collection: new Mongo.Collection(null), + }); + + const NESTED_OBJECT_MANY_LINK_FOREIGN_FIELD_SINGLE_VALUE = new Linker(null, 'meta.comments', { + type: 'many', + field: 'meta.commentId', + foreignIdentityField: 'originalId', + collection: new Mongo.Collection(null), + }); + + it('works for nested objects', function () { + const childCollectionNode = { + linkName: 'meta.comments', + }; + + const parentResult = { + _id: 1, + meta: { + commentIds: [1, 2], + }, + }; + + assembleMany(parentResult, { + childCollectionNode, + linker: NESTED_OBJECT_MANY_LINK, + resultsByKeyId: GROUPED_COMMENTS_BY_ID, + }); + + expect(parentResult.meta.comments).to.be.an('array').and.have.length(2); + }); + + it('works for nested objects when nested object is empty', function () { + const childCollectionNode = { + linkName: 'meta.comments', + }; + + const parentResult = { + _id: 1, + }; + + assembleOne(parentResult, { + childCollectionNode, + linker: NESTED_OBJECT_MANY_LINK, + resultsByKeyId: GROUPED_COMMENTS_BY_ID, + }); + + expect(parentResult.meta).to.be.undefined; + }); + + it('works for nested objects with foreign field', function () { + const childCollectionNode = { + linkName: 'meta.comments', + }; + + const parentResult = { + _id: 1, + meta: { + commentIds: [1, 4], + }, + }; + + assembleMany(parentResult, { + childCollectionNode, + linker: NESTED_OBJECT_MANY_LINK_FOREIGN_FIELD, + resultsByKeyId: GROUPED_COMMENTS_BY_ORIGINAL_ID, + }); + + expect(parentResult.meta.comments).to.be.an('array').and.have.length(3); + }); + + it('works for nested objects with foreign field - single value', function () { + const childCollectionNode = { + linkName: 'meta.comments', + }; + + const parentResult = { + _id: 1, + meta: { + commentId: 1, + }, + }; + + assembleMany(parentResult, { + childCollectionNode, + linker: NESTED_OBJECT_MANY_LINK_FOREIGN_FIELD_SINGLE_VALUE, + resultsByKeyId: GROUPED_COMMENTS_BY_ORIGINAL_ID, + }); + + expect(parentResult.meta.comments).to.be.an('array').and.have.length(2); + }); + }); + + describe('nested arrays', function () { + const NESTED_ARRAY_MANY_LINK = new Linker(null, 'meta.comments', { + type: 'many', + field: 'meta.commentIds', + collection: new Mongo.Collection(null), + }); + + const NESTED_ARRAY_MANY_LINK_FOREIGN_FIELD = new Linker(null, 'meta.comments', { + type: 'many', + field: 'meta.commentIds', + foreignIdentityField: 'originalId', + collection: new Mongo.Collection(null), + }); + + const NESTED_ARRAY_MANY_LINK_FOREIGN_FIELD_SINGLE_VALUE = new Linker(null, 'meta.comments', { + type: 'many', + field: 'meta.commentId', + foreignIdentityField: 'originalId', + collection: new Mongo.Collection(null), + }); + + it('works for nested arrays', function () { + const childCollectionNode = { + linkName: 'meta.comments', + }; + + const parentResult = { + _id: 1, + meta: [{ + commentIds: [1, 2], + }, { + commentIds: [3], + }], + }; + + assembleMany(parentResult, { + childCollectionNode, + linker: NESTED_ARRAY_MANY_LINK, + resultsByKeyId: GROUPED_COMMENTS_BY_ID, + }); + + expect(parentResult.meta[0].comments).to.be.an('array').and.have.length(2); + expect(_.pluck(parentResult.meta[0].comments, '_id')).to.be.eql([1, 2]); + expect(parentResult.meta[1].comments).to.be.an('array').and.have.length(1); + }); + + it('works for nested arrays with foreign field', function () { + const childCollectionNode = { + linkName: 'meta.comments', + }; + + const parentResult = { + _id: 1, + meta: [{ + commentIds: [1, 2], + }, { + commentIds: [4], + }], + }; + + assembleMany(parentResult, { + childCollectionNode, + linker: NESTED_ARRAY_MANY_LINK_FOREIGN_FIELD, + resultsByKeyId: GROUPED_COMMENTS_BY_ORIGINAL_ID, + }); + + expect(parentResult.meta[0].comments).to.be.an('array').and.have.length(3); + expect(_.pluck(parentResult.meta[0].comments, '_id')).to.be.eql([1, 3, 2]); + expect(parentResult.meta[1].comments).to.be.an('array').and.have.length(1); + }); + + it('works for nested arrays with foreign field - single value', function () { + const childCollectionNode = { + linkName: 'meta.comments', + }; + + const parentResult = { + _id: 1, + meta: [{ + commentId: 1, + }, { + commentId: 4, + }], + }; + + assembleMany(parentResult, { + childCollectionNode, + linker: NESTED_ARRAY_MANY_LINK_FOREIGN_FIELD_SINGLE_VALUE, + resultsByKeyId: GROUPED_COMMENTS_BY_ORIGINAL_ID, + }); + + expect(parentResult.meta[0].comments).to.be.an('array').and.have.length(2); + expect(_.pluck(parentResult.meta[0].comments, '_id')).to.be.eql([1, 3]); + expect(parentResult.meta[1].comments).to.be.an('array').and.have.length(1); + }); + }); + }); + + describe('one-meta', function () { + const ONE_META_LINK = new Linker(null, 'comment', { + type: 'one', + field: '_comment', + metadata: true, + collection: new Mongo.Collection(null), + }); + + it('works in trivial case', function () { + const childCollectionNode = { + linkName: 'comment', + }; + + const parentResult = { + _id: 1, + _comment: { + _id: 1, + public: true, + }, + }; + + assembleOneMeta(parentResult, { + childCollectionNode, + linker: ONE_META_LINK, + resultsByKeyId: GROUPED_COMMENTS_BY_ID, + }); + + expect(parentResult.comment).to.be.an('array').and.have.length(1); + expect(parentResult.comment[0]._id).to.be.equal(1); + }); + }); + + describe('many-meta', function () { + const MANY_META_LINK = new Linker(null, 'comments', { + type: 'many', + field: '_comments', + metadata: true, + collection: new Mongo.Collection(null), + }); + + it('works in trivial case', function () { + const childCollectionNode = { + linkName: 'comments', + }; + + const parentResult = { + _id: 1, + _comments: [{ + _id: 1, + public: true, + }, { + _id: 3, + public: false, + }], + }; + + assembleManyMeta(parentResult, { + childCollectionNode, + linker: MANY_META_LINK, + resultsByKeyId: GROUPED_COMMENTS_BY_ID, + }); + + expect(parentResult.comments).to.be.an('array').and.have.length(2); + expect(parentResult.comments[0]._id).to.be.equal(1); + expect(parentResult.comments[1]._id).to.be.equal(3); + }); + }); +}); diff --git a/lib/query/hypernova/testing/processVirtualNode.test.js b/lib/query/hypernova/testing/processVirtualNode.test.js new file mode 100644 index 00000000..99ea227a --- /dev/null +++ b/lib/query/hypernova/testing/processVirtualNode.test.js @@ -0,0 +1,100 @@ +import {expect} from 'chai'; +import {EJSON} from 'meteor/ejson'; +import processVirtualNode from "../processVirtualNode"; +import CollectionNode from "../../nodes/collectionNode"; + +const POST_COLLECTION = new Mongo.Collection(null); +const COMMENT_COLLECTION = new Mongo.Collection(null); + +describe('processVirtualNode', function () { + describe('many', function () { + POST_COLLECTION.addLinks({ + comment: { + type: 'one', + field: 'commentId', + collection: COMMENT_COLLECTION, + }, + commentForeign: { + type: 'many', + field: 'commentId', + collection: COMMENT_COLLECTION, + foreignIdentityField: 'originalId', + } + }); + COMMENT_COLLECTION.addLinks({ + posts: { + collection: POST_COLLECTION, + inversedBy: 'comment', + }, + postsForeign: { + collection: POST_COLLECTION, + inversedBy: 'commentForeign', + }, + }); + + const POSTS = [{ + _id: 1, + commentId: 1, + }, { + _id: 2, + commentId: 2, + }, { + _id: 3, + }]; + + const COMMENTS = [{ + _id: 1, + text: '1', + originalId: 1, + }, { + _id: 2, + text: '2', + originalId: 1, + }, { + _id: 3, + text: '3', + originalId: 3, + }]; + + /** + * Assuming query like + * + * comments: { + * post: { + * _id: 1, + * } + * } + * + */ + + it('works', function () { + const POST_COLLECTION_NODE = new CollectionNode(POST_COLLECTION, {}, 'posts'); + const COMMENT_COLLECTION_NODE = new CollectionNode(COMMENT_COLLECTION, {}); + COMMENT_COLLECTION_NODE.add(POST_COLLECTION_NODE, COMMENT_COLLECTION.__links['posts']); + + const results = EJSON.clone(COMMENTS); + POST_COLLECTION_NODE.parent.results = results; + + processVirtualNode(POST_COLLECTION_NODE, POSTS); + + expect(results[0].posts).to.be.an('array').and.be.eql([{_id: 1, commentId: 1}]); + expect(results[1].posts).to.be.an('array').and.be.eql([{_id: 2, commentId: 2}]); + expect(results[2].posts).to.be.undefined; + }); + + it('works with foreignIdentityField', function () { + const POST_COLLECTION_NODE = new CollectionNode(POST_COLLECTION, {}, 'postsForeign'); + const COMMENT_COLLECTION_NODE = new CollectionNode(COMMENT_COLLECTION, {}); + COMMENT_COLLECTION_NODE.add(POST_COLLECTION_NODE, COMMENT_COLLECTION.__links['postsForeign']); + + const results = EJSON.clone(COMMENTS); + POST_COLLECTION_NODE.parent.results = results; + + processVirtualNode(POST_COLLECTION_NODE, POSTS); + + expect(results[0].postsForeign).to.be.an('array').and.be.eql([{_id: 1, commentId: 1}]); + expect(results[1].postsForeign).to.be.an('array').and.be.eql([{_id: 1, commentId: 1}]); + expect(results[2].postsForeign).to.be.undefined; + }); + }); +}); diff --git a/lib/query/lib/createGraph.js b/lib/query/lib/createGraph.js index f738452c..2611c238 100755 --- a/lib/query/lib/createGraph.js +++ b/lib/query/lib/createGraph.js @@ -1,3 +1,4 @@ +import dot from 'dot-object'; import CollectionNode from '../nodes/collectionNode.js'; import FieldNode from '../nodes/fieldNode.js'; import ReducerNode from '../nodes/reducerNode.js'; @@ -90,6 +91,32 @@ function isProjectionOperatorExpression(body) { return false; } +function tryFindLink(root, dottizedPath) { + // This would be the link in form of {nestedDocument: {linkedCollection: {...fields...}}} + const parts = dottizedPath.split('.'); + const firstPart = parts.slice(0, 2); + // Here we have a situation where we have link inside a nested document of a nested document + // {nestedDocument: {subnestedDocument: {linkedCollection: {...fields...}}} + const nestedParts = parts.slice(2); + + const potentialLinks = nestedParts.reduce((acc, part) => { + return [ + ...acc, + `${_.last(acc)}.${part}`, + ]; + }, [firstPart.join('.')]); + + // Trying to find topmost link + while (potentialLinks[0]) { + const linkerKey = potentialLinks.splice(0, 1); + const linker = root.collection.getLinker(linkerKey); + if (linker) { + return linker; + } + } +} + + /** * @param body * @param fieldName @@ -101,6 +128,21 @@ export function addFieldNode(body, fieldName, root) { if (!isProjectionOperatorExpression(body)) { let dotted = dotize.convert({[fieldName]: body}); _.each(dotted, (value, key) => { + // check for link + const linker = tryFindLink(root, key); + + if (linker && !root.hasCollectionNode(linker.linkName)) { + const path = linker.linkName.split('.').slice(1).join('.'); + const subrootBody = dot.pick(path, body); + + const subroot = new CollectionNode(linker.getLinkedCollection(), subrootBody, linker.linkName); + // must be before adding linker because _shouldCleanStorage method + createNodes(subroot); + root.add(subroot, linker); + return; + } + + // checking if it's a reducer const reducer = root.collection.getReducer(key); @@ -131,7 +173,8 @@ export function getNodeNamespace(node) { const parts = []; let n = node; while (n) { - const name = n.linker ? n.linker.linkName : n.collection._name; + // links can now contain '.' (nested links) + const name = n.linker ? n.linker.linkName.replace(/\./, '_') : n.collection._name; parts.push(name); // console.log('linker', node.linker ? node.linker.linkName : node.collection._name); n = n.parent; diff --git a/lib/query/lib/prepareForDelivery.js b/lib/query/lib/prepareForDelivery.js index 3810bcda..8beec0df 100755 --- a/lib/query/lib/prepareForDelivery.js +++ b/lib/query/lib/prepareForDelivery.js @@ -7,6 +7,7 @@ import cleanReducerLeftovers from '../reducers/lib/cleanReducerLeftovers'; import sift from 'sift'; import dot from 'dot-object'; import {Minimongo} from 'meteor/minimongo'; +import CollectionNode from '../nodes/collectionNode'; export default (node, params) => { snapBackCaches(node); @@ -141,6 +142,28 @@ export function removeLinkStorages(node, sameLevelResults) { }) } +function removeArrayFromObject(result, linkName) { + const linkData = dot.pick(linkName, result); + if (linkData && Array.isArray(linkData)) { + dot.remove(linkName, result); + dot.str(linkName, _.first(linkData), result); + } +} + +function removeArrayForOneResult(result, linkName) { + const [root, ...rest] = linkName.split('.'); + + const rootValue = result[root]; + if (rest.length > 0 && Array.isArray(rootValue)) { + rootValue.forEach(value => { + removeArrayFromObject(value, rest.join('.')); + }); + } + else { + removeArrayFromObject(result, linkName); + } +} + export function storeOneResults(node, sameLevelResults) { if (!sameLevelResults || !Array.isArray(sameLevelResults)) { return; @@ -154,17 +177,25 @@ export function storeOneResults(node, sameLevelResults) { return; } - storeOneResults(collectionNode, result[collectionNode.linkName]); + const [root, ...rest] = collectionNode.linkName.split('.'); + if (rest.length === 0) { + storeOneResults(collectionNode, result[root]); + } + else { + const rootValue = result[root]; + if (Array.isArray(rootValue)) { + rootValue.forEach(value => { + storeOneResults(collectionNode, dot.pick(rest.join('.'), value)); + }); + } + else if (_.isObject(rootValue)) { + storeOneResults(collectionNode, dot.pick(rest.join('.'), rootValue)); + } + } }); if (collectionNode.isOneResult) { - _.each(sameLevelResults, result => { - if (result[collectionNode.linkName] && Array.isArray(result[collectionNode.linkName])) { - result[collectionNode.linkName] = result[collectionNode.linkName] - ? _.first(result[collectionNode.linkName]) - : undefined; - } - }) + _.each(sameLevelResults, result => removeArrayForOneResult(result, collectionNode.linkName)); } }) } diff --git a/lib/query/lib/recursiveFetch.js b/lib/query/lib/recursiveFetch.js index bf265ea8..2c21ab75 100755 --- a/lib/query/lib/recursiveFetch.js +++ b/lib/query/lib/recursiveFetch.js @@ -1,5 +1,5 @@ +import dot from 'dot-object'; import applyProps from './applyProps.js'; -import { assembleMetadata, removeLinkStorages, storeOneResults } from './prepareForDelivery'; import prepareForDelivery from './prepareForDelivery'; import {getNodeNamespace} from './createGraph'; import {isFieldInProjection} from '../lib/fieldInProjection'; @@ -45,7 +45,28 @@ function fetch(node, parentObject, fetchOptions = {}) { _.each(node.collectionNodes, collectionNode => { _.each(results, result => { const collectionNodeResults = fetch(collectionNode, result, fetchOptions); - result[collectionNode.linkName] = collectionNodeResults; + const [root, ...rest] = collectionNode.linkName.split('.'); + + if (rest.length === 0) { + result[collectionNode.linkName] = collectionNodeResults; + } + else { + const value = result[root]; + if (Array.isArray(value)) { + const [, ...storageRest] = collectionNode.linker.linkStorageField.split('.'); + const nestedPath = storageRest.join('.'); + value.forEach(item => { + const storageValue = item[nestedPath]; + // todo: use _id or foreignIdentityField + item[rest.join('.')] = collectionNode.linker.isSingle() + ? collectionNodeResults.filter(result => result._id === storageValue) + : collectionNodeResults.filter(result => _.contains(storageValue || [], result._id)); + }); + } + else if (_.isObject(value)) { + dot.str(rest.join('.'), collectionNodeResults, value); + } + } //delete result[node.linker.linkStorageField]; /** @@ -60,7 +81,7 @@ function fetch(node, parentObject, fetchOptions = {}) { collectionNode.results.push(...collectionNodeResults); - // this was not working because all references must be replaced in snapBackCaches, not only the ones that are + // this was not working because all references must be replaced in snapBackCaches, not only the ones that are // found first // const currentIds = _.pluck(collectionNode.results, '_id'); // collectionNode.results.push(...collectionNodeResults.filter(res => !_.contains(currentIds, res._id))); diff --git a/lib/query/nodes/collectionNode.js b/lib/query/nodes/collectionNode.js index b7ab308a..b78f1467 100644 --- a/lib/query/nodes/collectionNode.js +++ b/lib/query/nodes/collectionNode.js @@ -50,7 +50,7 @@ export default class CollectionNode { if (node instanceof FieldNode) { runFieldSanityChecks(node.name); } - + if (linker) { node.linker = linker; node.linkStorageField = linker.linkStorageField; @@ -99,9 +99,9 @@ export default class CollectionNode { /** * $meta field should be added to the options.fields, but MongoDB does not exclude other fields. * Therefore, we do not count this as a field addition. - * + * * See: https://docs.mongodb.com/manual/reference/operator/projection/meta/ - * The $meta expression specifies the inclusion of the field to the result set + * The $meta expression specifies the inclusion of the field to the result set * and does not specify the exclusion of the other fields. */ if (n.projectionOperator !== '$meta') { @@ -148,7 +148,7 @@ export default class CollectionNode { * @returns {boolean} */ hasField(fieldName, checkNested = false) { - // for checkNested flag it expands profile.phone.verified into + // for checkNested flag it expands profile.phone.verified into // ['profile', 'profile.phone', 'profile.phone.verified'] // if any of these fields match it means that field exists @@ -257,7 +257,7 @@ export default class CollectionNode { /** * Make sure that the field is ok to be added - * @param {*} fieldName + * @param {*} fieldName */ export function runFieldSanityChecks(fieldName) { // Run sanity checks on the field diff --git a/lib/query/testing/bootstrap/files/links.js b/lib/query/testing/bootstrap/files/links.js index cdff714e..772901ee 100755 --- a/lib/query/testing/bootstrap/files/links.js +++ b/lib/query/testing/bootstrap/files/links.js @@ -13,4 +13,32 @@ Files.addLinks({ // metas is an array field: 'metas.projectId', }, + + // include nested fields directly in the nested documents + 'meta.project': { + collection: Projects, + type: 'one', + field: 'meta.projectId', + }, + 'meta.manyProjects': { + collection: Projects, + type: 'many', + field: 'meta.projectIds', + }, + 'metas.project': { + collection: Projects, + type: 'one', + field: 'metas.projectId', + }, + 'meta.projects': { + collection: Projects, + type: 'many', + // metas is an array + field: 'metas.projectId', + }, + 'metas.manyProjects': { + collection: Projects, + type: 'many', + field: 'metas.projectIds', + }, }); diff --git a/lib/query/testing/bootstrap/fixtures.js b/lib/query/testing/bootstrap/fixtures.js index bef7b842..0f710b65 100755 --- a/lib/query/testing/bootstrap/fixtures.js +++ b/lib/query/testing/bootstrap/fixtures.js @@ -111,13 +111,16 @@ Files.insert({ metas: [{ type: 'text', projectId: project1, + projectIds: [project1], }, { type: 'hidden', projectId: project2, + projectIds: [project2, project1], }], meta: { type: 'text', projectId: project1, + projectIds: [project2], }, }); @@ -130,6 +133,7 @@ Files.insert({ meta: { type: 'pdf', projectId: project1, + projectIds: [project2, project1], }, }); diff --git a/lib/query/testing/client.test.js b/lib/query/testing/client.test.js index 907981ee..2fc4d1bb 100755 --- a/lib/query/testing/client.test.js +++ b/lib/query/testing/client.test.js @@ -333,6 +333,137 @@ describe('Query Client Tests', function () { }); }); + it('Should work with links on nested fields inside nested fields - one', async () => { + const query = createQuery({ + files: { + filename: 1, + meta: { + type: 1, + project: { + name: 1, + }, + }, + }, + }); + + + const handle = query.subscribe(); + await waitForHandleToBeReady(handle); + + const files = query.fetch(); + + // console.log('files', files); + + expect(files).to.be.an('array'); + expect(files).to.have.length(2); + files.forEach(file => { + expect(file.meta).to.be.an('object'); + expect(file.meta.project).to.be.an('object'); + expect(file.meta.project.name).to.be.a('string'); + }); + + handle.stop(); + }); + + it('Should work with links on nested fields inside nested fields (array) - one', async () => { + const query = createQuery({ + files: { + filename: 1, + metas: { + type: 1, + project: { + name: 1, + }, + }, + }, + }); + + + const handle = query.subscribe(); + await waitForHandleToBeReady(handle); + + const files = query.fetch(); + + expect(files).to.be.an('array'); + expect(files).to.have.length(2); + files.forEach(file => { + expect(file.metas).to.be.an('array'); + expect(file.metas[0].project).to.be.an('object'); + expect(file.metas[0].project.name).to.be.a('string'); + }); + + handle.stop(); + }); + + it('Should work with links on nested fields inside nested fields - many in object', async () => { + const query = createQuery({ + files: { + filename: 1, + meta: { + type: 1, + manyProjects: { + name: 1, + }, + }, + }, + }); + + const handle = query.subscribe(); + await waitForHandleToBeReady(handle); + + const files = query.fetch(); + + // console.log('files', files); + + expect(files).to.be.an('array'); + expect(files).to.have.length(2); + files.forEach(file => { + expect(file.meta).to.be.an('object'); + expect(file.meta.manyProjects).to.be.an('array'); + }); + + handle.stop(); + }); + + it('Should work with links on nested fields inside nested fields - many in array', async () => { + const query = createQuery({ + files: { + filename: 1, + metas: { + type: 1, + manyProjects: { + name: 1, + }, + }, + }, + }); + + const handle = query.subscribe(); + await waitForHandleToBeReady(handle); + + const files = query.fetch(); + + // console.log('files', files); + + expect(files).to.be.an('array'); + expect(files).to.have.length(2); + files.forEach(file => { + expect(file.metas).to.be.an('array'); + if (file.metas.length > 0) { + expect(file.metas[0].manyProjects).to.be.an('array'); + } + if (file.filename === 'test.txt') { + expect(file.metas[0].manyProjects).to.have.length(1); + expect(file.metas[1].manyProjects).to.have.length(2); + expect(file.metas[0].manyProjects[0].name).to.be.equal('Project 1'); + // expect(file.metas[1].manyProjects[0].name).to.be.equal('Project 2'); + // expect(file.metas[1].manyProjects[1].name).to.be.equal('Project 1'); + } + }); + + handle.stop(); + }); + it('Should work with links on nested fields - one inversed', async () => { const query = createQuery({ projects: { @@ -344,7 +475,6 @@ describe('Query Client Tests', function () { }, }); - const handle = query.subscribe(); await waitForHandleToBeReady(handle); @@ -357,10 +487,12 @@ describe('Query Client Tests', function () { project.files.forEach(file => { expect(file.filename).to.be.a('string'); expect(file.meta).to.be.an('object'); - // both keys expected - expect(_.keys(file.meta)).to.have.length(2); + // all keys expected + expect(_.keys(file.meta)).to.have.length(3); }); }); + + handle.stop(); }); it('Should work with links on nested fields - one inversed without meta (remove link storages)', async () => { @@ -387,6 +519,8 @@ describe('Query Client Tests', function () { expect(file.meta).to.be.eql({}); }); }); + + handle.stop(); }); it('Should work with links on nested fields - many', async () => { diff --git a/lib/query/testing/server.test.js b/lib/query/testing/server.test.js index 2c89eef4..5a6c3846 100755 --- a/lib/query/testing/server.test.js +++ b/lib/query/testing/server.test.js @@ -1158,6 +1158,45 @@ describe("Hypernova", function() { expect(result.meta.projectId).to.be.a("string"); }); + it("Should work with links on nested fields inside nested fields - one", () => { + const result = Files.createQuery({ + filename: 1, + meta: { + type: 1, + project: { + name: 1, + }, + }, + }).fetchOne(); + + // console.log('result', result); + + expect(result).to.be.an('object'); + expect(result.meta).to.be.an('object'); + expect(result.meta.project).to.be.an('object'); + expect(result.meta.project.name).to.be.equal('Project 1'); + }); + + it("Should work with links on nested fields inside nested fields (array) - one", () => { + const result = Files.createQuery({ + filename: 1, + metas: { + type: 1, + project: { + name: 1, + }, + }, + }).fetchOne(); + + // console.log('result', result); + + expect(result).to.be.an('object'); + expect(result.metas).to.be.an('array'); + expect(result.metas[0].project).to.be.an('object'); + expect(result.metas[0].project.name).to.be.equal('Project 1'); + expect(result.metas[1].project.name).to.be.equal('Project 2'); + }); + it("Should work with links on nested fields - one (w/o meta)", () => { const result = Files.createQuery({ filename: 1, @@ -1189,7 +1228,7 @@ describe("Hypernova", function() { expect(file._id).to.be.a("string"); expect(file.filename).to.be.a("string"); expect(file.meta).to.be.an("object"); - expect(_.keys(file.meta)).to.be.eql(["type", "projectId"]); + expect(_.keys(file.meta)).to.be.eql(["type", "projectId", "projectIds"]); }); }); @@ -1215,7 +1254,7 @@ describe("Hypernova", function() { expect(project1.name).to.be.equal("Project 1"); expect(project2.name).to.be.equal("Project 2"); expect(res1.metas).to.be.an("array"); - expect(_.keys(res1.metas[0])).to.be.eql(["type", "projectId"]); + expect(_.keys(res1.metas[0])).to.be.eql(["type", "projectId", "projectIds"]); expect(res2.projects).to.be.an("array"); expect(res2.projects).to.have.length(1); @@ -1226,6 +1265,61 @@ describe("Hypernova", function() { expect(project.name).to.be.equal("Project 2"); }); + it('Should work with links on nested fields inside nested fields - many in object', () => { + const result = Files.createQuery({ + filename: 1, + meta: { + manyProjects: { + name: 1, + }, + }, + }).fetch(); + + // console.log('result', JSON.stringify(result)); + + expect(result).to.be.an("array"); + expect(result).to.have.length(2); + + result.forEach(file => { + expect(file.meta).to.be.an('object'); + expect(file.meta.manyProjects).to.be.an('array'); + expect(file.meta.manyProjects.length).to.be.greaterThan(0); + }); + }); + + it('Should work with links on nested fields inside nested fields - many in array', () => { + const result = Files.createQuery({ + filename: 1, + metas: { + manyProjects: { + name: 1, + }, + }, + }).fetch(); + + expect(result).to.be.an("array"); + expect(result).to.have.length(2); + + result.forEach(file => { + if (file.filename === 'test.txt') { + expect(file.metas).to.be.an('array').and.have.length(2); + expect(file.metas[0].manyProjects.map((p) => ({ + name: p.name + }))).to.be.eql([ + { + name: 'Project 1' + } + ]); + expect(file.metas[1].manyProjects).to.have.length(2); + } + // invoice.pdf file + else { + expect(file.metas).to.be.an('array').and.have.length(1); + expect(file.metas[0].manyProjects).to.be.an('array').and.have.length(0); + } + }); + }); + it("Should work with links on nested fields - many (w/o metas)", () => { const result = Files.createQuery({ filename: 1, diff --git a/package.js b/package.js index e1ccf2b8..4be099c7 100755 --- a/package.js +++ b/package.js @@ -97,6 +97,10 @@ Package.onTest(function (api) { api.addFiles("lib/namedQuery/testing/server.test.js", "server"); api.addFiles("lib/namedQuery/testing/client.test.js", "client"); + // hypernova + api.addFiles("lib/query/hypernova/testing/assembler.test.js", "server"); + api.addFiles("lib/query/hypernova/testing/processVirtualNode.test.js", "server"); + // GRAPHQL api.addFiles("lib/graphql/testing/index.js", "server"); });