diff --git a/.changeset/wise-mugs-swim.md b/.changeset/wise-mugs-swim.md new file mode 100644 index 00000000..05cebea2 --- /dev/null +++ b/.changeset/wise-mugs-swim.md @@ -0,0 +1,6 @@ +--- +"@graphprotocol/hypergraph": patch +--- + +improve include type for fineOne + \ No newline at end of file diff --git a/.changeset/yummy-beans-float.md b/.changeset/yummy-beans-float.md new file mode 100644 index 00000000..46e5734b --- /dev/null +++ b/.changeset/yummy-beans-float.md @@ -0,0 +1,6 @@ +--- +"@graphprotocol/hypergraph-react": minor +--- + +remove useQueryEntity and add useEntity hook + \ No newline at end of file diff --git a/apps/events/src/components/event.tsx b/apps/events/src/components/event.tsx new file mode 100644 index 00000000..5ba8bda4 --- /dev/null +++ b/apps/events/src/components/event.tsx @@ -0,0 +1,30 @@ +import { useEntity } from '@graphprotocol/hypergraph-react'; +import { Event as EventType } from '../schema'; + +export const Event = ({ spaceId, entityId }: { spaceId: string; entityId: string }) => { + const { data, isPending, isError } = useEntity(EventType, { + mode: 'public', + include: { + sponsors: { + jobOffers: {}, + }, + }, + id: entityId, + space: spaceId, + }); + + console.log({ component: 'Event', isPending, isError, data }); + + return ( +
+ {isPending &&
Loading...
} + {isError &&
Error
} + {data && ( +
+

Event Details

+
{JSON.stringify(data, null, 2)}
+
+ )} +
+ ); +}; diff --git a/apps/events/src/routes/playground.lazy.tsx b/apps/events/src/routes/playground.lazy.tsx index 14a4476e..a628da74 100644 --- a/apps/events/src/routes/playground.lazy.tsx +++ b/apps/events/src/routes/playground.lazy.tsx @@ -2,6 +2,7 @@ import { HypergraphSpaceProvider } from '@graphprotocol/hypergraph-react'; import { createLazyFileRoute } from '@tanstack/react-router'; import { CreateEvents } from '@/components/create-events'; import { CreatePropertiesAndTypesEvent } from '@/components/create-properties-and-types-event'; +import { Event } from '@/components/event'; import { Playground } from '@/components/playground'; export const Route = createLazyFileRoute('/playground')({ @@ -12,6 +13,7 @@ function RouteComponent() { const space = 'a393e509-ae56-4d99-987c-bed71d9db631'; return ( <> +
diff --git a/packages/hypergraph-react/src/HypergraphSpaceContext.tsx b/packages/hypergraph-react/src/HypergraphSpaceContext.tsx index 84c49193..f912ae98 100644 --- a/packages/hypergraph-react/src/HypergraphSpaceContext.tsx +++ b/packages/hypergraph-react/src/HypergraphSpaceContext.tsx @@ -1,6 +1,6 @@ 'use client'; -import { Entity, store } from '@graphprotocol/hypergraph'; +import { Entity, type Id, store } from '@graphprotocol/hypergraph'; import { useSelector } from '@xstate/store/react'; import * as Schema from 'effect/Schema'; import { @@ -14,6 +14,7 @@ import { useSyncExternalStore, } from 'react'; import { useHypergraphApp } from './HypergraphAppContext.js'; +import { useEntityPublic } from './internal/use-entity-public.js'; import { usePublicSpace } from './internal/use-public-space.js'; // TODO space can be undefined @@ -181,19 +182,28 @@ export function useQueryLocal(type: S, para return { entities, deletedEntities }; } -export function useQueryEntity( +function useEntityPrivate( type: S, - id: string, - params?: { space?: string; include?: { [K in keyof Schema.Schema.Type]?: Record } }, + params: { + id: string | Id; + enabled?: boolean; + space?: string; + include?: { [K in keyof Schema.Schema.Type]?: Record> } | undefined; + }, ) { const { space: spaceFromContext } = useHypergraphSpaceInternal(); - const { space: spaceFromParams, include } = params ?? {}; - const handle = useSubscribeToSpaceAndGetHandle({ spaceId: spaceFromParams ?? spaceFromContext, enabled: true }); - const prevEntityRef = useRef | undefined>(undefined); + const { space: spaceFromParams, include, id, enabled = true } = params; + const handle = useSubscribeToSpaceAndGetHandle({ spaceId: spaceFromParams ?? spaceFromContext, enabled }); + const prevEntityRef = useRef<{ + data: Entity.Entity | undefined; + invalidEntity: Record | undefined; + isPending: boolean; + isError: boolean; + }>({ data: undefined, invalidEntity: undefined, isPending: false, isError: false }); const equals = Schema.equivalence(type); const subscribe = (callback: () => void) => { - if (!handle) { + if (!handle || !enabled) { return () => {}; } const handleChange = () => { @@ -214,7 +224,7 @@ export function useQueryEntity( }; return useSyncExternalStore(subscribe, () => { - if (!handle) { + if (!handle || !enabled) { return prevEntityRef.current; } const doc = handle.doc(); @@ -223,16 +233,39 @@ export function useQueryEntity( } const found = Entity.findOne(handle, type, include)(id); - if (found === undefined && prevEntityRef.current !== undefined) { + if (found === undefined && prevEntityRef.current.data !== undefined) { // entity was maybe deleted, delete from the ref - prevEntityRef.current = undefined; - } else if (found !== undefined && prevEntityRef.current === undefined) { - prevEntityRef.current = found; - } else if (found !== undefined && prevEntityRef.current !== undefined && !equals(found, prevEntityRef.current)) { + prevEntityRef.current = { data: undefined, invalidEntity: undefined, isPending: false, isError: false }; + } else if (found !== undefined && prevEntityRef.current.data === undefined) { + prevEntityRef.current = { data: found, invalidEntity: undefined, isPending: false, isError: false }; + } else if ( + found !== undefined && + prevEntityRef.current.data !== undefined && + !equals(found, prevEntityRef.current.data) + ) { // found and ref have a value, compare for equality, if they are not equal, update the ref and return - prevEntityRef.current = found; + prevEntityRef.current = { data: found, invalidEntity: undefined, isPending: false, isError: false }; } return prevEntityRef.current; }); } + +export function useEntity( + type: S, + params: { + id: string | Id; + space?: string; + mode: 'private' | 'public'; + include?: { [K in keyof Schema.Schema.Type]?: Record> } | undefined; + }, +) { + const resultPublic = useEntityPublic(type, { ...params, enabled: params.mode === 'public' }); + const resultPrivate = useEntityPrivate(type, { ...params, enabled: params.mode === 'private' }); + + if (params.mode === 'public') { + return resultPublic; + } + + return resultPrivate; +} diff --git a/packages/hypergraph-react/src/index.ts b/packages/hypergraph-react/src/index.ts index f2f80723..26da47dc 100644 --- a/packages/hypergraph-react/src/index.ts +++ b/packages/hypergraph-react/src/index.ts @@ -9,8 +9,8 @@ export { HypergraphSpaceProvider, useCreateEntity, useDeleteEntity, + useEntity, useHardDeleteEntity, - useQueryEntity, useQueryLocal as _useQueryLocal, useRemoveRelation, useSpace, @@ -26,6 +26,7 @@ export { usePublishToPublicSpace } from './hooks/usePublishToSpace.js'; export { generateDeleteOps as _generateDeleteOps } from './internal/generate-delete-ops.js'; export { useCreateEntityPublic as _useCreateEntityPublic } from './internal/use-create-entity-public.js'; export { useDeleteEntityPublic as _useDeleteEntityPublic } from './internal/use-delete-entity-public.js'; +export { useEntityPublic as _useEntityPublic } from './internal/use-entity-public.js'; export { useQueryPublic as _useQueryPublic } from './internal/use-query-public.js'; export { preparePublish } from './prepare-publish.js'; export { publishOps } from './publish-ops.js'; diff --git a/packages/hypergraph-react/src/internal/use-entity-public.tsx b/packages/hypergraph-react/src/internal/use-entity-public.tsx new file mode 100644 index 00000000..7ccd744d --- /dev/null +++ b/packages/hypergraph-react/src/internal/use-entity-public.tsx @@ -0,0 +1,398 @@ +import { Graph } from '@graphprotocol/grc-20'; +import { type Entity, type Mapping, store, TypeUtils } from '@graphprotocol/hypergraph'; +import { useQuery as useQueryTanstack } from '@tanstack/react-query'; +import { useSelector } from '@xstate/store/react'; +import * as Either from 'effect/Either'; +import * as Schema from 'effect/Schema'; +import { gql, request } from 'graphql-request'; +import { useMemo } from 'react'; +import { useHypergraphSpaceInternal } from '../HypergraphSpaceContext.js'; + +const entityQueryDocumentLevel0 = gql` +query entity($id: UUID!, $spaceId: UUID!) { + entity( + id: $id, + ) { + id + name + valuesList(filter: {spaceId: {is: $spaceId}}) { + propertyId + string + boolean + number + time + point + } + } +} +`; + +const entityQueryDocumentLevel1 = gql` +query entity($id: UUID!, $spaceId: UUID!, $relationTypeIdsLevel1: [UUID!]!) { + entity( + id: $id, + ) { + id + name + valuesList(filter: {spaceId: {is: $spaceId}}) { + propertyId + string + boolean + number + time + point + } + relationsList( + filter: {spaceId: {is: $spaceId}, typeId:{ in: $relationTypeIdsLevel1}}, + ) { + toEntity { + id + name + valuesList(filter: {spaceId: {is: $spaceId}}) { + propertyId + string + boolean + number + time + point + } + } + typeId + } + } +} +`; + +const entityQueryDocumentLevel2 = gql` +query entity($id: UUID!, $spaceId: UUID!, $relationTypeIdsLevel1: [UUID!]!, $relationTypeIdsLevel2: [UUID!]!) { + entity( + id: $id, + ) { + id + name + valuesList(filter: {spaceId: {is: $spaceId}}) { + propertyId + string + boolean + number + time + point + } + relationsList( + filter: {spaceId: {is: $spaceId}, typeId:{ in: $relationTypeIdsLevel1}}, + ) { + toEntity { + id + name + valuesList(filter: {spaceId: {is: $spaceId}}) { + propertyId + string + boolean + number + time + point + } + relationsList( + filter: {spaceId: {is: $spaceId}, typeId:{ in: $relationTypeIdsLevel2}}, + ) { + toEntity { + id + name + valuesList(filter: {spaceId: {is: $spaceId}}) { + propertyId + string + boolean + number + time + point + } + } + typeId + } + } + typeId + } + } +} +`; + +type EntityQueryResult = { + entity: { + id: string; + name: string; + valuesList: { + propertyId: string; + string: string; + boolean: boolean; + number: number; + time: string; + point: string; + }[]; + relationsList?: { + toEntity: { + id: string; + name: string; + valuesList: { + propertyId: string; + string: string; + boolean: boolean; + number: number; + time: string; + point: string; + }[]; + relationsList?: { + toEntity: { + id: string; + name: string; + valuesList: { + propertyId: string; + string: string; + boolean: boolean; + number: number; + time: string; + point: string; + }[]; + }; + typeId: string; + }[]; + }; + typeId: string; + }[]; + } | null; +}; + +// A recursive representation of the entity structure returned by the public GraphQL +// endpoint. `values` and `relations` are optional because the nested `to` selections +// get slimmer the deeper we traverse in the query. This type intentionally mirrors +// only the fields we actually consume inside `convertRelations`. +type RecursiveQueryEntity = { + id: string; + name: string; + valuesList?: { + propertyId: string; + string: string; + boolean: boolean; + number: number; + time: string; + point: string; + }[]; + relationsList?: { + toEntity: RecursiveQueryEntity; + typeId: string; + }[]; +}; + +const convertRelations = ( + queryEntity: RecursiveQueryEntity, + type: S, + mappingEntry: Mapping.MappingEntry, + mapping: Mapping.Mapping, +) => { + const rawEntity: Record = {}; + + for (const [key, relationId] of Object.entries(mappingEntry?.relations ?? {})) { + const properties = (queryEntity.relationsList ?? []).filter((a) => a.typeId === relationId); + if (properties.length === 0) { + rawEntity[key] = [] as unknown[]; + continue; + } + + const field = type.fields[key]; + if (!field) { + // @ts-expect-error TODO: properly access the type.name + console.error(`Field ${key} not found in ${type.name}`); + continue; + } + // @ts-expect-error TODO: properly access the type.name + const annotations = field.ast.rest[0].type.to.annotations; + + // TODO: fix this access using proper effect types + const relationTypeName = + annotations[ + Object.getOwnPropertySymbols(annotations).find((sym) => sym.description === 'effect/annotation/Identifier') + ]; + + const relationMappingEntry = mapping[relationTypeName]; + if (!relationMappingEntry) { + console.error(`Relation mapping entry for ${relationTypeName} not found`); + continue; + } + + const newRelationEntities = properties.map((propertyEntry) => { + // @ts-expect-error TODO: properly access the type.name + const type = field.value; + + let rawEntity: Record = { + id: propertyEntry.toEntity.id, + name: propertyEntry.toEntity.name, + // TODO: should be determined by the actual value + __deleted: false, + // TODO: should be determined by the actual value + __version: '', + }; + + // take the mappingEntry and assign the attributes to the rawEntity + for (const [key, value] of Object.entries(relationMappingEntry?.properties ?? {})) { + const property = propertyEntry.toEntity.valuesList?.find((a) => a.propertyId === value); + if (property) { + rawEntity[key] = convertPropertyValue(property, key, type); + } + } + + rawEntity = { + ...rawEntity, + ...convertRelations(propertyEntry.toEntity, type, relationMappingEntry, mapping), + }; + + return rawEntity; + }); + + if (rawEntity[key]) { + rawEntity[key] = [ + // @ts-expect-error TODO: properly access the type.name + ...rawEntity[key], + ...newRelationEntities, + ]; + } else { + rawEntity[key] = newRelationEntities; + } + } + + return rawEntity; +}; + +const convertPropertyValue = ( + property: { propertyId: string; string: string; boolean: boolean; number: number; time: string; point: string }, + key: string, + type: Entity.AnyNoContext, +) => { + if (TypeUtils.isBooleanOrOptionalBooleanType(type.fields[key]) && property.boolean !== undefined) { + return Boolean(property.boolean); + } + if (TypeUtils.isPointOrOptionalPointType(type.fields[key]) && property.point !== undefined) { + return property.point; + } + if (TypeUtils.isDateOrOptionalDateType(type.fields[key]) && property.time !== undefined) { + return property.time; + } + if (TypeUtils.isNumberOrOptionalNumberType(type.fields[key]) && property.number !== undefined) { + return Number(property.number); + } + return property.string; +}; + +export const parseResult = ( + queryData: EntityQueryResult, + type: S, + mappingEntry: Mapping.MappingEntry, + mapping: Mapping.Mapping, +) => { + if (!queryData.entity) { + return { data: null, invalidEntity: null }; + } + + const decode = Schema.decodeUnknownEither(type); + const queryEntity = queryData.entity; + let rawEntity: Record = { + id: queryEntity.id, + }; + + // take the mappingEntry and assign the attributes to the rawEntity + for (const [key, value] of Object.entries(mappingEntry?.properties ?? {})) { + const property = queryEntity.valuesList.find((a) => a.propertyId === value); + if (property) { + rawEntity[key] = convertPropertyValue(property, key, type); + } + } + + rawEntity = { + ...rawEntity, + ...convertRelations(queryEntity, type, mappingEntry, mapping), + }; + + const decodeResult = decode({ + ...rawEntity, + __deleted: false, + __version: '', + }); + + if (Either.isRight(decodeResult)) { + return { + data: { ...decodeResult.right, __schema: type } as Entity.Entity, + invalidEntity: null, + }; + } + + return { data: null, invalidEntity: rawEntity }; +}; + +type UseEntityPublicParams = { + id: string; + enabled?: boolean; + space?: string; + // TODO: for multi-level nesting it should only allow the allowed properties instead of Record> + include?: { [K in keyof Schema.Schema.Type]?: Record> } | undefined; +}; + +export const useEntityPublic = (type: S, params: UseEntityPublicParams) => { + const { id, enabled = true, space: spaceFromParams, include } = params; + const { space: spaceFromContext } = useHypergraphSpaceInternal(); + const space = spaceFromParams ?? spaceFromContext; + const mapping = useSelector(store, (state) => state.context.mapping); + + // @ts-expect-error TODO should use the actual type instead of the name in the mapping + const typeName = type.name; + const mappingEntry = mapping?.[typeName]; + if (enabled && !mappingEntry) { + throw new Error(`Mapping entry for ${typeName} not found`); + } + + // constructing the relation type ids for the query + const relationTypeIdsLevel1: string[] = []; + const relationTypeIdsLevel2: string[] = []; + for (const key in mappingEntry?.relations ?? {}) { + if (include?.[key] && mappingEntry?.relations?.[key]) { + relationTypeIdsLevel1.push(mappingEntry?.relations?.[key]); + const field = type.fields[key]; + // @ts-expect-error TODO find a better way to access the relation type name + const typeName2 = field.value.name; + const mappingEntry2 = mapping[typeName2]; + for (const key2 in mappingEntry2?.relations ?? {}) { + if (include?.[key][key2] && mappingEntry2?.relations?.[key2]) { + relationTypeIdsLevel2.push(mappingEntry2?.relations?.[key2]); + } + } + } + } + + const result = useQueryTanstack({ + queryKey: ['hypergraph-public-entity', typeName, id, space, relationTypeIdsLevel1, relationTypeIdsLevel2, include], + queryFn: async () => { + let queryDocument = entityQueryDocumentLevel0; + if (relationTypeIdsLevel1.length > 0) { + queryDocument = entityQueryDocumentLevel1; + } + if (relationTypeIdsLevel2.length > 0) { + queryDocument = entityQueryDocumentLevel2; + } + + const result = await request(`${Graph.TESTNET_API_ORIGIN}/graphql`, queryDocument, { + id, + spaceId: space, + relationTypeIdsLevel1, + relationTypeIdsLevel2, + }); + return result; + }, + enabled: enabled && !!id && !!space, + }); + + const { data, invalidEntity } = useMemo(() => { + if (result.data && mappingEntry) { + return parseResult(result.data, type, mappingEntry, mapping); + } + return { data: null, invalidEntity: null }; + }, [result.data, type, mappingEntry, mapping]); + + return { ...result, data, invalidEntity }; +}; diff --git a/packages/hypergraph-react/src/internal/use-query-public.tsx b/packages/hypergraph-react/src/internal/use-query-public.tsx index 95ac5f05..fe64a7ba 100644 --- a/packages/hypergraph-react/src/internal/use-query-public.tsx +++ b/packages/hypergraph-react/src/internal/use-query-public.tsx @@ -283,11 +283,7 @@ const convertRelations = ( }); if (rawEntity[key]) { - rawEntity[key] = [ - // @ts-expect-error TODO: properly access the type.name - ...rawEntity[key], - ...newRelationEntities, - ]; + rawEntity[key] = [...rawEntity[key], ...newRelationEntities]; } else { rawEntity[key] = newRelationEntities; } diff --git a/packages/hypergraph-react/test/HypergraphSpaceContext.test.tsx b/packages/hypergraph-react/test/HypergraphSpaceContext.test.tsx index 565ff4bd..4c8194fd 100644 --- a/packages/hypergraph-react/test/HypergraphSpaceContext.test.tsx +++ b/packages/hypergraph-react/test/HypergraphSpaceContext.test.tsx @@ -9,7 +9,7 @@ import { HypergraphSpaceProvider, useCreateEntity, useDeleteEntity, - useQueryEntity, + useEntity, useQueryLocal, useUpdateEntity, } from '../src/HypergraphSpaceContext.js'; @@ -86,9 +86,12 @@ describe('HypergraphSpaceContext', () => { }); if (createdEntity != null) { - const { result: queryEntityResult } = renderHook(() => useQueryEntity(Event, createdEntity?.id || ''), { - wrapper, - }); + const { result: queryEntityResult } = renderHook( + () => useEntity(Event, { id: createdEntity?.id || '', mode: 'private' }), + { + wrapper, + }, + ); expect(queryEntityResult.current).toEqual(createdEntity); } @@ -137,7 +140,9 @@ describe('HypergraphSpaceContext', () => { __schema: Person, }); - const { result: queryEntityResult } = renderHook(() => useQueryEntity(Person, id), { wrapper }); + const { result: queryEntityResult } = renderHook(() => useEntity(Person, { id: id, mode: 'private' }), { + wrapper, + }); expect(queryEntityResult.current).toEqual({ // @ts-expect-error - TODO: fix the types error ...createdEntity, diff --git a/packages/hypergraph/src/entity/findOne.ts b/packages/hypergraph/src/entity/findOne.ts index 1c092f75..e501667f 100644 --- a/packages/hypergraph/src/entity/findOne.ts +++ b/packages/hypergraph/src/entity/findOne.ts @@ -10,7 +10,7 @@ import type { AnyNoContext, DocumentContent, Entity } from './types.js'; export const findOne = ( handle: DocHandle, type: S, - include: { [K in keyof Schema.Schema.Type]?: Record } = {}, + include: { [K in keyof Schema.Schema.Type]?: Record> } | undefined = undefined, ) => { const decode = Schema.decodeUnknownSync(type);