Skip to content

Commit

Permalink
feat: pass deletePrefix and delete spread through dedupe (#319)
Browse files Browse the repository at this point in the history
  • Loading branch information
ForbesLindesay authored Feb 20, 2024
1 parent b8ffa7a commit 66c0122
Show file tree
Hide file tree
Showing 9 changed files with 418 additions and 90 deletions.
39 changes: 39 additions & 0 deletions docs/dataloader.md
Original file line number Diff line number Diff line change
Expand Up @@ -349,6 +349,45 @@ function onUserChanged(id: DbUser['id']) {
}
```

One downside of this approach is that you can only use this outside of a transaction. The simplest way to resolve this is to separate the implementation from the caching:

```typescript
import {dedupeAsync} from '@databases/dataloader';
import database, {tables, DbUser} from './database';

async function getUserBase(
database: Queryable,
userId: DbUser['id'],
): Promise<DbUser> {
return await tables.users(database).findOneRequired({id: userId});
}

// (userId: DbUser['id']) => Promise<DbUser>
const getUserCached = dedupeAsync<DbUser['id'], DbUser>(
async (userId) => await getUserBase(userId, database),
{cache: createCache({name: 'Users'})},
);

export async function getUser(
db: Queryable,
userId: DbUser['id'],
): Promise<DbUser> {
if (db === database) {
// If we're using the default connection,
// it's safe to read from the cache
return await getUserCached(userId);
} else {
// If we're inside a transaction, we may
// need to bypass the cache
return await getUserBase(db, userId);
}
}

function onUserChanged(id: DbUser['id']) {
getUserCached.cache.delete(id);
}
```

#### Caching fetch requests

The following example caches requests to load a user from some imaginary API.
Expand Down
2 changes: 1 addition & 1 deletion packages/cache/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -442,7 +442,7 @@ export default function createCacheRealm(
}
this._delete(k);
}
if (onReplicationEvent) {
if (onReplicationEvent && serializedKeys.size) {
onReplicationEvent({
kind: 'DELETE_MULTIPLE',
name: this.name,
Expand Down
112 changes: 112 additions & 0 deletions packages/dataloader/src/CacheMapImplementation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import {AsyncCacheMap, CacheMap, CacheMapInput, KeyPrefix} from './types';

const supportsDeleteSpreadCache = new WeakMap<Function, boolean>();

function supportsDeleteSpreadUncached<TKey, TValue>(
cacheMap: CacheMapInput<TKey, TValue>,
): boolean {
return /^[^={]*\.\.\./.test(cacheMap.delete.toString());
}

function supportsDeleteSpread<TKey, TValue>(
cacheMap: CacheMapInput<TKey, TValue>,
): boolean {
if (cacheMap.constructor === Map || cacheMap.constructor === WeakMap) {
return false;
}
if (cacheMap.constructor === Object || cacheMap.constructor === Function) {
return supportsDeleteSpreadUncached(cacheMap);
}

const cached = supportsDeleteSpreadCache.get(cacheMap.constructor);
if (cached !== undefined) return cached;

const freshValue = supportsDeleteSpreadUncached(cacheMap);
supportsDeleteSpreadCache.set(cacheMap.constructor, freshValue);
return freshValue;
}

class CacheMapImplementation<TKey, TResult, TMappedKey>
implements CacheMap<TKey, TResult>
{
private readonly _map: CacheMapInput<TMappedKey, TResult>;
private readonly _mapKey: (key: TKey) => TMappedKey;
private readonly _supportsDeleteSpread: boolean;
constructor(
map: CacheMapInput<TMappedKey, TResult>,
mapKey: (key: TKey) => TMappedKey,
) {
this._map = map;
this._mapKey = mapKey;
this._supportsDeleteSpread = supportsDeleteSpread(map);
}

get size() {
return this._map.size;
}
get(key: TKey): TResult | undefined {
const cacheKey = this._mapKey(key);
return this._map.get(cacheKey);
}
set(key: TKey, value: TResult): void {
const cacheKey = this._mapKey(key);
this._map.set(cacheKey, value);
}
deletePrefix(prefix: KeyPrefix<TKey>): void {
if (this._map.deletePrefix) {
this._map.deletePrefix(prefix as any);
} else if (this._map.keys && typeof prefix === 'string') {
for (const key of this._map.keys()) {
const k: unknown = key;
if (typeof k !== 'string') {
throw new Error(
`This cache contains non-string keys so you cannot use deletePrefix.`,
);
}
if (k.startsWith(prefix)) {
this._map.delete(key);
}
}
} else {
throw new Error(`This cache does not support deletePrefix.`);
}
}
delete(...keys: TKey[]): void {
if (!this._supportsDeleteSpread || keys.length < 2) {
for (const key of keys) {
const cacheKey = this._mapKey(key);
this._map.delete(cacheKey);
}
} else {
const cacheKeys = keys.map(this._mapKey);
this._map.delete(...cacheKeys);
}
}
clear(): void {
if (!this._map.clear) {
throw new Error(`This cache does not support clearing`);
}
this._map.clear();
}
}
export function createCacheMap<TKey, TValue, TMappedKey = TKey>(
map: CacheMapInput<TMappedKey, TValue>,
mapKey: (key: TKey) => TMappedKey,
): CacheMap<TKey, TValue> {
return new CacheMapImplementation(map, mapKey);
}

class AsyncCacheMapImplementation<TKey, TResult, TMappedKey>
extends CacheMapImplementation<TKey, Promise<TResult>, TMappedKey>
implements AsyncCacheMap<TKey, TResult>
{
set(key: TKey, value: TResult | Promise<TResult>): void {
super.set(key, Promise.resolve(value));
}
}
export function createAsyncCacheMap<TKey, TValue, TMappedKey = TKey>(
map: CacheMapInput<TMappedKey, Promise<TValue>>,
mapKey: (key: TKey) => TMappedKey,
): AsyncCacheMap<TKey, TValue> {
return new AsyncCacheMapImplementation(map, mapKey);
}
17 changes: 6 additions & 11 deletions packages/dataloader/src/MultiKeyMap.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,10 @@
import {CacheMap, CacheMapInput} from './types';
import {CacheMapInput, Path, SubPath} from './types';

type Path = readonly [unknown, ...(readonly unknown[])];
type SubPath<TKeys extends readonly unknown[]> = TKeys extends readonly [
...infer THead,
infer TTail,
]
? {readonly [i in keyof TKeys]: TKeys[i]} | SubPath<THead>
: never;

export interface MultiKeyMap<TKeys extends Path, TValue>
extends CacheMap<TKeys, TValue> {
export interface MultiKeyMap<TKeys extends Path, TValue> {
readonly size: number;
get: (key: TKeys) => TValue | undefined;
set: (key: TKeys, value: TValue) => void;
deletePrefix: (key: SubPath<TKeys>) => void;
delete: (key: TKeys | SubPath<TKeys>) => void;
clear: () => void;
}
Expand Down Expand Up @@ -136,6 +128,9 @@ class MultiKeyMapImplementation<TKeys extends Path, TValue, TMappedKey>
set(key: TKeys, value: TValue): void {
this._root.set(key, value);
}
deletePrefix(key: SubPath<TKeys>): void {
this._root.delete(key);
}
delete(key: TKeys | SubPath<TKeys>): void {
this._root.delete(key);
}
Expand Down
Loading

0 comments on commit 66c0122

Please sign in to comment.