From 00c0f54c8839d01c9857cbf9267f78cb67b6ba05 Mon Sep 17 00:00:00 2001 From: Berislav Date: Tue, 31 Mar 2020 21:37:45 +0200 Subject: [PATCH 01/15] Using nested fields as target for nested field ONE links --- lib/query/hypernova/aggregateSearchFilters.js | 18 +++++- lib/query/hypernova/assembler.js | 57 ++++++++++++++----- lib/query/lib/createGraph.js | 15 +++++ lib/query/lib/prepareForDelivery.js | 33 ++++++++--- lib/query/testing/bootstrap/files/links.js | 18 ++++++ lib/query/testing/reducers.server.test.js | 2 + lib/query/testing/server.test.js | 39 +++++++++++++ 7 files changed, 159 insertions(+), 23 deletions(-) diff --git a/lib/query/hypernova/aggregateSearchFilters.js b/lib/query/hypernova/aggregateSearchFilters.js index 653dae36..de0b92b8 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 (_.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); } /** diff --git a/lib/query/hypernova/assembler.js b/lib/query/hypernova/assembler.js index 7f805493..e8903063 100644 --- a/lib/query/hypernova/assembler.js +++ b/lib/query/hypernova/assembler.js @@ -2,6 +2,26 @@ import createSearchFilters from '../../links/lib/createSearchFilters'; import cleanObjectForMetaFilters from './lib/cleanObjectForMetaFilters'; import sift from 'sift'; import dot from 'dot-object'; +import { isString } from 'util'; + +function getIdsFromArray(array, nested) { + const ids = []; + array.forEach(v => { + const _id = nested.length > 0 ? dot.pick(nested.join('.'), v) : v; + ids.push(_id); + }); + return ids; +} + +function getIdsForMany(parentResult, fieldStorage) { + // support dotted fields + const [root, ...nested] = fieldStorage.split('.'); + const value = dot.pick(root, parentResult); + if (!value) { + return []; + } + return getIdsFromArray(value, nested); +} export default (childCollectionNode, { limit, skip, metaFilters }) => { if (childCollectionNode.results.length === 0) { @@ -33,33 +53,42 @@ export default (childCollectionNode, { limit, skip, metaFilters }) => { if (strategy === 'one') { parent.results.forEach(parentResult => { + const [root, ...rest] = fieldStorage.split('.'); + const rootValue = parentResult[root]; + if (!rootValue) { + return; + } + + const path = childCollectionNode.linkName.split('.'); + + if (_.isArray(rootValue)) { + rootValue.map(result => { + const value = dot.pick(rest.join('.'), result); + const data = filterAssembledData( + resultsByKeyId[value], + { limit, skip } + ); + result[path.slice(1).join('.')] = data; + }); + return; + } + const value = dot.pick(fieldStorage, parentResult); if (!value) { return; } - parentResult[childCollectionNode.linkName] = filterAssembledData( + const data = filterAssembledData( resultsByKeyId[value], { limit, skip } ); + dot.str(childCollectionNode.linkName, data, parentResult); }); } 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] || [])); - }); - + const data = getIdsForMany(parentResult, fieldStorage).map(id => _.first(resultsByKeyId[id])); parentResult[childCollectionNode.linkName] = filterAssembledData( data, { limit, skip } diff --git a/lib/query/lib/createGraph.js b/lib/query/lib/createGraph.js index f738452c..d9b03c25 100755 --- a/lib/query/lib/createGraph.js +++ b/lib/query/lib/createGraph.js @@ -101,6 +101,21 @@ export function addFieldNode(body, fieldName, root) { if (!isProjectionOperatorExpression(body)) { let dotted = dotize.convert({[fieldName]: body}); _.each(dotted, (value, key) => { + // check for link + const parts = key.split('.'); + const linkerKey = parts.slice(0, 2).join('.'); + + const linker = root.collection.getLinker(linkerKey); + + if (linker) { + const subroot = new CollectionNode(linker.getLinkedCollection(), body[parts[1]], linkerKey); + // 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); diff --git a/lib/query/lib/prepareForDelivery.js b/lib/query/lib/prepareForDelivery.js index 3810bcda..4e961636 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 && _.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 && _.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,11 @@ export function storeOneResults(node, sameLevelResults) { return; } - storeOneResults(collectionNode, result[collectionNode.linkName]); + storeOneResults(collectionNode, dot.pick(collectionNode.linkName, result)); }); 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/testing/bootstrap/files/links.js b/lib/query/testing/bootstrap/files/links.js index cdff714e..b8a91d8c 100755 --- a/lib/query/testing/bootstrap/files/links.js +++ b/lib/query/testing/bootstrap/files/links.js @@ -13,4 +13,22 @@ 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', + }, + 'metas.project': { + collection: Projects, + type: 'one', + field: 'metas.projectId', + }, + 'meta.projects': { + collection: Projects, + type: 'many', + // metas is an array + field: 'metas.projectId', + }, }); diff --git a/lib/query/testing/reducers.server.test.js b/lib/query/testing/reducers.server.test.js index 419c78ab..6b10b9fe 100755 --- a/lib/query/testing/reducers.server.test.js +++ b/lib/query/testing/reducers.server.test.js @@ -91,6 +91,8 @@ describe('Reducers', function() { }, }).fetch(); + console.log('data', data[0].author); + assert.isTrue(data.length > 0); data.forEach(post => { diff --git a/lib/query/testing/server.test.js b/lib/query/testing/server.test.js index 2c89eef4..18a71890 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, From 3345d850bdb34b78f4564ed38f1cfef2760e73e1 Mon Sep 17 00:00:00 2001 From: Berislav Date: Tue, 31 Mar 2020 22:36:37 +0200 Subject: [PATCH 02/15] Subscription updates: --- lib/links/lib/createSearchFilters.js | 18 ++++++++- lib/query/lib/recursiveFetch.js | 22 +++++++++- lib/query/testing/client.test.js | 60 ++++++++++++++++++++++++++++ 3 files changed, 98 insertions(+), 2 deletions(-) diff --git a/lib/links/lib/createSearchFilters.js b/lib/links/lib/createSearchFilters.js index 7a01e4e5..0504c985 100755 --- a/lib/links/lib/createSearchFilters.js +++ b/lib/links/lib/createSearchFilters.js @@ -26,12 +26,28 @@ export default function createSearchFilters(object, linker, metaFilters) { } } +function getIdQueryFieldStorage(object, fieldStorage) { + const [root, ...rest] = fieldStorage.split('.'); + if (rest.length === 0) { + return object[fieldStorage]; + } + + const nestedPath = rest.join('.'); + const rootValue = object[root]; + if (_.isArray(rootValue)) { + return {$in: rootValue.map(item => dot.pick(nestedPath, item))}; + } + else if (_.isObject(rootValue)) { + return 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: []}, }; } diff --git a/lib/query/lib/recursiveFetch.js b/lib/query/lib/recursiveFetch.js index bf265ea8..52cf4982 100755 --- a/lib/query/lib/recursiveFetch.js +++ b/lib/query/lib/recursiveFetch.js @@ -1,3 +1,4 @@ +import dot from 'dot-object'; import applyProps from './applyProps.js'; import { assembleMetadata, removeLinkStorages, storeOneResults } from './prepareForDelivery'; import prepareForDelivery from './prepareForDelivery'; @@ -45,7 +46,26 @@ 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 (_.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('.')] = collectionNodeResults.filter(result => result._id === storageValue); + }); + } + else if (_.isObject(value)) { + dot.str(rest.join('.'), collectionNodeResults, value); + } + } //delete result[node.linker.linkStorageField]; /** diff --git a/lib/query/testing/client.test.js b/lib/query/testing/client.test.js index 907981ee..4dd4be3a 100755 --- a/lib/query/testing/client.test.js +++ b/lib/query/testing/client.test.js @@ -333,6 +333,66 @@ 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'); + }); + }); + + 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(); + + 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'); + expect(file.metas[0].project).to.be.an('object'); + expect(file.metas[0].project.name).to.be.a('string'); + }); + }); + it('Should work with links on nested fields - one inversed', async () => { const query = createQuery({ projects: { From 595516d90d5e05f30148d80287b64f67980fb183 Mon Sep 17 00:00:00 2001 From: Berislav Date: Wed, 1 Apr 2020 12:01:38 +0200 Subject: [PATCH 03/15] Nested MANY links --- lib/links/lib/createSearchFilters.js | 21 ++---- lib/query/hypernova/assembler.js | 74 +++++++++++++++++-- lib/query/lib/recursiveFetch.js | 4 +- lib/query/testing/bootstrap/files/links.js | 10 +++ lib/query/testing/bootstrap/fixtures.js | 4 ++ lib/query/testing/client.test.js | 84 ++++++++++++++++++++-- lib/query/testing/reducers.server.test.js | 2 - lib/query/testing/server.test.js | 47 +++++++++++- 8 files changed, 217 insertions(+), 29 deletions(-) diff --git a/lib/links/lib/createSearchFilters.js b/lib/links/lib/createSearchFilters.js index 0504c985..16ea4611 100755 --- a/lib/links/lib/createSearchFilters.js +++ b/lib/links/lib/createSearchFilters.js @@ -26,19 +26,19 @@ export default function createSearchFilters(object, linker, metaFilters) { } } -function getIdQueryFieldStorage(object, fieldStorage) { +function getIdQueryFieldStorage(object, fieldStorage, isMany = false) { const [root, ...rest] = fieldStorage.split('.'); if (rest.length === 0) { - return object[fieldStorage]; + return isMany ? {$in: object[fieldStorage] || []} : object[fieldStorage]; } const nestedPath = rest.join('.'); const rootValue = object[root]; if (_.isArray(rootValue)) { - return {$in: rootValue.map(item => dot.pick(nestedPath, item))}; + return {$in: _.uniq(_.union(...rootValue.map(item => dot.pick(nestedPath, item))))}; } else if (_.isObject(rootValue)) { - return dot.pick(nestedPath, rootValue); + return isMany ? {$in: dot.pick(nestedPath, rootValue) || []} : dot.pick(nestedPath, rootValue); } } @@ -85,19 +85,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/assembler.js b/lib/query/hypernova/assembler.js index e8903063..959341b4 100644 --- a/lib/query/hypernova/assembler.js +++ b/lib/query/hypernova/assembler.js @@ -13,6 +13,30 @@ function getIdsFromArray(array, nested) { return ids; } +/** + * Possible options: + * + * A. array of ids directly inside the parentResult + * { + * projectIds: [...], + * } + * + * B. array of ids in nested document + * { + * nested: { + * projectIds: [...], + * } + * } + * + * C. array of ids in nested array + * { + * nestedArray: [{ + * projectIds: [...], + * }, { + * projectIds: [...], + * }] + * } + */ function getIdsForMany(parentResult, fieldStorage) { // support dotted fields const [root, ...nested] = fieldStorage.split('.'); @@ -20,6 +44,23 @@ function getIdsForMany(parentResult, fieldStorage) { if (!value) { return []; } + + // Option A. + if (nested.length === 0) { + return value; + } + + // Option C. + if (_.isArray(value)) { + return _.flatten(value.map(v => dot.pick(nested.join('.'), v) || [])); + } + + // Option B + if (_.isObject(value)) { + return dot.pick(nested.join('.'), value) || []; + } + + // Option B. return getIdsFromArray(value, nested); } @@ -62,7 +103,7 @@ export default (childCollectionNode, { limit, skip, metaFilters }) => { const path = childCollectionNode.linkName.split('.'); if (_.isArray(rootValue)) { - rootValue.map(result => { + rootValue.forEach(result => { const value = dot.pick(rest.join('.'), result); const data = filterAssembledData( resultsByKeyId[value], @@ -88,11 +129,36 @@ export default (childCollectionNode, { limit, skip, metaFilters }) => { if (strategy === 'many') { parent.results.forEach(parentResult => { - const data = getIdsForMany(parentResult, fieldStorage).map(id => _.first(resultsByKeyId[id])); - parentResult[childCollectionNode.linkName] = filterAssembledData( - data, + const [root, ...rest] = fieldStorage.split('.'); + const rootValue = parentResult[root]; + if (!rootValue) { + return; + } + + const [, ...nestedLinkPath] = childCollectionNode.linkName.split('.'); + if (nestedLinkPath.length > 0 && _.isArray(rootValue)) { + rootValue.forEach(result => { + const value = (dot.pick(rest.join('.'), result) || []).map(id => _.first(resultsByKeyId[id])); + const data = filterAssembledData( + value, + { limit, skip } + ); + result[nestedLinkPath.join('.')] = data; + }); + return; + } + + const results = getIdsForMany(parentResult, fieldStorage).map(id => _.first(resultsByKeyId[id])); + + // console.log(parentResult); + // console.log('results', results); + + const data = filterAssembledData( + results, { limit, skip } ); + + dot.str(childCollectionNode.linkName, data, parentResult); }); } diff --git a/lib/query/lib/recursiveFetch.js b/lib/query/lib/recursiveFetch.js index 52cf4982..ac87f490 100755 --- a/lib/query/lib/recursiveFetch.js +++ b/lib/query/lib/recursiveFetch.js @@ -59,7 +59,9 @@ function fetch(node, parentObject, fetchOptions = {}) { value.forEach(item => { const storageValue = item[nestedPath]; // todo: use _id or foreignIdentityField - item[rest.join('.')] = collectionNodeResults.filter(result => result._id === storageValue); + item[rest.join('.')] = collectionNode.linker.isSingle() + ? collectionNodeResults.filter(result => result._id === storageValue) + : collectionNodeResults.filter(result => _.contains(storageValue || [], result._id)); }); } else if (_.isObject(value)) { diff --git a/lib/query/testing/bootstrap/files/links.js b/lib/query/testing/bootstrap/files/links.js index b8a91d8c..772901ee 100755 --- a/lib/query/testing/bootstrap/files/links.js +++ b/lib/query/testing/bootstrap/files/links.js @@ -20,6 +20,11 @@ Files.addLinks({ type: 'one', field: 'meta.projectId', }, + 'meta.manyProjects': { + collection: Projects, + type: 'many', + field: 'meta.projectIds', + }, 'metas.project': { collection: Projects, type: 'one', @@ -31,4 +36,9 @@ Files.addLinks({ // 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 4dd4be3a..c858bf6f 100755 --- a/lib/query/testing/client.test.js +++ b/lib/query/testing/client.test.js @@ -361,6 +361,8 @@ describe('Query Client Tests', function () { 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 () => { @@ -382,7 +384,7 @@ describe('Query Client Tests', function () { const files = query.fetch(); - console.log('files', files); + // console.log('files', files); expect(files).to.be.an('array'); expect(files).to.have.length(2); @@ -391,6 +393,77 @@ describe('Query Client Tests', function () { 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 () => { @@ -404,7 +477,6 @@ describe('Query Client Tests', function () { }, }); - const handle = query.subscribe(); await waitForHandleToBeReady(handle); @@ -417,10 +489,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 () => { @@ -447,6 +521,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/reducers.server.test.js b/lib/query/testing/reducers.server.test.js index 6b10b9fe..419c78ab 100755 --- a/lib/query/testing/reducers.server.test.js +++ b/lib/query/testing/reducers.server.test.js @@ -91,8 +91,6 @@ describe('Reducers', function() { }, }).fetch(); - console.log('data', data[0].author); - assert.isTrue(data.length > 0); data.forEach(post => { diff --git a/lib/query/testing/server.test.js b/lib/query/testing/server.test.js index 18a71890..7b6cd78f 100755 --- a/lib/query/testing/server.test.js +++ b/lib/query/testing/server.test.js @@ -1228,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"]); }); }); @@ -1254,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); @@ -1265,6 +1265,49 @@ 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(); + + // console.log('result', JSON.stringify(result)); + + expect(result).to.be.an("array"); + expect(result).to.have.length(2); + + result.forEach(file => { + expect(file.metas).to.be.an('array'); + expect(file.metas[0].manyProjects).to.be.an('array'); + }); + }); + it("Should work with links on nested fields - many (w/o metas)", () => { const result = Files.createQuery({ filename: 1, From 02d8559e0ec9167ade103a87ffd8f33984f759f3 Mon Sep 17 00:00:00 2001 From: Berislav Date: Wed, 25 Oct 2023 13:25:12 +0200 Subject: [PATCH 04/15] Improved tests --- lib/query/testing/server.test.js | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/lib/query/testing/server.test.js b/lib/query/testing/server.test.js index 7b6cd78f..5a6c3846 100755 --- a/lib/query/testing/server.test.js +++ b/lib/query/testing/server.test.js @@ -1297,14 +1297,26 @@ describe("Hypernova", function() { }, }).fetch(); - // console.log('result', JSON.stringify(result)); - expect(result).to.be.an("array"); expect(result).to.have.length(2); result.forEach(file => { - expect(file.metas).to.be.an('array'); - expect(file.metas[0].manyProjects).to.be.an('array'); + 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); + } }); }); From 564f31124bd0b56dbba4d35fe90eaedb79492b0f Mon Sep 17 00:00:00 2001 From: Berislav Date: Thu, 23 Nov 2023 14:37:55 +0100 Subject: [PATCH 05/15] Minor updates --- lib/query/hypernova/aggregateSearchFilters.js | 2 +- lib/query/hypernova/assembler.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/query/hypernova/aggregateSearchFilters.js b/lib/query/hypernova/aggregateSearchFilters.js index de0b92b8..b5aae31f 100644 --- a/lib/query/hypernova/aggregateSearchFilters.js +++ b/lib/query/hypernova/aggregateSearchFilters.js @@ -60,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 959341b4..a37aa611 100644 --- a/lib/query/hypernova/assembler.js +++ b/lib/query/hypernova/assembler.js @@ -47,7 +47,7 @@ function getIdsForMany(parentResult, fieldStorage) { // Option A. if (nested.length === 0) { - return value; + return _.isArray(value) ? value : [value]; } // Option C. From e2f1ebfca47593ed1448899d6db795f7633045e8 Mon Sep 17 00:00:00 2001 From: Berislav Date: Wed, 1 Apr 2020 17:44:45 +0200 Subject: [PATCH 06/15] Assembler small fix --- lib/query/hypernova/assembler.js | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/lib/query/hypernova/assembler.js b/lib/query/hypernova/assembler.js index a37aa611..8ad58b3b 100644 --- a/lib/query/hypernova/assembler.js +++ b/lib/query/hypernova/assembler.js @@ -138,7 +138,7 @@ export default (childCollectionNode, { limit, skip, metaFilters }) => { const [, ...nestedLinkPath] = childCollectionNode.linkName.split('.'); if (nestedLinkPath.length > 0 && _.isArray(rootValue)) { rootValue.forEach(result => { - const value = (dot.pick(rest.join('.'), result) || []).map(id => _.first(resultsByKeyId[id])); + const value = _.union(...dot.pick(rest.join('.'), result) || []).map(id => resultsByKeyId[id]); const data = filterAssembledData( value, { limit, skip } @@ -148,10 +148,7 @@ export default (childCollectionNode, { limit, skip, metaFilters }) => { return; } - const results = getIdsForMany(parentResult, fieldStorage).map(id => _.first(resultsByKeyId[id])); - - // console.log(parentResult); - // console.log('results', results); + const results = _.union(...getIdsForMany(parentResult, fieldStorage).map(id => resultsByKeyId[id])); const data = filterAssembledData( results, From 72bb8ed77b2709218be7ff9963b08fba7374a4c6 Mon Sep 17 00:00:00 2001 From: Berislav Date: Thu, 2 Apr 2020 12:35:41 +0200 Subject: [PATCH 07/15] Additional tests for assemble() and processVirtualNode() --- lib/query/hypernova/assembler.js | 230 ++++++---- lib/query/hypernova/storeHypernovaResults.js | 2 +- lib/query/hypernova/testing/assembler.test.js | 426 ++++++++++++++++++ .../testing/processVirtualNode.test.js | 100 ++++ lib/query/lib/recursiveFetch.js | 5 +- lib/query/nodes/collectionNode.js | 10 +- lib/query/testing/client.test.js | 2 - package.js | 4 + 8 files changed, 691 insertions(+), 88 deletions(-) create mode 100644 lib/query/hypernova/testing/assembler.test.js create mode 100644 lib/query/hypernova/testing/processVirtualNode.test.js diff --git a/lib/query/hypernova/assembler.js b/lib/query/hypernova/assembler.js index 8ad58b3b..dde5e970 100644 --- a/lib/query/hypernova/assembler.js +++ b/lib/query/hypernova/assembler.js @@ -60,8 +60,128 @@ function getIdsForMany(parentResult, fieldStorage) { return dot.pick(nested.join('.'), value) || []; } - // Option B. - return getIdsFromArray(value, nested); + return []; +} + +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 && _.isArray(rootValue)) { + rootValue.forEach(result => { + const value = _.flatten(_.union(...(dot.pick(rest.join('.'), result) || [])).map(id => resultsByKeyId[id])); + const data = filterAssembledData( + value, + { 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 (_.isArray(rootValue)) { + rootValue.forEach(result => { + const value = dot.pick(rest.join('.'), result); + const data = filterAssembledData( + resultsByKeyId[value], + { limit, skip } + ); + 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 }) => { @@ -92,99 +212,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 [root, ...rest] = fieldStorage.split('.'); - const rootValue = parentResult[root]; - if (!rootValue) { - return; - } - - const path = childCollectionNode.linkName.split('.'); - - if (_.isArray(rootValue)) { - rootValue.forEach(result => { - const value = dot.pick(rest.join('.'), result); - const data = filterAssembledData( - resultsByKeyId[value], - { limit, skip } - ); - 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); + return assembleOne(parentResult, { + childCollectionNode, + linker, + limit, + skip, + resultsByKeyId, + }); }); } if (strategy === 'many') { parent.results.forEach(parentResult => { - const [root, ...rest] = fieldStorage.split('.'); - const rootValue = parentResult[root]; - if (!rootValue) { - return; - } - - const [, ...nestedLinkPath] = childCollectionNode.linkName.split('.'); - if (nestedLinkPath.length > 0 && _.isArray(rootValue)) { - rootValue.forEach(result => { - const value = _.union(...dot.pick(rest.join('.'), result) || []).map(id => resultsByKeyId[id]); - const data = filterAssembledData( - value, - { 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); + return assembleMany(parentResult, { + childCollectionNode, + linker, + skip, + limit, + resultsByKeyId, + }); }); } 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/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..be4165cc --- /dev/null +++ b/lib/query/hypernova/testing/assembler.test.js @@ -0,0 +1,426 @@ +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), + }); + + 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); + }); + }); + + 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), + }); + + 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); + }); + }); + }); + + 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/recursiveFetch.js b/lib/query/lib/recursiveFetch.js index ac87f490..6caf4538 100755 --- a/lib/query/lib/recursiveFetch.js +++ b/lib/query/lib/recursiveFetch.js @@ -1,6 +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'; @@ -59,7 +58,7 @@ function fetch(node, parentObject, fetchOptions = {}) { value.forEach(item => { const storageValue = item[nestedPath]; // todo: use _id or foreignIdentityField - item[rest.join('.')] = collectionNode.linker.isSingle() + item[rest.join('.')] = collectionNode.linker.isSingle() ? collectionNodeResults.filter(result => result._id === storageValue) : collectionNodeResults.filter(result => _.contains(storageValue || [], result._id)); }); @@ -82,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/client.test.js b/lib/query/testing/client.test.js index c858bf6f..2fc4d1bb 100755 --- a/lib/query/testing/client.test.js +++ b/lib/query/testing/client.test.js @@ -384,8 +384,6 @@ describe('Query Client Tests', function () { const files = query.fetch(); - // console.log('files', files); - expect(files).to.be.an('array'); expect(files).to.have.length(2); files.forEach(file => { diff --git a/package.js b/package.js index 5bd77252..470e9ced 100755 --- a/package.js +++ b/package.js @@ -96,6 +96,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"); }); From 3d67b3998b0f5c7ca35db9eaa5400764173b8d69 Mon Sep 17 00:00:00 2001 From: Berislav Date: Wed, 8 Apr 2020 20:51:57 +0200 Subject: [PATCH 08/15] Fixes for nested links --- lib/query/hypernova/assembler.js | 5 ++- lib/query/hypernova/testing/assembler.test.js | 32 +++++++++++++++++++ lib/query/lib/createGraph.js | 2 +- 3 files changed, 37 insertions(+), 2 deletions(-) diff --git a/lib/query/hypernova/assembler.js b/lib/query/hypernova/assembler.js index dde5e970..e54066a8 100644 --- a/lib/query/hypernova/assembler.js +++ b/lib/query/hypernova/assembler.js @@ -81,7 +81,10 @@ export function assembleMany(parentResult, { const [, ...nestedLinkPath] = childCollectionNode.linkName.split('.'); if (nestedLinkPath.length > 0 && _.isArray(rootValue)) { rootValue.forEach(result => { - const value = _.flatten(_.union(...(dot.pick(rest.join('.'), result) || [])).map(id => resultsByKeyId[id])); + const pickedValue = dot.pick(rest.join('.'), result); + const normalizedValue = _.isArray(pickedValue) ? pickedValue : (_.isUndefined(pickedValue) ? [] : [pickedValue]); + + const value = _.flatten(_.union(...normalizedValue).map(id => resultsByKeyId[id])); const data = filterAssembledData( value, { limit, skip } diff --git a/lib/query/hypernova/testing/assembler.test.js b/lib/query/hypernova/testing/assembler.test.js index be4165cc..eff1f6fa 100644 --- a/lib/query/hypernova/testing/assembler.test.js +++ b/lib/query/hypernova/testing/assembler.test.js @@ -304,6 +304,13 @@ describe('Assembler test', function () { 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', @@ -353,6 +360,31 @@ describe('Assembler test', function () { 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); + }); }); }); diff --git a/lib/query/lib/createGraph.js b/lib/query/lib/createGraph.js index d9b03c25..81aec4ba 100755 --- a/lib/query/lib/createGraph.js +++ b/lib/query/lib/createGraph.js @@ -107,7 +107,7 @@ export function addFieldNode(body, fieldName, root) { const linker = root.collection.getLinker(linkerKey); - if (linker) { + if (linker && !root.hasCollectionNode(linkerKey)) { const subroot = new CollectionNode(linker.getLinkedCollection(), body[parts[1]], linkerKey); // must be before adding linker because _shouldCleanStorage method createNodes(subroot); From a390c26ab0ae7635c5f5ca5c2ec1e00bd3df3a74 Mon Sep 17 00:00:00 2001 From: Berislav Date: Thu, 9 Apr 2020 10:23:41 +0200 Subject: [PATCH 09/15] More assembler updates --- lib/query/hypernova/assembler.js | 50 +++++++++++-------- lib/query/hypernova/testing/assembler.test.js | 28 +++++++++++ 2 files changed, 58 insertions(+), 20 deletions(-) diff --git a/lib/query/hypernova/assembler.js b/lib/query/hypernova/assembler.js index e54066a8..19ad6cd2 100644 --- a/lib/query/hypernova/assembler.js +++ b/lib/query/hypernova/assembler.js @@ -2,18 +2,11 @@ import createSearchFilters from '../../links/lib/createSearchFilters'; import cleanObjectForMetaFilters from './lib/cleanObjectForMetaFilters'; import sift from 'sift'; import dot from 'dot-object'; -import { isString } from 'util'; - -function getIdsFromArray(array, nested) { - const ids = []; - array.forEach(v => { - const _id = nested.length > 0 ? dot.pick(nested.join('.'), v) : v; - ids.push(_id); - }); - return ids; -} /** + * + * getIdsForMany + * * Possible options: * * A. array of ids directly inside the parentResult @@ -28,6 +21,13 @@ function getIdsFromArray(array, nested) { * } * } * + * 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: [{ @@ -36,12 +36,22 @@ function getIdsFromArray(array, nested) { * 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 (!value) { + + if (_.isUndefined(value) || _.isNull(value)) { return []; } @@ -52,17 +62,22 @@ function getIdsForMany(parentResult, fieldStorage) { // Option C. if (_.isArray(value)) { - return _.flatten(value.map(v => dot.pick(nested.join('.'), v) || [])); + return _.flatten(value.map(v => getIdsFromObject(v, nested.join('.')))); } // Option B if (_.isObject(value)) { - return dot.pick(nested.join('.'), value) || []; + return getIdsFromObject(value, nested.join('.')); } return []; } +function getIdsFromObject(object, path) { + const pickedValue = dot.pick(path, object); + return _.isArray(pickedValue) ? pickedValue : ((_.isUndefined(pickedValue) || _.isNull(pickedValue)) ? [] : [pickedValue]); +} + export function assembleMany(parentResult, { childCollectionNode, linker, @@ -81,12 +96,9 @@ export function assembleMany(parentResult, { const [, ...nestedLinkPath] = childCollectionNode.linkName.split('.'); if (nestedLinkPath.length > 0 && _.isArray(rootValue)) { rootValue.forEach(result => { - const pickedValue = dot.pick(rest.join('.'), result); - const normalizedValue = _.isArray(pickedValue) ? pickedValue : (_.isUndefined(pickedValue) ? [] : [pickedValue]); - - const value = _.flatten(_.union(...normalizedValue).map(id => resultsByKeyId[id])); + const results = _.flatten(_.union(...getIdsForMany(result, rest.join('.')).map(id => resultsByKeyId[id]))); const data = filterAssembledData( - value, + results, { limit, skip } ); result[nestedLinkPath.join('.')] = data; @@ -95,12 +107,10 @@ export function assembleMany(parentResult, { } const results = _.union(...getIdsForMany(parentResult, fieldStorage).map(id => resultsByKeyId[id])); - const data = filterAssembledData( results, { limit, skip } ); - dot.str(childCollectionNode.linkName, data, parentResult); } diff --git a/lib/query/hypernova/testing/assembler.test.js b/lib/query/hypernova/testing/assembler.test.js index eff1f6fa..16609325 100644 --- a/lib/query/hypernova/testing/assembler.test.js +++ b/lib/query/hypernova/testing/assembler.test.js @@ -229,6 +229,13 @@ describe('Assembler test', function () { 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', @@ -288,6 +295,27 @@ describe('Assembler test', function () { 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 () { From 4a11adf90e9f1f89cf093c58ee23f43629cbc102 Mon Sep 17 00:00:00 2001 From: Berislav Date: Thu, 16 Jul 2020 12:40:54 +0200 Subject: [PATCH 10/15] Fix for node namespace for nested links --- lib/query/lib/createGraph.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/query/lib/createGraph.js b/lib/query/lib/createGraph.js index 81aec4ba..4c28b757 100755 --- a/lib/query/lib/createGraph.js +++ b/lib/query/lib/createGraph.js @@ -146,7 +146,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; From 386e3bfc5f6a45fc18fd347a104875b91bd88607 Mon Sep 17 00:00:00 2001 From: Berislav Date: Thu, 23 Nov 2023 15:06:32 +0100 Subject: [PATCH 11/15] Search filters fix --- lib/links/lib/createSearchFilters.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/links/lib/createSearchFilters.js b/lib/links/lib/createSearchFilters.js index 16ea4611..37392809 100755 --- a/lib/links/lib/createSearchFilters.js +++ b/lib/links/lib/createSearchFilters.js @@ -29,7 +29,8 @@ export default function createSearchFilters(object, linker, metaFilters) { function getIdQueryFieldStorage(object, fieldStorage, isMany = false) { const [root, ...rest] = fieldStorage.split('.'); if (rest.length === 0) { - return isMany ? {$in: object[fieldStorage] || []} : object[fieldStorage]; + const ids = object[fieldStorage]; + return _.isArray(ids) ? {$in: ids} : ids; } const nestedPath = rest.join('.'); From 291a470b506c1ca1fa3d65126050da944f2657b4 Mon Sep 17 00:00:00 2001 From: Berislav Date: Mon, 17 Aug 2020 10:58:30 +0200 Subject: [PATCH 12/15] storeOneResults fix --- lib/query/lib/prepareForDelivery.js | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/lib/query/lib/prepareForDelivery.js b/lib/query/lib/prepareForDelivery.js index 4e961636..7bd7b2dc 100755 --- a/lib/query/lib/prepareForDelivery.js +++ b/lib/query/lib/prepareForDelivery.js @@ -177,7 +177,21 @@ export function storeOneResults(node, sameLevelResults) { return; } - storeOneResults(collectionNode, dot.pick(collectionNode.linkName, result)); + const [root, ...rest] = collectionNode.linkName.split('.'); + if (rest.length === 0) { + storeOneResults(collectionNode, result[root]); + } + else { + const rootValue = result[root]; + if (_.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) { From 89dd825da134177a63a28f07b262738249c7b395 Mon Sep 17 00:00:00 2001 From: Berislav Date: Tue, 14 Dec 2021 12:25:54 +0100 Subject: [PATCH 13/15] Updated links for deeply nested documents --- lib/query/hypernova/aggregateSearchFilters.js | 4 +- lib/query/hypernova/assembler.js | 4 +- lib/query/lib/createGraph.js | 37 ++++++++++++++++--- 3 files changed, 36 insertions(+), 9 deletions(-) diff --git a/lib/query/hypernova/aggregateSearchFilters.js b/lib/query/hypernova/aggregateSearchFilters.js index b5aae31f..7db91534 100644 --- a/lib/query/hypernova/aggregateSearchFilters.js +++ b/lib/query/hypernova/aggregateSearchFilters.js @@ -9,10 +9,10 @@ function getIdsFromObject(object, field) { const rootValue = object[parts[0]]; if (_.isArray(rootValue)) { - return rootValue.map(item => dot.pick(parts.slice(1).join(','), item)); + return rootValue.map(item => dot.pick(parts.slice(1).join('.'), item)); } else if (_.isObject(rootValue)) { - return [dot.pick(parts.slice(1).join(','), rootValue)]; + return [dot.pick(parts.slice(1).join('.'), rootValue)]; } return []; } diff --git a/lib/query/hypernova/assembler.js b/lib/query/hypernova/assembler.js index 19ad6cd2..5095c736 100644 --- a/lib/query/hypernova/assembler.js +++ b/lib/query/hypernova/assembler.js @@ -180,7 +180,8 @@ export function assembleOne(parentResult, { resultsByKeyId[value], { limit, skip } ); - result[path.slice(1).join('.')] = data; + dot.set(path.slice(1).join('.'), data, result); + // result[path.slice(1).join('.')] = data; }); return; } @@ -206,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; diff --git a/lib/query/lib/createGraph.js b/lib/query/lib/createGraph.js index 4c28b757..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 @@ -102,13 +129,13 @@ export function addFieldNode(body, fieldName, root) { let dotted = dotize.convert({[fieldName]: body}); _.each(dotted, (value, key) => { // check for link - const parts = key.split('.'); - const linkerKey = parts.slice(0, 2).join('.'); + const linker = tryFindLink(root, key); - const linker = root.collection.getLinker(linkerKey); + if (linker && !root.hasCollectionNode(linker.linkName)) { + const path = linker.linkName.split('.').slice(1).join('.'); + const subrootBody = dot.pick(path, body); - if (linker && !root.hasCollectionNode(linkerKey)) { - const subroot = new CollectionNode(linker.getLinkedCollection(), body[parts[1]], linkerKey); + const subroot = new CollectionNode(linker.getLinkedCollection(), subrootBody, linker.linkName); // must be before adding linker because _shouldCleanStorage method createNodes(subroot); root.add(subroot, linker); From 74b010649d0ae0d15dd1407c15832ab604995dcf Mon Sep 17 00:00:00 2001 From: Berislav Date: Thu, 23 Nov 2023 16:14:33 +0100 Subject: [PATCH 14/15] Docs update --- docs/linking_collections.md | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) 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. From 96c6042db02bb5dc41d56bb10f8d2cfdbed41836 Mon Sep 17 00:00:00 2001 From: Bero Date: Mon, 4 Mar 2024 20:17:03 +0100 Subject: [PATCH 15/15] Replacing _.isArray with Array.isArray --- lib/links/lib/createSearchFilters.js | 4 ++-- lib/query/hypernova/aggregateSearchFilters.js | 2 +- lib/query/hypernova/assembler.js | 10 +++++----- lib/query/hypernova/buildVirtualNodeProps.js | 2 +- lib/query/hypernova/processVirtualNode.js | 2 +- lib/query/lib/prepareForDelivery.js | 6 +++--- lib/query/lib/recursiveFetch.js | 2 +- 7 files changed, 14 insertions(+), 14 deletions(-) diff --git a/lib/links/lib/createSearchFilters.js b/lib/links/lib/createSearchFilters.js index 37392809..c556dff8 100755 --- a/lib/links/lib/createSearchFilters.js +++ b/lib/links/lib/createSearchFilters.js @@ -30,12 +30,12 @@ function getIdQueryFieldStorage(object, fieldStorage, isMany = false) { const [root, ...rest] = fieldStorage.split('.'); if (rest.length === 0) { const ids = object[fieldStorage]; - return _.isArray(ids) ? {$in: ids} : ids; + return Array.isArray(ids) ? {$in: ids} : ids; } const nestedPath = rest.join('.'); const rootValue = object[root]; - if (_.isArray(rootValue)) { + if (Array.isArray(rootValue)) { return {$in: _.uniq(_.union(...rootValue.map(item => dot.pick(nestedPath, item))))}; } else if (_.isObject(rootValue)) { diff --git a/lib/query/hypernova/aggregateSearchFilters.js b/lib/query/hypernova/aggregateSearchFilters.js index 7db91534..a441c573 100644 --- a/lib/query/hypernova/aggregateSearchFilters.js +++ b/lib/query/hypernova/aggregateSearchFilters.js @@ -8,7 +8,7 @@ function getIdsFromObject(object, field) { } const rootValue = object[parts[0]]; - if (_.isArray(rootValue)) { + if (Array.isArray(rootValue)) { return rootValue.map(item => dot.pick(parts.slice(1).join('.'), item)); } else if (_.isObject(rootValue)) { diff --git a/lib/query/hypernova/assembler.js b/lib/query/hypernova/assembler.js index 5095c736..7da27f05 100644 --- a/lib/query/hypernova/assembler.js +++ b/lib/query/hypernova/assembler.js @@ -57,11 +57,11 @@ function getIdsForMany(parentResult, fieldStorage) { // Option A. if (nested.length === 0) { - return _.isArray(value) ? value : [value]; + return Array.isArray(value) ? value : [value]; } // Option C. - if (_.isArray(value)) { + if (Array.isArray(value)) { return _.flatten(value.map(v => getIdsFromObject(v, nested.join('.')))); } @@ -75,7 +75,7 @@ function getIdsForMany(parentResult, fieldStorage) { function getIdsFromObject(object, path) { const pickedValue = dot.pick(path, object); - return _.isArray(pickedValue) ? pickedValue : ((_.isUndefined(pickedValue) || _.isNull(pickedValue)) ? [] : [pickedValue]); + return Array.isArray(pickedValue) ? pickedValue : ((_.isUndefined(pickedValue) || _.isNull(pickedValue)) ? [] : [pickedValue]); } export function assembleMany(parentResult, { @@ -94,7 +94,7 @@ export function assembleMany(parentResult, { } const [, ...nestedLinkPath] = childCollectionNode.linkName.split('.'); - if (nestedLinkPath.length > 0 && _.isArray(rootValue)) { + 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( @@ -173,7 +173,7 @@ export function assembleOne(parentResult, { // todo: using linker.linkName should be correct here since it should be the same as childCollectionNode.linkName const path = childCollectionNode.linkName.split('.'); - if (_.isArray(rootValue)) { + if (Array.isArray(rootValue)) { rootValue.forEach(result => { const value = dot.pick(rest.join('.'), result); const data = filterAssembledData( 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/lib/prepareForDelivery.js b/lib/query/lib/prepareForDelivery.js index 7bd7b2dc..8beec0df 100755 --- a/lib/query/lib/prepareForDelivery.js +++ b/lib/query/lib/prepareForDelivery.js @@ -144,7 +144,7 @@ export function removeLinkStorages(node, sameLevelResults) { function removeArrayFromObject(result, linkName) { const linkData = dot.pick(linkName, result); - if (linkData && _.isArray(linkData)) { + if (linkData && Array.isArray(linkData)) { dot.remove(linkName, result); dot.str(linkName, _.first(linkData), result); } @@ -154,7 +154,7 @@ function removeArrayForOneResult(result, linkName) { const [root, ...rest] = linkName.split('.'); const rootValue = result[root]; - if (rest.length > 0 && _.isArray(rootValue)) { + if (rest.length > 0 && Array.isArray(rootValue)) { rootValue.forEach(value => { removeArrayFromObject(value, rest.join('.')); }); @@ -183,7 +183,7 @@ export function storeOneResults(node, sameLevelResults) { } else { const rootValue = result[root]; - if (_.isArray(rootValue)) { + if (Array.isArray(rootValue)) { rootValue.forEach(value => { storeOneResults(collectionNode, dot.pick(rest.join('.'), value)); }); diff --git a/lib/query/lib/recursiveFetch.js b/lib/query/lib/recursiveFetch.js index 6caf4538..2c21ab75 100755 --- a/lib/query/lib/recursiveFetch.js +++ b/lib/query/lib/recursiveFetch.js @@ -52,7 +52,7 @@ function fetch(node, parentObject, fetchOptions = {}) { } else { const value = result[root]; - if (_.isArray(value)) { + if (Array.isArray(value)) { const [, ...storageRest] = collectionNode.linker.linkStorageField.split('.'); const nestedPath = storageRest.join('.'); value.forEach(item => {