@apollo/client
Version:
A fully-featured caching GraphQL client.
186 lines (185 loc) • 10.1 kB
JavaScript
import { argumentsObjectFromField, DeepMerger, isArray, isNonEmptyArray, isNonNullObject, } from "@apollo/client/utilities/internal";
import { invariant } from "@apollo/client/utilities/invariant";
import { hasOwn } from "./helpers.js";
// Mapping from JSON-encoded KeySpecifier strings to associated information.
const specifierInfoCache = {};
function lookupSpecifierInfo(spec) {
// It's safe to encode KeySpecifier arrays with JSON.stringify, since they're
// just arrays of strings or nested KeySpecifier arrays, and the order of the
// array elements is important (and suitably preserved by JSON.stringify).
const cacheKey = JSON.stringify(spec);
return specifierInfoCache[cacheKey] || (specifierInfoCache[cacheKey] = {});
}
export function keyFieldsFnFromSpecifier(specifier) {
const info = lookupSpecifierInfo(specifier);
return (info.keyFieldsFn || (info.keyFieldsFn = (object, context) => {
const extract = (from, key) => context.readField(key, from);
const keyObject = (context.keyObject = collectSpecifierPaths(specifier, (schemaKeyPath) => {
let extracted = extractKeyPath(context.storeObject, schemaKeyPath,
// Using context.readField to extract paths from context.storeObject
// allows the extraction to see through Reference objects and respect
// custom read functions.
extract);
if (extracted === void 0 &&
object !== context.storeObject &&
hasOwn.call(object, schemaKeyPath[0])) {
// If context.storeObject fails to provide a value for the requested
// path, fall back to the raw result object, if it has a top-level key
// matching the first key in the path (schemaKeyPath[0]). This allows
// key fields included in the written data to be saved in the cache
// even if they are not selected explicitly in context.selectionSet.
// Not being mentioned by context.selectionSet is convenient here,
// since it means these extra fields cannot be affected by field
// aliasing, which is why we can use extractKey instead of
// context.readField for this extraction.
extracted = extractKeyPath(object, schemaKeyPath, extractKey);
}
invariant(extracted !== void 0, 99, schemaKeyPath.join("."), object);
return extracted;
}));
return `${context.typename}:${JSON.stringify(keyObject)}`;
}));
}
// The keyArgs extraction process is roughly analogous to keyFields extraction,
// but there are no aliases involved, missing fields are tolerated (by merely
// omitting them from the key), and drawing from field.directives or variables
// is allowed (in addition to drawing from the field's arguments object).
// Concretely, these differences mean passing a different key path extractor
// function to collectSpecifierPaths, reusing the shared extractKeyPath helper
// wherever possible.
export function keyArgsFnFromSpecifier(specifier) {
const info = lookupSpecifierInfo(specifier);
return (info.keyArgsFn ||
(info.keyArgsFn = (args, { field, variables, fieldName }) => {
const collected = collectSpecifierPaths(specifier, (keyPath) => {
const firstKey = keyPath[0];
const firstChar = firstKey.charAt(0);
if (firstChar === "@") {
if (field && isNonEmptyArray(field.directives)) {
const directiveName = firstKey.slice(1);
// If the directive appears multiple times, only the first
// occurrence's arguments will be used. TODO Allow repetition?
// TODO Cache this work somehow, a la aliasMap?
const d = field.directives.find((d) => d.name.value === directiveName);
// Fortunately argumentsObjectFromField works for DirectiveNode!
const directiveArgs = d && argumentsObjectFromField(d, variables);
// For directives without arguments (d defined, but directiveArgs ===
// null), the presence or absence of the directive still counts as
// part of the field key, so we return null in those cases. If no
// directive with this name was found for this field (d undefined and
// thus directiveArgs undefined), we return undefined, which causes
// this value to be omitted from the key object returned by
// collectSpecifierPaths.
return (directiveArgs &&
extractKeyPath(directiveArgs,
// If keyPath.length === 1, this code calls extractKeyPath with an
// empty path, which works because it uses directiveArgs as the
// extracted value.
keyPath.slice(1)));
}
// If the key started with @ but there was no corresponding directive,
// we want to omit this value from the key object, not fall through to
// treating @whatever as a normal argument name.
return;
}
if (firstChar === "$") {
const variableName = firstKey.slice(1);
if (variables && hasOwn.call(variables, variableName)) {
const varKeyPath = keyPath.slice(0);
varKeyPath[0] = variableName;
return extractKeyPath(variables, varKeyPath);
}
// If the key started with $ but there was no corresponding variable, we
// want to omit this value from the key object, not fall through to
// treating $whatever as a normal argument name.
return;
}
if (args) {
return extractKeyPath(args, keyPath);
}
});
const suffix = JSON.stringify(collected);
// If no arguments were passed to this field, and it didn't have any other
// field key contributions from directives or variables, hide the empty
// :{} suffix from the field key. However, a field passed no arguments can
// still end up with a non-empty :{...} suffix if its key configuration
// refers to directives or variables.
if (args || suffix !== "{}") {
fieldName += ":" + suffix;
}
return fieldName;
}));
}
export function collectSpecifierPaths(specifier, extractor) {
// For each path specified by specifier, invoke the extractor, and repeatedly
// merge the results together, with appropriate ancestor context.
const merger = new DeepMerger();
return getSpecifierPaths(specifier).reduce((collected, path) => {
let toMerge = extractor(path);
if (toMerge !== void 0) {
// This path is not expected to contain array indexes, so the toMerge
// reconstruction will not contain arrays. TODO Fix this?
for (let i = path.length - 1; i >= 0; --i) {
toMerge = { [path[i]]: toMerge };
}
collected = merger.merge(collected, toMerge);
}
return collected;
}, {});
}
export function getSpecifierPaths(spec) {
const info = lookupSpecifierInfo(spec);
if (!info.paths) {
const paths = (info.paths = []);
const currentPath = [];
spec.forEach((s, i) => {
if (isArray(s)) {
getSpecifierPaths(s).forEach((p) => paths.push(currentPath.concat(p)));
currentPath.length = 0;
}
else {
currentPath.push(s);
if (!isArray(spec[i + 1])) {
paths.push(currentPath.slice(0));
currentPath.length = 0;
}
}
});
}
return info.paths;
}
function extractKey(object, key) {
return object[key];
}
export function extractKeyPath(object, path, extract) {
// For each key in path, extract the corresponding child property from obj,
// flattening arrays if encountered (uncommon for keyFields and keyArgs, but
// possible). The final result of path.reduce is normalized so unexpected leaf
// objects have their keys safely sorted. That final result is difficult to
// type as anything other than any. You're welcome to try to improve the
// return type, but keep in mind extractKeyPath is not a public function
// (exported only for testing), so the effort may not be worthwhile unless the
// limited set of actual callers (see above) pass arguments that TypeScript
// can statically type. If we know only that path is some array of strings
// (and not, say, a specific tuple of statically known strings), any (or
// possibly unknown) is the honest answer.
extract = extract || extractKey;
return normalize(path.reduce(function reducer(obj, key) {
return isArray(obj) ?
obj.map((child) => reducer(child, key))
: obj && extract(obj, key);
}, object));
}
function normalize(value) {
// Usually the extracted value will be a scalar value, since most primary
// key fields are scalar, but just in case we get an object or an array, we
// need to do some normalization of the order of (nested) keys.
if (isNonNullObject(value)) {
if (isArray(value)) {
return value.map(normalize);
}
return collectSpecifierPaths(Object.keys(value).sort(), (path) => extractKeyPath(value, path));
}
return value;
}
//# sourceMappingURL=key-extractor.js.map