Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: pass deletePrefix and delete spread through dedupe #319

Merged
merged 2 commits into from
Feb 20, 2024
Merged
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
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
Loading