Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions api/src/services/hierarchy/lineage-manipulation.js
Original file line number Diff line number Diff line change
@@ -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,
};
86 changes: 86 additions & 0 deletions api/src/services/hierarchy/replace-lineage.js
Original file line number Diff line number Diff line change
@@ -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);
},
};
61 changes: 61 additions & 0 deletions api/tests/mocha/services/hierarchy/lineage-manipulation.spec.js
Original file line number Diff line number Diff line change
@@ -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');
});
});
});
77 changes: 77 additions & 0 deletions api/tests/mocha/services/hierarchy/replace-lineage.spec.js
Original file line number Diff line number Diff line change
@@ -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);
});
});
});
Loading