diff --git a/api/src/services/hierarchy/lineage-manipulation.js b/api/src/services/hierarchy/lineage-manipulation.js new file mode 100644 index 00000000000..542ff0e62e2 --- /dev/null +++ b/api/src/services/hierarchy/lineage-manipulation.js @@ -0,0 +1,40 @@ +/** + * Lineage manipulation helpers for hierarchy operations (move/merge). + * + * Ported from cht-conf `src/lib/hierarchy-operations/lineage-manipulation.js`, minus its + * `minifyLineagesInDoc`: that function duplicates `@medic/lineage`'s `minify`, which is available + * server-side, so callers should use `require('@medic/lineage')(Promise, db).minify` instead of + * porting a copy. The remaining helpers (`createLineageFromDoc`, `pluckIdsFromLineage`) and the + * re-exported `replace-lineage` functions have no shared-lib equivalent and are ported here. + */ + +const { replaceContactLineage, replaceParentLineage } = require('./replace-lineage'); + +const createLineageFromDoc = doc => { + if (!doc) { + return undefined; + } + + return { + _id: doc._id, + parent: doc.parent || undefined, + }; +}; + +/* +Given a lineage, return the ids therein +*/ +const pluckIdsFromLineage = (lineage, results = []) => { + if (!lineage) { + return results; + } + + return pluckIdsFromLineage(lineage.parent, [...results, lineage._id]); +}; + +module.exports = { + createLineageFromDoc, + pluckIdsFromLineage, + replaceParentLineage, + replaceContactLineage, +}; diff --git a/api/src/services/hierarchy/replace-lineage.js b/api/src/services/hierarchy/replace-lineage.js new file mode 100644 index 00000000000..0edda83441f --- /dev/null +++ b/api/src/services/hierarchy/replace-lineage.js @@ -0,0 +1,86 @@ +/** + * Lineage replacement helpers for hierarchy operations (move/merge). + * + * Ported from cht-conf `src/lib/hierarchy-operations/replace-lineage.js`. These are pure functions + * (no I/O) that rewrite the embedded `parent`/`contact` lineage of a document in place. They are + * shared by the move and merge services; the `params.merge` flag selects the merge variant. + */ + +const replaceEntireLineage = (replaceInDoc, lineageAttributeName, replaceWith) => { + if (!replaceWith) { + const lineageWasDeleted = !!replaceInDoc[lineageAttributeName]; + replaceInDoc[lineageAttributeName] = undefined; + return lineageWasDeleted; + } + + replaceInDoc[lineageAttributeName] = replaceWith; + return true; +}; + +const replaceLineageForMove = (doc, lineageAttributeName, params) => { + let currentElement = doc[lineageAttributeName]; + while (currentElement) { + if (currentElement?._id === params.startingFromId) { + return replaceEntireLineage(currentElement, 'parent', params.replaceWith); + } + + currentElement = currentElement.parent; + } + + return false; +}; + +const replaceLineageForMerge = (doc, lineageAttributeName, params) => { + let currentElement = doc; + let currentAttributeName = lineageAttributeName; + while (currentElement) { + if (currentElement[currentAttributeName]?._id === params.startingFromId) { + return replaceEntireLineage(currentElement, currentAttributeName, params.replaceWith); + } + + currentElement = currentElement[currentAttributeName]; + currentAttributeName = 'parent'; + } + + return false; +}; + +const replaceLineage = (doc, lineageAttributeName, params) => { + // Replace the full lineage + if (!params.startingFromId) { + return replaceEntireLineage(doc, lineageAttributeName, params.replaceWith); + } + + const selectedFunction = params.merge ? replaceLineageForMerge : replaceLineageForMove; + return selectedFunction(doc, lineageAttributeName, params); +}; + +module.exports = { + /** + * Given a doc, replace the parent's lineage + * + * @param {Object} doc A CouchDB document containing a parent lineage (eg. parent.parent._id) + * @param {Object} params + * @param {Object} params.replaceWith The new hierarchy { parent: { _id: 'parent', parent: { _id: 'grandparent' } } + * @param {string} params.startingFromId Only the part of the lineage "after" this id will be replaced + * @param {boolean} params.merge When true, startingFromId is replaced and when false, startingFromId's parent is + * replaced + */ + replaceParentLineage: (doc, params) => { + return replaceLineage(doc, 'parent', params); + }, + + /** + * Given a doc, replace the contact's lineage + * + * @param {Object} doc A CouchDB document containing a contact lineage (eg. contact.parent._id) + * @param {Object} params + * @param {Object} params.replaceWith The new hierarchy { parent: { _id: 'parent', parent: { _id: 'grandparent' } } + * @param {string} params.startingFromId Only the part of the lineage "after" this id will be replaced + * @param {boolean} params.merge When true, startingFromId is replaced and when false, startingFromId's parent is + * replaced + */ + replaceContactLineage: (doc, params) => { + return replaceLineage(doc, 'contact', params); + }, +}; diff --git a/api/tests/mocha/services/hierarchy/lineage-manipulation.spec.js b/api/tests/mocha/services/hierarchy/lineage-manipulation.spec.js new file mode 100644 index 00000000000..837e7530475 --- /dev/null +++ b/api/tests/mocha/services/hierarchy/lineage-manipulation.spec.js @@ -0,0 +1,61 @@ +const { expect } = require('chai'); +const { + createLineageFromDoc, + pluckIdsFromLineage, + replaceParentLineage, + replaceContactLineage, +} = require('../../../../src/services/hierarchy/lineage-manipulation'); + +describe('hierarchy/lineage-manipulation', () => { + describe('createLineageFromDoc', () => { + it('returns undefined for a falsy doc', () => { + expect(createLineageFromDoc(undefined)).to.equal(undefined); + expect(createLineageFromDoc(null)).to.equal(undefined); + }); + + it('builds a lineage stub carrying the id and the existing parent chain', () => { + const doc = { _id: 'destination', parent: { _id: 'district', parent: { _id: 'root' } }, name: 'Dest' }; + + const lineage = createLineageFromDoc(doc); + + expect(lineage).to.deep.equal({ + _id: 'destination', + parent: { _id: 'district', parent: { _id: 'root' } }, + }); + // Does not carry over unrelated fields such as name. + expect(lineage).to.not.have.property('name'); + }); + + it('leaves parent undefined for a top-level doc', () => { + const lineage = createLineageFromDoc({ _id: 'top' }); + + expect(lineage).to.deep.equal({ _id: 'top', parent: undefined }); + }); + }); + + describe('pluckIdsFromLineage', () => { + it('returns an empty array for a falsy lineage', () => { + expect(pluckIdsFromLineage(undefined)).to.deep.equal([]); + expect(pluckIdsFromLineage(null)).to.deep.equal([]); + }); + + it('collects ids from the lineage root downwards', () => { + const lineage = { _id: 'a', parent: { _id: 'b', parent: { _id: 'c' } } }; + + expect(pluckIdsFromLineage(lineage)).to.deep.equal(['a', 'b', 'c']); + }); + + it('appends to a provided results array', () => { + const lineage = { _id: 'b', parent: { _id: 'c' } }; + + expect(pluckIdsFromLineage(lineage, ['a'])).to.deep.equal(['a', 'b', 'c']); + }); + }); + + describe('re-exported replace-lineage helpers', () => { + it('re-exports replaceParentLineage and replaceContactLineage', () => { + expect(replaceParentLineage).to.be.a('function'); + expect(replaceContactLineage).to.be.a('function'); + }); + }); +}); diff --git a/api/tests/mocha/services/hierarchy/replace-lineage.spec.js b/api/tests/mocha/services/hierarchy/replace-lineage.spec.js new file mode 100644 index 00000000000..571db276733 --- /dev/null +++ b/api/tests/mocha/services/hierarchy/replace-lineage.spec.js @@ -0,0 +1,77 @@ +const { expect } = require('chai'); +const { + replaceParentLineage, + replaceContactLineage, +} = require('../../../../src/services/hierarchy/replace-lineage'); + +describe('hierarchy/replace-lineage', () => { + const newHierarchy = { _id: 'new-parent', parent: { _id: 'new-grandparent' } }; + + describe('replaceParentLineage', () => { + it('replaces the entire parent lineage when no startingFromId is given', () => { + const doc = { _id: 'doc', parent: { _id: 'old-parent', parent: { _id: 'old-grandparent' } } }; + + const changed = replaceParentLineage(doc, { replaceWith: newHierarchy }); + + expect(changed).to.equal(true); + expect(doc.parent).to.deep.equal(newHierarchy); + }); + + it('replaces the lineage after the matching ancestor (move variant)', () => { + const doc = { + _id: 'doc', + parent: { _id: 'child', parent: { _id: 'source', parent: { _id: 'old-grandparent' } } }, + }; + + const changed = replaceParentLineage(doc, { startingFromId: 'source', replaceWith: newHierarchy }); + + expect(changed).to.equal(true); + // 'source' is preserved; everything above it is replaced. + expect(doc.parent.parent._id).to.equal('source'); + expect(doc.parent.parent.parent).to.deep.equal(newHierarchy); + }); + + it('returns false when startingFromId is not present in the lineage', () => { + const doc = { _id: 'doc', parent: { _id: 'old-parent', parent: { _id: 'old-grandparent' } } }; + + const changed = replaceParentLineage(doc, { startingFromId: 'absent', replaceWith: newHierarchy }); + + expect(changed).to.equal(false); + expect(doc.parent).to.deep.equal({ _id: 'old-parent', parent: { _id: 'old-grandparent' } }); + }); + + it('clears the lineage when replaceWith is falsy', () => { + const doc = { _id: 'doc', parent: { _id: 'old-parent' } }; + + const changed = replaceParentLineage(doc, { replaceWith: undefined }); + + expect(changed).to.equal(true); + expect(doc.parent).to.equal(undefined); + }); + }); + + describe('replaceContactLineage', () => { + it('replaces the entire contact lineage when no startingFromId is given', () => { + const doc = { _id: 'place', contact: { _id: 'old-contact', parent: { _id: 'old-parent' } } }; + + const changed = replaceContactLineage(doc, { replaceWith: newHierarchy }); + + expect(changed).to.equal(true); + expect(doc.contact).to.deep.equal(newHierarchy); + }); + + it('replaces the lineage starting from the matching id in merge mode', () => { + const doc = { _id: 'place', contact: { _id: 'source', parent: { _id: 'old-parent' } } }; + + const changed = replaceContactLineage(doc, { + startingFromId: 'source', + replaceWith: newHierarchy, + merge: true, + }); + + expect(changed).to.equal(true); + // In merge mode the matching id itself is replaced. + expect(doc.contact).to.deep.equal(newHierarchy); + }); + }); +});