Skip to content

Commit

Permalink
feat(#9237): add functionality of getting people with pagination in c…
Browse files Browse the repository at this point in the history
…ht-datasource (#9266)
  • Loading branch information
sugat009 authored Aug 9, 2024
1 parent 6dabd10 commit b20dc22
Show file tree
Hide file tree
Showing 17 changed files with 647 additions and 44 deletions.
14 changes: 14 additions & 0 deletions shared-libs/cht-datasource/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,20 @@ export const getDatasource = (ctx: DataContext) => {
* @throws Error if no UUID is provided
*/
getByUuidWithLineage: (uuid: string) => ctx.bind(Person.v1.getWithLineage)(Qualifier.byUuid(uuid)),

/**
* Returns an array of people for the provided page specifications.
* @param personType the type of people to return
* @param limit the maximum number of people to return. Default is 100.
* @param skip the number of people to skip. Default is 0.
* @returns an array of people for the provided page specifications
* @throws Error if no type is provided or if the type is not for a person
* @throws Error if the provided limit is `<= 0`
* @throws Error if the provided skip is `< 0`
*/
getPageByType: (personType: string, limit = 100, skip = 0) => ctx.bind(Person.v1.getPage)(
Qualifier.byContactType(personType), limit, skip
),
}
}
};
Expand Down
6 changes: 6 additions & 0 deletions shared-libs/cht-datasource/src/libs/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,3 +112,9 @@ export const findById = <T extends Identifiable>(values: T[], id: string): Nulla
export abstract class AbstractDataContext implements DataContext {
readonly bind = <T>(fn: (ctx: DataContext) => T): T => fn(this);
}

