diff --git a/.changeset/two-moose-speak.md b/.changeset/two-moose-speak.md new file mode 100644 index 00000000..e3fe14e2 --- /dev/null +++ b/.changeset/two-moose-speak.md @@ -0,0 +1,7 @@ +--- +"@graphprotocol/hypergraph-react": patch +"@graphprotocol/hypergraph": patch +--- + +add value filtering for useQuery(Type, { mode: "public", … }) + \ No newline at end of file diff --git a/apps/events/src/components/playground.tsx b/apps/events/src/components/playground.tsx index 5505de57..1c378788 100644 --- a/apps/events/src/components/playground.tsx +++ b/apps/events/src/components/playground.tsx @@ -17,6 +17,11 @@ export const Playground = ({ spaceId }: { spaceId: string }) => { jobOffers: {}, }, }, + // filter: { + // name: { + // startsWith: 'test', + // }, + // }, first: 100, space: spaceId, }); diff --git a/docs/docs/filtering-query-results.md b/docs/docs/filtering-query-results.md index d47ef8be..48c1c909 100644 --- a/docs/docs/filtering-query-results.md +++ b/docs/docs/filtering-query-results.md @@ -2,8 +2,6 @@ The filter API allows you to filter the results of a query by property values and in the future also by relations. -Note: Filtering is not yet supported for public data. - ## Filtering by property values ```tsx @@ -89,8 +87,8 @@ The filter API supports different filters for different property types and offer const { data } = useQuery(Person, { filter: { or: [ - not: { name: { is: "Jane Doe" } }, - not: { name: { is: "John Doe" } }, + { not: { name: { is: 'Jane Doe' } } }, + { not: { name: { is: 'John Doe' } } }, ], }, }); @@ -99,11 +97,11 @@ const { data } = useQuery(Person, { const { data } = useQuery(Person, { filter: { age: { - equals: 42 + is: 42 }, or: [ - not: { name: { is: "Jane Doe" } }, - not: { name: { is: "John Doe" } }, + { not: { name: { is: 'Jane Doe' } } }, + { not: { name: { is: 'John Doe' } } }, ], not: { or: [ diff --git a/packages/hypergraph-react/src/internal/translate-filter-to-graphql.test.ts b/packages/hypergraph-react/src/internal/translate-filter-to-graphql.test.ts new file mode 100644 index 00000000..434102dd --- /dev/null +++ b/packages/hypergraph-react/src/internal/translate-filter-to-graphql.test.ts @@ -0,0 +1,348 @@ +import { Graph, Id } from '@graphprotocol/grc-20'; +import { Entity, type Mapping, Type } from '@graphprotocol/hypergraph'; +import { describe, expect, it } from 'vitest'; +import { translateFilterToGraphql } from './translate-filter-to-graphql.js'; + +export class Todo extends Entity.Class('Todo')({ + name: Type.String, + completed: Type.Boolean, + priority: Type.Number, +}) {} + +const mapping: Mapping.Mapping = { + Todo: { + typeIds: [Id('a288444f-06a3-4037-9ace-66fe325864d0')], + properties: { + name: Id('a126ca53-0c8e-48d5-b888-82c734c38935'), + completed: Id('d2d64cd3-a337-4784-9e30-25bea0349471'), + priority: Id('ee920534-42ce-4113-a63b-8f3c889dd772'), + }, + }, +}; + +describe('translateFilterToGraphql string filters', () => { + it('should translate string `is` filter correctly', () => { + const filter: Entity.EntityFilter = { + name: { is: 'test' }, + }; + + const result = translateFilterToGraphql(filter, Todo, mapping); + + expect(result).toEqual({ + values: { + some: { + propertyId: { is: 'a126ca53-0c8e-48d5-b888-82c734c38935' }, + string: { is: 'test' }, + }, + }, + }); + }); + + it('should translate string `contains` filter correctly', () => { + const filter: Entity.EntityFilter = { + name: { contains: 'test' }, + }; + + const result = translateFilterToGraphql(filter, Todo, mapping); + + expect(result).toEqual({ + values: { + some: { + propertyId: { is: 'a126ca53-0c8e-48d5-b888-82c734c38935' }, + string: { includes: 'test' }, + }, + }, + }); + }); + + it('should translate string `startsWith` filter correctly', () => { + const filter: Entity.EntityFilter = { + name: { startsWith: 'test' }, + }; + + const result = translateFilterToGraphql(filter, Todo, mapping); + + expect(result).toEqual({ + values: { + some: { + propertyId: { is: 'a126ca53-0c8e-48d5-b888-82c734c38935' }, + string: { startsWith: 'test' }, + }, + }, + }); + }); + + it('should translate string `endsWith` filter correctly', () => { + const filter: Entity.EntityFilter = { + name: { endsWith: 'test' }, + }; + + const result = translateFilterToGraphql(filter, Todo, mapping); + + expect(result).toEqual({ + values: { + some: { + propertyId: { is: 'a126ca53-0c8e-48d5-b888-82c734c38935' }, + string: { endsWith: 'test' }, + }, + }, + }); + }); +}); + +describe('translateFilterToGraphql boolean filters', () => { + it('should translate boolean `is` filter correctly', () => { + const filter: Entity.EntityFilter = { + completed: { is: true }, + }; + + const result = translateFilterToGraphql(filter, Todo, mapping); + + expect(result).toEqual({ + values: { + some: { + propertyId: { is: 'd2d64cd3-a337-4784-9e30-25bea0349471' }, + boolean: { is: true }, + }, + }, + }); + }); +}); + +describe('translateFilterToGraphql number filters', () => { + it('should translate number `is` filter correctly', () => { + const filter: Entity.EntityFilter = { + priority: { is: 1 }, + }; + + const result = translateFilterToGraphql(filter, Todo, mapping); + + expect(result).toEqual({ + values: { + some: { + propertyId: { is: 'ee920534-42ce-4113-a63b-8f3c889dd772' }, + number: { is: Graph.serializeNumber(1) }, + }, + }, + }); + }); + + it('should translate number `greaterThan` filter correctly', () => { + const filter: Entity.EntityFilter = { + priority: { greaterThan: 1 }, + }; + + const result = translateFilterToGraphql(filter, Todo, mapping); + + expect(result).toEqual({ + values: { + some: { + propertyId: { is: 'ee920534-42ce-4113-a63b-8f3c889dd772' }, + number: { greaterThan: Graph.serializeNumber(1) }, + }, + }, + }); + }); +}); + +describe('translateFilterToGraphql multiple filters', () => { + it('should translate multiple filters correctly', () => { + const filter: Entity.EntityFilter = { + name: { is: 'test' }, + completed: { is: true }, + priority: { greaterThan: 1 }, + }; + + const result = translateFilterToGraphql(filter, Todo, mapping); + + expect(result).toEqual({ + and: [ + { + values: { + some: { + propertyId: { is: 'a126ca53-0c8e-48d5-b888-82c734c38935' }, + string: { is: 'test' }, + }, + }, + }, + { + values: { + some: { + propertyId: { is: 'd2d64cd3-a337-4784-9e30-25bea0349471' }, + boolean: { is: true }, + }, + }, + }, + { + values: { + some: { + propertyId: { is: 'ee920534-42ce-4113-a63b-8f3c889dd772' }, + number: { greaterThan: Graph.serializeNumber(1) }, + }, + }, + }, + ], + }); + }); +}); + +describe('translateFilterToGraphql with OR operator', () => { + it('should translate OR operator in nested filter array', () => { + const filter: Entity.EntityFilter = { + or: [{ name: { is: 'test' } }, { name: { is: 'test2' } }], + }; + + const result = translateFilterToGraphql(filter, Todo, mapping); + + expect(result).toEqual({ + or: [ + { + values: { some: { propertyId: { is: 'a126ca53-0c8e-48d5-b888-82c734c38935' }, string: { is: 'test' } } }, + }, + { + values: { some: { propertyId: { is: 'a126ca53-0c8e-48d5-b888-82c734c38935' }, string: { is: 'test2' } } }, + }, + ], + }); + }); + + it('should translate OR operator in nested filter array', () => { + const filter: Entity.EntityFilter = { + or: [{ name: { is: 'test' } }, { completed: { is: true } }], + }; + + const result = translateFilterToGraphql(filter, Todo, mapping); + + expect(result).toEqual({ + or: [ + { + values: { some: { propertyId: { is: 'a126ca53-0c8e-48d5-b888-82c734c38935' }, string: { is: 'test' } } }, + }, + { + values: { some: { propertyId: { is: 'd2d64cd3-a337-4784-9e30-25bea0349471' }, boolean: { is: true } } }, + }, + ], + }); + }); +}); + +describe('translateFilterToGraphql with NOT operator', () => { + it('should translate NOT operator', () => { + const filter: Entity.EntityFilter = { + not: { name: { is: 'test' } }, + }; + + const result = translateFilterToGraphql(filter, Todo, mapping); + + expect(result).toEqual({ + not: { values: { some: { propertyId: { is: 'a126ca53-0c8e-48d5-b888-82c734c38935' }, string: { is: 'test' } } } }, + }); + }); + + it('should translate NOT operator with multiple filters', () => { + const filter: Entity.EntityFilter = { + not: { name: { is: 'test' }, completed: { is: true } }, + }; + + const result = translateFilterToGraphql(filter, Todo, mapping); + + expect(result).toEqual({ + not: { + and: [ + { values: { some: { propertyId: { is: 'a126ca53-0c8e-48d5-b888-82c734c38935' }, string: { is: 'test' } } } }, + { values: { some: { propertyId: { is: 'd2d64cd3-a337-4784-9e30-25bea0349471' }, boolean: { is: true } } } }, + ], + }, + }); + }); +}); + +describe('translateFilterToGraphql with complex nested filters', () => { + it('should translate complex nested filters with or and not', () => { + const filter: Entity.EntityFilter = { + or: [{ not: { name: { is: 'Jane Doe' } } }, { not: { name: { is: 'John Doe' } } }], + }; + + const result = translateFilterToGraphql(filter, Todo, mapping); + + expect(result).toEqual({ + or: [ + { + not: { + values: { + some: { propertyId: { is: 'a126ca53-0c8e-48d5-b888-82c734c38935' }, string: { is: 'Jane Doe' } }, + }, + }, + }, + { + not: { + values: { + some: { propertyId: { is: 'a126ca53-0c8e-48d5-b888-82c734c38935' }, string: { is: 'John Doe' } }, + }, + }, + }, + ], + }); + }); + + it('should translate complex nested filters with and, or and not', () => { + const filter: Entity.EntityFilter = { + priority: { + is: 42, + }, + or: [{ not: { name: { is: 'Jane Doe' } } }, { not: { name: { is: 'John Doe' } } }], + not: { + or: [{ name: { is: 'Jane Doe' } }, { name: { is: 'John Doe' } }], + }, + }; + + const result = translateFilterToGraphql(filter, Todo, mapping); + + expect(result).toEqual({ + and: [ + { + values: { + some: { + propertyId: { is: 'ee920534-42ce-4113-a63b-8f3c889dd772' }, + number: { is: Graph.serializeNumber(42) }, + }, + }, + }, + { + or: [ + { + not: { + values: { + some: { propertyId: { is: 'a126ca53-0c8e-48d5-b888-82c734c38935' }, string: { is: 'Jane Doe' } }, + }, + }, + }, + { + not: { + values: { + some: { propertyId: { is: 'a126ca53-0c8e-48d5-b888-82c734c38935' }, string: { is: 'John Doe' } }, + }, + }, + }, + ], + }, + { + not: { + or: [ + { + values: { + some: { propertyId: { is: 'a126ca53-0c8e-48d5-b888-82c734c38935' }, string: { is: 'Jane Doe' } }, + }, + }, + { + values: { + some: { propertyId: { is: 'a126ca53-0c8e-48d5-b888-82c734c38935' }, string: { is: 'John Doe' } }, + }, + }, + ], + }, + }, + ], + }); + }); +}); diff --git a/packages/hypergraph-react/src/internal/translate-filter-to-graphql.ts b/packages/hypergraph-react/src/internal/translate-filter-to-graphql.ts new file mode 100644 index 00000000..14ea0ffa --- /dev/null +++ b/packages/hypergraph-react/src/internal/translate-filter-to-graphql.ts @@ -0,0 +1,138 @@ +import { Graph } from '@graphprotocol/grc-20'; +import { type Entity, type Mapping, TypeUtils } from '@graphprotocol/hypergraph'; +import type * as Schema from 'effect/Schema'; + +type GraphqlFilterEntry = + | { + values: { + some: + | { + propertyId: { is: string }; + string: { is: string } | { startsWith: string } | { endsWith: string } | { includes: string }; + } + | { + propertyId: { is: string }; + boolean: { is: boolean }; + } + | { + propertyId: { is: string }; + number: { is: string } | { greaterThan: string } | { lessThan: string }; + }; + }; + } + | { + not: GraphqlFilterEntry; + } + | { + or: GraphqlFilterEntry[]; + } + | { + and: GraphqlFilterEntry[]; + } + | { [k: string]: never }; + +/** + * Translates internal filter format to GraphQL filter format + * Maps the internal EntityFieldFilter structure to the expected GraphQL filter structure + */ +export function translateFilterToGraphql( + filter: { [K in keyof Schema.Schema.Type]?: Entity.EntityFieldFilter[K]> } | undefined, + type: S, + mapping: Mapping.Mapping, +): GraphqlFilterEntry { + if (!filter) { + return {}; + } + + // @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 (!mappingEntry) { + throw new Error(`Mapping entry for ${typeName} not found`); + } + + const graphqlFilter: GraphqlFilterEntry[] = []; + + for (const [fieldName, fieldFilter] of Object.entries(filter)) { + if (fieldName === 'or') { + graphqlFilter.push({ + or: fieldFilter.map( + (filter: { [K in keyof Schema.Schema.Type]?: Entity.EntityFieldFilter[K]> }) => + translateFilterToGraphql(filter, type, mapping), + ), + }); + continue; + } + + if (fieldName === 'not') { + graphqlFilter.push({ + not: translateFilterToGraphql(fieldFilter, type, mapping), + }); + continue; + } + + if (!fieldFilter) continue; + + const propertyId = mappingEntry?.properties?.[fieldName]; + + if (propertyId) { + if ( + TypeUtils.isStringOrOptionalStringType(type.fields[fieldName]) && + (fieldFilter.is || fieldFilter.startsWith || fieldFilter.endsWith || fieldFilter.contains) + ) { + graphqlFilter.push({ + values: { + some: { + propertyId: { is: propertyId }, + string: fieldFilter.is + ? { is: fieldFilter.is } + : fieldFilter.startsWith + ? { startsWith: fieldFilter.startsWith } + : fieldFilter.endsWith + ? { endsWith: fieldFilter.endsWith } + : { includes: fieldFilter.contains }, + }, + }, + }); + } + + if (TypeUtils.isBooleanOrOptionalBooleanType(type.fields[fieldName]) && fieldFilter.is) { + graphqlFilter.push({ + values: { + some: { + propertyId: { is: propertyId }, + boolean: { is: fieldFilter.is }, + }, + }, + }); + } + + if ( + TypeUtils.isNumberOrOptionalNumberType(type.fields[fieldName]) && + (fieldFilter.is || fieldFilter.greaterThan || fieldFilter.lessThan) + ) { + graphqlFilter.push({ + values: { + some: { + propertyId: { is: propertyId }, + number: fieldFilter.is + ? { is: Graph.serializeNumber(fieldFilter.is) } + : fieldFilter.greaterThan + ? { greaterThan: Graph.serializeNumber(fieldFilter.greaterThan) } + : { lessThan: Graph.serializeNumber(fieldFilter.lessThan) }, + }, + }, + }); + } + } + } + + if (graphqlFilter.length === 1) { + return graphqlFilter[0]; + } + + return { + and: graphqlFilter, + }; +} diff --git a/packages/hypergraph-react/src/internal/types.ts b/packages/hypergraph-react/src/internal/types.ts index f71a97c4..16bd583d 100644 --- a/packages/hypergraph-react/src/internal/types.ts +++ b/packages/hypergraph-react/src/internal/types.ts @@ -4,6 +4,7 @@ import type * as Schema from 'effect/Schema'; export type QueryPublicParams = { enabled: boolean; space?: string | undefined; + filter?: { [K in keyof Schema.Schema.Type]?: Entity.EntityFieldFilter[K]> } | undefined; // TODO: for multi-level nesting it should only allow the allowed properties instead of Record> include?: { [K in keyof Schema.Schema.Type]?: Record> } | undefined; first?: number | undefined; diff --git a/packages/hypergraph-react/src/internal/use-query-public.tsx b/packages/hypergraph-react/src/internal/use-query-public.tsx index 7acf1344..95ac5f05 100644 --- a/packages/hypergraph-react/src/internal/use-query-public.tsx +++ b/packages/hypergraph-react/src/internal/use-query-public.tsx @@ -7,13 +7,16 @@ import * as Schema from 'effect/Schema'; import { gql, request } from 'graphql-request'; import { useMemo } from 'react'; import { useHypergraphSpaceInternal } from '../HypergraphSpaceContext.js'; +import { translateFilterToGraphql } from './translate-filter-to-graphql.js'; import type { QueryPublicParams } from './types.js'; const entitiesQueryDocumentLevel0 = gql` -query entities($spaceId: UUID!, $typeIds: [UUID!]!, $first: Int) { +query entities($spaceId: UUID!, $typeIds: [UUID!]!, $first: Int, $filter: EntityFilter!) { entities( - filter: { - relations: {some: {typeId: {is: "8f151ba4-de20-4e3c-9cb4-99ddf96f48f1"}, toEntityId: {in: $typeIds}}}, spaceIds: {in: [$spaceId]}}, + filter: { and: [{ + relations: {some: {typeId: {is: "8f151ba4-de20-4e3c-9cb4-99ddf96f48f1"}, toEntityId: {in: $typeIds}}}, + spaceIds: {in: [$spaceId]}, + }, $filter]} first: $first ) { id @@ -31,11 +34,14 @@ query entities($spaceId: UUID!, $typeIds: [UUID!]!, $first: Int) { `; const entitiesQueryDocumentLevel1 = gql` -query entities($spaceId: UUID!, $typeIds: [UUID!]!, $relationTypeIdsLevel1: [UUID!]!, $first: Int) { +query entities($spaceId: UUID!, $typeIds: [UUID!]!, $relationTypeIdsLevel1: [UUID!]!, $first: Int, $filter: EntityFilter!) { entities( first: $first - filter: { - relations: {some: {typeId: {is: "8f151ba4-de20-4e3c-9cb4-99ddf96f48f1"}, toEntityId: {in: $typeIds}}}, spaceIds: {in: [$spaceId]}}) { + filter: { and: [{ + relations: {some: {typeId: {is: "8f151ba4-de20-4e3c-9cb4-99ddf96f48f1"}, toEntityId: {in: $typeIds}}}, + spaceIds: {in: [$spaceId]}, + }, $filter]} + ) { id name valuesList(filter: {spaceId: {is: $spaceId}}) { @@ -68,11 +74,14 @@ query entities($spaceId: UUID!, $typeIds: [UUID!]!, $relationTypeIdsLevel1: [UUI `; const entitiesQueryDocumentLevel2 = gql` -query entities($spaceId: UUID!, $typeIds: [UUID!]!, $relationTypeIdsLevel1: [UUID!]!, $relationTypeIdsLevel2: [UUID!]!, $first: Int) { +query entities($spaceId: UUID!, $typeIds: [UUID!]!, $relationTypeIdsLevel1: [UUID!]!, $relationTypeIdsLevel2: [UUID!]!, $first: Int, $filter: EntityFilter!) { entities( first: $first - filter: { - relations: {some: {typeId: {is: "8f151ba4-de20-4e3c-9cb4-99ddf96f48f1"}, toEntityId: {in: $typeIds}}}, spaceIds: {in: [$spaceId]}}) { + filter: { and: [{ + relations: {some: {typeId: {is: "8f151ba4-de20-4e3c-9cb4-99ddf96f48f1"}, toEntityId: {in: $typeIds}}}, + spaceIds: {in: [$spaceId]}, + }, $filter]} + ) { id name valuesList(filter: {spaceId: {is: $spaceId}}) { @@ -332,7 +341,7 @@ export const parseResult = ( }; export const useQueryPublic = (type: S, params?: QueryPublicParams) => { - const { enabled = true, include, space: spaceFromParams, first = 100 } = params ?? {}; + const { enabled = true, filter, include, space: spaceFromParams, first = 100 } = params ?? {}; const { space: spaceFromContext } = useHypergraphSpaceInternal(); const space = spaceFromParams ?? spaceFromContext; const mapping = useSelector(store, (state) => state.context.mapping); @@ -370,7 +379,8 @@ export const useQueryPublic = (type: S, params?: mappingEntry?.typeIds, relationTypeIdsLevel1, relationTypeIdsLevel2, - // TODO should `first` be in here? + filter, + first, ], queryFn: async () => { let queryDocument = entitiesQueryDocumentLevel0; @@ -387,6 +397,7 @@ export const useQueryPublic = (type: S, params?: relationTypeIdsLevel1, relationTypeIdsLevel2, first, + filter: filter ? translateFilterToGraphql(filter, type, mapping) : {}, }); return result; }, diff --git a/packages/hypergraph-react/src/use-query.tsx b/packages/hypergraph-react/src/use-query.tsx index 2b14abf3..1436eead 100644 --- a/packages/hypergraph-react/src/use-query.tsx +++ b/packages/hypergraph-react/src/use-query.tsx @@ -16,7 +16,7 @@ const preparePublishDummy = () => undefined; export function useQuery(type: S, params: QueryParams) { const { mode, filter, include, space, first } = params; - const publicResult = useQueryPublic(type, { enabled: mode === 'public', include, first, space }); + const publicResult = useQueryPublic(type, { enabled: mode === 'public', filter, include, first, space }); const localResult = useQueryLocal(type, { enabled: mode === 'private', filter, include, space }); if (mode === 'public') { diff --git a/packages/hypergraph/src/entity/types.ts b/packages/hypergraph/src/entity/types.ts index 2ba4ab01..10171577 100644 --- a/packages/hypergraph/src/entity/types.ts +++ b/packages/hypergraph/src/entity/types.ts @@ -64,7 +64,6 @@ export type EntityStringFilter = { startsWith?: string; endsWith?: string; contains?: string; - equals?: string; not?: EntityStringFilter; or?: EntityStringFilter[]; };