diff options
Diffstat (limited to 'devtools/client/debugger/src/utils/resource')
11 files changed, 2337 insertions, 0 deletions
diff --git a/devtools/client/debugger/src/utils/resource/base-query.js b/devtools/client/debugger/src/utils/resource/base-query.js new file mode 100644 index 0000000000..fda49b0901 --- /dev/null +++ b/devtools/client/debugger/src/utils/resource/base-query.js @@ -0,0 +1,174 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow + +import { + getResourceValues, + getValidatedResource, + makeIdentity, + type ResourceBound, + type Id, + type ResourceState, + type ResourceValues, + type ResourceIdentity, +} from "./core"; +import { arrayShallowEqual } from "./compare"; + +export type ResourceQuery<R: ResourceBound, Args, Reduced> = ( + ResourceState<R>, + Args +) => Reduced; + +export type QueryFilter<R: ResourceBound, Args> = ( + ResourceValues<R>, + Args +) => Array<Id<R>>; + +export type QueryMap<R: ResourceBound, Args, Mapped> = + | QueryMapNoArgs<R, Mapped> + | QueryMapWithArgs<R, Args, Mapped>; +export type QueryMapNoArgs<R: ResourceBound, Mapped> = { + +needsArgs?: false, + (R, ResourceIdentity): Mapped, +}; +export type QueryMapWithArgs<R: ResourceBound, Args, Mapped> = { + +needsArgs: true, + (R, ResourceIdentity, Args): Mapped, +}; + +export type QueryReduce<R: ResourceBound, Args, Mapped, Reduced> = ( + $ReadOnlyArray<Mapped>, + $ReadOnlyArray<Id<R>>, + Args +) => Reduced; + +export type QueryContext<Args> = { + args: Args, + identMap: WeakMap<ResourceIdentity, ResourceIdentity>, +}; +export type QueryResult<Mapped, Reduced> = { + mapped: Array<Mapped>, + reduced: Reduced, +}; +export type QueryResultCompare<Reduced> = (Reduced, Reduced) => boolean; + +export type QueryCacheHandler<R: ResourceBound, Args, Mapped, Reduced> = ( + ResourceState<R>, + QueryContext<Args>, + QueryResult<Mapped, Reduced> | null +) => QueryResult<Mapped, Reduced>; + +export type QueryCache<R: ResourceBound, Args, Mapped, Reduced> = ( + handler: QueryCacheHandler<R, Args, Mapped, Reduced> +) => ResourceQuery<R, Args, Reduced>; + +export function makeMapWithArgs<R: ResourceBound, Args, Mapped>( + map: (R, ResourceIdentity, Args) => Mapped +): QueryMapWithArgs<R, Args, Mapped> { + const wrapper = (resource, identity, args) => map(resource, identity, args); + wrapper.needsArgs = true; + return wrapper; +} + +export function makeResourceQuery<R: ResourceBound, Args, Mapped, Reduced>({ + cache, + filter, + map, + reduce, + resultCompare, +}: {| + cache: QueryCache<R, Args, Mapped, Reduced>, + filter: QueryFilter<R, Args>, + map: QueryMap<R, Args, Mapped>, + reduce: QueryReduce<R, Args, Mapped, Reduced>, + resultCompare: QueryResultCompare<Reduced>, +|}): ResourceQuery<R, Args, Reduced> { + const loadResource = makeResourceMapper(map); + + return cache((state, context, existing) => { + const ids = filter(getResourceValues(state), context.args); + const mapped = ids.map(id => loadResource(state, id, context)); + + if (existing && arrayShallowEqual(existing.mapped, mapped)) { + // If the items are exactly the same as the existing ones, we return + // early to reuse the existing result. + return existing; + } + + const reduced = reduce(mapped, ids, context.args); + + if (existing && resultCompare(existing.reduced, reduced)) { + return existing; + } + + return { mapped, reduced }; + }); +} + +type ResourceLoader<R: ResourceBound, Args, Mapped> = ( + ResourceState<R>, + Id<R>, + QueryContext<Args> +) => Mapped; + +function makeResourceMapper<R: ResourceBound, Args, Mapped>( + map: QueryMap<R, Args, Mapped> +): ResourceLoader<R, Args, Mapped> { + return map.needsArgs + ? makeResourceArgsMapper(map) + : makeResourceNoArgsMapper(map); +} + +/** + * Resources loaded when things care about arguments need to be given a + * special ResourceIdentity object that correlates with both the resource + * _and_ the arguments being passed to the query. That means they need extra + * logic when loading those resources. + */ +function makeResourceArgsMapper<R: ResourceBound, Args, Mapped>( + map: QueryMapWithArgs<R, Args, Mapped> +): ResourceLoader<R, Args, Mapped> { + const mapper = (value, identity, context) => + map(value, getIdentity(context.identMap, identity), context.args); + return (state, id, context) => getCachedResource(state, id, context, mapper); +} + +function makeResourceNoArgsMapper<R: ResourceBound, Args, Mapped>( + map: QueryMapNoArgs<R, Mapped> +): ResourceLoader<R, Args, Mapped> { + const mapper = (value, identity, context) => map(value, identity); + return (state, id, context) => getCachedResource(state, id, context, mapper); +} + +function getCachedResource<R: ResourceBound, Args, Mapped>( + state: ResourceState<R>, + id: Id<R>, + context: QueryContext<Args>, + map: ( + value: R, + identity: ResourceIdentity, + context: QueryContext<Args> + ) => Mapped +): Mapped { + const validatedState = getValidatedResource(state, id); + if (!validatedState) { + throw new Error(`Resource ${id} does not exist`); + } + + return map(validatedState.values[id], validatedState.identity[id], context); +} + +function getIdentity( + identMap: WeakMap<ResourceIdentity, ResourceIdentity>, + identity: ResourceIdentity +) { + let ident = identMap.get(identity); + if (!ident) { + ident = makeIdentity(); + identMap.set(identity, ident); + } + + return ident; +} diff --git a/devtools/client/debugger/src/utils/resource/compare.js b/devtools/client/debugger/src/utils/resource/compare.js new file mode 100644 index 0000000000..5320e1f067 --- /dev/null +++ b/devtools/client/debugger/src/utils/resource/compare.js @@ -0,0 +1,44 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow + +export function strictEqual(value: mixed, other: mixed): boolean { + return value === other; +} + +export function shallowEqual(value: mixed, other: mixed): boolean { + return ( + value === other || + (Array.isArray(value) && + Array.isArray(other) && + arrayShallowEqual(value, other)) || + (isObject(value) && isObject(other) && objectShallowEqual(value, other)) + ); +} + +export function arrayShallowEqual( + value: $ReadOnlyArray<mixed>, + other: $ReadOnlyArray<mixed> +): boolean { + return value.length === other.length && value.every((k, i) => k === other[i]); +} + +function objectShallowEqual( + value: { [string]: mixed }, + other: { [string]: mixed } +): boolean { + const existingKeys = Object.keys(other); + const keys = Object.keys(value); + + return ( + keys.length === existingKeys.length && + keys.every((k, i) => k === existingKeys[i]) && + keys.every(k => value[k] === other[k]) + ); +} + +function isObject(value: mixed): boolean %checks { + return typeof value === "object" && !!value; +} diff --git a/devtools/client/debugger/src/utils/resource/core.js b/devtools/client/debugger/src/utils/resource/core.js new file mode 100644 index 0000000000..dad5337447 --- /dev/null +++ b/devtools/client/debugger/src/utils/resource/core.js @@ -0,0 +1,180 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow + +export type Resource<R: ResourceBound> = $ReadOnly<$Exact<R>>; + +export type ResourceBound = { + +id: string, +}; +export type Id<R: ResourceBound> = $ElementType<R, "id">; + +type ResourceSubset<R: ResourceBound> = $ReadOnly<{ + +id: Id<R>, + ...$Shape<$Rest<R, { +id: Id<R> }>>, +}>; + +export opaque type ResourceIdentity: { [string]: mixed } = {||}; +export type ResourceValues<R: ResourceBound> = { [Id<R>]: R }; + +export opaque type ResourceState<R: ResourceBound> = { + identity: { [Id<R>]: ResourceIdentity }, + values: ResourceValues<R>, +}; + +export function createInitial<R: ResourceBound>(): ResourceState<R> { + return { + identity: {}, + values: {}, + }; +} + +export function insertResources<R: ResourceBound>( + state: ResourceState<R>, + resources: $ReadOnlyArray<R> +): ResourceState<R> { + if (resources.length === 0) { + return state; + } + + state = { + identity: { ...state.identity }, + values: { ...state.values }, + }; + + for (const resource of resources) { + const { id } = resource; + if (state.identity[id]) { + throw new Error( + `Resource "${id}" already exists, cannot insert ${JSON.stringify( + resource + )}` + ); + } + if (state.values[id]) { + throw new Error( + `Resource state corrupt: ${id} has value but no identity` + ); + } + + state.identity[resource.id] = makeIdentity(); + state.values[resource.id] = resource; + } + return state; +} + +export function removeResources<R: ResourceBound>( + state: ResourceState<R>, + resources: $ReadOnlyArray<ResourceSubset<R> | Id<R>> +): ResourceState<R> { + if (resources.length === 0) { + return state; + } + + state = { + identity: { ...state.identity }, + values: { ...state.values }, + }; + + for (let id of resources) { + if (typeof id !== "string") { + id = id.id; + } + + if (!state.identity[id]) { + throw new Error(`Resource "${id}" does not exists, cannot remove`); + } + if (!state.values[id]) { + throw new Error( + `Resource state corrupt: ${id} has identity but no value` + ); + } + + delete state.identity[id]; + delete state.values[id]; + } + return state; +} + +export function updateResources<R: ResourceBound>( + state: ResourceState<R>, + resources: $ReadOnlyArray<ResourceSubset<R>> +): ResourceState<R> { + if (resources.length === 0) { + return state; + } + + let didCopyValues = false; + + for (const subset of resources) { + const { id } = subset; + + if (!state.identity[id]) { + throw new Error(`Resource "${id}" does not exists, cannot update`); + } + if (!state.values[id]) { + throw new Error( + `Resource state corrupt: ${id} has identity but no value` + ); + } + + const existing = state.values[id]; + const updated = {}; + + for (const field of Object.keys(subset)) { + if (field === "id") { + continue; + } + + if (subset[field] !== existing[field]) { + updated[field] = subset[field]; + } + } + + if (Object.keys(updated).length > 0) { + if (!didCopyValues) { + didCopyValues = true; + state = { + identity: state.identity, + values: { ...state.values }, + }; + } + + state.values[id] = { ...existing, ...updated }; + } + } + + return state; +} + +export function makeIdentity(): ResourceIdentity { + return ({}: any); +} + +export function getValidatedResource<R: ResourceBound>( + state: ResourceState<R>, + id: Id<R> +): + | (ResourceState<R> & { + values: Array<R>, + identity: Array<string>, + }) + | null { + const value = state.values[id]; + const identity = state.identity[id]; + if ((value && !identity) || (!value && identity)) { + throw new Error( + `Resource state corrupt: ${id} has mismatched value and identity` + ); + } + + return value ? (state: any) : null; +} + +export function getResourceValues<R: ResourceBound>( + state: ResourceState<R> +): ResourceValues<R> { + return state.values; +} diff --git a/devtools/client/debugger/src/utils/resource/index.js b/devtools/client/debugger/src/utils/resource/index.js new file mode 100644 index 0000000000..fb8b917d2d --- /dev/null +++ b/devtools/client/debugger/src/utils/resource/index.js @@ -0,0 +1,74 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow + +export { + createInitial, + insertResources, + removeResources, + updateResources, +} from "./core"; +export type { + Id, + Resource, + ResourceBound, + // Disabled pending eslint-plugin-import bug #1345 + // eslint-disable-next-line import/named + ResourceState, + // Disabled pending eslint-plugin-import bug #1345 + // eslint-disable-next-line import/named + ResourceIdentity, + ResourceValues, +} from "./core"; + +export { + hasResource, + getResourceIds, + getResource, + getMappedResource, +} from "./selector"; +export type { ResourceMap } from "./selector"; + +export { makeResourceQuery, makeMapWithArgs } from "./base-query"; +export type { + ResourceQuery, + QueryMap, + QueryMapNoArgs, + QueryMapWithArgs, + QueryFilter, + QueryReduce, + QueryResultCompare, +} from "./base-query"; + +export { + filterAllIds, + makeWeakQuery, + makeShallowQuery, + makeStrictQuery, + makeIdQuery, + makeLoadQuery, + makeFilterQuery, + makeReduceQuery, + makeReduceAllQuery, +} from "./query"; +export type { + WeakQuery, + ShallowQuery, + StrictQuery, + IdQuery, + LoadQuery, + FilterQuery, + ReduceQuery, + ReduceAllQuery, +} from "./query"; + +export { + queryCacheWeak, + queryCacheShallow, + queryCacheStrict, +} from "./query-cache"; +export type { WeakArgsBound, ShallowArgsBound } from "./query-cache"; + +export { memoizeResourceShallow } from "./memoize"; diff --git a/devtools/client/debugger/src/utils/resource/memoize.js b/devtools/client/debugger/src/utils/resource/memoize.js new file mode 100644 index 0000000000..b00501b4df --- /dev/null +++ b/devtools/client/debugger/src/utils/resource/memoize.js @@ -0,0 +1,54 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow + +import type { ResourceBound } from "./core"; +import type { QueryMap } from "./base-query"; +import { shallowEqual } from "./compare"; + +/** + * Wraps a 'mapper' function to create a shallow-equality memoized version + * of the mapped result. The returned function will return the same value + * even if the input object is different, as long as the identity is the same + * and the mapped result is shallow-equal to the most recent mapped value. + */ +export function memoizeResourceShallow< + R: ResourceBound, + Args, + Mapped, + T: QueryMap<R, Args, Mapped> +>(map: T): T { + const cache = new WeakMap(); + + const fn = (input, identity, args) => { + let existingEntry = cache.get(identity); + + if (!existingEntry || existingEntry.input !== input) { + const mapper = (map: any); + const output = mapper(input, identity, args); + + if (existingEntry) { + // If the new output is shallow-equal to the old output, we reuse + // the previous object instead to preserve object equality. + const newOutput = shallowEqual(output, existingEntry.output) + ? existingEntry.output + : output; + + existingEntry.output = newOutput; + existingEntry.input = input; + } else { + existingEntry = { + input, + output, + }; + cache.set(identity, existingEntry); + } + } + + return existingEntry.output; + }; + fn.needsArgs = map.needsArgs; + return (fn: any); +} diff --git a/devtools/client/debugger/src/utils/resource/moz.build b/devtools/client/debugger/src/utils/resource/moz.build new file mode 100644 index 0000000000..7fa8b2a810 --- /dev/null +++ b/devtools/client/debugger/src/utils/resource/moz.build @@ -0,0 +1,17 @@ +# vim: set filetype=python: +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +DIRS += [] + +CompiledModules( + "base-query.js", + "compare.js", + "core.js", + "index.js", + "memoize.js", + "query-cache.js", + "query.js", + "selector.js", +) diff --git a/devtools/client/debugger/src/utils/resource/query-cache.js b/devtools/client/debugger/src/utils/resource/query-cache.js new file mode 100644 index 0000000000..6fa8c922ad --- /dev/null +++ b/devtools/client/debugger/src/utils/resource/query-cache.js @@ -0,0 +1,148 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow + +import type { ResourceBound, ResourceState } from "./core"; +import type { + ResourceQuery, + QueryCacheHandler, + QueryContext, + QueryResult, +} from "./base-query"; +import { strictEqual, shallowEqual } from "./compare"; + +export type WeakArgsBound = + | $ReadOnly<{ [string]: mixed }> + | $ReadOnlyArray<mixed>; + +export type ShallowArgsBound = + | $ReadOnly<{ [string]: mixed }> + | $ReadOnlyArray<mixed>; + +/** + * A query 'cache' function that uses the identity of the arguments object to + * cache data for the query itself. + */ +export function queryCacheWeak< + R: ResourceBound, + Args: WeakArgsBound, + Mapped, + Reduced +>( + handler: QueryCacheHandler<R, Args, Mapped, Reduced> +): ResourceQuery<R, Args, Reduced> { + const cache = new WeakMap(); + return makeCacheFunction({ + handler, + // The WeakMap will only return entries for the exact object, + // so there is no need to compare at all. + compareArgs: () => true, + getEntry: args => cache.get(args) || null, + setEntry: (args, entry) => { + cache.set(args, entry); + }, + }); +} + +/** + * A query 'cache' function that uses shallow comparison to cache the most + * recent calculated result based on the value of the argument. + */ +export function queryCacheShallow< + R: ResourceBound, + // We require args to be an object here because if you're using a primitive + // then you should be using queryCacheStrict instead. + Args: ShallowArgsBound, + Mapped, + Reduced +>( + handler: QueryCacheHandler<R, Args, Mapped, Reduced> +): ResourceQuery<R, Args, Reduced> { + let latestEntry = null; + return makeCacheFunction({ + handler, + compareArgs: shallowEqual, + getEntry: () => latestEntry, + setEntry: (args, entry) => { + latestEntry = entry; + }, + }); +} + +/** + * A query 'cache' function that uses strict comparison to cache the most + * recent calculated result based on the value of the argument. + */ +export function queryCacheStrict<R: ResourceBound, Args, Mapped, Reduced>( + handler: QueryCacheHandler<R, Args, Mapped, Reduced> +): ResourceQuery<R, Args, Reduced> { + let latestEntry = null; + return makeCacheFunction({ + handler, + compareArgs: strictEqual, + getEntry: () => latestEntry, + setEntry: (args, entry) => { + latestEntry = entry; + }, + }); +} + +type CacheEntry<R: ResourceBound, Args, Mapped, Reduced> = { + context: QueryContext<Args>, + state: ResourceState<R>, + result: QueryResult<Mapped, Reduced>, +}; + +type CacheFunctionInfo<R: ResourceBound, Args, Mapped, Reduced> = {| + // The handler to call when the args or the state are different from + // those in the entry for the arguments. + handler: QueryCacheHandler<R, Args, Mapped, Reduced>, + + // Compare two sets of arguments to decide whether or not they should be + // treated as the same set of arguments from the standpoint of caching. + compareArgs: (a: Args, b: Args) => boolean, + + getEntry: (args: Args) => CacheEntry<R, Args, Mapped, Reduced> | null, + setEntry: (args: Args, entry: CacheEntry<R, Args, Mapped, Reduced>) => void, +|}; +function makeCacheFunction<R: ResourceBound, Args, Mapped, Reduced>( + info: CacheFunctionInfo<R, Args, Mapped, Reduced> +): ResourceQuery<R, Args, Reduced> { + const { handler, compareArgs, getEntry, setEntry } = info; + + return (state, args: Args) => { + let entry = getEntry(args); + + const sameArgs = !!entry && compareArgs(entry.context.args, args); + const sameState = !!entry && entry.state === state; + + if (!entry || !sameArgs || !sameState) { + const context = + !entry || !sameArgs + ? { + args, + identMap: new WeakMap(), + } + : entry.context; + + const result = handler(state, context, entry ? entry.result : null); + + if (entry) { + entry.context = context; + entry.state = state; + entry.result = result; + } else { + entry = { + context, + state, + result, + }; + setEntry(args, entry); + } + } + + return entry.result.reduced; + }; +} diff --git a/devtools/client/debugger/src/utils/resource/query.js b/devtools/client/debugger/src/utils/resource/query.js new file mode 100644 index 0000000000..b2edd0f5e5 --- /dev/null +++ b/devtools/client/debugger/src/utils/resource/query.js @@ -0,0 +1,245 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow + +import type { ResourceBound, Id, ResourceValues } from "./core"; + +import { + makeResourceQuery, + type ResourceQuery, + type QueryFilter, + type QueryMap, + type QueryReduce, +} from "./base-query"; + +import { + queryCacheWeak, + queryCacheShallow, + queryCacheStrict, + type WeakArgsBound, + type ShallowArgsBound, +} from "./query-cache"; + +import { memoizeResourceShallow } from "./memoize"; +import { shallowEqual } from "./compare"; + +export function filterAllIds<R: ResourceBound>( + values: ResourceValues<R> +): Array<Id<R>> { + return Object.keys(values); +} + +/** + * Create a query function to take a list of IDs and map each Reduceding + * resource object into a mapped form. + */ +export type WeakQuery< + R: ResourceBound, + Args: WeakArgsBound, + Reduced +> = ResourceQuery<R, Args, Reduced>; +export function makeWeakQuery< + R: ResourceBound, + Args: WeakArgsBound, + Mapped, + Reduced +>({ + filter, + map, + reduce, +}: {| + filter: QueryFilter<R, Args>, + map: QueryMap<R, Args, Mapped>, + reduce: QueryReduce<R, Args, Mapped, Reduced>, +|}): WeakQuery<R, Args, Reduced> { + return makeResourceQuery({ + cache: queryCacheWeak, + filter, + map: memoizeResourceShallow(map), + reduce, + resultCompare: shallowEqual, + }); +} + +/** + * Create a query function to take a list of IDs and map each Reduceding + * resource object into a mapped form. + */ +export type ShallowQuery<R: ResourceBound, Args, Reduced> = ResourceQuery< + R, + Args, + Reduced +>; +export function makeShallowQuery< + R: ResourceBound, + Args: ShallowArgsBound, + Mapped, + Reduced +>({ + filter, + map, + reduce, +}: {| + filter: QueryFilter<R, Args>, + map: QueryMap<R, Args, Mapped>, + reduce: QueryReduce<R, Args, Mapped, Reduced>, +|}): ShallowQuery<R, Args, Reduced> { + return makeResourceQuery({ + cache: queryCacheShallow, + filter, + map: memoizeResourceShallow(map), + reduce, + resultCompare: shallowEqual, + }); +} + +/** + * Create a query function to take a list of IDs and map each Reduceding + * resource object into a mapped form. + */ +export type StrictQuery<R: ResourceBound, Args, Reduced> = ResourceQuery< + R, + Args, + Reduced +>; +export function makeStrictQuery<R: ResourceBound, Args, Mapped, Reduced>({ + filter, + map, + reduce, +}: {| + filter: QueryFilter<R, Args>, + map: QueryMap<R, Args, Mapped>, + reduce: QueryReduce<R, Args, Mapped, Reduced>, +|}): StrictQuery<R, Args, Reduced> { + return makeResourceQuery({ + cache: queryCacheStrict, + filter, + map: memoizeResourceShallow(map), + reduce, + resultCompare: shallowEqual, + }); +} + +/** + * Create a query function to take a list of IDs and map each Reduceding + * resource object into a mapped form. + */ +export type IdQuery<R: ResourceBound, Mapped> = WeakQuery< + R, + Array<Id<R>>, + Array<Mapped> +>; +export function makeIdQuery<R: ResourceBound, Mapped>( + map: QueryMap<R, void, Mapped> +): IdQuery<R, Mapped> { + return makeWeakQuery({ + filter: (state, ids) => ids, + map: (r, identity) => map(r, identity), + reduce: items => items.slice(), + }); +} + +/** + * Create a query function to take a list of IDs and map each Reduceding + * resource object into a mapped form. + */ +export type LoadQuery<R: ResourceBound, Mapped> = WeakQuery< + R, + Array<Id<R>>, + $ReadOnly<{ [Id<R>]: Mapped }> +>; +export function makeLoadQuery<R: ResourceBound, Mapped>( + map: QueryMap<R, void, Mapped> +): LoadQuery<R, Mapped> { + return makeWeakQuery({ + filter: (state, ids) => ids, + map: (r, identity) => map(r, identity), + reduce: reduceMappedArrayToObject, + }); +} + +/** + * Create a query function that accepts an argument and can filter the + * resource items to a subset before mapping each reduced resource. + */ +export type FilterQuery< + R: ResourceBound, + Args: WeakArgsBound, + Mapped +> = WeakQuery<R, Args, $ReadOnly<{ [Id<R>]: Mapped }>>; +export function makeFilterQuery<R: ResourceBound, Args: WeakArgsBound, Mapped>( + filter: (R, Args) => boolean, + map: QueryMap<R, Args, Mapped> +): FilterQuery<R, Args, Mapped> { + return makeWeakQuery({ + filter: (values, args) => { + const ids = []; + for (const id of Object.keys(values)) { + if (filter(values[id], args)) { + ids.push(id); + } + } + return ids; + }, + map, + reduce: reduceMappedArrayToObject, + }); +} + +/** + * Create a query function that accepts an argument and can filter the + * resource items to a subset before mapping each resulting resource. + */ +export type ReduceQuery< + R: ResourceBound, + Args: ShallowArgsBound, + Reduced +> = ShallowQuery<R, Args, Reduced>; +export function makeReduceQuery< + R: ResourceBound, + Args: ShallowArgsBound, + Mapped, + Reduced +>( + map: QueryMap<R, Args, Mapped>, + reduce: QueryReduce<R, Args, Mapped, Reduced> +): ReduceQuery<R, Args, Reduced> { + return makeShallowQuery({ + filter: filterAllIds, + map, + reduce, + }); +} + +/** + * Create a query function that accepts an argument and can filter the + * resource items to a subset before mapping each resulting resource. + */ +export type ReduceAllQuery<R: ResourceBound, Reduced> = ShallowQuery< + R, + void, + Reduced +>; +export function makeReduceAllQuery<R: ResourceBound, Mapped, Reduced>( + map: QueryMap<R, void, Mapped>, + reduce: QueryReduce<R, void, Mapped, Reduced> +): ReduceAllQuery<R, Reduced> { + return makeStrictQuery({ + filter: filterAllIds, + map, + reduce, + }); +} + +function reduceMappedArrayToObject<Args, ID, Mapped>( + items: $ReadOnlyArray<Mapped>, + ids: $ReadOnlyArray<ID>, + args: Args +): { [ID]: Mapped } { + return items.reduce((acc: { [ID]: Mapped }, item, i) => { + acc[ids[i]] = item; + return acc; + }, {}); +} diff --git a/devtools/client/debugger/src/utils/resource/selector.js b/devtools/client/debugger/src/utils/resource/selector.js new file mode 100644 index 0000000000..df5fae4422 --- /dev/null +++ b/devtools/client/debugger/src/utils/resource/selector.js @@ -0,0 +1,56 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow + +import { + getValidatedResource, + getResourceValues, + type ResourceState, + type Id, + type ResourceBound, + type ResourceIdentity, +} from "./core"; + +export type ResourceMap<R: ResourceBound, Mapped> = ( + R, + ResourceIdentity +) => Mapped; + +export function hasResource<R: ResourceBound>( + state: ResourceState<R>, + id: Id<R> +): boolean %checks { + return !!getValidatedResource(state, id); +} + +export function getResourceIds<R: ResourceBound>( + state: ResourceState<R> +): Array<Id<R>> { + return Object.keys(getResourceValues(state)); +} + +export function getResource<R: ResourceBound>( + state: ResourceState<R>, + id: Id<R> +): R { + const validatedState = getValidatedResource(state, id); + if (!validatedState) { + throw new Error(`Resource ${id} does not exist`); + } + return validatedState.values[id]; +} + +export function getMappedResource<R: ResourceBound, Mapped>( + state: ResourceState<R>, + id: Id<R>, + map: ResourceMap<R, Mapped> +): Mapped { + const validatedState = getValidatedResource(state, id); + if (!validatedState) { + throw new Error(`Resource ${id} does not exist`); + } + + return map(validatedState.values[id], validatedState.identity[id]); +} diff --git a/devtools/client/debugger/src/utils/resource/tests/crud.spec.js b/devtools/client/debugger/src/utils/resource/tests/crud.spec.js new file mode 100644 index 0000000000..474323e6e5 --- /dev/null +++ b/devtools/client/debugger/src/utils/resource/tests/crud.spec.js @@ -0,0 +1,266 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow +declare var describe: (name: string, func: () => void) => void; +declare var it: (desc: string, func: () => void) => void; +declare var expect: (value: any) => any; + +import { + createInitial, + insertResources, + removeResources, + updateResources, + hasResource, + getResourceIds, + getResource, + getMappedResource, + type Resource, + type ResourceIdentity, +} from ".."; + +type TestResource = Resource<{ + id: string, + name: string, + data: number, + obj: {}, +}>; + +const makeResource = (id: string): TestResource => ({ + id, + name: `name-${id}`, + data: 42, + obj: {}, +}); + +const mapName = (resource: TestResource) => resource.name; +const mapWithIdent = (resource: TestResource, identity: ResourceIdentity) => ({ + resource, + identity, + obj: {}, +}); + +const clone = <T>(v: T): T => (JSON.parse((JSON.stringify(v): any)): any); + +describe("resource CRUD operations", () => { + let r1, r2, r3; + let originalInitial; + let initialState; + beforeEach(() => { + r1 = makeResource("id-1"); + r2 = makeResource("id-2"); + r3 = makeResource("id-3"); + + initialState = createInitial(); + originalInitial = clone(initialState); + }); + + describe("insert", () => { + it("should work", () => { + const state = insertResources(initialState, [r1, r2, r3]); + + expect(initialState).toEqual(originalInitial); + expect(getResource(state, r1.id)).toBe(r1); + expect(getResource(state, r2.id)).toBe(r2); + expect(getResource(state, r3.id)).toBe(r3); + }); + + it("should throw on duplicate", () => { + const state = insertResources(initialState, [r1]); + expect(() => { + insertResources(state, [r1]); + }).toThrow(/already exists/); + + expect(() => { + insertResources(state, [r2, r2]); + }).toThrow(/already exists/); + }); + + it("should be a no-op when given no resources", () => { + const state = insertResources(initialState, []); + + expect(state).toBe(initialState); + }); + }); + + describe("read", () => { + beforeEach(() => { + initialState = insertResources(initialState, [r1, r2, r3]); + }); + + it("should allow reading all IDs", () => { + expect(getResourceIds(initialState)).toEqual([r1.id, r2.id, r3.id]); + }); + + it("should allow checking for existing of an ID", () => { + expect(hasResource(initialState, r1.id)).toBe(true); + expect(hasResource(initialState, r2.id)).toBe(true); + expect(hasResource(initialState, r3.id)).toBe(true); + expect(hasResource(initialState, "unknownId")).toBe(false); + }); + + it("should allow reading an item", () => { + expect(getResource(initialState, r1.id)).toBe(r1); + expect(getResource(initialState, r2.id)).toBe(r2); + expect(getResource(initialState, r3.id)).toBe(r3); + + expect(() => { + getResource(initialState, "unknownId"); + }).toThrow(/does not exist/); + }); + + it("should allow reading and mapping an item", () => { + expect(getMappedResource(initialState, r1.id, mapName)).toBe(r1.name); + expect(getMappedResource(initialState, r2.id, mapName)).toBe(r2.name); + expect(getMappedResource(initialState, r3.id, mapName)).toBe(r3.name); + + expect(() => { + getMappedResource(initialState, "unknownId", mapName); + }).toThrow(/does not exist/); + }); + + it("should allow reading and mapping an item with identity", () => { + const r1Ident = getMappedResource(initialState, r1.id, mapWithIdent); + const r2Ident = getMappedResource(initialState, r2.id, mapWithIdent); + + const state = updateResources(initialState, [{ ...r1, obj: {} }]); + + const r1NewIdent = getMappedResource(state, r1.id, mapWithIdent); + const r2NewIdent = getMappedResource(state, r2.id, mapWithIdent); + + // The update changed the resource object, but not the identity. + expect(r1NewIdent.resource).not.toBe(r1Ident.resource); + expect(r1NewIdent.resource).toEqual(r1Ident.resource); + expect(r1NewIdent.identity).toBe(r1Ident.identity); + + // The update did not change the r2 resource. + expect(r2NewIdent.resource).toBe(r2Ident.resource); + expect(r2NewIdent.identity).toBe(r2Ident.identity); + }); + }); + + describe("update", () => { + beforeEach(() => { + initialState = insertResources(initialState, [r1, r2, r3]); + originalInitial = clone(initialState); + }); + + it("should work", () => { + const r1Ident = getMappedResource(initialState, r1.id, mapWithIdent); + const r2Ident = getMappedResource(initialState, r2.id, mapWithIdent); + const r3Ident = getMappedResource(initialState, r3.id, mapWithIdent); + + const state = updateResources(initialState, [ + { + id: r1.id, + data: 21, + }, + { + id: r2.id, + name: "newName", + }, + ]); + + expect(initialState).toEqual(originalInitial); + expect(getResource(state, r1.id)).toEqual({ ...r1, data: 21 }); + expect(getResource(state, r2.id)).toEqual({ ...r2, name: "newName" }); + expect(getResource(state, r3.id)).toBe(r3); + + const r1NewIdent = getMappedResource(state, r1.id, mapWithIdent); + const r2NewIdent = getMappedResource(state, r2.id, mapWithIdent); + const r3NewIdent = getMappedResource(state, r3.id, mapWithIdent); + + // The update changed the resource object, but not the identity. + expect(r1NewIdent.resource).not.toBe(r1Ident.resource); + expect(r1NewIdent.resource).toEqual({ + ...r1Ident.resource, + data: 21, + }); + expect(r1NewIdent.identity).toBe(r1Ident.identity); + + // The update changed the resource object, but not the identity. + expect(r2NewIdent.resource).toEqual({ + ...r2Ident.resource, + name: "newName", + }); + expect(r2NewIdent.identity).toBe(r2Ident.identity); + + // The update did not change the r3 resource. + expect(r3NewIdent.resource).toBe(r3Ident.resource); + expect(r3NewIdent.identity).toBe(r3Ident.identity); + }); + + it("should throw if not found", () => { + expect(() => { + updateResources(initialState, [ + { + ...r1, + id: "unknownId", + }, + ]); + }).toThrow(/does not exists/); + }); + + it("should be a no-op when new fields are strict-equal", () => { + const state = updateResources(initialState, [r1]); + expect(state).toBe(initialState); + }); + + it("should be a no-op when given no resources", () => { + const state = updateResources(initialState, []); + expect(state).toBe(initialState); + }); + }); + + describe("delete", () => { + beforeEach(() => { + initialState = insertResources(initialState, [r1, r2, r3]); + originalInitial = clone(initialState); + }); + + it("should work with objects", () => { + const state = removeResources(initialState, [r1]); + + expect(initialState).toEqual(originalInitial); + expect(hasResource(state, r1.id)).toBe(false); + expect(hasResource(state, r2.id)).toBe(true); + expect(hasResource(state, r3.id)).toBe(true); + }); + + it("should work with object subsets", () => { + const state = removeResources(initialState, [{ id: r1.id }]); + + expect(initialState).toEqual(originalInitial); + expect(hasResource(state, r1.id)).toBe(false); + expect(hasResource(state, r2.id)).toBe(true); + expect(hasResource(state, r3.id)).toBe(true); + }); + + it("should work with ids", () => { + const state = removeResources(initialState, [r1.id]); + + expect(initialState).toEqual(originalInitial); + expect(hasResource(state, r1.id)).toBe(false); + expect(hasResource(state, r2.id)).toBe(true); + expect(hasResource(state, r3.id)).toBe(true); + }); + + it("should throw if not found", () => { + expect(() => { + removeResources(initialState, [makeResource("unknownId")]); + }).toThrow(/does not exist/); + expect(() => { + removeResources(initialState, [{ id: "unknownId" }]); + }).toThrow(/does not exist/); + expect(() => { + removeResources(initialState, ["unknownId"]); + }).toThrow(/does not exist/); + }); + + it("should be a no-op when given no resources", () => { + const state = removeResources(initialState, []); + expect(state).toBe(initialState); + }); + }); +}); diff --git a/devtools/client/debugger/src/utils/resource/tests/query.spec.js b/devtools/client/debugger/src/utils/resource/tests/query.spec.js new file mode 100644 index 0000000000..adca73f750 --- /dev/null +++ b/devtools/client/debugger/src/utils/resource/tests/query.spec.js @@ -0,0 +1,1079 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow + +import { + createInitial, + insertResources, + updateResources, + makeMapWithArgs, + makeWeakQuery, + makeShallowQuery, + makeStrictQuery, + type Id, + type Resource, + type ResourceValues, + type ResourceIdentity, + type QueryMapNoArgs, + type QueryMapWithArgs, + type QueryFilter, + type QueryReduce, +} from ".."; + +type TestResource = Resource<{ + id: string, + name: string, + data: number, + obj: {}, +}>; + +const makeResource = (id: string): TestResource => ({ + id, + name: `name-${id}`, + data: 42, + obj: {}, +}); + +// Jest's mock type just wouldn't cooperate below, so this is a custom version +// that does what I need. +type MockedFn<InputFn, OutputFn> = OutputFn & { + mock: { + calls: Array<any>, + }, + mockImplementation(fn: InputFn): void, +}; + +// We need to pass the 'needsArgs' prop through to the query fn so we use +// this utility to do that and at the same time preserve typechecking. +const mockFn = (f: any) => Object.assign((jest.fn(f): any), f); + +const mockFilter = <Args, F: QueryFilter<TestResource, Args>>( + callback: F +): MockedFn<F, F> => mockFn(callback); +const mockMapNoArgs = <Mapped, F: (TestResource, ResourceIdentity) => Mapped>( + callback: F +): MockedFn<F, QueryMapNoArgs<TestResource, Mapped>> => mockFn(callback); +const mockMapWithArgs = < + Args, + Mapped, + F: (TestResource, ResourceIdentity, Args) => Mapped +>( + callback: F +): MockedFn<F, QueryMapWithArgs<TestResource, Args, Mapped>> => + mockFn(makeMapWithArgs(callback)); +const mockReduce = < + Args, + Mapped, + Reduced, + F: QueryReduce<TestResource, Args, Mapped, Reduced> +>( + callback: F +): MockedFn<F, F> => mockFn(callback); + +type TestArgs = Array<Id<TestResource>>; +type TestReduced = { [Id<TestResource>]: TestResource }; + +describe("resource query operations", () => { + let r1, r2, r3; + let initialState; + let mapNoArgs, mapWithArgs, reduce; + + beforeEach(() => { + r1 = makeResource("id-1"); + r2 = makeResource("id-2"); + r3 = makeResource("id-3"); + + initialState = createInitial(); + + initialState = insertResources(initialState, [r1, r2, r3]); + + mapNoArgs = mockMapNoArgs( + (resource: TestResource, ident: ResourceIdentity): TestResource => + resource + ); + mapWithArgs = mockMapWithArgs( + ( + resource: TestResource, + ident: ResourceIdentity, + args: mixed + ): TestResource => resource + ); + reduce = mockReduce( + ( + mapped: $ReadOnlyArray<TestResource>, + ids: $ReadOnlyArray<string>, + args: mixed + ): TestReduced => { + return mapped.reduce((acc, item, i) => { + acc[ids[i]] = item; + return acc; + }, {}); + } + ); + }); + + describe("weak cache", () => { + let filter; + + beforeEach(() => { + filter = mockFilter( + // eslint-disable-next-line max-nested-callbacks + (values: ResourceValues<TestResource>, args: TestArgs): TestArgs => args + ); + }); + + describe("no args", () => { + let query; + + beforeEach(() => { + query = makeWeakQuery({ filter, map: mapNoArgs, reduce }); + }); + + it("should return same with same state and same args", () => { + const args = [r1.id, r2.id]; + const result1 = query(initialState, args); + expect(result1).toEqual({ + [r1.id]: r1, + [r2.id]: r2, + }); + expect(filter.mock.calls).toHaveLength(1); + expect(mapNoArgs.mock.calls).toHaveLength(2); + expect(reduce.mock.calls).toHaveLength(1); + + const result2 = query(initialState, args); + expect(result2).toBe(result1); + expect(result2).toEqual({ + [r1.id]: r1, + [r2.id]: r2, + }); + expect(filter.mock.calls).toHaveLength(1); + expect(mapNoArgs.mock.calls).toHaveLength(2); + expect(reduce.mock.calls).toHaveLength(1); + }); + + it("should return same with updated other state and same args 1", () => { + const args = [r1.id, r2.id]; + const result1 = query(initialState, args); + expect(result1).toEqual({ + [r1.id]: r1, + [r2.id]: r2, + }); + expect(filter.mock.calls).toHaveLength(1); + expect(mapNoArgs.mock.calls).toHaveLength(2); + expect(reduce.mock.calls).toHaveLength(1); + + // Updating r2 does not affect cached result that only cares about r2. + const state = updateResources(initialState, [ + { + id: r3.id, + obj: {}, + }, + ]); + + const result2 = query(state, args); + expect(result2).toBe(result1); + expect(result2).toEqual({ + [r1.id]: r1, + [r2.id]: r2, + }); + expect(filter.mock.calls).toHaveLength(2); + expect(mapNoArgs.mock.calls).toHaveLength(2); + expect(reduce.mock.calls).toHaveLength(1); + }); + + it("should return same with updated other state and same args 2", () => { + // eslint-disable-next-line max-nested-callbacks + mapNoArgs.mockImplementation(resource => ({ ...resource, name: "" })); + + const args = [r1.id, r2.id]; + const result1 = query(initialState, args); + expect(result1).toEqual({ + [r1.id]: { ...r1, name: "" }, + [r2.id]: { ...r2, name: "" }, + }); + expect(filter.mock.calls).toHaveLength(1); + expect(mapNoArgs.mock.calls).toHaveLength(2); + expect(reduce.mock.calls).toHaveLength(1); + + // Since the map function ignores the name value, updating it should + // not reset the cached for this query. + const state = updateResources(initialState, [ + { + id: r3.id, + name: "newName", + }, + ]); + + const result2 = query(state, args); + expect(result2).toBe(result1); + expect(result2).toEqual({ + [r1.id]: { ...r1, name: "" }, + [r2.id]: { ...r2, name: "" }, + }); + expect(filter.mock.calls).toHaveLength(2); + expect(mapNoArgs.mock.calls).toHaveLength(2); + expect(reduce.mock.calls).toHaveLength(1); + }); + + it("should return diff with updated id state and same args", () => { + const args = [r1.id, r2.id]; + const result1 = query(initialState, args); + expect(result1).toEqual({ + [r1.id]: r1, + [r2.id]: r2, + }); + expect(filter.mock.calls).toHaveLength(1); + expect(mapNoArgs.mock.calls).toHaveLength(2); + expect(reduce.mock.calls).toHaveLength(1); + + // Since the mapper returns a value with name, changing a name will + // invalidate the cache. + const state = updateResources(initialState, [ + { + id: r1.id, + name: "newName", + }, + ]); + + const result2 = query(state, args); + expect(result2).not.toBe(result1); + expect(result2).toEqual({ + [r1.id]: { ...r1, name: "newName" }, + [r2.id]: r2, + }); + expect(filter.mock.calls).toHaveLength(2); + expect(mapNoArgs.mock.calls).toHaveLength(3); + expect(reduce.mock.calls).toHaveLength(2); + }); + + it("should return diff with same state and diff args", () => { + const firstArgs = [r1.id, r2.id]; + const secondArgs = [r1.id, r2.id]; + const result1 = query(initialState, firstArgs); + expect(result1).toEqual({ + [r1.id]: r1, + [r2.id]: r2, + }); + expect(filter.mock.calls).toHaveLength(1); + expect(mapNoArgs.mock.calls).toHaveLength(2); + expect(reduce.mock.calls).toHaveLength(1); + + const result2 = query(initialState, secondArgs); + expect(result2).not.toBe(result1); + expect(result2).toEqual({ + [r1.id]: r1, + [r2.id]: r2, + }); + expect(filter.mock.calls).toHaveLength(2); + expect(mapNoArgs.mock.calls).toHaveLength(2); + expect(reduce.mock.calls).toHaveLength(2); + + // Same result from first query still available. + const result3 = query(initialState, firstArgs); + expect(result3).not.toBe(result2); + expect(result3).toBe(result1); + expect(filter.mock.calls).toHaveLength(2); + expect(mapNoArgs.mock.calls).toHaveLength(2); + expect(reduce.mock.calls).toHaveLength(2); + + // Same result from second query still available. + const result4 = query(initialState, secondArgs); + expect(result4).toBe(result2); + expect(result4).not.toBe(result3); + expect(filter.mock.calls).toHaveLength(2); + expect(mapNoArgs.mock.calls).toHaveLength(2); + expect(reduce.mock.calls).toHaveLength(2); + }); + }); + + describe("with args", () => { + let query; + + beforeEach(() => { + query = makeWeakQuery({ filter, map: mapWithArgs, reduce }); + }); + + it("should return same with same state and same args", () => { + const args = [r1.id, r2.id]; + const result1 = query(initialState, args); + expect(result1).toEqual({ + [r1.id]: r1, + [r2.id]: r2, + }); + expect(filter.mock.calls).toHaveLength(1); + expect(mapWithArgs.mock.calls).toHaveLength(2); + expect(reduce.mock.calls).toHaveLength(1); + + const result2 = query(initialState, args); + expect(result2).toBe(result1); + expect(result2).toEqual({ + [r1.id]: r1, + [r2.id]: r2, + }); + expect(filter.mock.calls).toHaveLength(1); + expect(mapWithArgs.mock.calls).toHaveLength(2); + expect(reduce.mock.calls).toHaveLength(1); + }); + + it("should return same with updated other state and same args 1", () => { + const args = [r1.id, r2.id]; + const result1 = query(initialState, args); + expect(result1).toEqual({ + [r1.id]: r1, + [r2.id]: r2, + }); + expect(filter.mock.calls).toHaveLength(1); + expect(mapWithArgs.mock.calls).toHaveLength(2); + expect(reduce.mock.calls).toHaveLength(1); + + // Updating r2 does not affect cached result that only cares about r2. + const state = updateResources(initialState, [ + { + id: r3.id, + obj: {}, + }, + ]); + + const result2 = query(state, args); + expect(result2).toBe(result1); + expect(result2).toEqual({ + [r1.id]: r1, + [r2.id]: r2, + }); + expect(filter.mock.calls).toHaveLength(2); + expect(mapWithArgs.mock.calls).toHaveLength(2); + expect(reduce.mock.calls).toHaveLength(1); + }); + + it("should return same with updated other state and same args 2", () => { + // eslint-disable-next-line max-nested-callbacks + mapWithArgs.mockImplementation(resource => ({ ...resource, name: "" })); + + const args = [r1.id, r2.id]; + const result1 = query(initialState, args); + expect(result1).toEqual({ + [r1.id]: { ...r1, name: "" }, + [r2.id]: { ...r2, name: "" }, + }); + expect(filter.mock.calls).toHaveLength(1); + expect(mapWithArgs.mock.calls).toHaveLength(2); + expect(reduce.mock.calls).toHaveLength(1); + + // Since the map function ignores the name value, updating it should + // not reset the cached for this query. + const state = updateResources(initialState, [ + { + id: r3.id, + name: "newName", + }, + ]); + + const result2 = query(state, args); + expect(result2).toBe(result1); + expect(result2).toEqual({ + [r1.id]: { ...r1, name: "" }, + [r2.id]: { ...r2, name: "" }, + }); + expect(filter.mock.calls).toHaveLength(2); + expect(mapWithArgs.mock.calls).toHaveLength(2); + expect(reduce.mock.calls).toHaveLength(1); + }); + + it("should return diff with updated id state and same args", () => { + const args = [r1.id, r2.id]; + const result1 = query(initialState, args); + expect(result1).toEqual({ + [r1.id]: r1, + [r2.id]: r2, + }); + expect(filter.mock.calls).toHaveLength(1); + expect(mapWithArgs.mock.calls).toHaveLength(2); + expect(reduce.mock.calls).toHaveLength(1); + + // Since the mapper returns a value with name, changing a name will + // invalidate the cache. + const state = updateResources(initialState, [ + { + id: r1.id, + name: "newName", + }, + ]); + + const result2 = query(state, args); + expect(result2).not.toBe(result1); + expect(result2).toEqual({ + [r1.id]: { ...r1, name: "newName" }, + [r2.id]: r2, + }); + expect(filter.mock.calls).toHaveLength(2); + expect(mapWithArgs.mock.calls).toHaveLength(3); + expect(reduce.mock.calls).toHaveLength(2); + }); + + it("should return diff with same state and diff args", () => { + const firstArgs = [r1.id, r2.id]; + const secondArgs = [r1.id, r2.id]; + + const result1 = query(initialState, firstArgs); + expect(result1).toEqual({ + [r1.id]: r1, + [r2.id]: r2, + }); + expect(filter.mock.calls).toHaveLength(1); + expect(mapWithArgs.mock.calls).toHaveLength(2); + expect(reduce.mock.calls).toHaveLength(1); + + const result2 = query(initialState, secondArgs); + expect(result2).not.toBe(result1); + expect(result2).toEqual({ + [r1.id]: r1, + [r2.id]: r2, + }); + expect(filter.mock.calls).toHaveLength(2); + expect(mapWithArgs.mock.calls).toHaveLength(4); + expect(reduce.mock.calls).toHaveLength(2); + + // Same result from first query still available. + const result3 = query(initialState, firstArgs); + expect(result3).not.toBe(result2); + expect(result3).toBe(result1); + expect(filter.mock.calls).toHaveLength(2); + expect(mapWithArgs.mock.calls).toHaveLength(4); + expect(reduce.mock.calls).toHaveLength(2); + + // Same result from second query still available. + const result4 = query(initialState, secondArgs); + expect(result4).toBe(result2); + expect(result4).not.toBe(result3); + expect(filter.mock.calls).toHaveLength(2); + expect(mapWithArgs.mock.calls).toHaveLength(4); + expect(reduce.mock.calls).toHaveLength(2); + }); + }); + }); + + describe("shallow cache", () => { + let filter; + + beforeEach(() => { + filter = mockFilter( + // eslint-disable-next-line max-nested-callbacks + ( + values: ResourceValues<TestResource>, + { ids }: { ids: TestArgs } + ): TestArgs => ids + ); + }); + + describe("no args", () => { + let query; + + beforeEach(() => { + query = makeShallowQuery({ filter, map: mapNoArgs, reduce }); + }); + + it("should return last with same state and same args", () => { + const ids = [r1.id, r2.id]; + const result1 = query(initialState, { ids }); + expect(result1).toEqual({ + [r1.id]: r1, + [r2.id]: r2, + }); + expect(filter.mock.calls).toHaveLength(1); + expect(mapNoArgs.mock.calls).toHaveLength(2); + expect(reduce.mock.calls).toHaveLength(1); + + const result2 = query(initialState, { ids }); + expect(result2).toBe(result1); + expect(filter.mock.calls).toHaveLength(1); + expect(mapNoArgs.mock.calls).toHaveLength(2); + expect(reduce.mock.calls).toHaveLength(1); + }); + + it("should return last with updated other state and same args 1", () => { + const ids = [r1.id, r2.id]; + const result1 = query(initialState, { ids }); + expect(result1).toEqual({ + [r1.id]: r1, + [r2.id]: r2, + }); + expect(filter.mock.calls).toHaveLength(1); + expect(mapNoArgs.mock.calls).toHaveLength(2); + expect(reduce.mock.calls).toHaveLength(1); + + // Updating r2 does not affect cached result that only cares about r2. + const state = updateResources(initialState, [ + { + id: r3.id, + obj: {}, + }, + ]); + + const result2 = query(state, { ids }); + expect(result2).toBe(result1); + expect(filter.mock.calls).toHaveLength(2); + expect(mapNoArgs.mock.calls).toHaveLength(2); + expect(reduce.mock.calls).toHaveLength(1); + }); + + it("should return last with updated other state and same args 2", () => { + // eslint-disable-next-line max-nested-callbacks + mapNoArgs.mockImplementation(resource => ({ ...resource, name: "" })); + + const ids = [r1.id, r2.id]; + const result1 = query(initialState, { ids }); + expect(result1).toEqual({ + [r1.id]: { ...r1, name: "" }, + [r2.id]: { ...r2, name: "" }, + }); + expect(filter.mock.calls).toHaveLength(1); + expect(mapNoArgs.mock.calls).toHaveLength(2); + expect(reduce.mock.calls).toHaveLength(1); + + // Since the map function ignores the name value, updating it should + // not reset the cached for this query. + const state = updateResources(initialState, [ + { + id: r3.id, + name: "newName", + }, + ]); + + const result2 = query(state, { ids }); + expect(result2).toBe(result1); + expect(filter.mock.calls).toHaveLength(2); + expect(mapNoArgs.mock.calls).toHaveLength(2); + expect(reduce.mock.calls).toHaveLength(1); + }); + + it("should return new with updated id state and same args", () => { + const ids = [r1.id, r2.id]; + const result1 = query(initialState, { ids }); + expect(result1).toEqual({ + [r1.id]: r1, + [r2.id]: r2, + }); + expect(filter.mock.calls).toHaveLength(1); + expect(mapNoArgs.mock.calls).toHaveLength(2); + expect(reduce.mock.calls).toHaveLength(1); + + // Updating r2 does not affect cached result that only cares about r2. + const state = updateResources(initialState, [ + { + id: r2.id, + name: "newName", + }, + ]); + + const result2 = query(state, { ids }); + expect(result2).not.toBe(result1); + expect(result2).toEqual({ + [r1.id]: r1, + [r2.id]: { ...r2, name: "newName" }, + }); + expect(filter.mock.calls).toHaveLength(2); + expect(mapNoArgs.mock.calls).toHaveLength(3); + expect(reduce.mock.calls).toHaveLength(2); + }); + + it("should return diff with same state and diff args", () => { + const ids = [r1.id, r2.id]; + const result1 = query(initialState, { ids, flag: true }); + expect(result1).toEqual({ + [r1.id]: r1, + [r2.id]: r2, + }); + expect(filter.mock.calls).toHaveLength(1); + expect(mapNoArgs.mock.calls).toHaveLength(2); + expect(reduce.mock.calls).toHaveLength(1); + + const result2 = query(initialState, { ids, flag: false }); + expect(result2).toBe(result1); + expect(filter.mock.calls).toHaveLength(2); + expect(mapNoArgs.mock.calls).toHaveLength(2); + expect(reduce.mock.calls).toHaveLength(1); + + const result3 = query(initialState, { ids, flag: true }); + expect(result3).toBe(result1); + expect(filter.mock.calls).toHaveLength(3); + expect(mapNoArgs.mock.calls).toHaveLength(2); + expect(reduce.mock.calls).toHaveLength(1); + + const result4 = query(initialState, { ids, flag: false }); + expect(result4).toBe(result1); + expect(filter.mock.calls).toHaveLength(4); + expect(mapNoArgs.mock.calls).toHaveLength(2); + expect(reduce.mock.calls).toHaveLength(1); + }); + }); + + describe("with args", () => { + let query; + + beforeEach(() => { + query = makeShallowQuery({ filter, map: mapWithArgs, reduce }); + }); + + it("should return last with same state and same args", () => { + const ids = [r1.id, r2.id]; + const result1 = query(initialState, { ids }); + expect(result1).toEqual({ + [r1.id]: r1, + [r2.id]: r2, + }); + expect(filter.mock.calls).toHaveLength(1); + expect(mapWithArgs.mock.calls).toHaveLength(2); + expect(reduce.mock.calls).toHaveLength(1); + + const result2 = query(initialState, { ids }); + expect(result2).toBe(result1); + expect(filter.mock.calls).toHaveLength(1); + expect(mapWithArgs.mock.calls).toHaveLength(2); + expect(reduce.mock.calls).toHaveLength(1); + }); + + it("should return last with updated other state and same args 1", () => { + const ids = [r1.id, r2.id]; + const result1 = query(initialState, { ids }); + expect(result1).toEqual({ + [r1.id]: r1, + [r2.id]: r2, + }); + expect(filter.mock.calls).toHaveLength(1); + expect(mapWithArgs.mock.calls).toHaveLength(2); + expect(reduce.mock.calls).toHaveLength(1); + + // Updating r2 does not affect cached result that only cares about r2. + const state = updateResources(initialState, [ + { + id: r3.id, + obj: {}, + }, + ]); + + const result2 = query(state, { ids }); + expect(result2).toBe(result1); + expect(filter.mock.calls).toHaveLength(2); + expect(mapWithArgs.mock.calls).toHaveLength(2); + expect(reduce.mock.calls).toHaveLength(1); + }); + + it("should return last with updated other state and same args 2", () => { + // eslint-disable-next-line max-nested-callbacks + mapWithArgs.mockImplementation(resource => ({ ...resource, name: "" })); + + const ids = [r1.id, r2.id]; + const result1 = query(initialState, { ids }); + expect(result1).toEqual({ + [r1.id]: { ...r1, name: "" }, + [r2.id]: { ...r2, name: "" }, + }); + expect(filter.mock.calls).toHaveLength(1); + expect(mapWithArgs.mock.calls).toHaveLength(2); + expect(reduce.mock.calls).toHaveLength(1); + + // Since the map function ignores the name value, updating it should + // not reset the cached for this query. + const state = updateResources(initialState, [ + { + id: r3.id, + name: "newName", + }, + ]); + + const result2 = query(state, { ids }); + expect(result2).toBe(result1); + expect(filter.mock.calls).toHaveLength(2); + expect(mapWithArgs.mock.calls).toHaveLength(2); + expect(reduce.mock.calls).toHaveLength(1); + }); + + it("should return new with updated id state and same args", () => { + const ids = [r1.id, r2.id]; + const result1 = query(initialState, { ids }); + expect(result1).toEqual({ + [r1.id]: r1, + [r2.id]: r2, + }); + expect(filter.mock.calls).toHaveLength(1); + expect(mapWithArgs.mock.calls).toHaveLength(2); + expect(reduce.mock.calls).toHaveLength(1); + + // Updating r2 does not affect cached result that only cares about r2. + const state = updateResources(initialState, [ + { + id: r2.id, + name: "newName", + }, + ]); + + const result2 = query(state, { ids }); + expect(result2).not.toBe(result1); + expect(result2).toEqual({ + [r1.id]: r1, + [r2.id]: { ...r2, name: "newName" }, + }); + expect(filter.mock.calls).toHaveLength(2); + expect(mapWithArgs.mock.calls).toHaveLength(3); + expect(reduce.mock.calls).toHaveLength(2); + }); + + it("should return diff with same state and diff args", () => { + const ids = [r1.id, r2.id]; + const result1 = query(initialState, { ids, flag: true }); + expect(result1).toEqual({ + [r1.id]: r1, + [r2.id]: r2, + }); + expect(filter.mock.calls).toHaveLength(1); + expect(mapWithArgs.mock.calls).toHaveLength(2); + expect(reduce.mock.calls).toHaveLength(1); + + const result2 = query(initialState, { ids, flag: false }); + expect(result2).toBe(result1); + expect(filter.mock.calls).toHaveLength(2); + expect(mapWithArgs.mock.calls).toHaveLength(4); + expect(reduce.mock.calls).toHaveLength(1); + + const result3 = query(initialState, { ids, flag: true }); + expect(result3).toBe(result1); + expect(filter.mock.calls).toHaveLength(3); + expect(mapWithArgs.mock.calls).toHaveLength(6); + expect(reduce.mock.calls).toHaveLength(1); + + const result4 = query(initialState, { ids, flag: false }); + expect(result4).toBe(result1); + expect(filter.mock.calls).toHaveLength(4); + expect(mapWithArgs.mock.calls).toHaveLength(8); + expect(reduce.mock.calls).toHaveLength(1); + }); + }); + }); + + describe("strict cache", () => { + let filter; + + beforeEach(() => { + filter = mockFilter( + // eslint-disable-next-line max-nested-callbacks + (values: ResourceValues<TestResource>, ids: Array<string>): TestArgs => + ids + ); + }); + + describe("no args", () => { + let query; + + beforeEach(() => { + query = makeStrictQuery({ filter, map: mapNoArgs, reduce }); + }); + + it("should return same with same state and same args", () => { + const args = [r1.id, r2.id]; + const result1 = query(initialState, args); + expect(result1).toEqual({ + [r1.id]: r1, + [r2.id]: r2, + }); + expect(filter.mock.calls).toHaveLength(1); + expect(mapNoArgs.mock.calls).toHaveLength(2); + expect(reduce.mock.calls).toHaveLength(1); + + const result2 = query(initialState, args); + expect(result2).toBe(result1); + expect(result2).toEqual({ + [r1.id]: r1, + [r2.id]: r2, + }); + expect(filter.mock.calls).toHaveLength(1); + expect(mapNoArgs.mock.calls).toHaveLength(2); + expect(reduce.mock.calls).toHaveLength(1); + }); + + it("should return same with updated other state and same args 1", () => { + const args = [r1.id, r2.id]; + const result1 = query(initialState, args); + expect(result1).toEqual({ + [r1.id]: r1, + [r2.id]: r2, + }); + expect(filter.mock.calls).toHaveLength(1); + expect(mapNoArgs.mock.calls).toHaveLength(2); + expect(reduce.mock.calls).toHaveLength(1); + + // Updating r2 does not affect cached result that only cares about r2. + const state = updateResources(initialState, [ + { + id: r3.id, + obj: {}, + }, + ]); + + const result2 = query(state, args); + expect(result2).toBe(result1); + expect(result2).toEqual({ + [r1.id]: r1, + [r2.id]: r2, + }); + expect(filter.mock.calls).toHaveLength(2); + expect(mapNoArgs.mock.calls).toHaveLength(2); + expect(reduce.mock.calls).toHaveLength(1); + }); + + it("should return same with updated other state and same args 2", () => { + // eslint-disable-next-line max-nested-callbacks + mapNoArgs.mockImplementation(resource => ({ ...resource, name: "" })); + + const args = [r1.id, r2.id]; + const result1 = query(initialState, args); + expect(result1).toEqual({ + [r1.id]: { ...r1, name: "" }, + [r2.id]: { ...r2, name: "" }, + }); + expect(filter.mock.calls).toHaveLength(1); + expect(mapNoArgs.mock.calls).toHaveLength(2); + expect(reduce.mock.calls).toHaveLength(1); + + // Since the map function ignores the name value, updating it should + // not reset the cached for this query. + const state = updateResources(initialState, [ + { + id: r3.id, + name: "newName", + }, + ]); + + const result2 = query(state, args); + expect(result2).toBe(result1); + expect(result2).toEqual({ + [r1.id]: { ...r1, name: "" }, + [r2.id]: { ...r2, name: "" }, + }); + expect(filter.mock.calls).toHaveLength(2); + expect(mapNoArgs.mock.calls).toHaveLength(2); + expect(reduce.mock.calls).toHaveLength(1); + }); + + it("should return diff with updated id state and same args", () => { + const args = [r1.id, r2.id]; + const result1 = query(initialState, args); + expect(result1).toEqual({ + [r1.id]: r1, + [r2.id]: r2, + }); + expect(filter.mock.calls).toHaveLength(1); + expect(mapNoArgs.mock.calls).toHaveLength(2); + expect(reduce.mock.calls).toHaveLength(1); + + // Since the mapper returns a value with name, changing a name will + // invalidate the cache. + const state = updateResources(initialState, [ + { + id: r1.id, + name: "newName", + }, + ]); + + const result2 = query(state, args); + expect(result2).not.toBe(result1); + expect(result2).toEqual({ + [r1.id]: { ...r1, name: "newName" }, + [r2.id]: r2, + }); + expect(filter.mock.calls).toHaveLength(2); + expect(mapNoArgs.mock.calls).toHaveLength(3); + expect(reduce.mock.calls).toHaveLength(2); + }); + + it("should return diff with same state and diff args", () => { + const firstArgs = [r1.id, r2.id]; + const secondArgs = [r1.id, r2.id]; + const result1 = query(initialState, firstArgs); + expect(result1).toEqual({ + [r1.id]: r1, + [r2.id]: r2, + }); + expect(filter.mock.calls).toHaveLength(1); + expect(mapNoArgs.mock.calls).toHaveLength(2); + expect(reduce.mock.calls).toHaveLength(1); + + const result2 = query(initialState, secondArgs); + expect(result2).toBe(result1); + expect(filter.mock.calls).toHaveLength(2); + expect(mapNoArgs.mock.calls).toHaveLength(2); + expect(reduce.mock.calls).toHaveLength(1); + + const result3 = query(initialState, firstArgs); + expect(result3).toBe(result2); + expect(filter.mock.calls).toHaveLength(3); + expect(mapNoArgs.mock.calls).toHaveLength(2); + expect(reduce.mock.calls).toHaveLength(1); + + const result4 = query(initialState, secondArgs); + expect(result4).toBe(result3); + expect(filter.mock.calls).toHaveLength(4); + expect(mapNoArgs.mock.calls).toHaveLength(2); + expect(reduce.mock.calls).toHaveLength(1); + }); + }); + + describe("with args", () => { + let query; + + beforeEach(() => { + query = makeStrictQuery({ filter, map: mapWithArgs, reduce }); + }); + + it("should return same with same state and same args", () => { + const args = [r1.id, r2.id]; + const result1 = query(initialState, args); + expect(result1).toEqual({ + [r1.id]: r1, + [r2.id]: r2, + }); + expect(filter.mock.calls).toHaveLength(1); + expect(mapWithArgs.mock.calls).toHaveLength(2); + expect(reduce.mock.calls).toHaveLength(1); + + const result2 = query(initialState, args); + expect(result2).toBe(result1); + expect(result2).toEqual({ + [r1.id]: r1, + [r2.id]: r2, + }); + expect(filter.mock.calls).toHaveLength(1); + expect(mapWithArgs.mock.calls).toHaveLength(2); + expect(reduce.mock.calls).toHaveLength(1); + }); + + it("should return same with updated other state and same args 1", () => { + const args = [r1.id, r2.id]; + const result1 = query(initialState, args); + expect(result1).toEqual({ + [r1.id]: r1, + [r2.id]: r2, + }); + expect(filter.mock.calls).toHaveLength(1); + expect(mapWithArgs.mock.calls).toHaveLength(2); + expect(reduce.mock.calls).toHaveLength(1); + + // Updating r2 does not affect cached result that only cares about r2. + const state = updateResources(initialState, [ + { + id: r3.id, + obj: {}, + }, + ]); + + const result2 = query(state, args); + expect(result2).toBe(result1); + expect(result2).toEqual({ + [r1.id]: r1, + [r2.id]: r2, + }); + expect(filter.mock.calls).toHaveLength(2); + expect(mapWithArgs.mock.calls).toHaveLength(2); + expect(reduce.mock.calls).toHaveLength(1); + }); + + it("should return same with updated other state and same args 2", () => { + // eslint-disable-next-line max-nested-callbacks + mapWithArgs.mockImplementation(resource => ({ ...resource, name: "" })); + + const args = [r1.id, r2.id]; + const result1 = query(initialState, args); + expect(result1).toEqual({ + [r1.id]: { ...r1, name: "" }, + [r2.id]: { ...r2, name: "" }, + }); + expect(filter.mock.calls).toHaveLength(1); + expect(mapWithArgs.mock.calls).toHaveLength(2); + expect(reduce.mock.calls).toHaveLength(1); + + // Since the map function ignores the name value, updating it should + // not reset the cached for this query. + const state = updateResources(initialState, [ + { + id: r3.id, + name: "newName", + }, + ]); + + const result2 = query(state, args); + expect(result2).toBe(result1); + expect(result2).toEqual({ + [r1.id]: { ...r1, name: "" }, + [r2.id]: { ...r2, name: "" }, + }); + expect(filter.mock.calls).toHaveLength(2); + expect(mapWithArgs.mock.calls).toHaveLength(2); + expect(reduce.mock.calls).toHaveLength(1); + }); + + it("should return diff with updated id state and same args", () => { + const args = [r1.id, r2.id]; + const result1 = query(initialState, args); + expect(result1).toEqual({ + [r1.id]: r1, + [r2.id]: r2, + }); + expect(filter.mock.calls).toHaveLength(1); + expect(mapWithArgs.mock.calls).toHaveLength(2); + expect(reduce.mock.calls).toHaveLength(1); + + // Since the mapper returns a value with name, changing a name will + // invalidate the cache. + const state = updateResources(initialState, [ + { + id: r1.id, + name: "newName", + }, + ]); + + const result2 = query(state, args); + expect(result2).not.toBe(result1); + expect(result2).toEqual({ + [r1.id]: { ...r1, name: "newName" }, + [r2.id]: r2, + }); + expect(filter.mock.calls).toHaveLength(2); + expect(mapWithArgs.mock.calls).toHaveLength(3); + expect(reduce.mock.calls).toHaveLength(2); + }); + + it("should return diff with same state and diff args", () => { + const firstArgs = [r1.id, r2.id]; + const secondArgs = [r1.id, r2.id]; + + const result1 = query(initialState, firstArgs); + expect(result1).toEqual({ + [r1.id]: r1, + [r2.id]: r2, + }); + expect(filter.mock.calls).toHaveLength(1); + expect(mapWithArgs.mock.calls).toHaveLength(2); + expect(reduce.mock.calls).toHaveLength(1); + + const result2 = query(initialState, secondArgs); + expect(result2).toBe(result1); + expect(filter.mock.calls).toHaveLength(2); + expect(mapWithArgs.mock.calls).toHaveLength(4); + expect(reduce.mock.calls).toHaveLength(1); + + const result3 = query(initialState, firstArgs); + expect(result3).toBe(result2); + expect(filter.mock.calls).toHaveLength(3); + expect(mapWithArgs.mock.calls).toHaveLength(6); + expect(reduce.mock.calls).toHaveLength(1); + + const result4 = query(initialState, secondArgs); + expect(result4).toBe(result3); + expect(filter.mock.calls).toHaveLength(4); + expect(mapWithArgs.mock.calls).toHaveLength(8); + expect(reduce.mock.calls).toHaveLength(1); + }); + }); + }); +}); |