diff --git a/.changeset/warm-oranges-check.md b/.changeset/warm-oranges-check.md new file mode 100644 index 00000000..20fb6860 --- /dev/null +++ b/.changeset/warm-oranges-check.md @@ -0,0 +1,18 @@ +--- +"@graphprotocol/hypergraph": patch +"@graphprotocol/hypergraph-react": patch +--- + +Allow relation includes to override nested relation and value space filters by adding _config: { relationSpaces, valueSpaces } to any include branch; GraphQL fragments now honor those overrides when building queries. + +``` +include: { + friends: { + _config: { + relationSpaces: ['space-a', 'space-b'], + valueSpaces: 'all', + }, + }, +} +``` + \ No newline at end of file diff --git a/apps/events/src/routes/podcasts.lazy.tsx b/apps/events/src/routes/podcasts.lazy.tsx index 4efde94f..b1be91a8 100644 --- a/apps/events/src/routes/podcasts.lazy.tsx +++ b/apps/events/src/routes/podcasts.lazy.tsx @@ -1,6 +1,6 @@ import { useEntities, useEntity, usePublicSpaces } from '@graphprotocol/hypergraph-react'; import { createLazyFileRoute } from '@tanstack/react-router'; -import { Podcast, Topic } from '@/schema'; +import { Person, Podcast, Topic } from '@/schema'; export const Route = createLazyFileRoute('/podcasts')({ component: RouteComponent, @@ -22,6 +22,25 @@ function RouteComponent() { // }, 1000); // }, []); + const { + data: person, + invalidEntity: personInvalidEntity, + invalidRelationEntities: personInvalidRelationEntities, + } = useEntity(Person, { + id: '9800a4e8-8437-4310-9af6-ac91644f7c26', + mode: 'public', + space: '95a4a1cc-bfcc-4038-b7a1-02c513d27700', + include: { + skills: { + _config: { + relationSpaces: ['95a4a1cc-bfcc-4038-b7a1-02c513d27700'], + valueSpaces: ['021265e2-d839-47c3-8d03-0ee3dfb29ffc', '95a4a1cc-bfcc-4038-b7a1-02c513d27700'], + }, + }, + }, + }); + console.log({ person, personInvalidEntity, personInvalidRelationEntities }); + const { data: podcast, invalidEntity, diff --git a/apps/events/src/schema.ts b/apps/events/src/schema.ts index 87f0c45c..d930f414 100644 --- a/apps/events/src/schema.ts +++ b/apps/events/src/schema.ts @@ -1,4 +1,4 @@ -import { SystemIds } from '@graphprotocol/grc-20'; +import { ContentIds, SystemIds } from '@graphprotocol/grc-20'; import { Entity, Id, Type } from '@graphprotocol/hypergraph'; export const User = Entity.Schema( @@ -119,11 +119,24 @@ export const Project = Entity.Schema( }, ); +export const Skill = Entity.Schema( + { + name: Type.String, + }, + { + types: [ContentIds.SKILL_TYPE], + properties: { + name: SystemIds.NAME_PROPERTY, + }, + }, +); + export const Person = Entity.Schema( { name: Type.String, description: Type.optional(Type.String), avatar: Type.Relation(Image), + skills: Type.Relation(Skill), }, { types: [Id('7ed45f2b-c48b-419e-8e46-64d5ff680b0d')], @@ -131,6 +144,7 @@ export const Person = Entity.Schema( name: Id('a126ca53-0c8e-48d5-b888-82c734c38935'), description: Id('9b1f76ff-9711-404c-861e-59dc3fa7d037'), avatar: Id('1155beff-fad5-49b7-a2e0-da4777b8792c'), + skills: Id(ContentIds.SKILLS_PROPERTY), }, }, ); diff --git a/docs/docs/query-public-data.md b/docs/docs/query-public-data.md index 7ad4618e..1964a2bf 100644 --- a/docs/docs/query-public-data.md +++ b/docs/docs/query-public-data.md @@ -62,6 +62,36 @@ const { data, isPending, isError } = useEntities(Event, { For deeper relations you can use the `include` parameter multiple levels deep. Currently two levels of relations are supported for public data. +#### Controlling include scopes with `_config` + +Each branch within `include` can optionally carry a `_config` object that lets you override which spaces Hypergraph will inspect for the relation edges and the related entity values. When you omit `_config`, the query automatically reuses the `space`/`spaces` selection you passed to `useEntities`, `useEntity`, `Entity.findOnePublic`, `Entity.findManyPublic` and `Entity.searchManyPublic` helpers. + +```ts +const { data: project } = useEntity(Project, { + id: '9f130661-8c3f-4db7-9bdc-3ce69631c5ef', + mode: 'public', + space: '3f32353d-3b27-4a13-b71a-746f06e1f7db', + include: { + contributors: { + _config: { + relationSpaces: ['3f32353d-3b27-4a13-b71a-746f06e1f7db', '95a4a1cc-bfcc-4038-b7a1-02c513d27700'], + valueSpaces: 'all', + }, + organizations: { + _config: { + valueSpaces: ['95a4a1cc-bfcc-4038-b7a1-02c513d27700'], + }, + }, + }, + }, +}); +``` + +- `relationSpaces` controls which spaces are searched for the relation edges themselves (`relations`/`backlinks`). Pass an array to whitelist specific spaces, `'all'` to drop the filter entirely, or `[]` if you intentionally want the branch to match nothing. +- `valueSpaces` applies the same override to the `valuesList` lookups for the related entities. This lets you fetch relation edges from one space while trusting the canonical values that live in another. + +Each nested branch can have its own `_config` settings,so you can attach `_config` anywhere within the two supported include levels. Mix and match the settings per branch to stitch together data that spans multiple public spaces without issuing separate queries. + ### Querying from a specific space You can also query from a specific space by passing in the `space` parameter. diff --git a/packages/hypergraph/src/entity/types.ts b/packages/hypergraph/src/entity/types.ts index f665c47b..e572eca0 100644 --- a/packages/hypergraph/src/entity/types.ts +++ b/packages/hypergraph/src/entity/types.ts @@ -2,8 +2,17 @@ import type * as Schema from 'effect/Schema'; type SchemaKey = Extract, string>; +export type RelationSpacesOverride = 'all' | readonly string[]; + +export type RelationIncludeConfig = { + relationSpaces?: RelationSpacesOverride; + valueSpaces?: RelationSpacesOverride; +}; + export type RelationIncludeBranch = { - [key: string]: RelationIncludeBranch | boolean | undefined; + _config?: RelationIncludeConfig; +} & { + [key: string]: RelationIncludeBranch | RelationIncludeConfig | boolean | undefined; }; export type EntityInclude = Partial< diff --git a/packages/hypergraph/src/utils/get-relation-type-ids.ts b/packages/hypergraph/src/utils/get-relation-type-ids.ts index 06487bee..3a8f764e 100644 --- a/packages/hypergraph/src/utils/get-relation-type-ids.ts +++ b/packages/hypergraph/src/utils/get-relation-type-ids.ts @@ -2,7 +2,7 @@ import { Constants, Utils } from '@graphprotocol/hypergraph'; import * as Option from 'effect/Option'; import type * as Schema from 'effect/Schema'; import * as SchemaAST from 'effect/SchemaAST'; -import type { EntityInclude, RelationIncludeBranch } from '../entity/types.js'; +import type { EntityInclude, RelationIncludeBranch, RelationSpacesOverride } from '../entity/types.js'; export type RelationListField = 'relations' | 'backlinks'; @@ -12,6 +12,8 @@ export type RelationTypeIdInfo = { listField: RelationListField; includeNodes: boolean; includeTotalCount: boolean; + relationSpaces?: RelationSpacesOverride; + valueSpaces?: RelationSpacesOverride; children?: RelationTypeIdInfo[]; }; @@ -35,8 +37,9 @@ export const getRelationTypeIds = ( const result = SchemaAST.getAnnotation(Constants.PropertyIdSymbol)(prop.type); if (Option.isSome(result)) { const propertyName = String(prop.name); - const includeBranch = include?.[propertyName as keyof EntityInclude] as RelationIncludeBranch | undefined; - const includeNodes = isRelationIncludeBranch(includeBranch); + const includeBranchCandidate = include?.[propertyName as keyof EntityInclude]; + const includeBranch = isRelationIncludeBranch(includeBranchCandidate) ? includeBranchCandidate : undefined; + const includeNodes = Boolean(includeBranch); const includeTotalCount = hasTotalCountFlag(include as Record | undefined, propertyName); if (!includeNodes && !includeTotalCount) { @@ -47,13 +50,24 @@ export const getRelationTypeIds = ( Option.getOrElse(() => false), ); const listField: RelationListField = isBacklink ? 'backlinks' : 'relations'; - const level1Info: RelationTypeIdInfo = { + const relationSpaces = includeBranch?._config?.relationSpaces; + const valueSpaces = includeBranch?._config?.valueSpaces; + + const level1InfoBase: RelationTypeIdInfo = { typeId: result.value, propertyName, listField, includeNodes, includeTotalCount, }; + const level1Info: RelationTypeIdInfo = + relationSpaces === undefined && valueSpaces === undefined + ? level1InfoBase + : { + ...level1InfoBase, + ...(relationSpaces !== undefined ? { relationSpaces } : {}), + ...(valueSpaces !== undefined ? { valueSpaces } : {}), + }; const nestedRelations: RelationTypeIdInfo[] = []; if (!SchemaAST.isTupleType(prop.type)) { @@ -78,8 +92,11 @@ export const getRelationTypeIds = ( const nestedResult = SchemaAST.getAnnotation(Constants.PropertyIdSymbol)(nestedProp.type); const nestedPropertyName = String(nestedProp.name); - const nestedIncludeBranch = includeBranch?.[nestedPropertyName]; - const nestedIncludeNodes = isRelationIncludeBranch(nestedIncludeBranch); + const nestedIncludeBranchCandidate = includeBranch?.[nestedPropertyName]; + const nestedIncludeBranch = isRelationIncludeBranch(nestedIncludeBranchCandidate) + ? nestedIncludeBranchCandidate + : undefined; + const nestedIncludeNodes = Boolean(nestedIncludeBranch); const nestedIncludeTotalCount = hasTotalCountFlag( includeBranch as Record | undefined, nestedPropertyName, @@ -90,13 +107,23 @@ export const getRelationTypeIds = ( nestedProp.type, ).pipe(Option.getOrElse(() => false)); const nestedListField: RelationListField = nestedIsBacklink ? 'backlinks' : 'relations'; - const nestedInfo: RelationTypeIdInfo = { + const nestedRelationSpaces = nestedIncludeBranch?._config?.relationSpaces; + const nestedValueSpaces = nestedIncludeBranch?._config?.valueSpaces; + const nestedInfoBase: RelationTypeIdInfo = { typeId: nestedResult.value, propertyName: nestedPropertyName, listField: nestedListField, includeNodes: nestedIncludeNodes, includeTotalCount: nestedIncludeTotalCount, }; + const nestedInfo: RelationTypeIdInfo = + nestedRelationSpaces === undefined && nestedValueSpaces === undefined + ? nestedInfoBase + : { + ...nestedInfoBase, + ...(nestedRelationSpaces !== undefined ? { relationSpaces: nestedRelationSpaces } : {}), + ...(nestedValueSpaces !== undefined ? { valueSpaces: nestedValueSpaces } : {}), + }; nestedRelations.push(nestedInfo); } } diff --git a/packages/hypergraph/src/utils/relation-query-helpers.ts b/packages/hypergraph/src/utils/relation-query-helpers.ts index c934cd26..0bb16b0e 100644 --- a/packages/hypergraph/src/utils/relation-query-helpers.ts +++ b/packages/hypergraph/src/utils/relation-query-helpers.ts @@ -2,24 +2,58 @@ import type { RelationTypeIdInfo } from './get-relation-type-ids.js'; type SpaceSelectionMode = 'single' | 'many' | 'all'; -const buildValuesListFilter = (spaceSelectionMode: SpaceSelectionMode) => { - if (spaceSelectionMode === 'single') { - return '(filter: {spaceId: {is: $spaceId}})'; +const formatGraphQLStringArray = (values: readonly string[]) => + `[${values.map((value) => JSON.stringify(value)).join(', ')}]`; + +const buildValuesListFilter = ( + spaceSelectionMode: SpaceSelectionMode, + override?: RelationTypeIdInfo['valueSpaces'], +) => { + if (!override) { + if (spaceSelectionMode === 'single') { + return '(filter: {spaceId: {is: $spaceId}})'; + } + if (spaceSelectionMode === 'many') { + return '(filter: {spaceId: {in: $spaceIds}})'; + } + return ''; } - if (spaceSelectionMode === 'many') { - return '(filter: {spaceId: {in: $spaceIds}})'; + + if (override === 'all') { + return ''; + } + + if (override.length === 0) { + // Explicit empty overrides should produce a match-nothing filter. + return '(filter: {spaceId: {in: []}})'; } - return ''; + + return `(filter: {spaceId: {in: ${formatGraphQLStringArray(override)}}})`; }; -const buildRelationSpaceFilter = (spaceSelectionMode: SpaceSelectionMode) => { - if (spaceSelectionMode === 'single') { - return 'spaceId: {is: $spaceId}, '; +const buildRelationSpaceFilter = ( + spaceSelectionMode: SpaceSelectionMode, + override?: RelationTypeIdInfo['relationSpaces'], +) => { + if (!override) { + if (spaceSelectionMode === 'single') { + return 'spaceId: {is: $spaceId}, '; + } + if (spaceSelectionMode === 'many') { + return 'spaceId: {in: $spaceIds}, '; + } + return ''; + } + + if (override === 'all') { + return ''; } - if (spaceSelectionMode === 'many') { - return 'spaceId: {in: $spaceIds}, '; + + if (override.length === 0) { + return 'spaceId: {in: []}, '; } - return ''; + + return `spaceId: {in: ${formatGraphQLStringArray(override)}}, `; }; export const getRelationAlias = (typeId: string) => `relations_${typeId.replace(/-/g, '_')}`; @@ -31,8 +65,8 @@ const buildRelationsListFragment = (info: RelationTypeIdInfo, level: 1 | 2, spac const connectionField = listField === 'backlinks' ? 'backlinks' : 'relations'; const toEntityField = listField === 'backlinks' ? 'fromEntity' : 'toEntity'; const toEntitySelectionHeader = toEntityField === 'toEntity' ? 'toEntity' : `toEntity: ${toEntityField}`; - const valuesListFilter = buildValuesListFilter(spaceSelectionMode); - const relationSpaceFilter = buildRelationSpaceFilter(spaceSelectionMode); + const valuesListFilter = buildValuesListFilter(spaceSelectionMode, info.valueSpaces); + const relationSpaceFilter = buildRelationSpaceFilter(spaceSelectionMode, info.relationSpaces); if (!info.includeNodes && !info.includeTotalCount) { return ''; diff --git a/packages/hypergraph/test/utils/relation-config-overrides.test.ts b/packages/hypergraph/test/utils/relation-config-overrides.test.ts new file mode 100644 index 00000000..90ef0900 --- /dev/null +++ b/packages/hypergraph/test/utils/relation-config-overrides.test.ts @@ -0,0 +1,124 @@ +import { Id } from '@graphprotocol/grc-20'; +import { describe, expect, it } from 'vitest'; +import * as Entity from '../../src/entity/index.js'; +import * as Type from '../../src/type/type.js'; +import { getRelationTypeIds } from '../../src/utils/get-relation-type-ids.js'; +import { buildRelationsSelection } from '../../src/utils/relation-query-helpers.js'; + +const FRIENDS_RELATION_PROPERTY_ID = Id('f44ae32a-2f13-4d3f-875f-19d2338a32b8'); +const CHILDREN_RELATION_PROPERTY_ID = Id('8a6dcb99-9c7b-4ca9-9f7b-98f2f404b405'); +const PARENT_TYPES = [Id('842e8ae0-9904-40a8-9bfe-19e1f4400c5e')]; +const CHILD_TYPES = [Id('cd9a2ae2-831c-4fa2-b714-ad3aa254db7d')]; +const FRIEND_TYPES = [Id('35ac5c3e-4f31-466e-b3da-51fdfbb4b38e')]; +const NAME_PROPERTY_ID = Id('9f5e7ea4-51bb-4c9f-8739-7fa0aa695d02'); + +const Friend = Entity.Schema( + { + nickname: Type.String, + }, + { + types: FRIEND_TYPES, + properties: { + nickname: NAME_PROPERTY_ID, + }, + }, +); + +const Child = Entity.Schema( + { + name: Type.String, + friends: Type.Relation(Friend), + }, + { + types: CHILD_TYPES, + properties: { + name: NAME_PROPERTY_ID, + friends: FRIENDS_RELATION_PROPERTY_ID, + }, + }, +); + +const Parent = Entity.Schema( + { + title: Type.String, + children: Type.Relation(Child), + }, + { + types: PARENT_TYPES, + properties: { + title: NAME_PROPERTY_ID, + children: CHILDREN_RELATION_PROPERTY_ID, + }, + }, +); + +describe('relation include config overrides', () => { + it('propagates spaces overrides to query fragments', () => { + const include = { + children: { + _config: { + relationSpaces: ['space-rel', 'space-rel-2'], + valueSpaces: ['space-values'], + }, + friends: { + _config: { + relationSpaces: 'all', + }, + }, + }, + } satisfies Entity.EntityInclude; + + const relationInfo = getRelationTypeIds(Parent, include); + expect(relationInfo[0]).toMatchObject({ + relationSpaces: ['space-rel', 'space-rel-2'], + valueSpaces: ['space-values'], + }); + expect(relationInfo[0]?.children?.[0]).toMatchObject({ + relationSpaces: 'all', + }); + + const selection = buildRelationsSelection(relationInfo, 'single'); + + expect(selection).toContain('spaceId: {in: ["space-rel", "space-rel-2"]}'); + expect(selection).toContain('valuesList(filter: {spaceId: {in: ["space-values"]}})'); + expect(selection.split('relations_f44ae32a_2f13_4d3f_875f_19d2338a32b8')[0]).not.toContain( + 'spaceId: {is: $spaceId}', + ); + }); + + it('omits filters entirely when overrides use "all"', () => { + const include = { + children: { + _config: { + relationSpaces: 'all', + valueSpaces: 'all', + }, + }, + } satisfies Entity.EntityInclude; + + const relationInfo = getRelationTypeIds(Parent, include); + const selection = buildRelationsSelection(relationInfo, 'single'); + + expect(selection).not.toContain('spaceId: {is: $spaceId}'); + expect(selection).not.toContain('spaceId: {in: $spaceIds}'); + expect(selection).not.toContain('valuesList(filter: {spaceId: {is: $spaceId}})'); + expect(selection).not.toContain('valuesList(filter: {spaceId: {in: $spaceIds}})'); + }); + + it('renders match-nothing filters when overrides are empty arrays', () => { + const include = { + children: { + _config: { + relationSpaces: [], + valueSpaces: [], + }, + }, + } satisfies Entity.EntityInclude; + + const relationInfo = getRelationTypeIds(Parent, include); + const selection = buildRelationsSelection(relationInfo, 'single'); + + expect(selection).toContain('spaceId: {in: []}'); + expect(selection).toContain('valuesList(filter: {spaceId: {in: []}})'); + }); +});