Skip to content

Commit

Permalink
feat: useQuery
Browse files Browse the repository at this point in the history
  • Loading branch information
ntucker committed Feb 14, 2024
1 parent dab41ae commit 96f5edf
Show file tree
Hide file tree
Showing 13 changed files with 252 additions and 58 deletions.
4 changes: 4 additions & 0 deletions examples/benchmark/core.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
Endpoint,
Entity,
normalize,
WeakEntityMap,
} from './dist/index.js';
import { printStatus } from './printStatus.js';
import {
Expand Down Expand Up @@ -113,6 +114,9 @@ export default function addReducerSuite(suite) {
controller.globalCache = {
entities: {},
results: {},
queries: new Map(),
inputEndpointCache: {},
infer: new WeakEntityMap(),
};
return controller.getResponse(getProject, cachedState);
})
Expand Down
183 changes: 158 additions & 25 deletions packages/core/src/controller/Controller.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import type {
ErrorTypes,
SnapshotInterface,
DenormalizeCache,
Schema,
Denormalize,
Queryable,
SchemaArgs,
ResultCache,
} from '@data-client/normalizr';
import {
WeakEntityMap,
Expand Down Expand Up @@ -33,7 +35,7 @@ import ensurePojo from './ensurePojo.js';
import type { EndpointUpdateFunction } from './types.js';
import { initialState } from '../state/reducer/createReducer.js';
import selectMeta from '../state/selectMeta.js';
import type { ActionTypes, State } from '../types.js';
import type { ActionTypes, State, DenormalizeCache } from '../types.js';

export type GenericDispatch = (value: any) => Promise<void>;
export type DataClientDispatch = (value: ActionTypes) => Promise<void>;
Expand Down Expand Up @@ -85,6 +87,9 @@ export default class Controller<
globalCache = {
entities: {},
results: {},
queries: new Map(),
inputEndpointCache: {},
infer: new WeakEntityMap(),
},
}: ConstructorProps<D> = {}) {
this.dispatch = dispatch;
Expand Down Expand Up @@ -335,21 +340,42 @@ export default class Controller<
* Gets the (globally referentially stable) response for a given endpoint/args pair from state given.
* @see https://dataclient.io/docs/api/Controller#getResponse
*/
getResponse = <
getResponse<E extends EndpointInterface>(
endpoint: E,
...rest: readonly [null, State<unknown>]
): {
data: DenormalizeNullable<E['schema']>;
expiryStatus: ExpiryStatus;
expiresAt: number;
};

getResponse<E extends EndpointInterface>(
endpoint: E,
...rest: readonly [...Parameters<E>, State<unknown>]
): {
data: DenormalizeNullable<E['schema']>;
expiryStatus: ExpiryStatus;
expiresAt: number;
};

getResponse<
E extends Pick<EndpointInterface, 'key' | 'schema' | 'invalidIfStale'>,
Args extends readonly [...Parameters<E['key']>] | readonly [null],
>(
endpoint: E,
...rest: [...Args, State<unknown>]
...rest: readonly [
...(readonly [...Parameters<E['key']>] | readonly [null]),
State<unknown>,
]
): {
data: DenormalizeNullable<E['schema']>;
expiryStatus: ExpiryStatus;
expiresAt: number;
} => {
} {
const state = rest[rest.length - 1] as State<unknown>;
// this is typescript generics breaking
const args: any = rest
.slice(0, rest.length - 1)
// handle FormData
.map(ensurePojo) as Parameters<E['key']>;
const isActive = args.length !== 1 || args[0] !== null;
const key = isActive ? endpoint.key(...args) : '';
Expand All @@ -360,17 +386,25 @@ export default class Controller<

let invalidResults = false;
let results;
let resultCache: ResultCache;
if (cacheResults === undefined && endpoint.schema !== undefined) {
results = inferResults(
endpoint.schema,
args,
state.indexes,
state.entities,
);
if (!this.globalCache.inputEndpointCache[key])
this.globalCache.inputEndpointCache[key] = inferResults(
endpoint.schema,
args,
state.indexes,
state.entities,
);
results = this.globalCache.inputEndpointCache[key];

invalidResults = !validateInference(results);
if (!expiresAt && invalidResults) expiresAt = 1;
resultCache = this.globalCache.infer;
} else {
results = cacheResults;
if (!this.globalCache.results[key])
this.globalCache.results[key] = new WeakEntityMap();
resultCache = this.globalCache.results[key];
}

if (!isActive) {
Expand All @@ -396,19 +430,89 @@ export default class Controller<
};
}

if (!this.globalCache.results[key])
this.globalCache.results[key] = new WeakEntityMap();
return this.getSchemaResponse(
results,
schema as Exclude<E['schema'], undefined>,
args,
state,
expiresAt,
resultCache,
endpoint.invalidIfStale || invalidResults,
meta,
);
}

