Skip to content
Draft
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
279 changes: 279 additions & 0 deletions engine/src/helpers/LRUCache.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,279 @@
import { LRUCache } from "./LRUCache";

describe("LRUCache", () => {
describe("constructor", () => {
it("creates an instance with positive capacity", () => {
const cache = new LRUCache<string, number>(3);
expect(cache).toBeInstanceOf(LRUCache);
expect(cache.size).toBe(0);
});

it("throws if capacity is 0 or negative", () => {
expect(() => new LRUCache<string, number>(0)).toThrow(
"Capacity must be greater than 0"
);
expect(() => new LRUCache<string, number>(-1)).toThrow(
"Capacity must be greater than 0"
);
});
});

describe("set and get basics", () => {
it("stores and retrieves values", () => {
const cache = new LRUCache<string, number>(2);
cache.set("a", 1);
cache.set("b", 2);

expect(cache.get("a")).toBe(1);
expect(cache.get("b")).toBe(2);
});

it("returns undefined for missing keys", () => {
const cache = new LRUCache<string, number>(2);
expect(cache.get("missing")).toBeUndefined();
});
});

describe("recency and ordering", () => {
it("moves key to most recently used on get", () => {
const cache = new LRUCache<string, number>(3);
cache.set("a", 1);
cache.set("b", 2);
cache.set("c", 3);

// Initial order: a (LRU), b, c (MRU)
expect(cache.keys()).toEqual(["a", "b", "c"]);

// Access "a", now a should be MRU
expect(cache.get("a")).toBe(1);
expect(cache.keys()).toEqual(["b", "c", "a"]);

// Access "c", now c should be MRU
cache.get("c");
expect(cache.keys()).toEqual(["b", "a", "c"]);
});

it("updates recency on set for existing key", () => {
const cache = new LRUCache<string, number>(3);
cache.set("a", 1);
cache.set("b", 2);
cache.set("c", 3);

// Initial: a, b, c
expect(cache.keys()).toEqual(["a", "b", "c"]);

// Set "b" again with new value
cache.set("b", 20);

// "b" should become MRU, but keys stay same set
expect(cache.keys()).toEqual(["a", "c", "b"]);
expect(cache.get("b")).toBe(20);
});
});

describe("eviction behavior", () => {
it("evicts least recently used when capacity exceeded", () => {
const cache = new LRUCache<string, number>(2);
cache.set("a", 1);
cache.set("b", 2);
// At this point: a (LRU), b (MRU)

cache.set("c", 3); // should evict "a"

expect(cache.get("a")).toBeUndefined();
expect(cache.get("b")).toBe(2);
expect(cache.get("c")).toBe(3);
expect(cache.keys()).toEqual(["b", "c"]);
});

it("evicts after recency changes caused by get", () => {
const cache = new LRUCache<string, number>(2);
cache.set("a", 1);
cache.set("b", 2);
// Order: a (LRU), b (MRU)

// Access "a" -> now b (LRU), a (MRU)
cache.get("a");

cache.set("c", 3); // should evict "b"

expect(cache.get("b")).toBeUndefined();
expect(cache.get("a")).toBe(1);
expect(cache.get("c")).toBe(3);
expect(cache.keys()).toEqual(["a", "c"]);
});

it("evicts after recency changes caused by set(existing key)", () => {
const cache = new LRUCache<string, number>(2);
cache.set("a", 1);
cache.set("b", 2);
// Order: a (LRU), b (MRU)

cache.set("a", 10); // a becomes MRU, order: b (LRU), a (MRU)

Check warning on line 112 in engine/src/helpers/LRUCache.test.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Verify this is the index that was intended; "a" was already set on line 108.

See more on https://sonarcloud.io/project/issues?id=dhis2_app-runtime&issues=AZrFtbzcflGFhJuDdzP-&open=AZrFtbzcflGFhJuDdzP-&pullRequest=1418
expect(cache.keys()).toEqual(["b", "a"]);

cache.set("c", 3); // should evict "b"
expect(cache.get("b")).toBeUndefined();
expect(cache.get("a")).toBe(10);
expect(cache.get("c")).toBe(3);
expect(cache.keys()).toEqual(["a", "c"]);
});

it("works when capacity is 1 (always evicts previous)", () => {
const cache = new LRUCache<string, number>(1);

cache.set("a", 1);
expect(cache.keys()).toEqual(["a"]);

cache.set("b", 2); // evicts "a"
expect(cache.get("a")).toBeUndefined();
expect(cache.get("b")).toBe(2);
expect(cache.keys()).toEqual(["b"]);

cache.set("b", 3); // update existing, no eviction
expect(cache.get("b")).toBe(3);
expect(cache.keys()).toEqual(["b"]);
});
});

describe("has", () => {
it("returns true if key exists, false otherwise", () => {
const cache = new LRUCache<string, number>(2);
cache.set("a", 1);

expect(cache.has("a")).toBe(true);
expect(cache.has("b")).toBe(false);
});

it("does not change recency when checking has()", () => {
const cache = new LRUCache<string, number>(2);
cache.set("a", 1);
cache.set("b", 2);
// Order: a (LRU), b (MRU)

expect(cache.has("a")).toBe(true);
// Order should remain unchanged
expect(cache.keys()).toEqual(["a", "b"]);
});
});

describe("delete", () => {
it("removes a key and returns true if it existed", () => {
const cache = new LRUCache<string, number>(2);
cache.set("a", 1);
cache.set("b", 2);

const result = cache.delete("a");
expect(result).toBe(true);
expect(cache.has("a")).toBe(false);
expect(cache.size).toBe(1);
expect(cache.keys()).toEqual(["b"]);
});

it("returns false when deleting a non-existent key", () => {
const cache = new LRUCache<string, number>(2);
cache.set("a", 1);

const result = cache.delete("b");
expect(result).toBe(false);
expect(cache.size).toBe(1);
expect(cache.keys()).toEqual(["a"]);
});
});

describe("clear", () => {
it("removes all entries", () => {
const cache = new LRUCache<string, number>(3);
cache.set("a", 1);
cache.set("b", 2);
cache.set("c", 3);

expect(cache.size).toBe(3);
cache.clear();
expect(cache.size).toBe(0);
expect(cache.keys()).toEqual([]);
expect(cache.get("a")).toBeUndefined();
expect(cache.get("b")).toBeUndefined();
expect(cache.get("c")).toBeUndefined();
});
});

describe("size", () => {
it("reflects the current number of elements", () => {
const cache = new LRUCache<string, number>(2);
expect(cache.size).toBe(0);

cache.set("a", 1);
expect(cache.size).toBe(1);

cache.set("b", 2);
expect(cache.size).toBe(2);

// exceeds capacity -> eviction, but size stays at capacity
cache.set("c", 3);
expect(cache.size).toBe(2);

cache.delete("c");
expect(cache.size).toBe(1);
});
});

describe("keys()", () => {
it("returns keys from least to most recently used", () => {
const cache = new LRUCache<string, number>(3);
cache.set("a", 1);
cache.set("b", 2);
cache.set("c", 3);

expect(cache.keys()).toEqual(["a", "b", "c"]);

cache.get("a"); // recency: b, c, a
expect(cache.keys()).toEqual(["b", "c", "a"]);

cache.set("b", 20); // recency: c, a, b
expect(cache.keys()).toEqual(["c", "a", "b"]);
});
});

describe("non-primitive keys and generic types", () => {
it("handles object keys correctly", () => {
type Key = { id: number };
const k1: Key = { id: 1 };
const k2: Key = { id: 2 };
const k3: Key = { id: 3 };

const cache = new LRUCache<Key, string>(2);
cache.set(k1, "one");
cache.set(k2, "two");

expect(cache.get(k1)).toBe("one");
expect(cache.get(k2)).toBe("two");

// Using a different object with same shape should not match
expect(cache.get({ id: 1 })).toBeUndefined();

// Trigger eviction
cache.set(k3, "three");
// k1 is LRU at this point? Let's check:
// insertion: k1, k2; order: k1 (LRU), k2 (MRU)
// haven't touched recency, so k1 should be evicted
expect(cache.get(k1)).toBeUndefined();
expect(cache.get(k2)).toBe("two");
expect(cache.get(k3)).toBe("three");
});

it("supports complex value types", () => {
interface User {
id: number;
name: string;
}

const cache = new LRUCache<string, User>(2);
cache.set("u1", { id: 1, name: "Alice" });
cache.set("u2", { id: 2, name: "Bob" });

expect(cache.get("u1")).toEqual({ id: 1, name: "Alice" });
expect(cache.get("u2")).toEqual({ id: 2, name: "Bob" });
});
});
});
86 changes: 86 additions & 0 deletions engine/src/helpers/LRUCache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
export class LRUCache<K, V> {
private cache: Map<K, V>;

Check warning on line 2 in engine/src/helpers/LRUCache.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Member 'cache' is never reassigned; mark it as `readonly`.

See more on https://sonarcloud.io/project/issues?id=dhis2_app-runtime&issues=AZrFtbzsflGFhJuDdzP_&open=AZrFtbzsflGFhJuDdzP_&pullRequest=1418
private readonly capacity: number;

constructor(capacity: number) {
if (capacity <= 0) {
throw new Error("Capacity must be greater than 0");
}

this.capacity = capacity;
this.cache = new Map<K, V>();
}

/**
* Get a value by key.
* Moves the key to the "most recently used" position if found.
*/
get(key: K): V | undefined {
if (!this.cache.has(key)) {
return undefined;
}

// Refresh the key by re-inserting it
const value = this.cache.get(key)!;
this.cache.delete(key);
this.cache.set(key, value);

return value;
}

/**
* Insert or update a value by key.
* If capacity is exceeded, evicts the least recently used item.
*/
set(key: K, value: V): void {
// If key exists, we delete it so that insertion order is updated
if (this.cache.has(key)) {
this.cache.delete(key);
}

this.cache.set(key, value);

// If over capacity, remove least recently used (first item in Map)
if (this.cache.size > this.capacity) {
const lruKey = this.cache.keys().next().value; // first inserted
if (lruKey !== undefined) {
this.cache.delete(lruKey);
}
}
}

/**
* Check if the cache contains a key (without updating its recency).
*/
has(key: K): boolean {
return this.cache.has(key);
}

/**
* Delete a specific key from the cache.
*/
delete(key: K): boolean {
return this.cache.delete(key);
}

/**
* Clear the entire cache.
*/
clear(): void {
this.cache.clear();
}

/**
* Current number of elements in cache.
*/
get size(): number {
return this.cache.size;
}

/**
* Returns keys from least -> most recently used.
*/
keys(): K[] {
return Array.from(this.cache.keys());
}
}
6 changes: 5 additions & 1 deletion engine/src/links/RestAPILink.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { LRUCache } from '../helpers/LRUCache'
import type { DataEngineConfig } from '../types/DataEngineConfig'
import type {
DataEngineLink,
Expand All @@ -6,6 +7,7 @@ import type {
import type { FetchType } from '../types/ExecuteOptions'
import type { JsonValue } from '../types/JsonValue'
import type { ResolvedResourceQuery } from '../types/Query'
import { QueryAlias, QueryAliasCache } from '../types/QueryAlias'
import { fetchData } from './RestAPILink/fetchData'
import { joinPath } from './RestAPILink/path'
import { queryToRequestOptions } from './RestAPILink/queryToRequestOptions'
Expand All @@ -16,14 +18,16 @@ export class RestAPILink implements DataEngineLink {
public readonly versionedApiPath: string
public readonly unversionedApiPath: string

public readonly queryAliasCache: QueryAliasCache = new LRUCache<string, QueryAlias>(100)

public constructor(config: DataEngineConfig) {
this.config = config
this.versionedApiPath = joinPath('api', String(config.apiVersion))
this.unversionedApiPath = joinPath('api')
}

private fetch(path: string, options: RequestInit): Promise<JsonValue> {
return fetchData(joinPath(this.config.baseUrl, path), options)
return fetchData(joinPath(this.config.baseUrl, path), options, this)
}

public executeResourceQuery(
Expand Down
Loading
Loading