Files
speckle-server/packages/frontend-2/lib/common/helpers/graphql.ts
T
Kristaps Fabians Geikins b02a07e2b6 feat: Frontend 2.0 MVP
2023-05-08 10:47:01 +03:00

299 lines
8.9 KiB
TypeScript

/* eslint-disable @typescript-eslint/no-unsafe-assignment */
import { isUndefinedOrVoid, Optional } from '@speckle/shared'
import {
ApolloError,
FetchResult,
DataProxy,
ApolloCache,
defaultDataIdFromObject,
TypedDocumentNode
} from '@apollo/client/core'
import { DocumentNode, GraphQLError } from 'graphql'
import { flatten, isUndefined } from 'lodash-es'
import { Modifier } from '@apollo/client/cache'
import { PartialDeep } from 'type-fest'
/**
* Utility type for typing cached data in Apollo modify functions.
* Essentially inside a modify function all references to cached objects that can be uniquely identified (have an ID field) are converted
* to CacheObjectReference objects and additionally all properties are optional as you can never know what exactly has been requested & cached
* and what hasn't
*/
export type ModifyFnCacheData<Data> = Data extends
| Record<string, unknown>
| Record<string, unknown>[]
? PartialDeep<{
[key in keyof Data]: Data[key] extends { id: string }
? CacheObjectReference
: Data[key] extends { id: string }[]
? CacheObjectReference[]
: ModifyFnCacheData<Data[key]>
}>
: Data
/**
* Get a cached object's identifier
*/
export function getCacheId(typeName: string, id: string) {
const cachedId = defaultDataIdFromObject({
__typename: typeName,
id
})
if (!cachedId) throw new Error('Unable to build Apollo cache ID')
return cachedId
}
/**
* Convert an error thrown during $apollo.mutate() into a fetch result
*/
export function convertThrowIntoFetchResult(err: unknown): FetchResult<undefined> {
let gqlErrors: readonly GraphQLError[]
if (err instanceof ApolloError) {
gqlErrors = err.graphQLErrors
} else if (err instanceof Error) {
gqlErrors = [new GraphQLError(err.message)]
} else {
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions
gqlErrors = [new GraphQLError(`${err}`)]
}
return {
data: undefined,
errors: gqlErrors
}
}
/**
* Get first error message from a GQL errors array
*/
export function getFirstErrorMessage(
errs: readonly GraphQLError[] | undefined | null,
fallbackMessage = 'An unexpected issue occurred'
): string {
return errs?.[0]?.message || fallbackMessage
}
/**
* Find some cached Apollo data through a fragment/query and use the updater function
* to return the replacement for the data that the fragment initially found
* @returns Whether an update was made
*/
export function updateCacheByFilter<TData, TVariables = unknown>(
cache: ApolloCache<unknown>,
filter: {
fragment?: DataProxy.Fragment<TVariables, TData>
query?: DataProxy.Query<TVariables, TData>
},
/**
* If returns undefined/void, then updating is essentially canceled. Be careful not to
* mutate anything being passed into this function! E.g. if you want to mutate arrays,
* create new arrays through slice()/filter() instead
*/
updater: (data: TData) => TData | undefined | void,
options: Partial<{
/**
* Whether to suppress errors that occur when the fragment being queried
* doesn't find anything
* Default: true
*/
ignoreCacheErrors: boolean
/**
* Whether to overwrite the old cache results, instead of triggering a merge function
* to merge the old and new results.
* Default: true
*/
overwrite: boolean
}> = {}
): boolean {
const { fragment, query } = filter
const { ignoreCacheErrors = true, overwrite = true } = options
if (!fragment && !query) {
throw new Error(
'Either fragment or query must be specified to be able to find cached data to update'
)
}
const readData = (): TData | null => {
if (fragment) {
return cache.readFragment(fragment)
} else if (query) {
return cache.readQuery(query)
} else {
return null
}
}
const writeData = (data: TData): boolean => {
if (fragment) {
cache.writeFragment({ ...fragment, data, overwrite })
return true
} else if (query) {
cache.writeQuery({ ...query, data, overwrite })
return true
} else {
return false
}
}
try {
const currentData = readData()
if (!currentData) return false
const newData = updater(currentData)
if (isUndefinedOrVoid(newData)) return false
return writeData(newData)
} catch (e: unknown) {
if (e instanceof Error) {
// Invalid fragment - throw always
if (e.message.toLowerCase().includes('no fragment named')) {
throw e
}
}
if (ignoreCacheErrors) {
console.warn('Failed Apollo cache update:', e)
return false
}
throw e
}
}
/**
* A graphql-codegen generated fragment doesn't include the definition of other fragments in it,
* so if you ever need a document that contains a fragment as well as other fragments that it uses
* you can use this helper
*
* TODO: Figure out if we can turn off dedupeFragments to get fragments to contain all dependency
* fragments as well. Previously this caused an error when duplicate fragments were sent to the API
* through a query/mutation.
*/
export function addFragmentDependencies<R = unknown, V = unknown>(
fragment: TypedDocumentNode<R, V>,
...fragmentDependencies: DocumentNode[]
) {
return {
kind: 'Document',
definitions: [
...fragment.definitions.filter((d) => d.kind === 'FragmentDefinition'),
...flatten(
fragmentDependencies.map((f) =>
f.definitions.filter((d) => d.kind === 'FragmentDefinition')
)
)
]
} as TypedDocumentNode<R, V>
}
/**
* Resolve the string key of a field in the apollo cache. Is useful in cache.modify() calls.
*/
export function getStoreFieldName(
fieldName: string,
variables?: Record<string, unknown>
) {
return (
fieldName +
(Object.values(variables || {}).length ? `:${JSON.stringify(variables)}` : '')
)
}
/**
* Inside cache.modify calls you'll get these instead of full objects when reading fields that hold
* identifiable objects or object arrays
*/
export type CacheObjectReference = { __ref: string }
/**
* Objects & object arrays in `cache.modify` calls are represented through reference objects, so
* if you want to add new ones you shouldn't add the entire object, but only its reference
*/
export function getObjectReference(typeName: string, id: string): CacheObjectReference {
return {
__ref: getCacheId(typeName, id)
}
}
/**
* Iterate over a cached object's fields and optionally update them. Similar to cache.modify, except allows
* better filtering capabilities to filter filters to update (e.g. you can actually get each field's variables)
* Note: This uses cache.modify underneath which means that `data` will only hold object references (CacheObjectReference) not
* full objects. Read more: https://www.apollographql.com/docs/react/caching/cache-interaction/#values-vs-references
*/
export function modifyObjectFields<
V extends Optional<Record<string, unknown>> = undefined,
D = unknown
>(
cache: ApolloCache<unknown>,
id: string,
updater: (
fieldName: string,
variables: V,
value: ModifyFnCacheData<D>,
details: Parameters<Modifier<ModifyFnCacheData<D>>>[1] & {
ref: typeof getObjectReference
}
) => Optional<ModifyFnCacheData<D>> | void
) {
cache.modify({
id,
fields(fieldValue, details) {
const { storeFieldName, fieldName } = details
let variables: Optional<V> = undefined
if (storeFieldName !== fieldName) {
const variablesStringbase = storeFieldName.substring(fieldName.length)
if (variablesStringbase.startsWith(':')) {
variables = JSON.parse(variablesStringbase.substring(1)) as V
} else if (variablesStringbase.startsWith('(')) {
variables = JSON.parse(
variablesStringbase.substring(1, variablesStringbase.length - 1)
) as V
}
}
const res = updater(
fieldName,
variables as V,
fieldValue as ModifyFnCacheData<D>,
{
...details,
ref: getObjectReference
}
)
if (isUndefined(res)) {
return fieldValue as unknown
} else {
return res
}
}
})
}
/**
* Iterate over a cached object's fields and evict/delete the ones that the predicate returns true for
* Note: This uses cache.modify underneath which means that `data` will only hold object references (CacheObjectReference) not
* full objects. Read more: https://www.apollographql.com/docs/react/caching/cache-interaction/#values-vs-references
*/
export function evictObjectFields<
V extends Optional<Record<string, unknown>> = undefined,
D = unknown
>(
cache: ApolloCache<unknown>,
id: string,
predicate: (
fieldName: string,
variables: V,
value: ModifyFnCacheData<D>,
details: Parameters<Modifier<ModifyFnCacheData<D>>>[1]
) => boolean
) {
modifyObjectFields<V, D>(cache, id, (fieldName, variables, value, details) => {
if (!predicate(fieldName, variables, value, details)) return undefined
return details.DELETE as ModifyFnCacheData<D>
})
}