Skip to content

Commit

Permalink
feat: add component atlas source dataset apis (#275) (#291)
Browse files Browse the repository at this point in the history
* feat: add component atlas source dataset apis (#275)

* test: add tests for component atlas source dataset apis (#275)

* style: fix variable name (#275)

* chore: remove commented-out code (#275)

* feat: add component atlas source datasets get api (#275)

* test: add tests for component atlas source datasets get api (#275)

* feat: add component atlas individual source dataset api (#275)

* fix: remove inconsistent and unnecessary client parameter (#275)
  • Loading branch information
hunterckx authored Jun 9, 2024
1 parent 335d884 commit bdd3c46
Show file tree
Hide file tree
Showing 12 changed files with 1,281 additions and 8 deletions.

Large diffs are not rendered by default.

480 changes: 480 additions & 0 deletions __tests__/api-atlases-id-component-atlases-id-source-datasets.test.ts

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ describe("/api/atlases/[id]/source-studies/[sourceStudyId]/source-datasets", ()
);
expect(res._getStatusCode()).toEqual(200);
const sourceDatasets = res._getJSONData() as HCAAtlasTrackerSourceDataset[];
expect(sourceDatasets).toHaveLength(4);
expect(sourceDatasets).toHaveLength(8);
expectSourceDatasetsToMatch(sourceDatasets, [
SOURCE_DATASET_FOO,
SOURCE_DATASET_BAR,
Expand All @@ -92,7 +92,7 @@ describe("/api/atlases/[id]/source-studies/[sourceStudyId]/source-datasets", ()
);
expect(res._getStatusCode()).toEqual(200);
const sourceDatasets = res._getJSONData() as HCAAtlasTrackerSourceDataset[];
expect(sourceDatasets).toHaveLength(4);
expect(sourceDatasets).toHaveLength(8);
expectSourceDatasetsToMatch(sourceDatasets, [
SOURCE_DATASET_FOO,
SOURCE_DATASET_BAR,
Expand Down
4 changes: 2 additions & 2 deletions __tests__/update-cellxgene-source-datasets.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ describe("updateCellxGeneSourceDatasets", () => {
SOURCE_STUDY_WITH_SOURCE_DATASETS.id
);

expect(sourceDatasetsBefore).toHaveLength(4);
expect(sourceDatasetsBefore).toHaveLength(8);

expectSourceDatasetToMatch(
findSourceDatasetById(
Expand Down Expand Up @@ -61,7 +61,7 @@ describe("updateCellxGeneSourceDatasets", () => {
SOURCE_STUDY_WITH_SOURCE_DATASETS.id
);

expect(sourceDatasetsAfter).toHaveLength(5);
expect(sourceDatasetsAfter).toHaveLength(9);

expectSourceDatasetToMatch(
findSourceDatasetById(
Expand Down
21 changes: 21 additions & 0 deletions app/apis/catalog/hca-atlas-tracker/common/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,27 @@ export const componentAtlasEditSchema = newComponentAtlasSchema;

export type ComponentAtlasEditData = InferType<typeof componentAtlasEditSchema>;

/**
* Schema for data used to add source datasets to a component atlas.
*/
export const componentAtlasAddSourceDatasetsSchema = object({
sourceDatasetIds: array(string().required()).required(),
}).strict();

export type ComponentAtlasAddSourceDatasetsData = InferType<
typeof componentAtlasAddSourceDatasetsSchema
>;

/**
* Schema for data used to remove source datasets from a component atlas.
*/
export const componentAtlasDeleteSourceDatasetsSchema =
componentAtlasAddSourceDatasetsSchema;

export type ComponentAtlasDeleteSourceDatasetsData = InferType<
typeof componentAtlasDeleteSourceDatasetsSchema
>;

/**
* Create schema that combines an unpublished source study schema and a published source study schema.
* @param publishedSchema - Published source study schema.
Expand Down
89 changes: 87 additions & 2 deletions app/services/component-atlases.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { NotFoundError } from "app/utils/api-handler";
import { InvalidOperationError, NotFoundError } from "app/utils/api-handler";
import {
HCAAtlasTrackerDBComponentAtlas,
HCAAtlasTrackerDBComponentAtlasInfo,
Expand All @@ -9,6 +9,7 @@ import {
} from "../apis/catalog/hca-atlas-tracker/common/schema";
import { confirmAtlasExists } from "./atlases";
import { query } from "./database";
import { confirmSourceDatasetsExist } from "./source-datasets";

/**
* Get all component atlases of the given atlas.
Expand Down Expand Up @@ -119,7 +120,91 @@ export async function deleteComponentAtlas(
throw getComponentAtlasNotFoundError(atlasId, componentAtlasId);
}

function getComponentAtlasNotFoundError(
/**
* Add the given source datasets to the specified comonent atlas.
* @param atlasId - Atlas that the component atlas is accessed through.
* @param componentAtlasId - Component atlas ID.
* @param sourceDatasetIds - IDs of source datasets to add.
*/
export async function addSourceDatasetsToComponentAtlas(
atlasId: string,
componentAtlasId: string,
sourceDatasetIds: string[]
): Promise<void> {
await confirmSourceDatasetsExist(sourceDatasetIds);

const existingDatasetsResult = await query<{ array: string[] }>(
`
SELECT ARRAY(
SELECT sd_id FROM unnest(source_datasets) AS sd_id WHERE sd_id=ANY($1)
) FROM hat.component_atlases WHERE id=$2 AND atlas_id=$3
`,
[sourceDatasetIds, componentAtlasId, atlasId]
);

if (existingDatasetsResult.rows.length === 0)
throw getComponentAtlasNotFoundError(atlasId, componentAtlasId);

const existingSpecifiedDatasets = existingDatasetsResult.rows[0].array;

if (existingSpecifiedDatasets.length !== 0)
throw new InvalidOperationError(
`Component atlas with ID ${componentAtlasId} already has source datasets with IDs: ${existingSpecifiedDatasets.join(
", "
)}`
);

await query(
"UPDATE hat.component_atlases SET source_datasets=source_datasets||$1 WHERE id=$2",
[sourceDatasetIds, componentAtlasId]
);
}

/**
* Remove the given source datasets from the specified comonent atlas.
* @param atlasId - Atlas that the component atlas is accessed through.
* @param componentAtlasId - Component atlas ID.
* @param sourceDatasetIds - IDs of source datasets to remove.
*/
export async function deleteSourceDatasetsFromComponentAtlas(
atlasId: string,
componentAtlasId: string,
sourceDatasetIds: string[]
): Promise<void> {
await confirmSourceDatasetsExist(sourceDatasetIds);

const missingDatasetsResult = await query<{ array: string[] }>(
`
SELECT ARRAY(
SELECT sd_id FROM unnest($1::uuid[]) AS sd_id WHERE NOT sd_id=ANY(source_datasets)
) FROM hat.component_atlases WHERE id=$2 AND atlas_id=$3
`,
[sourceDatasetIds, componentAtlasId, atlasId]
);

if (missingDatasetsResult.rows.length === 0)
throw getComponentAtlasNotFoundError(atlasId, componentAtlasId);

const missingDatasets = missingDatasetsResult.rows[0].array;

if (missingDatasets.length !== 0)
throw new InvalidOperationError(
`Component atlas with ID ${componentAtlasId} doesn't have source datasets with IDs: ${missingDatasets.join(
", "
)}`
);

await query(
`
UPDATE hat.component_atlases
SET source_datasets = ARRAY(SELECT unnest(source_datasets) EXCEPT SELECT unnest($1::uuid[]))
WHERE id=$2
`,
[sourceDatasetIds, componentAtlasId]
);
}

export function getComponentAtlasNotFoundError(
atlasId: string,
componentAtlasId: string
): NotFoundError {
Expand Down
83 changes: 83 additions & 0 deletions app/services/source-datasets.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { CellxGeneDataset } from "app/utils/cellxgene-api";
import pg from "pg";
import {
HCAAtlasTrackerDBComponentAtlas,
HCAAtlasTrackerDBSourceDataset,
HCAAtlasTrackerDBSourceDatasetInfo,
HCAAtlasTrackerDBSourceDatasetWithStudyProperties,
Expand All @@ -11,6 +12,7 @@ import {
} from "../apis/catalog/hca-atlas-tracker/common/schema";
import { InvalidOperationError, NotFoundError } from "../utils/api-handler";
import { getCellxGeneDatasetsByCollectionId } from "./cellxgene";
import { getComponentAtlasNotFoundError } from "./component-atlases";
import { doTransaction, query } from "./database";
import { confirmSourceStudyExistsOnAtlas } from "./source-studies";

Expand All @@ -33,6 +35,33 @@ export async function getSourceStudyDatasets(
return queryResult.rows;
}

/**
* Get all source datasets of the given component atlas.
* @param atlasId - ID of the atlas that the component atlas is accesed through.
* @param componentAtlasId - Component atlas ID.
* @returns database-model source datasets.
*/
export async function getComponentAtlasDatasets(
atlasId: string,
componentAtlasId: string
): Promise<HCAAtlasTrackerDBSourceDatasetWithStudyProperties[]> {
const componentAtlasResult = await query<
Pick<HCAAtlasTrackerDBComponentAtlas, "source_datasets">
>(
"SELECT source_datasets FROM hat.component_atlases WHERE id=$1 AND atlas_id=$2",
[componentAtlasId, atlasId]
);
if (componentAtlasResult.rows.length === 0)
throw getComponentAtlasNotFoundError(atlasId, componentAtlasId);
const sourceDatasetIds = componentAtlasResult.rows[0].source_datasets;
const queryResult =
await query<HCAAtlasTrackerDBSourceDatasetWithStudyProperties>(
"SELECT d.*, s.doi, s.study_info FROM hat.source_datasets d JOIN hat.source_studies s ON d.source_study_id = s.id WHERE d.id = ANY($1)",
[sourceDatasetIds]
);
return queryResult.rows;
}

/**
* Get a source dataset.
* @param atlasId - ID of the atlas that the source dataset is accessed through.
Expand Down Expand Up @@ -64,6 +93,40 @@ export async function getSourceDataset(
return queryResult.rows[0];
}

/**
* Get a source dataset of a component atlas.
* @param atlasId - ID of the atlas that the source dataset is accessed through.
* @param componentAtlasId - ID of the component atlas that the source dataset is accessed through.
* @param sourceDatasetId - Source dataset ID.
* @returns database model of the source dataset.
*/
export async function getComponentAtlasSourceDataset(
atlasId: string,
componentAtlasId: string,
sourceDatasetId: string
): Promise<HCAAtlasTrackerDBSourceDatasetWithStudyProperties> {
const { exists } = (
await query<{ exists: boolean }>(
"SELECT EXISTS(SELECT 1 FROM hat.component_atlases WHERE $1=ANY(source_datasets) AND id=$2 AND atlas_id=$3)",
[sourceDatasetId, componentAtlasId, atlasId]
)
).rows[0];
if (!exists)
throw new NotFoundError(
`Source dataset with ID ${sourceDatasetId} doesn't exist on component atlas with ID ${componentAtlasId} on atlas with ID ${atlasId}`
);
const queryResult =
await query<HCAAtlasTrackerDBSourceDatasetWithStudyProperties>(
"SELECT d.*, s.doi, s.study_info FROM hat.source_datasets d JOIN hat.source_studies s ON d.source_study_id = s.id WHERE d.id = $1",
[sourceDatasetId]
);
if (queryResult.rows.length === 0)
throw new NotFoundError(
`Source dataset with ID ${sourceDatasetId} doesn't exist`
);
return queryResult.rows[0];
}

/**
* Create a source dataset for the given source study.
* @param atlasId - ID of the atlas that the source study is accessed through.
Expand Down Expand Up @@ -250,6 +313,26 @@ async function confirmSourceDatasetIsNonCellxGene(
);
}

/**
* Check whether a list of dataset IDs exist, and throw an error if any don't.
* @param sourceDatasetIds - Source dataset IDs to check for.
*/
export async function confirmSourceDatasetsExist(
sourceDatasetIds: string[]
): Promise<void> {
const queryResult = await query<Pick<HCAAtlasTrackerDBSourceDataset, "id">>(
"SELECT id FROM hat.source_datasets WHERE id=ANY($1)",
[sourceDatasetIds]
);
if (queryResult.rows.length < sourceDatasetIds.length) {
const foundIds = queryResult.rows.map((r) => r.id);
const missingIds = sourceDatasetIds.filter((id) => !foundIds.includes(id));
throw new InvalidOperationError(
`No source datasets exist with IDs: ${missingIds.join(", ")}`
);
}
}

function getSourceDatasetNotFoundError(
sourceStudyId: string,
sourceDatasetId: string
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { ROLE_GROUP } from "app/apis/catalog/hca-atlas-tracker/common/constants";
import { ROLE } from "../../../../../../app/apis/catalog/hca-atlas-tracker/common/entities";
import { componentAtlasAddSourceDatasetsSchema } from "../../../../../../app/apis/catalog/hca-atlas-tracker/common/schema";
import { dbSourceDatasetToApiSourceDataset } from "../../../../../../app/apis/catalog/hca-atlas-tracker/common/utils";
import { METHOD } from "../../../../../../app/common/entities";
import {
addSourceDatasetsToComponentAtlas,
deleteSourceDatasetsFromComponentAtlas,
} from "../../../../../../app/services/component-atlases";
import { getComponentAtlasDatasets } from "../../../../../../app/services/source-datasets";
import {
handleByMethod,
handler,
role,
} from "../../../../../../app/utils/api-handler";

const getHandler = handler(role(ROLE_GROUP.READ), async (req, res) => {
const atlasId = req.query.atlasId as string;
const componentAtlasId = req.query.componentAtlasId as string;
res
.status(200)
.json(
(await getComponentAtlasDatasets(atlasId, componentAtlasId)).map(
dbSourceDatasetToApiSourceDataset
)
);
});

const postHandler = handler(role(ROLE.CONTENT_ADMIN), async (req, res) => {
const atlasId = req.query.atlasId as string;
const componentAtlasId = req.query.componentAtlasId as string;
const { sourceDatasetIds } =
await componentAtlasAddSourceDatasetsSchema.validate(req.body);
await addSourceDatasetsToComponentAtlas(
atlasId,
componentAtlasId,
sourceDatasetIds
);
res.status(201).end();
});

const deleteHandler = handler(role(ROLE.CONTENT_ADMIN), async (req, res) => {
const atlasId = req.query.atlasId as string;
const componentAtlasId = req.query.componentAtlasId as string;
const { sourceDatasetIds } =
await componentAtlasAddSourceDatasetsSchema.validate(req.body);
await deleteSourceDatasetsFromComponentAtlas(
atlasId,
componentAtlasId,
sourceDatasetIds
);
res.status(200).end();
});

/**
* API route for adding or deleting multiple source datasets on a component atlas.
*/
export default handleByMethod({
[METHOD.GET]: getHandler,
[METHOD.POST]: postHandler,
[METHOD.DELETE]: deleteHandler,
});
Loading

0 comments on commit bdd3c46

Please sign in to comment.