/**
* Queries the store for a Querable schema
* @see https://dataclient.io/docs/api/Controller#query
*/
query<S extends Queryable>(
schema: S,
...rest: readonly [
...SchemaArgs<S>,
Pick<State<unknown>, 'entities' | 'entityMeta'>,
]
): DenormalizeNullable<S> | undefined {
const state = rest[rest.length - 1] as State<unknown>;
// this is typescript generics breaking
const args: any = rest
.slice(0, rest.length - 1)
.map(ensurePojo) as SchemaArgs<S>;

// MEMOIZE inferResults - vary on schema + args
// NOTE: different orders can result in cache busting here; but since it's just a perf penalty we will allow for now
const key = JSON.stringify(args);
if (!this.globalCache.queries.has(schema)) {
this.globalCache.queries.set(schema, {});
}
const querySchemaCache = this.globalCache.queries.get(schema) as {
[key: string]: unknown;
};
if (!querySchemaCache[key])
querySchemaCache[key] = inferResults(
schema,
args,
state.indexes,
state.entities,
);
const results = querySchemaCache[key];
// END BLOCK

const data = denormalizeCached(
results,
schema,
state.entities,
this.globalCache.entities,
this.globalCache.infer,
args,
).data;
return typeof data === 'symbol' ? undefined : (data as any);
}

private getSchemaResponse<S extends Schema>(
input: any,
schema: S,
args: any,
state: State<unknown>,
expiresAt: number,
resultCache: ResultCache,
invalidIfStale: boolean,
meta: { error?: unknown; invalidated?: unknown } = {},
): {
data: DenormalizeNullable<S>;
expiryStatus: ExpiryStatus;
expiresAt: number;
} {
// second argument is false if any entities are missing
// eslint-disable-next-line prefer-const
const { data, paths } = denormalizeCached(
results,
input,
schema,
state.entities,
this.globalCache.entities,
this.globalCache.results[key],
resultCache,
args,
) as { data: DenormalizeNullable<E['schema']>; paths: Path[] };
) as { data: DenormalizeNullable<S>; paths: Path[] };
const invalidDenormalize = typeof data === 'symbol';

// fallback to entity expiry time
Expand All @@ -422,12 +526,11 @@ export default class Controller<
const expiryStatus =
meta?.invalidated || (invalidDenormalize && !meta?.error) ?
ExpiryStatus.Invalid
: invalidDenormalize || endpoint.invalidIfStale || invalidResults ?
ExpiryStatus.InvalidIfStale
: invalidDenormalize || invalidIfStale ? ExpiryStatus.InvalidIfStale
: ExpiryStatus.Valid;

return { data, expiryStatus, expiresAt };
};
}
}

// benchmark: https://www.measurethat.net/Benchmarks/Show/24691/0/min-reducer-vs-imperative-with-paths
Expand Down Expand Up @@ -487,19 +590,38 @@ class Snapshot<T = unknown> implements SnapshotInterface {

/*************** Data Access ***************/
/** @see https://dataclient.io/docs/api/Snapshot#getResponse */
getResponse = <
getResponse<E extends EndpointInterface>(
endpoint: E,
...args: readonly [null]
): {
data: DenormalizeNullable<E['schema']>;
expiryStatus: ExpiryStatus;
expiresAt: number;
};

getResponse<E extends EndpointInterface>(
endpoint: E,
...args: readonly [...Parameters<E>]
): {
data: DenormalizeNullable<E['schema']>;
expiryStatus: ExpiryStatus;
expiresAt: number;
};

getResponse<
E extends Pick<EndpointInterface, 'key' | 'schema' | 'invalidIfStale'>,
Args extends readonly [...Parameters<E['key']>],
>(
endpoint: E,
...args: Args
...args: readonly [...Parameters<E['key']>] | readonly [null]
): {
data: DenormalizeNullable<E['schema']>;
expiryStatus: ExpiryStatus;
expiresAt: number;
} => {
} {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
return this.controller.getResponse(endpoint, ...args, this.state);
};
}

/** @see https://dataclient.io/docs/api/Snapshot#getError */
getError = <
Expand All @@ -511,4 +633,15 @@ class Snapshot<T = unknown> implements SnapshotInterface {
): ErrorTypes | undefined => {
return this.controller.getError(endpoint, ...args, this.state);
};

