Skip to content

Commit

Permalink
Merge pull request #17148 from davelopez/add_simple_store_composable
Browse files Browse the repository at this point in the history
Add simpleKeyStore composable
  • Loading branch information
davelopez authored Dec 12, 2023
2 parents 819a799 + 36fd9e3 commit b5d81e3
Show file tree
Hide file tree
Showing 11 changed files with 346 additions and 157 deletions.
12 changes: 5 additions & 7 deletions client/src/api/datasetCollections.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { CollectionEntry, DatasetCollectionAttributes, DCESummary, HDCADetailed, isHDCA } from "@/api";
import { CollectionEntry, DCESummary, HDCADetailed, isHDCA } from "@/api";
import { fetcher } from "@/api/schema";

const DEFAULT_LIMIT = 50;
Expand Down Expand Up @@ -57,9 +57,7 @@ export async function fetchElementsFromCollection(params: {
});
}

const getCollectionAttributes = fetcher.path("/api/dataset_collections/{id}/attributes").method("get").create();

export async function fetchCollectionAttributes(params: { hdcaId: string }): Promise<DatasetCollectionAttributes> {
const { data } = await getCollectionAttributes({ id: params.hdcaId, instance_type: "history" });
return data;
}
export const fetchCollectionAttributes = fetcher
.path("/api/dataset_collections/{id}/attributes")
.method("get")
.create();
4 changes: 2 additions & 2 deletions client/src/api/datasets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,10 +36,10 @@ export async function getDatasets(options: GetDatasetsOptions = {}) {
return data;
}

const getDataset = fetcher.path("/api/datasets/{dataset_id}").method("get").create();
export const fetchDataset = fetcher.path("/api/datasets/{dataset_id}").method("get").create();

