Skip to main content

Generic Find Many Query Hook

object name, filter, sort, pagination, field selection을 받아 metadata 기반 list query와 cursor state를 한 hook으로 감싸는 패턴이다.

핵심은 caller passes object intent, hook owns GraphQL document and pagination state다.

1. 모듈 코드

  • src/modules/object-record/hooks/useFindManyRecords.ts: metadata, permissions, soft-delete filter, query execution, completion/error handling, fetchMore wrapper를 묶는다.
  • src/modules/object-record/hooks/useFindManyRecordsQuery.ts: object metadata와 requested fields로 find-many GraphQL document를 memoize한다.
  • src/modules/object-record/hooks/useHandleFindManyRecordsCompleted.ts: connection 결과를 records로 풀고 cursor/hasNextPage atom state를 갱신한다.
// filepath: src/modules/object-record/hooks/useFindManyRecords.ts
export const useFindManyRecords = <T extends ObjectRecord = ObjectRecord>({
objectNameSingular,
filter,
orderBy,
skip,
recordGqlFields,
fetchPolicy,
onError,
onCompleted,
cursorFilter,
limit = QUERY_DEFAULT_LIMIT_RECORDS,
withSoftDeleted = false,
}: UseFindManyRecordsParams<T>) => {
const { objectMetadataItem } = useObjectMetadataItem({ objectNameSingular });
const apolloCoreClient = useApolloCoreClient();

const { findManyRecordsQuery } = useFindManyRecordsQuery({
objectNameSingular,
recordGqlFields,
cursorDirection: cursorFilter?.cursorDirection,
});

const withSoftDeleteFilter = withSoftDeleted
? {
and: [
...(filter ? [filter] : []),
{ or: [{ deletedAt: { is: 'NULL' } }, { deletedAt: { is: 'NOT_NULL' } }] },
],
}
: filter;

const queryIdentifier = getQueryIdentifier({
objectNameSingular,
filter: withSoftDeleteFilter,
orderBy,
limit,
});

const { handleFindManyRecordsCompleted } = useHandleFindManyRecordsCompleted({
objectMetadataItem,
queryIdentifier,
onCompleted,
});

const { handleFindManyRecordsError } = useHandleFindManyRecordsError({
objectMetadataItem,
handleError: onError,
});

const objectPermissions = useObjectPermissionsForObject(objectMetadataItem.id);

const { data, loading, error, fetchMore, refetch } =
useQuery<RecordGqlOperationFindManyResult>(findManyRecordsQuery, {
skip:
skip ||
!isDefined(objectMetadataItem) ||
!objectPermissions.canReadObjectRecords,
variables: {
filter: withSoftDeleteFilter,
orderBy,
lastCursor: cursorFilter?.cursor ?? undefined,
limit,
},
fetchPolicy,
client: apolloCoreClient,
});

useEffect(() => {
if (data) {
handleFindManyRecordsCompleted(data);
}
}, [data, handleFindManyRecordsCompleted]);

useEffect(() => {
if (error) {
handleFindManyRecordsError(error);
}
}, [error, handleFindManyRecordsError]);

const { fetchMoreRecords, records, hasNextPage } =
useFetchMoreRecordsWithPagination<T>({
objectNameSingular,
filter: withSoftDeleteFilter,
orderBy,
limit,
fetchMore,
data,
error,
objectMetadataItem,
});

return {
objectMetadataItem,
records,
totalCount: data?.[objectMetadataItem.namePlural]?.totalCount,
loading,
error,
fetchMoreRecords,
queryIdentifier,
hasNextPage,
pageInfo: data?.[objectMetadataItem.namePlural]?.pageInfo,
refetch,
};
};

// filepath: src/modules/object-record/hooks/useFindManyRecordsQuery.ts
export const useFindManyRecordsQuery = ({
objectNameSingular,
recordGqlFields,
computeReferences,
cursorDirection = 'after',
}: {
objectNameSingular: string;
recordGqlFields?: RecordGqlOperationGqlRecordFields;
computeReferences?: boolean;
cursorDirection?: QueryCursorDirection;
}) => {
const { objectMetadataItem } = useObjectMetadataItem({ objectNameSingular });
const objectMetadataItems = useAtomStateValue(objectMetadataItemsSelector);
const { objectPermissionsByObjectMetadataId } = useObjectPermissions();

const findManyRecordsQuery = useMemo(
() =>
generateFindManyRecordsQuery({
objectMetadataItem,
objectMetadataItems,
recordGqlFields,
computeReferences,
cursorDirection,
objectPermissionsByObjectMetadataId,
}),
[
objectMetadataItem,
objectMetadataItems,
recordGqlFields,
computeReferences,
cursorDirection,
objectPermissionsByObjectMetadataId,
],
);

return { findManyRecordsQuery };
};

// filepath: src/modules/object-record/hooks/useHandleFindManyRecordsCompleted.ts
export const useHandleFindManyRecordsCompleted = <T>({
queryIdentifier,
onCompleted,
objectMetadataItem,
}: {
queryIdentifier: string;
objectMetadataItem: EnrichedObjectMetadataItem;
onCompleted?: OnFindManyRecordsCompleted<T>;
}) => {
const store = useStore();

const handleFindManyRecordsCompleted = useCallback(
(data: RecordGqlOperationFindManyResult) => {
const pageInfo = data?.[objectMetadataItem.namePlural]?.pageInfo;
const records = getRecordsFromRecordConnection({
recordConnection: data?.[objectMetadataItem.namePlural],
}) as T[];

onCompleted?.(records, {
pageInfo,
totalCount: data?.[objectMetadataItem.namePlural]?.totalCount,
});

if (isDefined(data?.[objectMetadataItem.namePlural])) {
store.set(cursorFamilyState.atomFamily(queryIdentifier), pageInfo.endCursor ?? '');
store.set(
hasNextPageFamilyState.atomFamily(queryIdentifier),
pageInfo.hasNextPage ?? false,
);
}
},
[objectMetadataItem.namePlural, onCompleted, queryIdentifier, store],
);

return { handleFindManyRecordsCompleted };
};

2. 사용 예제

// filepath: src/modules/object-record/record-index/hooks/useRecordIndexTableQuery.ts
export const useRecordIndexTableQuery = (objectNameSingular: string) => {
const params = useFindManyRecordIndexTableParams(objectNameSingular);
const { objectMetadataItem } = useObjectMetadataItem({ objectNameSingular });
const recordGqlFields = useRelevantRecordsGqlFields({ objectMetadataItem });

const {
records,
hasNextPage,
queryIdentifier,
loading,
error,
totalCount,
fetchMoreRecords,
} = useFindManyRecords({
...params,
recordGqlFields,
});

return {
records,
loading,
error,
hasNextPage,
queryIdentifier,
totalCount,
fetchMoreRecords,
};
};

// filepath: src/modules/object-record/record-list/components/RecordListRecordGroup.tsx
export const RecordListRecordGroup = () => {
const { objectNameSingular } = useRecordListContextOrThrow();

const { records, loading, error, hasNextPage, fetchMoreRecords } =
useRecordIndexTableQuery(objectNameSingular);

return (
<RecordListRecords
records={records}
loading={loading}
error={error}
hasNextPage={hasNextPage}
fetchMoreRecords={fetchMoreRecords}
isVisible
/>
);
};