Feat/put target object identifier on use activities (#4682)

When writing to the normalized cache (record), it's crucial to use _refs
for relationships to avoid many problems. Essentially, we only deal with
level 0 and generate all fields to be comfortable with their defaults.

When writing in queries (which should be very rare, the only cases are
prefetch and the case of activities due to the nested query; I've
reduced this to a single file for activities
usePrepareFindManyActivitiesQuery 🙂), it's important to use queryFields
to avoid bugs. I've implemented them on the side of query generation and
record generation.

When doing an updateOne / createOne, etc., it's necessary to distinguish
between optimistic writing (which we actually want to do with _refs) and
the server response without refs. This allows for a clean write in the
optimistic cache without worrying about nesting (as the first point).

To simplify the whole activities part, write to the normalized cache
first. Then, base queries on it in an idempotent manner. This way,
there's no need to worry about the current page or action. The
normalized cache is up-to-date, so I update the queries. Same idea as
for optimisticEffects, actually.

Finally, I've triggered optimisticEffects rather than the manual update
of many queries.

---------

Co-authored-by: Lucas Bordeau <bordeau.lucas@gmail.com>
This commit is contained in:
Charles Bochet
2024-04-01 13:12:37 +02:00
committed by GitHub
parent 4e109c9a38
commit 02673a82af
172 changed files with 2182 additions and 4915 deletions

View File

@ -1,64 +0,0 @@
import { useApolloClient } from '@apollo/client';
import gql from 'graphql-tag';
import { useRecoilCallback, useRecoilValue } from 'recoil';
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { mapObjectMetadataToGraphQLQuery } from '@/object-metadata/utils/mapObjectMetadataToGraphQLQuery';
import { useInjectIntoFindOneRecordQueryCache } from '@/object-record/cache/hooks/useInjectIntoFindOneRecordQueryCache';
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
import { capitalize } from '~/utils/string/capitalize';
export const useAddRecordInCache = ({
objectMetadataItem,
}: {
objectMetadataItem: ObjectMetadataItem;
}) => {
const objectMetadataItems = useRecoilValue(objectMetadataItemsState);
const apolloClient = useApolloClient();
const { injectIntoFindOneRecordQueryCache } =
useInjectIntoFindOneRecordQueryCache({
objectMetadataItem,
});
return useRecoilCallback(
({ set }) =>
(record: ObjectRecord) => {
const fragment = gql`
fragment Create${capitalize(
objectMetadataItem.nameSingular,
)}InCache on ${capitalize(
objectMetadataItem.nameSingular,
)} ${mapObjectMetadataToGraphQLQuery({
objectMetadataItems,
objectMetadataItem,
})}
`;
const cachedObjectRecord = {
__typename: `${capitalize(objectMetadataItem.nameSingular)}`,
...record,
};
apolloClient.writeFragment({
id: `${capitalize(objectMetadataItem.nameSingular)}:${record.id}`,
fragment,
data: cachedObjectRecord,
});
// TODO: should we keep this here ? Or should the caller of createOneRecordInCache/createManyRecordsInCache be responsible for this ?
injectIntoFindOneRecordQueryCache(cachedObjectRecord);
// TODO: remove this once we get rid of entityFieldsFamilyState
set(recordStoreFamilyState(record.id), record);
},
[
objectMetadataItem,
objectMetadataItems,
apolloClient,
injectIntoFindOneRecordQueryCache,
],
);
};

View File

@ -1,50 +0,0 @@
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { useReadFindManyRecordsQueryInCache } from '@/object-record/cache/hooks/useReadFindManyRecordsQueryInCache';
import { useUpsertFindManyRecordsQueryInCache } from '@/object-record/cache/hooks/useUpsertFindManyRecordsQueryInCache';
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
import { ObjectRecordQueryVariables } from '@/object-record/types/ObjectRecordQueryVariables';
export const useAppendToFindManyRecordsQueryInCache = ({
objectMetadataItem,
}: {
objectMetadataItem: ObjectMetadataItem;
}) => {
const { readFindManyRecordsQueryInCache } =
useReadFindManyRecordsQueryInCache({
objectMetadataItem,
});
const {
upsertFindManyRecordsQueryInCache: overwriteFindManyRecordsQueryInCache,
} = useUpsertFindManyRecordsQueryInCache({
objectMetadataItem,
});
const appendToFindManyRecordsQueryInCache = <
T extends ObjectRecord = ObjectRecord,
>({
queryVariables,
objectRecordsToAppend,
}: {
queryVariables: ObjectRecordQueryVariables;
objectRecordsToAppend: T[];
}) => {
const existingObjectRecords = readFindManyRecordsQueryInCache({
queryVariables,
});
const newObjectRecordList = [
...existingObjectRecords,
...objectRecordsToAppend,
];
overwriteFindManyRecordsQueryInCache({
objectRecordsToOverwrite: newObjectRecordList,
queryVariables,
});
};
return {
appendToFindManyRecordsQueryInCache,
};
};

View File

@ -0,0 +1,42 @@
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { ObjectMetadataItemIdentifier } from '@/object-metadata/types/ObjectMetadataItemIdentifier';
import { useCreateOneRecordInCache } from '@/object-record/cache/hooks/useCreateOneRecordInCache';
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
import { prefillRecord } from '@/object-record/utils/prefillRecord';
import { isDefined } from '~/utils/isDefined';
export const useCreateManyRecordsInCache = <T extends ObjectRecord>({
objectNameSingular,
}: ObjectMetadataItemIdentifier) => {
const { objectMetadataItem } = useObjectMetadataItem({
objectNameSingular,
});
const createOneRecordInCache = useCreateOneRecordInCache({
objectMetadataItem,
});
const createManyRecordsInCache = (recordsToCreate: Partial<T>[]) => {
const recordsWithId = recordsToCreate
.map((record) => {
return prefillRecord<T>({
input: record,
objectMetadataItem,
});
})
.filter(isDefined);
const createdRecordsInCache = [] as T[];
for (const record of recordsWithId) {
if (isDefined(record)) {
createOneRecordInCache(record);
createdRecordsInCache.push(record);
}
}
return createdRecordsInCache;
};
return { createManyRecordsInCache };
};

View File

@ -0,0 +1,62 @@
import { useApolloClient } from '@apollo/client';
import gql from 'graphql-tag';
import { useRecoilValue } from 'recoil';
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { mapObjectMetadataToGraphQLQuery } from '@/object-metadata/utils/mapObjectMetadataToGraphQLQuery';
import { useGetRecordFromCache } from '@/object-record/cache/hooks/useGetRecordFromCache';
import { getRecordNodeFromRecord } from '@/object-record/cache/utils/getRecordNodeFromRecord';
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
import { prefillRecord } from '@/object-record/utils/prefillRecord';
import { capitalize } from '~/utils/string/capitalize';
export const useCreateOneRecordInCache = <T extends ObjectRecord>({
objectMetadataItem,
}: {
objectMetadataItem: ObjectMetadataItem;
}) => {
const getRecordFromCache = useGetRecordFromCache({
objectMetadataItem,
});
const objectMetadataItems = useRecoilValue(objectMetadataItemsState);
const apolloClient = useApolloClient();
return (record: ObjectRecord) => {
const fragment = gql`
fragment Create${capitalize(
objectMetadataItem.nameSingular,
)}InCache on ${capitalize(
objectMetadataItem.nameSingular,
)} ${mapObjectMetadataToGraphQLQuery({
objectMetadataItems,
objectMetadataItem,
computeReferences: true,
})}
`;
const prefilledRecord = prefillRecord({
objectMetadataItem,
input: record,
depth: 1,
});
const recordToCreateWithNestedConnections = getRecordNodeFromRecord({
record: prefilledRecord,
objectMetadataItem,
objectMetadataItems,
});
const cachedObjectRecord = {
__typename: `${capitalize(objectMetadataItem.nameSingular)}`,
...recordToCreateWithNestedConnections,
};
apolloClient.writeFragment({
id: `${capitalize(objectMetadataItem.nameSingular)}:${record.id}`,
fragment,
data: cachedObjectRecord,
});
return getRecordFromCache(record.id) as T;
};
};

View File

@ -0,0 +1,29 @@
import { useApolloClient } from '@apollo/client';
import { useObjectMetadataItemOnly } from '@/object-metadata/hooks/useObjectMetadataItemOnly';
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
import { deleteRecordFromCache } from '@/object-record/cache/utils/deleteRecordFromCache';
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
export const useDeleteRecordFromCache = ({
objectNameSingular,
}: {
objectNameSingular: string;
}) => {
const apolloClient = useApolloClient();
const { objectMetadataItem } = useObjectMetadataItemOnly({
objectNameSingular,
});
const { objectMetadataItems } = useObjectMetadataItems();
return (recordToDelete: ObjectRecord) => {
deleteRecordFromCache({
objectMetadataItem,
objectMetadataItems,
recordToDelete,
cache: apolloClient.cache,
});
};
};

View File

@ -1,79 +0,0 @@
import { v4 } from 'uuid';
import { z } from 'zod';
import { useGetRelationMetadata } from '@/object-metadata/hooks/useGetRelationMetadata';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
import { generateEmptyFieldValue } from '@/object-record/utils/generateEmptyFieldValue';
import { capitalize } from '~/utils/string/capitalize';
export const useGenerateObjectRecordOptimisticResponse = ({
objectMetadataItem,
}: {
objectMetadataItem: ObjectMetadataItem;
}) => {
const getRelationMetadata = useGetRelationMetadata();
const generateObjectRecordOptimisticResponse = <
GeneratedObjectRecord extends ObjectRecord,
>(
input: Record<string, unknown>,
) => {
const recordSchema = z.object(
Object.fromEntries(
objectMetadataItem.fields.map((fieldMetadataItem) => [
fieldMetadataItem.name,
z.unknown().default(generateEmptyFieldValue(fieldMetadataItem)),
]),
),
);
const inputWithRelationFields = objectMetadataItem.fields.reduce(
(result, fieldMetadataItem) => {
const relationIdFieldName = `${fieldMetadataItem.name}Id`;
if (!(relationIdFieldName in input)) return result;
const relationMetadata = getRelationMetadata({ fieldMetadataItem });
if (!relationMetadata) return result;
const relationRecordTypeName = capitalize(
relationMetadata.relationObjectMetadataItem.nameSingular,
);
const relationRecordId = result[relationIdFieldName] as string | null;
const relationRecord = input[fieldMetadataItem.name] as
| ObjectRecord
| undefined;
return {
...result,
[fieldMetadataItem.name]: relationRecordId
? {
__typename: relationRecordTypeName,
id: relationRecordId,
// TODO: there are too many bugs if we don't include the entire relation record
// See if we can find a way to work only with the id and typename
...relationRecord,
}
: null,
};
},
input,
);
return {
__typename: capitalize(objectMetadataItem.nameSingular),
...recordSchema.parse({
id: v4(),
createdAt: new Date().toISOString(),
...inputWithRelationFields,
}),
} as GeneratedObjectRecord & { __typename: string };
};
return {
generateObjectRecordOptimisticResponse,
};
};

View File

@ -1,13 +1,11 @@
import { useCallback } from 'react';
import { gql, useApolloClient } from '@apollo/client';
import { useApolloClient } from '@apollo/client';
import { useRecoilValue } from 'recoil';
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { mapObjectMetadataToGraphQLQuery } from '@/object-metadata/utils/mapObjectMetadataToGraphQLQuery';
import { getRecordFromCache } from '@/object-record/cache/utils/getRecordFromCache';
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull';
import { capitalize } from '~/utils/string/capitalize';
export const useGetRecordFromCache = ({
objectMetadataItem,
@ -23,29 +21,11 @@ export const useGetRecordFromCache = ({
recordId: string,
cache = apolloClient.cache,
) => {
if (isUndefinedOrNull(objectMetadataItem)) {
return null;
}
const capitalizedObjectName = capitalize(objectMetadataItem.nameSingular);
const cacheReadFragment = gql`
fragment ${capitalizedObjectName}Fragment on ${capitalizedObjectName} ${mapObjectMetadataToGraphQLQuery(
{
objectMetadataItems,
objectMetadataItem,
},
)}
`;
const cachedRecordId = cache.identify({
__typename: capitalize(objectMetadataItem.nameSingular),
id: recordId,
});
return cache.readFragment<CachedObjectRecord & { __typename: string }>({
id: cachedRecordId,
fragment: cacheReadFragment,
return getRecordFromCache<CachedObjectRecord>({
cache,
recordId,
objectMetadataItems,
objectMetadataItem,
});
},
[objectMetadataItem, objectMetadataItems, apolloClient],

View File

@ -1,44 +0,0 @@
import { useApolloClient } from '@apollo/client';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { useGenerateFindOneRecordQuery } from '@/object-record/hooks/useGenerateFindOneRecordQuery';
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
import { capitalize } from '~/utils/string/capitalize';
export const useInjectIntoFindOneRecordQueryCache = ({
objectMetadataItem,
}: {
objectMetadataItem: ObjectMetadataItem;
}) => {
const apolloClient = useApolloClient();
const generateFindOneRecordQuery = useGenerateFindOneRecordQuery();
const injectIntoFindOneRecordQueryCache = <
T extends ObjectRecord = ObjectRecord,
>(
record: T,
) => {
const findOneRecordQueryForCacheInjection = generateFindOneRecordQuery({
objectMetadataItem,
depth: 1,
});
apolloClient.writeQuery({
query: findOneRecordQueryForCacheInjection,
variables: {
objectRecordId: record.id,
},
data: {
[objectMetadataItem.nameSingular]: {
__typename: `${capitalize(objectMetadataItem.nameSingular)}`,
...record,
},
},
});
};
return {
injectIntoFindOneRecordQueryCache,
};
};

View File

@ -1,32 +0,0 @@
import { useApolloClient } from '@apollo/client';
import { Modifiers } from '@apollo/client/cache';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull';
import { capitalize } from '~/utils/string/capitalize';
export const useModifyRecordFromCache = ({
objectMetadataItem,
}: {
objectMetadataItem: ObjectMetadataItem;
}) => {
const { cache } = useApolloClient();
return <CachedObjectRecord extends ObjectRecord = ObjectRecord>(
recordId: string,
fieldModifiers: Modifiers<CachedObjectRecord>,
) => {
if (isUndefinedOrNull(objectMetadataItem)) return;
const cachedRecordId = cache.identify({
__typename: capitalize(objectMetadataItem.nameSingular),
id: recordId,
});
cache.modify<CachedObjectRecord>({
id: cachedRecordId,
fields: fieldModifiers,
});
};
};

View File

@ -21,11 +21,17 @@ export const useReadFindManyRecordsQueryInCache = ({
T extends ObjectRecord = ObjectRecord,
>({
queryVariables,
queryFields,
depth,
}: {
queryVariables: ObjectRecordQueryVariables;
queryFields?: Record<string, any>;
depth?: number;
}) => {
const findManyRecordsQueryForCacheRead = generateFindManyRecordsQuery({
objectMetadataItem,
queryFields,
depth,
});
const existingRecordsQueryResult = apolloClient.readQuery<

View File

@ -1,5 +1,7 @@
import { useApolloClient } from '@apollo/client';
import { useRecoilValue } from 'recoil';
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { MAX_QUERY_DEPTH_FOR_CACHE_INJECTION } from '@/object-record/cache/constants/MaxQueryDepthForCacheInjection';
import { getRecordConnectionFromRecords } from '@/object-record/cache/utils/getRecordConnectionFromRecords';
@ -18,6 +20,7 @@ export const useUpsertFindManyRecordsQueryInCache = ({
const apolloClient = useApolloClient();
const generateFindManyRecordsQuery = useGenerateFindManyRecordsQuery();
const objectMetadataItems = useRecoilValue(objectMetadataItemsState);
const upsertFindManyRecordsQueryInCache = <
T extends ObjectRecord = ObjectRecord,
@ -25,19 +28,28 @@ export const useUpsertFindManyRecordsQueryInCache = ({
queryVariables,
depth = MAX_QUERY_DEPTH_FOR_CACHE_INJECTION,
objectRecordsToOverwrite,
queryFields,
computeReferences = false,
}: {
queryVariables: ObjectRecordQueryVariables;
depth?: number;
objectRecordsToOverwrite: T[];
queryFields?: Record<string, any>;
computeReferences?: boolean;
}) => {
const findManyRecordsQueryForCacheOverwrite = generateFindManyRecordsQuery({
objectMetadataItem,
depth, // TODO: fix this
depth,
queryFields,
computeReferences,
});
const newObjectRecordConnection = getRecordConnectionFromRecords({
objectNameSingular: objectMetadataItem.nameSingular,
objectMetadataItems: objectMetadataItems,
objectMetadataItem: objectMetadataItem,
records: objectRecordsToOverwrite,
queryFields,
computeReferences,
});
apolloClient.writeQuery({

View File

@ -0,0 +1,30 @@
import { ApolloCache } from '@apollo/client';
import { triggerDeleteRecordsOptimisticEffect } from '@/apollo/optimistic-effect/utils/triggerDeleteRecordsOptimisticEffect';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { getObjectTypename } from '@/object-record/cache/utils/getObjectTypename';
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
export const deleteRecordFromCache = ({
objectMetadataItem,
objectMetadataItems,
recordToDelete,
cache,
}: {
objectMetadataItem: ObjectMetadataItem;
objectMetadataItems: ObjectMetadataItem[];
recordToDelete: ObjectRecord;
cache: ApolloCache<object>;
}) => {
triggerDeleteRecordsOptimisticEffect({
cache,
objectMetadataItem,
objectMetadataItems,
recordsToDelete: [
{
...recordToDelete,
__typename: getObjectTypename(objectMetadataItem.nameSingular),
},
],
});
};

View File

@ -1,31 +0,0 @@
import { ApolloClient, makeReference, Reference } from '@apollo/client';
import { getCachedRecordFromRecord } from '@/object-record/cache/utils/getCachedRecordFromRecord';
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
export const getCacheReferenceFromRecord = <T extends ObjectRecord>({
apolloClient,
objectNameSingular,
record,
}: {
apolloClient: ApolloClient<object>;
objectNameSingular: string;
record: T;
}): Reference => {
const cachedRecord = getCachedRecordFromRecord({
objectNameSingular,
record,
});
const id = apolloClient.cache.identify(cachedRecord);
if (!id) {
throw new Error(
`Could not identify record "${objectNameSingular}", id : "${record.id}"`,
);
}
const recordReference = makeReference(id);
return recordReference;
};

View File

@ -1,43 +0,0 @@
import { ApolloClient, makeReference } from '@apollo/client';
import { CachedObjectRecordEdge } from '@/apollo/types/CachedObjectRecordEdge';
import { getCachedRecordFromRecord } from '@/object-record/cache/utils/getCachedRecordFromRecord';
import { getEdgeTypename } from '@/object-record/cache/utils/getEdgeTypename';
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
export const getCachedRecordEdgesFromRecords = <T extends ObjectRecord>({
apolloClient,
objectNameSingular,
records,
}: {
apolloClient: ApolloClient<object>;
objectNameSingular: string;
records: T[];
}): CachedObjectRecordEdge[] => {
const cachedRecordEdges = records.map((record) => {
const cachedRecord = getCachedRecordFromRecord({
objectNameSingular,
record,
});
const id = apolloClient.cache.identify(cachedRecord);
if (!id) {
throw new Error(
`Could not identify record "${objectNameSingular}", id : "${record.id}"`,
);
}
const reference = makeReference(id);
const cachedObjectRecordEdge: CachedObjectRecordEdge = {
cursor: '',
node: reference,
__typename: getEdgeTypename({ objectNameSingular }),
};
return cachedObjectRecordEdge;
});
return cachedRecordEdges;
};

View File

@ -1,16 +0,0 @@
import { CachedObjectRecord } from '@/apollo/types/CachedObjectRecord';
import { getNodeTypename } from '@/object-record/cache/utils/getNodeTypename';
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
export const getCachedRecordFromRecord = <T extends ObjectRecord>({
objectNameSingular,
record,
}: {
objectNameSingular: string;
record: T;
}): CachedObjectRecord<T> => {
return {
__typename: getNodeTypename({ objectNameSingular }),
...record,
};
};

View File

@ -1,9 +1,6 @@
import { getObjectTypename } from '@/object-record/cache/utils/getObjectTypename';
import { capitalize } from '~/utils/string/capitalize';
export const getConnectionTypename = ({
objectNameSingular,
}: {
objectNameSingular: string;
}) => {
return `${capitalize(objectNameSingular)}Connection`;
export const getConnectionTypename = (objectNameSingular: string) => {
return `${capitalize(getObjectTypename(objectNameSingular))}Connection`;
};

View File

@ -1,9 +1,6 @@
import { getObjectTypename } from '@/object-record/cache/utils/getObjectTypename';
import { capitalize } from '~/utils/string/capitalize';
export const getEdgeTypename = ({
objectNameSingular,
}: {
objectNameSingular: string;
}) => {
return `${capitalize(objectNameSingular)}Edge`;
export const getEdgeTypename = (objectNameSingular: string) => {
return `${capitalize(getObjectTypename(objectNameSingular))}Edge`;
};

View File

@ -1,9 +1,6 @@
import { getObjectTypename } from '@/object-record/cache/utils/getObjectTypename';
import { capitalize } from '~/utils/string/capitalize';
export const getNodeTypename = ({
objectNameSingular,
}: {
objectNameSingular: string;
}) => {
return capitalize(objectNameSingular);
export const getNodeTypename = (objectNameSingular: string) => {
return capitalize(getObjectTypename(objectNameSingular));
};

View File

@ -0,0 +1,5 @@
import { capitalize } from '~/utils/string/capitalize';
export const getObjectTypename = (objectNameSingular: string) => {
return capitalize(objectNameSingular);
};

View File

@ -1,19 +0,0 @@
import { getConnectionTypename } from '@/object-record/cache/utils/getConnectionTypename';
import { getEmptyPageInfo } from '@/object-record/cache/utils/getEmptyPageInfo';
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
import { ObjectRecordConnection } from '@/object-record/types/ObjectRecordConnection';
import { ObjectRecordEdge } from '@/object-record/types/ObjectRecordEdge';
export const getRecordConnectionFromEdges = <T extends ObjectRecord>({
objectNameSingular,
edges,
}: {
objectNameSingular: string;
edges: ObjectRecordEdge<T>[];
}) => {
return {
__typename: getConnectionTypename({ objectNameSingular }),
edges: edges,
pageInfo: getEmptyPageInfo(),
} as ObjectRecordConnection<T>;
};

View File

@ -1,3 +1,4 @@
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { getConnectionTypename } from '@/object-record/cache/utils/getConnectionTypename';
import { getEmptyPageInfo } from '@/object-record/cache/utils/getEmptyPageInfo';
import { getRecordEdgeFromRecord } from '@/object-record/cache/utils/getRecordEdgeFromRecord';
@ -5,21 +6,41 @@ import { ObjectRecord } from '@/object-record/types/ObjectRecord';
import { ObjectRecordConnection } from '@/object-record/types/ObjectRecordConnection';
export const getRecordConnectionFromRecords = <T extends ObjectRecord>({
objectNameSingular,
objectMetadataItems,
objectMetadataItem,
records,
queryFields,
withPageInfo = true,
computeReferences = false,
isRootLevel = true,
depth = 1,
}: {
objectNameSingular: string;
objectMetadataItems: ObjectMetadataItem[];
objectMetadataItem: Pick<
ObjectMetadataItem,
'fields' | 'namePlural' | 'nameSingular'
>;
records: T[];
queryFields?: Record<string, any>;
withPageInfo?: boolean;
isRootLevel?: boolean;
computeReferences?: boolean;
depth?: number;
}) => {
return {
__typename: getConnectionTypename({ objectNameSingular }),
__typename: getConnectionTypename(objectMetadataItem.nameSingular),
edges: records.map((record) => {
return getRecordEdgeFromRecord({
objectNameSingular,
objectMetadataItems,
objectMetadataItem,
queryFields,
record,
isRootLevel,
computeReferences,
depth,
});
}),
pageInfo: getEmptyPageInfo(),
totalCount: records.length,
...(withPageInfo && { pageInfo: getEmptyPageInfo() }),
...(withPageInfo && { totalCount: records.length }),
} as ObjectRecordConnection<T>;
};

View File

@ -1,37 +1,40 @@
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { getEdgeTypename } from '@/object-record/cache/utils/getEdgeTypename';
import { getNodeTypename } from '@/object-record/cache/utils/getNodeTypename';
import { getRecordConnectionFromRecords } from '@/object-record/cache/utils/getRecordConnectionFromRecords';
import { getRecordNodeFromRecord } from '@/object-record/cache/utils/getRecordNodeFromRecord';
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
import { ObjectRecordEdge } from '@/object-record/types/ObjectRecordEdge';
export const getRecordEdgeFromRecord = <T extends ObjectRecord>({
objectNameSingular,
objectMetadataItems,
objectMetadataItem,
queryFields,
record,
computeReferences = false,
isRootLevel = false,
}: {
objectNameSingular: string;
objectMetadataItems: ObjectMetadataItem[];
objectMetadataItem: Pick<
ObjectMetadataItem,
'fields' | 'namePlural' | 'nameSingular'
>;
queryFields?: Record<string, any>;
computeReferences?: boolean;
isRootLevel?: boolean;
depth?: number;
record: T;
}) => {
const nestedRecord = Object.fromEntries(
Object.entries(record).map(([key, value]) => {
if (Array.isArray(value)) {
return [
key,
getRecordConnectionFromRecords({
// Todo: this is a ugly and broken hack to get the singular, we need to infer this from metadata
objectNameSingular: key.slice(0, -1),
records: value as ObjectRecord[],
}),
];
}
return [key, value];
}),
) as T; // Todo fix typing once we have investigated apollo edges / nodes removal
return {
__typename: getEdgeTypename({ objectNameSingular }),
__typename: getEdgeTypename(objectMetadataItem.nameSingular),
node: {
__typename: getNodeTypename({ objectNameSingular }),
...nestedRecord,
...getRecordNodeFromRecord({
objectMetadataItems,
objectMetadataItem,
queryFields,
record,
computeReferences,
isRootLevel,
depth: 1,
}),
},
cursor: '',
} as ObjectRecordEdge<T>;

View File

@ -0,0 +1,55 @@
import { ApolloCache, gql } from '@apollo/client';
import { CachedObjectRecord } from '@/apollo/types/CachedObjectRecord';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { mapObjectMetadataToGraphQLQuery } from '@/object-metadata/utils/mapObjectMetadataToGraphQLQuery';
import { getRecordFromRecordNode } from '@/object-record/cache/utils/getRecordFromRecordNode';
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull';
import { capitalize } from '~/utils/string/capitalize';
export const getRecordFromCache = <T extends ObjectRecord = ObjectRecord>({
objectMetadataItem,
objectMetadataItems,
cache,
recordId,
}: {
cache: ApolloCache<object>;
recordId: string;
objectMetadataItems: ObjectMetadataItem[];
objectMetadataItem: ObjectMetadataItem;
}) => {
if (isUndefinedOrNull(objectMetadataItem)) {
return null;
}
const capitalizedObjectName = capitalize(objectMetadataItem.nameSingular);
const cacheReadFragment = gql`
fragment ${capitalizedObjectName}Fragment on ${capitalizedObjectName} ${mapObjectMetadataToGraphQLQuery(
{
objectMetadataItems,
objectMetadataItem,
},
)}
`;
const cachedRecordId = cache.identify({
__typename: capitalize(objectMetadataItem.nameSingular),
id: recordId,
});
const record = cache.readFragment<T & { __typename: string }>({
id: cachedRecordId,
fragment: cacheReadFragment,
returnPartialData: true,
});
if (isUndefinedOrNull(record)) {
return null;
}
return getRecordFromRecordNode({
recordNode: record,
}) as CachedObjectRecord<T>;
};

View File

@ -0,0 +1,34 @@
import { getRecordsFromRecordConnection } from '@/object-record/cache/utils/getRecordsFromRecordConnection';
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
import { isDefined } from '~/utils/isDefined';
import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull';
export const getRecordFromRecordNode = <T extends ObjectRecord>({
recordNode,
}: {
recordNode: T;
}): T => {
return {
...Object.fromEntries(
Object.entries(recordNode).map(([fieldName, value]) => {
if (isUndefinedOrNull(value)) {
return [fieldName, value];
}
if (typeof value === 'object' && isDefined(value.edges)) {
return [
fieldName,
getRecordsFromRecordConnection({ recordConnection: value }),
];
}
if (typeof value === 'object' && !isDefined(value.edges)) {
return [fieldName, getRecordFromRecordNode<T>({ recordNode: value })];
}
return [fieldName, value];
}),
),
id: recordNode.id,
} as T;
};

View File

@ -0,0 +1,143 @@
import { isNull, isUndefined } from '@sniptt/guards';
import { CachedObjectRecord } from '@/apollo/types/CachedObjectRecord';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { getNodeTypename } from '@/object-record/cache/utils/getNodeTypename';
import { getObjectTypename } from '@/object-record/cache/utils/getObjectTypename';
import { getRecordConnectionFromRecords } from '@/object-record/cache/utils/getRecordConnectionFromRecords';
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
import { FieldMetadataType } from '~/generated-metadata/graphql';
import { isDefined } from '~/utils/isDefined';
export const getRecordNodeFromRecord = <T extends ObjectRecord>({
objectMetadataItems,
objectMetadataItem,
queryFields,
record,
computeReferences = true,
isRootLevel = true,
depth = 1,
}: {
objectMetadataItems: ObjectMetadataItem[];
objectMetadataItem: Pick<
ObjectMetadataItem,
'fields' | 'namePlural' | 'nameSingular'
>;
queryFields?: Record<string, any>;
computeReferences?: boolean;
isRootLevel?: boolean;
record: T | null;
depth?: number;
}) => {
if (isNull(record)) {
return null;
}
const nodeTypeName = getNodeTypename(objectMetadataItem.nameSingular);
if (!isRootLevel && computeReferences) {
return {
__ref: `${nodeTypeName}:${record.id}`,
} as unknown as CachedObjectRecord<T>; // Todo Fix typing
}
const nestedRecord = Object.fromEntries(
Object.entries(record)
.map(([fieldName, value]) => {
if (isDefined(queryFields) && !queryFields[fieldName]) {
return undefined;
}
const field = objectMetadataItem.fields.find(
(field) => field.name === fieldName,
);
if (isUndefined(field)) {
return undefined;
}
if (
!isUndefined(depth) &&
depth < 1 &&
field.type === FieldMetadataType.Relation
) {
return undefined;
}
if (Array.isArray(value)) {
const objectMetadataItem = objectMetadataItems.find(
(objectMetadataItem) => objectMetadataItem.namePlural === fieldName,
);
if (!objectMetadataItem) {
return undefined;
}
return [
fieldName,
getRecordConnectionFromRecords({
objectMetadataItems,
objectMetadataItem: objectMetadataItem,
records: value as ObjectRecord[],
queryFields:
queryFields?.[fieldName] === true ||
isUndefined(queryFields?.[fieldName])
? undefined
: queryFields?.[fieldName],
withPageInfo: false,
isRootLevel: false,
computeReferences,
depth: depth - 1,
}),
];
}
if (field.type === 'RELATION') {
if (
isUndefined(
field.relationDefinition?.targetObjectMetadata.nameSingular,
)
) {
return undefined;
}
if (isNull(value)) {
return [fieldName, null];
}
if (isUndefined(value?.id)) {
return undefined;
}
const typeName = getObjectTypename(
field.relationDefinition?.targetObjectMetadata.nameSingular,
);
if (computeReferences) {
return [
fieldName,
{
__ref: `${typeName}:${value.id}`,
},
];
}
return [
fieldName,
{
__typename: typeName,
...value,
},
];
}
return [fieldName, value];
})
.filter(isDefined),
) as T; // Todo fix typing once we have investigated apollo edges / nodes removal
return {
__typename: getNodeTypename(objectMetadataItem.nameSingular),
...nestedRecord,
};
};

View File

@ -1,3 +1,4 @@
import { getRecordFromRecordNode } from '@/object-record/cache/utils/getRecordFromRecordNode';
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
import { ObjectRecordConnection } from '@/object-record/types/ObjectRecordConnection';
@ -6,5 +7,7 @@ export const getRecordsFromRecordConnection = <T extends ObjectRecord>({
}: {
recordConnection: ObjectRecordConnection<T>;
}): T[] => {
return recordConnection.edges.map((edge) => edge.node);
return recordConnection.edges.map((edge) =>
getRecordFromRecordNode<T>({ recordNode: edge.node }),
);
};

View File

@ -0,0 +1,30 @@
import { z } from 'zod';
import { ObjectRecordConnection } from '@/object-record/types/ObjectRecordConnection';
import { capitalize } from '~/utils/string/capitalize';
export const isObjectRecordConnection = (
objectNameSingular: string,
value: unknown,
): value is ObjectRecordConnection => {
const objectConnectionTypeName = `${capitalize(
objectNameSingular,
)}Connection`;
const objectEdgeTypeName = `${capitalize(objectNameSingular)}Edge`;
const objectConnectionSchema = z.object({
__typename: z.literal(objectConnectionTypeName).optional(),
edges: z.array(
z.object({
__typename: z.literal(objectEdgeTypeName).optional(),
node: z.object({
id: z.string().uuid(),
}),
}),
),
});
const connectionValidation = objectConnectionSchema.safeParse(value);
return connectionValidation.success;
};

View File

@ -0,0 +1,30 @@
import { StoreValue } from '@apollo/client';
import { z } from 'zod';
import { CachedObjectRecordConnection } from '@/apollo/types/CachedObjectRecordConnection';
import { capitalize } from '~/utils/string/capitalize';
export const isObjectRecordConnectionWithRefs = (
objectNameSingular: string,
storeValue: StoreValue,
): storeValue is CachedObjectRecordConnection => {
const objectConnectionTypeName = `${capitalize(
objectNameSingular,
)}Connection`;
const objectEdgeTypeName = `${capitalize(objectNameSingular)}Edge`;
const cachedObjectConnectionSchema = z.object({
__typename: z.literal(objectConnectionTypeName),
edges: z.array(
z.object({
__typename: z.literal(objectEdgeTypeName),
node: z.object({
__ref: z.string().startsWith(`${capitalize(objectNameSingular)}:`),
}),
}),
),
});
const cachedConnectionValidation =
cachedObjectConnectionSchema.safeParse(storeValue);
return cachedConnectionValidation.success;
};

View File

@ -0,0 +1,33 @@
import { ApolloCache, Modifiers } from '@apollo/client/cache';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull';
import { capitalize } from '~/utils/string/capitalize';
export const modifyRecordFromCache = <
CachedObjectRecord extends ObjectRecord = ObjectRecord,
>({
objectMetadataItem,
cache,
fieldModifiers,
recordId,
}: {
objectMetadataItem: ObjectMetadataItem;
cache: ApolloCache<object>;
fieldModifiers: Modifiers<CachedObjectRecord>;
recordId: string;
}) => {
if (isUndefinedOrNull(objectMetadataItem)) return;
const cachedRecordId = cache.identify({
__typename: capitalize(objectMetadataItem.nameSingular),
id: recordId,
});
cache.modify<CachedObjectRecord>({
id: cachedRecordId,
fields: fieldModifiers,
optimistic: true,
});
};

View File

@ -0,0 +1,59 @@
import { ApolloCache } from '@apollo/client/cache';
import gql from 'graphql-tag';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { mapObjectMetadataToGraphQLQuery } from '@/object-metadata/utils/mapObjectMetadataToGraphQLQuery';
import { getRecordNodeFromRecord } from '@/object-record/cache/utils/getRecordNodeFromRecord';
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull';
import { capitalize } from '~/utils/string/capitalize';
export const updateRecordFromCache = <T extends ObjectRecord>({
objectMetadataItems,
objectMetadataItem,
cache,
record,
}: {
objectMetadataItems: ObjectMetadataItem[];
objectMetadataItem: ObjectMetadataItem;
cache: ApolloCache<object>;
record: T;
}) => {
if (isUndefinedOrNull(objectMetadataItem)) {
return null;
}
const capitalizedObjectName = capitalize(objectMetadataItem.nameSingular);
const cacheWriteFragment = gql`
fragment ${capitalizedObjectName}Fragment on ${capitalizedObjectName} ${mapObjectMetadataToGraphQLQuery(
{
objectMetadataItems,
objectMetadataItem,
computeReferences: true,
},
)}
`;
const cachedRecordId = cache.identify({
__typename: capitalize(objectMetadataItem.nameSingular),
id: record.id,
});
const recordWithConnection = getRecordNodeFromRecord<T>({
objectMetadataItems,
objectMetadataItem,
record,
depth: 1,
});
if (isUndefinedOrNull(recordWithConnection)) {
return;
}
cache.writeFragment<T & { __typename: string }>({
id: cachedRecordId,
fragment: cacheWriteFragment,
data: recordWithConnection,
});
};