/** @internal */
export interface Page<T> {
readonly data: T[];
readonly cursor: string;
}
26 changes: 18 additions & 8 deletions shared-libs/cht-datasource/src/local/libs/doc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,15 +27,25 @@ export const getDocsByIds = (db: PouchDB.Database<Doc>) => async (uuids: string[
.filter((doc): doc is Doc => isDoc(doc));
};

const queryDocs = (db: PouchDB.Database<Doc>, view: string, options: PouchDB.Query.Options<Doc, unknown>) => db
.query(view, {...options})
.then(({ rows }) => rows.map(({ doc }) => isDoc(doc) ? doc : null));

/** @internal */
export const queryDocsByKey = (
export const queryDocsByRange = (
db: PouchDB.Database<Doc>,
view: string
) => async (key: string): Promise<Nullable<Doc>[]> => db
.query(view, {
startkey: [key],
endkey: [key, {}],
include_docs: true
})
.then(({ rows }) => rows.map(({ doc }) => isDoc(doc) ? doc : null));
) => async (
startkey: unknown,
endkey: unknown
): Promise<Nullable<Doc>[]> => queryDocs(db, view, { include_docs: true, startkey, endkey});

/** @internal */
export const queryDocsByKey = (
db: PouchDB.Database<Doc>,
view: string
) => async (
key: unknown,
limit: number,
skip: number
): Promise<Nullable<Doc>[]> => queryDocs(db, view, { include_docs: true, key, limit, skip });
9 changes: 5 additions & 4 deletions shared-libs/cht-datasource/src/local/libs/lineage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,17 +10,18 @@ import {
Nullable
} from '../../libs/core';
import { Doc } from '../../libs/doc';
import { queryDocsByKey } from './doc';
import { queryDocsByRange } from './doc';
import logger from '@medic/logger';

/**
* Returns the identified document along with the parent documents recorded for its lineage. The returned array is
* sorted such that the identified document is the first element and the parent documents are in order of lineage.
* @internal
*/
export const getLineageDocsById = (
medicDb: PouchDB.Database<Doc>
): (id: string) => Promise<Nullable<Doc>[]> => queryDocsByKey(medicDb, 'medic-client/docs_by_id_lineage');
export const getLineageDocsById = (medicDb: PouchDB.Database<Doc>): (id: string) => Promise<Nullable<Doc>[]> => {
const fn = queryDocsByRange(medicDb, 'medic-client/docs_by_id_lineage');
return (id: string) => fn([id], [id, {}]);
};

/** @internal */
export const getPrimaryContactIds = (places: NonEmptyArray<Nullable<Doc>>): string[] => places
Expand Down
66 changes: 60 additions & 6 deletions shared-libs/cht-datasource/src/local/person.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,16 @@
import { Doc } from '../libs/doc';
import contactTypeUtils from '@medic/contact-types-utils';
import { deepCopy, isNonEmptyArray, Nullable } from '../libs/core';
import { UuidQualifier } from '../qualifier';
import { deepCopy, isNonEmptyArray, Nullable, Page } from '../libs/core';
import { ContactTypeQualifier, UuidQualifier } from '../qualifier';
import * as Person from '../person';
import { getDocById, getDocsByIds } from './libs/doc';
import { getDocById, getDocsByIds, queryDocsByKey } from './libs/doc';
import { LocalDataContext, SettingsService } from './libs/data-context';
import logger from '@medic/logger';
import { getLineageDocsById, getPrimaryContactIds, hydrateLineage, hydratePrimaryContact } from './libs/lineage';

/** @internal */
export namespace v1 {
const isPerson = (settings: SettingsService, uuid: string, doc: Nullable<Doc>): doc is Person.v1.Person => {
const isPerson = (settings: SettingsService, doc: Nullable<Doc>, uuid = ''): doc is Person.v1.Person => {
if (!doc) {
logger.warn(`No person found for identifier [${uuid}].`);
return false;
Expand All @@ -28,7 +28,7 @@ export namespace v1 {
const getMedicDocById = getDocById(medicDb);
return async (identifier: UuidQualifier): Promise<Nullable<Person.v1.Person>> => {
const doc = await getMedicDocById(identifier.uuid);
if (!isPerson(settings, identifier.uuid, doc)) {
if (!isPerson(settings, doc, identifier.uuid)) {
return null;
}
return doc;
Expand All @@ -41,7 +41,7 @@ export namespace v1 {
const getMedicDocsById = getDocsByIds(medicDb);
return async (identifier: UuidQualifier): Promise<Nullable<Person.v1.PersonWithLineage>> => {
const [person, ...lineagePlaces] = await getLineageDocs(identifier.uuid);
if (!isPerson(settings, identifier.uuid, person)) {
if (!isPerson(settings, person, identifier.uuid)) {
return null;
}
// Intentionally not further validating lineage. For passivity, lineage problems should not block retrieval.
Expand All @@ -58,4 +58,58 @@ export namespace v1 {
return deepCopy(personWithLineage);
};
};

/** @internal */
export const getPage = ({ medicDb, settings }: LocalDataContext) => {
return async (
personType: ContactTypeQualifier,
limit: number,
skip: number
): Promise<Page<Person.v1.Person>> => {
const personTypes = contactTypeUtils.getPersonTypes(settings.getAll());
const personTypesIds = personTypes.map((item) => item.id);

if (!personTypesIds.includes(personType.contactType)) {
throw new Error(`Invalid person type: ${personType.contactType}`);
}

const getDocsByPage = queryDocsByKey(medicDb, 'medic-client/contacts_by_type');

const fetchAndFilter = async (
currentLimit: number,
currentSkip: number,
personDocs: Person.v1.Person[],
totalDocsFetched = 0,
): Promise<Page<Person.v1.Person>> => {
const docs = await getDocsByPage([personType.contactType], currentLimit, currentSkip);
if (docs.length === 0) {
return { data: personDocs, cursor: '-1' };
}

const tempFilteredDocs = docs.filter((doc): doc is Person.v1.Person => isPerson(settings, doc, doc?._id));

personDocs.push(...tempFilteredDocs);
totalDocsFetched += docs.length;

if (personDocs.length >= limit) {
let cursor: number;
if (docs.length < currentLimit) {
cursor = -1;
} else {
cursor = skip + totalDocsFetched - (personDocs.length - limit);
}
return { data: personDocs.slice(0, limit), cursor: cursor.toString() };
}

return fetchAndFilter(
(currentLimit - tempFilteredDocs.length) * 2,
currentSkip + currentLimit,
personDocs,
totalDocsFetched
);
};

return fetchAndFilter(limit, skip, []);
};
};
}
63 changes: 62 additions & 1 deletion shared-libs/cht-datasource/src/person.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import { isUuidQualifier, UuidQualifier } from './qualifier';
import { isContactTypeQualifier, isUuidQualifier, ContactTypeQualifier, UuidQualifier } from './qualifier';
import { adapt, assertDataContext, DataContext } from './libs/data-context';
import { Contact, NormalizedParent } from './libs/contact';
import * as Remote from './remote';
import * as Local from './local';
import * as Place from './place';
import { LocalDataContext } from './local/libs/data-context';
import { RemoteDataContext } from './remote/libs/data-context';
import { Page } from './libs/core';

/** */
export namespace v1 {
Expand All @@ -32,6 +33,26 @@ export namespace v1 {
}
};

const assertTypeQualifier: (qualifier: unknown) => asserts qualifier is ContactTypeQualifier = (
qualifier: unknown
) => {
if (!isContactTypeQualifier(qualifier)) {
throw new Error(`Invalid type [${JSON.stringify(qualifier)}].`);
}
};

const assertLimit = (limit: unknown) => {
if (typeof limit !== 'number' || !Number.isInteger(limit) || limit <= 0) {
throw new Error(`The limit must be a positive number: [${String(limit)}]`);
}
};

const assertSkip = (skip: unknown) => {
if (typeof skip !== 'number' || !Number.isInteger(skip) || skip < 0) {
throw new Error(`The skip must be a non-negative number: [${String(skip)}]`);
}
};

const getPerson = <T>(
localFn: (c: LocalDataContext) => (qualifier: UuidQualifier) => Promise<T>,
remoteFn: (c: RemoteDataContext) => (qualifier: UuidQualifier) => Promise<T>
Expand Down Expand Up @@ -59,4 +80,44 @@ export namespace v1 {
* @throws Error if the provided context or qualifier is invalid
*/
export const getWithLineage = getPerson(Local.Person.v1.getWithLineage, Remote.Person.v1.getWithLineage);

/**
* Returns a function for retrieving a paged array of people from the given data context.
* @param context the current data context
* @returns a function for retrieving a paged array of people
* @throws Error if a data context is not provided
*/
export const getPage = (
context: DataContext
): (
personType: ContactTypeQualifier,
limit: number,
skip: number
) => Promise<Page<Person>> => {
assertDataContext(context);
const fn = adapt(context, Local.Person.v1.getPage, Remote.Person.v1.getPage);

/**
* Returns an array of people for the provided page specifications.
* @param personType the type of people to return
* @param limit the maximum number of people to return. Default is 100.
* @param skip the number of people to skip. Default is 0.
* @returns an array of people for the provided page specifications.
* @throws Error if no type is provided or if the type is not for a person
* @throws Error if the provided `limit` value is `<=0`
* @throws Error if the provided `skip` value is `<0`
*/
const curriedFn = async (
personType: ContactTypeQualifier,
limit = 100,
skip = 0
): Promise<Page<Person>> => {
assertTypeQualifier(personType);
assertLimit(limit);
assertSkip(skip);

return fn(personType, limit, skip);
};
return curriedFn;
};
}
28 changes: 28 additions & 0 deletions shared-libs/cht-datasource/src/qualifier.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,31 @@ export const byUuid = (uuid: string): UuidQualifier => {
export const isUuidQualifier = (identifier: unknown): identifier is UuidQualifier => {
return isRecord(identifier) && hasField(identifier, { name: 'uuid', type: 'string' });
};

/**
A qualifier that identifies contacts based on type.
*/
export type ContactTypeQualifier = Readonly<{ contactType: string }>;

/**
* Build the TypeQualifier that categorizes an entity by its type
* @param contactType the type of the entity
* @returns the type
* @throws Error if the type is invalid
*/
export const byContactType = (contactType: string): ContactTypeQualifier => {
if (!isString(contactType) || contactType.length === 0) {
throw new Error(`Invalid ContactType [${JSON.stringify(contactType)}].`);
}

return { contactType };
};

/**
* Returns `true` if the given qualifier is a {@link ContactTypeQualifier} otherwise `false`.
* @param contactType the type to check
* @returns `true` if the given type is a {@link ContactTypeQualifier}, otherwise `false`.
*/
export const isContactTypeQualifier = (contactType: unknown): contactType is ContactTypeQualifier => {
return isRecord(contactType) && hasField(contactType, { name: 'contactType', type: 'string' });
};
18 changes: 18 additions & 0 deletions shared-libs/cht-datasource/src/remote/libs/data-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,3 +58,21 @@ export const getResource = (context: RemoteDataContext, path: string) => async <
throw error;
}
};

/** @internal */
export const getResources = (context: RemoteDataContext, path: string) => async <T>(
queryParams?: Record<string, string>,
): Promise<T> => {
const params = new URLSearchParams(queryParams).toString();
try {
const response = await fetch(`${context.url}/${path}?${params}`);
if (!response.ok) {
throw new Error(response.statusText);
}

return (await response.json()) as T;
} catch (error) {
logger.error(`Failed to fetch resources from ${context.url}/${path} with params: ${params}`, error);
throw error;
}
};
17 changes: 14 additions & 3 deletions shared-libs/cht-datasource/src/remote/person.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import { Nullable } from '../libs/core';
import { UuidQualifier } from '../qualifier';
import { Nullable, Page } from '../libs/core';
import { ContactTypeQualifier, UuidQualifier } from '../qualifier';
import * as Person from '../person';
import { getResource, RemoteDataContext } from './libs/data-context';
import { getResource, getResources, RemoteDataContext } from './libs/data-context';

/** @internal */
export namespace v1 {
const getPerson = (remoteContext: RemoteDataContext) => getResource(remoteContext, 'api/v1/person');

const getPeople = (remoteContext: RemoteDataContext) => getResources(remoteContext, 'api/v1/person');

/** @internal */
export const get = (remoteContext: RemoteDataContext) => (
identifier: UuidQualifier
Expand All @@ -19,4 +21,13 @@ export namespace v1 {
identifier.uuid,
{ with_lineage: 'true' }
);

/** @internal */
export const getPage = (remoteContext: RemoteDataContext) => (
personType: ContactTypeQualifier,
limit: number,
skip: number
): Promise<Page<Person.v1.Person>> => getPeople(remoteContext)(
{'limit': limit.toString(), 'skip': skip.toString(), 'contactType': personType.contactType}
);
}
21 changes: 20 additions & 1 deletion shared-libs/cht-datasource/test/index.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import * as Qualifier from '../src/qualifier';
import sinon, { SinonStub } from 'sinon';
import * as Context from '../src/libs/data-context';
import { DataContext } from '../src';
import { Page } from '../src/libs/core';

describe('CHT Script API - getDatasource', () => {
let dataContext: DataContext;
Expand Down Expand Up @@ -92,7 +93,7 @@ describe('CHT Script API - getDatasource', () => {
beforeEach(() => person = v1.person);

it('contains expected keys', () => {
expect(person).to.have.all.keys(['getByUuid', 'getByUuidWithLineage']);
expect(person).to.have.all.keys(['getByUuid', 'getByUuidWithLineage', 'getPageByType']);
});

it('getByUuid', async () => {
Expand Down Expand Up @@ -124,6 +125,24 @@ describe('CHT Script API - getDatasource', () => {
expect(personGet.calledOnceWithExactly(qualifier)).to.be.true;
expect(byUuid.calledOnceWithExactly(qualifier.uuid)).to.be.true;
});

it('getPage', async () => {
const expectedPeople: Page<Person.v1.Person> = {data: [], cursor: '-1'};
const personGetPage = sinon.stub().resolves(expectedPeople);
dataContextBind.returns(personGetPage);
const personType = 'person';
const limit = 2;
const skip = 1;
const personTypeQualifier = { contactType: personType };
const byContactType = sinon.stub(Qualifier, 'byContactType').returns(personTypeQualifier);

const returnedPeople = await person.getPageByType(personType, limit, skip);

expect(returnedPeople).to.equal(expectedPeople);
expect(dataContextBind.calledOnceWithExactly(Person.v1.getPage)).to.be.true;
expect(personGetPage.calledOnceWithExactly(personTypeQualifier, limit, skip)).to.be.true;
expect(byContactType.calledOnceWithExactly(personType)).to.be.true;
});
});
});
});
Loading

0 comments on commit b20dc22

Please sign in to comment.