export async function fetchDatasetDetails(params: { id: string }): Promise<DatasetDetails> {
const { data } = await getDataset({ dataset_id: params.id, view: "detailed" });
const { data } = await fetchDataset({ dataset_id: params.id, view: "detailed" });
// We know that the server will return a DatasetDetails object because of the view parameter
// but the type system doesn't, so we have to cast it.
return data as unknown as DatasetDetails;
Expand Down
4 changes: 3 additions & 1 deletion client/src/api/schema/fetcher.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import type { Middleware } from "openapi-typescript-fetch";
import type { ApiResponse, Middleware } from "openapi-typescript-fetch";
import { Fetcher } from "openapi-typescript-fetch";

import { getAppRoot } from "@/onload/loadConfig";
import { rethrowSimple } from "@/utils/simple-error";

import type { paths } from "./schema";

export { ApiResponse };

const rethrowSimpleMiddleware: Middleware = async (url, init, next) => {
try {
const response = await next(url, init);
Expand Down
2 changes: 1 addition & 1 deletion client/src/api/schema/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
export { fetcher } from "./fetcher";
export { type ApiResponse, fetcher } from "./fetcher";
export type { components, operations, paths } from "./schema";
Original file line number Diff line number Diff line change
Expand Up @@ -119,10 +119,10 @@ export default {
return this.getAttributes(this.collectionId);
},
databaseKeyFromElements: function () {
return this.attributesData.dbkey;
return this.attributesData?.dbkey;
},
datatypeFromElements: function () {
return this.attributesData.extension;
return this.attributesData?.extension;
},
},
methods: {
Expand Down
181 changes: 181 additions & 0 deletions client/src/composables/keyedCache.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
import flushPromises from "flush-promises";
import { computed, ref } from "vue";

import { useKeyedCache } from "./keyedCache";

interface ItemData {
id: string;
name: string;
}

const fetchItem = jest.fn();
const shouldFetch = jest.fn();

describe("useKeyedCache", () => {
beforeEach(() => {
fetchItem.mockClear();
shouldFetch.mockClear();
});

it("should fetch the item if it is not already stored", async () => {
const id = "1";
const item = { id: id, name: "Item 1" };
const fetchParams = { id: id };
const apiResponse = { data: item };

fetchItem.mockResolvedValue(apiResponse);

const { storedItems, getItemById, isLoadingItem } = useKeyedCache<ItemData>(fetchItem);

expect(storedItems.value).toEqual({});
expect(isLoadingItem.value(id)).toBeFalsy();

getItemById.value(id);

expect(isLoadingItem.value(id)).toBeTruthy();
await flushPromises();
expect(isLoadingItem.value(id)).toBeFalsy();
expect(storedItems.value[id]).toEqual(item);
expect(fetchItem).toHaveBeenCalledWith(fetchParams);
});

it("should not fetch the item if it is already stored", async () => {
const id = "1";
const item = { id: id, name: "Item 1" };

fetchItem.mockResolvedValue({ data: item });

const { storedItems, getItemById, isLoadingItem } = useKeyedCache<ItemData>(fetchItem);

storedItems.value[id] = item;

expect(isLoadingItem.value(id)).toBeFalsy();

getItemById.value(id);

expect(isLoadingItem.value(id)).toBeFalsy();
expect(storedItems.value[id]).toEqual(item);
expect(fetchItem).not.toHaveBeenCalled();
});

it("should fetch the item regardless of whether it is already stored if shouldFetch returns true", async () => {
const id = "1";
const item = { id: id, name: "Item 1" };
const fetchParams = { id: id };
const apiResponse = { data: item };

fetchItem.mockResolvedValue(apiResponse);
shouldFetch.mockReturnValue(() => true);

const { storedItems, getItemById, isLoadingItem } = useKeyedCache<ItemData>(fetchItem, shouldFetch);

storedItems.value[id] = item;

expect(isLoadingItem.value(id)).toBeFalsy();

getItemById.value(id);

expect(isLoadingItem.value(id)).toBeTruthy();
await flushPromises();
expect(isLoadingItem.value(id)).toBeFalsy();
expect(storedItems.value[id]).toEqual(item);
expect(fetchItem).toHaveBeenCalledWith(fetchParams);
expect(shouldFetch).toHaveBeenCalled();
});

it("should not fetch the item if it is already being fetched", async () => {
const id = "1";
const item = { id: id, name: "Item 1" };
const fetchParams = { id: id };
const apiResponse = { data: item };

fetchItem.mockResolvedValue(apiResponse);

const { storedItems, getItemById, isLoadingItem } = useKeyedCache<ItemData>(fetchItem);

expect(isLoadingItem.value(id)).toBeFalsy();

getItemById.value(id);
getItemById.value(id);

expect(isLoadingItem.value(id)).toBeTruthy();
await flushPromises();
expect(isLoadingItem.value(id)).toBeFalsy();
expect(storedItems.value[id]).toEqual(item);
expect(fetchItem).toHaveBeenCalledTimes(1);
expect(fetchItem).toHaveBeenCalledWith(fetchParams);
});

it("should not fetch the item if it is already being fetched, even if shouldFetch returns true", async () => {
const id = "1";
const item = { id: id, name: "Item 1" };
const fetchParams = { id: id };
const apiResponse = { data: item };

fetchItem.mockResolvedValue(apiResponse);
shouldFetch.mockReturnValue(() => true);

const { storedItems, getItemById, isLoadingItem } = useKeyedCache<ItemData>(fetchItem, shouldFetch);

expect(isLoadingItem.value(id)).toBeFalsy();

getItemById.value(id);
getItemById.value(id);

expect(isLoadingItem.value(id)).toBeTruthy();
await flushPromises();
expect(isLoadingItem.value(id)).toBeFalsy();
expect(storedItems.value[id]).toEqual(item);
expect(fetchItem).toHaveBeenCalledTimes(1);
expect(fetchItem).toHaveBeenCalledWith(fetchParams);
expect(shouldFetch).toHaveBeenCalled();
});

it("should accept a ref for fetchItem", async () => {
const id = "1";
const item = { id: id, name: "Item 1" };
const fetchParams = { id: id };
const apiResponse = { data: item };

fetchItem.mockResolvedValue(apiResponse);

const fetchItemRef = ref(fetchItem);

const { storedItems, getItemById, isLoadingItem } = useKeyedCache<ItemData>(fetchItemRef);

expect(isLoadingItem.value(id)).toBeFalsy();

getItemById.value(id);

expect(isLoadingItem.value(id)).toBeTruthy();
await flushPromises();
expect(isLoadingItem.value(id)).toBeFalsy();
expect(storedItems.value[id]).toEqual(item);
expect(fetchItem).toHaveBeenCalledWith(fetchParams);
});

it("should accept a computed for shouldFetch", async () => {
const id = "1";
const item = { id: id, name: "Item 1" };
const fetchParams = { id: id };
const apiResponse = { data: item };

fetchItem.mockResolvedValue(apiResponse);
shouldFetch.mockReturnValue(true);

const shouldFetchComputed = computed(() => shouldFetch);

const { storedItems, getItemById, isLoadingItem } = useKeyedCache<ItemData>(fetchItem, shouldFetchComputed);

expect(isLoadingItem.value(id)).toBeFalsy();

getItemById.value(id);

expect(isLoadingItem.value(id)).toBeTruthy();
await flushPromises();
expect(isLoadingItem.value(id)).toBeFalsy();
expect(storedItems.value[id]).toEqual(item);
expect(fetchItem).toHaveBeenCalledWith(fetchParams);
expect(shouldFetch).toHaveBeenCalled();
});
});
106 changes: 106 additions & 0 deletions client/src/composables/keyedCache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import { MaybeRefOrGetter, toValue } from "@vueuse/core";
import { computed, del, type Ref, ref, set, unref } from "vue";

import type { ApiResponse } from "@/api/schema";

/**
* Parameters for fetching an item from the server.
*
* Minimally, this should include an id for indexing the item.
*/
interface FetchParams {
id: string;
}

/**
* A function that fetches an item from the server.
*/
type FetchHandler<T> = (params: FetchParams) => Promise<ApiResponse<T>>;

/**
* A function that returns true if the item should be fetched.
* Provides fine-grained control over when to fetch an item.
*/
type ShouldFetchHandler<T> = (item?: T) => boolean;

/**
* Returns true if the item is not defined.
* @param item The item to check.
*/
const fetchIfAbsent = <T>(item?: T) => !item;

/**
* A composable that provides a simple key-value cache for items fetched from the server.
*
* Useful for storing items that are fetched by id.
*
* @param fetchItemHandler Fetches an item from the server.
* @param shouldFetchHandler Returns true if the item should be fetched.
* Provides fine-grained control over when to fetch an item.
* If not provided, by default, the item will be fetched if it is not already stored.
*/
export function useKeyedCache<T>(
fetchItemHandler: Ref<FetchHandler<T>> | FetchHandler<T>,
shouldFetchHandler?: MaybeRefOrGetter<ShouldFetchHandler<T>>
) {
const storedItems = ref<{ [key: string]: T }>({});
const loadingItem = ref<{ [key: string]: boolean }>({});

const getItemById = computed(() => {
return (id: string) => {
const item = storedItems.value[id];
if (!loadingItem.value[id] && shouldFetch(item)) {
fetchItemById({ id: id });
}
return item ?? null;
};
});

function shouldFetch(item?: T) {
if (shouldFetchHandler == undefined) {
return fetchIfAbsent(item);
}
return toValue(shouldFetchHandler)(item);
}

const isLoadingItem = computed(() => {
return (id: string) => {
return loadingItem.value[id] ?? false;
};
});

async function fetchItemById(params: FetchParams) {
const itemId = params.id;
set(loadingItem.value, itemId, true);
try {
const fetchItem = unref(fetchItemHandler);
const { data } = await fetchItem({ id: itemId });
set(storedItems.value, itemId, data);
return data;
} finally {
del(loadingItem.value, itemId);
}
}

return {
/**
* The stored items as a reactive object.
*/
storedItems,
/**
* A computed function that returns the item with the given id.
* If the item is not already stored, it will be fetched from the server.
* And reactively updated when the fetch completes.
*/
getItemById,
/**
* A computed function that returns true if the item with the given id is currently being fetched.
*/
isLoadingItem,
/**
* Fetches the item with the given id from the server.
* And reactively updates the stored item when the fetch completes.
*/
fetchItemById,
};
}
Loading

0 comments on commit b5d81e3

Please sign in to comment.