/**
* Queries the store for a Querable schema
* @see https://dataclient.io/docs/api/Snapshot#query
*/
query<S extends Queryable>(
schema: S,
...args: SchemaArgs<S>
): DenormalizeNullable<S> | undefined {
return this.controller.query(schema, ...args, this.state);
}
}
5 changes: 4 additions & 1 deletion packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,16 @@ export type {
EndpointInterface,
EntityInterface,
ResolveType,
DenormalizeCache,
EntityCache,
ResultCache,
DenormalizeNullable,
Denormalize,
Normalize,
NormalizeNullable,
FetchFunction,
EndpointExtraOptions,
Queryable,
SchemaArgs,
} from '@data-client/normalizr';
export { ExpiryStatus } from '@data-client/normalizr';
export {
Expand Down
21 changes: 21 additions & 0 deletions packages/core/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@ import { NormalizedIndex } from '@data-client/normalizr';
import type {
UpdateFunction,
AbstractInstanceType,
EntityCache,
ResultCache,
Queryable,
} from '@data-client/normalizr';
import type { ErrorTypes } from '@data-client/normalizr';

Expand Down Expand Up @@ -44,6 +47,24 @@ export interface State<T> {
readonly lastReset: number;
}

export interface DenormalizeCache {
entities: EntityCache;
results: {
[key: string]: ResultCache;
};
infer: ResultCache;

inputEndpointCache: {
[key: string]: unknown;
};
queries: Map<
Queryable,
{
[key: string]: unknown;
}
>;
}

export * from './actions.js';

export interface Manager<Actions = ActionTypes> {
Expand Down
7 changes: 4 additions & 3 deletions packages/endpoint/src/queryEndpoint.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type {
EntityTable,
NormalizedIndex,
Queryable,
SchemaSimple,
} from './interface.js';
import type { Denormalize, SchemaArgs } from './normal.js';
Expand All @@ -10,7 +11,7 @@ import type { Denormalize, SchemaArgs } from './normal.js';
* @see https://dataclient.io/rest/api/Query
*/
export class Query<
S extends SchemaSimple,
S extends Queryable,
P extends SchemaArgs<S> = SchemaArgs<S>,
R = Denormalize<S>,
> {
Expand All @@ -30,7 +31,7 @@ export class Query<
return `QUERY ${JSON.stringify(args)}`;
}

protected createQuerySchema(schema: SchemaSimple) {
protected createQuerySchema(schema: S) {
const query = Object.create(schema);
query.denormalize = (input: any, args: P, unvisit: any) => {
const value = unvisit(input, schema);
Expand All @@ -44,7 +45,7 @@ export class Query<
args: any,
indexes: any,
recurse: (
schema: SchemaSimple,
schema: any,
args: any[],
indexes: NormalizedIndex,
entities: EntityTable,
Expand Down
Loading

1 comment on commit 96f5edf

@github-actions
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Benchmark

Benchmark suite Current: 96f5edf Previous: 5771da3 Ratio
normalizeLong 364 ops/sec (±2.75%) 447 ops/sec (±1.68%) 1.23
infer All 9729 ops/sec (±0.63%) 9629 ops/sec (±1.67%) 0.99
denormalizeLong 310 ops/sec (±2.27%) 321 ops/sec (±2.22%) 1.04
denormalizeLong donotcache 815 ops/sec (±0.22%) 888 ops/sec (±0.46%) 1.09
denormalizeShort donotcache 500x 1344 ops/sec (±0.29%) 1351 ops/sec (±0.13%) 1.01
denormalizeShort 500x 948 ops/sec (±0.64%) 958 ops/sec (±0.24%) 1.01
denormalizeLong with mixin Entity 300 ops/sec (±1.15%) 303 ops/sec (±0.26%) 1.01
denormalizeLong withCache 7570 ops/sec (±0.23%) 6836 ops/sec (±0.26%) 0.90
denormalizeLongAndShort withEntityCacheOnly 1600 ops/sec (±0.30%) 1562 ops/sec (±0.31%) 0.98
denormalizeLong All withCache 7141 ops/sec (±0.14%) 6337 ops/sec (±0.17%) 0.89
denormalizeLong Query-sorted withCache 6949 ops/sec (±0.26%) 6647 ops/sec (±0.38%) 0.96
getResponse 5453 ops/sec (±1.16%) 4951 ops/sec (±0.88%) 0.91
getResponse (null) 3841228 ops/sec (±0.35%) 2888601 ops/sec (±0.21%) 0.75
getResponse (clear cache) 301 ops/sec (±0.37%) 292 ops/sec (±1.10%) 0.97
getSmallResponse 2268 ops/sec (±0.25%) 2328 ops/sec (±0.30%) 1.03
getSmallInferredResponse 1993 ops/sec (±0.21%) 1765 ops/sec (±0.34%) 0.89
getResponse Query-sorted 3057 ops/sec (±0.33%) 687 ops/sec (±1.09%) 0.22
getResponse Collection 5263 ops/sec (±1.24%) 5124 ops/sec (±1.06%) 0.97
setLong 374 ops/sec (±3.04%) 437 ops/sec (±2.25%) 1.17
setLongWithMerge 176 ops/sec (±1.67%) 189 ops/sec (±0.36%) 1.07
setLongWithSimpleMerge 186 ops/sec (±2.05%) 202 ops/sec (±0.27%) 1.09

This comment was automatically generated by workflow using github-action-benchmark.

Please sign in to comment.